mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+150
@@ -0,0 +1,150 @@
|
||||
import { EXECUTION_ORIGINS, RUN_STATUSES } from '../../run/run';
|
||||
import type { RunRepositoryReader } from '../../run/runRepository';
|
||||
import {
|
||||
BoundedRunReadProjectionUnavailableError,
|
||||
executeBoundedRunReadProjection,
|
||||
} from '../../run/projection/boundedRunReadProjection';
|
||||
import {
|
||||
normalizeToolDefinition,
|
||||
type ToolJsonValue,
|
||||
} from '../tool-registry/toolRegistry';
|
||||
|
||||
export const BUILTIN_RUN_READ_TOOL = Object.freeze({
|
||||
name: 'qinglong.run.get',
|
||||
version: '1.0.0',
|
||||
});
|
||||
export const BUILTIN_RUN_READ_TIMEOUT_SECONDS = 5;
|
||||
|
||||
const MAX_INT = 2_147_483_647;
|
||||
const MIN_INT = -2_147_483_648;
|
||||
|
||||
export const BUILTIN_RUN_READ_TOOL_DEFINITION = normalizeToolDefinition({
|
||||
name: BUILTIN_RUN_READ_TOOL.name,
|
||||
version: BUILTIN_RUN_READ_TOOL.version,
|
||||
description: 'Read one low-sensitive Project-scoped Run projection',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
},
|
||||
required: ['runId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
found: { type: 'boolean' },
|
||||
id: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
taskId: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
taskRevision: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
status: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: RUN_STATUSES,
|
||||
},
|
||||
version: { type: 'integer', minimum: 0, maximum: MAX_INT },
|
||||
eventSequence: { type: 'integer', minimum: 0, maximum: MAX_INT },
|
||||
priority: { type: 'integer', minimum: MIN_INT, maximum: MAX_INT },
|
||||
executionOrigin: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: EXECUTION_ORIGINS,
|
||||
},
|
||||
executionOwner: {
|
||||
type: 'string',
|
||||
maxLength: 16,
|
||||
enum: ['legacy', 'runtime'],
|
||||
},
|
||||
createdAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
queuedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
startedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
finishedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
},
|
||||
required: ['found'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['run.read'],
|
||||
timeoutSeconds: BUILTIN_RUN_READ_TIMEOUT_SECONDS,
|
||||
});
|
||||
|
||||
export class InvalidBuiltInRunReadToolError extends TypeError {
|
||||
readonly code = 'BUILTIN_RUN_READ_TOOL_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Built-in Run read Tool is invalid: ${message}`);
|
||||
this.name = 'InvalidBuiltInRunReadToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BuiltInRunReadToolUnavailableError extends Error {
|
||||
readonly code = 'BUILTIN_RUN_READ_TOOL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Built-in Run read Tool is unavailable');
|
||||
this.name = 'BuiltInRunReadToolUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidBuiltInRunReadToolError(message);
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximum: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= maximum &&
|
||||
!CONTROL_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeBuiltInRunReadTool(
|
||||
runs: Pick<RunRepositoryReader, 'findRunById'>,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
): Promise<Readonly<Record<string, ToolJsonValue>>> {
|
||||
const inputRecord =
|
||||
input && typeof input === 'object' && !Array.isArray(input)
|
||||
? (input as Readonly<Record<string, ToolJsonValue>>)
|
||||
: null;
|
||||
if (
|
||||
!runs ||
|
||||
typeof runs.findRunById !== 'function' ||
|
||||
!boundedText(projectId, 128) ||
|
||||
!inputRecord ||
|
||||
Reflect.ownKeys(inputRecord).length !== 1 ||
|
||||
!Object.hasOwn(inputRecord, 'runId') ||
|
||||
!boundedText(inputRecord.runId, 128)
|
||||
) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
const runId = inputRecord.runId;
|
||||
try {
|
||||
return await executeBoundedRunReadProjection(runs, projectId, runId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof BoundedRunReadProjectionUnavailableError)) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
throw new BuiltInRunReadToolUnavailableError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { RunRepositoryReader } from '../../run/runRepository';
|
||||
import {
|
||||
normalizeProjectToolDefinitionSnapshot,
|
||||
type ProjectToolDefinitionSnapshot,
|
||||
} from '../tool-registry/projectToolDefinitionSnapshot';
|
||||
import {
|
||||
ToolDefinitionRegistry,
|
||||
type ToolJsonValue,
|
||||
} from '../tool-registry/toolRegistry';
|
||||
import {
|
||||
createTrustedToolHandlerBinding,
|
||||
normalizeTrustedToolHandlerBinding,
|
||||
type TrustedToolHandlerBinding,
|
||||
} from '../trustedToolInvocation';
|
||||
import type { DeploymentProfile } from '../../cluster-control/clusterControlActivation';
|
||||
import type {
|
||||
TrustedToolExecutionAdapter,
|
||||
TrustedToolExecutionAdapterContext,
|
||||
} from '../trustedToolExecution';
|
||||
import {
|
||||
BUILTIN_RUN_READ_TOOL,
|
||||
BUILTIN_RUN_READ_TOOL_DEFINITION,
|
||||
BUILTIN_RUN_READ_TIMEOUT_SECONDS,
|
||||
InvalidBuiltInRunReadToolError,
|
||||
executeBuiltInRunReadTool,
|
||||
} from './builtInRunReadProjection';
|
||||
|
||||
export {
|
||||
BUILTIN_RUN_READ_TOOL,
|
||||
BUILTIN_RUN_READ_TOOL_DEFINITION,
|
||||
BUILTIN_RUN_READ_TIMEOUT_SECONDS,
|
||||
BuiltInRunReadToolUnavailableError,
|
||||
InvalidBuiltInRunReadToolError,
|
||||
executeBuiltInRunReadTool,
|
||||
} from './builtInRunReadProjection';
|
||||
|
||||
export const BUILTIN_RUN_READ_ADAPTER = Object.freeze({
|
||||
id: 'builtin.qinglong.run-get',
|
||||
version: '1.0.0',
|
||||
});
|
||||
export const BUILTIN_RUN_READ_REDACTION_CONTRACT = Object.freeze({
|
||||
id: 'redaction.qinglong.run-get',
|
||||
version: '1.0.0',
|
||||
});
|
||||
export const BUILTIN_RUN_READ_AUDIT_CONTRACT = Object.freeze({
|
||||
id: 'audit.qinglong.tool-call',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidBuiltInRunReadToolError(message);
|
||||
}
|
||||
|
||||
function sameValue(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximum: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= maximum &&
|
||||
!CONTROL_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function createBuiltInRunReadToolHandlerBinding(
|
||||
snapshotValue: ProjectToolDefinitionSnapshot,
|
||||
profiles: readonly DeploymentProfile[],
|
||||
): Readonly<TrustedToolHandlerBinding> {
|
||||
const snapshot = normalizeProjectToolDefinitionSnapshot(snapshotValue);
|
||||
const definition = snapshot.definitions.find(
|
||||
(entry) =>
|
||||
entry.definition.name === BUILTIN_RUN_READ_TOOL.name &&
|
||||
entry.definition.version === BUILTIN_RUN_READ_TOOL.version,
|
||||
)?.definition;
|
||||
if (!definition || !sameValue(definition, BUILTIN_RUN_READ_TOOL_DEFINITION)) {
|
||||
return invalid('reviewed Tool definition is absent or changed');
|
||||
}
|
||||
return createTrustedToolHandlerBinding(snapshot, {
|
||||
tool: BUILTIN_RUN_READ_TOOL,
|
||||
adapter: BUILTIN_RUN_READ_ADAPTER,
|
||||
executionClass: 'builtin_in_process',
|
||||
profiles,
|
||||
authorities: ['database.read'],
|
||||
timeoutSeconds: BUILTIN_RUN_READ_TIMEOUT_SECONDS,
|
||||
redactionContract: BUILTIN_RUN_READ_REDACTION_CONTRACT,
|
||||
auditContract: BUILTIN_RUN_READ_AUDIT_CONTRACT,
|
||||
});
|
||||
}
|
||||
|
||||
export class BuiltInRunReadToolAdapter implements TrustedToolExecutionAdapter {
|
||||
readonly binding!: Readonly<TrustedToolHandlerBinding>;
|
||||
readonly profile!: DeploymentProfile;
|
||||
readonly recoveryMode = 'retry_safe_read' as const;
|
||||
readonly #runs!: Pick<RunRepositoryReader, 'findRunById'>;
|
||||
|
||||
constructor(
|
||||
bindingValue: TrustedToolHandlerBinding,
|
||||
profile: DeploymentProfile,
|
||||
definitions: ToolDefinitionRegistry,
|
||||
runs: Pick<RunRepositoryReader, 'findRunById'>,
|
||||
) {
|
||||
const binding = normalizeTrustedToolHandlerBinding(bindingValue);
|
||||
if (!(definitions instanceof ToolDefinitionRegistry)) {
|
||||
return invalid('Tool Definition registry is invalid');
|
||||
}
|
||||
let definition;
|
||||
try {
|
||||
definition = definitions.resolve(
|
||||
BUILTIN_RUN_READ_TOOL.name,
|
||||
BUILTIN_RUN_READ_TOOL.version,
|
||||
);
|
||||
} catch {
|
||||
return invalid('reviewed Tool definition is unavailable');
|
||||
}
|
||||
if (
|
||||
!sameValue(binding.tool, BUILTIN_RUN_READ_TOOL) ||
|
||||
!sameValue(binding.adapter, BUILTIN_RUN_READ_ADAPTER) ||
|
||||
binding.executionClass !== 'builtin_in_process' ||
|
||||
!sameValue(binding.authorities, ['database.read']) ||
|
||||
binding.timeoutSeconds !== BUILTIN_RUN_READ_TIMEOUT_SECONDS ||
|
||||
!sameValue(
|
||||
binding.redactionContract,
|
||||
BUILTIN_RUN_READ_REDACTION_CONTRACT,
|
||||
) ||
|
||||
!sameValue(binding.auditContract, BUILTIN_RUN_READ_AUDIT_CONTRACT) ||
|
||||
!binding.profiles.includes(profile) ||
|
||||
!sameValue(definition, BUILTIN_RUN_READ_TOOL_DEFINITION)
|
||||
) {
|
||||
return invalid('binding does not match the reviewed adapter contract');
|
||||
}
|
||||
if (!runs || typeof runs.findRunById !== 'function') {
|
||||
return invalid('Run repository is invalid');
|
||||
}
|
||||
this.binding = binding;
|
||||
this.profile = profile;
|
||||
this.#runs = runs;
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
async execute(
|
||||
context: Readonly<TrustedToolExecutionAdapterContext>,
|
||||
input: ToolJsonValue,
|
||||
): Promise<unknown> {
|
||||
if (
|
||||
!context ||
|
||||
typeof context !== 'object' ||
|
||||
!boundedText(context.projectId, 128)
|
||||
) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
return executeBuiltInRunReadTool(this.#runs, context.projectId, input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { ProjectPermission } from '../../security/project-policy/projectPolicy';
|
||||
import type {
|
||||
SecurityPolicyDecision,
|
||||
SecurityPolicyFence,
|
||||
SecurityPrincipal,
|
||||
SecuritySubject,
|
||||
} from '../../security/security';
|
||||
|
||||
export const TOOL_INVOCATION_SCHEMA = 'qinglong/tool-invocation@v1';
|
||||
export const MAX_TOOL_DEFINITIONS = 128;
|
||||
export const MAX_TOOL_REQUIRED_PERMISSIONS = 16;
|
||||
export const MAX_TOOL_SCHEMA_DEPTH = 8;
|
||||
export const MAX_TOOL_SCHEMA_NODES = 256;
|
||||
export const MAX_TOOL_SCHEMA_PROPERTIES = 64;
|
||||
export const MAX_TOOL_SCHEMA_ENUM_VALUES = 64;
|
||||
export const MAX_TOOL_ARRAY_ITEMS = 256;
|
||||
export const MAX_TOOL_INPUT_BYTES = 64 * 1024;
|
||||
export const MAX_TOOL_OUTPUT_BYTES = 256 * 1024;
|
||||
export const MAX_TOOL_TIMEOUT_SECONDS = 60 * 60;
|
||||
|
||||
export const TOOL_EFFECTS = ['read', 'write', 'execute', 'external'] as const;
|
||||
export const TOOL_RISKS = ['low', 'medium', 'high', 'critical'] as const;
|
||||
export const TOOL_JSON_SCHEMA_TYPES = [
|
||||
'null',
|
||||
'boolean',
|
||||
'string',
|
||||
'number',
|
||||
'integer',
|
||||
'array',
|
||||
'object',
|
||||
] as const;
|
||||
|
||||
export type ToolEffect = (typeof TOOL_EFFECTS)[number];
|
||||
export type ToolRisk = (typeof TOOL_RISKS)[number];
|
||||
export type ToolJsonSchemaType = (typeof TOOL_JSON_SCHEMA_TYPES)[number];
|
||||
export type ToolInvocationStatus = 'ready' | 'approval_required';
|
||||
export type ToolJsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| readonly ToolJsonValue[]
|
||||
| Readonly<{ [key: string]: ToolJsonValue }>;
|
||||
|
||||
export type ToolJsonSchema =
|
||||
| Readonly<{ type: 'null' }>
|
||||
| Readonly<{ type: 'boolean' }>
|
||||
| Readonly<{
|
||||
type: 'string';
|
||||
minLength?: number;
|
||||
maxLength: number;
|
||||
enum?: readonly string[];
|
||||
}>
|
||||
| Readonly<{
|
||||
type: 'number';
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
type: 'integer';
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
type: 'array';
|
||||
items: ToolJsonSchema;
|
||||
minItems?: number;
|
||||
maxItems: number;
|
||||
uniqueItems?: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
type: 'object';
|
||||
properties: Readonly<Record<string, ToolJsonSchema>>;
|
||||
required: readonly string[];
|
||||
additionalProperties: false;
|
||||
}>;
|
||||
|
||||
export interface ToolDefinition {
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly description: string;
|
||||
readonly inputSchema: ToolJsonSchema;
|
||||
readonly outputSchema?: ToolJsonSchema;
|
||||
readonly effect: ToolEffect;
|
||||
readonly risk: ToolRisk;
|
||||
readonly requiredPermissions: readonly ProjectPermission[];
|
||||
readonly timeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface ToolPolicyAuthorizer {
|
||||
authorize(
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
projectId: string,
|
||||
permission: ProjectPermission,
|
||||
): Promise<SecurityPolicyDecision>;
|
||||
}
|
||||
|
||||
export interface ToolInvocationRequest {
|
||||
readonly projectId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
readonly nowMs: number;
|
||||
readonly tool: Readonly<{ name: string; version: string }>;
|
||||
readonly input: unknown;
|
||||
}
|
||||
|
||||
export interface DeniedToolInvocation {
|
||||
readonly status: 'denied';
|
||||
readonly tool: Readonly<{ name: string; version: string }>;
|
||||
readonly permission: ProjectPermission;
|
||||
}
|
||||
|
||||
export interface PreparedToolInvocation {
|
||||
readonly status: ToolInvocationStatus;
|
||||
readonly schema: typeof TOOL_INVOCATION_SCHEMA;
|
||||
readonly projectId: string;
|
||||
readonly requestedBy: Readonly<SecuritySubject>;
|
||||
readonly tool: Readonly<{ name: string; version: string }>;
|
||||
readonly permission: ProjectPermission;
|
||||
readonly requiredPermissions: readonly ProjectPermission[];
|
||||
readonly effect: ToolEffect;
|
||||
readonly risk: ToolRisk;
|
||||
readonly timeoutSeconds: number;
|
||||
readonly fence: Readonly<SecurityPolicyFence>;
|
||||
readonly input: ToolJsonValue;
|
||||
readonly inputDigest: string;
|
||||
readonly actionDigest: string;
|
||||
}
|
||||
|
||||
export type ToolInvocationPlan =
|
||||
| Readonly<DeniedToolInvocation>
|
||||
| Readonly<PreparedToolInvocation>;
|
||||
|
||||
export class InvalidToolDefinitionError extends TypeError {
|
||||
readonly code = 'TOOL_DEFINITION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Tool definition is invalid: ${message}`);
|
||||
this.name = 'InvalidToolDefinitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidToolJsonValueError extends TypeError {
|
||||
readonly code = 'TOOL_JSON_VALUE_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Tool JSON value is invalid: ${message}`);
|
||||
this.name = 'InvalidToolJsonValueError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedToolError extends Error {
|
||||
readonly code = 'TOOL_UNSUPPORTED';
|
||||
|
||||
constructor() {
|
||||
super('Tool is not registered');
|
||||
this.name = 'UnsupportedToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolPolicyUnavailableError extends Error {
|
||||
readonly code = 'TOOL_POLICY_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Tool policy authorization is unavailable');
|
||||
this.name = 'ToolPolicyUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolPolicySnapshotConflictError extends Error {
|
||||
readonly code = 'TOOL_POLICY_SNAPSHOT_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Tool permissions were not authorized by one policy snapshot');
|
||||
this.name = 'ToolPolicySnapshotConflictError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import {
|
||||
normalizeProjectPermission,
|
||||
type ProjectPermission,
|
||||
} from '../../security/project-policy/projectPolicy';
|
||||
import { semver } from '../../versioning/pinnedSemver';
|
||||
import {
|
||||
InvalidToolDefinitionError,
|
||||
MAX_TOOL_ARRAY_ITEMS,
|
||||
MAX_TOOL_OUTPUT_BYTES,
|
||||
MAX_TOOL_REQUIRED_PERMISSIONS,
|
||||
MAX_TOOL_SCHEMA_DEPTH,
|
||||
MAX_TOOL_SCHEMA_ENUM_VALUES,
|
||||
MAX_TOOL_SCHEMA_NODES,
|
||||
MAX_TOOL_SCHEMA_PROPERTIES,
|
||||
MAX_TOOL_TIMEOUT_SECONDS,
|
||||
TOOL_EFFECTS,
|
||||
TOOL_JSON_SCHEMA_TYPES,
|
||||
TOOL_RISKS,
|
||||
type ToolDefinition,
|
||||
type ToolEffect,
|
||||
type ToolJsonSchema,
|
||||
type ToolJsonSchemaType,
|
||||
type ToolRisk,
|
||||
} from './contracts';
|
||||
|
||||
const TOOL_NAME_PATTERN =
|
||||
/^[a-z][a-z0-9-]{0,62}(?:\.[a-z][a-z0-9-]{0,62}){1,7}$/;
|
||||
const PROPERTY_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function definitionRecord(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidToolDefinitionError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactDefinitionKeys(
|
||||
value: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const keys = Object.keys(value);
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (
|
||||
required.some((key) => !Object.hasOwn(value, key)) ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
throw new InvalidToolDefinitionError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedDefinitionText(
|
||||
value: unknown,
|
||||
label: string,
|
||||
maximumBytes: number,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
CONTROL_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes
|
||||
) {
|
||||
throw new InvalidToolDefinitionError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
throw new InvalidToolDefinitionError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
interface SchemaBudget {
|
||||
nodes: number;
|
||||
}
|
||||
|
||||
function normalizeSchema(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
budget: SchemaBudget,
|
||||
): ToolJsonSchema {
|
||||
if (depth > MAX_TOOL_SCHEMA_DEPTH) {
|
||||
throw new InvalidToolDefinitionError('JSON Schema depth exceeded');
|
||||
}
|
||||
budget.nodes += 1;
|
||||
if (budget.nodes > MAX_TOOL_SCHEMA_NODES) {
|
||||
throw new InvalidToolDefinitionError('JSON Schema node budget exceeded');
|
||||
}
|
||||
const schema = definitionRecord(value, 'JSON Schema');
|
||||
const type = schema.type;
|
||||
if (
|
||||
typeof type !== 'string' ||
|
||||
!TOOL_JSON_SCHEMA_TYPES.includes(type as ToolJsonSchemaType)
|
||||
) {
|
||||
throw new InvalidToolDefinitionError('JSON Schema type is invalid');
|
||||
}
|
||||
if (type === 'null' || type === 'boolean') {
|
||||
exactDefinitionKeys(schema, ['type'], [], 'JSON Schema');
|
||||
return Object.freeze({ type });
|
||||
}
|
||||
if (type === 'string') {
|
||||
exactDefinitionKeys(
|
||||
schema,
|
||||
['maxLength', 'type'],
|
||||
['enum', 'minLength'],
|
||||
'string JSON Schema',
|
||||
);
|
||||
const maxLength = boundedInteger(
|
||||
schema.maxLength,
|
||||
0,
|
||||
MAX_TOOL_OUTPUT_BYTES,
|
||||
'string maxLength',
|
||||
);
|
||||
const minLength =
|
||||
schema.minLength === undefined
|
||||
? undefined
|
||||
: boundedInteger(schema.minLength, 0, maxLength, 'string minLength');
|
||||
let values: readonly string[] | undefined;
|
||||
if (schema.enum !== undefined) {
|
||||
if (
|
||||
!Array.isArray(schema.enum) ||
|
||||
schema.enum.length < 1 ||
|
||||
schema.enum.length > MAX_TOOL_SCHEMA_ENUM_VALUES
|
||||
) {
|
||||
throw new InvalidToolDefinitionError('string enum is invalid');
|
||||
}
|
||||
const unique = new Set<string>();
|
||||
for (const item of schema.enum) {
|
||||
if (
|
||||
typeof item !== 'string' ||
|
||||
Array.from(item).length > maxLength ||
|
||||
(minLength !== undefined && Array.from(item).length < minLength) ||
|
||||
unique.has(item)
|
||||
) {
|
||||
throw new InvalidToolDefinitionError(
|
||||
'string enum contains an invalid or duplicate value',
|
||||
);
|
||||
}
|
||||
unique.add(item);
|
||||
}
|
||||
values = Object.freeze([...unique].sort());
|
||||
}
|
||||
return Object.freeze({
|
||||
type: 'string',
|
||||
...(minLength === undefined ? {} : { minLength }),
|
||||
maxLength,
|
||||
...(values === undefined ? {} : { enum: values }),
|
||||
});
|
||||
}
|
||||
if (type === 'number' || type === 'integer') {
|
||||
exactDefinitionKeys(
|
||||
schema,
|
||||
['maximum', 'minimum', 'type'],
|
||||
[],
|
||||
'numeric JSON Schema',
|
||||
);
|
||||
if (
|
||||
typeof schema.minimum !== 'number' ||
|
||||
!Number.isFinite(schema.minimum) ||
|
||||
Math.abs(schema.minimum) > Number.MAX_SAFE_INTEGER ||
|
||||
typeof schema.maximum !== 'number' ||
|
||||
!Number.isFinite(schema.maximum) ||
|
||||
Math.abs(schema.maximum) > Number.MAX_SAFE_INTEGER ||
|
||||
schema.maximum < schema.minimum ||
|
||||
(type === 'integer' &&
|
||||
(!Number.isSafeInteger(schema.minimum) ||
|
||||
!Number.isSafeInteger(schema.maximum)))
|
||||
) {
|
||||
throw new InvalidToolDefinitionError(
|
||||
'numeric JSON Schema bounds are invalid',
|
||||
);
|
||||
}
|
||||
return type === 'number'
|
||||
? Object.freeze({
|
||||
type: 'number',
|
||||
minimum: schema.minimum,
|
||||
maximum: schema.maximum,
|
||||
})
|
||||
: Object.freeze({
|
||||
type: 'integer',
|
||||
minimum: schema.minimum,
|
||||
maximum: schema.maximum,
|
||||
});
|
||||
}
|
||||
if (type === 'array') {
|
||||
exactDefinitionKeys(
|
||||
schema,
|
||||
['items', 'maxItems', 'type'],
|
||||
['minItems', 'uniqueItems'],
|
||||
'array JSON Schema',
|
||||
);
|
||||
const maxItems = boundedInteger(
|
||||
schema.maxItems,
|
||||
0,
|
||||
MAX_TOOL_ARRAY_ITEMS,
|
||||
'array maxItems',
|
||||
);
|
||||
const minItems =
|
||||
schema.minItems === undefined
|
||||
? undefined
|
||||
: boundedInteger(schema.minItems, 0, maxItems, 'array minItems');
|
||||
if (
|
||||
schema.uniqueItems !== undefined &&
|
||||
typeof schema.uniqueItems !== 'boolean'
|
||||
) {
|
||||
throw new InvalidToolDefinitionError('array uniqueItems is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
type: 'array',
|
||||
items: normalizeSchema(schema.items, depth + 1, budget),
|
||||
...(minItems === undefined ? {} : { minItems }),
|
||||
maxItems,
|
||||
...(schema.uniqueItems === undefined
|
||||
? {}
|
||||
: { uniqueItems: schema.uniqueItems }),
|
||||
});
|
||||
}
|
||||
|
||||
exactDefinitionKeys(
|
||||
schema,
|
||||
['additionalProperties', 'properties', 'required', 'type'],
|
||||
[],
|
||||
'object JSON Schema',
|
||||
);
|
||||
if (schema.additionalProperties !== false) {
|
||||
throw new InvalidToolDefinitionError(
|
||||
'object additionalProperties must be false',
|
||||
);
|
||||
}
|
||||
const properties = definitionRecord(
|
||||
schema.properties,
|
||||
'JSON Schema properties',
|
||||
);
|
||||
const propertyEntries = Object.entries(properties);
|
||||
if (propertyEntries.length > MAX_TOOL_SCHEMA_PROPERTIES) {
|
||||
throw new InvalidToolDefinitionError(
|
||||
'JSON Schema property budget exceeded',
|
||||
);
|
||||
}
|
||||
const normalizedProperties: Record<string, ToolJsonSchema> = {};
|
||||
for (const [name, propertySchema] of propertyEntries.sort((left, right) =>
|
||||
left[0].localeCompare(right[0]),
|
||||
)) {
|
||||
if (!PROPERTY_NAME_PATTERN.test(name)) {
|
||||
throw new InvalidToolDefinitionError(
|
||||
'JSON Schema property name is invalid',
|
||||
);
|
||||
}
|
||||
normalizedProperties[name] = normalizeSchema(
|
||||
propertySchema,
|
||||
depth + 1,
|
||||
budget,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(schema.required) ||
|
||||
schema.required.length > propertyEntries.length
|
||||
) {
|
||||
throw new InvalidToolDefinitionError(
|
||||
'JSON Schema required list is invalid',
|
||||
);
|
||||
}
|
||||
const required = new Set<string>();
|
||||
for (const item of schema.required) {
|
||||
if (
|
||||
typeof item !== 'string' ||
|
||||
!Object.hasOwn(normalizedProperties, item) ||
|
||||
required.has(item)
|
||||
) {
|
||||
throw new InvalidToolDefinitionError(
|
||||
'JSON Schema required list is invalid or duplicated',
|
||||
);
|
||||
}
|
||||
required.add(item);
|
||||
}
|
||||
return Object.freeze({
|
||||
type: 'object',
|
||||
properties: Object.freeze(normalizedProperties),
|
||||
required: Object.freeze([...required].sort()),
|
||||
additionalProperties: false,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePermissions(value: unknown): readonly ProjectPermission[] {
|
||||
if (!Array.isArray(value) || value.length > MAX_TOOL_REQUIRED_PERMISSIONS) {
|
||||
throw new InvalidToolDefinitionError('required permissions are invalid');
|
||||
}
|
||||
const permissions = new Set<ProjectPermission>();
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') {
|
||||
throw new InvalidToolDefinitionError('required permission is invalid');
|
||||
}
|
||||
let permission: ProjectPermission;
|
||||
try {
|
||||
permission = normalizeProjectPermission(item);
|
||||
} catch {
|
||||
throw new InvalidToolDefinitionError('required permission is invalid');
|
||||
}
|
||||
if (permission.startsWith('tool.call:') || permissions.has(permission)) {
|
||||
throw new InvalidToolDefinitionError(
|
||||
'nested or duplicate Tool permission is invalid',
|
||||
);
|
||||
}
|
||||
permissions.add(permission);
|
||||
}
|
||||
return Object.freeze([...permissions].sort());
|
||||
}
|
||||
|
||||
export function normalizeToolDefinition(
|
||||
value: unknown,
|
||||
): Readonly<ToolDefinition> {
|
||||
const definition = definitionRecord(value, 'definition');
|
||||
exactDefinitionKeys(
|
||||
definition,
|
||||
[
|
||||
'description',
|
||||
'effect',
|
||||
'inputSchema',
|
||||
'name',
|
||||
'requiredPermissions',
|
||||
'risk',
|
||||
'timeoutSeconds',
|
||||
'version',
|
||||
],
|
||||
['outputSchema'],
|
||||
'definition',
|
||||
);
|
||||
const name = boundedDefinitionText(definition.name, 'name', 255);
|
||||
if (!TOOL_NAME_PATTERN.test(name)) {
|
||||
throw new InvalidToolDefinitionError('name is invalid');
|
||||
}
|
||||
const version = boundedDefinitionText(definition.version, 'version', 128);
|
||||
if (semver().valid(version) !== version) {
|
||||
throw new InvalidToolDefinitionError('version is invalid');
|
||||
}
|
||||
if (
|
||||
typeof definition.effect !== 'string' ||
|
||||
!TOOL_EFFECTS.includes(definition.effect as ToolEffect) ||
|
||||
typeof definition.risk !== 'string' ||
|
||||
!TOOL_RISKS.includes(definition.risk as ToolRisk)
|
||||
) {
|
||||
throw new InvalidToolDefinitionError('effect or risk is invalid');
|
||||
}
|
||||
const budget: SchemaBudget = { nodes: 0 };
|
||||
const inputSchema = normalizeSchema(definition.inputSchema, 1, budget);
|
||||
if (inputSchema.type !== 'object') {
|
||||
throw new InvalidToolDefinitionError(
|
||||
'input JSON Schema root must be an object',
|
||||
);
|
||||
}
|
||||
const outputSchema =
|
||||
definition.outputSchema === undefined
|
||||
? undefined
|
||||
: normalizeSchema(definition.outputSchema, 1, budget);
|
||||
return Object.freeze({
|
||||
name,
|
||||
version,
|
||||
description: boundedDefinitionText(
|
||||
definition.description,
|
||||
'description',
|
||||
4096,
|
||||
),
|
||||
inputSchema,
|
||||
...(outputSchema === undefined ? {} : { outputSchema }),
|
||||
effect: definition.effect as ToolEffect,
|
||||
risk: definition.risk as ToolRisk,
|
||||
requiredPermissions: normalizePermissions(definition.requiredPermissions),
|
||||
timeoutSeconds: boundedInteger(
|
||||
definition.timeoutSeconds,
|
||||
1,
|
||||
MAX_TOOL_TIMEOUT_SECONDS,
|
||||
'timeoutSeconds',
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPermission,
|
||||
} from '../../security/project-policy/projectPolicy';
|
||||
import {
|
||||
normalizeSecurityPolicyDecision,
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPolicyFence,
|
||||
} from '../../security/security';
|
||||
import {
|
||||
InvalidToolJsonValueError,
|
||||
TOOL_INVOCATION_SCHEMA,
|
||||
ToolPolicySnapshotConflictError,
|
||||
ToolPolicyUnavailableError,
|
||||
type ToolInvocationPlan,
|
||||
type ToolInvocationRequest,
|
||||
type ToolPolicyAuthorizer,
|
||||
} from './contracts';
|
||||
import { ToolDefinitionRegistry } from './registryProtocol';
|
||||
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
function sameFence(
|
||||
left: Readonly<SecurityPolicyFence>,
|
||||
right: Readonly<SecurityPolicyFence>,
|
||||
): boolean {
|
||||
return (
|
||||
left.projectVersion === right.projectVersion &&
|
||||
left.bindingVersion === right.bindingVersion
|
||||
);
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
const result = createHash('sha256')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
if (!SHA256_PATTERN.test(result)) {
|
||||
throw new Error('unreachable SHA-256 result');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function prepareToolInvocation(
|
||||
registry: ToolDefinitionRegistry,
|
||||
request: ToolInvocationRequest,
|
||||
authorizer: ToolPolicyAuthorizer,
|
||||
): Promise<ToolInvocationPlan> {
|
||||
if (!(registry instanceof ToolDefinitionRegistry)) {
|
||||
throw new TypeError('Tool registry is invalid');
|
||||
}
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new InvalidToolJsonValueError('request must be an object');
|
||||
}
|
||||
const requestRecord = request as unknown as Record<string, unknown>;
|
||||
const keys = Object.keys(requestRecord).sort();
|
||||
const expected = ['input', 'nowMs', 'principal', 'projectId', 'tool'].sort();
|
||||
if (
|
||||
keys.length !== expected.length ||
|
||||
keys.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new InvalidToolJsonValueError('request shape is invalid');
|
||||
}
|
||||
if (
|
||||
!request.tool ||
|
||||
typeof request.tool !== 'object' ||
|
||||
Array.isArray(request.tool) ||
|
||||
Object.keys(request.tool).sort().join(',') !== 'name,version'
|
||||
) {
|
||||
throw new InvalidToolJsonValueError('request Tool identity is invalid');
|
||||
}
|
||||
assertProjectPolicyProjectId(request.projectId);
|
||||
const principal = normalizeSecurityPrincipal(
|
||||
request.principal,
|
||||
request.nowMs,
|
||||
);
|
||||
const definition = registry.resolve(request.tool.name, request.tool.version);
|
||||
if (!authorizer || typeof authorizer.authorize !== 'function') {
|
||||
throw new ToolPolicyUnavailableError();
|
||||
}
|
||||
const permission = normalizeProjectPermission(`tool.call:${definition.name}`);
|
||||
const permissions = Object.freeze([
|
||||
permission,
|
||||
...definition.requiredPermissions,
|
||||
]);
|
||||
const decisions: Readonly<SecurityPolicyDecision>[] = [];
|
||||
for (const requiredPermission of permissions) {
|
||||
try {
|
||||
const decision = normalizeSecurityPolicyDecision(
|
||||
await authorizer.authorize(
|
||||
principal,
|
||||
request.projectId,
|
||||
requiredPermission,
|
||||
),
|
||||
);
|
||||
if (decision.effect === 'deny') {
|
||||
return Object.freeze({
|
||||
status: 'denied',
|
||||
tool: Object.freeze({
|
||||
name: definition.name,
|
||||
version: definition.version,
|
||||
}),
|
||||
permission,
|
||||
});
|
||||
}
|
||||
decisions.push(decision);
|
||||
} catch {
|
||||
throw new ToolPolicyUnavailableError();
|
||||
}
|
||||
}
|
||||
const fences = decisions.map((decision) => decision.fence);
|
||||
const fence = fences[0];
|
||||
if (
|
||||
!fence ||
|
||||
fences.some(
|
||||
(candidate) => candidate === null || !sameFence(fence, candidate),
|
||||
)
|
||||
) {
|
||||
throw new ToolPolicySnapshotConflictError();
|
||||
}
|
||||
|
||||
const input = registry.normalizeInput(
|
||||
definition.name,
|
||||
definition.version,
|
||||
request.input,
|
||||
);
|
||||
const inputDigest = digest(input);
|
||||
const actionDigest = digest({
|
||||
schema: TOOL_INVOCATION_SCHEMA,
|
||||
projectId: request.projectId,
|
||||
requestedBy: principal.subject,
|
||||
tool: {
|
||||
name: definition.name,
|
||||
version: definition.version,
|
||||
},
|
||||
permission,
|
||||
requiredPermissions: definition.requiredPermissions,
|
||||
effect: definition.effect,
|
||||
risk: definition.risk,
|
||||
timeoutSeconds: definition.timeoutSeconds,
|
||||
inputDigest,
|
||||
});
|
||||
return Object.freeze({
|
||||
status: decisions.some((decision) => decision.effect === 'require_approval')
|
||||
? 'approval_required'
|
||||
: 'ready',
|
||||
schema: TOOL_INVOCATION_SCHEMA,
|
||||
projectId: request.projectId,
|
||||
requestedBy: principal.subject,
|
||||
tool: Object.freeze({
|
||||
name: definition.name,
|
||||
version: definition.version,
|
||||
}),
|
||||
permission,
|
||||
requiredPermissions: definition.requiredPermissions,
|
||||
effect: definition.effect,
|
||||
risk: definition.risk,
|
||||
timeoutSeconds: definition.timeoutSeconds,
|
||||
fence,
|
||||
input,
|
||||
inputDigest,
|
||||
actionDigest,
|
||||
});
|
||||
}
|
||||
+1223
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
import { semver } from '../../versioning/pinnedSemver';
|
||||
import {
|
||||
InvalidToolDefinitionError,
|
||||
InvalidToolJsonValueError,
|
||||
MAX_TOOL_DEFINITIONS,
|
||||
MAX_TOOL_INPUT_BYTES,
|
||||
MAX_TOOL_OUTPUT_BYTES,
|
||||
UnsupportedToolError,
|
||||
type ToolDefinition,
|
||||
type ToolJsonSchema,
|
||||
type ToolJsonValue,
|
||||
} from './contracts';
|
||||
import { normalizeToolDefinition } from './definitionProtocol';
|
||||
|
||||
function valueRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidToolJsonValueError(`${label} must be an object`);
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new InvalidToolJsonValueError(`${label} must be a plain JSON object`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
!Object.hasOwn(descriptor, 'value') ||
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined,
|
||||
)
|
||||
) {
|
||||
throw new InvalidToolJsonValueError(
|
||||
`${label} must contain only JSON data properties`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeJson(
|
||||
value: unknown,
|
||||
schema: ToolJsonSchema,
|
||||
path: string,
|
||||
): ToolJsonValue {
|
||||
if (schema.type === 'null') {
|
||||
if (value !== null) {
|
||||
throw new InvalidToolJsonValueError(`${path} must be null`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (schema.type === 'boolean') {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new InvalidToolJsonValueError(`${path} must be boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (schema.type === 'string') {
|
||||
if (typeof value !== 'string') {
|
||||
throw new InvalidToolJsonValueError(`${path} must be a string`);
|
||||
}
|
||||
const length = Array.from(value).length;
|
||||
if (
|
||||
length > schema.maxLength ||
|
||||
(schema.minLength !== undefined && length < schema.minLength) ||
|
||||
(schema.enum !== undefined && !schema.enum.includes(value))
|
||||
) {
|
||||
throw new InvalidToolJsonValueError(`${path} violates its string bounds`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (schema.type === 'number' || schema.type === 'integer') {
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isFinite(value) ||
|
||||
Math.abs(value) > Number.MAX_SAFE_INTEGER ||
|
||||
value < schema.minimum ||
|
||||
value > schema.maximum ||
|
||||
(schema.type === 'integer' && !Number.isSafeInteger(value))
|
||||
) {
|
||||
throw new InvalidToolJsonValueError(
|
||||
`${path} violates its numeric bounds`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (schema.type === 'array') {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length > schema.maxItems ||
|
||||
(schema.minItems !== undefined && value.length < schema.minItems)
|
||||
) {
|
||||
throw new InvalidToolJsonValueError(`${path} violates its array bounds`);
|
||||
}
|
||||
const enumerableKeys = Object.keys(value);
|
||||
if (
|
||||
enumerableKeys.length !== value.length ||
|
||||
enumerableKeys.some(
|
||||
(key, index) =>
|
||||
key !== String(index) ||
|
||||
!Object.hasOwn(value, index) ||
|
||||
!Object.hasOwn(
|
||||
Object.getOwnPropertyDescriptor(value, key) ?? {},
|
||||
'value',
|
||||
),
|
||||
)
|
||||
) {
|
||||
throw new InvalidToolJsonValueError(`${path} must be a dense JSON array`);
|
||||
}
|
||||
const normalized = value.map((item, index) =>
|
||||
normalizeJson(item, schema.items, `${path}[${index}]`),
|
||||
);
|
||||
if (schema.uniqueItems) {
|
||||
const identities = normalized.map((item) => JSON.stringify(item));
|
||||
if (new Set(identities).size !== identities.length) {
|
||||
throw new InvalidToolJsonValueError(`${path} contains duplicate items`);
|
||||
}
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
const source = valueRecord(value, path);
|
||||
const keys = Object.keys(source);
|
||||
if (
|
||||
keys.some((key) => !Object.hasOwn(schema.properties, key)) ||
|
||||
schema.required.some((key) => !Object.hasOwn(source, key))
|
||||
) {
|
||||
throw new InvalidToolJsonValueError(
|
||||
`${path} has missing or unknown properties`,
|
||||
);
|
||||
}
|
||||
const normalized: Record<string, ToolJsonValue> = {};
|
||||
for (const key of keys.sort()) {
|
||||
normalized[key] = normalizeJson(
|
||||
source[key],
|
||||
schema.properties[key]!,
|
||||
`${path}.${key}`,
|
||||
);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function normalizeBoundedJson(
|
||||
value: unknown,
|
||||
schema: ToolJsonSchema,
|
||||
maximumBytes: number,
|
||||
label: string,
|
||||
): ToolJsonValue {
|
||||
const normalized = normalizeJson(value, schema, label);
|
||||
if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > maximumBytes) {
|
||||
throw new InvalidToolJsonValueError(`${label} byte budget exceeded`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function toolIdentity(name: string, version: string): string {
|
||||
return `${name}@${version}`;
|
||||
}
|
||||
|
||||
export class ToolDefinitionRegistry {
|
||||
readonly #definitions: ReadonlyMap<string, Readonly<ToolDefinition>>;
|
||||
readonly #metadata: readonly Readonly<ToolDefinition>[];
|
||||
|
||||
constructor(definitions: readonly unknown[]) {
|
||||
if (
|
||||
!Array.isArray(definitions) ||
|
||||
definitions.length > MAX_TOOL_DEFINITIONS
|
||||
) {
|
||||
throw new InvalidToolDefinitionError(
|
||||
'registry definition count is invalid',
|
||||
);
|
||||
}
|
||||
const byIdentity = new Map<string, Readonly<ToolDefinition>>();
|
||||
for (const value of definitions) {
|
||||
const definition = normalizeToolDefinition(value);
|
||||
const identity = toolIdentity(definition.name, definition.version);
|
||||
if (byIdentity.has(identity)) {
|
||||
throw new InvalidToolDefinitionError(
|
||||
'registry definition is duplicated',
|
||||
);
|
||||
}
|
||||
byIdentity.set(identity, definition);
|
||||
}
|
||||
this.#definitions = byIdentity;
|
||||
this.#metadata = Object.freeze(
|
||||
[...byIdentity.values()].sort(
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name) ||
|
||||
semver().compare(left.version, right.version),
|
||||
),
|
||||
);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
list(): readonly Readonly<ToolDefinition>[] {
|
||||
return this.#metadata;
|
||||
}
|
||||
|
||||
resolve(name: string, version: string): Readonly<ToolDefinition> {
|
||||
const definition = this.#definitions.get(toolIdentity(name, version));
|
||||
if (!definition) throw new UnsupportedToolError();
|
||||
return definition;
|
||||
}
|
||||
|
||||
normalizeInput(name: string, version: string, input: unknown): ToolJsonValue {
|
||||
const definition = this.resolve(name, version);
|
||||
return normalizeBoundedJson(
|
||||
input,
|
||||
definition.inputSchema,
|
||||
MAX_TOOL_INPUT_BYTES,
|
||||
'input',
|
||||
);
|
||||
}
|
||||
|
||||
normalizeOutput(
|
||||
name: string,
|
||||
version: string,
|
||||
output: unknown,
|
||||
): ToolJsonValue {
|
||||
const definition = this.resolve(name, version);
|
||||
if (definition.outputSchema === undefined) {
|
||||
if (output !== null) {
|
||||
throw new InvalidToolJsonValueError(
|
||||
'output must be null when outputSchema is absent',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return normalizeBoundedJson(
|
||||
output,
|
||||
definition.outputSchema,
|
||||
MAX_TOOL_OUTPUT_BYTES,
|
||||
'output',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Stable Tool Registry facade.
|
||||
export {
|
||||
InvalidToolDefinitionError,
|
||||
InvalidToolJsonValueError,
|
||||
MAX_TOOL_ARRAY_ITEMS,
|
||||
MAX_TOOL_DEFINITIONS,
|
||||
MAX_TOOL_INPUT_BYTES,
|
||||
MAX_TOOL_OUTPUT_BYTES,
|
||||
MAX_TOOL_REQUIRED_PERMISSIONS,
|
||||
MAX_TOOL_SCHEMA_DEPTH,
|
||||
MAX_TOOL_SCHEMA_ENUM_VALUES,
|
||||
MAX_TOOL_SCHEMA_NODES,
|
||||
MAX_TOOL_SCHEMA_PROPERTIES,
|
||||
MAX_TOOL_TIMEOUT_SECONDS,
|
||||
TOOL_EFFECTS,
|
||||
TOOL_INVOCATION_SCHEMA,
|
||||
TOOL_JSON_SCHEMA_TYPES,
|
||||
TOOL_RISKS,
|
||||
ToolPolicySnapshotConflictError,
|
||||
ToolPolicyUnavailableError,
|
||||
UnsupportedToolError,
|
||||
type DeniedToolInvocation,
|
||||
type PreparedToolInvocation,
|
||||
type ToolDefinition,
|
||||
type ToolEffect,
|
||||
type ToolInvocationPlan,
|
||||
type ToolInvocationRequest,
|
||||
type ToolInvocationStatus,
|
||||
type ToolJsonSchema,
|
||||
type ToolJsonSchemaType,
|
||||
type ToolJsonValue,
|
||||
type ToolPolicyAuthorizer,
|
||||
type ToolRisk,
|
||||
} from './contracts';
|
||||
export { normalizeToolDefinition } from './definitionProtocol';
|
||||
export { prepareToolInvocation } from './invocationAdmission';
|
||||
export { ToolDefinitionRegistry } from './registryProtocol';
|
||||
@@ -0,0 +1,986 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
|
||||
import { normalizeStepRunMutation, type StepRunMutation } from '../run/stepRun';
|
||||
import {
|
||||
normalizeToolExecutionStartBarrierRecord,
|
||||
type ToolExecutionStartBarrierRecord,
|
||||
} from './toolExecutionStartBarrier';
|
||||
import {
|
||||
normalizeToolResultKeyCatalogFence,
|
||||
type ToolResultKeyCatalogFence,
|
||||
} from './toolResultKeyCatalog';
|
||||
import {
|
||||
MAX_TOOL_OUTPUT_BYTES,
|
||||
ToolDefinitionRegistry,
|
||||
type ToolJsonValue,
|
||||
} from './tool-registry/toolRegistry';
|
||||
import {
|
||||
TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA,
|
||||
type TrustedToolExecutionResult,
|
||||
} from './trustedToolExecution';
|
||||
|
||||
export const TOOL_EXECUTION_RESULT_ARTIFACT_SCHEMA =
|
||||
'qinglong/tool-execution-result-artifact@v1' as const;
|
||||
export const TOOL_EXECUTION_COMPLETION_SCHEMA =
|
||||
'qinglong/tool-execution-completion@v1' as const;
|
||||
export const TOOL_EXECUTION_COMPLETION_COMMAND_SCHEMA =
|
||||
'qinglong/tool-execution-completion-command@v2' as const;
|
||||
export const TOOL_EXECUTION_RESULT_KEY_BINDING_SCHEMA =
|
||||
'qinglong/tool-execution-result-key-binding@v1' as const;
|
||||
export const TOOL_EXECUTION_RESULT_ARTIFACT_ALGORITHM = 'aes-256-gcm' as const;
|
||||
export const MAX_TOOL_EXECUTION_RESULT_ARTIFACT_JSON_BYTES = 384 * 1024;
|
||||
export const MAX_TOOL_EXECUTION_COMPLETION_JSON_BYTES = 24 * 1024;
|
||||
|
||||
export interface ToolExecutionResultArtifact {
|
||||
readonly schema: typeof TOOL_EXECUTION_RESULT_ARTIFACT_SCHEMA;
|
||||
readonly artifactId: string;
|
||||
readonly projectId: string;
|
||||
readonly startId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly tool: Readonly<{ name: string; version: string }>;
|
||||
readonly barrierDigest: string;
|
||||
readonly adapterDigest: string;
|
||||
readonly outputDigest: string;
|
||||
readonly executionResultDigest: string;
|
||||
readonly keyId: string;
|
||||
readonly algorithm: typeof TOOL_EXECUTION_RESULT_ARTIFACT_ALGORITHM;
|
||||
readonly nonce: string;
|
||||
readonly ciphertext: string;
|
||||
readonly authTag: string;
|
||||
readonly plaintextBytes: number;
|
||||
readonly sealedAtMs: number;
|
||||
readonly artifactDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolExecutionResultArtifactReference {
|
||||
readonly artifactId: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly outputDigest: string;
|
||||
readonly executionResultDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolExecutionCompletionRecord {
|
||||
readonly schema: typeof TOOL_EXECUTION_COMPLETION_SCHEMA;
|
||||
readonly startId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly startedStepRunVersion: number;
|
||||
readonly completedStepRunVersion: number;
|
||||
readonly barrierDigest: string;
|
||||
readonly adapterDigest: string;
|
||||
readonly resultArtifact: Readonly<ToolExecutionResultArtifactReference>;
|
||||
readonly stepRunMutationId: string;
|
||||
readonly stepRunMutationDigest: string;
|
||||
readonly completedStepRunDigest: string;
|
||||
readonly runEventId: string;
|
||||
readonly completedAtMs: number;
|
||||
readonly completionDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolExecutionCompletionCommand {
|
||||
readonly schema: typeof TOOL_EXECUTION_COMPLETION_COMMAND_SCHEMA;
|
||||
readonly barrier: Readonly<ToolExecutionStartBarrierRecord>;
|
||||
readonly executionResult: Readonly<TrustedToolExecutionResult>;
|
||||
readonly resultArtifact: Readonly<ToolExecutionResultArtifact>;
|
||||
readonly resultKeyCatalogFence: Readonly<ToolResultKeyCatalogFence>;
|
||||
readonly stepRunMutation: Readonly<StepRunMutation>;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolExecutionResultKeyBinding {
|
||||
readonly schema: typeof TOOL_EXECUTION_RESULT_KEY_BINDING_SCHEMA;
|
||||
readonly startId: string;
|
||||
readonly artifactId: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly catalogGeneration: number;
|
||||
readonly catalogDigest: string;
|
||||
readonly keyId: string;
|
||||
readonly materialProof: string;
|
||||
readonly bindingDigest: string;
|
||||
}
|
||||
|
||||
export interface CommitToolExecutionCompletionResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly completion: Readonly<ToolExecutionCompletionRecord>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionCompletionRepository {
|
||||
findByStartId(
|
||||
startId: string,
|
||||
): Promise<Readonly<ToolExecutionCompletionRecord> | null>;
|
||||
findResultArtifact(
|
||||
artifactId: string,
|
||||
): Promise<Readonly<ToolExecutionResultArtifact> | null>;
|
||||
commit(
|
||||
command: ToolExecutionCompletionCommand,
|
||||
): Promise<Readonly<CommitToolExecutionCompletionResult>>;
|
||||
}
|
||||
|
||||
export class InvalidToolExecutionCompletionError extends TypeError {
|
||||
readonly code = 'TOOL_EXECUTION_COMPLETION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Tool execution completion is invalid: ${message}`);
|
||||
this.name = 'InvalidToolExecutionCompletionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolExecutionCompletionConflictError extends Error {
|
||||
readonly code = 'TOOL_EXECUTION_COMPLETION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Tool execution completion conflicts with durable state');
|
||||
this.name = 'ToolExecutionCompletionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolExecutionCompletionUnavailableError extends Error {
|
||||
readonly code = 'TOOL_EXECUTION_COMPLETION_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Tool execution completion authority is unavailable', options);
|
||||
this.name = 'ToolExecutionCompletionUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TOOL_NAME_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
|
||||
const TOOL_VERSION_PATTERN =
|
||||
/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/;
|
||||
const KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
|
||||
const OUTPUT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-output-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const EXECUTION_RESULT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-result-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const RESULT_ARTIFACT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-result-artifact-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const COMPLETION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-completion-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const COMPLETION_COMMAND_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-completion-command-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const RESULT_KEY_BINDING_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-result-key-binding-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidToolExecutionCompletionError(message);
|
||||
}
|
||||
|
||||
function hash(domain: Uint8Array, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
return invalid(`${label} is not a plain object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function version(value: unknown, label: string): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 2 ||
|
||||
(value as number) > 2_147_483_647
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function toolIdentity(
|
||||
value: Readonly<{ name: string; version: string }>,
|
||||
): Readonly<{ name: string; version: string }> {
|
||||
const candidate = record(value, 'Tool identity');
|
||||
exactKeys(candidate, ['name', 'version'], 'Tool identity');
|
||||
if (
|
||||
typeof value.name !== 'string' ||
|
||||
!TOOL_NAME_PATTERN.test(value.name) ||
|
||||
typeof value.version !== 'string' ||
|
||||
!TOOL_VERSION_PATTERN.test(value.version)
|
||||
) {
|
||||
return invalid('Tool identity is invalid');
|
||||
}
|
||||
return Object.freeze({ name: value.name, version: value.version });
|
||||
}
|
||||
|
||||
function jsonValue(value: unknown, depth = 0): ToolJsonValue {
|
||||
if (depth > 16) return invalid('output nesting is too deep');
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'string'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return invalid('output number is invalid');
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return Object.freeze(value.map((item) => jsonValue(item, depth + 1)));
|
||||
}
|
||||
const source = record(value, 'output value');
|
||||
const normalized: Record<string, ToolJsonValue> = {};
|
||||
for (const key of Object.keys(source)) {
|
||||
normalized[key] = jsonValue(source[key], depth + 1);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function base64url(value: unknown, label: string, exactBytes?: number): Buffer {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!BASE64URL_PATTERN.test(value) ||
|
||||
value.length === 0
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
const decoded = Buffer.from(value, 'base64url');
|
||||
if (
|
||||
decoded.toString('base64url') !== value ||
|
||||
(exactBytes !== undefined && decoded.length !== exactBytes)
|
||||
) {
|
||||
decoded.fill(0);
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function ownedKey(value: Uint8Array): Buffer {
|
||||
if (!(value instanceof Uint8Array) || value.byteLength !== 32) {
|
||||
return invalid('result Artifact key is invalid');
|
||||
}
|
||||
return Buffer.from(value);
|
||||
}
|
||||
|
||||
function normalizeExecutionResult(
|
||||
value: TrustedToolExecutionResult,
|
||||
): Readonly<TrustedToolExecutionResult> {
|
||||
const candidate = record(value, 'execution result');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'adapterDigest',
|
||||
'barrierDigest',
|
||||
'completedAtMs',
|
||||
'output',
|
||||
'outputDigest',
|
||||
'resultDigest',
|
||||
'schema',
|
||||
'startId',
|
||||
],
|
||||
'execution result',
|
||||
);
|
||||
if (value.schema !== TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA) {
|
||||
return invalid('execution result schema is invalid');
|
||||
}
|
||||
const output = jsonValue(value.output);
|
||||
const outputJson = JSON.stringify(output);
|
||||
if (Buffer.byteLength(outputJson, 'utf8') > MAX_TOOL_OUTPUT_BYTES) {
|
||||
return invalid('execution result output exceeds its budget');
|
||||
}
|
||||
const outputDigest = digest(value.outputDigest, 'output digest');
|
||||
if (hash(OUTPUT_DIGEST_DOMAIN, output) !== outputDigest) {
|
||||
return invalid('execution result output digest does not match');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA,
|
||||
startId: identity(value.startId, 'execution result start id'),
|
||||
barrierDigest: digest(value.barrierDigest, 'barrier digest'),
|
||||
adapterDigest: digest(value.adapterDigest, 'adapter digest'),
|
||||
output,
|
||||
outputDigest,
|
||||
completedAtMs: timestamp(value.completedAtMs, 'completion time'),
|
||||
});
|
||||
const resultDigest = digest(value.resultDigest, 'execution result digest');
|
||||
if (hash(EXECUTION_RESULT_DIGEST_DOMAIN, unsigned) !== resultDigest) {
|
||||
return invalid('execution result digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, resultDigest });
|
||||
}
|
||||
|
||||
function artifactMetadata(
|
||||
artifact: Omit<
|
||||
ToolExecutionResultArtifact,
|
||||
'artifactDigest' | 'authTag' | 'ciphertext' | 'nonce'
|
||||
>,
|
||||
): Readonly<
|
||||
Omit<
|
||||
ToolExecutionResultArtifact,
|
||||
'artifactDigest' | 'authTag' | 'ciphertext' | 'nonce'
|
||||
>
|
||||
> {
|
||||
return Object.freeze(artifact);
|
||||
}
|
||||
|
||||
function artifactAad(metadata: ReturnType<typeof artifactMetadata>): Buffer {
|
||||
return Buffer.from(JSON.stringify(metadata), 'utf8');
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionResultArtifact(
|
||||
value: ToolExecutionResultArtifact,
|
||||
): Readonly<ToolExecutionResultArtifact> {
|
||||
const candidate = record(value, 'result Artifact');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'adapterDigest',
|
||||
'algorithm',
|
||||
'artifactDigest',
|
||||
'artifactId',
|
||||
'authTag',
|
||||
'barrierDigest',
|
||||
'ciphertext',
|
||||
'executionResultDigest',
|
||||
'keyId',
|
||||
'nonce',
|
||||
'outputDigest',
|
||||
'plaintextBytes',
|
||||
'projectId',
|
||||
'runId',
|
||||
'schema',
|
||||
'sealedAtMs',
|
||||
'startId',
|
||||
'stepRunId',
|
||||
'tool',
|
||||
],
|
||||
'result Artifact',
|
||||
);
|
||||
if (
|
||||
value.schema !== TOOL_EXECUTION_RESULT_ARTIFACT_SCHEMA ||
|
||||
value.algorithm !== TOOL_EXECUTION_RESULT_ARTIFACT_ALGORITHM ||
|
||||
typeof value.keyId !== 'string' ||
|
||||
!KEY_ID_PATTERN.test(value.keyId)
|
||||
) {
|
||||
return invalid('result Artifact schema, algorithm or key id is invalid');
|
||||
}
|
||||
const nonce = base64url(value.nonce, 'result Artifact nonce', 12);
|
||||
const ciphertext = base64url(value.ciphertext, 'result Artifact ciphertext');
|
||||
const authTag = base64url(value.authTag, 'result Artifact auth tag', 16);
|
||||
nonce.fill(0);
|
||||
ciphertext.fill(0);
|
||||
authTag.fill(0);
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_RESULT_ARTIFACT_SCHEMA,
|
||||
artifactId: identity(value.artifactId, 'result Artifact id'),
|
||||
projectId: identity(value.projectId, 'result Artifact project id'),
|
||||
startId: identity(value.startId, 'result Artifact start id'),
|
||||
runId: identity(value.runId, 'result Artifact Run id'),
|
||||
stepRunId: identity(value.stepRunId, 'result Artifact StepRun id'),
|
||||
tool: toolIdentity(value.tool),
|
||||
barrierDigest: digest(
|
||||
value.barrierDigest,
|
||||
'result Artifact barrier digest',
|
||||
),
|
||||
adapterDigest: digest(
|
||||
value.adapterDigest,
|
||||
'result Artifact adapter digest',
|
||||
),
|
||||
outputDigest: digest(value.outputDigest, 'result Artifact output digest'),
|
||||
executionResultDigest: digest(
|
||||
value.executionResultDigest,
|
||||
'execution result digest',
|
||||
),
|
||||
keyId: value.keyId,
|
||||
algorithm: TOOL_EXECUTION_RESULT_ARTIFACT_ALGORITHM,
|
||||
nonce: value.nonce,
|
||||
ciphertext: value.ciphertext,
|
||||
authTag: value.authTag,
|
||||
plaintextBytes: timestamp(value.plaintextBytes, 'plaintext byte count'),
|
||||
sealedAtMs: timestamp(value.sealedAtMs, 'result Artifact seal time'),
|
||||
});
|
||||
if (
|
||||
unsigned.plaintextBytes > MAX_TOOL_OUTPUT_BYTES ||
|
||||
Buffer.byteLength(JSON.stringify(unsigned), 'utf8') >
|
||||
MAX_TOOL_EXECUTION_RESULT_ARTIFACT_JSON_BYTES
|
||||
) {
|
||||
return invalid('result Artifact exceeds its budget');
|
||||
}
|
||||
const artifactDigest = digest(value.artifactDigest, 'result Artifact digest');
|
||||
if (hash(RESULT_ARTIFACT_DIGEST_DOMAIN, unsigned) !== artifactDigest) {
|
||||
return invalid('result Artifact digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, artifactDigest });
|
||||
}
|
||||
|
||||
export function toolExecutionResultArtifactReference(
|
||||
value: ToolExecutionResultArtifact,
|
||||
): Readonly<ToolExecutionResultArtifactReference> {
|
||||
const artifact = normalizeToolExecutionResultArtifact(value);
|
||||
return Object.freeze({
|
||||
artifactId: artifact.artifactId,
|
||||
artifactDigest: artifact.artifactDigest,
|
||||
outputDigest: artifact.outputDigest,
|
||||
executionResultDigest: artifact.executionResultDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function createToolExecutionResultArtifact(
|
||||
input: Readonly<{
|
||||
artifactId: string;
|
||||
projectId: string;
|
||||
runId: string;
|
||||
stepRunId: string;
|
||||
tool: Readonly<{ name: string; version: string }>;
|
||||
executionResult: Readonly<TrustedToolExecutionResult>;
|
||||
keyId: string;
|
||||
key: Uint8Array;
|
||||
}>,
|
||||
registry: ToolDefinitionRegistry,
|
||||
nonceFactory: () => Uint8Array = () => randomBytes(12),
|
||||
): Readonly<ToolExecutionResultArtifact> {
|
||||
if (!(registry instanceof ToolDefinitionRegistry)) {
|
||||
return invalid('Tool registry is invalid');
|
||||
}
|
||||
const result = normalizeExecutionResult(input.executionResult);
|
||||
const tool = toolIdentity(input.tool);
|
||||
const output = registry.normalizeOutput(
|
||||
tool.name,
|
||||
tool.version,
|
||||
result.output,
|
||||
);
|
||||
if (JSON.stringify(output) !== JSON.stringify(result.output)) {
|
||||
return invalid('execution output is not canonical for the Tool');
|
||||
}
|
||||
const plaintext = Buffer.from(JSON.stringify(output), 'utf8');
|
||||
const key = ownedKey(input.key);
|
||||
let nonce: Buffer | undefined;
|
||||
try {
|
||||
nonce = Buffer.from(nonceFactory());
|
||||
if (nonce.length !== 12) {
|
||||
throw new ToolExecutionCompletionUnavailableError();
|
||||
}
|
||||
const metadata = artifactMetadata({
|
||||
schema: TOOL_EXECUTION_RESULT_ARTIFACT_SCHEMA,
|
||||
artifactId: identity(input.artifactId, 'result Artifact id'),
|
||||
projectId: identity(input.projectId, 'result Artifact project id'),
|
||||
startId: result.startId,
|
||||
runId: identity(input.runId, 'result Artifact Run id'),
|
||||
stepRunId: identity(input.stepRunId, 'result Artifact StepRun id'),
|
||||
tool,
|
||||
barrierDigest: result.barrierDigest,
|
||||
adapterDigest: result.adapterDigest,
|
||||
outputDigest: result.outputDigest,
|
||||
executionResultDigest: result.resultDigest,
|
||||
keyId:
|
||||
typeof input.keyId === 'string' && KEY_ID_PATTERN.test(input.keyId)
|
||||
? input.keyId
|
||||
: invalid('result Artifact key id is invalid'),
|
||||
algorithm: TOOL_EXECUTION_RESULT_ARTIFACT_ALGORITHM,
|
||||
plaintextBytes: plaintext.length,
|
||||
sealedAtMs: result.completedAtMs,
|
||||
});
|
||||
const cipher = createCipheriv(
|
||||
TOOL_EXECUTION_RESULT_ARTIFACT_ALGORITHM,
|
||||
key,
|
||||
nonce,
|
||||
{ authTagLength: 16 },
|
||||
);
|
||||
const aad = artifactAad(metadata);
|
||||
try {
|
||||
cipher.setAAD(aad);
|
||||
} finally {
|
||||
aad.fill(0);
|
||||
}
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintext),
|
||||
cipher.final(),
|
||||
]);
|
||||
try {
|
||||
const unsigned = Object.freeze({
|
||||
schema: metadata.schema,
|
||||
artifactId: metadata.artifactId,
|
||||
projectId: metadata.projectId,
|
||||
startId: metadata.startId,
|
||||
runId: metadata.runId,
|
||||
stepRunId: metadata.stepRunId,
|
||||
tool: metadata.tool,
|
||||
barrierDigest: metadata.barrierDigest,
|
||||
adapterDigest: metadata.adapterDigest,
|
||||
outputDigest: metadata.outputDigest,
|
||||
executionResultDigest: metadata.executionResultDigest,
|
||||
keyId: metadata.keyId,
|
||||
algorithm: metadata.algorithm,
|
||||
nonce: nonce.toString('base64url'),
|
||||
ciphertext: ciphertext.toString('base64url'),
|
||||
authTag: cipher.getAuthTag().toString('base64url'),
|
||||
plaintextBytes: metadata.plaintextBytes,
|
||||
sealedAtMs: metadata.sealedAtMs,
|
||||
});
|
||||
return normalizeToolExecutionResultArtifact({
|
||||
...unsigned,
|
||||
artifactDigest: hash(RESULT_ARTIFACT_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
} finally {
|
||||
ciphertext.fill(0);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof InvalidToolExecutionCompletionError ||
|
||||
cause instanceof ToolExecutionCompletionUnavailableError
|
||||
) {
|
||||
throw cause;
|
||||
}
|
||||
throw new ToolExecutionCompletionUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
} finally {
|
||||
key.fill(0);
|
||||
plaintext.fill(0);
|
||||
nonce?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function openToolExecutionResultArtifact(
|
||||
artifactValue: ToolExecutionResultArtifact,
|
||||
keyValue: Uint8Array,
|
||||
registry: ToolDefinitionRegistry,
|
||||
): ToolJsonValue {
|
||||
const artifact = normalizeToolExecutionResultArtifact(artifactValue);
|
||||
if (!(registry instanceof ToolDefinitionRegistry)) {
|
||||
return invalid('Tool registry is invalid');
|
||||
}
|
||||
const key = ownedKey(keyValue);
|
||||
const nonce = base64url(artifact.nonce, 'result Artifact nonce', 12);
|
||||
const ciphertext = base64url(
|
||||
artifact.ciphertext,
|
||||
'result Artifact ciphertext',
|
||||
);
|
||||
const authTag = base64url(artifact.authTag, 'result Artifact auth tag', 16);
|
||||
const aad = artifactAad(
|
||||
artifactMetadata({
|
||||
schema: artifact.schema,
|
||||
artifactId: artifact.artifactId,
|
||||
projectId: artifact.projectId,
|
||||
startId: artifact.startId,
|
||||
runId: artifact.runId,
|
||||
stepRunId: artifact.stepRunId,
|
||||
tool: artifact.tool,
|
||||
barrierDigest: artifact.barrierDigest,
|
||||
adapterDigest: artifact.adapterDigest,
|
||||
outputDigest: artifact.outputDigest,
|
||||
executionResultDigest: artifact.executionResultDigest,
|
||||
keyId: artifact.keyId,
|
||||
algorithm: artifact.algorithm,
|
||||
plaintextBytes: artifact.plaintextBytes,
|
||||
sealedAtMs: artifact.sealedAtMs,
|
||||
}),
|
||||
);
|
||||
let plaintext: Buffer | undefined;
|
||||
try {
|
||||
const decipher = createDecipheriv(
|
||||
TOOL_EXECUTION_RESULT_ARTIFACT_ALGORITHM,
|
||||
key,
|
||||
nonce,
|
||||
{ authTagLength: 16 },
|
||||
);
|
||||
decipher.setAAD(aad);
|
||||
decipher.setAuthTag(authTag);
|
||||
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
if (plaintext.length !== artifact.plaintextBytes) {
|
||||
throw new ToolExecutionCompletionUnavailableError();
|
||||
}
|
||||
const parsed = JSON.parse(plaintext.toString('utf8')) as unknown;
|
||||
const output = registry.normalizeOutput(
|
||||
artifact.tool.name,
|
||||
artifact.tool.version,
|
||||
parsed,
|
||||
);
|
||||
if (
|
||||
JSON.stringify(output) !== plaintext.toString('utf8') ||
|
||||
hash(OUTPUT_DIGEST_DOMAIN, output) !== artifact.outputDigest
|
||||
) {
|
||||
throw new ToolExecutionCompletionUnavailableError();
|
||||
}
|
||||
return output;
|
||||
} catch (cause) {
|
||||
if (cause instanceof InvalidToolExecutionCompletionError) throw cause;
|
||||
throw new ToolExecutionCompletionUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
} finally {
|
||||
key.fill(0);
|
||||
nonce.fill(0);
|
||||
ciphertext.fill(0);
|
||||
authTag.fill(0);
|
||||
aad.fill(0);
|
||||
plaintext?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionCompletionRecord(
|
||||
value: ToolExecutionCompletionRecord,
|
||||
): Readonly<ToolExecutionCompletionRecord> {
|
||||
const candidate = record(value, 'completion');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'adapterDigest',
|
||||
'barrierDigest',
|
||||
'completedAtMs',
|
||||
'completedStepRunDigest',
|
||||
'completedStepRunVersion',
|
||||
'completionDigest',
|
||||
'projectId',
|
||||
'resultArtifact',
|
||||
'runEventId',
|
||||
'runId',
|
||||
'schema',
|
||||
'startId',
|
||||
'startedStepRunVersion',
|
||||
'stepRunId',
|
||||
'stepRunMutationDigest',
|
||||
'stepRunMutationId',
|
||||
],
|
||||
'completion',
|
||||
);
|
||||
if (value.schema !== TOOL_EXECUTION_COMPLETION_SCHEMA) {
|
||||
return invalid('completion schema is invalid');
|
||||
}
|
||||
const referenceValue = record(
|
||||
value.resultArtifact,
|
||||
'result Artifact reference',
|
||||
);
|
||||
exactKeys(
|
||||
referenceValue,
|
||||
['artifactDigest', 'artifactId', 'executionResultDigest', 'outputDigest'],
|
||||
'result Artifact reference',
|
||||
);
|
||||
const startedStepRunVersion = version(
|
||||
value.startedStepRunVersion,
|
||||
'started StepRun version',
|
||||
);
|
||||
const completedStepRunVersion = version(
|
||||
value.completedStepRunVersion,
|
||||
'completed StepRun version',
|
||||
);
|
||||
if (completedStepRunVersion !== startedStepRunVersion + 1) {
|
||||
return invalid('completion StepRun version fence is invalid');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_COMPLETION_SCHEMA,
|
||||
startId: identity(value.startId, 'completion start id'),
|
||||
projectId: identity(value.projectId, 'completion project id'),
|
||||
runId: identity(value.runId, 'completion Run id'),
|
||||
stepRunId: identity(value.stepRunId, 'completion StepRun id'),
|
||||
startedStepRunVersion,
|
||||
completedStepRunVersion,
|
||||
barrierDigest: digest(value.barrierDigest, 'completion barrier digest'),
|
||||
adapterDigest: digest(value.adapterDigest, 'completion adapter digest'),
|
||||
resultArtifact: Object.freeze({
|
||||
artifactId: identity(
|
||||
value.resultArtifact.artifactId,
|
||||
'result Artifact id',
|
||||
),
|
||||
artifactDigest: digest(
|
||||
value.resultArtifact.artifactDigest,
|
||||
'result Artifact digest',
|
||||
),
|
||||
outputDigest: digest(
|
||||
value.resultArtifact.outputDigest,
|
||||
'result Artifact output digest',
|
||||
),
|
||||
executionResultDigest: digest(
|
||||
value.resultArtifact.executionResultDigest,
|
||||
'execution result digest',
|
||||
),
|
||||
}),
|
||||
stepRunMutationId: identity(
|
||||
value.stepRunMutationId,
|
||||
'completion mutation id',
|
||||
),
|
||||
stepRunMutationDigest: digest(
|
||||
value.stepRunMutationDigest,
|
||||
'completion mutation digest',
|
||||
),
|
||||
completedStepRunDigest: digest(
|
||||
value.completedStepRunDigest,
|
||||
'completed StepRun digest',
|
||||
),
|
||||
runEventId: identity(value.runEventId, 'completion Run event id'),
|
||||
completedAtMs: timestamp(value.completedAtMs, 'completion time'),
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(unsigned), 'utf8') >
|
||||
MAX_TOOL_EXECUTION_COMPLETION_JSON_BYTES
|
||||
) {
|
||||
return invalid('completion exceeds its budget');
|
||||
}
|
||||
const completionDigest = digest(value.completionDigest, 'completion digest');
|
||||
if (hash(COMPLETION_DIGEST_DOMAIN, unsigned) !== completionDigest) {
|
||||
return invalid('completion digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, completionDigest });
|
||||
}
|
||||
|
||||
function completionFromParts(
|
||||
barrier: Readonly<ToolExecutionStartBarrierRecord>,
|
||||
result: Readonly<TrustedToolExecutionResult>,
|
||||
artifact: Readonly<ToolExecutionResultArtifact>,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): Readonly<ToolExecutionCompletionRecord> {
|
||||
const reference = toolExecutionResultArtifactReference(artifact);
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_COMPLETION_SCHEMA,
|
||||
startId: barrier.startId,
|
||||
projectId: barrier.projectId,
|
||||
runId: barrier.runId,
|
||||
stepRunId: barrier.stepRunId,
|
||||
startedStepRunVersion: barrier.startedStepRunVersion,
|
||||
completedStepRunVersion: mutation.stepRun.version,
|
||||
barrierDigest: barrier.barrierDigest,
|
||||
adapterDigest: barrier.adapterDigest,
|
||||
resultArtifact: reference,
|
||||
stepRunMutationId: mutation.mutationId,
|
||||
stepRunMutationDigest: mutation.mutationDigest,
|
||||
completedStepRunDigest: mutation.stepRun.stepRunDigest,
|
||||
runEventId: mutation.event.id,
|
||||
completedAtMs: result.completedAtMs,
|
||||
});
|
||||
return normalizeToolExecutionCompletionRecord({
|
||||
...unsigned,
|
||||
completionDigest: hash(COMPLETION_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionCompletionCommand(
|
||||
value: ToolExecutionCompletionCommand,
|
||||
): Readonly<ToolExecutionCompletionCommand> {
|
||||
const candidate = record(value, 'completion command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'barrier',
|
||||
'commandDigest',
|
||||
'executionResult',
|
||||
'resultArtifact',
|
||||
'resultKeyCatalogFence',
|
||||
'schema',
|
||||
'stepRunMutation',
|
||||
],
|
||||
'completion command',
|
||||
);
|
||||
if (value.schema !== TOOL_EXECUTION_COMPLETION_COMMAND_SCHEMA) {
|
||||
return invalid('completion command schema is invalid');
|
||||
}
|
||||
const barrier = normalizeToolExecutionStartBarrierRecord(value.barrier);
|
||||
const executionResult = normalizeExecutionResult(value.executionResult);
|
||||
const resultArtifact = normalizeToolExecutionResultArtifact(
|
||||
value.resultArtifact,
|
||||
);
|
||||
const resultKeyCatalogFence = normalizeToolResultKeyCatalogFence(
|
||||
value.resultKeyCatalogFence,
|
||||
);
|
||||
const stepRunMutation = normalizeStepRunMutation(value.stepRunMutation);
|
||||
if (
|
||||
executionResult.startId !== barrier.startId ||
|
||||
executionResult.barrierDigest !== barrier.barrierDigest ||
|
||||
executionResult.adapterDigest !== barrier.adapterDigest ||
|
||||
executionResult.completedAtMs < barrier.startedAtMs ||
|
||||
resultArtifact.projectId !== barrier.projectId ||
|
||||
resultArtifact.startId !== barrier.startId ||
|
||||
resultArtifact.runId !== barrier.runId ||
|
||||
resultArtifact.stepRunId !== barrier.stepRunId ||
|
||||
resultArtifact.barrierDigest !== barrier.barrierDigest ||
|
||||
resultArtifact.adapterDigest !== barrier.adapterDigest ||
|
||||
resultArtifact.outputDigest !== executionResult.outputDigest ||
|
||||
resultArtifact.executionResultDigest !== executionResult.resultDigest ||
|
||||
resultArtifact.keyId !== resultKeyCatalogFence.keyId ||
|
||||
resultArtifact.sealedAtMs !== executionResult.completedAtMs ||
|
||||
stepRunMutation.runId !== barrier.runId ||
|
||||
stepRunMutation.stepRun.id !== barrier.stepRunId ||
|
||||
stepRunMutation.stepRun.kind !== 'tool' ||
|
||||
stepRunMutation.previousStatus !== 'running' ||
|
||||
stepRunMutation.expectedStepRunVersion !== barrier.startedStepRunVersion ||
|
||||
stepRunMutation.expectedStepRunDigest !== barrier.startedStepRunDigest ||
|
||||
stepRunMutation.stepRun.status !== 'succeeded' ||
|
||||
stepRunMutation.stepRun.outputRef !== resultArtifact.artifactId ||
|
||||
stepRunMutation.stepRun.finishedAtMs !== executionResult.completedAtMs ||
|
||||
stepRunMutation.stepRun.updatedAtMs !== executionResult.completedAtMs
|
||||
) {
|
||||
throw new ToolExecutionCompletionConflictError();
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_COMPLETION_COMMAND_SCHEMA,
|
||||
barrier,
|
||||
executionResult,
|
||||
resultArtifact,
|
||||
resultKeyCatalogFence,
|
||||
stepRunMutation,
|
||||
});
|
||||
const commandDigest = digest(
|
||||
value.commandDigest,
|
||||
'completion command digest',
|
||||
);
|
||||
if (hash(COMPLETION_COMMAND_DIGEST_DOMAIN, unsigned) !== commandDigest) {
|
||||
return invalid('completion command digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, commandDigest });
|
||||
}
|
||||
|
||||
export function createToolExecutionCompletionCommand(
|
||||
value: Omit<ToolExecutionCompletionCommand, 'commandDigest' | 'schema'>,
|
||||
): Readonly<ToolExecutionCompletionCommand> {
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_COMPLETION_COMMAND_SCHEMA,
|
||||
barrier: normalizeToolExecutionStartBarrierRecord(value.barrier),
|
||||
executionResult: normalizeExecutionResult(value.executionResult),
|
||||
resultArtifact: normalizeToolExecutionResultArtifact(value.resultArtifact),
|
||||
resultKeyCatalogFence: normalizeToolResultKeyCatalogFence(
|
||||
value.resultKeyCatalogFence,
|
||||
),
|
||||
stepRunMutation: normalizeStepRunMutation(value.stepRunMutation),
|
||||
});
|
||||
return normalizeToolExecutionCompletionCommand({
|
||||
...unsigned,
|
||||
commandDigest: hash(COMPLETION_COMMAND_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionResultKeyBinding(
|
||||
value: ToolExecutionResultKeyBinding,
|
||||
): Readonly<ToolExecutionResultKeyBinding> {
|
||||
const candidate = record(value, 'result key binding');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'artifactDigest',
|
||||
'artifactId',
|
||||
'bindingDigest',
|
||||
'catalogDigest',
|
||||
'catalogGeneration',
|
||||
'keyId',
|
||||
'materialProof',
|
||||
'schema',
|
||||
'startId',
|
||||
],
|
||||
'result key binding',
|
||||
);
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_RESULT_KEY_BINDING_SCHEMA,
|
||||
startId: identity(value.startId, 'result key binding start id'),
|
||||
artifactId: identity(value.artifactId, 'result key binding Artifact id'),
|
||||
artifactDigest: digest(
|
||||
value.artifactDigest,
|
||||
'result key binding Artifact digest',
|
||||
),
|
||||
catalogGeneration: timestamp(
|
||||
value.catalogGeneration,
|
||||
'result key catalog generation',
|
||||
),
|
||||
catalogDigest: digest(value.catalogDigest, 'result key catalog digest'),
|
||||
keyId:
|
||||
typeof value.keyId === 'string' && KEY_ID_PATTERN.test(value.keyId)
|
||||
? value.keyId
|
||||
: invalid('result key binding key id is invalid'),
|
||||
materialProof: digest(value.materialProof, 'result key material proof'),
|
||||
});
|
||||
if (unsigned.catalogGeneration < 1) {
|
||||
return invalid('result key catalog generation is invalid');
|
||||
}
|
||||
const bindingDigest = digest(
|
||||
value.bindingDigest,
|
||||
'result key binding digest',
|
||||
);
|
||||
if (hash(RESULT_KEY_BINDING_DIGEST_DOMAIN, unsigned) !== bindingDigest) {
|
||||
return invalid('result key binding digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, bindingDigest });
|
||||
}
|
||||
|
||||
export function toolExecutionResultKeyBinding(
|
||||
commandValue: ToolExecutionCompletionCommand,
|
||||
): Readonly<ToolExecutionResultKeyBinding> {
|
||||
const command = normalizeToolExecutionCompletionCommand(commandValue);
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_RESULT_KEY_BINDING_SCHEMA,
|
||||
startId: command.barrier.startId,
|
||||
artifactId: command.resultArtifact.artifactId,
|
||||
artifactDigest: command.resultArtifact.artifactDigest,
|
||||
catalogGeneration: command.resultKeyCatalogFence.generation,
|
||||
catalogDigest: command.resultKeyCatalogFence.catalogDigest,
|
||||
keyId: command.resultKeyCatalogFence.keyId,
|
||||
materialProof: command.resultKeyCatalogFence.materialProof,
|
||||
});
|
||||
return normalizeToolExecutionResultKeyBinding({
|
||||
...unsigned,
|
||||
bindingDigest: hash(RESULT_KEY_BINDING_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function toolExecutionCompletionRecord(
|
||||
commandValue: ToolExecutionCompletionCommand,
|
||||
): Readonly<ToolExecutionCompletionRecord> {
|
||||
const command = normalizeToolExecutionCompletionCommand(commandValue);
|
||||
return completionFromParts(
|
||||
command.barrier,
|
||||
command.executionResult,
|
||||
command.resultArtifact,
|
||||
command.stepRunMutation,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '../security/audit/securityAudit';
|
||||
|
||||
export const TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA =
|
||||
'qinglong/tool-execution-trace-anchor@v1' as const;
|
||||
export const TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA =
|
||||
'qinglong/tool-execution-audit-receipt@v1' as const;
|
||||
export const TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA =
|
||||
'qinglong/tool-execution-evidence-bundle@v1' as const;
|
||||
export const TOOL_EXECUTION_START_AUDIT_OPERATION =
|
||||
'tool.invoke.start' as const;
|
||||
|
||||
export const MAX_TOOL_EXECUTION_EVIDENCE_BYTES = 16 * 1024;
|
||||
export const MAX_TOOL_EXECUTION_EVIDENCE_PAGE_SIZE = 128;
|
||||
|
||||
export interface ToolExecutionTraceAnchor {
|
||||
readonly schema: typeof TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA;
|
||||
readonly traceId: string;
|
||||
readonly spanId: string;
|
||||
readonly parentSpanId: string | null;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly invocationPlanDigest: string;
|
||||
readonly bindingDigest: string;
|
||||
readonly adapterDigest: string;
|
||||
readonly redactionContractDigest: string;
|
||||
readonly auditContractDigest: string;
|
||||
readonly createdAtMs: number;
|
||||
readonly traceDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolExecutionAuditReceipt {
|
||||
readonly schema: typeof TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA;
|
||||
readonly eventId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly traceId: string;
|
||||
readonly spanId: string;
|
||||
readonly traceDigest: string;
|
||||
readonly invocationPlanDigest: string;
|
||||
readonly bindingDigest: string;
|
||||
readonly auditRecordDigest: string;
|
||||
readonly createdAtMs: number;
|
||||
readonly receiptDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolExecutionEvidenceBundle {
|
||||
readonly schema: typeof TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA;
|
||||
readonly trace: Readonly<ToolExecutionTraceAnchor>;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
readonly receipt: Readonly<ToolExecutionAuditReceipt>;
|
||||
}
|
||||
|
||||
export interface CreateToolExecutionEvidenceInput {
|
||||
readonly traceId: string;
|
||||
readonly spanId: string;
|
||||
readonly parentSpanId?: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly invocationPlanDigest: string;
|
||||
readonly bindingDigest: string;
|
||||
readonly adapterDigest: string;
|
||||
readonly redactionContractDigest: string;
|
||||
readonly auditContractDigest: string;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface ToolExecutionEvidenceCursor {
|
||||
readonly createdAtMs: number;
|
||||
readonly traceId: string;
|
||||
readonly spanId: string;
|
||||
}
|
||||
|
||||
export interface ListToolExecutionEvidenceQuery {
|
||||
readonly runId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: ToolExecutionEvidenceCursor;
|
||||
}
|
||||
|
||||
export interface ListToolExecutionEvidenceResult {
|
||||
readonly bundles: readonly Readonly<ToolExecutionEvidenceBundle>[];
|
||||
readonly truncated: boolean;
|
||||
readonly next?: Readonly<ToolExecutionEvidenceCursor>;
|
||||
}
|
||||
|
||||
export interface PrepareToolExecutionEvidenceResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly bundle: Readonly<ToolExecutionEvidenceBundle>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionEvidenceRepository {
|
||||
findByTrace(
|
||||
traceId: string,
|
||||
spanId: string,
|
||||
): Promise<Readonly<ToolExecutionEvidenceBundle> | null>;
|
||||
findByAuditEventId(
|
||||
eventId: string,
|
||||
): Promise<Readonly<ToolExecutionEvidenceBundle> | null>;
|
||||
listByRun(
|
||||
query: ListToolExecutionEvidenceQuery,
|
||||
): Promise<ListToolExecutionEvidenceResult>;
|
||||
prepare(
|
||||
bundle: ToolExecutionEvidenceBundle,
|
||||
): Promise<Readonly<PrepareToolExecutionEvidenceResult>>;
|
||||
}
|
||||
|
||||
export class InvalidToolExecutionEvidenceError extends TypeError {
|
||||
readonly code = 'TOOL_EXECUTION_EVIDENCE_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Tool execution evidence is invalid: ${message}`);
|
||||
this.name = 'InvalidToolExecutionEvidenceError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolExecutionEvidenceConflictError extends Error {
|
||||
readonly code = 'TOOL_EXECUTION_EVIDENCE_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Tool execution evidence identity is bound to different content');
|
||||
this.name = 'ToolExecutionEvidenceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolExecutionEvidenceUnavailableError extends Error {
|
||||
readonly code = 'TOOL_EXECUTION_EVIDENCE_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Tool execution evidence repository is unavailable', options);
|
||||
this.name = 'ToolExecutionEvidenceUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/;
|
||||
const SPAN_ID_PATTERN = /^[0-9a-f]{16}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const TRACE_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-trace-anchor-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const AUDIT_RECORD_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-audit-record-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const AUDIT_RECEIPT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-audit-receipt-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidToolExecutionEvidenceError(message);
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
return invalid(`${label} must be an object`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
return invalid(`${label} must contain enumerable data properties`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const keys = Object.keys(value).sort();
|
||||
const requiredKeys = [...required].sort();
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (
|
||||
requiredKeys.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function traceId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !TRACE_ID_PATTERN.test(value)) {
|
||||
return invalid('traceId is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function spanId(value: unknown, label = 'spanId'): string {
|
||||
if (typeof value !== 'string' || !SPAN_ID_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'string'
|
||||
) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isSafeInteger(value)) invalid('canonical value is invalid');
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
|
||||
}
|
||||
const record = dataRecord(value, 'canonical value');
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
||||
function hash(domain: Buffer, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(canonicalJson(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function traceUnsigned(
|
||||
value: Readonly<ToolExecutionTraceAnchor>,
|
||||
): Omit<ToolExecutionTraceAnchor, 'traceDigest'> {
|
||||
const {
|
||||
traceDigest: _traceDigest,
|
||||
...unsigned
|
||||
} = value;
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
function receiptUnsigned(
|
||||
value: Readonly<ToolExecutionAuditReceipt>,
|
||||
): Omit<ToolExecutionAuditReceipt, 'receiptDigest'> {
|
||||
const {
|
||||
receiptDigest: _receiptDigest,
|
||||
...unsigned
|
||||
} = value;
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
export function toolExecutionAuditRecordDigest(
|
||||
value: SecurityAuditRecord,
|
||||
): string {
|
||||
return hash(
|
||||
AUDIT_RECORD_DIGEST_DOMAIN,
|
||||
normalizeSecurityAuditRecord(value),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionTraceAnchor(
|
||||
value: ToolExecutionTraceAnchor,
|
||||
): Readonly<ToolExecutionTraceAnchor> {
|
||||
const record = dataRecord(value, 'trace anchor');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'adapterDigest',
|
||||
'auditContractDigest',
|
||||
'bindingDigest',
|
||||
'createdAtMs',
|
||||
'invocationPlanDigest',
|
||||
'parentSpanId',
|
||||
'projectId',
|
||||
'redactionContractDigest',
|
||||
'runId',
|
||||
'schema',
|
||||
'spanId',
|
||||
'stepRunId',
|
||||
'traceDigest',
|
||||
'traceId',
|
||||
],
|
||||
[],
|
||||
'trace anchor',
|
||||
);
|
||||
if (value.schema !== TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA) {
|
||||
return invalid('trace anchor schema is invalid');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA,
|
||||
traceId: traceId(value.traceId),
|
||||
spanId: spanId(value.spanId),
|
||||
parentSpanId:
|
||||
value.parentSpanId === null
|
||||
? null
|
||||
: spanId(value.parentSpanId, 'parentSpanId'),
|
||||
projectId: identifier(value.projectId, 'projectId'),
|
||||
runId: identifier(value.runId, 'runId'),
|
||||
stepRunId: identifier(value.stepRunId, 'stepRunId'),
|
||||
invocationPlanDigest: digest(
|
||||
value.invocationPlanDigest,
|
||||
'invocationPlanDigest',
|
||||
),
|
||||
bindingDigest: digest(value.bindingDigest, 'bindingDigest'),
|
||||
adapterDigest: digest(value.adapterDigest, 'adapterDigest'),
|
||||
redactionContractDigest: digest(
|
||||
value.redactionContractDigest,
|
||||
'redactionContractDigest',
|
||||
),
|
||||
auditContractDigest: digest(
|
||||
value.auditContractDigest,
|
||||
'auditContractDigest',
|
||||
),
|
||||
createdAtMs: timestamp(value.createdAtMs, 'createdAtMs'),
|
||||
traceDigest: digest(value.traceDigest, 'traceDigest'),
|
||||
});
|
||||
if (
|
||||
normalized.parentSpanId === normalized.spanId ||
|
||||
hash(TRACE_DIGEST_DOMAIN, traceUnsigned(normalized)) !==
|
||||
normalized.traceDigest ||
|
||||
Buffer.byteLength(canonicalJson(normalized), 'utf8') >
|
||||
MAX_TOOL_EXECUTION_EVIDENCE_BYTES
|
||||
) {
|
||||
return invalid('trace anchor semantic digest is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionAuditReceipt(
|
||||
value: ToolExecutionAuditReceipt,
|
||||
): Readonly<ToolExecutionAuditReceipt> {
|
||||
const record = dataRecord(value, 'audit receipt');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'auditRecordDigest',
|
||||
'bindingDigest',
|
||||
'createdAtMs',
|
||||
'eventId',
|
||||
'invocationPlanDigest',
|
||||
'projectId',
|
||||
'receiptDigest',
|
||||
'runId',
|
||||
'schema',
|
||||
'spanId',
|
||||
'stepRunId',
|
||||
'traceDigest',
|
||||
'traceId',
|
||||
],
|
||||
[],
|
||||
'audit receipt',
|
||||
);
|
||||
if (value.schema !== TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA) {
|
||||
return invalid('audit receipt schema is invalid');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA,
|
||||
eventId: identifier(value.eventId, 'eventId'),
|
||||
projectId: identifier(value.projectId, 'projectId'),
|
||||
runId: identifier(value.runId, 'runId'),
|
||||
stepRunId: identifier(value.stepRunId, 'stepRunId'),
|
||||
traceId: traceId(value.traceId),
|
||||
spanId: spanId(value.spanId),
|
||||
traceDigest: digest(value.traceDigest, 'traceDigest'),
|
||||
invocationPlanDigest: digest(
|
||||
value.invocationPlanDigest,
|
||||
'invocationPlanDigest',
|
||||
),
|
||||
bindingDigest: digest(value.bindingDigest, 'bindingDigest'),
|
||||
auditRecordDigest: digest(
|
||||
value.auditRecordDigest,
|
||||
'auditRecordDigest',
|
||||
),
|
||||
createdAtMs: timestamp(value.createdAtMs, 'createdAtMs'),
|
||||
receiptDigest: digest(value.receiptDigest, 'receiptDigest'),
|
||||
});
|
||||
if (
|
||||
hash(AUDIT_RECEIPT_DIGEST_DOMAIN, receiptUnsigned(normalized)) !==
|
||||
normalized.receiptDigest ||
|
||||
Buffer.byteLength(canonicalJson(normalized), 'utf8') >
|
||||
MAX_TOOL_EXECUTION_EVIDENCE_BYTES
|
||||
) {
|
||||
return invalid('audit receipt semantic digest is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionEvidenceBundle(
|
||||
value: ToolExecutionEvidenceBundle,
|
||||
): Readonly<ToolExecutionEvidenceBundle> {
|
||||
const record = dataRecord(value, 'evidence bundle');
|
||||
exactKeys(record, ['audit', 'receipt', 'schema', 'trace'], [], 'evidence bundle');
|
||||
if (value.schema !== TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA) {
|
||||
return invalid('evidence bundle schema is invalid');
|
||||
}
|
||||
let audit: Readonly<SecurityAuditRecord>;
|
||||
try {
|
||||
audit = normalizeSecurityAuditRecord(value.audit);
|
||||
} catch {
|
||||
return invalid('security audit record is invalid');
|
||||
}
|
||||
const trace = normalizeToolExecutionTraceAnchor(value.trace);
|
||||
const receipt = normalizeToolExecutionAuditReceipt(value.receipt);
|
||||
if (
|
||||
audit.operationId !== TOOL_EXECUTION_START_AUDIT_OPERATION ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
audit.projectId !== trace.projectId ||
|
||||
audit.fence === null ||
|
||||
audit.occurredAtMs !== trace.createdAtMs ||
|
||||
receipt.eventId !== audit.eventId ||
|
||||
receipt.projectId !== trace.projectId ||
|
||||
receipt.runId !== trace.runId ||
|
||||
receipt.stepRunId !== trace.stepRunId ||
|
||||
receipt.traceId !== trace.traceId ||
|
||||
receipt.spanId !== trace.spanId ||
|
||||
receipt.traceDigest !== trace.traceDigest ||
|
||||
receipt.invocationPlanDigest !== trace.invocationPlanDigest ||
|
||||
receipt.bindingDigest !== trace.bindingDigest ||
|
||||
receipt.auditRecordDigest !== toolExecutionAuditRecordDigest(audit) ||
|
||||
receipt.createdAtMs !== trace.createdAtMs
|
||||
) {
|
||||
return invalid('evidence bundle bindings are inconsistent');
|
||||
}
|
||||
const bundle = Object.freeze({
|
||||
schema: TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA,
|
||||
trace,
|
||||
audit,
|
||||
receipt,
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(canonicalJson(bundle), 'utf8') >
|
||||
MAX_TOOL_EXECUTION_EVIDENCE_BYTES
|
||||
) {
|
||||
return invalid('evidence bundle exceeds the byte limit');
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
export function createToolExecutionEvidenceBundle(
|
||||
inputValue: CreateToolExecutionEvidenceInput,
|
||||
): Readonly<ToolExecutionEvidenceBundle> {
|
||||
const input = dataRecord(inputValue, 'create input');
|
||||
exactKeys(
|
||||
input,
|
||||
[
|
||||
'adapterDigest',
|
||||
'audit',
|
||||
'auditContractDigest',
|
||||
'bindingDigest',
|
||||
'createdAtMs',
|
||||
'invocationPlanDigest',
|
||||
'projectId',
|
||||
'redactionContractDigest',
|
||||
'runId',
|
||||
'spanId',
|
||||
'stepRunId',
|
||||
'traceId',
|
||||
],
|
||||
['parentSpanId'],
|
||||
'create input',
|
||||
);
|
||||
let audit: Readonly<SecurityAuditRecord>;
|
||||
try {
|
||||
audit = normalizeSecurityAuditRecord(inputValue.audit);
|
||||
} catch {
|
||||
return invalid('security audit record is invalid');
|
||||
}
|
||||
const traceWithoutDigest = Object.freeze({
|
||||
schema: TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA,
|
||||
traceId: traceId(inputValue.traceId),
|
||||
spanId: spanId(inputValue.spanId),
|
||||
parentSpanId:
|
||||
inputValue.parentSpanId === undefined
|
||||
? null
|
||||
: spanId(inputValue.parentSpanId, 'parentSpanId'),
|
||||
projectId: identifier(inputValue.projectId, 'projectId'),
|
||||
runId: identifier(inputValue.runId, 'runId'),
|
||||
stepRunId: identifier(inputValue.stepRunId, 'stepRunId'),
|
||||
invocationPlanDigest: digest(
|
||||
inputValue.invocationPlanDigest,
|
||||
'invocationPlanDigest',
|
||||
),
|
||||
bindingDigest: digest(inputValue.bindingDigest, 'bindingDigest'),
|
||||
adapterDigest: digest(inputValue.adapterDigest, 'adapterDigest'),
|
||||
redactionContractDigest: digest(
|
||||
inputValue.redactionContractDigest,
|
||||
'redactionContractDigest',
|
||||
),
|
||||
auditContractDigest: digest(
|
||||
inputValue.auditContractDigest,
|
||||
'auditContractDigest',
|
||||
),
|
||||
createdAtMs: timestamp(inputValue.createdAtMs, 'createdAtMs'),
|
||||
});
|
||||
const trace = normalizeToolExecutionTraceAnchor({
|
||||
...traceWithoutDigest,
|
||||
traceDigest: hash(TRACE_DIGEST_DOMAIN, traceWithoutDigest),
|
||||
});
|
||||
const receiptWithoutDigest = Object.freeze({
|
||||
schema: TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA,
|
||||
eventId: audit.eventId,
|
||||
projectId: trace.projectId,
|
||||
runId: trace.runId,
|
||||
stepRunId: trace.stepRunId,
|
||||
traceId: trace.traceId,
|
||||
spanId: trace.spanId,
|
||||
traceDigest: trace.traceDigest,
|
||||
invocationPlanDigest: trace.invocationPlanDigest,
|
||||
bindingDigest: trace.bindingDigest,
|
||||
auditRecordDigest: toolExecutionAuditRecordDigest(audit),
|
||||
createdAtMs: trace.createdAtMs,
|
||||
});
|
||||
const receipt = normalizeToolExecutionAuditReceipt({
|
||||
...receiptWithoutDigest,
|
||||
receiptDigest: hash(
|
||||
AUDIT_RECEIPT_DIGEST_DOMAIN,
|
||||
receiptWithoutDigest,
|
||||
),
|
||||
});
|
||||
return normalizeToolExecutionEvidenceBundle({
|
||||
schema: TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA,
|
||||
trace,
|
||||
audit,
|
||||
receipt,
|
||||
});
|
||||
}
|
||||
|
||||
export function toolExecutionAdmissionEvidence(
|
||||
value: ToolExecutionEvidenceBundle,
|
||||
): Readonly<{
|
||||
trace: Readonly<{ traceId: string; spanId: string; digest: string }>;
|
||||
audit: Readonly<{ eventId: string; digest: string }>;
|
||||
}> {
|
||||
const bundle = normalizeToolExecutionEvidenceBundle(value);
|
||||
return Object.freeze({
|
||||
trace: Object.freeze({
|
||||
traceId: bundle.trace.traceId,
|
||||
spanId: bundle.trace.spanId,
|
||||
digest: bundle.trace.traceDigest,
|
||||
}),
|
||||
audit: Object.freeze({
|
||||
eventId: bundle.receipt.eventId,
|
||||
digest: bundle.receipt.receiptDigest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeListToolExecutionEvidenceQuery(
|
||||
value: ListToolExecutionEvidenceQuery,
|
||||
): Readonly<ListToolExecutionEvidenceQuery> {
|
||||
const query = dataRecord(value, 'list query');
|
||||
exactKeys(query, ['limit', 'runId'], ['after'], 'list query');
|
||||
const limit = value.limit;
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_TOOL_EXECUTION_EVIDENCE_PAGE_SIZE
|
||||
) {
|
||||
return invalid('list limit is invalid');
|
||||
}
|
||||
let after: Readonly<ToolExecutionEvidenceCursor> | undefined;
|
||||
if (value.after !== undefined) {
|
||||
const cursor = dataRecord(value.after, 'list cursor');
|
||||
exactKeys(
|
||||
cursor,
|
||||
['createdAtMs', 'spanId', 'traceId'],
|
||||
[],
|
||||
'list cursor',
|
||||
);
|
||||
after = Object.freeze({
|
||||
createdAtMs: timestamp(value.after.createdAtMs, 'cursor createdAtMs'),
|
||||
traceId: traceId(value.after.traceId),
|
||||
spanId: spanId(value.after.spanId),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
runId: identifier(value.runId, 'runId'),
|
||||
limit,
|
||||
...(after ? { after } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeListToolExecutionEvidenceResult(
|
||||
value: ListToolExecutionEvidenceResult,
|
||||
queryValue: ListToolExecutionEvidenceQuery,
|
||||
): Readonly<ListToolExecutionEvidenceResult> {
|
||||
const query = normalizeListToolExecutionEvidenceQuery(queryValue);
|
||||
const result = dataRecord(value, 'list result');
|
||||
exactKeys(result, ['bundles', 'truncated'], ['next'], 'list result');
|
||||
if (
|
||||
!Array.isArray(value.bundles) ||
|
||||
value.bundles.length > query.limit ||
|
||||
typeof value.truncated !== 'boolean'
|
||||
) {
|
||||
return invalid('list result is invalid');
|
||||
}
|
||||
const bundles = value.bundles.map(normalizeToolExecutionEvidenceBundle);
|
||||
if (
|
||||
bundles.some((bundle) => bundle.trace.runId !== query.runId) ||
|
||||
bundles.some(
|
||||
(bundle, index) =>
|
||||
index > 0 &&
|
||||
(bundle.trace.createdAtMs < bundles[index - 1]!.trace.createdAtMs ||
|
||||
(bundle.trace.createdAtMs ===
|
||||
bundles[index - 1]!.trace.createdAtMs &&
|
||||
`${bundle.trace.traceId}:${bundle.trace.spanId}` <=
|
||||
`${bundles[index - 1]!.trace.traceId}:${
|
||||
bundles[index - 1]!.trace.spanId
|
||||
}`)),
|
||||
)
|
||||
) {
|
||||
return invalid('list result ordering is invalid');
|
||||
}
|
||||
const last = bundles.at(-1);
|
||||
const expectedNext =
|
||||
value.truncated && last
|
||||
? Object.freeze({
|
||||
createdAtMs: last.trace.createdAtMs,
|
||||
traceId: last.trace.traceId,
|
||||
spanId: last.trace.spanId,
|
||||
})
|
||||
: undefined;
|
||||
if (
|
||||
(expectedNext === undefined) !== (value.next === undefined) ||
|
||||
(expectedNext &&
|
||||
(value.next!.createdAtMs !== expectedNext.createdAtMs ||
|
||||
value.next!.traceId !== expectedNext.traceId ||
|
||||
value.next!.spanId !== expectedNext.spanId))
|
||||
) {
|
||||
return invalid('list continuation is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
bundles: Object.freeze(bundles),
|
||||
truncated: value.truncated,
|
||||
...(expectedNext ? { next: expectedNext } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { normalizeStepRunMutation, type StepRunMutation } from '../run/stepRun';
|
||||
import {
|
||||
normalizeToolExecutionStartBarrierRecord,
|
||||
type ToolExecutionStartBarrierRecord,
|
||||
} from './toolExecutionStartBarrier';
|
||||
|
||||
export const TOOL_EXECUTION_FAILURE_RESULT_SCHEMA =
|
||||
'qinglong/tool-execution-failure-result@v1' as const;
|
||||
export const TOOL_EXECUTION_FAILURE_COMPLETION_SCHEMA =
|
||||
'qinglong/tool-execution-failure-completion@v1' as const;
|
||||
export const TOOL_EXECUTION_FAILURE_COMPLETION_COMMAND_SCHEMA =
|
||||
'qinglong/tool-execution-failure-completion-command@v1' as const;
|
||||
export const MAX_TOOL_EXECUTION_FAILURE_COMPLETION_JSON_BYTES = 24 * 1024;
|
||||
|
||||
export const TOOL_EXECUTION_FAILURE_OUTCOMES = ['failed', 'timed_out'] as const;
|
||||
|
||||
export type ToolExecutionFailureOutcome =
|
||||
(typeof TOOL_EXECUTION_FAILURE_OUTCOMES)[number];
|
||||
|
||||
export const TOOL_EXECUTION_FAILURE_FACTS = Object.freeze({
|
||||
failed: Object.freeze({
|
||||
resultCode: 'tool_adapter_failed',
|
||||
errorSummary: 'Trusted Tool execution failed',
|
||||
}),
|
||||
timed_out: Object.freeze({
|
||||
resultCode: 'tool_deadline_exceeded',
|
||||
errorSummary: 'Trusted Tool execution deadline exceeded',
|
||||
}),
|
||||
} satisfies Record<ToolExecutionFailureOutcome, Readonly<{ resultCode: string; errorSummary: string }>>);
|
||||
|
||||
export interface ToolExecutionFailureResult {
|
||||
readonly schema: typeof TOOL_EXECUTION_FAILURE_RESULT_SCHEMA;
|
||||
readonly startId: string;
|
||||
readonly barrierDigest: string;
|
||||
readonly adapterDigest: string;
|
||||
readonly outcome: ToolExecutionFailureOutcome;
|
||||
readonly resultCode: string;
|
||||
readonly errorSummary: string;
|
||||
readonly completedAtMs: number;
|
||||
readonly failureDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolExecutionFailureCompletionRecord {
|
||||
readonly schema: typeof TOOL_EXECUTION_FAILURE_COMPLETION_SCHEMA;
|
||||
readonly startId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly startedStepRunVersion: number;
|
||||
readonly completedStepRunVersion: number;
|
||||
readonly barrierDigest: string;
|
||||
readonly adapterDigest: string;
|
||||
readonly outcome: ToolExecutionFailureOutcome;
|
||||
readonly resultCode: string;
|
||||
readonly errorSummary: string;
|
||||
readonly stepRunMutationId: string;
|
||||
readonly stepRunMutationDigest: string;
|
||||
readonly completedStepRunDigest: string;
|
||||
readonly runEventId: string;
|
||||
readonly completedAtMs: number;
|
||||
readonly completionDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolExecutionFailureCompletionCommand {
|
||||
readonly schema: typeof TOOL_EXECUTION_FAILURE_COMPLETION_COMMAND_SCHEMA;
|
||||
readonly barrier: Readonly<ToolExecutionStartBarrierRecord>;
|
||||
readonly failure: Readonly<ToolExecutionFailureResult>;
|
||||
readonly stepRunMutation: Readonly<StepRunMutation>;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface CommitToolExecutionFailureCompletionResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly completion: Readonly<ToolExecutionFailureCompletionRecord>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionFailureCompletionRepository {
|
||||
findByStartId(
|
||||
startId: string,
|
||||
): Promise<Readonly<ToolExecutionFailureCompletionRecord> | null>;
|
||||
commit(
|
||||
command: ToolExecutionFailureCompletionCommand,
|
||||
): Promise<Readonly<CommitToolExecutionFailureCompletionResult>>;
|
||||
}
|
||||
|
||||
export class InvalidToolExecutionFailureCompletionError extends TypeError {
|
||||
readonly code = 'TOOL_EXECUTION_FAILURE_COMPLETION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Tool execution failure completion is invalid: ${message}`);
|
||||
this.name = 'InvalidToolExecutionFailureCompletionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolExecutionFailureCompletionConflictError extends Error {
|
||||
readonly code = 'TOOL_EXECUTION_FAILURE_COMPLETION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Tool execution failure completion conflicts with durable state');
|
||||
this.name = 'ToolExecutionFailureCompletionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolExecutionFailureCompletionUnavailableError extends Error {
|
||||
readonly code = 'TOOL_EXECUTION_FAILURE_COMPLETION_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super(
|
||||
'Tool execution failure completion authority is unavailable',
|
||||
options,
|
||||
);
|
||||
this.name = 'ToolExecutionFailureCompletionUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const FAILURE_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-failure-result-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const COMPLETION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-failure-completion-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const COMMAND_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-failure-completion-command-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidToolExecutionFailureCompletionError(message);
|
||||
}
|
||||
|
||||
function hash(domain: Uint8Array, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
return invalid(`${label} is not a plain object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function version(value: unknown, label: string): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 2 ||
|
||||
(value as number) > 2_147_483_647
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function outcome(value: unknown): ToolExecutionFailureOutcome {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!TOOL_EXECUTION_FAILURE_OUTCOMES.includes(
|
||||
value as ToolExecutionFailureOutcome,
|
||||
)
|
||||
) {
|
||||
return invalid('failure outcome is invalid');
|
||||
}
|
||||
return value as ToolExecutionFailureOutcome;
|
||||
}
|
||||
|
||||
function exactFailureFacts(
|
||||
value: Readonly<{
|
||||
outcome: ToolExecutionFailureOutcome;
|
||||
resultCode: unknown;
|
||||
errorSummary: unknown;
|
||||
}>,
|
||||
): Readonly<{ resultCode: string; errorSummary: string }> {
|
||||
const expected = TOOL_EXECUTION_FAILURE_FACTS[value.outcome];
|
||||
if (
|
||||
value.resultCode !== expected.resultCode ||
|
||||
value.errorSummary !== expected.errorSummary
|
||||
) {
|
||||
return invalid('failure facts are invalid');
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionFailureResult(
|
||||
value: ToolExecutionFailureResult,
|
||||
): Readonly<ToolExecutionFailureResult> {
|
||||
const candidate = record(value, 'failure result');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'adapterDigest',
|
||||
'barrierDigest',
|
||||
'completedAtMs',
|
||||
'errorSummary',
|
||||
'failureDigest',
|
||||
'outcome',
|
||||
'resultCode',
|
||||
'schema',
|
||||
'startId',
|
||||
],
|
||||
'failure result',
|
||||
);
|
||||
if (value.schema !== TOOL_EXECUTION_FAILURE_RESULT_SCHEMA) {
|
||||
return invalid('failure result schema is invalid');
|
||||
}
|
||||
const normalizedOutcome = outcome(value.outcome);
|
||||
const facts = exactFailureFacts({
|
||||
outcome: normalizedOutcome,
|
||||
resultCode: value.resultCode,
|
||||
errorSummary: value.errorSummary,
|
||||
});
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_FAILURE_RESULT_SCHEMA,
|
||||
startId: identity(value.startId, 'failure start id'),
|
||||
barrierDigest: digest(value.barrierDigest, 'failure barrier digest'),
|
||||
adapterDigest: digest(value.adapterDigest, 'failure adapter digest'),
|
||||
outcome: normalizedOutcome,
|
||||
resultCode: facts.resultCode,
|
||||
errorSummary: facts.errorSummary,
|
||||
completedAtMs: timestamp(value.completedAtMs, 'failure completion time'),
|
||||
});
|
||||
const failureDigest = digest(value.failureDigest, 'failure digest');
|
||||
if (hash(FAILURE_DIGEST_DOMAIN, unsigned) !== failureDigest) {
|
||||
return invalid('failure digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, failureDigest });
|
||||
}
|
||||
|
||||
export function createToolExecutionFailureResult(
|
||||
barrierValue: ToolExecutionStartBarrierRecord,
|
||||
failureOutcome: ToolExecutionFailureOutcome,
|
||||
completedAtMs: number,
|
||||
): Readonly<ToolExecutionFailureResult> {
|
||||
const barrier = normalizeToolExecutionStartBarrierRecord(barrierValue);
|
||||
const normalizedOutcome = outcome(failureOutcome);
|
||||
const facts = TOOL_EXECUTION_FAILURE_FACTS[normalizedOutcome];
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_FAILURE_RESULT_SCHEMA,
|
||||
startId: barrier.startId,
|
||||
barrierDigest: barrier.barrierDigest,
|
||||
adapterDigest: barrier.adapterDigest,
|
||||
outcome: normalizedOutcome,
|
||||
resultCode: facts.resultCode,
|
||||
errorSummary: facts.errorSummary,
|
||||
completedAtMs: timestamp(completedAtMs, 'failure completion time'),
|
||||
});
|
||||
if (unsigned.completedAtMs < barrier.startedAtMs) {
|
||||
return invalid('failure completion precedes durable start');
|
||||
}
|
||||
return normalizeToolExecutionFailureResult({
|
||||
...unsigned,
|
||||
failureDigest: hash(FAILURE_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionFailureCompletionRecord(
|
||||
value: ToolExecutionFailureCompletionRecord,
|
||||
): Readonly<ToolExecutionFailureCompletionRecord> {
|
||||
const candidate = record(value, 'failure completion');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'adapterDigest',
|
||||
'barrierDigest',
|
||||
'completedAtMs',
|
||||
'completedStepRunDigest',
|
||||
'completedStepRunVersion',
|
||||
'completionDigest',
|
||||
'errorSummary',
|
||||
'outcome',
|
||||
'projectId',
|
||||
'resultCode',
|
||||
'runEventId',
|
||||
'runId',
|
||||
'schema',
|
||||
'startId',
|
||||
'startedStepRunVersion',
|
||||
'stepRunId',
|
||||
'stepRunMutationDigest',
|
||||
'stepRunMutationId',
|
||||
],
|
||||
'failure completion',
|
||||
);
|
||||
if (value.schema !== TOOL_EXECUTION_FAILURE_COMPLETION_SCHEMA) {
|
||||
return invalid('failure completion schema is invalid');
|
||||
}
|
||||
const normalizedOutcome = outcome(value.outcome);
|
||||
const facts = exactFailureFacts({
|
||||
outcome: normalizedOutcome,
|
||||
resultCode: value.resultCode,
|
||||
errorSummary: value.errorSummary,
|
||||
});
|
||||
const startedStepRunVersion = version(
|
||||
value.startedStepRunVersion,
|
||||
'started StepRun version',
|
||||
);
|
||||
const completedStepRunVersion = version(
|
||||
value.completedStepRunVersion,
|
||||
'completed StepRun version',
|
||||
);
|
||||
if (completedStepRunVersion !== startedStepRunVersion + 1) {
|
||||
return invalid('failure completion StepRun version fence is invalid');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_FAILURE_COMPLETION_SCHEMA,
|
||||
startId: identity(value.startId, 'failure completion start id'),
|
||||
projectId: identity(value.projectId, 'failure completion project id'),
|
||||
runId: identity(value.runId, 'failure completion Run id'),
|
||||
stepRunId: identity(value.stepRunId, 'failure completion StepRun id'),
|
||||
startedStepRunVersion,
|
||||
completedStepRunVersion,
|
||||
barrierDigest: digest(
|
||||
value.barrierDigest,
|
||||
'failure completion barrier digest',
|
||||
),
|
||||
adapterDigest: digest(
|
||||
value.adapterDigest,
|
||||
'failure completion adapter digest',
|
||||
),
|
||||
outcome: normalizedOutcome,
|
||||
resultCode: facts.resultCode,
|
||||
errorSummary: facts.errorSummary,
|
||||
stepRunMutationId: identity(
|
||||
value.stepRunMutationId,
|
||||
'failure completion mutation id',
|
||||
),
|
||||
stepRunMutationDigest: digest(
|
||||
value.stepRunMutationDigest,
|
||||
'failure completion mutation digest',
|
||||
),
|
||||
completedStepRunDigest: digest(
|
||||
value.completedStepRunDigest,
|
||||
'failed StepRun digest',
|
||||
),
|
||||
runEventId: identity(value.runEventId, 'failure completion Run event id'),
|
||||
completedAtMs: timestamp(value.completedAtMs, 'failure completion time'),
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(unsigned), 'utf8') >
|
||||
MAX_TOOL_EXECUTION_FAILURE_COMPLETION_JSON_BYTES
|
||||
) {
|
||||
return invalid('failure completion exceeds its budget');
|
||||
}
|
||||
const completionDigest = digest(
|
||||
value.completionDigest,
|
||||
'failure completion digest',
|
||||
);
|
||||
if (hash(COMPLETION_DIGEST_DOMAIN, unsigned) !== completionDigest) {
|
||||
return invalid('failure completion digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, completionDigest });
|
||||
}
|
||||
|
||||
function completionFromParts(
|
||||
barrier: Readonly<ToolExecutionStartBarrierRecord>,
|
||||
failure: Readonly<ToolExecutionFailureResult>,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): Readonly<ToolExecutionFailureCompletionRecord> {
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_FAILURE_COMPLETION_SCHEMA,
|
||||
startId: barrier.startId,
|
||||
projectId: barrier.projectId,
|
||||
runId: barrier.runId,
|
||||
stepRunId: barrier.stepRunId,
|
||||
startedStepRunVersion: barrier.startedStepRunVersion,
|
||||
completedStepRunVersion: mutation.stepRun.version,
|
||||
barrierDigest: barrier.barrierDigest,
|
||||
adapterDigest: barrier.adapterDigest,
|
||||
outcome: failure.outcome,
|
||||
resultCode: failure.resultCode,
|
||||
errorSummary: failure.errorSummary,
|
||||
stepRunMutationId: mutation.mutationId,
|
||||
stepRunMutationDigest: mutation.mutationDigest,
|
||||
completedStepRunDigest: mutation.stepRun.stepRunDigest,
|
||||
runEventId: mutation.event.id,
|
||||
completedAtMs: failure.completedAtMs,
|
||||
});
|
||||
return normalizeToolExecutionFailureCompletionRecord({
|
||||
...unsigned,
|
||||
completionDigest: hash(COMPLETION_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionFailureCompletionCommand(
|
||||
value: ToolExecutionFailureCompletionCommand,
|
||||
): Readonly<ToolExecutionFailureCompletionCommand> {
|
||||
const candidate = record(value, 'failure completion command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
['barrier', 'commandDigest', 'failure', 'schema', 'stepRunMutation'],
|
||||
'failure completion command',
|
||||
);
|
||||
if (value.schema !== TOOL_EXECUTION_FAILURE_COMPLETION_COMMAND_SCHEMA) {
|
||||
return invalid('failure completion command schema is invalid');
|
||||
}
|
||||
const barrier = normalizeToolExecutionStartBarrierRecord(value.barrier);
|
||||
const failure = normalizeToolExecutionFailureResult(value.failure);
|
||||
const stepRunMutation = normalizeStepRunMutation(value.stepRunMutation);
|
||||
if (
|
||||
failure.startId !== barrier.startId ||
|
||||
failure.barrierDigest !== barrier.barrierDigest ||
|
||||
failure.adapterDigest !== barrier.adapterDigest ||
|
||||
failure.completedAtMs < barrier.startedAtMs ||
|
||||
stepRunMutation.runId !== barrier.runId ||
|
||||
stepRunMutation.stepRun.id !== barrier.stepRunId ||
|
||||
stepRunMutation.stepRun.kind !== 'tool' ||
|
||||
stepRunMutation.previousStatus !== 'running' ||
|
||||
stepRunMutation.expectedStepRunVersion !== barrier.startedStepRunVersion ||
|
||||
stepRunMutation.expectedStepRunDigest !== barrier.startedStepRunDigest ||
|
||||
stepRunMutation.stepRun.status !== failure.outcome ||
|
||||
stepRunMutation.stepRun.outputRef !== null ||
|
||||
stepRunMutation.stepRun.resultCode !== failure.resultCode ||
|
||||
stepRunMutation.stepRun.errorSummary !== failure.errorSummary ||
|
||||
stepRunMutation.stepRun.finishedAtMs !== failure.completedAtMs ||
|
||||
stepRunMutation.stepRun.updatedAtMs !== failure.completedAtMs
|
||||
) {
|
||||
throw new ToolExecutionFailureCompletionConflictError();
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_FAILURE_COMPLETION_COMMAND_SCHEMA,
|
||||
barrier,
|
||||
failure,
|
||||
stepRunMutation,
|
||||
});
|
||||
const commandDigest = digest(
|
||||
value.commandDigest,
|
||||
'failure completion command digest',
|
||||
);
|
||||
if (hash(COMMAND_DIGEST_DOMAIN, unsigned) !== commandDigest) {
|
||||
return invalid('failure completion command digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, commandDigest });
|
||||
}
|
||||
|
||||
export function createToolExecutionFailureCompletionCommand(
|
||||
value: Omit<
|
||||
ToolExecutionFailureCompletionCommand,
|
||||
'commandDigest' | 'schema'
|
||||
>,
|
||||
): Readonly<ToolExecutionFailureCompletionCommand> {
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_FAILURE_COMPLETION_COMMAND_SCHEMA,
|
||||
barrier: normalizeToolExecutionStartBarrierRecord(value.barrier),
|
||||
failure: normalizeToolExecutionFailureResult(value.failure),
|
||||
stepRunMutation: normalizeStepRunMutation(value.stepRunMutation),
|
||||
});
|
||||
return normalizeToolExecutionFailureCompletionCommand({
|
||||
...unsigned,
|
||||
commandDigest: hash(COMMAND_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function toolExecutionFailureCompletionRecord(
|
||||
commandValue: ToolExecutionFailureCompletionCommand,
|
||||
): Readonly<ToolExecutionFailureCompletionRecord> {
|
||||
const command = normalizeToolExecutionFailureCompletionCommand(commandValue);
|
||||
return completionFromParts(
|
||||
command.barrier,
|
||||
command.failure,
|
||||
command.stepRunMutation,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
normalizeProjectPolicySubject,
|
||||
} from '../security/project-policy/projectPolicy';
|
||||
import type { SecuritySubject } from '../security/security';
|
||||
import {
|
||||
normalizeStepRunMutation,
|
||||
type StepRunMutation,
|
||||
} from '../run/stepRun';
|
||||
import {
|
||||
normalizeToolExecutionEvidenceBundle,
|
||||
type ToolExecutionEvidenceBundle,
|
||||
} from './toolExecutionEvidence';
|
||||
import {
|
||||
normalizeToolInvocationInputArtifactReference,
|
||||
normalizeToolInvocationPreviewArtifactReference,
|
||||
type ToolInvocationInputArtifactReference,
|
||||
type ToolInvocationPreviewArtifactReference,
|
||||
} from './toolInvocationArtifact';
|
||||
import {
|
||||
TRUSTED_TOOL_DEPLOYMENT_PROFILES,
|
||||
TRUSTED_TOOL_EXECUTION_CLASSES,
|
||||
normalizeTrustedToolExecutionAdmission,
|
||||
trustedToolContractIdentityDigest,
|
||||
type TrustedToolContractIdentity,
|
||||
type TrustedToolExecutionAdmission,
|
||||
type TrustedToolExecutionClass,
|
||||
} from './trustedToolInvocation';
|
||||
import type { DeploymentProfile } from '../cluster-control/clusterControlActivation';
|
||||
import type { SecurityPolicyFence } from '../security/security';
|
||||
|
||||
export const TOOL_EXECUTION_START_COMMAND_SCHEMA =
|
||||
'qinglong/tool-execution-start-command@v1' as const;
|
||||
export const TOOL_EXECUTION_START_BARRIER_SCHEMA =
|
||||
'qinglong/tool-execution-start-barrier@v1' as const;
|
||||
export const MAX_TOOL_EXECUTION_START_COMMAND_BYTES = 64 * 1024;
|
||||
export const MAX_TOOL_EXECUTION_START_BARRIER_BYTES = 16 * 1024;
|
||||
|
||||
export interface CreateToolExecutionStartCommandInput {
|
||||
readonly startId: string;
|
||||
readonly admission: TrustedToolExecutionAdmission;
|
||||
readonly evidence: ToolExecutionEvidenceBundle;
|
||||
readonly stepRunMutation: StepRunMutation;
|
||||
}
|
||||
|
||||
export interface ToolExecutionStartCommand {
|
||||
readonly schema: typeof TOOL_EXECUTION_START_COMMAND_SCHEMA;
|
||||
readonly startId: string;
|
||||
readonly admission: Readonly<TrustedToolExecutionAdmission>;
|
||||
readonly evidence: Readonly<ToolExecutionEvidenceBundle>;
|
||||
readonly stepRunMutation: Readonly<StepRunMutation>;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolExecutionStartBarrierRecord {
|
||||
readonly schema: typeof TOOL_EXECUTION_START_BARRIER_SCHEMA;
|
||||
readonly startId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly actionRef: string;
|
||||
readonly planDigest: string;
|
||||
readonly actionDigest: string;
|
||||
readonly snapshotDigest: string;
|
||||
readonly definitionDigest: string;
|
||||
readonly bindingDigest: string;
|
||||
readonly admissionDigest: string;
|
||||
readonly invocationArtifact: Readonly<ToolInvocationInputArtifactReference>;
|
||||
readonly previewArtifact: Readonly<ToolInvocationPreviewArtifactReference>;
|
||||
readonly requestedBy: Readonly<SecuritySubject>;
|
||||
readonly profile: DeploymentProfile;
|
||||
readonly adapter: Readonly<TrustedToolContractIdentity>;
|
||||
readonly adapterDigest: string;
|
||||
readonly redactionContract: Readonly<TrustedToolContractIdentity>;
|
||||
readonly redactionContractDigest: string;
|
||||
readonly auditContract: Readonly<TrustedToolContractIdentity>;
|
||||
readonly auditContractDigest: string;
|
||||
readonly executionClass: TrustedToolExecutionClass;
|
||||
readonly timeoutSeconds: number;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence>;
|
||||
readonly approvalRequestId: string | null;
|
||||
readonly approvalDispatchId: string | null;
|
||||
readonly approvalDispatchDigest: string | null;
|
||||
readonly previousStepRunVersion: number;
|
||||
readonly previousStepRunDigest: string;
|
||||
readonly startedStepRunVersion: number;
|
||||
readonly startedStepRunDigest: string;
|
||||
readonly stepRunMutationId: string;
|
||||
readonly stepRunMutationDigest: string;
|
||||
readonly runEventId: string;
|
||||
readonly traceId: string;
|
||||
readonly spanId: string;
|
||||
readonly traceDigest: string;
|
||||
readonly auditEventId: string;
|
||||
readonly auditReceiptDigest: string;
|
||||
readonly startedAtMs: number;
|
||||
readonly commandDigest: string;
|
||||
readonly barrierDigest: string;
|
||||
}
|
||||
|
||||
export interface PrepareToolExecutionStartResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly barrier: Readonly<ToolExecutionStartBarrierRecord>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionStartBarrierRepository {
|
||||
findByStartId(
|
||||
startId: string,
|
||||
): Promise<Readonly<ToolExecutionStartBarrierRecord> | null>;
|
||||
findByStepRun(
|
||||
runId: string,
|
||||
stepRunId: string,
|
||||
startedStepRunVersion: number,
|
||||
): Promise<Readonly<ToolExecutionStartBarrierRecord> | null>;
|
||||
prepare(
|
||||
command: ToolExecutionStartCommand,
|
||||
): Promise<Readonly<PrepareToolExecutionStartResult>>;
|
||||
}
|
||||
|
||||
export class InvalidToolExecutionStartBarrierError extends TypeError {
|
||||
readonly code = 'TOOL_EXECUTION_START_BARRIER_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Tool execution start barrier is invalid: ${message}`);
|
||||
this.name = 'InvalidToolExecutionStartBarrierError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolExecutionStartBarrierConflictError extends Error {
|
||||
readonly code = 'TOOL_EXECUTION_START_BARRIER_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Tool execution start identity is bound to different content');
|
||||
this.name = 'ToolExecutionStartBarrierConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolExecutionStartBarrierUnavailableError extends Error {
|
||||
readonly code = 'TOOL_EXECUTION_START_BARRIER_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Tool execution start barrier repository is unavailable', options);
|
||||
this.name = 'ToolExecutionStartBarrierUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/;
|
||||
const SPAN_ID_PATTERN = /^[0-9a-f]{16}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const COMMAND_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-start-command-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const BARRIER_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-start-barrier-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidToolExecutionStartBarrierError(message);
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
return invalid(`${label} must be an object`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
return invalid(`${label} must contain enumerable data properties`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
required: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const keys = Reflect.ownKeys(value);
|
||||
const allowed = new Set(required);
|
||||
if (
|
||||
keys.length !== required.length ||
|
||||
keys.some((key) => typeof key !== 'string' || !allowed.has(key)) ||
|
||||
required.some((key) => !keys.includes(key))
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function hash(domain: Buffer, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
return invalid('action reference is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function traceIdentity(
|
||||
value: unknown,
|
||||
pattern: RegExp,
|
||||
label: string,
|
||||
): string {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameFence(
|
||||
left: Readonly<SecurityPolicyFence>,
|
||||
right: Readonly<SecurityPolicyFence>,
|
||||
): boolean {
|
||||
return (
|
||||
left.projectVersion === right.projectVersion &&
|
||||
left.bindingVersion === right.bindingVersion
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFence(
|
||||
value: SecurityPolicyFence,
|
||||
): Readonly<SecurityPolicyFence> {
|
||||
const record = dataRecord(value, 'policy fence');
|
||||
exactKeys(record, ['bindingVersion', 'projectVersion'], 'policy fence');
|
||||
if (
|
||||
!Number.isSafeInteger(value.projectVersion) ||
|
||||
value.projectVersion < 1 ||
|
||||
(value.bindingVersion !== null &&
|
||||
(!Number.isSafeInteger(value.bindingVersion) ||
|
||||
value.bindingVersion < 1))
|
||||
) {
|
||||
return invalid('policy fence is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
projectVersion: value.projectVersion,
|
||||
bindingVersion: value.bindingVersion,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeContractIdentity(
|
||||
value: TrustedToolContractIdentity,
|
||||
label: string,
|
||||
): Readonly<TrustedToolContractIdentity> {
|
||||
try {
|
||||
trustedToolContractIdentityDigest(value);
|
||||
} catch {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return Object.freeze({ id: value.id, version: value.version });
|
||||
}
|
||||
|
||||
function commandUnsigned(
|
||||
value: Readonly<ToolExecutionStartCommand>,
|
||||
): Omit<ToolExecutionStartCommand, 'commandDigest'> {
|
||||
return Object.freeze({
|
||||
schema: value.schema,
|
||||
startId: value.startId,
|
||||
admission: value.admission,
|
||||
evidence: value.evidence,
|
||||
stepRunMutation: value.stepRunMutation,
|
||||
});
|
||||
}
|
||||
|
||||
function validateCommandBindings(
|
||||
admission: Readonly<TrustedToolExecutionAdmission>,
|
||||
evidence: Readonly<ToolExecutionEvidenceBundle>,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): void {
|
||||
const trace = evidence.trace;
|
||||
const audit = evidence.audit;
|
||||
const receipt = evidence.receipt;
|
||||
const stepRun = mutation.stepRun;
|
||||
const expectedDefinitionRef =
|
||||
`tool:${admission.tool.name}@${admission.tool.version}`;
|
||||
if (
|
||||
admission.projectId !== trace.projectId ||
|
||||
admission.planDigest !== trace.invocationPlanDigest ||
|
||||
admission.bindingDigest !== trace.bindingDigest ||
|
||||
trustedToolContractIdentityDigest(admission.adapter) !==
|
||||
trace.adapterDigest ||
|
||||
trustedToolContractIdentityDigest(admission.redactionContract) !==
|
||||
trace.redactionContractDigest ||
|
||||
trustedToolContractIdentityDigest(admission.auditContract) !==
|
||||
trace.auditContractDigest ||
|
||||
admission.evidence.trace.traceId !== trace.traceId ||
|
||||
admission.evidence.trace.spanId !== trace.spanId ||
|
||||
admission.evidence.trace.digest !== trace.traceDigest ||
|
||||
admission.evidence.audit.eventId !== receipt.eventId ||
|
||||
admission.evidence.audit.digest !== receipt.receiptDigest ||
|
||||
admission.evidence.stepRun.id !== stepRun.id ||
|
||||
admission.evidence.stepRun.version !==
|
||||
mutation.expectedStepRunVersion ||
|
||||
admission.evidence.stepRun.digest !==
|
||||
mutation.expectedStepRunDigest ||
|
||||
admission.requestedBy.type !== audit.subject?.type ||
|
||||
admission.requestedBy.id !== audit.subject?.id ||
|
||||
audit.fence === null ||
|
||||
!sameFence(admission.policyFence, audit.fence) ||
|
||||
audit.occurredAtMs !== admission.admittedAtMs ||
|
||||
trace.createdAtMs !== admission.admittedAtMs ||
|
||||
mutation.runId !== trace.runId ||
|
||||
stepRun.runId !== trace.runId ||
|
||||
stepRun.id !== trace.stepRunId ||
|
||||
stepRun.kind !== 'tool' ||
|
||||
stepRun.definitionRef !== expectedDefinitionRef ||
|
||||
stepRun.definitionDigest !== admission.definitionDigest ||
|
||||
(mutation.previousStatus !== 'ready' &&
|
||||
mutation.previousStatus !== 'waiting_approval') ||
|
||||
mutation.expectedStepRunVersion === null ||
|
||||
mutation.expectedStepRunDigest === null ||
|
||||
stepRun.status !== 'running' ||
|
||||
stepRun.startedAtMs !== admission.admittedAtMs ||
|
||||
stepRun.updatedAtMs !== admission.admittedAtMs ||
|
||||
mutation.event.createdAtMs !== admission.admittedAtMs ||
|
||||
mutation.event.type !== 'step.running' ||
|
||||
(admission.approvalRequestId === null) !==
|
||||
(admission.approvalDispatchId === null) ||
|
||||
(admission.approvalDispatchId === null) !==
|
||||
(mutation.previousStatus === 'ready') ||
|
||||
(admission.approvalRequestId === null
|
||||
? stepRun.approvalRequestId !== null
|
||||
: stepRun.approvalRequestId !== admission.approvalRequestId)
|
||||
) {
|
||||
return invalid('start command bindings are inconsistent');
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionStartCommand(
|
||||
value: ToolExecutionStartCommand,
|
||||
): Readonly<ToolExecutionStartCommand> {
|
||||
const record = dataRecord(value, 'start command');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'admission',
|
||||
'commandDigest',
|
||||
'evidence',
|
||||
'schema',
|
||||
'startId',
|
||||
'stepRunMutation',
|
||||
],
|
||||
'start command',
|
||||
);
|
||||
if (value.schema !== TOOL_EXECUTION_START_COMMAND_SCHEMA) {
|
||||
return invalid('start command schema is invalid');
|
||||
}
|
||||
let admission: Readonly<TrustedToolExecutionAdmission>;
|
||||
let evidence: Readonly<ToolExecutionEvidenceBundle>;
|
||||
let stepRunMutation: Readonly<StepRunMutation>;
|
||||
try {
|
||||
admission = normalizeTrustedToolExecutionAdmission(value.admission);
|
||||
evidence = normalizeToolExecutionEvidenceBundle(value.evidence);
|
||||
stepRunMutation = normalizeStepRunMutation(value.stepRunMutation);
|
||||
} catch {
|
||||
return invalid('start command contains invalid durable facts');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: TOOL_EXECUTION_START_COMMAND_SCHEMA,
|
||||
startId: identifier(value.startId, 'start id'),
|
||||
admission,
|
||||
evidence,
|
||||
stepRunMutation,
|
||||
commandDigest: digest(value.commandDigest, 'command digest'),
|
||||
});
|
||||
validateCommandBindings(admission, evidence, stepRunMutation);
|
||||
if (
|
||||
hash(COMMAND_DIGEST_DOMAIN, commandUnsigned(normalized)) !==
|
||||
normalized.commandDigest ||
|
||||
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
|
||||
MAX_TOOL_EXECUTION_START_COMMAND_BYTES
|
||||
) {
|
||||
return invalid('start command digest or size is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createToolExecutionStartCommand(
|
||||
inputValue: CreateToolExecutionStartCommandInput,
|
||||
): Readonly<ToolExecutionStartCommand> {
|
||||
const input = dataRecord(inputValue, 'create start command input');
|
||||
exactKeys(
|
||||
input,
|
||||
['admission', 'evidence', 'startId', 'stepRunMutation'],
|
||||
'create start command input',
|
||||
);
|
||||
let admission: Readonly<TrustedToolExecutionAdmission>;
|
||||
let evidence: Readonly<ToolExecutionEvidenceBundle>;
|
||||
let stepRunMutation: Readonly<StepRunMutation>;
|
||||
try {
|
||||
admission = normalizeTrustedToolExecutionAdmission(inputValue.admission);
|
||||
evidence = normalizeToolExecutionEvidenceBundle(inputValue.evidence);
|
||||
stepRunMutation = normalizeStepRunMutation(inputValue.stepRunMutation);
|
||||
} catch {
|
||||
return invalid('create start command input contains invalid facts');
|
||||
}
|
||||
validateCommandBindings(admission, evidence, stepRunMutation);
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_START_COMMAND_SCHEMA,
|
||||
startId: identifier(inputValue.startId, 'start id'),
|
||||
admission,
|
||||
evidence,
|
||||
stepRunMutation,
|
||||
});
|
||||
return normalizeToolExecutionStartCommand({
|
||||
...unsigned,
|
||||
commandDigest: hash(COMMAND_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
function barrierUnsigned(
|
||||
value: Readonly<ToolExecutionStartBarrierRecord>,
|
||||
): Omit<ToolExecutionStartBarrierRecord, 'barrierDigest'> {
|
||||
const { barrierDigest: _barrierDigest, ...unsigned } = value;
|
||||
return Object.freeze(unsigned);
|
||||
}
|
||||
|
||||
export function toolExecutionStartBarrierRecord(
|
||||
commandValue: ToolExecutionStartCommand,
|
||||
): Readonly<ToolExecutionStartBarrierRecord> {
|
||||
const command = normalizeToolExecutionStartCommand(commandValue);
|
||||
const admission = command.admission;
|
||||
const evidence = command.evidence;
|
||||
const mutation = command.stepRunMutation;
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_EXECUTION_START_BARRIER_SCHEMA,
|
||||
startId: command.startId,
|
||||
projectId: admission.projectId,
|
||||
runId: mutation.runId,
|
||||
stepRunId: mutation.stepRun.id,
|
||||
actionRef: admission.actionRef,
|
||||
planDigest: admission.planDigest,
|
||||
actionDigest: admission.actionDigest,
|
||||
snapshotDigest: admission.snapshotDigest,
|
||||
definitionDigest: admission.definitionDigest,
|
||||
bindingDigest: admission.bindingDigest,
|
||||
admissionDigest: admission.admissionDigest,
|
||||
invocationArtifact: admission.invocationArtifact,
|
||||
previewArtifact: admission.previewArtifact,
|
||||
requestedBy: admission.requestedBy,
|
||||
profile: admission.profile,
|
||||
adapter: admission.adapter,
|
||||
adapterDigest: evidence.trace.adapterDigest,
|
||||
redactionContract: admission.redactionContract,
|
||||
redactionContractDigest: evidence.trace.redactionContractDigest,
|
||||
auditContract: admission.auditContract,
|
||||
auditContractDigest: evidence.trace.auditContractDigest,
|
||||
executionClass: admission.executionClass,
|
||||
timeoutSeconds: admission.timeoutSeconds,
|
||||
policyFence: admission.policyFence,
|
||||
approvalRequestId: admission.approvalRequestId,
|
||||
approvalDispatchId: admission.approvalDispatchId,
|
||||
approvalDispatchDigest: admission.approvalDispatchDigest,
|
||||
previousStepRunVersion: mutation.expectedStepRunVersion!,
|
||||
previousStepRunDigest: mutation.expectedStepRunDigest!,
|
||||
startedStepRunVersion: mutation.stepRun.version,
|
||||
startedStepRunDigest: mutation.stepRun.stepRunDigest,
|
||||
stepRunMutationId: mutation.mutationId,
|
||||
stepRunMutationDigest: mutation.mutationDigest,
|
||||
runEventId: mutation.event.id,
|
||||
traceId: evidence.trace.traceId,
|
||||
spanId: evidence.trace.spanId,
|
||||
traceDigest: evidence.trace.traceDigest,
|
||||
auditEventId: evidence.audit.eventId,
|
||||
auditReceiptDigest: evidence.receipt.receiptDigest,
|
||||
startedAtMs: admission.admittedAtMs,
|
||||
commandDigest: command.commandDigest,
|
||||
} satisfies Omit<ToolExecutionStartBarrierRecord, 'barrierDigest'>);
|
||||
return normalizeToolExecutionStartBarrierRecord({
|
||||
...unsigned,
|
||||
barrierDigest: hash(BARRIER_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionStartBarrierRecord(
|
||||
value: ToolExecutionStartBarrierRecord,
|
||||
): Readonly<ToolExecutionStartBarrierRecord> {
|
||||
const record = dataRecord(value, 'start barrier');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'actionDigest',
|
||||
'actionRef',
|
||||
'adapter',
|
||||
'adapterDigest',
|
||||
'admissionDigest',
|
||||
'approvalDispatchDigest',
|
||||
'approvalDispatchId',
|
||||
'approvalRequestId',
|
||||
'auditContract',
|
||||
'auditContractDigest',
|
||||
'auditEventId',
|
||||
'auditReceiptDigest',
|
||||
'barrierDigest',
|
||||
'bindingDigest',
|
||||
'commandDigest',
|
||||
'definitionDigest',
|
||||
'executionClass',
|
||||
'invocationArtifact',
|
||||
'planDigest',
|
||||
'policyFence',
|
||||
'previousStepRunDigest',
|
||||
'previousStepRunVersion',
|
||||
'profile',
|
||||
'projectId',
|
||||
'previewArtifact',
|
||||
'redactionContract',
|
||||
'redactionContractDigest',
|
||||
'requestedBy',
|
||||
'runEventId',
|
||||
'runId',
|
||||
'schema',
|
||||
'snapshotDigest',
|
||||
'spanId',
|
||||
'startId',
|
||||
'startedAtMs',
|
||||
'startedStepRunDigest',
|
||||
'startedStepRunVersion',
|
||||
'stepRunId',
|
||||
'stepRunMutationDigest',
|
||||
'stepRunMutationId',
|
||||
'timeoutSeconds',
|
||||
'traceDigest',
|
||||
'traceId',
|
||||
],
|
||||
'start barrier',
|
||||
);
|
||||
if (value.schema !== TOOL_EXECUTION_START_BARRIER_SCHEMA) {
|
||||
return invalid('start barrier schema is invalid');
|
||||
}
|
||||
const approvalRequestId =
|
||||
value.approvalRequestId === null
|
||||
? null
|
||||
: identifier(value.approvalRequestId, 'approval request id');
|
||||
const approvalDispatchId =
|
||||
value.approvalDispatchId === null
|
||||
? null
|
||||
: identifier(value.approvalDispatchId, 'approval dispatch id');
|
||||
const approvalDispatchDigest =
|
||||
value.approvalDispatchDigest === null
|
||||
? null
|
||||
: digest(value.approvalDispatchDigest, 'approval dispatch digest');
|
||||
if (
|
||||
(approvalRequestId === null) !== (approvalDispatchId === null) ||
|
||||
(approvalDispatchId === null) !== (approvalDispatchDigest === null)
|
||||
) {
|
||||
return invalid('start barrier approval binding is incomplete');
|
||||
}
|
||||
if (!TRUSTED_TOOL_DEPLOYMENT_PROFILES.includes(value.profile)) {
|
||||
return invalid('start barrier profile is invalid');
|
||||
}
|
||||
if (!TRUSTED_TOOL_EXECUTION_CLASSES.includes(value.executionClass)) {
|
||||
return invalid('start barrier execution class is invalid');
|
||||
}
|
||||
let requestedBy: Readonly<SecuritySubject>;
|
||||
try {
|
||||
requestedBy = normalizeProjectPolicySubject(value.requestedBy);
|
||||
} catch {
|
||||
return invalid('start barrier subject is invalid');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: TOOL_EXECUTION_START_BARRIER_SCHEMA,
|
||||
startId: identifier(value.startId, 'start id'),
|
||||
projectId: identifier(value.projectId, 'project id'),
|
||||
runId: identifier(value.runId, 'Run id'),
|
||||
stepRunId: identifier(value.stepRunId, 'StepRun id'),
|
||||
actionRef: actionRef(value.actionRef),
|
||||
planDigest: digest(value.planDigest, 'plan digest'),
|
||||
actionDigest: digest(value.actionDigest, 'action digest'),
|
||||
snapshotDigest: digest(value.snapshotDigest, 'snapshot digest'),
|
||||
definitionDigest: digest(value.definitionDigest, 'definition digest'),
|
||||
bindingDigest: digest(value.bindingDigest, 'binding digest'),
|
||||
admissionDigest: digest(value.admissionDigest, 'admission digest'),
|
||||
invocationArtifact: normalizeToolInvocationInputArtifactReference(
|
||||
value.invocationArtifact,
|
||||
),
|
||||
previewArtifact: normalizeToolInvocationPreviewArtifactReference(
|
||||
value.previewArtifact,
|
||||
),
|
||||
requestedBy,
|
||||
profile: value.profile,
|
||||
adapter: normalizeContractIdentity(value.adapter, 'adapter identity'),
|
||||
adapterDigest: digest(value.adapterDigest, 'adapter digest'),
|
||||
redactionContract: normalizeContractIdentity(
|
||||
value.redactionContract,
|
||||
'redaction contract identity',
|
||||
),
|
||||
redactionContractDigest: digest(
|
||||
value.redactionContractDigest,
|
||||
'redaction contract digest',
|
||||
),
|
||||
auditContract: normalizeContractIdentity(
|
||||
value.auditContract,
|
||||
'audit contract identity',
|
||||
),
|
||||
auditContractDigest: digest(
|
||||
value.auditContractDigest,
|
||||
'audit contract digest',
|
||||
),
|
||||
executionClass: value.executionClass,
|
||||
timeoutSeconds: integer(
|
||||
value.timeoutSeconds,
|
||||
1,
|
||||
60 * 60,
|
||||
'timeout',
|
||||
),
|
||||
policyFence: normalizeFence(value.policyFence),
|
||||
approvalRequestId,
|
||||
approvalDispatchId,
|
||||
approvalDispatchDigest,
|
||||
previousStepRunVersion: integer(
|
||||
value.previousStepRunVersion,
|
||||
1,
|
||||
2_147_483_646,
|
||||
'previous StepRun version',
|
||||
),
|
||||
previousStepRunDigest: digest(
|
||||
value.previousStepRunDigest,
|
||||
'previous StepRun digest',
|
||||
),
|
||||
startedStepRunVersion: integer(
|
||||
value.startedStepRunVersion,
|
||||
2,
|
||||
2_147_483_647,
|
||||
'started StepRun version',
|
||||
),
|
||||
startedStepRunDigest: digest(
|
||||
value.startedStepRunDigest,
|
||||
'started StepRun digest',
|
||||
),
|
||||
stepRunMutationId: identifier(
|
||||
value.stepRunMutationId,
|
||||
'StepRun mutation id',
|
||||
),
|
||||
stepRunMutationDigest: digest(
|
||||
value.stepRunMutationDigest,
|
||||
'StepRun mutation digest',
|
||||
),
|
||||
runEventId: identifier(value.runEventId, 'Run event id'),
|
||||
traceId: traceIdentity(value.traceId, TRACE_ID_PATTERN, 'trace id'),
|
||||
spanId: traceIdentity(value.spanId, SPAN_ID_PATTERN, 'span id'),
|
||||
traceDigest: digest(value.traceDigest, 'trace digest'),
|
||||
auditEventId: traceIdentity(
|
||||
value.auditEventId,
|
||||
UUID_V4_PATTERN,
|
||||
'audit event id',
|
||||
),
|
||||
auditReceiptDigest: digest(
|
||||
value.auditReceiptDigest,
|
||||
'audit receipt digest',
|
||||
),
|
||||
startedAtMs: integer(
|
||||
value.startedAtMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'start time',
|
||||
),
|
||||
commandDigest: digest(value.commandDigest, 'command digest'),
|
||||
barrierDigest: digest(value.barrierDigest, 'barrier digest'),
|
||||
});
|
||||
if (
|
||||
trustedToolContractIdentityDigest(normalized.adapter) !==
|
||||
normalized.adapterDigest ||
|
||||
trustedToolContractIdentityDigest(normalized.redactionContract) !==
|
||||
normalized.redactionContractDigest ||
|
||||
trustedToolContractIdentityDigest(normalized.auditContract) !==
|
||||
normalized.auditContractDigest ||
|
||||
normalized.previewArtifact.actionDigest !== normalized.actionDigest ||
|
||||
normalized.previewArtifact.redactionContractDigest !==
|
||||
normalized.redactionContractDigest ||
|
||||
normalized.startedStepRunVersion !==
|
||||
normalized.previousStepRunVersion + 1 ||
|
||||
hash(BARRIER_DIGEST_DOMAIN, barrierUnsigned(normalized)) !==
|
||||
normalized.barrierDigest ||
|
||||
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
|
||||
MAX_TOOL_EXECUTION_START_BARRIER_BYTES
|
||||
) {
|
||||
return invalid('start barrier digest, version or size is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
|
||||
import { normalizeProjectPolicySubject } from '../security/project-policy/projectPolicy';
|
||||
import type { SecuritySubject } from '../security/security';
|
||||
import {
|
||||
MAX_TOOL_INPUT_BYTES,
|
||||
ToolDefinitionRegistry,
|
||||
type ToolJsonValue,
|
||||
} from './tool-registry/toolRegistry';
|
||||
|
||||
export const TOOL_INVOCATION_INPUT_ARTIFACT_SCHEMA =
|
||||
'qinglong/tool-invocation-input-artifact@v1' as const;
|
||||
export const TOOL_INVOCATION_PREVIEW_ARTIFACT_SCHEMA =
|
||||
'qinglong/tool-invocation-preview-artifact@v1' as const;
|
||||
export const TOOL_INVOCATION_ARTIFACT_ALGORITHM = 'aes-256-gcm' as const;
|
||||
export const MAX_TOOL_INVOCATION_ARTIFACT_ID_BYTES = 128;
|
||||
export const MAX_TOOL_INVOCATION_ARTIFACT_KEY_ID_BYTES = 128;
|
||||
export const MAX_TOOL_INVOCATION_PREVIEW_ARTIFACT_BYTES = 8 * 1024;
|
||||
export const MAX_TOOL_INVOCATION_INPUT_ARTIFACT_JSON_BYTES = 96 * 1024;
|
||||
export const MAX_TOOL_INVOCATION_PREVIEW_ARTIFACT_JSON_BYTES = 16 * 1024;
|
||||
|
||||
export interface ToolInvocationPreviewField {
|
||||
readonly kind: 'count' | 'identifier' | 'redacted' | 'text';
|
||||
readonly label: string;
|
||||
readonly value: string | null;
|
||||
}
|
||||
|
||||
export interface ToolInvocationPreviewDocument {
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly fields: readonly Readonly<ToolInvocationPreviewField>[];
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
export interface ToolInvocationInputArtifact {
|
||||
readonly schema: typeof TOOL_INVOCATION_INPUT_ARTIFACT_SCHEMA;
|
||||
readonly artifactId: string;
|
||||
readonly projectId: string;
|
||||
readonly actionRef: string;
|
||||
readonly requestedBy: Readonly<SecuritySubject>;
|
||||
readonly tool: Readonly<{ name: string; version: string }>;
|
||||
readonly inputDigest: string;
|
||||
readonly invocationActionDigest: string;
|
||||
readonly keyId: string;
|
||||
readonly algorithm: typeof TOOL_INVOCATION_ARTIFACT_ALGORITHM;
|
||||
readonly nonce: string;
|
||||
readonly ciphertext: string;
|
||||
readonly authTag: string;
|
||||
readonly plaintextBytes: number;
|
||||
readonly sealedAtMs: number;
|
||||
readonly artifactDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolInvocationPreviewArtifact {
|
||||
readonly schema: typeof TOOL_INVOCATION_PREVIEW_ARTIFACT_SCHEMA;
|
||||
readonly artifactId: string;
|
||||
readonly projectId: string;
|
||||
readonly actionRef: string;
|
||||
readonly actionDigest: string;
|
||||
readonly redactionContractDigest: string;
|
||||
readonly preview: Readonly<ToolInvocationPreviewDocument>;
|
||||
readonly previewDigest: string;
|
||||
readonly byteLength: number;
|
||||
readonly sealedAtMs: number;
|
||||
readonly artifactDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolInvocationInputArtifactReference {
|
||||
readonly artifactId: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly inputDigest: string;
|
||||
readonly keyId: string;
|
||||
readonly algorithm: typeof TOOL_INVOCATION_ARTIFACT_ALGORITHM;
|
||||
readonly plaintextBytes: number;
|
||||
}
|
||||
|
||||
export interface ToolInvocationPreviewArtifactReference {
|
||||
readonly artifactId: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly actionDigest: string;
|
||||
readonly previewDigest: string;
|
||||
readonly redactionContractDigest: string;
|
||||
readonly byteLength: number;
|
||||
}
|
||||
|
||||
export interface ToolInvocationArtifactKeyMaterial {
|
||||
readonly keyId: string;
|
||||
/** Exactly 32 bytes. The consumer owns this copy and must wipe it. */
|
||||
readonly key: Uint8Array;
|
||||
}
|
||||
|
||||
export interface ToolInvocationArtifactKeyProvider {
|
||||
active(): Promise<ToolInvocationArtifactKeyMaterial>;
|
||||
resolve(keyId: string): Promise<ToolInvocationArtifactKeyMaterial | null>;
|
||||
}
|
||||
|
||||
export interface ToolInvocationArtifactRepository {
|
||||
put(
|
||||
inputArtifact: ToolInvocationInputArtifact,
|
||||
previewArtifact: ToolInvocationPreviewArtifact,
|
||||
): Promise<Readonly<{ status: 'inserted' | 'existing' }>>;
|
||||
findInput(
|
||||
artifactId: string,
|
||||
): Promise<Readonly<ToolInvocationInputArtifact> | null>;
|
||||
findPreview(
|
||||
artifactId: string,
|
||||
): Promise<Readonly<ToolInvocationPreviewArtifact> | null>;
|
||||
}
|
||||
|
||||
export class InvalidToolInvocationArtifactError extends TypeError {
|
||||
readonly code = 'TOOL_INVOCATION_ARTIFACT_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Tool invocation Artifact is invalid: ${message}`);
|
||||
this.name = 'InvalidToolInvocationArtifactError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolInvocationArtifactUnavailableError extends Error {
|
||||
readonly code = 'TOOL_INVOCATION_ARTIFACT_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Tool invocation Artifact is unavailable', options);
|
||||
this.name = 'ToolInvocationArtifactUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolInvocationArtifactConflictError extends Error {
|
||||
readonly code = 'TOOL_INVOCATION_ARTIFACT_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Tool invocation Artifact identity is bound to different content');
|
||||
this.name = 'ToolInvocationArtifactConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const TOOL_NAME_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
|
||||
const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/;
|
||||
const KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const WARNING_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const PREVIEW_FIELD_KINDS = [
|
||||
'count',
|
||||
'identifier',
|
||||
'redacted',
|
||||
'text',
|
||||
] as const;
|
||||
const INPUT_DIGEST_DOMAIN = Buffer.alloc(0);
|
||||
const INPUT_ARTIFACT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-invocation-input-artifact-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const INPUT_ARTIFACT_AAD_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-invocation-input-artifact-aad@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const PREVIEW_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-invocation-preview-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const PREVIEW_ARTIFACT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-invocation-preview-artifact-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidToolInvocationArtifactError(message);
|
||||
}
|
||||
|
||||
function hash(domain: Buffer, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
return invalid(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Reflect.ownKeys(value);
|
||||
const allowed = new Set(expected);
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key) => typeof key !== 'string' || !allowed.has(key)) ||
|
||||
expected.some((key) => !actual.includes(key))
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
return invalid('action reference is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 0 ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function boundedText(
|
||||
value: unknown,
|
||||
maximumBytes: number,
|
||||
label: string,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
CONTROL_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeToolInvocationPreviewDocument(
|
||||
value: ToolInvocationPreviewDocument,
|
||||
): Readonly<ToolInvocationPreviewDocument> {
|
||||
const record = dataRecord(value, 'preview document');
|
||||
exactKeys(
|
||||
record,
|
||||
['fields', 'summary', 'title', 'warnings'],
|
||||
'preview document',
|
||||
);
|
||||
if (
|
||||
!Array.isArray(value.fields) ||
|
||||
value.fields.length > 16 ||
|
||||
!Array.isArray(value.warnings) ||
|
||||
value.warnings.length > 8
|
||||
) {
|
||||
return invalid('preview document collections are invalid');
|
||||
}
|
||||
const fields = value.fields.map((fieldValue) => {
|
||||
const field = dataRecord(fieldValue, 'preview field');
|
||||
exactKeys(field, ['kind', 'label', 'value'], 'preview field');
|
||||
if (!PREVIEW_FIELD_KINDS.includes(fieldValue.kind)) {
|
||||
return invalid('preview field kind is invalid');
|
||||
}
|
||||
if (
|
||||
(fieldValue.kind === 'redacted' && fieldValue.value !== null) ||
|
||||
(fieldValue.kind !== 'redacted' && fieldValue.value === null)
|
||||
) {
|
||||
return invalid('preview field redaction is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: fieldValue.kind,
|
||||
label: boundedText(fieldValue.label, 128, 'preview field label'),
|
||||
value:
|
||||
fieldValue.value === null
|
||||
? null
|
||||
: boundedText(fieldValue.value, 512, 'preview field value'),
|
||||
});
|
||||
});
|
||||
const warnings = value.warnings.map((warning) => {
|
||||
if (typeof warning !== 'string' || !WARNING_PATTERN.test(warning)) {
|
||||
return invalid('preview warning is invalid');
|
||||
}
|
||||
return warning;
|
||||
});
|
||||
if (new Set(warnings).size !== warnings.length) {
|
||||
return invalid('preview warnings are duplicated');
|
||||
}
|
||||
return Object.freeze({
|
||||
title: boundedText(value.title, 256, 'preview title'),
|
||||
summary: boundedText(value.summary, 2048, 'preview summary'),
|
||||
fields: Object.freeze(fields),
|
||||
warnings: Object.freeze([...warnings].sort()),
|
||||
});
|
||||
}
|
||||
|
||||
function toolIdentity(
|
||||
value: Readonly<{ name: string; version: string }>,
|
||||
): Readonly<{ name: string; version: string }> {
|
||||
const record = dataRecord(value, 'Tool identity');
|
||||
exactKeys(record, ['name', 'version'], 'Tool identity');
|
||||
if (
|
||||
typeof value.name !== 'string' ||
|
||||
!TOOL_NAME_PATTERN.test(value.name) ||
|
||||
typeof value.version !== 'string' ||
|
||||
!VERSION_PATTERN.test(value.version)
|
||||
) {
|
||||
return invalid('Tool identity is invalid');
|
||||
}
|
||||
return Object.freeze({ name: value.name, version: value.version });
|
||||
}
|
||||
|
||||
function base64url(
|
||||
value: unknown,
|
||||
label: string,
|
||||
expectedBytes?: number,
|
||||
): Buffer {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
!BASE64URL_PATTERN.test(value)
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
const bytes = Buffer.from(value, 'base64url');
|
||||
if (
|
||||
bytes.toString('base64url') !== value ||
|
||||
(expectedBytes !== undefined && bytes.length !== expectedBytes)
|
||||
) {
|
||||
bytes.fill(0);
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function ownedKey(value: Uint8Array): Buffer {
|
||||
if (!(value instanceof Uint8Array) || value.byteLength !== 32) {
|
||||
throw new ToolInvocationArtifactUnavailableError();
|
||||
}
|
||||
return Buffer.from(value);
|
||||
}
|
||||
|
||||
function inputArtifactAad(
|
||||
value: Omit<
|
||||
ToolInvocationInputArtifact,
|
||||
'artifactDigest' | 'authTag' | 'ciphertext' | 'nonce'
|
||||
>,
|
||||
): Buffer {
|
||||
return Buffer.concat([
|
||||
INPUT_ARTIFACT_AAD_DOMAIN,
|
||||
Buffer.from(JSON.stringify(value), 'utf8'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function normalizeToolInvocationInputArtifact(
|
||||
value: ToolInvocationInputArtifact,
|
||||
): Readonly<ToolInvocationInputArtifact> {
|
||||
const record = dataRecord(value, 'input Artifact');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'actionRef',
|
||||
'algorithm',
|
||||
'artifactDigest',
|
||||
'artifactId',
|
||||
'authTag',
|
||||
'ciphertext',
|
||||
'inputDigest',
|
||||
'invocationActionDigest',
|
||||
'keyId',
|
||||
'nonce',
|
||||
'plaintextBytes',
|
||||
'projectId',
|
||||
'requestedBy',
|
||||
'schema',
|
||||
'sealedAtMs',
|
||||
'tool',
|
||||
],
|
||||
'input Artifact',
|
||||
);
|
||||
if (
|
||||
value.schema !== TOOL_INVOCATION_INPUT_ARTIFACT_SCHEMA ||
|
||||
value.algorithm !== TOOL_INVOCATION_ARTIFACT_ALGORITHM ||
|
||||
typeof value.keyId !== 'string' ||
|
||||
!KEY_ID_PATTERN.test(value.keyId)
|
||||
) {
|
||||
return invalid('input Artifact schema, algorithm or key is invalid');
|
||||
}
|
||||
const nonce = base64url(value.nonce, 'input Artifact nonce', 12);
|
||||
const ciphertext = base64url(value.ciphertext, 'input Artifact ciphertext');
|
||||
const authTag = base64url(value.authTag, 'input Artifact auth tag', 16);
|
||||
const plaintextBytes = boundedInteger(
|
||||
value.plaintextBytes,
|
||||
MAX_TOOL_INPUT_BYTES,
|
||||
'input Artifact plaintext bytes',
|
||||
);
|
||||
try {
|
||||
if (ciphertext.length !== plaintextBytes) {
|
||||
return invalid('input Artifact ciphertext length does not match');
|
||||
}
|
||||
} finally {
|
||||
nonce.fill(0);
|
||||
ciphertext.fill(0);
|
||||
authTag.fill(0);
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_INVOCATION_INPUT_ARTIFACT_SCHEMA,
|
||||
artifactId: identifier(value.artifactId, 'input Artifact id'),
|
||||
projectId: identifier(value.projectId, 'input Artifact project id'),
|
||||
actionRef: actionRef(value.actionRef),
|
||||
requestedBy: normalizeProjectPolicySubject(value.requestedBy),
|
||||
tool: toolIdentity(value.tool),
|
||||
inputDigest: digest(value.inputDigest, 'input digest'),
|
||||
invocationActionDigest: digest(
|
||||
value.invocationActionDigest,
|
||||
'invocation action digest',
|
||||
),
|
||||
keyId: value.keyId,
|
||||
algorithm: TOOL_INVOCATION_ARTIFACT_ALGORITHM,
|
||||
nonce: value.nonce,
|
||||
ciphertext: value.ciphertext,
|
||||
authTag: value.authTag,
|
||||
plaintextBytes,
|
||||
sealedAtMs: timestamp(value.sealedAtMs, 'input Artifact seal time'),
|
||||
} satisfies Omit<ToolInvocationInputArtifact, 'artifactDigest'>);
|
||||
const artifactDigest = digest(value.artifactDigest, 'input Artifact digest');
|
||||
if (
|
||||
hash(INPUT_ARTIFACT_DIGEST_DOMAIN, unsigned) !== artifactDigest ||
|
||||
Buffer.byteLength(JSON.stringify({ ...unsigned, artifactDigest }), 'utf8') >
|
||||
MAX_TOOL_INVOCATION_INPUT_ARTIFACT_JSON_BYTES
|
||||
) {
|
||||
return invalid('input Artifact digest or size does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, artifactDigest });
|
||||
}
|
||||
|
||||
export function createToolInvocationInputArtifact(
|
||||
inputValue: Readonly<{
|
||||
artifactId: string;
|
||||
projectId: string;
|
||||
actionRef: string;
|
||||
requestedBy: Readonly<SecuritySubject>;
|
||||
tool: Readonly<{ name: string; version: string }>;
|
||||
input: ToolJsonValue;
|
||||
inputDigest: string;
|
||||
invocationActionDigest: string;
|
||||
keyId: string;
|
||||
key: Uint8Array;
|
||||
sealedAtMs: number;
|
||||
}>,
|
||||
nonceFactory: () => Uint8Array = () => randomBytes(12),
|
||||
): Readonly<ToolInvocationInputArtifact> {
|
||||
const inputDigest = digest(inputValue.inputDigest, 'input digest');
|
||||
if (hash(INPUT_DIGEST_DOMAIN, inputValue.input) !== inputDigest) {
|
||||
return invalid('input digest does not match plaintext');
|
||||
}
|
||||
const plaintext = Buffer.from(JSON.stringify(inputValue.input), 'utf8');
|
||||
if (plaintext.length > MAX_TOOL_INPUT_BYTES) {
|
||||
plaintext.fill(0);
|
||||
return invalid('input plaintext is too large');
|
||||
}
|
||||
const key = ownedKey(inputValue.key);
|
||||
let nonce: Buffer | undefined;
|
||||
try {
|
||||
nonce = Buffer.from(nonceFactory());
|
||||
if (nonce.length !== 12) {
|
||||
throw new ToolInvocationArtifactUnavailableError();
|
||||
}
|
||||
const metadata = Object.freeze({
|
||||
schema: TOOL_INVOCATION_INPUT_ARTIFACT_SCHEMA,
|
||||
artifactId: identifier(inputValue.artifactId, 'input Artifact id'),
|
||||
projectId: identifier(inputValue.projectId, 'input Artifact project id'),
|
||||
actionRef: actionRef(inputValue.actionRef),
|
||||
requestedBy: normalizeProjectPolicySubject(inputValue.requestedBy),
|
||||
tool: toolIdentity(inputValue.tool),
|
||||
inputDigest,
|
||||
invocationActionDigest: digest(
|
||||
inputValue.invocationActionDigest,
|
||||
'invocation action digest',
|
||||
),
|
||||
keyId:
|
||||
typeof inputValue.keyId === 'string' &&
|
||||
KEY_ID_PATTERN.test(inputValue.keyId)
|
||||
? inputValue.keyId
|
||||
: invalid('input Artifact key id is invalid'),
|
||||
algorithm: TOOL_INVOCATION_ARTIFACT_ALGORITHM,
|
||||
plaintextBytes: plaintext.length,
|
||||
sealedAtMs: timestamp(inputValue.sealedAtMs, 'input Artifact seal time'),
|
||||
});
|
||||
const cipher = createCipheriv(
|
||||
TOOL_INVOCATION_ARTIFACT_ALGORITHM,
|
||||
key,
|
||||
nonce,
|
||||
{ authTagLength: 16 },
|
||||
);
|
||||
const aad = inputArtifactAad(metadata);
|
||||
try {
|
||||
cipher.setAAD(aad);
|
||||
} finally {
|
||||
aad.fill(0);
|
||||
}
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintext),
|
||||
cipher.final(),
|
||||
]);
|
||||
try {
|
||||
const unsigned = Object.freeze({
|
||||
schema: metadata.schema,
|
||||
artifactId: metadata.artifactId,
|
||||
projectId: metadata.projectId,
|
||||
actionRef: metadata.actionRef,
|
||||
requestedBy: metadata.requestedBy,
|
||||
tool: metadata.tool,
|
||||
inputDigest: metadata.inputDigest,
|
||||
invocationActionDigest: metadata.invocationActionDigest,
|
||||
keyId: metadata.keyId,
|
||||
algorithm: metadata.algorithm,
|
||||
nonce: nonce.toString('base64url'),
|
||||
ciphertext: ciphertext.toString('base64url'),
|
||||
authTag: cipher.getAuthTag().toString('base64url'),
|
||||
plaintextBytes: metadata.plaintextBytes,
|
||||
sealedAtMs: metadata.sealedAtMs,
|
||||
});
|
||||
return normalizeToolInvocationInputArtifact({
|
||||
...unsigned,
|
||||
artifactDigest: hash(INPUT_ARTIFACT_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
} finally {
|
||||
ciphertext.fill(0);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof InvalidToolInvocationArtifactError ||
|
||||
cause instanceof ToolInvocationArtifactUnavailableError
|
||||
) {
|
||||
throw cause;
|
||||
}
|
||||
throw new ToolInvocationArtifactUnavailableError({ cause });
|
||||
} finally {
|
||||
key.fill(0);
|
||||
plaintext.fill(0);
|
||||
nonce?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function openToolInvocationInputArtifact(
|
||||
artifactValue: ToolInvocationInputArtifact,
|
||||
keyValue: Uint8Array,
|
||||
registry: ToolDefinitionRegistry,
|
||||
): ToolJsonValue {
|
||||
const artifact = normalizeToolInvocationInputArtifact(artifactValue);
|
||||
if (!(registry instanceof ToolDefinitionRegistry)) {
|
||||
return invalid('Tool registry is invalid');
|
||||
}
|
||||
const key = ownedKey(keyValue);
|
||||
const nonce = base64url(artifact.nonce, 'input Artifact nonce', 12);
|
||||
const ciphertext = base64url(
|
||||
artifact.ciphertext,
|
||||
'input Artifact ciphertext',
|
||||
);
|
||||
const authTag = base64url(artifact.authTag, 'input Artifact auth tag', 16);
|
||||
const metadata = {
|
||||
schema: artifact.schema,
|
||||
artifactId: artifact.artifactId,
|
||||
projectId: artifact.projectId,
|
||||
actionRef: artifact.actionRef,
|
||||
requestedBy: artifact.requestedBy,
|
||||
tool: artifact.tool,
|
||||
inputDigest: artifact.inputDigest,
|
||||
invocationActionDigest: artifact.invocationActionDigest,
|
||||
keyId: artifact.keyId,
|
||||
algorithm: artifact.algorithm,
|
||||
plaintextBytes: artifact.plaintextBytes,
|
||||
sealedAtMs: artifact.sealedAtMs,
|
||||
};
|
||||
const aad = inputArtifactAad(metadata);
|
||||
let plaintext: Buffer | undefined;
|
||||
try {
|
||||
const decipher = createDecipheriv(
|
||||
TOOL_INVOCATION_ARTIFACT_ALGORITHM,
|
||||
key,
|
||||
nonce,
|
||||
{ authTagLength: 16 },
|
||||
);
|
||||
decipher.setAAD(aad);
|
||||
decipher.setAuthTag(authTag);
|
||||
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
if (plaintext.length !== artifact.plaintextBytes) {
|
||||
throw new ToolInvocationArtifactUnavailableError();
|
||||
}
|
||||
const parsed = JSON.parse(plaintext.toString('utf8')) as unknown;
|
||||
const normalized = registry.normalizeInput(
|
||||
artifact.tool.name,
|
||||
artifact.tool.version,
|
||||
parsed,
|
||||
);
|
||||
if (hash(INPUT_DIGEST_DOMAIN, normalized) !== artifact.inputDigest) {
|
||||
throw new ToolInvocationArtifactUnavailableError();
|
||||
}
|
||||
return normalized;
|
||||
} catch (cause) {
|
||||
if (cause instanceof ToolInvocationArtifactUnavailableError) throw cause;
|
||||
throw new ToolInvocationArtifactUnavailableError({ cause });
|
||||
} finally {
|
||||
key.fill(0);
|
||||
nonce.fill(0);
|
||||
ciphertext.fill(0);
|
||||
authTag.fill(0);
|
||||
aad.fill(0);
|
||||
plaintext?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function toolInvocationInputArtifactReference(
|
||||
value: ToolInvocationInputArtifact,
|
||||
): Readonly<ToolInvocationInputArtifactReference> {
|
||||
const artifact = normalizeToolInvocationInputArtifact(value);
|
||||
return Object.freeze({
|
||||
artifactId: artifact.artifactId,
|
||||
artifactDigest: artifact.artifactDigest,
|
||||
inputDigest: artifact.inputDigest,
|
||||
keyId: artifact.keyId,
|
||||
algorithm: artifact.algorithm,
|
||||
plaintextBytes: artifact.plaintextBytes,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeToolInvocationInputArtifactReference(
|
||||
value: ToolInvocationInputArtifactReference,
|
||||
): Readonly<ToolInvocationInputArtifactReference> {
|
||||
const record = dataRecord(value, 'input Artifact reference');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'algorithm',
|
||||
'artifactDigest',
|
||||
'artifactId',
|
||||
'inputDigest',
|
||||
'keyId',
|
||||
'plaintextBytes',
|
||||
],
|
||||
'input Artifact reference',
|
||||
);
|
||||
if (
|
||||
value.algorithm !== TOOL_INVOCATION_ARTIFACT_ALGORITHM ||
|
||||
typeof value.keyId !== 'string' ||
|
||||
!KEY_ID_PATTERN.test(value.keyId)
|
||||
) {
|
||||
return invalid('input Artifact reference algorithm or key is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
artifactId: identifier(value.artifactId, 'input Artifact id'),
|
||||
artifactDigest: digest(value.artifactDigest, 'input Artifact digest'),
|
||||
inputDigest: digest(value.inputDigest, 'input digest'),
|
||||
keyId: value.keyId,
|
||||
algorithm: TOOL_INVOCATION_ARTIFACT_ALGORITHM,
|
||||
plaintextBytes: boundedInteger(
|
||||
value.plaintextBytes,
|
||||
MAX_TOOL_INPUT_BYTES,
|
||||
'input Artifact plaintext bytes',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function previewArtifactUnsigned(
|
||||
value: ToolInvocationPreviewArtifact,
|
||||
): Omit<ToolInvocationPreviewArtifact, 'artifactDigest'> {
|
||||
const { artifactDigest: _artifactDigest, ...unsigned } = value;
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
export function createToolInvocationPreviewArtifact(
|
||||
inputValue: Readonly<{
|
||||
artifactId: string;
|
||||
projectId: string;
|
||||
actionRef: string;
|
||||
actionDigest: string;
|
||||
redactionContractDigest: string;
|
||||
preview: Readonly<ToolInvocationPreviewDocument>;
|
||||
sealedAtMs: number;
|
||||
}>,
|
||||
): Readonly<ToolInvocationPreviewArtifact> {
|
||||
const actionDigest = digest(inputValue.actionDigest, 'action digest');
|
||||
const preview = normalizeToolInvocationPreviewDocument(inputValue.preview);
|
||||
const previewDigest = hash(PREVIEW_DIGEST_DOMAIN, {
|
||||
actionDigest,
|
||||
preview,
|
||||
});
|
||||
const byteLength = Buffer.byteLength(JSON.stringify(preview), 'utf8');
|
||||
if (byteLength > MAX_TOOL_INVOCATION_PREVIEW_ARTIFACT_BYTES) {
|
||||
return invalid('preview Artifact is too large');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_INVOCATION_PREVIEW_ARTIFACT_SCHEMA,
|
||||
artifactId: identifier(inputValue.artifactId, 'preview Artifact id'),
|
||||
projectId: identifier(inputValue.projectId, 'preview Artifact project id'),
|
||||
actionRef: actionRef(inputValue.actionRef),
|
||||
actionDigest,
|
||||
redactionContractDigest: digest(
|
||||
inputValue.redactionContractDigest,
|
||||
'redaction contract digest',
|
||||
),
|
||||
preview,
|
||||
previewDigest,
|
||||
byteLength,
|
||||
sealedAtMs: timestamp(inputValue.sealedAtMs, 'preview Artifact seal time'),
|
||||
} satisfies Omit<ToolInvocationPreviewArtifact, 'artifactDigest'>);
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
artifactDigest: hash(PREVIEW_ARTIFACT_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeToolInvocationPreviewArtifact(
|
||||
value: ToolInvocationPreviewArtifact,
|
||||
): Readonly<ToolInvocationPreviewArtifact> {
|
||||
const record = dataRecord(value, 'preview Artifact');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'actionDigest',
|
||||
'actionRef',
|
||||
'artifactDigest',
|
||||
'artifactId',
|
||||
'byteLength',
|
||||
'preview',
|
||||
'previewDigest',
|
||||
'projectId',
|
||||
'redactionContractDigest',
|
||||
'schema',
|
||||
'sealedAtMs',
|
||||
],
|
||||
'preview Artifact',
|
||||
);
|
||||
if (value.schema !== TOOL_INVOCATION_PREVIEW_ARTIFACT_SCHEMA) {
|
||||
return invalid('preview Artifact schema is invalid');
|
||||
}
|
||||
const preview = normalizeToolInvocationPreviewDocument(value.preview);
|
||||
const byteLength = boundedInteger(
|
||||
value.byteLength,
|
||||
MAX_TOOL_INVOCATION_PREVIEW_ARTIFACT_BYTES,
|
||||
'preview Artifact bytes',
|
||||
);
|
||||
if (Buffer.byteLength(JSON.stringify(preview), 'utf8') !== byteLength) {
|
||||
return invalid('preview Artifact byte length does not match');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_INVOCATION_PREVIEW_ARTIFACT_SCHEMA,
|
||||
artifactId: identifier(value.artifactId, 'preview Artifact id'),
|
||||
projectId: identifier(value.projectId, 'preview Artifact project id'),
|
||||
actionRef: actionRef(value.actionRef),
|
||||
actionDigest: digest(value.actionDigest, 'action digest'),
|
||||
redactionContractDigest: digest(
|
||||
value.redactionContractDigest,
|
||||
'redaction contract digest',
|
||||
),
|
||||
preview,
|
||||
previewDigest: digest(value.previewDigest, 'preview digest'),
|
||||
byteLength,
|
||||
sealedAtMs: timestamp(value.sealedAtMs, 'preview Artifact seal time'),
|
||||
} satisfies Omit<ToolInvocationPreviewArtifact, 'artifactDigest'>);
|
||||
if (
|
||||
hash(PREVIEW_DIGEST_DOMAIN, {
|
||||
actionDigest: unsigned.actionDigest,
|
||||
preview: unsigned.preview,
|
||||
}) !== unsigned.previewDigest
|
||||
) {
|
||||
return invalid('preview digest does not match');
|
||||
}
|
||||
const artifactDigest = digest(
|
||||
value.artifactDigest,
|
||||
'preview Artifact digest',
|
||||
);
|
||||
if (
|
||||
hash(PREVIEW_ARTIFACT_DIGEST_DOMAIN, unsigned) !== artifactDigest ||
|
||||
Buffer.byteLength(JSON.stringify({ ...unsigned, artifactDigest }), 'utf8') >
|
||||
MAX_TOOL_INVOCATION_PREVIEW_ARTIFACT_JSON_BYTES
|
||||
) {
|
||||
return invalid('preview Artifact digest or size does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, artifactDigest });
|
||||
}
|
||||
|
||||
export function toolInvocationPreviewArtifactReference(
|
||||
value: ToolInvocationPreviewArtifact,
|
||||
): Readonly<ToolInvocationPreviewArtifactReference> {
|
||||
const artifact = normalizeToolInvocationPreviewArtifact(value);
|
||||
return Object.freeze({
|
||||
artifactId: artifact.artifactId,
|
||||
artifactDigest: artifact.artifactDigest,
|
||||
actionDigest: artifact.actionDigest,
|
||||
previewDigest: artifact.previewDigest,
|
||||
redactionContractDigest: artifact.redactionContractDigest,
|
||||
byteLength: artifact.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeToolInvocationPreviewArtifactReference(
|
||||
value: ToolInvocationPreviewArtifactReference,
|
||||
): Readonly<ToolInvocationPreviewArtifactReference> {
|
||||
const record = dataRecord(value, 'preview Artifact reference');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'artifactDigest',
|
||||
'artifactId',
|
||||
'actionDigest',
|
||||
'byteLength',
|
||||
'previewDigest',
|
||||
'redactionContractDigest',
|
||||
],
|
||||
'preview Artifact reference',
|
||||
);
|
||||
return Object.freeze({
|
||||
artifactId: identifier(value.artifactId, 'preview Artifact id'),
|
||||
artifactDigest: digest(value.artifactDigest, 'preview Artifact digest'),
|
||||
actionDigest: digest(value.actionDigest, 'action digest'),
|
||||
previewDigest: digest(value.previewDigest, 'preview digest'),
|
||||
redactionContractDigest: digest(
|
||||
value.redactionContractDigest,
|
||||
'redaction contract digest',
|
||||
),
|
||||
byteLength: boundedInteger(
|
||||
value.byteLength,
|
||||
MAX_TOOL_INVOCATION_PREVIEW_ARTIFACT_BYTES,
|
||||
'preview Artifact bytes',
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
|
||||
export const TOOL_RESULT_KEY_CATALOG_SCHEMA =
|
||||
'qinglong/tool-result-key-catalog@v1' as const;
|
||||
export const TOOL_RESULT_KEY_CATALOG_COMMAND_SCHEMA =
|
||||
'qinglong/tool-result-key-catalog-command@v1' as const;
|
||||
export const MAX_TOOL_RESULT_DECRYPTABLE_KEYS = 16;
|
||||
export const MAX_TOOL_RESULT_CATALOG_KEYS = 64;
|
||||
|
||||
export type ToolResultKeyState = 'active' | 'decrypt_only' | 'retired' | 'lost';
|
||||
|
||||
export type ToolResultKeyCatalogMutationKind =
|
||||
| 'bootstrap'
|
||||
| 'rotate'
|
||||
| 'retire'
|
||||
| 'mark_lost'
|
||||
| 'restore';
|
||||
|
||||
export interface ToolResultKeyCatalogEntry {
|
||||
readonly keyId: string;
|
||||
readonly state: ToolResultKeyState;
|
||||
readonly materialProof: string;
|
||||
readonly introducedGeneration: number;
|
||||
readonly stateChangedGeneration: number;
|
||||
readonly retirementReceiptDigest: string | null;
|
||||
}
|
||||
|
||||
export interface ToolResultKeyCatalogSnapshot {
|
||||
readonly schema: typeof TOOL_RESULT_KEY_CATALOG_SCHEMA;
|
||||
readonly generation: number;
|
||||
readonly previousCatalogDigest: string | null;
|
||||
readonly activeKeyId: string | null;
|
||||
readonly keys: readonly Readonly<ToolResultKeyCatalogEntry>[];
|
||||
readonly mutationKind: ToolResultKeyCatalogMutationKind;
|
||||
readonly mutationId: string;
|
||||
readonly catalogDigest: string;
|
||||
}
|
||||
|
||||
export interface ToolResultKeyCatalogRecord
|
||||
extends ToolResultKeyCatalogSnapshot {
|
||||
readonly committedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ToolResultKeyCatalogFence {
|
||||
readonly generation: number;
|
||||
readonly catalogDigest: string;
|
||||
readonly keyId: string;
|
||||
readonly materialProof: string;
|
||||
}
|
||||
|
||||
export interface ToolResultKeyCatalogCommand {
|
||||
readonly schema: typeof TOOL_RESULT_KEY_CATALOG_COMMAND_SCHEMA;
|
||||
readonly expectedGeneration: number;
|
||||
readonly expectedCatalogDigest: string | null;
|
||||
readonly next: Readonly<ToolResultKeyCatalogSnapshot>;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface CommitToolResultKeyCatalogResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly catalog: Readonly<ToolResultKeyCatalogRecord>;
|
||||
}
|
||||
|
||||
export interface ToolResultKeyCatalogReader {
|
||||
findCurrent(): Promise<Readonly<ToolResultKeyCatalogRecord> | null>;
|
||||
}
|
||||
|
||||
export interface ToolResultKeyCatalogRepository
|
||||
extends ToolResultKeyCatalogReader {
|
||||
append(
|
||||
command: Readonly<ToolResultKeyCatalogCommand>,
|
||||
): Promise<Readonly<CommitToolResultKeyCatalogResult>>;
|
||||
}
|
||||
|
||||
export class InvalidToolResultKeyCatalogError extends TypeError {
|
||||
readonly code = 'TOOL_RESULT_KEY_CATALOG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Tool result key catalog is invalid: ${message}`);
|
||||
this.name = 'InvalidToolResultKeyCatalogError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolResultKeyCatalogConflictError extends Error {
|
||||
readonly code = 'TOOL_RESULT_KEY_CATALOG_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Tool result key catalog conflicts with durable state');
|
||||
this.name = 'ToolResultKeyCatalogConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolResultKeyCatalogUnavailableError extends Error {
|
||||
readonly code = 'TOOL_RESULT_KEY_CATALOG_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Tool result key catalog is unavailable', options);
|
||||
this.name = 'ToolResultKeyCatalogUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolResultKeyLostError extends Error {
|
||||
readonly code = 'TOOL_RESULT_KEY_LOST';
|
||||
readonly keyId: string;
|
||||
|
||||
constructor(keyId: string) {
|
||||
super('Tool result key material is lost');
|
||||
this.name = 'ToolResultKeyLostError';
|
||||
this.keyId = keyId;
|
||||
}
|
||||
}
|
||||
|
||||
const KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const CATALOG_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-result-key-catalog-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const COMMAND_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-result-key-catalog-command-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const MATERIAL_PROOF_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-result-key-material-proof@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidToolResultKeyCatalogError(message);
|
||||
}
|
||||
|
||||
function hash(domain: Uint8Array, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
if (
|
||||
actual.length !== sortedExpected.length ||
|
||||
actual.some((key, index) => key !== sortedExpected[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
return invalid(`${label} is not a plain object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function keyId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !KEY_ID_PATTERN.test(value)) {
|
||||
return invalid('key id is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableDigest(value: unknown, label: string): string | null {
|
||||
return value === null ? null : digest(value, label);
|
||||
}
|
||||
|
||||
function generation(value: unknown, minimum = 1): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > 2_147_483_647
|
||||
) {
|
||||
return invalid('generation is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function state(value: unknown): ToolResultKeyState {
|
||||
if (
|
||||
value !== 'active' &&
|
||||
value !== 'decrypt_only' &&
|
||||
value !== 'retired' &&
|
||||
value !== 'lost'
|
||||
) {
|
||||
return invalid('key state is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mutationKind(value: unknown): ToolResultKeyCatalogMutationKind {
|
||||
if (
|
||||
value !== 'bootstrap' &&
|
||||
value !== 'rotate' &&
|
||||
value !== 'retire' &&
|
||||
value !== 'mark_lost' &&
|
||||
value !== 'restore'
|
||||
) {
|
||||
return invalid('mutation kind is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizedEntry(
|
||||
value: ToolResultKeyCatalogEntry,
|
||||
catalogGeneration: number,
|
||||
): Readonly<ToolResultKeyCatalogEntry> {
|
||||
const candidate = record(value, 'catalog key');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'introducedGeneration',
|
||||
'keyId',
|
||||
'materialProof',
|
||||
'retirementReceiptDigest',
|
||||
'state',
|
||||
'stateChangedGeneration',
|
||||
],
|
||||
'catalog key',
|
||||
);
|
||||
const normalizedState = state(value.state);
|
||||
const introducedGeneration = generation(value.introducedGeneration);
|
||||
const stateChangedGeneration = generation(value.stateChangedGeneration);
|
||||
if (
|
||||
introducedGeneration > stateChangedGeneration ||
|
||||
stateChangedGeneration > catalogGeneration
|
||||
) {
|
||||
return invalid('key generation fence is invalid');
|
||||
}
|
||||
const retirementReceiptDigest = nullableDigest(
|
||||
value.retirementReceiptDigest,
|
||||
'retirement receipt digest',
|
||||
);
|
||||
if ((normalizedState === 'retired') !== (retirementReceiptDigest !== null)) {
|
||||
return invalid('retired key receipt is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
keyId: keyId(value.keyId),
|
||||
state: normalizedState,
|
||||
materialProof: digest(value.materialProof, 'material proof'),
|
||||
introducedGeneration,
|
||||
stateChangedGeneration,
|
||||
retirementReceiptDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function unsignedSnapshot(
|
||||
value: Omit<ToolResultKeyCatalogSnapshot, 'catalogDigest'>,
|
||||
): Omit<ToolResultKeyCatalogSnapshot, 'catalogDigest'> {
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
export function normalizeToolResultKeyCatalogSnapshot(
|
||||
value: ToolResultKeyCatalogSnapshot,
|
||||
): Readonly<ToolResultKeyCatalogSnapshot> {
|
||||
const candidate = record(value, 'catalog snapshot');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'activeKeyId',
|
||||
'catalogDigest',
|
||||
'generation',
|
||||
'keys',
|
||||
'mutationId',
|
||||
'mutationKind',
|
||||
'previousCatalogDigest',
|
||||
'schema',
|
||||
],
|
||||
'catalog snapshot',
|
||||
);
|
||||
if (
|
||||
value.schema !== TOOL_RESULT_KEY_CATALOG_SCHEMA ||
|
||||
!Array.isArray(value.keys) ||
|
||||
value.keys.length < 1 ||
|
||||
value.keys.length > MAX_TOOL_RESULT_CATALOG_KEYS
|
||||
) {
|
||||
return invalid('catalog snapshot header is invalid');
|
||||
}
|
||||
const normalizedGeneration = generation(value.generation);
|
||||
const keys = value.keys
|
||||
.map((entry) => normalizedEntry(entry, normalizedGeneration))
|
||||
.sort((left, right) => left.keyId.localeCompare(right.keyId));
|
||||
if (
|
||||
keys.some(
|
||||
(entry, index) => index > 0 && entry.keyId === keys[index - 1]!.keyId,
|
||||
)
|
||||
) {
|
||||
return invalid('catalog key id is duplicated');
|
||||
}
|
||||
const decryptable = keys.filter(
|
||||
(entry) => entry.state === 'active' || entry.state === 'decrypt_only',
|
||||
);
|
||||
if (decryptable.length > MAX_TOOL_RESULT_DECRYPTABLE_KEYS) {
|
||||
return invalid('decryptable key budget is exceeded');
|
||||
}
|
||||
const active = keys.filter((entry) => entry.state === 'active');
|
||||
const activeKeyId =
|
||||
value.activeKeyId === null ? null : keyId(value.activeKeyId);
|
||||
if (
|
||||
active.length > 1 ||
|
||||
(activeKeyId === null && active.length !== 0) ||
|
||||
(activeKeyId !== null &&
|
||||
(active.length !== 1 || active[0]!.keyId !== activeKeyId))
|
||||
) {
|
||||
return invalid('active key projection is invalid');
|
||||
}
|
||||
const previousCatalogDigest = nullableDigest(
|
||||
value.previousCatalogDigest,
|
||||
'previous catalog digest',
|
||||
);
|
||||
if ((normalizedGeneration === 1) !== (previousCatalogDigest === null)) {
|
||||
return invalid('previous catalog digest fence is invalid');
|
||||
}
|
||||
const unsigned = unsignedSnapshot({
|
||||
schema: TOOL_RESULT_KEY_CATALOG_SCHEMA,
|
||||
generation: normalizedGeneration,
|
||||
previousCatalogDigest,
|
||||
activeKeyId,
|
||||
keys: Object.freeze(keys),
|
||||
mutationKind: mutationKind(value.mutationKind),
|
||||
mutationId: identity(value.mutationId, 'mutation id'),
|
||||
});
|
||||
const catalogDigest = digest(value.catalogDigest, 'catalog digest');
|
||||
if (hash(CATALOG_DIGEST_DOMAIN, unsigned) !== catalogDigest) {
|
||||
return invalid('catalog digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, catalogDigest });
|
||||
}
|
||||
|
||||
export function normalizeToolResultKeyCatalogRecord(
|
||||
value: ToolResultKeyCatalogRecord,
|
||||
): Readonly<ToolResultKeyCatalogRecord> {
|
||||
const candidate = record(value, 'catalog record');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'activeKeyId',
|
||||
'catalogDigest',
|
||||
'committedAtMs',
|
||||
'generation',
|
||||
'keys',
|
||||
'mutationId',
|
||||
'mutationKind',
|
||||
'previousCatalogDigest',
|
||||
'schema',
|
||||
],
|
||||
'catalog record',
|
||||
);
|
||||
if (!Number.isSafeInteger(value.committedAtMs) || value.committedAtMs < 0) {
|
||||
return invalid('commit time is invalid');
|
||||
}
|
||||
const snapshot = normalizeToolResultKeyCatalogSnapshot({
|
||||
schema: value.schema,
|
||||
generation: value.generation,
|
||||
previousCatalogDigest: value.previousCatalogDigest,
|
||||
activeKeyId: value.activeKeyId,
|
||||
keys: value.keys,
|
||||
mutationKind: value.mutationKind,
|
||||
mutationId: value.mutationId,
|
||||
catalogDigest: value.catalogDigest,
|
||||
});
|
||||
return Object.freeze({ ...snapshot, committedAtMs: value.committedAtMs });
|
||||
}
|
||||
|
||||
export function normalizeToolResultKeyCatalogFence(
|
||||
value: ToolResultKeyCatalogFence,
|
||||
): Readonly<ToolResultKeyCatalogFence> {
|
||||
const candidate = record(value, 'catalog fence');
|
||||
exactKeys(
|
||||
candidate,
|
||||
['catalogDigest', 'generation', 'keyId', 'materialProof'],
|
||||
'catalog fence',
|
||||
);
|
||||
return Object.freeze({
|
||||
generation: generation(value.generation),
|
||||
catalogDigest: digest(value.catalogDigest, 'catalog digest'),
|
||||
keyId: keyId(value.keyId),
|
||||
materialProof: digest(value.materialProof, 'material proof'),
|
||||
});
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
input: Omit<ToolResultKeyCatalogSnapshot, 'catalogDigest' | 'schema'>,
|
||||
): Readonly<ToolResultKeyCatalogSnapshot> {
|
||||
const unsigned = unsignedSnapshot({
|
||||
schema: TOOL_RESULT_KEY_CATALOG_SCHEMA,
|
||||
...input,
|
||||
keys: Object.freeze(
|
||||
[...input.keys].sort((left, right) =>
|
||||
left.keyId.localeCompare(right.keyId),
|
||||
),
|
||||
),
|
||||
});
|
||||
return normalizeToolResultKeyCatalogSnapshot({
|
||||
...unsigned,
|
||||
catalogDigest: hash(CATALOG_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
function command(
|
||||
expectedGeneration: number,
|
||||
expectedCatalogDigest: string | null,
|
||||
next: Readonly<ToolResultKeyCatalogSnapshot>,
|
||||
): Readonly<ToolResultKeyCatalogCommand> {
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_RESULT_KEY_CATALOG_COMMAND_SCHEMA,
|
||||
expectedGeneration,
|
||||
expectedCatalogDigest,
|
||||
next,
|
||||
});
|
||||
return normalizeToolResultKeyCatalogCommand({
|
||||
...unsigned,
|
||||
commandDigest: hash(COMMAND_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeToolResultKeyCatalogCommand(
|
||||
value: ToolResultKeyCatalogCommand,
|
||||
): Readonly<ToolResultKeyCatalogCommand> {
|
||||
const candidate = record(value, 'catalog command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'commandDigest',
|
||||
'expectedCatalogDigest',
|
||||
'expectedGeneration',
|
||||
'next',
|
||||
'schema',
|
||||
],
|
||||
'catalog command',
|
||||
);
|
||||
if (
|
||||
value.schema !== TOOL_RESULT_KEY_CATALOG_COMMAND_SCHEMA ||
|
||||
!Number.isSafeInteger(value.expectedGeneration) ||
|
||||
value.expectedGeneration < 0
|
||||
) {
|
||||
return invalid('catalog command header is invalid');
|
||||
}
|
||||
const expectedCatalogDigest = nullableDigest(
|
||||
value.expectedCatalogDigest,
|
||||
'expected catalog digest',
|
||||
);
|
||||
const next = normalizeToolResultKeyCatalogSnapshot(value.next);
|
||||
if (
|
||||
next.generation !== value.expectedGeneration + 1 ||
|
||||
next.previousCatalogDigest !== expectedCatalogDigest ||
|
||||
(value.expectedGeneration === 0) !==
|
||||
(expectedCatalogDigest === null && next.mutationKind === 'bootstrap')
|
||||
) {
|
||||
return invalid('catalog command fence is invalid');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: TOOL_RESULT_KEY_CATALOG_COMMAND_SCHEMA,
|
||||
expectedGeneration: value.expectedGeneration,
|
||||
expectedCatalogDigest,
|
||||
next,
|
||||
});
|
||||
const commandDigest = digest(value.commandDigest, 'command digest');
|
||||
if (hash(COMMAND_DIGEST_DOMAIN, unsigned) !== commandDigest) {
|
||||
return invalid('command digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, commandDigest });
|
||||
}
|
||||
|
||||
export function toolResultKeyMaterialProof(
|
||||
candidateKeyId: string,
|
||||
value: Uint8Array,
|
||||
): string {
|
||||
const normalizedKeyId = keyId(candidateKeyId);
|
||||
if (!(value instanceof Uint8Array) || value.byteLength !== 32) {
|
||||
return invalid('key material is invalid');
|
||||
}
|
||||
const key = Buffer.from(value);
|
||||
try {
|
||||
return createHmac('sha256', key)
|
||||
.update(MATERIAL_PROOF_DOMAIN)
|
||||
.update(normalizedKeyId)
|
||||
.digest('hex');
|
||||
} finally {
|
||||
key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function createToolResultKeyCatalogBootstrapCommand(input: {
|
||||
readonly keyId: string;
|
||||
readonly materialProof: string;
|
||||
readonly mutationId: string;
|
||||
}): Readonly<ToolResultKeyCatalogCommand> {
|
||||
const next = snapshot({
|
||||
generation: 1,
|
||||
previousCatalogDigest: null,
|
||||
activeKeyId: keyId(input.keyId),
|
||||
keys: Object.freeze([
|
||||
Object.freeze({
|
||||
keyId: keyId(input.keyId),
|
||||
state: 'active' as const,
|
||||
materialProof: digest(input.materialProof, 'material proof'),
|
||||
introducedGeneration: 1,
|
||||
stateChangedGeneration: 1,
|
||||
retirementReceiptDigest: null,
|
||||
}),
|
||||
]),
|
||||
mutationKind: 'bootstrap',
|
||||
mutationId: identity(input.mutationId, 'mutation id'),
|
||||
});
|
||||
return command(0, null, next);
|
||||
}
|
||||
|
||||
function currentRecord(
|
||||
value: ToolResultKeyCatalogRecord,
|
||||
): Readonly<ToolResultKeyCatalogRecord> {
|
||||
return normalizeToolResultKeyCatalogRecord(value);
|
||||
}
|
||||
|
||||
function nextKeys(
|
||||
current: Readonly<ToolResultKeyCatalogRecord>,
|
||||
): ToolResultKeyCatalogEntry[] {
|
||||
return current.keys
|
||||
.filter((entry) => entry.state !== 'retired')
|
||||
.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
export function createToolResultKeyRotationCommand(
|
||||
value: ToolResultKeyCatalogRecord,
|
||||
input: {
|
||||
readonly keyId: string;
|
||||
readonly materialProof: string;
|
||||
readonly mutationId: string;
|
||||
},
|
||||
): Readonly<ToolResultKeyCatalogCommand> {
|
||||
const current = currentRecord(value);
|
||||
const candidateKeyId = keyId(input.keyId);
|
||||
if (current.keys.some((entry) => entry.keyId === candidateKeyId)) {
|
||||
return invalid('rotation key id already exists');
|
||||
}
|
||||
const nextGeneration = current.generation + 1;
|
||||
const keys = nextKeys(current).map((entry) =>
|
||||
entry.state === 'active'
|
||||
? {
|
||||
...entry,
|
||||
state: 'decrypt_only' as const,
|
||||
stateChangedGeneration: nextGeneration,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
keys.push({
|
||||
keyId: candidateKeyId,
|
||||
state: 'active',
|
||||
materialProof: digest(input.materialProof, 'material proof'),
|
||||
introducedGeneration: nextGeneration,
|
||||
stateChangedGeneration: nextGeneration,
|
||||
retirementReceiptDigest: null,
|
||||
});
|
||||
const next = snapshot({
|
||||
generation: nextGeneration,
|
||||
previousCatalogDigest: current.catalogDigest,
|
||||
activeKeyId: candidateKeyId,
|
||||
keys: Object.freeze(keys),
|
||||
mutationKind: 'rotate',
|
||||
mutationId: identity(input.mutationId, 'mutation id'),
|
||||
});
|
||||
return command(current.generation, current.catalogDigest, next);
|
||||
}
|
||||
|
||||
export function createToolResultKeyRetirementCommand(
|
||||
value: ToolResultKeyCatalogRecord,
|
||||
input: {
|
||||
readonly keyId: string;
|
||||
readonly retirementReceiptDigest: string;
|
||||
readonly mutationId: string;
|
||||
},
|
||||
): Readonly<ToolResultKeyCatalogCommand> {
|
||||
const current = currentRecord(value);
|
||||
const candidateKeyId = keyId(input.keyId);
|
||||
const receipt = digest(
|
||||
input.retirementReceiptDigest,
|
||||
'retirement receipt digest',
|
||||
);
|
||||
const target = current.keys.find((entry) => entry.keyId === candidateKeyId);
|
||||
if (!target || target.state !== 'decrypt_only') {
|
||||
return invalid('only a decrypt-only key can be retired');
|
||||
}
|
||||
const nextGeneration = current.generation + 1;
|
||||
const keys = nextKeys(current).map((entry) =>
|
||||
entry.keyId === candidateKeyId
|
||||
? {
|
||||
...entry,
|
||||
state: 'retired' as const,
|
||||
stateChangedGeneration: nextGeneration,
|
||||
retirementReceiptDigest: receipt,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
const next = snapshot({
|
||||
generation: nextGeneration,
|
||||
previousCatalogDigest: current.catalogDigest,
|
||||
activeKeyId: current.activeKeyId,
|
||||
keys: Object.freeze(keys),
|
||||
mutationKind: 'retire',
|
||||
mutationId: identity(input.mutationId, 'mutation id'),
|
||||
});
|
||||
return command(current.generation, current.catalogDigest, next);
|
||||
}
|
||||
|
||||
export function createToolResultKeyLostCommand(
|
||||
value: ToolResultKeyCatalogRecord,
|
||||
input: {
|
||||
readonly keyId: string;
|
||||
readonly mutationId: string;
|
||||
},
|
||||
): Readonly<ToolResultKeyCatalogCommand> {
|
||||
const current = currentRecord(value);
|
||||
const candidateKeyId = keyId(input.keyId);
|
||||
const target = current.keys.find((entry) => entry.keyId === candidateKeyId);
|
||||
if (
|
||||
!target ||
|
||||
(target.state !== 'active' && target.state !== 'decrypt_only')
|
||||
) {
|
||||
return invalid('only a decryptable key can be marked lost');
|
||||
}
|
||||
const nextGeneration = current.generation + 1;
|
||||
const keys = nextKeys(current).map((entry) =>
|
||||
entry.keyId === candidateKeyId
|
||||
? {
|
||||
...entry,
|
||||
state: 'lost' as const,
|
||||
stateChangedGeneration: nextGeneration,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
const next = snapshot({
|
||||
generation: nextGeneration,
|
||||
previousCatalogDigest: current.catalogDigest,
|
||||
activeKeyId:
|
||||
current.activeKeyId === candidateKeyId ? null : current.activeKeyId,
|
||||
keys: Object.freeze(keys),
|
||||
mutationKind: 'mark_lost',
|
||||
mutationId: identity(input.mutationId, 'mutation id'),
|
||||
});
|
||||
return command(current.generation, current.catalogDigest, next);
|
||||
}
|
||||
|
||||
export function createToolResultKeyRestoreCommand(
|
||||
value: ToolResultKeyCatalogRecord,
|
||||
input: {
|
||||
readonly keyId: string;
|
||||
readonly materialProof: string;
|
||||
readonly mutationId: string;
|
||||
},
|
||||
): Readonly<ToolResultKeyCatalogCommand> {
|
||||
const current = currentRecord(value);
|
||||
const candidateKeyId = keyId(input.keyId);
|
||||
const target = current.keys.find((entry) => entry.keyId === candidateKeyId);
|
||||
if (
|
||||
!target ||
|
||||
target.state !== 'lost' ||
|
||||
target.materialProof !== digest(input.materialProof, 'material proof')
|
||||
) {
|
||||
return invalid('lost key restore proof does not match');
|
||||
}
|
||||
const nextGeneration = current.generation + 1;
|
||||
const restoredState = 'decrypt_only' as const;
|
||||
const keys = nextKeys(current).map((entry) =>
|
||||
entry.keyId === candidateKeyId
|
||||
? {
|
||||
...entry,
|
||||
state: restoredState,
|
||||
stateChangedGeneration: nextGeneration,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
const next = snapshot({
|
||||
generation: nextGeneration,
|
||||
previousCatalogDigest: current.catalogDigest,
|
||||
activeKeyId: current.activeKeyId,
|
||||
keys: Object.freeze(keys),
|
||||
mutationKind: 'restore',
|
||||
mutationId: identity(input.mutationId, 'mutation id'),
|
||||
});
|
||||
return command(current.generation, current.catalogDigest, next);
|
||||
}
|
||||
|
||||
export function findToolResultKeyCatalogEntry(
|
||||
value: ToolResultKeyCatalogRecord,
|
||||
candidateKeyId: string,
|
||||
): Readonly<ToolResultKeyCatalogEntry> | null {
|
||||
const catalog = currentRecord(value);
|
||||
const normalizedKeyId = keyId(candidateKeyId);
|
||||
return catalog.keys.find((entry) => entry.keyId === normalizedKeyId) ?? null;
|
||||
}
|
||||
|
||||
export function requireActiveToolResultKey(
|
||||
value: ToolResultKeyCatalogRecord,
|
||||
): Readonly<ToolResultKeyCatalogEntry> {
|
||||
const catalog = currentRecord(value);
|
||||
if (catalog.activeKeyId === null) {
|
||||
throw new ToolResultKeyCatalogUnavailableError();
|
||||
}
|
||||
const entry = catalog.keys.find(
|
||||
(candidate) => candidate.keyId === catalog.activeKeyId,
|
||||
);
|
||||
if (!entry || entry.state !== 'active') {
|
||||
throw new ToolResultKeyCatalogUnavailableError();
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function requireDecryptableToolResultKey(
|
||||
value: ToolResultKeyCatalogRecord,
|
||||
candidateKeyId: string,
|
||||
): Readonly<ToolResultKeyCatalogEntry> {
|
||||
const entry = findToolResultKeyCatalogEntry(value, candidateKeyId);
|
||||
if (!entry) throw new ToolResultKeyCatalogUnavailableError();
|
||||
if (entry.state === 'lost') throw new ToolResultKeyLostError(entry.keyId);
|
||||
if (entry.state !== 'active' && entry.state !== 'decrypt_only') {
|
||||
throw new ToolResultKeyCatalogUnavailableError();
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function toolResultKeyCatalogFence(
|
||||
value: ToolResultKeyCatalogRecord,
|
||||
entryValue: ToolResultKeyCatalogEntry,
|
||||
): Readonly<ToolResultKeyCatalogFence> {
|
||||
const catalog = currentRecord(value);
|
||||
const entry = normalizedEntry(entryValue, catalog.generation);
|
||||
const stored = catalog.keys.find(
|
||||
(candidate) => candidate.keyId === entry.keyId,
|
||||
);
|
||||
if (
|
||||
!stored ||
|
||||
JSON.stringify(stored) !== JSON.stringify(entry) ||
|
||||
entry.state !== 'active' ||
|
||||
catalog.activeKeyId !== entry.keyId
|
||||
) {
|
||||
return invalid('catalog fence key is not active');
|
||||
}
|
||||
return normalizeToolResultKeyCatalogFence({
|
||||
generation: catalog.generation,
|
||||
catalogDigest: catalog.catalogDigest,
|
||||
keyId: entry.keyId,
|
||||
materialProof: entry.materialProof,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertToolResultKeyCatalogTransition(
|
||||
currentValue: ToolResultKeyCatalogRecord | null,
|
||||
commandValue: ToolResultKeyCatalogCommand,
|
||||
): void {
|
||||
const candidate = normalizeToolResultKeyCatalogCommand(commandValue);
|
||||
let expected: Readonly<ToolResultKeyCatalogCommand>;
|
||||
if (currentValue === null) {
|
||||
if (
|
||||
candidate.next.mutationKind !== 'bootstrap' ||
|
||||
candidate.next.keys.length !== 1
|
||||
) {
|
||||
throw new ToolResultKeyCatalogConflictError();
|
||||
}
|
||||
const entry = candidate.next.keys[0]!;
|
||||
expected = createToolResultKeyCatalogBootstrapCommand({
|
||||
keyId: entry.keyId,
|
||||
materialProof: entry.materialProof,
|
||||
mutationId: candidate.next.mutationId,
|
||||
});
|
||||
} else {
|
||||
const current = currentRecord(currentValue);
|
||||
if (
|
||||
candidate.expectedGeneration !== current.generation ||
|
||||
candidate.expectedCatalogDigest !== current.catalogDigest
|
||||
) {
|
||||
throw new ToolResultKeyCatalogConflictError();
|
||||
}
|
||||
const changed = candidate.next.keys.filter((entry) => {
|
||||
const previous = current.keys.find(
|
||||
(candidateEntry) => candidateEntry.keyId === entry.keyId,
|
||||
);
|
||||
return !previous || JSON.stringify(previous) !== JSON.stringify(entry);
|
||||
});
|
||||
switch (candidate.next.mutationKind) {
|
||||
case 'rotate': {
|
||||
const added = changed.find(
|
||||
(entry) =>
|
||||
!current.keys.some(
|
||||
(currentEntry) => currentEntry.keyId === entry.keyId,
|
||||
),
|
||||
);
|
||||
if (!added) throw new ToolResultKeyCatalogConflictError();
|
||||
expected = createToolResultKeyRotationCommand(current, {
|
||||
keyId: added.keyId,
|
||||
materialProof: added.materialProof,
|
||||
mutationId: candidate.next.mutationId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'retire': {
|
||||
const retired = changed.find((entry) => entry.state === 'retired');
|
||||
if (!retired || retired.retirementReceiptDigest === null) {
|
||||
throw new ToolResultKeyCatalogConflictError();
|
||||
}
|
||||
expected = createToolResultKeyRetirementCommand(current, {
|
||||
keyId: retired.keyId,
|
||||
retirementReceiptDigest: retired.retirementReceiptDigest,
|
||||
mutationId: candidate.next.mutationId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'mark_lost': {
|
||||
const lost = changed.find((entry) => entry.state === 'lost');
|
||||
if (!lost) throw new ToolResultKeyCatalogConflictError();
|
||||
expected = createToolResultKeyLostCommand(current, {
|
||||
keyId: lost.keyId,
|
||||
mutationId: candidate.next.mutationId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'restore': {
|
||||
const restored = changed.find((entry) => {
|
||||
const previous = current.keys.find(
|
||||
(currentEntry) => currentEntry.keyId === entry.keyId,
|
||||
);
|
||||
return (
|
||||
previous?.state === 'lost' &&
|
||||
(entry.state === 'active' || entry.state === 'decrypt_only')
|
||||
);
|
||||
});
|
||||
if (!restored) throw new ToolResultKeyCatalogConflictError();
|
||||
expected = createToolResultKeyRestoreCommand(current, {
|
||||
keyId: restored.keyId,
|
||||
materialProof: restored.materialProof,
|
||||
mutationId: candidate.next.mutationId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new ToolResultKeyCatalogConflictError();
|
||||
}
|
||||
}
|
||||
if (JSON.stringify(expected) !== JSON.stringify(candidate)) {
|
||||
throw new ToolResultKeyCatalogConflictError();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
approvedActionDispatchDigest,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '../../approved-action/approvedAction';
|
||||
import {
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPolicySubject,
|
||||
} from '../../security/project-policy/projectPolicy';
|
||||
import {
|
||||
normalizeSecurityPolicyDecision,
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPolicyFence,
|
||||
type SecurityPrincipal,
|
||||
} from '../../security/security';
|
||||
import type { ToolPolicyAuthorizer } from '../tool-registry/toolRegistry';
|
||||
import {
|
||||
normalizeToolInvocationInputArtifactReference,
|
||||
normalizeToolInvocationPreviewArtifactReference,
|
||||
} from '../toolInvocationArtifact';
|
||||
import { TrustedToolHandlerBindingRegistry } from './binding';
|
||||
import {
|
||||
ADMISSION_DIGEST_DOMAIN,
|
||||
dataRecord,
|
||||
digest,
|
||||
exactKeys,
|
||||
hash,
|
||||
identifier,
|
||||
invalid,
|
||||
normalizeContractIdentity,
|
||||
normalizeFence,
|
||||
normalizeProfile,
|
||||
normalizeToolIdentity,
|
||||
positiveInteger,
|
||||
sameFence,
|
||||
sameSubject,
|
||||
timestamp,
|
||||
} from './codec';
|
||||
import {
|
||||
TRUSTED_TOOL_EXECUTION_ADMISSION_SCHEMA,
|
||||
TRUSTED_TOOL_EXECUTION_CLASSES,
|
||||
TrustedToolExecutionApprovalRequiredError,
|
||||
TrustedToolExecutionPolicyDeniedError,
|
||||
TrustedToolExecutionPolicyUnavailableError,
|
||||
TrustedToolInvocationBindingConflictError,
|
||||
type AdmitTrustedToolExecutionInput,
|
||||
type ToolExecutionStartEvidence,
|
||||
type TrustedToolExecutionAdmission,
|
||||
type TrustedToolInvocationPlan,
|
||||
} from './contracts';
|
||||
import {
|
||||
assertTrustedToolApprovedDispatch,
|
||||
normalizeTrustedToolInvocationPlan,
|
||||
} from './plan';
|
||||
|
||||
function normalizeExecutionEvidence(
|
||||
value: ToolExecutionStartEvidence,
|
||||
): Readonly<ToolExecutionStartEvidence> {
|
||||
const evidence = dataRecord(value, 'execution evidence');
|
||||
exactKeys(evidence, ['audit', 'stepRun', 'trace'], [], 'execution evidence');
|
||||
const stepRun = dataRecord(value.stepRun, 'StepRun evidence');
|
||||
exactKeys(stepRun, ['digest', 'id', 'version'], [], 'StepRun evidence');
|
||||
const trace = dataRecord(value.trace, 'Trace evidence');
|
||||
exactKeys(trace, ['digest', 'spanId', 'traceId'], [], 'Trace evidence');
|
||||
const audit = dataRecord(value.audit, 'Audit evidence');
|
||||
exactKeys(audit, ['digest', 'eventId'], [], 'Audit evidence');
|
||||
return Object.freeze({
|
||||
stepRun: Object.freeze({
|
||||
id: identifier(value.stepRun.id, 'StepRun id'),
|
||||
version: positiveInteger(
|
||||
value.stepRun.version,
|
||||
2_147_483_647,
|
||||
'StepRun version',
|
||||
),
|
||||
digest: digest(value.stepRun.digest, 'StepRun digest'),
|
||||
}),
|
||||
trace: Object.freeze({
|
||||
traceId: identifier(value.trace.traceId, 'Trace id'),
|
||||
spanId: identifier(value.trace.spanId, 'Trace span id'),
|
||||
digest: digest(value.trace.digest, 'Trace digest'),
|
||||
}),
|
||||
audit: Object.freeze({
|
||||
eventId: identifier(value.audit.eventId, 'Audit event id'),
|
||||
digest: digest(value.audit.digest, 'Audit digest'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeTrustedToolExecutionAdmission(
|
||||
value: TrustedToolExecutionAdmission,
|
||||
): Readonly<TrustedToolExecutionAdmission> {
|
||||
const record = dataRecord(value, 'execution admission');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'actionRef',
|
||||
'actionDigest',
|
||||
'adapter',
|
||||
'admissionDigest',
|
||||
'admittedAtMs',
|
||||
'approvalRequestId',
|
||||
'approvalDispatchDigest',
|
||||
'approvalDispatchId',
|
||||
'auditContract',
|
||||
'bindingDigest',
|
||||
'evidence',
|
||||
'executionClass',
|
||||
'definitionDigest',
|
||||
'invocationArtifact',
|
||||
'planDigest',
|
||||
'policyFence',
|
||||
'profile',
|
||||
'projectId',
|
||||
'previewArtifact',
|
||||
'requestedBy',
|
||||
'redactionContract',
|
||||
'schema',
|
||||
'snapshotDigest',
|
||||
'timeoutSeconds',
|
||||
'tool',
|
||||
],
|
||||
[],
|
||||
'execution admission',
|
||||
);
|
||||
if (value.schema !== TRUSTED_TOOL_EXECUTION_ADMISSION_SCHEMA) {
|
||||
return invalid('execution admission schema is invalid');
|
||||
}
|
||||
assertProjectPolicyProjectId(value.projectId);
|
||||
const requestedBy = normalizeProjectPolicySubject(value.requestedBy);
|
||||
const tool = normalizeToolIdentity(value.tool, 'admitted Tool');
|
||||
const profile = normalizeProfile(value.profile);
|
||||
const adapter = normalizeContractIdentity(value.adapter, 'admitted adapter');
|
||||
if (!TRUSTED_TOOL_EXECUTION_CLASSES.includes(value.executionClass)) {
|
||||
return invalid('execution admission class is invalid');
|
||||
}
|
||||
const approvalDispatchId =
|
||||
value.approvalDispatchId === null
|
||||
? null
|
||||
: identifier(value.approvalDispatchId, 'approval dispatch id');
|
||||
const approvalRequestId =
|
||||
value.approvalRequestId === null
|
||||
? null
|
||||
: identifier(value.approvalRequestId, 'approval request id');
|
||||
const approvalDispatchDigest =
|
||||
value.approvalDispatchDigest === null
|
||||
? null
|
||||
: digest(value.approvalDispatchDigest, 'approval dispatch digest');
|
||||
if (
|
||||
(approvalRequestId === null) !== (approvalDispatchId === null) ||
|
||||
(approvalDispatchId === null) !== (approvalDispatchDigest === null)
|
||||
) {
|
||||
return invalid('execution admission approval binding is incomplete');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: TRUSTED_TOOL_EXECUTION_ADMISSION_SCHEMA,
|
||||
actionRef: identifier(value.actionRef, 'admitted action reference'),
|
||||
planDigest: digest(value.planDigest, 'admitted plan digest'),
|
||||
actionDigest: digest(value.actionDigest, 'admitted action digest'),
|
||||
projectId: value.projectId,
|
||||
requestedBy,
|
||||
tool,
|
||||
profile,
|
||||
snapshotDigest: digest(value.snapshotDigest, 'admitted snapshot digest'),
|
||||
definitionDigest: digest(
|
||||
value.definitionDigest,
|
||||
'admitted definition digest',
|
||||
),
|
||||
bindingDigest: digest(value.bindingDigest, 'admitted binding digest'),
|
||||
invocationArtifact: normalizeToolInvocationInputArtifactReference(
|
||||
value.invocationArtifact,
|
||||
),
|
||||
previewArtifact: normalizeToolInvocationPreviewArtifactReference(
|
||||
value.previewArtifact,
|
||||
),
|
||||
adapter,
|
||||
redactionContract: normalizeContractIdentity(
|
||||
value.redactionContract,
|
||||
'admitted redaction contract',
|
||||
),
|
||||
auditContract: normalizeContractIdentity(
|
||||
value.auditContract,
|
||||
'admitted audit contract',
|
||||
),
|
||||
executionClass: value.executionClass,
|
||||
timeoutSeconds: positiveInteger(
|
||||
value.timeoutSeconds,
|
||||
60 * 60,
|
||||
'admitted timeout',
|
||||
),
|
||||
policyFence: normalizeFence(value.policyFence),
|
||||
approvalRequestId,
|
||||
approvalDispatchId,
|
||||
approvalDispatchDigest,
|
||||
evidence: normalizeExecutionEvidence(value.evidence),
|
||||
admittedAtMs: timestamp(value.admittedAtMs, 'admission time'),
|
||||
} satisfies Omit<TrustedToolExecutionAdmission, 'admissionDigest'>);
|
||||
if (unsigned.previewArtifact.actionDigest !== unsigned.actionDigest) {
|
||||
return invalid('execution admission Artifact bindings do not match');
|
||||
}
|
||||
const admissionDigest = digest(value.admissionDigest, 'admission digest');
|
||||
if (hash(ADMISSION_DIGEST_DOMAIN, unsigned) !== admissionDigest) {
|
||||
return invalid('execution admission digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, admissionDigest });
|
||||
}
|
||||
|
||||
async function currentPolicyFence(
|
||||
plan: Readonly<TrustedToolInvocationPlan>,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
authorizer: ToolPolicyAuthorizer,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
fence: Readonly<SecurityPolicyFence>;
|
||||
decisions: readonly Readonly<SecurityPolicyDecision>[];
|
||||
}>
|
||||
> {
|
||||
if (!authorizer || typeof authorizer.authorize !== 'function') {
|
||||
throw new TrustedToolExecutionPolicyUnavailableError();
|
||||
}
|
||||
const permissions = [plan.permission, ...plan.requiredPermissions];
|
||||
const decisions: Readonly<SecurityPolicyDecision>[] = [];
|
||||
for (const permission of permissions) {
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = normalizeSecurityPolicyDecision(
|
||||
await authorizer.authorize(principal, plan.projectId, permission),
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new TrustedToolExecutionPolicyUnavailableError({ cause });
|
||||
}
|
||||
if (decision.effect === 'deny') {
|
||||
throw new TrustedToolExecutionPolicyDeniedError();
|
||||
}
|
||||
decisions.push(decision);
|
||||
}
|
||||
const fence = decisions[0]?.fence;
|
||||
if (
|
||||
!fence ||
|
||||
decisions.some(
|
||||
(decision) => !decision.fence || !sameFence(fence, decision.fence),
|
||||
)
|
||||
) {
|
||||
throw new TrustedToolExecutionPolicyUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
fence,
|
||||
decisions: Object.freeze(decisions),
|
||||
});
|
||||
}
|
||||
|
||||
export async function admitTrustedToolExecution(
|
||||
bindings: TrustedToolHandlerBindingRegistry,
|
||||
planValue: TrustedToolInvocationPlan,
|
||||
inputValue: AdmitTrustedToolExecutionInput,
|
||||
): Promise<Readonly<TrustedToolExecutionAdmission>> {
|
||||
if (!(bindings instanceof TrustedToolHandlerBindingRegistry)) {
|
||||
return invalid('handler binding registry is invalid');
|
||||
}
|
||||
const input = dataRecord(inputValue, 'execution admission input');
|
||||
exactKeys(
|
||||
input,
|
||||
['authorizer', 'evidence', 'nowMs', 'principal', 'profile'],
|
||||
['dispatch'],
|
||||
'execution admission input',
|
||||
);
|
||||
const plan = normalizeTrustedToolInvocationPlan(planValue, bindings);
|
||||
const nowMs = timestamp(inputValue.nowMs, 'admission time');
|
||||
const principal = normalizeSecurityPrincipal(inputValue.principal, nowMs);
|
||||
if (!sameSubject(principal.subject, plan.requestedBy)) {
|
||||
throw new TrustedToolInvocationBindingConflictError();
|
||||
}
|
||||
const profile = normalizeProfile(inputValue.profile);
|
||||
const currentBinding = bindings.resolve(
|
||||
plan.tool.name,
|
||||
plan.tool.version,
|
||||
profile,
|
||||
);
|
||||
if (
|
||||
profile !== plan.profile ||
|
||||
currentBinding.bindingDigest !== plan.binding.bindingDigest
|
||||
) {
|
||||
throw new TrustedToolInvocationBindingConflictError();
|
||||
}
|
||||
const policy = await currentPolicyFence(
|
||||
plan,
|
||||
principal,
|
||||
inputValue.authorizer,
|
||||
);
|
||||
let dispatch: Readonly<ApprovedActionDispatchRecord> | null = null;
|
||||
if (plan.status === 'approval_required') {
|
||||
if (!inputValue.dispatch) {
|
||||
throw new TrustedToolExecutionApprovalRequiredError();
|
||||
}
|
||||
dispatch = assertTrustedToolApprovedDispatch(
|
||||
plan,
|
||||
bindings,
|
||||
inputValue.dispatch,
|
||||
);
|
||||
} else {
|
||||
if (
|
||||
inputValue.dispatch !== undefined ||
|
||||
policy.decisions.some(
|
||||
(decision) => decision.effect === 'require_approval',
|
||||
)
|
||||
) {
|
||||
throw new TrustedToolExecutionApprovalRequiredError();
|
||||
}
|
||||
}
|
||||
const evidence = normalizeExecutionEvidence(inputValue.evidence);
|
||||
const unsigned = Object.freeze({
|
||||
schema: TRUSTED_TOOL_EXECUTION_ADMISSION_SCHEMA,
|
||||
actionRef: plan.actionRef,
|
||||
planDigest: plan.planDigest,
|
||||
actionDigest: plan.actionDigest,
|
||||
projectId: plan.projectId,
|
||||
requestedBy: plan.requestedBy,
|
||||
tool: plan.tool,
|
||||
profile,
|
||||
snapshotDigest: plan.snapshotDigest,
|
||||
definitionDigest: plan.definitionDigest,
|
||||
bindingDigest: currentBinding.bindingDigest,
|
||||
invocationArtifact: plan.invocationArtifact,
|
||||
previewArtifact: plan.previewArtifact,
|
||||
adapter: currentBinding.adapter,
|
||||
redactionContract: currentBinding.redactionContract,
|
||||
auditContract: currentBinding.auditContract,
|
||||
executionClass: currentBinding.executionClass,
|
||||
timeoutSeconds: plan.timeoutSeconds,
|
||||
policyFence: policy.fence,
|
||||
approvalRequestId: dispatch?.approvalRequestId ?? null,
|
||||
approvalDispatchId: dispatch?.id ?? null,
|
||||
approvalDispatchDigest:
|
||||
dispatch === null ? null : approvedActionDispatchDigest(dispatch),
|
||||
evidence,
|
||||
admittedAtMs: nowMs,
|
||||
} satisfies Omit<TrustedToolExecutionAdmission, 'admissionDigest'>);
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
admissionDigest: hash(ADMISSION_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { DeploymentProfile } from '../../cluster-control/clusterControlActivation';
|
||||
import {
|
||||
normalizeProjectToolDefinitionSnapshot,
|
||||
type ProjectToolDefinitionSnapshot,
|
||||
type ProjectToolDefinitionSnapshotEntry,
|
||||
} from '../tool-registry/projectToolDefinitionSnapshot';
|
||||
import { ToolDefinitionRegistry } from '../tool-registry/toolRegistry';
|
||||
import {
|
||||
MAX_TRUSTED_TOOL_HANDLER_BINDINGS,
|
||||
TRUSTED_TOOL_HANDLER_BINDING_SCHEMA,
|
||||
TRUSTED_TOOL_EXECUTION_CLASSES,
|
||||
TrustedToolHandlerUnavailableError,
|
||||
TrustedToolInvocationBindingConflictError,
|
||||
type CreateTrustedToolHandlerBindingInput,
|
||||
type TrustedToolHandlerBinding,
|
||||
} from './contracts';
|
||||
import {
|
||||
BINDING_DIGEST_DOMAIN,
|
||||
dataRecord,
|
||||
digest,
|
||||
exactKeys,
|
||||
hash,
|
||||
invalid,
|
||||
normalizeAuthorities,
|
||||
normalizeContractIdentity,
|
||||
normalizeProfile,
|
||||
normalizeProfiles,
|
||||
normalizeToolIdentity,
|
||||
positiveInteger,
|
||||
} from './codec';
|
||||
|
||||
function definitionEntry(
|
||||
snapshot: Readonly<ProjectToolDefinitionSnapshot>,
|
||||
tool: Readonly<{ name: string; version: string }>,
|
||||
): Readonly<ProjectToolDefinitionSnapshotEntry> {
|
||||
const entry = snapshot.definitions.find(
|
||||
(candidate) =>
|
||||
candidate.definition.name === tool.name &&
|
||||
candidate.definition.version === tool.version,
|
||||
);
|
||||
if (!entry) throw new TrustedToolHandlerUnavailableError();
|
||||
return entry;
|
||||
}
|
||||
|
||||
function bindingWithoutDigest(
|
||||
value: Readonly<TrustedToolHandlerBinding>,
|
||||
): Omit<TrustedToolHandlerBinding, 'bindingDigest'> {
|
||||
const { bindingDigest: _bindingDigest, ...unsigned } = value;
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
function normalizeBindingUnsigned(
|
||||
value: Omit<TrustedToolHandlerBinding, 'bindingDigest'>,
|
||||
): Omit<TrustedToolHandlerBinding, 'bindingDigest'> {
|
||||
const record = dataRecord(value, 'handler binding');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'adapter',
|
||||
'auditContract',
|
||||
'authorities',
|
||||
'definitionDigest',
|
||||
'executionClass',
|
||||
'profiles',
|
||||
'redactionContract',
|
||||
'schema',
|
||||
'snapshotDigest',
|
||||
'timeoutSeconds',
|
||||
'tool',
|
||||
],
|
||||
[],
|
||||
'handler binding',
|
||||
);
|
||||
if (
|
||||
value.schema !== TRUSTED_TOOL_HANDLER_BINDING_SCHEMA ||
|
||||
!TRUSTED_TOOL_EXECUTION_CLASSES.includes(value.executionClass)
|
||||
) {
|
||||
return invalid('handler binding schema or execution class is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: TRUSTED_TOOL_HANDLER_BINDING_SCHEMA,
|
||||
snapshotDigest: digest(value.snapshotDigest, 'snapshot digest'),
|
||||
definitionDigest: digest(value.definitionDigest, 'definition digest'),
|
||||
tool: normalizeToolIdentity(value.tool, 'handler Tool'),
|
||||
adapter: normalizeContractIdentity(value.adapter, 'handler adapter'),
|
||||
executionClass: value.executionClass,
|
||||
profiles: normalizeProfiles(value.profiles),
|
||||
authorities: normalizeAuthorities(value.authorities),
|
||||
timeoutSeconds: positiveInteger(
|
||||
value.timeoutSeconds,
|
||||
60 * 60,
|
||||
'handler timeout',
|
||||
),
|
||||
redactionContract: normalizeContractIdentity(
|
||||
value.redactionContract,
|
||||
'redaction contract',
|
||||
),
|
||||
auditContract: normalizeContractIdentity(
|
||||
value.auditContract,
|
||||
'audit contract',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeTrustedToolHandlerBinding(
|
||||
value: TrustedToolHandlerBinding,
|
||||
): Readonly<TrustedToolHandlerBinding> {
|
||||
const record = dataRecord(value, 'handler binding');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'adapter',
|
||||
'auditContract',
|
||||
'authorities',
|
||||
'bindingDigest',
|
||||
'definitionDigest',
|
||||
'executionClass',
|
||||
'profiles',
|
||||
'redactionContract',
|
||||
'schema',
|
||||
'snapshotDigest',
|
||||
'timeoutSeconds',
|
||||
'tool',
|
||||
],
|
||||
[],
|
||||
'handler binding',
|
||||
);
|
||||
const unsigned = normalizeBindingUnsigned(bindingWithoutDigest(value));
|
||||
const bindingDigest = digest(value.bindingDigest, 'binding digest');
|
||||
if (hash(BINDING_DIGEST_DOMAIN, unsigned) !== bindingDigest) {
|
||||
return invalid('handler binding digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, bindingDigest });
|
||||
}
|
||||
|
||||
export function createTrustedToolHandlerBinding(
|
||||
snapshotValue: ProjectToolDefinitionSnapshot,
|
||||
inputValue: CreateTrustedToolHandlerBindingInput,
|
||||
): Readonly<TrustedToolHandlerBinding> {
|
||||
const snapshot = normalizeProjectToolDefinitionSnapshot(snapshotValue);
|
||||
const input = dataRecord(inputValue, 'handler binding input');
|
||||
exactKeys(
|
||||
input,
|
||||
[
|
||||
'adapter',
|
||||
'auditContract',
|
||||
'authorities',
|
||||
'executionClass',
|
||||
'profiles',
|
||||
'redactionContract',
|
||||
'timeoutSeconds',
|
||||
'tool',
|
||||
],
|
||||
[],
|
||||
'handler binding input',
|
||||
);
|
||||
const tool = normalizeToolIdentity(inputValue.tool, 'handler Tool');
|
||||
const entry = definitionEntry(snapshot, tool);
|
||||
const unsigned = normalizeBindingUnsigned({
|
||||
schema: TRUSTED_TOOL_HANDLER_BINDING_SCHEMA,
|
||||
snapshotDigest: snapshot.snapshotDigest,
|
||||
definitionDigest: entry.definitionDigest,
|
||||
tool,
|
||||
adapter: inputValue.adapter,
|
||||
executionClass: inputValue.executionClass,
|
||||
profiles: inputValue.profiles,
|
||||
authorities: inputValue.authorities,
|
||||
timeoutSeconds: inputValue.timeoutSeconds,
|
||||
redactionContract: inputValue.redactionContract,
|
||||
auditContract: inputValue.auditContract,
|
||||
});
|
||||
if (unsigned.timeoutSeconds > entry.definition.timeoutSeconds) {
|
||||
return invalid('handler timeout widens the Tool definition timeout');
|
||||
}
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
bindingDigest: hash(BINDING_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
function toolKey(tool: Readonly<{ name: string; version: string }>): string {
|
||||
return `${tool.name}@${tool.version}`;
|
||||
}
|
||||
|
||||
export class TrustedToolHandlerBindingRegistry {
|
||||
readonly #snapshot: Readonly<ProjectToolDefinitionSnapshot>;
|
||||
readonly #bindings: ReadonlyMap<string, Readonly<TrustedToolHandlerBinding>>;
|
||||
readonly #metadata: readonly Readonly<TrustedToolHandlerBinding>[];
|
||||
|
||||
constructor(
|
||||
snapshotValue: ProjectToolDefinitionSnapshot,
|
||||
bindingValues: readonly TrustedToolHandlerBinding[],
|
||||
) {
|
||||
const snapshot = normalizeProjectToolDefinitionSnapshot(snapshotValue);
|
||||
if (
|
||||
!Array.isArray(bindingValues) ||
|
||||
bindingValues.length > MAX_TRUSTED_TOOL_HANDLER_BINDINGS
|
||||
) {
|
||||
invalid('handler binding count is invalid');
|
||||
}
|
||||
const bindings = new Map<string, Readonly<TrustedToolHandlerBinding>>();
|
||||
for (const bindingValue of bindingValues) {
|
||||
const binding = normalizeTrustedToolHandlerBinding(bindingValue);
|
||||
const entry = definitionEntry(snapshot, binding.tool);
|
||||
if (
|
||||
binding.snapshotDigest !== snapshot.snapshotDigest ||
|
||||
binding.definitionDigest !== entry.definitionDigest ||
|
||||
binding.timeoutSeconds > entry.definition.timeoutSeconds
|
||||
) {
|
||||
throw new TrustedToolInvocationBindingConflictError();
|
||||
}
|
||||
const key = toolKey(binding.tool);
|
||||
if (bindings.has(key)) {
|
||||
invalid('handler binding is duplicated');
|
||||
}
|
||||
bindings.set(key, binding);
|
||||
}
|
||||
this.#snapshot = snapshot;
|
||||
this.#bindings = bindings;
|
||||
this.#metadata = Object.freeze(
|
||||
[...bindings.values()].sort((left, right) =>
|
||||
toolKey(left.tool).localeCompare(toolKey(right.tool)),
|
||||
),
|
||||
);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
get projectId(): string {
|
||||
return this.#snapshot.projectId;
|
||||
}
|
||||
|
||||
get snapshotDigest(): string {
|
||||
return this.#snapshot.snapshotDigest;
|
||||
}
|
||||
|
||||
list(): readonly Readonly<TrustedToolHandlerBinding>[] {
|
||||
return this.#metadata;
|
||||
}
|
||||
|
||||
resolve(
|
||||
name: string,
|
||||
version: string,
|
||||
profile: DeploymentProfile,
|
||||
): Readonly<TrustedToolHandlerBinding> {
|
||||
const normalizedProfile = normalizeProfile(profile);
|
||||
const binding = this.#bindings.get(toolKey({ name, version }));
|
||||
if (!binding || !binding.profiles.includes(normalizedProfile)) {
|
||||
throw new TrustedToolHandlerUnavailableError();
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
definitionRegistry(): ToolDefinitionRegistry {
|
||||
return new ToolDefinitionRegistry(
|
||||
this.#snapshot.definitions.map((entry) => entry.definition),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { DeploymentProfile } from '../../cluster-control/clusterControlActivation';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '../../security/security';
|
||||
import { semver } from '../../versioning/pinnedSemver';
|
||||
import {
|
||||
InvalidTrustedToolInvocationError,
|
||||
MAX_TRUSTED_TOOL_HANDLER_AUTHORITIES,
|
||||
TRUSTED_TOOL_DEPLOYMENT_PROFILES,
|
||||
TRUSTED_TOOL_HANDLER_AUTHORITIES,
|
||||
type TrustedToolContractIdentity,
|
||||
type TrustedToolHandlerAuthority,
|
||||
} from './contracts';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const CONTRACT_ID_PATTERN =
|
||||
/^[a-z][a-z0-9-]{0,62}(?:\.[a-z][a-z0-9-]{0,62}){1,7}$/;
|
||||
export const WARNING_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const PROFILE_ORDER = new Map(
|
||||
TRUSTED_TOOL_DEPLOYMENT_PROFILES.map((profile, index) => [profile, index]),
|
||||
);
|
||||
export const BINDING_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-handler-binding-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
export const ACTION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-invocation-action-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
export const PLAN_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-invocation-plan-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
export const ADMISSION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-admission-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const CONTRACT_IDENTITY_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-contract-identity-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
export function invalid(message: string): never {
|
||||
throw new InvalidTrustedToolInvocationError(message);
|
||||
}
|
||||
|
||||
export function dataRecord(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
return invalid(`${label} must be an object`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
return invalid(`${label} must contain enumerable data properties`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function exactKeys(
|
||||
value: object,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const keys = Reflect.ownKeys(value);
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (
|
||||
keys.some((key) => typeof key !== 'string' || !allowed.has(key)) ||
|
||||
required.some((key) => !keys.includes(key))
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function hash(domain: Buffer, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function positiveInteger(
|
||||
value: unknown,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function boundedText(
|
||||
value: unknown,
|
||||
maximumBytes: number,
|
||||
label: string,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
CONTROL_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
export function sameFence(
|
||||
left: Readonly<SecurityPolicyFence>,
|
||||
right: Readonly<SecurityPolicyFence>,
|
||||
): boolean {
|
||||
return (
|
||||
left.projectVersion === right.projectVersion &&
|
||||
left.bindingVersion === right.bindingVersion
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeFence(
|
||||
value: SecurityPolicyFence,
|
||||
): Readonly<SecurityPolicyFence> {
|
||||
const record = dataRecord(value, 'policy fence');
|
||||
exactKeys(record, ['bindingVersion', 'projectVersion'], [], 'policy fence');
|
||||
if (
|
||||
!Number.isSafeInteger(value.projectVersion) ||
|
||||
value.projectVersion < 1 ||
|
||||
(value.bindingVersion !== null &&
|
||||
(!Number.isSafeInteger(value.bindingVersion) || value.bindingVersion < 1))
|
||||
) {
|
||||
return invalid('policy fence is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
projectVersion: value.projectVersion,
|
||||
bindingVersion: value.bindingVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeToolIdentity(
|
||||
value: Readonly<{ name: string; version: string }>,
|
||||
label: string,
|
||||
): Readonly<{ name: string; version: string }> {
|
||||
const record = dataRecord(value, label);
|
||||
exactKeys(record, ['name', 'version'], [], label);
|
||||
const name = boundedText(value.name, 255, `${label} name`);
|
||||
const version = boundedText(value.version, 128, `${label} version`);
|
||||
if (!CONTRACT_ID_PATTERN.test(name) || semver().valid(version) !== version) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return Object.freeze({ name, version });
|
||||
}
|
||||
|
||||
export function normalizeContractIdentity(
|
||||
value: TrustedToolContractIdentity,
|
||||
label: string,
|
||||
): Readonly<TrustedToolContractIdentity> {
|
||||
const record = dataRecord(value, label);
|
||||
exactKeys(record, ['id', 'version'], [], label);
|
||||
const id = boundedText(value.id, 255, `${label} id`);
|
||||
const version = boundedText(value.version, 128, `${label} version`);
|
||||
if (!CONTRACT_ID_PATTERN.test(id) || semver().valid(version) !== version) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return Object.freeze({ id, version });
|
||||
}
|
||||
|
||||
export function trustedToolContractIdentityDigest(
|
||||
value: TrustedToolContractIdentity,
|
||||
): string {
|
||||
return hash(
|
||||
CONTRACT_IDENTITY_DIGEST_DOMAIN,
|
||||
normalizeContractIdentity(value, 'Tool contract identity'),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeProfile(value: unknown): DeploymentProfile {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!TRUSTED_TOOL_DEPLOYMENT_PROFILES.includes(value as DeploymentProfile)
|
||||
) {
|
||||
return invalid('deployment profile is invalid');
|
||||
}
|
||||
return value as DeploymentProfile;
|
||||
}
|
||||
|
||||
export function normalizeProfiles(
|
||||
value: readonly DeploymentProfile[],
|
||||
): readonly DeploymentProfile[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < 1 ||
|
||||
value.length > TRUSTED_TOOL_DEPLOYMENT_PROFILES.length
|
||||
) {
|
||||
return invalid('handler profiles are invalid');
|
||||
}
|
||||
const profiles = value.map(normalizeProfile);
|
||||
if (new Set(profiles).size !== profiles.length) {
|
||||
return invalid('handler profiles are duplicated');
|
||||
}
|
||||
return Object.freeze(
|
||||
profiles.sort(
|
||||
(left, right) =>
|
||||
(PROFILE_ORDER.get(left) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(PROFILE_ORDER.get(right) ?? Number.MAX_SAFE_INTEGER),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeAuthorities(
|
||||
value: readonly TrustedToolHandlerAuthority[],
|
||||
): readonly TrustedToolHandlerAuthority[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length > MAX_TRUSTED_TOOL_HANDLER_AUTHORITIES
|
||||
) {
|
||||
return invalid('handler authorities are invalid');
|
||||
}
|
||||
const authorities = value.map((authority) => {
|
||||
if (
|
||||
typeof authority !== 'string' ||
|
||||
!TRUSTED_TOOL_HANDLER_AUTHORITIES.includes(
|
||||
authority as TrustedToolHandlerAuthority,
|
||||
)
|
||||
) {
|
||||
return invalid('handler authority is invalid');
|
||||
}
|
||||
return authority as TrustedToolHandlerAuthority;
|
||||
});
|
||||
if (new Set(authorities).size !== authorities.length) {
|
||||
return invalid('handler authorities are duplicated');
|
||||
}
|
||||
return Object.freeze(authorities.sort());
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { ApprovedActionDispatchRecord } from '../../approved-action/approvedAction';
|
||||
import type { DeploymentProfile } from '../../cluster-control/clusterControlActivation';
|
||||
import type { ProjectPermission } from '../../security/project-policy/projectPolicy';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecurityPrincipal,
|
||||
SecuritySubject,
|
||||
} from '../../security/security';
|
||||
import type {
|
||||
ToolEffect,
|
||||
ToolInvocationStatus,
|
||||
ToolPolicyAuthorizer,
|
||||
ToolRisk,
|
||||
} from '../tool-registry/toolRegistry';
|
||||
import type {
|
||||
ToolInvocationInputArtifact,
|
||||
ToolInvocationInputArtifactReference,
|
||||
ToolInvocationPreviewArtifact,
|
||||
ToolInvocationPreviewArtifactReference,
|
||||
} from '../toolInvocationArtifact';
|
||||
|
||||
export const TRUSTED_TOOL_HANDLER_BINDING_SCHEMA =
|
||||
'qinglong/trusted-tool-handler-binding@v1' as const;
|
||||
export const TRUSTED_TOOL_INVOCATION_PLAN_SCHEMA =
|
||||
'qinglong/trusted-tool-invocation-plan@v1' as const;
|
||||
export const TRUSTED_TOOL_EXECUTION_ADMISSION_SCHEMA =
|
||||
'qinglong/trusted-tool-execution-admission@v1' as const;
|
||||
export const TOOL_INVOKE_ACTION_TYPE = 'tool.invoke' as const;
|
||||
|
||||
export const TRUSTED_TOOL_EXECUTION_CLASSES = [
|
||||
'builtin_in_process',
|
||||
'isolated_process',
|
||||
'remote_worker',
|
||||
'mcp_client',
|
||||
'http_connector',
|
||||
] as const;
|
||||
export const TRUSTED_TOOL_HANDLER_AUTHORITIES = [
|
||||
'artifact.read',
|
||||
'artifact.write',
|
||||
'database.read',
|
||||
'database.write',
|
||||
'filesystem.read',
|
||||
'filesystem.write',
|
||||
'mcp.call',
|
||||
'model.invoke',
|
||||
'network.connect',
|
||||
'process.spawn',
|
||||
'run.control',
|
||||
'secret.use',
|
||||
] as const;
|
||||
export const TRUSTED_TOOL_PREVIEW_FIELD_KINDS = [
|
||||
'count',
|
||||
'identifier',
|
||||
'redacted',
|
||||
'text',
|
||||
] as const;
|
||||
export const TRUSTED_TOOL_DEPLOYMENT_PROFILES = [
|
||||
'edge',
|
||||
'standalone',
|
||||
'cluster-control',
|
||||
'worker',
|
||||
] as const satisfies readonly DeploymentProfile[];
|
||||
|
||||
export const MAX_TRUSTED_TOOL_HANDLER_BINDINGS = 128;
|
||||
export const MAX_TRUSTED_TOOL_HANDLER_AUTHORITIES = 16;
|
||||
export const MAX_TRUSTED_TOOL_PREVIEW_FIELDS = 16;
|
||||
export const MAX_TRUSTED_TOOL_PREVIEW_WARNINGS = 8;
|
||||
|
||||
export type TrustedToolExecutionClass =
|
||||
(typeof TRUSTED_TOOL_EXECUTION_CLASSES)[number];
|
||||
export type TrustedToolHandlerAuthority =
|
||||
(typeof TRUSTED_TOOL_HANDLER_AUTHORITIES)[number];
|
||||
export type TrustedToolPreviewFieldKind =
|
||||
(typeof TRUSTED_TOOL_PREVIEW_FIELD_KINDS)[number];
|
||||
|
||||
export interface TrustedToolContractIdentity {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
export interface CreateTrustedToolHandlerBindingInput {
|
||||
readonly tool: Readonly<{ name: string; version: string }>;
|
||||
readonly adapter: TrustedToolContractIdentity;
|
||||
readonly executionClass: TrustedToolExecutionClass;
|
||||
readonly profiles: readonly DeploymentProfile[];
|
||||
readonly authorities: readonly TrustedToolHandlerAuthority[];
|
||||
readonly timeoutSeconds: number;
|
||||
readonly redactionContract: TrustedToolContractIdentity;
|
||||
readonly auditContract: TrustedToolContractIdentity;
|
||||
}
|
||||
|
||||
export interface TrustedToolHandlerBinding
|
||||
extends CreateTrustedToolHandlerBindingInput {
|
||||
readonly schema: typeof TRUSTED_TOOL_HANDLER_BINDING_SCHEMA;
|
||||
readonly snapshotDigest: string;
|
||||
readonly definitionDigest: string;
|
||||
readonly bindingDigest: string;
|
||||
}
|
||||
|
||||
export interface TrustedToolInvocationPreviewField {
|
||||
readonly kind: TrustedToolPreviewFieldKind;
|
||||
readonly label: string;
|
||||
readonly value: string | null;
|
||||
}
|
||||
|
||||
export interface TrustedToolInvocationPreview {
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly fields: readonly Readonly<TrustedToolInvocationPreviewField>[];
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
export interface TrustedToolInvocationPlan {
|
||||
readonly schema: typeof TRUSTED_TOOL_INVOCATION_PLAN_SCHEMA;
|
||||
readonly status: ToolInvocationStatus;
|
||||
readonly actionType: typeof TOOL_INVOKE_ACTION_TYPE;
|
||||
readonly actionRef: string;
|
||||
readonly projectId: string;
|
||||
readonly requestedBy: Readonly<SecuritySubject>;
|
||||
readonly tool: Readonly<{ name: string; version: string }>;
|
||||
readonly permission: ProjectPermission;
|
||||
readonly requiredPermissions: readonly ProjectPermission[];
|
||||
readonly effect: ToolEffect;
|
||||
readonly risk: ToolRisk;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence>;
|
||||
readonly profile: DeploymentProfile;
|
||||
readonly snapshotDigest: string;
|
||||
readonly definitionDigest: string;
|
||||
readonly binding: Readonly<TrustedToolHandlerBinding>;
|
||||
readonly timeoutSeconds: number;
|
||||
readonly invocationArtifact: Readonly<ToolInvocationInputArtifactReference>;
|
||||
readonly invocationActionDigest: string;
|
||||
readonly previewArtifact: Readonly<ToolInvocationPreviewArtifactReference>;
|
||||
readonly actionDigest: string;
|
||||
readonly sealedAtMs: number;
|
||||
readonly planDigest: string;
|
||||
}
|
||||
|
||||
export interface TrustedToolInvocationPlanBundle {
|
||||
readonly plan: Readonly<TrustedToolInvocationPlan>;
|
||||
readonly inputArtifact: Readonly<ToolInvocationInputArtifact>;
|
||||
readonly previewArtifact: Readonly<ToolInvocationPreviewArtifact>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionStartEvidence {
|
||||
readonly stepRun: Readonly<{
|
||||
id: string;
|
||||
version: number;
|
||||
digest: string;
|
||||
}>;
|
||||
readonly trace: Readonly<{
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
digest: string;
|
||||
}>;
|
||||
readonly audit: Readonly<{
|
||||
eventId: string;
|
||||
digest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TrustedToolExecutionAdmission {
|
||||
readonly schema: typeof TRUSTED_TOOL_EXECUTION_ADMISSION_SCHEMA;
|
||||
readonly actionRef: string;
|
||||
readonly planDigest: string;
|
||||
readonly actionDigest: string;
|
||||
readonly projectId: string;
|
||||
readonly requestedBy: Readonly<SecuritySubject>;
|
||||
readonly tool: Readonly<{ name: string; version: string }>;
|
||||
readonly profile: DeploymentProfile;
|
||||
readonly snapshotDigest: string;
|
||||
readonly definitionDigest: string;
|
||||
readonly bindingDigest: string;
|
||||
readonly invocationArtifact: Readonly<ToolInvocationInputArtifactReference>;
|
||||
readonly previewArtifact: Readonly<ToolInvocationPreviewArtifactReference>;
|
||||
readonly adapter: Readonly<TrustedToolContractIdentity>;
|
||||
readonly redactionContract: Readonly<TrustedToolContractIdentity>;
|
||||
readonly auditContract: Readonly<TrustedToolContractIdentity>;
|
||||
readonly executionClass: TrustedToolExecutionClass;
|
||||
readonly timeoutSeconds: number;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence>;
|
||||
readonly approvalRequestId: string | null;
|
||||
readonly approvalDispatchId: string | null;
|
||||
readonly approvalDispatchDigest: string | null;
|
||||
readonly evidence: Readonly<ToolExecutionStartEvidence>;
|
||||
readonly admittedAtMs: number;
|
||||
readonly admissionDigest: string;
|
||||
}
|
||||
|
||||
export interface AdmitTrustedToolExecutionInput {
|
||||
readonly principal: SecurityPrincipal;
|
||||
readonly profile: DeploymentProfile;
|
||||
readonly nowMs: number;
|
||||
readonly authorizer: ToolPolicyAuthorizer;
|
||||
readonly evidence: ToolExecutionStartEvidence;
|
||||
readonly dispatch?: ApprovedActionDispatchRecord;
|
||||
}
|
||||
|
||||
export class InvalidTrustedToolInvocationError extends TypeError {
|
||||
readonly code = 'TRUSTED_TOOL_INVOCATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Trusted Tool invocation is invalid: ${message}`);
|
||||
this.name = 'InvalidTrustedToolInvocationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TrustedToolHandlerUnavailableError extends Error {
|
||||
readonly code = 'TRUSTED_TOOL_HANDLER_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('No exact trusted Tool handler binding is available');
|
||||
this.name = 'TrustedToolHandlerUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TrustedToolInvocationBindingConflictError extends Error {
|
||||
readonly code = 'TRUSTED_TOOL_INVOCATION_BINDING_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Trusted Tool invocation binding changed');
|
||||
this.name = 'TrustedToolInvocationBindingConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TrustedToolExecutionPolicyDeniedError extends Error {
|
||||
readonly code = 'TRUSTED_TOOL_EXECUTION_POLICY_DENIED';
|
||||
|
||||
constructor() {
|
||||
super('Current Project Policy denies Tool execution');
|
||||
this.name = 'TrustedToolExecutionPolicyDeniedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TrustedToolExecutionApprovalRequiredError extends Error {
|
||||
readonly code = 'TRUSTED_TOOL_EXECUTION_APPROVAL_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Current Project Policy requires a new Tool approval');
|
||||
this.name = 'TrustedToolExecutionApprovalRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TrustedToolExecutionPolicyUnavailableError extends Error {
|
||||
readonly code = 'TRUSTED_TOOL_EXECUTION_POLICY_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Current Project Policy is unavailable for Tool execution', options);
|
||||
this.name = 'TrustedToolExecutionPolicyUnavailableError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
import {
|
||||
normalizeApprovedActionDispatchRecord,
|
||||
type ApprovedActionBinding,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '../../approved-action/approvedAction';
|
||||
import type { DeploymentProfile } from '../../cluster-control/clusterControlActivation';
|
||||
import {
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPermission,
|
||||
normalizeProjectPolicySubject,
|
||||
type ProjectPermission,
|
||||
} from '../../security/project-policy/projectPolicy';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '../../security/security';
|
||||
import {
|
||||
TOOL_INVOCATION_SCHEMA,
|
||||
ToolDefinitionRegistry,
|
||||
type PreparedToolInvocation,
|
||||
} from '../tool-registry/toolRegistry';
|
||||
import {
|
||||
createToolInvocationInputArtifact,
|
||||
createToolInvocationPreviewArtifact,
|
||||
normalizeToolInvocationInputArtifactReference,
|
||||
normalizeToolInvocationPreviewArtifactReference,
|
||||
toolInvocationInputArtifactReference,
|
||||
toolInvocationPreviewArtifactReference,
|
||||
} from '../toolInvocationArtifact';
|
||||
import {
|
||||
TrustedToolHandlerBindingRegistry,
|
||||
normalizeTrustedToolHandlerBinding,
|
||||
} from './binding';
|
||||
import {
|
||||
ACTION_DIGEST_DOMAIN,
|
||||
PLAN_DIGEST_DOMAIN,
|
||||
WARNING_PATTERN,
|
||||
boundedText,
|
||||
dataRecord,
|
||||
digest,
|
||||
exactKeys,
|
||||
hash,
|
||||
identifier,
|
||||
invalid,
|
||||
normalizeFence,
|
||||
normalizeProfile,
|
||||
normalizeToolIdentity,
|
||||
positiveInteger,
|
||||
sameSubject,
|
||||
timestamp,
|
||||
trustedToolContractIdentityDigest,
|
||||
} from './codec';
|
||||
import {
|
||||
MAX_TRUSTED_TOOL_PREVIEW_FIELDS,
|
||||
MAX_TRUSTED_TOOL_PREVIEW_WARNINGS,
|
||||
TOOL_INVOKE_ACTION_TYPE,
|
||||
TRUSTED_TOOL_INVOCATION_PLAN_SCHEMA,
|
||||
TRUSTED_TOOL_PREVIEW_FIELD_KINDS,
|
||||
TrustedToolExecutionApprovalRequiredError,
|
||||
TrustedToolInvocationBindingConflictError,
|
||||
type TrustedToolInvocationPlan,
|
||||
type TrustedToolInvocationPlanBundle,
|
||||
type TrustedToolInvocationPreview,
|
||||
type TrustedToolInvocationPreviewField,
|
||||
} from './contracts';
|
||||
|
||||
function normalizePreviewField(
|
||||
value: TrustedToolInvocationPreviewField,
|
||||
): Readonly<TrustedToolInvocationPreviewField> {
|
||||
const record = dataRecord(value, 'preview field');
|
||||
exactKeys(record, ['kind', 'label', 'value'], [], 'preview field');
|
||||
if (!TRUSTED_TOOL_PREVIEW_FIELD_KINDS.includes(value.kind)) {
|
||||
return invalid('preview field kind is invalid');
|
||||
}
|
||||
const label = boundedText(value.label, 128, 'preview field label');
|
||||
if (
|
||||
(value.kind === 'redacted' && value.value !== null) ||
|
||||
(value.kind !== 'redacted' && value.value === null)
|
||||
) {
|
||||
return invalid('preview field redaction is invalid');
|
||||
}
|
||||
const normalizedValue =
|
||||
value.value === null
|
||||
? null
|
||||
: boundedText(value.value, 512, 'preview field value');
|
||||
return Object.freeze({ kind: value.kind, label, value: normalizedValue });
|
||||
}
|
||||
|
||||
export function normalizeTrustedToolInvocationPreview(
|
||||
value: TrustedToolInvocationPreview,
|
||||
): Readonly<TrustedToolInvocationPreview> {
|
||||
const record = dataRecord(value, 'preview');
|
||||
exactKeys(record, ['fields', 'summary', 'title', 'warnings'], [], 'preview');
|
||||
if (
|
||||
!Array.isArray(value.fields) ||
|
||||
value.fields.length > MAX_TRUSTED_TOOL_PREVIEW_FIELDS ||
|
||||
!Array.isArray(value.warnings) ||
|
||||
value.warnings.length > MAX_TRUSTED_TOOL_PREVIEW_WARNINGS
|
||||
) {
|
||||
return invalid('preview collections are invalid');
|
||||
}
|
||||
const fields = value.fields.map(normalizePreviewField);
|
||||
const warnings = value.warnings.map((warning) => {
|
||||
if (typeof warning !== 'string' || !WARNING_PATTERN.test(warning)) {
|
||||
return invalid('preview warning is invalid');
|
||||
}
|
||||
return warning;
|
||||
});
|
||||
if (new Set(warnings).size !== warnings.length) {
|
||||
return invalid('preview warnings are duplicated');
|
||||
}
|
||||
return Object.freeze({
|
||||
title: boundedText(value.title, 256, 'preview title'),
|
||||
summary: boundedText(value.summary, 2048, 'preview summary'),
|
||||
fields: Object.freeze(fields),
|
||||
warnings: Object.freeze([...warnings].sort()),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePermissions(
|
||||
value: readonly ProjectPermission[],
|
||||
label: string,
|
||||
): readonly ProjectPermission[] {
|
||||
if (!Array.isArray(value) || value.length > 16) {
|
||||
return invalid(`${label} are invalid`);
|
||||
}
|
||||
const permissions = value.map((permission) => {
|
||||
try {
|
||||
return normalizeProjectPermission(permission);
|
||||
} catch {
|
||||
return invalid(`${label} are invalid`);
|
||||
}
|
||||
});
|
||||
if (
|
||||
new Set(permissions).size !== permissions.length ||
|
||||
permissions.some((permission) => permission.startsWith('tool.call:'))
|
||||
) {
|
||||
return invalid(`${label} are duplicated or nested`);
|
||||
}
|
||||
return Object.freeze([...permissions].sort());
|
||||
}
|
||||
|
||||
function normalizePreparedInvocation(
|
||||
value: PreparedToolInvocation,
|
||||
registry: ToolDefinitionRegistry,
|
||||
): Readonly<PreparedToolInvocation> {
|
||||
const record = dataRecord(value, 'prepared invocation');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'actionDigest',
|
||||
'effect',
|
||||
'fence',
|
||||
'input',
|
||||
'inputDigest',
|
||||
'permission',
|
||||
'projectId',
|
||||
'requestedBy',
|
||||
'requiredPermissions',
|
||||
'risk',
|
||||
'schema',
|
||||
'status',
|
||||
'timeoutSeconds',
|
||||
'tool',
|
||||
],
|
||||
[],
|
||||
'prepared invocation',
|
||||
);
|
||||
if (
|
||||
value.schema !== TOOL_INVOCATION_SCHEMA ||
|
||||
(value.status !== 'ready' && value.status !== 'approval_required')
|
||||
) {
|
||||
return invalid('prepared invocation schema or status is invalid');
|
||||
}
|
||||
assertProjectPolicyProjectId(value.projectId);
|
||||
const requestedBy = normalizeProjectPolicySubject(value.requestedBy);
|
||||
const tool = normalizeToolIdentity(value.tool, 'prepared Tool');
|
||||
const definition = registry.resolve(tool.name, tool.version);
|
||||
const permission = normalizeProjectPermission(value.permission);
|
||||
const expectedPermission = normalizeProjectPermission(
|
||||
`tool.call:${definition.name}`,
|
||||
);
|
||||
const requiredPermissions = normalizePermissions(
|
||||
value.requiredPermissions,
|
||||
'prepared required permissions',
|
||||
);
|
||||
if (
|
||||
permission !== expectedPermission ||
|
||||
JSON.stringify(requiredPermissions) !==
|
||||
JSON.stringify(definition.requiredPermissions) ||
|
||||
value.effect !== definition.effect ||
|
||||
value.risk !== definition.risk ||
|
||||
value.timeoutSeconds !== definition.timeoutSeconds
|
||||
) {
|
||||
return invalid('prepared invocation drifts from its Tool definition');
|
||||
}
|
||||
const input = registry.normalizeInput(tool.name, tool.version, value.input);
|
||||
const inputDigest = digest(value.inputDigest, 'input digest');
|
||||
if (hash(Buffer.alloc(0), input) !== inputDigest) {
|
||||
return invalid('prepared invocation input digest does not match');
|
||||
}
|
||||
const fence = normalizeFence(value.fence);
|
||||
const actionDigest = digest(value.actionDigest, 'invocation action digest');
|
||||
const expectedActionDigest = hash(Buffer.alloc(0), {
|
||||
schema: TOOL_INVOCATION_SCHEMA,
|
||||
projectId: value.projectId,
|
||||
requestedBy,
|
||||
tool,
|
||||
permission,
|
||||
requiredPermissions,
|
||||
effect: definition.effect,
|
||||
risk: definition.risk,
|
||||
timeoutSeconds: definition.timeoutSeconds,
|
||||
inputDigest,
|
||||
});
|
||||
if (actionDigest !== expectedActionDigest) {
|
||||
return invalid('prepared invocation action digest does not match');
|
||||
}
|
||||
return Object.freeze({
|
||||
status: value.status,
|
||||
schema: TOOL_INVOCATION_SCHEMA,
|
||||
projectId: value.projectId,
|
||||
requestedBy,
|
||||
tool,
|
||||
permission,
|
||||
requiredPermissions,
|
||||
effect: definition.effect,
|
||||
risk: definition.risk,
|
||||
timeoutSeconds: definition.timeoutSeconds,
|
||||
fence,
|
||||
input,
|
||||
inputDigest,
|
||||
actionDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function actionFields(
|
||||
value: Omit<
|
||||
TrustedToolInvocationPlan,
|
||||
'actionDigest' | 'planDigest' | 'previewArtifact'
|
||||
>,
|
||||
): object {
|
||||
return {
|
||||
schema: value.schema,
|
||||
status: value.status,
|
||||
actionType: value.actionType,
|
||||
actionRef: value.actionRef,
|
||||
projectId: value.projectId,
|
||||
requestedBy: value.requestedBy,
|
||||
tool: value.tool,
|
||||
permission: value.permission,
|
||||
requiredPermissions: value.requiredPermissions,
|
||||
effect: value.effect,
|
||||
risk: value.risk,
|
||||
policyFence: value.policyFence,
|
||||
profile: value.profile,
|
||||
snapshotDigest: value.snapshotDigest,
|
||||
definitionDigest: value.definitionDigest,
|
||||
bindingDigest: value.binding.bindingDigest,
|
||||
timeoutSeconds: value.timeoutSeconds,
|
||||
invocationArtifact: value.invocationArtifact,
|
||||
invocationActionDigest: value.invocationActionDigest,
|
||||
};
|
||||
}
|
||||
|
||||
function planWithoutDigest(
|
||||
value: Readonly<TrustedToolInvocationPlan>,
|
||||
): Omit<TrustedToolInvocationPlan, 'planDigest'> {
|
||||
const { planDigest: _planDigest, ...unsigned } = value;
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
export function createTrustedToolInvocationPlan(
|
||||
bindings: TrustedToolHandlerBindingRegistry,
|
||||
invocationValue: PreparedToolInvocation,
|
||||
inputValue: Readonly<{
|
||||
actionRef: string;
|
||||
profile: DeploymentProfile;
|
||||
preview: TrustedToolInvocationPreview;
|
||||
inputArtifactId: string;
|
||||
previewArtifactId: string;
|
||||
artifactKeyId: string;
|
||||
artifactKey: Uint8Array;
|
||||
artifactNonce: Uint8Array;
|
||||
sealedAtMs: number;
|
||||
}>,
|
||||
): Readonly<TrustedToolInvocationPlanBundle> {
|
||||
if (!(bindings instanceof TrustedToolHandlerBindingRegistry)) {
|
||||
return invalid('handler binding registry is invalid');
|
||||
}
|
||||
const input = dataRecord(inputValue, 'plan input');
|
||||
exactKeys(
|
||||
input,
|
||||
[
|
||||
'actionRef',
|
||||
'artifactKey',
|
||||
'artifactKeyId',
|
||||
'artifactNonce',
|
||||
'inputArtifactId',
|
||||
'preview',
|
||||
'previewArtifactId',
|
||||
'profile',
|
||||
'sealedAtMs',
|
||||
],
|
||||
[],
|
||||
'plan input',
|
||||
);
|
||||
const definitionRegistry = bindings.definitionRegistry();
|
||||
const invocation = normalizePreparedInvocation(
|
||||
invocationValue,
|
||||
definitionRegistry,
|
||||
);
|
||||
if (invocation.projectId !== bindings.projectId) {
|
||||
throw new TrustedToolInvocationBindingConflictError();
|
||||
}
|
||||
const profile = normalizeProfile(inputValue.profile);
|
||||
const binding = bindings.resolve(
|
||||
invocation.tool.name,
|
||||
invocation.tool.version,
|
||||
profile,
|
||||
);
|
||||
const preview = normalizeTrustedToolInvocationPreview(inputValue.preview);
|
||||
const sealedAtMs = timestamp(inputValue.sealedAtMs, 'plan seal time');
|
||||
const normalizedActionRef = identifier(
|
||||
inputValue.actionRef,
|
||||
'action reference',
|
||||
);
|
||||
const inputArtifact = createToolInvocationInputArtifact(
|
||||
{
|
||||
artifactId: inputValue.inputArtifactId,
|
||||
projectId: invocation.projectId,
|
||||
actionRef: normalizedActionRef,
|
||||
requestedBy: invocation.requestedBy,
|
||||
tool: invocation.tool,
|
||||
input: invocation.input,
|
||||
inputDigest: invocation.inputDigest,
|
||||
invocationActionDigest: invocation.actionDigest,
|
||||
keyId: inputValue.artifactKeyId,
|
||||
key: inputValue.artifactKey,
|
||||
sealedAtMs,
|
||||
},
|
||||
() => inputValue.artifactNonce,
|
||||
);
|
||||
const invocationArtifact =
|
||||
toolInvocationInputArtifactReference(inputArtifact);
|
||||
const base = Object.freeze({
|
||||
schema: TRUSTED_TOOL_INVOCATION_PLAN_SCHEMA,
|
||||
status: invocation.status,
|
||||
actionType: TOOL_INVOKE_ACTION_TYPE,
|
||||
actionRef: normalizedActionRef,
|
||||
projectId: invocation.projectId,
|
||||
requestedBy: invocation.requestedBy,
|
||||
tool: invocation.tool,
|
||||
permission: invocation.permission,
|
||||
requiredPermissions: invocation.requiredPermissions,
|
||||
effect: invocation.effect,
|
||||
risk: invocation.risk,
|
||||
policyFence: invocation.fence,
|
||||
profile,
|
||||
snapshotDigest: bindings.snapshotDigest,
|
||||
definitionDigest: binding.definitionDigest,
|
||||
binding,
|
||||
timeoutSeconds: Math.min(invocation.timeoutSeconds, binding.timeoutSeconds),
|
||||
invocationArtifact,
|
||||
invocationActionDigest: invocation.actionDigest,
|
||||
sealedAtMs,
|
||||
} satisfies Omit<TrustedToolInvocationPlan, 'actionDigest' | 'planDigest' | 'previewArtifact'>);
|
||||
const actionDigest = hash(ACTION_DIGEST_DOMAIN, actionFields(base));
|
||||
const previewArtifact = createToolInvocationPreviewArtifact({
|
||||
artifactId: inputValue.previewArtifactId,
|
||||
projectId: invocation.projectId,
|
||||
actionRef: normalizedActionRef,
|
||||
actionDigest,
|
||||
preview,
|
||||
redactionContractDigest: trustedToolContractIdentityDigest(
|
||||
binding.redactionContract,
|
||||
),
|
||||
sealedAtMs,
|
||||
});
|
||||
const previewArtifactReference =
|
||||
toolInvocationPreviewArtifactReference(previewArtifact);
|
||||
const unsigned = Object.freeze({
|
||||
...base,
|
||||
previewArtifact: previewArtifactReference,
|
||||
actionDigest,
|
||||
});
|
||||
const plan = Object.freeze({
|
||||
...unsigned,
|
||||
planDigest: hash(PLAN_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
return Object.freeze({
|
||||
plan,
|
||||
inputArtifact,
|
||||
previewArtifact,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeTrustedToolInvocationPlan(
|
||||
value: TrustedToolInvocationPlan,
|
||||
bindings: TrustedToolHandlerBindingRegistry,
|
||||
): Readonly<TrustedToolInvocationPlan> {
|
||||
if (!(bindings instanceof TrustedToolHandlerBindingRegistry)) {
|
||||
return invalid('handler binding registry is invalid');
|
||||
}
|
||||
const record = dataRecord(value, 'trusted invocation plan');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'actionRef',
|
||||
'actionDigest',
|
||||
'actionType',
|
||||
'binding',
|
||||
'definitionDigest',
|
||||
'effect',
|
||||
'invocationArtifact',
|
||||
'invocationActionDigest',
|
||||
'permission',
|
||||
'planDigest',
|
||||
'policyFence',
|
||||
'previewArtifact',
|
||||
'profile',
|
||||
'projectId',
|
||||
'requestedBy',
|
||||
'requiredPermissions',
|
||||
'risk',
|
||||
'schema',
|
||||
'sealedAtMs',
|
||||
'snapshotDigest',
|
||||
'status',
|
||||
'timeoutSeconds',
|
||||
'tool',
|
||||
],
|
||||
[],
|
||||
'trusted invocation plan',
|
||||
);
|
||||
if (
|
||||
value.schema !== TRUSTED_TOOL_INVOCATION_PLAN_SCHEMA ||
|
||||
value.actionType !== TOOL_INVOKE_ACTION_TYPE ||
|
||||
(value.status !== 'ready' && value.status !== 'approval_required')
|
||||
) {
|
||||
return invalid('trusted invocation plan schema or status is invalid');
|
||||
}
|
||||
assertProjectPolicyProjectId(value.projectId);
|
||||
const requestedBy = normalizeProjectPolicySubject(value.requestedBy);
|
||||
const tool = normalizeToolIdentity(value.tool, 'trusted plan Tool');
|
||||
const profile = normalizeProfile(value.profile);
|
||||
const currentBinding = bindings.resolve(tool.name, tool.version, profile);
|
||||
const binding = normalizeTrustedToolHandlerBinding(value.binding);
|
||||
if (
|
||||
value.projectId !== bindings.projectId ||
|
||||
digest(value.snapshotDigest, 'snapshot digest') !==
|
||||
bindings.snapshotDigest ||
|
||||
binding.bindingDigest !== currentBinding.bindingDigest ||
|
||||
digest(value.definitionDigest, 'definition digest') !==
|
||||
binding.definitionDigest
|
||||
) {
|
||||
throw new TrustedToolInvocationBindingConflictError();
|
||||
}
|
||||
const definitionRegistry = bindings.definitionRegistry();
|
||||
const definition = definitionRegistry.resolve(tool.name, tool.version);
|
||||
const permission = normalizeProjectPermission(value.permission);
|
||||
const requiredPermissions = normalizePermissions(
|
||||
value.requiredPermissions,
|
||||
'trusted plan required permissions',
|
||||
);
|
||||
const policyFence = normalizeFence(value.policyFence);
|
||||
const invocationArtifact = normalizeToolInvocationInputArtifactReference(
|
||||
value.invocationArtifact,
|
||||
);
|
||||
const invocationActionDigest = digest(
|
||||
value.invocationActionDigest,
|
||||
'invocation action digest',
|
||||
);
|
||||
const expectedInvocationActionDigest = hash(Buffer.alloc(0), {
|
||||
schema: TOOL_INVOCATION_SCHEMA,
|
||||
projectId: value.projectId,
|
||||
requestedBy,
|
||||
tool,
|
||||
permission,
|
||||
requiredPermissions,
|
||||
effect: definition.effect,
|
||||
risk: definition.risk,
|
||||
timeoutSeconds: definition.timeoutSeconds,
|
||||
inputDigest: invocationArtifact.inputDigest,
|
||||
});
|
||||
if (
|
||||
permission !== normalizeProjectPermission(`tool.call:${definition.name}`) ||
|
||||
JSON.stringify(requiredPermissions) !==
|
||||
JSON.stringify(definition.requiredPermissions) ||
|
||||
value.effect !== definition.effect ||
|
||||
value.risk !== definition.risk ||
|
||||
invocationActionDigest !== expectedInvocationActionDigest
|
||||
) {
|
||||
return invalid('trusted invocation plan drifts from its Tool definition');
|
||||
}
|
||||
const timeoutSeconds = positiveInteger(
|
||||
value.timeoutSeconds,
|
||||
definition.timeoutSeconds,
|
||||
'trusted plan timeout',
|
||||
);
|
||||
if (
|
||||
timeoutSeconds !==
|
||||
Math.min(definition.timeoutSeconds, binding.timeoutSeconds)
|
||||
) {
|
||||
return invalid('trusted plan timeout does not match its binding');
|
||||
}
|
||||
const previewArtifact = normalizeToolInvocationPreviewArtifactReference(
|
||||
value.previewArtifact,
|
||||
);
|
||||
if (
|
||||
previewArtifact.redactionContractDigest !==
|
||||
trustedToolContractIdentityDigest(binding.redactionContract)
|
||||
) {
|
||||
return invalid(
|
||||
'trusted preview Artifact redaction contract does not match',
|
||||
);
|
||||
}
|
||||
const base = Object.freeze({
|
||||
schema: TRUSTED_TOOL_INVOCATION_PLAN_SCHEMA,
|
||||
status: value.status,
|
||||
actionType: TOOL_INVOKE_ACTION_TYPE,
|
||||
actionRef: identifier(value.actionRef, 'action reference'),
|
||||
projectId: value.projectId,
|
||||
requestedBy,
|
||||
tool,
|
||||
permission,
|
||||
requiredPermissions,
|
||||
effect: definition.effect,
|
||||
risk: definition.risk,
|
||||
policyFence,
|
||||
profile,
|
||||
snapshotDigest: bindings.snapshotDigest,
|
||||
definitionDigest: binding.definitionDigest,
|
||||
binding,
|
||||
timeoutSeconds,
|
||||
invocationArtifact,
|
||||
invocationActionDigest,
|
||||
sealedAtMs: timestamp(value.sealedAtMs, 'plan seal time'),
|
||||
} satisfies Omit<TrustedToolInvocationPlan, 'actionDigest' | 'planDigest' | 'previewArtifact'>);
|
||||
const actionDigest = digest(value.actionDigest, 'action digest');
|
||||
const expectedActionDigest = hash(ACTION_DIGEST_DOMAIN, actionFields(base));
|
||||
if (
|
||||
actionDigest !== expectedActionDigest ||
|
||||
previewArtifact.actionDigest !== actionDigest
|
||||
) {
|
||||
return invalid('trusted invocation action digest does not match');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
...base,
|
||||
previewArtifact,
|
||||
actionDigest,
|
||||
});
|
||||
const planDigest = digest(value.planDigest, 'plan digest');
|
||||
if (hash(PLAN_DIGEST_DOMAIN, unsigned) !== planDigest) {
|
||||
return invalid('trusted invocation plan digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, planDigest });
|
||||
}
|
||||
|
||||
export function trustedToolInvocationApprovalBinding(
|
||||
planValue: TrustedToolInvocationPlan,
|
||||
bindings: TrustedToolHandlerBindingRegistry,
|
||||
): Readonly<ApprovedActionBinding> {
|
||||
const plan = normalizeTrustedToolInvocationPlan(planValue, bindings);
|
||||
if (plan.status !== 'approval_required') {
|
||||
throw new TrustedToolExecutionApprovalRequiredError();
|
||||
}
|
||||
return Object.freeze({
|
||||
permission: plan.permission,
|
||||
actionType: TOOL_INVOKE_ACTION_TYPE,
|
||||
actionRef: plan.actionRef,
|
||||
actionDigest: plan.actionDigest,
|
||||
previewDigest: plan.previewArtifact.previewDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertTrustedToolApprovedDispatch(
|
||||
planValue: TrustedToolInvocationPlan,
|
||||
bindings: TrustedToolHandlerBindingRegistry,
|
||||
dispatchValue: ApprovedActionDispatchRecord,
|
||||
): Readonly<ApprovedActionDispatchRecord> {
|
||||
const plan = normalizeTrustedToolInvocationPlan(planValue, bindings);
|
||||
if (plan.status !== 'approval_required') {
|
||||
throw new TrustedToolInvocationBindingConflictError();
|
||||
}
|
||||
const dispatch = normalizeApprovedActionDispatchRecord(dispatchValue);
|
||||
const expected = trustedToolInvocationApprovalBinding(plan, bindings);
|
||||
if (
|
||||
dispatch.projectId !== plan.projectId ||
|
||||
!sameSubject(dispatch.requestedBy, plan.requestedBy) ||
|
||||
dispatch.action.permission !== expected.permission ||
|
||||
dispatch.action.actionType !== expected.actionType ||
|
||||
dispatch.action.actionRef !== expected.actionRef ||
|
||||
dispatch.action.actionDigest !== expected.actionDigest ||
|
||||
dispatch.action.previewDigest !== expected.previewDigest ||
|
||||
dispatch.createdAtMs < plan.sealedAtMs
|
||||
) {
|
||||
throw new TrustedToolInvocationBindingConflictError();
|
||||
}
|
||||
return dispatch;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { RunRepositoryReader } from '../run/runRepository';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
transitionStepRunMutation,
|
||||
type StepRunRepository,
|
||||
} from '../run/stepRun';
|
||||
import {
|
||||
normalizeToolExecutionCompletionRecord,
|
||||
ToolExecutionCompletionConflictError,
|
||||
type ToolExecutionCompletionRecord,
|
||||
} from './toolExecutionCompletion';
|
||||
import {
|
||||
createToolExecutionFailureCompletionCommand,
|
||||
createToolExecutionFailureResult,
|
||||
normalizeToolExecutionFailureCompletionRecord,
|
||||
ToolExecutionFailureCompletionConflictError,
|
||||
ToolExecutionFailureCompletionUnavailableError,
|
||||
toolExecutionFailureCompletionRecord,
|
||||
type ToolExecutionFailureCompletionRecord,
|
||||
type ToolExecutionFailureCompletionRepository,
|
||||
type ToolExecutionFailureOutcome,
|
||||
} from './toolExecutionFailureCompletion';
|
||||
import {
|
||||
normalizeToolExecutionStartBarrierRecord,
|
||||
type ToolExecutionStartBarrierRecord,
|
||||
} from './toolExecutionStartBarrier';
|
||||
import {
|
||||
executeAndCompleteTrustedToolSuccess,
|
||||
type TrustedToolSuccessCompletionDependencies,
|
||||
type TrustedToolSuccessCompletionResult,
|
||||
} from './trustedToolSuccessCompletion';
|
||||
import {
|
||||
TrustedToolExecutionDeadlineExceededError,
|
||||
TrustedToolExecutionFailedError,
|
||||
} from './trustedToolExecution';
|
||||
import type { ToolJsonValue } from './tool-registry/toolRegistry';
|
||||
|
||||
export interface TrustedToolFailureCompletionIdentities {
|
||||
readonly mutationId: string;
|
||||
readonly eventId: string;
|
||||
}
|
||||
|
||||
export interface TrustedToolFailureCompletionIdentityFactory {
|
||||
create(startId: string): TrustedToolFailureCompletionIdentities;
|
||||
}
|
||||
|
||||
export interface TrustedToolCompletionDependencies
|
||||
extends TrustedToolSuccessCompletionDependencies {
|
||||
readonly failureCompletions: ToolExecutionFailureCompletionRepository;
|
||||
readonly failureIdentities: TrustedToolFailureCompletionIdentityFactory;
|
||||
readonly stepRuns: Pick<StepRunRepository, 'findById'>;
|
||||
readonly runs: Pick<RunRepositoryReader, 'findRunById'>;
|
||||
}
|
||||
|
||||
export interface TrustedToolSucceededCompletionResult {
|
||||
readonly outcome: 'succeeded';
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly completion: Readonly<ToolExecutionCompletionRecord>;
|
||||
readonly output: ToolJsonValue;
|
||||
}
|
||||
|
||||
export interface TrustedToolFailedCompletionResult {
|
||||
readonly outcome: ToolExecutionFailureOutcome;
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly completion: Readonly<ToolExecutionFailureCompletionRecord>;
|
||||
}
|
||||
|
||||
export type TrustedToolCompletionResult =
|
||||
| TrustedToolSucceededCompletionResult
|
||||
| TrustedToolFailedCompletionResult;
|
||||
|
||||
interface DurableTerminalState {
|
||||
readonly success: Readonly<ToolExecutionCompletionRecord> | null;
|
||||
readonly failure: Readonly<ToolExecutionFailureCompletionRecord> | null;
|
||||
}
|
||||
|
||||
const TERMINAL_RUN_STATUSES = new Set([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
]);
|
||||
|
||||
function unavailable(cause?: unknown): never {
|
||||
throw new ToolExecutionFailureCompletionUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function conflict(): never {
|
||||
throw new ToolExecutionFailureCompletionConflictError();
|
||||
}
|
||||
|
||||
function sameValue(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function validateDependencies(
|
||||
dependencies: TrustedToolCompletionDependencies,
|
||||
): void {
|
||||
if (
|
||||
!dependencies ||
|
||||
typeof dependencies !== 'object' ||
|
||||
!dependencies.failureCompletions ||
|
||||
typeof dependencies.failureCompletions.findByStartId !== 'function' ||
|
||||
typeof dependencies.failureCompletions.commit !== 'function' ||
|
||||
!dependencies.failureIdentities ||
|
||||
typeof dependencies.failureIdentities.create !== 'function'
|
||||
) {
|
||||
unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async function findTerminalState(
|
||||
startId: string,
|
||||
dependencies: TrustedToolCompletionDependencies,
|
||||
): Promise<Readonly<DurableTerminalState>> {
|
||||
try {
|
||||
const [successValue, failureValue] = await Promise.all([
|
||||
dependencies.completions.findByStartId(startId),
|
||||
dependencies.failureCompletions.findByStartId(startId),
|
||||
]);
|
||||
const success =
|
||||
successValue === null
|
||||
? null
|
||||
: normalizeToolExecutionCompletionRecord(successValue);
|
||||
const failure =
|
||||
failureValue === null
|
||||
? null
|
||||
: normalizeToolExecutionFailureCompletionRecord(failureValue);
|
||||
if (success && failure) return conflict();
|
||||
return Object.freeze({ success, failure });
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof ToolExecutionCompletionConflictError ||
|
||||
cause instanceof ToolExecutionFailureCompletionConflictError
|
||||
) {
|
||||
throw cause;
|
||||
}
|
||||
return unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async function findBarrier(
|
||||
startId: string,
|
||||
dependencies: TrustedToolCompletionDependencies,
|
||||
): Promise<Readonly<ToolExecutionStartBarrierRecord>> {
|
||||
try {
|
||||
const value = await dependencies.barriers.findByStartId(startId);
|
||||
if (!value) return unavailable();
|
||||
const barrier = normalizeToolExecutionStartBarrierRecord(value);
|
||||
if (barrier.startId !== startId) return unavailable();
|
||||
return barrier;
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
function failureMatchesBarrier(
|
||||
completion: Readonly<ToolExecutionFailureCompletionRecord>,
|
||||
barrier: Readonly<ToolExecutionStartBarrierRecord>,
|
||||
): boolean {
|
||||
return (
|
||||
completion.startId === barrier.startId &&
|
||||
completion.projectId === barrier.projectId &&
|
||||
completion.runId === barrier.runId &&
|
||||
completion.stepRunId === barrier.stepRunId &&
|
||||
completion.startedStepRunVersion === barrier.startedStepRunVersion &&
|
||||
completion.completedStepRunVersion === barrier.startedStepRunVersion + 1 &&
|
||||
completion.barrierDigest === barrier.barrierDigest &&
|
||||
completion.adapterDigest === barrier.adapterDigest
|
||||
);
|
||||
}
|
||||
|
||||
async function openDurableFailure(
|
||||
completion: Readonly<ToolExecutionFailureCompletionRecord>,
|
||||
dependencies: TrustedToolCompletionDependencies,
|
||||
): Promise<Readonly<TrustedToolFailedCompletionResult>> {
|
||||
const barrier = await findBarrier(completion.startId, dependencies);
|
||||
if (!failureMatchesBarrier(completion, barrier)) return conflict();
|
||||
return Object.freeze({
|
||||
outcome: completion.outcome,
|
||||
status: 'existing' as const,
|
||||
completion,
|
||||
});
|
||||
}
|
||||
|
||||
function succeeded(
|
||||
result: Readonly<TrustedToolSuccessCompletionResult>,
|
||||
): Readonly<TrustedToolSucceededCompletionResult> {
|
||||
return Object.freeze({
|
||||
outcome: 'succeeded' as const,
|
||||
status: result.status,
|
||||
completion: result.completion,
|
||||
output: result.output,
|
||||
});
|
||||
}
|
||||
|
||||
async function openDurableSuccess(
|
||||
startId: string,
|
||||
dependencies: TrustedToolCompletionDependencies,
|
||||
): Promise<Readonly<TrustedToolSucceededCompletionResult>> {
|
||||
return succeeded(
|
||||
await executeAndCompleteTrustedToolSuccess(startId, dependencies),
|
||||
);
|
||||
}
|
||||
|
||||
async function returnDurableWinner(
|
||||
startId: string,
|
||||
dependencies: TrustedToolCompletionDependencies,
|
||||
): Promise<Readonly<TrustedToolCompletionResult> | null> {
|
||||
const state = await findTerminalState(startId, dependencies);
|
||||
if (state.success) return openDurableSuccess(startId, dependencies);
|
||||
if (state.failure) return openDurableFailure(state.failure, dependencies);
|
||||
return null;
|
||||
}
|
||||
|
||||
function observedAtMs(
|
||||
barrier: Readonly<ToolExecutionStartBarrierRecord>,
|
||||
dependencies: TrustedToolCompletionDependencies,
|
||||
): number {
|
||||
let value: number;
|
||||
try {
|
||||
value = (dependencies.now ?? Date.now)();
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < barrier.startedAtMs ||
|
||||
value < 0
|
||||
) {
|
||||
return unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function findRunningStepAndRun(
|
||||
barrier: Readonly<ToolExecutionStartBarrierRecord>,
|
||||
dependencies: TrustedToolCompletionDependencies,
|
||||
) {
|
||||
try {
|
||||
const [stepValue, run] = await Promise.all([
|
||||
dependencies.stepRuns.findById(barrier.stepRunId),
|
||||
dependencies.runs.findRunById(barrier.runId),
|
||||
]);
|
||||
if (!stepValue || !run) return unavailable();
|
||||
const stepRun = normalizeStepRunRecord(stepValue);
|
||||
if (
|
||||
stepRun.id !== barrier.stepRunId ||
|
||||
stepRun.runId !== barrier.runId ||
|
||||
stepRun.kind !== 'tool' ||
|
||||
stepRun.status !== 'running' ||
|
||||
stepRun.version !== barrier.startedStepRunVersion ||
|
||||
stepRun.stepRunDigest !== barrier.startedStepRunDigest ||
|
||||
run.id !== barrier.runId ||
|
||||
run.projectId !== barrier.projectId ||
|
||||
!Number.isSafeInteger(run.version) ||
|
||||
run.version < 0 ||
|
||||
!Number.isSafeInteger(run.eventSequence) ||
|
||||
run.eventSequence < 0 ||
|
||||
TERMINAL_RUN_STATUSES.has(run.status)
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
return Object.freeze({ stepRun, run });
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
function failureDedupeKey(startId: string): string {
|
||||
return `tool-failure:${createHash('sha256').update(startId).digest('hex')}`;
|
||||
}
|
||||
|
||||
async function commitFailure(
|
||||
startId: string,
|
||||
outcome: ToolExecutionFailureOutcome,
|
||||
dependencies: TrustedToolCompletionDependencies,
|
||||
): Promise<Readonly<TrustedToolCompletionResult>> {
|
||||
const winner = await returnDurableWinner(startId, dependencies);
|
||||
if (winner) return winner;
|
||||
|
||||
const barrier = await findBarrier(startId, dependencies);
|
||||
const { stepRun, run } = await findRunningStepAndRun(barrier, dependencies);
|
||||
const failure = createToolExecutionFailureResult(
|
||||
barrier,
|
||||
outcome,
|
||||
observedAtMs(barrier, dependencies),
|
||||
);
|
||||
let identities: TrustedToolFailureCompletionIdentities;
|
||||
try {
|
||||
identities = dependencies.failureIdentities.create(startId);
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
const mutation = transitionStepRunMutation(
|
||||
stepRun,
|
||||
{
|
||||
expectedVersion: stepRun.version,
|
||||
expectedDigest: stepRun.stepRunDigest,
|
||||
mutationId: identities.mutationId,
|
||||
to: outcome,
|
||||
atMs: failure.completedAtMs,
|
||||
resultCode: failure.resultCode,
|
||||
errorSummary: failure.errorSummary,
|
||||
},
|
||||
{
|
||||
expectedRunVersion: run.version,
|
||||
expectedRunEventSequence: run.eventSequence,
|
||||
eventId: identities.eventId,
|
||||
dedupeKey: failureDedupeKey(startId),
|
||||
actor: Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'trusted-tool-runtime',
|
||||
}),
|
||||
},
|
||||
);
|
||||
const command = createToolExecutionFailureCompletionCommand({
|
||||
barrier,
|
||||
failure,
|
||||
stepRunMutation: mutation,
|
||||
});
|
||||
const expected = toolExecutionFailureCompletionRecord(command);
|
||||
|
||||
try {
|
||||
const committed = await dependencies.failureCompletions.commit(command);
|
||||
const completion = normalizeToolExecutionFailureCompletionRecord(
|
||||
committed.completion,
|
||||
);
|
||||
if (
|
||||
!['created', 'existing'].includes(committed.status) ||
|
||||
!sameValue(completion, expected)
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
const concurrentSuccess = await dependencies.completions.findByStartId(
|
||||
startId,
|
||||
);
|
||||
if (concurrentSuccess) return conflict();
|
||||
return Object.freeze({
|
||||
outcome: completion.outcome,
|
||||
status: committed.status,
|
||||
completion,
|
||||
});
|
||||
} catch (cause) {
|
||||
const recovered = await returnDurableWinner(startId, dependencies);
|
||||
if (recovered) return recovered;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes one already-started retry-safe Tool with exactly one durable
|
||||
* succeeded, failed or timed_out outcome.
|
||||
*
|
||||
* Only explicit adapter failure and deadline errors become terminal failures.
|
||||
* Binding, key, snapshot and storage errors stay non-terminal. A lost commit
|
||||
* response is recovered exclusively from durable state and never re-executes
|
||||
* the adapter in the same call.
|
||||
*/
|
||||
export async function executeAndCompleteTrustedTool(
|
||||
startId: string,
|
||||
dependencies: TrustedToolCompletionDependencies,
|
||||
): Promise<Readonly<TrustedToolCompletionResult>> {
|
||||
validateDependencies(dependencies);
|
||||
const winner = await returnDurableWinner(startId, dependencies);
|
||||
if (winner) return winner;
|
||||
|
||||
try {
|
||||
const result = await executeAndCompleteTrustedToolSuccess(
|
||||
startId,
|
||||
dependencies,
|
||||
);
|
||||
const concurrentFailure =
|
||||
await dependencies.failureCompletions.findByStartId(startId);
|
||||
if (concurrentFailure) return conflict();
|
||||
return succeeded(result);
|
||||
} catch (cause) {
|
||||
const durable = await returnDurableWinner(startId, dependencies);
|
||||
if (durable) return durable;
|
||||
if (cause instanceof TrustedToolExecutionDeadlineExceededError) {
|
||||
return commitFailure(startId, 'timed_out', dependencies);
|
||||
}
|
||||
if (cause instanceof TrustedToolExecutionFailedError) {
|
||||
return commitFailure(startId, 'failed', dependencies);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
TOOL_EXECUTION_START_BARRIER_SCHEMA,
|
||||
normalizeToolExecutionStartBarrierRecord,
|
||||
type ToolExecutionStartBarrierRecord,
|
||||
type ToolExecutionStartBarrierRepository,
|
||||
} from './toolExecutionStartBarrier';
|
||||
import {
|
||||
TOOL_INVOCATION_SCHEMA,
|
||||
type ToolJsonValue,
|
||||
} from './tool-registry/toolRegistry';
|
||||
import {
|
||||
normalizeToolInvocationInputArtifact,
|
||||
openToolInvocationInputArtifact,
|
||||
toolInvocationInputArtifactReference,
|
||||
type ToolInvocationArtifactKeyProvider,
|
||||
type ToolInvocationArtifactRepository,
|
||||
} from './toolInvocationArtifact';
|
||||
import {
|
||||
TrustedToolHandlerBindingRegistry,
|
||||
normalizeTrustedToolHandlerBinding,
|
||||
trustedToolContractIdentityDigest,
|
||||
type TrustedToolHandlerBinding,
|
||||
} from './trustedToolInvocation';
|
||||
import type { DeploymentProfile } from '../cluster-control/clusterControlActivation';
|
||||
import type { SecuritySubject } from '../security/security';
|
||||
|
||||
export const TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA =
|
||||
'qinglong/trusted-tool-execution-result@v1' as const;
|
||||
export const TRUSTED_TOOL_EXECUTION_RECOVERY_EVIDENCE_SCHEMA =
|
||||
'qinglong/trusted-tool-execution-recovery-evidence@v1' as const;
|
||||
export const MAX_TRUSTED_TOOL_EXECUTION_ADAPTERS = 128;
|
||||
|
||||
export interface TrustedToolExecutionAdapterContext {
|
||||
readonly startId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly actionRef: string;
|
||||
readonly requestedBy: Readonly<SecuritySubject>;
|
||||
readonly profile: DeploymentProfile;
|
||||
readonly startedAtMs: number;
|
||||
readonly deadlineAtMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An executable adapter is supplied only by a trusted Profile composition
|
||||
* root. Its immutable binding is Project/snapshot-specific; Package content,
|
||||
* invocation input and persisted plans cannot register executable code.
|
||||
*/
|
||||
export interface TrustedToolExecutionAdapter {
|
||||
readonly binding: Readonly<TrustedToolHandlerBinding>;
|
||||
readonly profile: DeploymentProfile;
|
||||
readonly recoveryMode: 'retry_safe_read';
|
||||
execute(
|
||||
context: Readonly<TrustedToolExecutionAdapterContext>,
|
||||
input: ToolJsonValue,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface TrustedToolExecutionDependencies {
|
||||
readonly barriers: Pick<ToolExecutionStartBarrierRepository, 'findByStartId'>;
|
||||
readonly artifacts: Pick<ToolInvocationArtifactRepository, 'findInput'>;
|
||||
readonly keys: Pick<ToolInvocationArtifactKeyProvider, 'resolve'>;
|
||||
readonly adapters: TrustedToolExecutionAdapterRegistry;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface TrustedToolExecutionResult {
|
||||
readonly schema: typeof TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA;
|
||||
readonly startId: string;
|
||||
readonly barrierDigest: string;
|
||||
readonly adapterDigest: string;
|
||||
readonly output: ToolJsonValue;
|
||||
readonly outputDigest: string;
|
||||
readonly completedAtMs: number;
|
||||
readonly resultDigest: string;
|
||||
}
|
||||
|
||||
export interface TrustedToolExecutionRecoveryEvidence {
|
||||
readonly schema: typeof TRUSTED_TOOL_EXECUTION_RECOVERY_EVIDENCE_SCHEMA;
|
||||
readonly startId: string;
|
||||
readonly barrierDigest: string;
|
||||
readonly adapterDigest: string;
|
||||
readonly disposition: 'retry_safe';
|
||||
readonly reason: 'read_only_no_side_effects';
|
||||
readonly inspectedAtMs: number;
|
||||
readonly evidenceDigest: string;
|
||||
}
|
||||
|
||||
export class InvalidTrustedToolExecutionError extends TypeError {
|
||||
readonly code = 'TRUSTED_TOOL_EXECUTION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Trusted Tool execution is invalid: ${message}`);
|
||||
this.name = 'InvalidTrustedToolExecutionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TrustedToolExecutionUnavailableError extends Error {
|
||||
readonly code = 'TRUSTED_TOOL_EXECUTION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Trusted Tool execution prerequisites are unavailable');
|
||||
this.name = 'TrustedToolExecutionUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TrustedToolExecutionBindingConflictError extends Error {
|
||||
readonly code = 'TRUSTED_TOOL_EXECUTION_BINDING_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Trusted Tool execution binding changed after durable start');
|
||||
this.name = 'TrustedToolExecutionBindingConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TrustedToolExecutionDeadlineExceededError extends Error {
|
||||
readonly code = 'TRUSTED_TOOL_EXECUTION_DEADLINE_EXCEEDED';
|
||||
|
||||
constructor() {
|
||||
super('Trusted Tool execution deadline was exceeded');
|
||||
this.name = 'TrustedToolExecutionDeadlineExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TrustedToolExecutionFailedError extends Error {
|
||||
readonly code = 'TRUSTED_TOOL_EXECUTION_FAILED';
|
||||
|
||||
constructor() {
|
||||
super('Trusted Tool adapter execution failed');
|
||||
this.name = 'TrustedToolExecutionFailedError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RESULT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-result-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const OUTPUT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-output-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const RECOVERY_EVIDENCE_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-recovery-evidence-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidTrustedToolExecutionError(message);
|
||||
}
|
||||
|
||||
function hash(domain: Uint8Array, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function sameValue(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function now(clock: (() => number) | undefined): number {
|
||||
let value: number;
|
||||
try {
|
||||
value = (clock ?? Date.now)();
|
||||
} catch {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function startIdentity(value: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
return invalid('start identity is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function adapterKey(bindingDigest: string, profile: DeploymentProfile): string {
|
||||
return `${bindingDigest}:${profile}`;
|
||||
}
|
||||
|
||||
function invocationActionDigest(
|
||||
barrier: Readonly<ToolExecutionStartBarrierRecord>,
|
||||
binding: Readonly<TrustedToolHandlerBinding>,
|
||||
definitions: ReturnType<
|
||||
TrustedToolHandlerBindingRegistry['definitionRegistry']
|
||||
>,
|
||||
): string {
|
||||
const definition = definitions.resolve(
|
||||
binding.tool.name,
|
||||
binding.tool.version,
|
||||
);
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
schema: TOOL_INVOCATION_SCHEMA,
|
||||
projectId: barrier.projectId,
|
||||
requestedBy: barrier.requestedBy,
|
||||
tool: binding.tool,
|
||||
permission: `tool.call:${definition.name}`,
|
||||
requiredPermissions: definition.requiredPermissions,
|
||||
effect: definition.effect,
|
||||
risk: definition.risk,
|
||||
timeoutSeconds: definition.timeoutSeconds,
|
||||
inputDigest: barrier.invocationArtifact.inputDigest,
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export class TrustedToolExecutionAdapterRegistry {
|
||||
readonly #bindings!: TrustedToolHandlerBindingRegistry;
|
||||
readonly #adapters!: ReadonlyMap<string, TrustedToolExecutionAdapter>;
|
||||
|
||||
constructor(
|
||||
bindings: TrustedToolHandlerBindingRegistry,
|
||||
adapters: readonly TrustedToolExecutionAdapter[],
|
||||
) {
|
||||
if (!(bindings instanceof TrustedToolHandlerBindingRegistry)) {
|
||||
return invalid('handler binding registry is invalid');
|
||||
}
|
||||
if (
|
||||
!Array.isArray(adapters) ||
|
||||
adapters.length > MAX_TRUSTED_TOOL_EXECUTION_ADAPTERS
|
||||
) {
|
||||
return invalid('adapter count is invalid');
|
||||
}
|
||||
const entries = new Map<string, TrustedToolExecutionAdapter>();
|
||||
const definitions = bindings.definitionRegistry();
|
||||
for (const candidate of adapters) {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'object' ||
|
||||
typeof candidate.execute !== 'function' ||
|
||||
candidate.recoveryMode !== 'retry_safe_read'
|
||||
) {
|
||||
return invalid('adapter shape is invalid');
|
||||
}
|
||||
const binding = normalizeTrustedToolHandlerBinding(candidate.binding);
|
||||
const current = bindings.resolve(
|
||||
binding.tool.name,
|
||||
binding.tool.version,
|
||||
candidate.profile,
|
||||
);
|
||||
const definition = definitions.resolve(
|
||||
binding.tool.name,
|
||||
binding.tool.version,
|
||||
);
|
||||
if (
|
||||
current.bindingDigest !== binding.bindingDigest ||
|
||||
definition.effect !== 'read' ||
|
||||
binding.executionClass !== 'builtin_in_process'
|
||||
) {
|
||||
throw new TrustedToolExecutionBindingConflictError();
|
||||
}
|
||||
const key = adapterKey(binding.bindingDigest, candidate.profile);
|
||||
if (entries.has(key)) {
|
||||
return invalid('adapter binding is duplicated');
|
||||
}
|
||||
entries.set(
|
||||
key,
|
||||
Object.freeze({
|
||||
binding,
|
||||
profile: candidate.profile,
|
||||
recoveryMode: candidate.recoveryMode,
|
||||
execute: candidate.execute.bind(candidate),
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.#bindings = bindings;
|
||||
this.#adapters = entries;
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
resolve(
|
||||
barrierValue: ToolExecutionStartBarrierRecord,
|
||||
): TrustedToolExecutionAdapter {
|
||||
const barrier = normalizeToolExecutionStartBarrierRecord(barrierValue);
|
||||
const current = this.#bindings
|
||||
.list()
|
||||
.find((binding) => binding.bindingDigest === barrier.bindingDigest);
|
||||
if (!current) {
|
||||
throw new TrustedToolExecutionBindingConflictError();
|
||||
}
|
||||
let resolved: Readonly<TrustedToolHandlerBinding>;
|
||||
try {
|
||||
resolved = this.#bindings.resolve(
|
||||
current.tool.name,
|
||||
current.tool.version,
|
||||
barrier.profile,
|
||||
);
|
||||
} catch {
|
||||
throw new TrustedToolExecutionBindingConflictError();
|
||||
}
|
||||
if (
|
||||
barrier.schema !== TOOL_EXECUTION_START_BARRIER_SCHEMA ||
|
||||
barrier.projectId !== this.#bindings.projectId ||
|
||||
barrier.snapshotDigest !== this.#bindings.snapshotDigest ||
|
||||
barrier.definitionDigest !== current.definitionDigest ||
|
||||
resolved.bindingDigest !== current.bindingDigest ||
|
||||
barrier.timeoutSeconds !== current.timeoutSeconds ||
|
||||
barrier.executionClass !== current.executionClass ||
|
||||
!sameValue(barrier.adapter, current.adapter) ||
|
||||
barrier.adapterDigest !==
|
||||
trustedToolContractIdentityDigest(current.adapter) ||
|
||||
!sameValue(barrier.redactionContract, current.redactionContract) ||
|
||||
barrier.redactionContractDigest !==
|
||||
trustedToolContractIdentityDigest(current.redactionContract) ||
|
||||
!sameValue(barrier.auditContract, current.auditContract) ||
|
||||
barrier.auditContractDigest !==
|
||||
trustedToolContractIdentityDigest(current.auditContract)
|
||||
) {
|
||||
throw new TrustedToolExecutionBindingConflictError();
|
||||
}
|
||||
const adapter = this.#adapters.get(
|
||||
adapterKey(current.bindingDigest, barrier.profile),
|
||||
);
|
||||
if (!adapter) {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
definitionRegistry(): ReturnType<
|
||||
TrustedToolHandlerBindingRegistry['definitionRegistry']
|
||||
> {
|
||||
return this.#bindings.definitionRegistry();
|
||||
}
|
||||
}
|
||||
|
||||
async function durableBarrier(
|
||||
startIdValue: string,
|
||||
dependencies: Pick<TrustedToolExecutionDependencies, 'adapters' | 'barriers'>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
barrier: Readonly<ToolExecutionStartBarrierRecord>;
|
||||
adapter: TrustedToolExecutionAdapter;
|
||||
}>
|
||||
> {
|
||||
const startId = startIdentity(startIdValue);
|
||||
if (
|
||||
!dependencies ||
|
||||
typeof dependencies !== 'object' ||
|
||||
!dependencies.barriers ||
|
||||
typeof dependencies.barriers.findByStartId !== 'function' ||
|
||||
!(dependencies.adapters instanceof TrustedToolExecutionAdapterRegistry)
|
||||
) {
|
||||
return invalid('durable execution dependencies are invalid');
|
||||
}
|
||||
let found: Readonly<ToolExecutionStartBarrierRecord> | null;
|
||||
try {
|
||||
found = await dependencies.barriers.findByStartId(startId);
|
||||
} catch {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
if (!found) {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
let barrier: Readonly<ToolExecutionStartBarrierRecord>;
|
||||
try {
|
||||
barrier = normalizeToolExecutionStartBarrierRecord(found);
|
||||
} catch {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
if (barrier.startId !== startId) {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
barrier,
|
||||
adapter: dependencies.adapters.resolve(barrier),
|
||||
});
|
||||
}
|
||||
|
||||
function executionContext(
|
||||
barrier: Readonly<ToolExecutionStartBarrierRecord>,
|
||||
): Readonly<TrustedToolExecutionAdapterContext> {
|
||||
const deadlineAtMs = barrier.startedAtMs + barrier.timeoutSeconds * 1_000;
|
||||
if (!Number.isSafeInteger(deadlineAtMs)) {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
startId: barrier.startId,
|
||||
projectId: barrier.projectId,
|
||||
runId: barrier.runId,
|
||||
stepRunId: barrier.stepRunId,
|
||||
actionRef: barrier.actionRef,
|
||||
requestedBy: barrier.requestedBy,
|
||||
profile: barrier.profile,
|
||||
startedAtMs: barrier.startedAtMs,
|
||||
deadlineAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
async function executeBeforeDeadline(
|
||||
adapter: TrustedToolExecutionAdapter,
|
||||
context: Readonly<TrustedToolExecutionAdapterContext>,
|
||||
input: ToolJsonValue,
|
||||
clock: (() => number) | undefined,
|
||||
): Promise<unknown> {
|
||||
const remainingMs = context.deadlineAtMs - now(clock);
|
||||
if (remainingMs <= 0) {
|
||||
throw new TrustedToolExecutionDeadlineExceededError();
|
||||
}
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new TrustedToolExecutionDeadlineExceededError());
|
||||
}, remainingMs);
|
||||
Promise.resolve()
|
||||
.then(() => adapter.execute(context, input))
|
||||
.then(
|
||||
(value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeTrustedToolAfterStart(
|
||||
startId: string,
|
||||
dependencies: TrustedToolExecutionDependencies,
|
||||
): Promise<Readonly<TrustedToolExecutionResult>> {
|
||||
if (
|
||||
!dependencies ||
|
||||
typeof dependencies !== 'object' ||
|
||||
!dependencies.artifacts ||
|
||||
typeof dependencies.artifacts.findInput !== 'function' ||
|
||||
!dependencies.keys ||
|
||||
typeof dependencies.keys.resolve !== 'function'
|
||||
) {
|
||||
return invalid('execution dependencies are invalid');
|
||||
}
|
||||
const { barrier, adapter } = await durableBarrier(startId, dependencies);
|
||||
const context = executionContext(barrier);
|
||||
if (now(dependencies.now) > context.deadlineAtMs) {
|
||||
throw new TrustedToolExecutionDeadlineExceededError();
|
||||
}
|
||||
|
||||
let artifactValue: Awaited<
|
||||
ReturnType<ToolInvocationArtifactRepository['findInput']>
|
||||
>;
|
||||
try {
|
||||
artifactValue = await dependencies.artifacts.findInput(
|
||||
barrier.invocationArtifact.artifactId,
|
||||
);
|
||||
} catch {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
if (!artifactValue) {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
let artifact: ReturnType<typeof normalizeToolInvocationInputArtifact>;
|
||||
try {
|
||||
artifact = normalizeToolInvocationInputArtifact(artifactValue);
|
||||
} catch {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
if (
|
||||
!sameValue(
|
||||
toolInvocationInputArtifactReference(artifact),
|
||||
barrier.invocationArtifact,
|
||||
) ||
|
||||
artifact.projectId !== barrier.projectId ||
|
||||
artifact.actionRef !== barrier.actionRef ||
|
||||
!sameValue(artifact.requestedBy, barrier.requestedBy) ||
|
||||
!sameValue(artifact.tool, adapter.binding.tool) ||
|
||||
artifact.invocationActionDigest !==
|
||||
invocationActionDigest(
|
||||
barrier,
|
||||
adapter.binding,
|
||||
dependencies.adapters.definitionRegistry(),
|
||||
) ||
|
||||
artifact.sealedAtMs > barrier.startedAtMs
|
||||
) {
|
||||
throw new TrustedToolExecutionBindingConflictError();
|
||||
}
|
||||
|
||||
let material: Awaited<
|
||||
ReturnType<ToolInvocationArtifactKeyProvider['resolve']>
|
||||
>;
|
||||
try {
|
||||
material = await dependencies.keys.resolve(artifact.keyId);
|
||||
} catch {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
if (!material) {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
const key = material.key;
|
||||
if (
|
||||
material.keyId !== artifact.keyId ||
|
||||
!(key instanceof Uint8Array) ||
|
||||
key.byteLength !== 32
|
||||
) {
|
||||
if (key instanceof Uint8Array) key.fill(0);
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
|
||||
const definitions = dependencies.adapters.definitionRegistry();
|
||||
let output: ToolJsonValue;
|
||||
try {
|
||||
const input = openToolInvocationInputArtifact(artifact, key, definitions);
|
||||
const candidate = await executeBeforeDeadline(
|
||||
adapter,
|
||||
context,
|
||||
input,
|
||||
dependencies.now,
|
||||
);
|
||||
output = definitions.normalizeOutput(
|
||||
adapter.binding.tool.name,
|
||||
adapter.binding.tool.version,
|
||||
candidate,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof TrustedToolExecutionDeadlineExceededError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TrustedToolExecutionFailedError();
|
||||
} finally {
|
||||
key.fill(0);
|
||||
}
|
||||
|
||||
const completedAtMs = now(dependencies.now);
|
||||
if (completedAtMs > context.deadlineAtMs) {
|
||||
throw new TrustedToolExecutionDeadlineExceededError();
|
||||
}
|
||||
const outputDigest = hash(OUTPUT_DIGEST_DOMAIN, output);
|
||||
const unsigned = Object.freeze({
|
||||
schema: TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA,
|
||||
startId: barrier.startId,
|
||||
barrierDigest: barrier.barrierDigest,
|
||||
adapterDigest: barrier.adapterDigest,
|
||||
output,
|
||||
outputDigest,
|
||||
completedAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
resultDigest: hash(RESULT_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery inspection deliberately does not load or decrypt invocation input.
|
||||
* A reviewed read-only in-process adapter can be retried because it has no
|
||||
* external side effect; later write/remote adapters require stronger,
|
||||
* adapter-specific evidence and are not accepted by this registry.
|
||||
*/
|
||||
export async function inspectTrustedToolExecutionRecovery(
|
||||
startId: string,
|
||||
dependencies: Pick<
|
||||
TrustedToolExecutionDependencies,
|
||||
'adapters' | 'barriers' | 'now'
|
||||
>,
|
||||
): Promise<Readonly<TrustedToolExecutionRecoveryEvidence>> {
|
||||
const { barrier, adapter } = await durableBarrier(startId, dependencies);
|
||||
if (adapter.recoveryMode !== 'retry_safe_read') {
|
||||
throw new TrustedToolExecutionUnavailableError();
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: TRUSTED_TOOL_EXECUTION_RECOVERY_EVIDENCE_SCHEMA,
|
||||
startId: barrier.startId,
|
||||
barrierDigest: barrier.barrierDigest,
|
||||
adapterDigest: barrier.adapterDigest,
|
||||
disposition: 'retry_safe' as const,
|
||||
reason: 'read_only_no_side_effects' as const,
|
||||
inspectedAtMs: now(dependencies.now),
|
||||
});
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
evidenceDigest: hash(RECOVERY_EVIDENCE_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export * from './trusted-tool-invocation/contracts';
|
||||
|
||||
export { trustedToolContractIdentityDigest } from './trusted-tool-invocation/codec';
|
||||
export {
|
||||
TrustedToolHandlerBindingRegistry,
|
||||
createTrustedToolHandlerBinding,
|
||||
normalizeTrustedToolHandlerBinding,
|
||||
} from './trusted-tool-invocation/binding';
|
||||
export {
|
||||
assertTrustedToolApprovedDispatch,
|
||||
createTrustedToolInvocationPlan,
|
||||
normalizeTrustedToolInvocationPlan,
|
||||
normalizeTrustedToolInvocationPreview,
|
||||
trustedToolInvocationApprovalBinding,
|
||||
} from './trusted-tool-invocation/plan';
|
||||
export {
|
||||
admitTrustedToolExecution,
|
||||
normalizeTrustedToolExecutionAdmission,
|
||||
} from './trusted-tool-invocation/admission';
|
||||
@@ -0,0 +1,475 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { RunRepositoryReader } from '../run/runRepository';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
transitionStepRunMutation,
|
||||
type StepRunRepository,
|
||||
} from '../run/stepRun';
|
||||
import {
|
||||
createToolExecutionCompletionCommand,
|
||||
createToolExecutionResultArtifact,
|
||||
normalizeToolExecutionCompletionRecord,
|
||||
normalizeToolExecutionResultArtifact,
|
||||
openToolExecutionResultArtifact,
|
||||
ToolExecutionCompletionConflictError,
|
||||
ToolExecutionCompletionUnavailableError,
|
||||
toolExecutionCompletionRecord,
|
||||
type ToolExecutionCompletionRecord,
|
||||
type ToolExecutionCompletionRepository,
|
||||
type ToolExecutionResultArtifact,
|
||||
} from './toolExecutionCompletion';
|
||||
import {
|
||||
normalizeToolExecutionStartBarrierRecord,
|
||||
type ToolExecutionStartBarrierRecord,
|
||||
} from './toolExecutionStartBarrier';
|
||||
import {
|
||||
normalizeToolResultKeyCatalogRecord,
|
||||
requireActiveToolResultKey,
|
||||
requireDecryptableToolResultKey,
|
||||
toolResultKeyCatalogFence,
|
||||
toolResultKeyMaterialProof,
|
||||
type ToolResultKeyCatalogReader,
|
||||
type ToolResultKeyCatalogRecord,
|
||||
} from './toolResultKeyCatalog';
|
||||
import type {
|
||||
ToolInvocationArtifactKeyMaterial,
|
||||
ToolInvocationArtifactKeyProvider,
|
||||
} from './toolInvocationArtifact';
|
||||
import {
|
||||
normalizeToolExecutionResultRekeyOverlay,
|
||||
openToolExecutionResultRekeyOverlay,
|
||||
type ToolExecutionResultRekeyOverlay,
|
||||
type ToolExecutionResultRekeyReader,
|
||||
} from './toolResultRekey';
|
||||
import {
|
||||
executeTrustedToolAfterStart,
|
||||
TrustedToolExecutionAdapterRegistry,
|
||||
type TrustedToolExecutionDependencies,
|
||||
} from './trustedToolExecution';
|
||||
import type { ToolJsonValue } from './tool-registry/toolRegistry';
|
||||
|
||||
export interface TrustedToolSuccessCompletionIdentities {
|
||||
readonly artifactId: string;
|
||||
readonly mutationId: string;
|
||||
readonly eventId: string;
|
||||
}
|
||||
|
||||
export interface TrustedToolSuccessCompletionIdentityFactory {
|
||||
create(startId: string): TrustedToolSuccessCompletionIdentities;
|
||||
}
|
||||
|
||||
export interface TrustedToolSuccessCompletionDependencies
|
||||
extends TrustedToolExecutionDependencies {
|
||||
readonly completions: ToolExecutionCompletionRepository;
|
||||
readonly stepRuns: Pick<StepRunRepository, 'findById'>;
|
||||
readonly runs: Pick<RunRepositoryReader, 'findRunById'>;
|
||||
readonly resultKeyCatalog: ToolResultKeyCatalogReader;
|
||||
readonly resultRekeys: ToolExecutionResultRekeyReader;
|
||||
readonly resultKeys: Pick<ToolInvocationArtifactKeyProvider, 'resolve'>;
|
||||
readonly identities: TrustedToolSuccessCompletionIdentityFactory;
|
||||
readonly nonceFactory?: () => Uint8Array;
|
||||
}
|
||||
|
||||
export interface TrustedToolSuccessCompletionResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly completion: Readonly<ToolExecutionCompletionRecord>;
|
||||
readonly output: ToolJsonValue;
|
||||
}
|
||||
|
||||
const TERMINAL_RUN_STATUSES = new Set([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
]);
|
||||
|
||||
function unavailable(cause?: unknown): never {
|
||||
throw new ToolExecutionCompletionUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function conflict(): never {
|
||||
throw new ToolExecutionCompletionConflictError();
|
||||
}
|
||||
|
||||
function sameValue(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function validateDependencies(
|
||||
dependencies: TrustedToolSuccessCompletionDependencies,
|
||||
): void {
|
||||
if (
|
||||
!dependencies ||
|
||||
typeof dependencies !== 'object' ||
|
||||
!dependencies.completions ||
|
||||
typeof dependencies.completions.findByStartId !== 'function' ||
|
||||
typeof dependencies.completions.findResultArtifact !== 'function' ||
|
||||
typeof dependencies.completions.commit !== 'function' ||
|
||||
!dependencies.stepRuns ||
|
||||
typeof dependencies.stepRuns.findById !== 'function' ||
|
||||
!dependencies.runs ||
|
||||
typeof dependencies.runs.findRunById !== 'function' ||
|
||||
!dependencies.resultKeys ||
|
||||
typeof dependencies.resultKeys.resolve !== 'function' ||
|
||||
!dependencies.resultKeyCatalog ||
|
||||
typeof dependencies.resultKeyCatalog.findCurrent !== 'function' ||
|
||||
!dependencies.resultRekeys ||
|
||||
typeof dependencies.resultRekeys.findHeadByArtifactId !== 'function' ||
|
||||
!dependencies.identities ||
|
||||
typeof dependencies.identities.create !== 'function' ||
|
||||
!(dependencies.adapters instanceof TrustedToolExecutionAdapterRegistry) ||
|
||||
(dependencies.nonceFactory !== undefined &&
|
||||
typeof dependencies.nonceFactory !== 'function')
|
||||
) {
|
||||
unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async function findResultRekeyHead(
|
||||
artifactId: string,
|
||||
dependencies: TrustedToolSuccessCompletionDependencies,
|
||||
): Promise<Readonly<ToolExecutionResultRekeyOverlay> | null> {
|
||||
try {
|
||||
const value = await dependencies.resultRekeys.findHeadByArtifactId(
|
||||
artifactId,
|
||||
);
|
||||
return value === null
|
||||
? null
|
||||
: normalizeToolExecutionResultRekeyOverlay(value);
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async function findResultKeyCatalog(
|
||||
dependencies: TrustedToolSuccessCompletionDependencies,
|
||||
): Promise<Readonly<ToolResultKeyCatalogRecord>> {
|
||||
try {
|
||||
const value = await dependencies.resultKeyCatalog.findCurrent();
|
||||
if (!value) return unavailable();
|
||||
return normalizeToolResultKeyCatalogRecord(value);
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
function validCatalogMaterial(
|
||||
material: ToolInvocationArtifactKeyMaterial | null,
|
||||
keyId: string,
|
||||
materialProof: string,
|
||||
): material is ToolInvocationArtifactKeyMaterial {
|
||||
return (
|
||||
validKey(material, keyId) &&
|
||||
toolResultKeyMaterialProof(keyId, material.key) === materialProof
|
||||
);
|
||||
}
|
||||
|
||||
async function findCompletion(
|
||||
startId: string,
|
||||
dependencies: TrustedToolSuccessCompletionDependencies,
|
||||
): Promise<Readonly<ToolExecutionCompletionRecord> | null> {
|
||||
try {
|
||||
const value = await dependencies.completions.findByStartId(startId);
|
||||
return value === null
|
||||
? null
|
||||
: normalizeToolExecutionCompletionRecord(value);
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async function findBarrier(
|
||||
startId: string,
|
||||
dependencies: TrustedToolSuccessCompletionDependencies,
|
||||
): Promise<Readonly<ToolExecutionStartBarrierRecord>> {
|
||||
try {
|
||||
const value = await dependencies.barriers.findByStartId(startId);
|
||||
if (!value) return unavailable();
|
||||
const barrier = normalizeToolExecutionStartBarrierRecord(value);
|
||||
if (barrier.startId !== startId) return unavailable();
|
||||
return barrier;
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
function validKey(
|
||||
material: ToolInvocationArtifactKeyMaterial | null,
|
||||
expectedKeyId?: string,
|
||||
): material is ToolInvocationArtifactKeyMaterial {
|
||||
return (
|
||||
material !== null &&
|
||||
typeof material.keyId === 'string' &&
|
||||
(expectedKeyId === undefined || material.keyId === expectedKeyId) &&
|
||||
material.key instanceof Uint8Array &&
|
||||
material.key.byteLength === 32
|
||||
);
|
||||
}
|
||||
|
||||
function wipeMaterial(
|
||||
material: ToolInvocationArtifactKeyMaterial | null | undefined,
|
||||
): void {
|
||||
if (material?.key instanceof Uint8Array) material.key.fill(0);
|
||||
}
|
||||
|
||||
function completionMatches(
|
||||
completion: Readonly<ToolExecutionCompletionRecord>,
|
||||
barrier: Readonly<ToolExecutionStartBarrierRecord>,
|
||||
artifact: Readonly<ToolExecutionResultArtifact>,
|
||||
): boolean {
|
||||
return (
|
||||
completion.startId === barrier.startId &&
|
||||
completion.projectId === barrier.projectId &&
|
||||
completion.runId === barrier.runId &&
|
||||
completion.stepRunId === barrier.stepRunId &&
|
||||
completion.startedStepRunVersion === barrier.startedStepRunVersion &&
|
||||
completion.barrierDigest === barrier.barrierDigest &&
|
||||
completion.adapterDigest === barrier.adapterDigest &&
|
||||
artifact.artifactId === completion.resultArtifact.artifactId &&
|
||||
artifact.artifactDigest === completion.resultArtifact.artifactDigest &&
|
||||
artifact.projectId === completion.projectId &&
|
||||
artifact.startId === completion.startId &&
|
||||
artifact.runId === completion.runId &&
|
||||
artifact.stepRunId === completion.stepRunId &&
|
||||
artifact.barrierDigest === completion.barrierDigest &&
|
||||
artifact.adapterDigest === completion.adapterDigest &&
|
||||
artifact.outputDigest === completion.resultArtifact.outputDigest &&
|
||||
artifact.executionResultDigest ===
|
||||
completion.resultArtifact.executionResultDigest &&
|
||||
artifact.sealedAtMs === completion.completedAtMs
|
||||
);
|
||||
}
|
||||
|
||||
async function openDurableCompletion(
|
||||
completion: Readonly<ToolExecutionCompletionRecord>,
|
||||
dependencies: TrustedToolSuccessCompletionDependencies,
|
||||
): Promise<Readonly<TrustedToolSuccessCompletionResult>> {
|
||||
const barrier = await findBarrier(completion.startId, dependencies);
|
||||
let artifact: Readonly<ToolExecutionResultArtifact>;
|
||||
try {
|
||||
const value = await dependencies.completions.findResultArtifact(
|
||||
completion.resultArtifact.artifactId,
|
||||
);
|
||||
if (!value) return unavailable();
|
||||
artifact = normalizeToolExecutionResultArtifact(value);
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
if (!completionMatches(completion, barrier, artifact)) {
|
||||
return conflict();
|
||||
}
|
||||
|
||||
const adapter = dependencies.adapters.resolve(barrier);
|
||||
if (!sameValue(artifact.tool, adapter.binding.tool)) {
|
||||
return conflict();
|
||||
}
|
||||
|
||||
let material: ToolInvocationArtifactKeyMaterial | null | undefined;
|
||||
try {
|
||||
const catalog = await findResultKeyCatalog(dependencies);
|
||||
const overlay = await findResultRekeyHead(
|
||||
artifact.artifactId,
|
||||
dependencies,
|
||||
);
|
||||
const keyId = overlay?.targetCatalogFence.keyId ?? artifact.keyId;
|
||||
const entry = requireDecryptableToolResultKey(catalog, keyId);
|
||||
const materialProof =
|
||||
overlay?.targetCatalogFence.materialProof ?? entry.materialProof;
|
||||
if (entry.materialProof !== materialProof) return unavailable();
|
||||
material = await dependencies.resultKeys.resolve(keyId);
|
||||
if (!validCatalogMaterial(material, keyId, materialProof)) {
|
||||
return unavailable();
|
||||
}
|
||||
const output = overlay
|
||||
? openToolExecutionResultRekeyOverlay(
|
||||
overlay,
|
||||
material.key,
|
||||
dependencies.adapters.definitionRegistry(),
|
||||
artifact,
|
||||
)
|
||||
: openToolExecutionResultArtifact(
|
||||
artifact,
|
||||
material.key,
|
||||
dependencies.adapters.definitionRegistry(),
|
||||
);
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
completion,
|
||||
output,
|
||||
});
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
} finally {
|
||||
wipeMaterial(material);
|
||||
}
|
||||
}
|
||||
|
||||
async function findStepRun(
|
||||
barrier: Readonly<ToolExecutionStartBarrierRecord>,
|
||||
dependencies: TrustedToolSuccessCompletionDependencies,
|
||||
) {
|
||||
try {
|
||||
const value = await dependencies.stepRuns.findById(barrier.stepRunId);
|
||||
if (!value) return unavailable();
|
||||
return normalizeStepRunRecord(value);
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async function findRun(
|
||||
barrier: Readonly<ToolExecutionStartBarrierRecord>,
|
||||
dependencies: TrustedToolSuccessCompletionDependencies,
|
||||
) {
|
||||
try {
|
||||
const value = await dependencies.runs.findRunById(barrier.runId);
|
||||
if (!value) return unavailable();
|
||||
return value;
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
function completionDedupeKey(startId: string): string {
|
||||
return `tool-success:${createHash('sha256').update(startId).digest('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes one already-started retry-safe Tool and closes its success path.
|
||||
*
|
||||
* Durable completion is checked before adapter execution and again after it.
|
||||
* If the commit response is lost, the stored encrypted result is reopened and
|
||||
* returned without executing the adapter a second time in this call.
|
||||
*/
|
||||
export async function executeAndCompleteTrustedToolSuccess(
|
||||
startId: string,
|
||||
dependencies: TrustedToolSuccessCompletionDependencies,
|
||||
): Promise<Readonly<TrustedToolSuccessCompletionResult>> {
|
||||
validateDependencies(dependencies);
|
||||
|
||||
const existing = await findCompletion(startId, dependencies);
|
||||
if (existing) return openDurableCompletion(existing, dependencies);
|
||||
|
||||
const executionResult = await executeTrustedToolAfterStart(
|
||||
startId,
|
||||
dependencies,
|
||||
);
|
||||
|
||||
const concurrent = await findCompletion(startId, dependencies);
|
||||
if (concurrent) return openDurableCompletion(concurrent, dependencies);
|
||||
|
||||
const barrier = await findBarrier(startId, dependencies);
|
||||
const adapter = dependencies.adapters.resolve(barrier);
|
||||
const stepRun = await findStepRun(barrier, dependencies);
|
||||
const run = await findRun(barrier, dependencies);
|
||||
if (
|
||||
stepRun.id !== barrier.stepRunId ||
|
||||
stepRun.runId !== barrier.runId ||
|
||||
stepRun.kind !== 'tool' ||
|
||||
stepRun.status !== 'running' ||
|
||||
stepRun.version !== barrier.startedStepRunVersion ||
|
||||
stepRun.stepRunDigest !== barrier.startedStepRunDigest ||
|
||||
run.id !== barrier.runId ||
|
||||
run.projectId !== barrier.projectId ||
|
||||
!Number.isSafeInteger(run.version) ||
|
||||
run.version < 0 ||
|
||||
!Number.isSafeInteger(run.eventSequence) ||
|
||||
run.eventSequence < 0 ||
|
||||
TERMINAL_RUN_STATUSES.has(run.status)
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
|
||||
let identities: TrustedToolSuccessCompletionIdentities;
|
||||
try {
|
||||
identities = dependencies.identities.create(startId);
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
|
||||
let material: ToolInvocationArtifactKeyMaterial | null | undefined;
|
||||
let resultArtifact: Readonly<ToolExecutionResultArtifact>;
|
||||
let resultKeyCatalogFence: ReturnType<typeof toolResultKeyCatalogFence>;
|
||||
try {
|
||||
const catalog = await findResultKeyCatalog(dependencies);
|
||||
const entry = requireActiveToolResultKey(catalog);
|
||||
material = await dependencies.resultKeys.resolve(entry.keyId);
|
||||
if (!validCatalogMaterial(material, entry.keyId, entry.materialProof)) {
|
||||
return unavailable();
|
||||
}
|
||||
resultKeyCatalogFence = toolResultKeyCatalogFence(catalog, entry);
|
||||
resultArtifact = createToolExecutionResultArtifact(
|
||||
{
|
||||
artifactId: identities.artifactId,
|
||||
projectId: barrier.projectId,
|
||||
runId: barrier.runId,
|
||||
stepRunId: barrier.stepRunId,
|
||||
tool: adapter.binding.tool,
|
||||
executionResult,
|
||||
keyId: entry.keyId,
|
||||
key: material.key,
|
||||
},
|
||||
dependencies.adapters.definitionRegistry(),
|
||||
dependencies.nonceFactory,
|
||||
);
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
} finally {
|
||||
wipeMaterial(material);
|
||||
}
|
||||
|
||||
const stepRunMutation = transitionStepRunMutation(
|
||||
stepRun,
|
||||
{
|
||||
expectedVersion: stepRun.version,
|
||||
expectedDigest: stepRun.stepRunDigest,
|
||||
mutationId: identities.mutationId,
|
||||
to: 'succeeded',
|
||||
atMs: executionResult.completedAtMs,
|
||||
outputRef: resultArtifact.artifactId,
|
||||
},
|
||||
{
|
||||
expectedRunVersion: run.version,
|
||||
expectedRunEventSequence: run.eventSequence,
|
||||
eventId: identities.eventId,
|
||||
dedupeKey: completionDedupeKey(startId),
|
||||
actor: Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'trusted-tool-runtime',
|
||||
}),
|
||||
},
|
||||
);
|
||||
const command = createToolExecutionCompletionCommand({
|
||||
barrier,
|
||||
executionResult,
|
||||
resultArtifact,
|
||||
resultKeyCatalogFence,
|
||||
stepRunMutation,
|
||||
});
|
||||
const expectedCompletion = toolExecutionCompletionRecord(command);
|
||||
|
||||
try {
|
||||
const committed = await dependencies.completions.commit(command);
|
||||
const completion = normalizeToolExecutionCompletionRecord(
|
||||
committed.completion,
|
||||
);
|
||||
if (
|
||||
!['created', 'existing'].includes(committed.status) ||
|
||||
!sameValue(completion, expectedCompletion)
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: committed.status,
|
||||
completion,
|
||||
output: executionResult.output,
|
||||
});
|
||||
} catch (cause) {
|
||||
const recovered = await findCompletion(startId, dependencies);
|
||||
if (recovered) return openDurableCompletion(recovered, dependencies);
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user