diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index f89615855d6..85ea2c1dac9 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -27,3 +27,4 @@ export * from "./reducer/session-reducer.js"; export * from "./tracer/phase0-runner.js"; export * from "./tracer/phase1-replay.js"; export * from "./tracer/phase2-live.js"; +export * from "./tools/index.js"; diff --git a/packages/paperclip-runner/src/tools/index.ts b/packages/paperclip-runner/src/tools/index.ts new file mode 100644 index 00000000000..34c7957103a --- /dev/null +++ b/packages/paperclip-runner/src/tools/index.ts @@ -0,0 +1,5 @@ +export * from "./phase7-semantic-tool-types.js"; +export * from "./phase7-semantic-tool-catalog.js"; +export * from "./phase7-tool-authorization.js"; +export * from "./phase7-semantic-tool-runtime.js"; +export * from "./phase7-tool-bindings.js"; diff --git a/packages/paperclip-runner/src/tools/phase7-semantic-tool-catalog.ts b/packages/paperclip-runner/src/tools/phase7-semantic-tool-catalog.ts new file mode 100644 index 00000000000..f93ee22c50c --- /dev/null +++ b/packages/paperclip-runner/src/tools/phase7-semantic-tool-catalog.ts @@ -0,0 +1,374 @@ +import type { + Phase7JsonSchema, + Phase7OptionalCatalogGroup, + Phase7SemanticToolDescriptor, + Phase7ToolTaskMode, +} from "./phase7-semantic-tool-types.js"; + +const ALL_MODES: Phase7ToolTaskMode[] = ["standard", "ask", "planning", "skill_test"]; +const WORK_MODES: Phase7ToolTaskMode[] = ["standard", "planning", "skill_test"]; +const STANDARD_MODES: Phase7ToolTaskMode[] = ["standard", "skill_test"]; +const RESULT_SCHEMA = objectSchema({ + schema: stringSchema({ enum: ["paperclip.phase7.tool-result.v1"] }), + ok: { type: "boolean" }, + operationId: stringSchema({ minLength: 1 }), + operationResultId: stringSchema({ minLength: 1 }), + value: {}, + commandResult: {}, + authorization: {}, +}, ["schema", "ok", "operationId", "operationResultId", "value", "commandResult", "authorization"]); +const SECRET_REDACTION = [ + { + path: "$.value", + replacement: "[SECRET_VALUE]" as const, + appliesTo: ["output", "error", "authorization_record"] as const, + }, +]; +const AUTH_REDACTION = [ + { + path: "$.headers.authorization", + replacement: "[REDACTED]" as const, + appliesTo: ["input", "output", "error", "authorization_record"] as const, + }, + { + path: "$.headers.cookie", + replacement: "[REDACTED]" as const, + appliesTo: ["input", "output", "error", "authorization_record"] as const, + }, +]; + +function stringSchema(options: Partial = {}): Phase7JsonSchema { + return { type: "string", ...options }; +} + +function arraySchema(items: Phase7JsonSchema): Phase7JsonSchema { + return { type: "array", items }; +} + +function objectSchema( + properties: Record, + required: string[] = [], +): Phase7JsonSchema { + return { type: "object", properties, required, additionalProperties: false }; +} + +function descriptor( + value: Omit & { + redaction?: Phase7SemanticToolDescriptor["redaction"]; + }, +): Phase7SemanticToolDescriptor { + const { redaction, ...rest } = value; + return { version: 1, ...rest, redaction: redaction ?? [] }; +} + +const ALWAYS_TOOLS: Phase7SemanticToolDescriptor[] = [ + descriptor({ + operationId: "get_task_context", + title: "Get task context", + description: "Read the active task, ancestors, wake context, linked results, and budget summary.", + inputSchema: objectSchema({}), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: ALL_MODES, + sideEffectClass: "read", + idempotency: "none", + mockCommandMapping: { kind: "context_read", projection: "active_task" }, + }), + descriptor({ + operationId: "get_task_history", + title: "Get task history", + description: "Read comments and audit-visible history for the active task only.", + inputSchema: objectSchema({ limit: { type: "integer", minimum: 1 } }), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: ALL_MODES, + sideEffectClass: "read", + idempotency: "none", + mockCommandMapping: { kind: "snapshot_read", projection: "active_task_history" }, + }), + descriptor({ + operationId: "list_documents", + title: "List task documents", + description: "List document metadata attached to the active task.", + inputSchema: objectSchema({}), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: ALL_MODES, + sideEffectClass: "read", + idempotency: "none", + mockCommandMapping: { kind: "snapshot_read", projection: "active_task_documents" }, + }), + descriptor({ + operationId: "read_document", + title: "Read task document", + description: "Read one document on the active task by stable key.", + inputSchema: objectSchema({ key: stringSchema({ minLength: 1 }) }, ["key"]), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: ALL_MODES, + sideEffectClass: "read", + idempotency: "none", + mockCommandMapping: { kind: "snapshot_read", projection: "active_task_document" }, + }), + descriptor({ + operationId: "list_document_revisions", + title: "List document revisions", + description: "Inspect revision history for one active-task document.", + inputSchema: objectSchema({ key: stringSchema({ minLength: 1 }) }, ["key"]), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: ALL_MODES, + sideEffectClass: "read", + idempotency: "none", + mockCommandMapping: { kind: "snapshot_read", projection: "active_task_document_revisions" }, + }), + descriptor({ + operationId: "report_progress", + title: "Report progress", + description: "Append a durable progress update to the active task.", + inputSchema: objectSchema({ body: stringSchema({ minLength: 1 }) }, ["body"]), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: WORK_MODES, + sideEffectClass: "task_write", + idempotency: "required", + mockCommandMapping: { kind: "semantic_command", commandKind: "report_progress" }, + }), + descriptor({ + operationId: "answer_status_question", + title: "Answer status question", + description: "Post an answer to a status question without changing task state.", + inputSchema: objectSchema({ body: stringSchema({ minLength: 1 }) }, ["body"]), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: ALL_MODES, + sideEffectClass: "task_write", + idempotency: "required", + mockCommandMapping: { kind: "semantic_command", commandKind: "report_progress" }, + }), + descriptor({ + operationId: "finish_task", + title: "Finish task", + description: "Finish the active task with a durable completion summary.", + inputSchema: objectSchema({ summary: stringSchema({ minLength: 1 }) }, ["summary"]), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: STANDARD_MODES, + sideEffectClass: "task_write", + idempotency: "required", + mockCommandMapping: { kind: "semantic_command", commandKind: "finish_task" }, + }), + descriptor({ + operationId: "block_task", + title: "Block task", + description: "Block the active task with a reason and optional first-class dependencies.", + inputSchema: objectSchema({ + reason: stringSchema({ minLength: 1 }), + blockedByTaskIds: arraySchema(stringSchema({ minLength: 1 })), + }, ["reason"]), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: WORK_MODES, + sideEffectClass: "task_write", + idempotency: "required", + mockCommandMapping: { kind: "semantic_command", commandKind: "block_task" }, + }), + descriptor({ + operationId: "request_review", + title: "Request task review", + description: "Move the active task to review with a durable summary.", + inputSchema: objectSchema({ summary: stringSchema({ minLength: 1 }) }, ["summary"]), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: WORK_MODES, + sideEffectClass: "task_write", + idempotency: "required", + mockCommandMapping: { kind: "semantic_command", commandKind: "request_review" }, + }), + descriptor({ + operationId: "write_document", + title: "Write task document", + description: "Create or revision-safely update an active-task document.", + inputSchema: objectSchema({ + key: stringSchema({ minLength: 1 }), + title: stringSchema({ minLength: 1 }), + body: stringSchema(), + baseRevisionId: { oneOf: [stringSchema({ minLength: 1 }), { type: "null" }] }, + changeSummary: stringSchema(), + }, ["key", "title", "body", "baseRevisionId"]), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: WORK_MODES, + sideEffectClass: "task_write", + idempotency: "required", + mockCommandMapping: { kind: "semantic_command", commandKind: "write_document" }, + }), + descriptor({ + operationId: "request_human_input", + title: "Request human input", + description: "Create a typed confirmation, checkbox, question, task suggestion, or item-verdict request.", + inputSchema: objectSchema({ + interactionKind: stringSchema({ enum: ["confirmation", "checkbox", "questions", "suggest_tasks", "item_verdicts"] }), + title: stringSchema({ minLength: 1 }), + prompt: stringSchema({ minLength: 1 }), + payload: {}, + targetRevisionId: { oneOf: [stringSchema({ minLength: 1 }), { type: "null" }] }, + continuationPolicy: stringSchema({ enum: ["none", "wake_assignee", "wake_assignee_on_accept"] }), + }, ["interactionKind", "title", "prompt", "continuationPolicy"]), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: WORK_MODES, + sideEffectClass: "task_write", + idempotency: "required", + mockCommandMapping: { kind: "semantic_command", commandKind: "request_human_input" }, + }), + descriptor({ + operationId: "register_deliverable", + title: "Register deliverable", + description: "Register an inspectable artifact and its work product on the active task.", + inputSchema: objectSchema({ + filename: stringSchema({ minLength: 1 }), + contentType: stringSchema({ minLength: 1 }), + byteSize: { type: "integer", minimum: 0 }, + sha256: stringSchema({ minLength: 1 }), + contentRef: stringSchema({ minLength: 1 }), + title: stringSchema({ minLength: 1 }), + }, ["filename", "contentType", "byteSize", "sha256", "contentRef", "title"]), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: STANDARD_MODES, + sideEffectClass: "task_write", + idempotency: "required", + mockCommandMapping: { kind: "semantic_command", commandKind: "register_deliverable" }, + }), + descriptor({ + operationId: "inspect_operation_result", + title: "Inspect operation result", + description: "Read a prior semantic operation result from this run.", + inputSchema: objectSchema({ operationResultId: stringSchema({ minLength: 1 }) }, ["operationResultId"]), + outputSchema: RESULT_SCHEMA, + disposition: "always_agent_tool", + optionalGroup: null, + requiredClaims: [], + taskModes: ALL_MODES, + sideEffectClass: "read", + idempotency: "none", + mockCommandMapping: { kind: "operation_result" }, + }), +]; + +function optional( + operationId: string, + group: Phase7OptionalCatalogGroup, + requiredClaims: string[], + sideEffectClass: Phase7SemanticToolDescriptor["sideEffectClass"], + mapping: Phase7SemanticToolDescriptor["mockCommandMapping"], + inputSchema: Phase7JsonSchema = objectSchema({}), + options: Partial> = {}, +): Phase7SemanticToolDescriptor { + const title = operationId.split("_").map((part) => part[0]!.toUpperCase() + part.slice(1)).join(" "); + return descriptor({ + operationId, + title, + description: `${title} through the Phase 7 ${group.replaceAll("_", " ")} capability set.`, + inputSchema, + outputSchema: RESULT_SCHEMA, + disposition: "optional_agent_tool", + optionalGroup: group, + requiredClaims, + taskModes: options.taskModes ?? STANDARD_MODES, + sideEffectClass, + idempotency: options.idempotency ?? (sideEffectClass === "read" ? "none" : "required"), + redaction: options.redaction, + allowedRoles: options.allowedRoles, + mockCommandMapping: mapping, + }); +} + +const OPTIONAL_TOOLS: Phase7SemanticToolDescriptor[] = [ + optional("search_tasks", "discovery", ["discovery:tasks:read"], "read", { kind: "snapshot_read", projection: "company_tasks" }, objectSchema({ query: stringSchema(), status: stringSchema() })), + optional("list_agents", "discovery", ["discovery:agents:read"], "read", { kind: "snapshot_read", projection: "company_actors" }), + optional("list_projects", "discovery", ["discovery:projects:read"], "read", { kind: "mock_extension", extension: "discovery.projects" }), + optional("list_goals", "discovery", ["discovery:goals:read"], "read", { kind: "mock_extension", extension: "discovery.goals" }), + + optional("create_task", "delegation_dependencies", ["delegation:tasks:create"], "company_write", { kind: "semantic_command", commandKind: "create_task" }, objectSchema({ title: stringSchema({ minLength: 1 }), description: stringSchema(), assigneeActorId: stringSchema(), priority: stringSchema(), blockedByTaskIds: arraySchema(stringSchema()) }, ["title"])), + optional("set_dependencies", "delegation_dependencies", ["dependencies:write"], "company_write", { kind: "semantic_command", commandKind: "set_dependencies" }, objectSchema({ blockedByTaskIds: arraySchema(stringSchema({ minLength: 1 })) }, ["blockedByTaskIds"])), + + optional("list_approvals", "governance", ["governance:approvals:read"], "read", { kind: "snapshot_read", projection: "company_approvals" }), + optional("request_approval", "governance", ["governance:approvals:request"], "governance", { kind: "semantic_command", commandKind: "request_approval" }, objectSchema({ approvalType: stringSchema({ minLength: 1 }), payload: {} }, ["approvalType", "payload"])), + optional("decide_approval", "governance", ["governance:approvals:decide"], "governance", { kind: "semantic_command", commandKind: "decide_approval" }, objectSchema({ approvalId: stringSchema({ minLength: 1 }), decision: stringSchema({ enum: ["approved", "rejected", "cancelled"] }), note: stringSchema({ minLength: 1 }) }, ["approvalId", "decision", "note"]), { allowedRoles: ["board", "approver", "security"] }), + optional("comment_on_approval", "governance", ["governance:approvals:comment"], "governance", { kind: "semantic_command", commandKind: "comment_on_approval" }, objectSchema({ approvalId: stringSchema({ minLength: 1 }), body: stringSchema({ minLength: 1 }) }, ["approvalId", "body"])), + + optional("list_cases", "cases", ["cases:read"], "read", { kind: "mock_extension", extension: "cases.list" }), + optional("upsert_case", "cases", ["cases:write"], "company_write", { kind: "mock_extension", extension: "cases.upsert" }, objectSchema({ key: stringSchema({ minLength: 1 }), body: stringSchema() }, ["key", "body"])), + + optional("get_workspace_runtime", "workspace_runtime", ["workspace:read"], "read", { kind: "snapshot_read", projection: "active_task_workspace" }), + optional("control_workspace_service", "workspace_runtime", ["workspace:control"], "workspace_control", { kind: "semantic_command", commandKind: "control_workspace_service" }, objectSchema({ serviceId: stringSchema({ minLength: 1 }), action: stringSchema({ enum: ["start", "stop", "fail"] }), url: stringSchema() }, ["serviceId", "action"])), + + optional("list_routines", "routines", ["routines:read"], "read", { kind: "mock_extension", extension: "routines.list" }), + optional("manage_routine", "routines", ["routines:write"], "admin", { kind: "mock_extension", extension: "routines.manage" }), + + optional("list_company_skills", "company_skills", ["company_skills:read"], "read", { kind: "mock_extension", extension: "company_skills.list" }), + optional("sync_company_skills", "company_skills", ["company_skills:write"], "admin", { kind: "mock_extension", extension: "company_skills.sync" }), + + optional("list_secret_metadata", "secrets", ["secrets:metadata:read"], "read", { kind: "mock_extension", extension: "secrets.metadata" }), + optional("read_secret_value", "secrets", ["secrets:values:read"], "secret_read", { kind: "mock_extension", extension: "secrets.value" }, objectSchema({ name: stringSchema({ minLength: 1 }) }, ["name"]), { redaction: [...SECRET_REDACTION], idempotency: "none" }), + + optional("export_company", "portability_admin", ["portability:export"], "admin", { kind: "mock_extension", extension: "portability.export" }), + optional("administer_company", "portability_admin", ["company:admin"], "admin", { kind: "mock_extension", extension: "company.admin" }, objectSchema({ action: stringSchema({ minLength: 1 }), payload: {} }, ["action"]), { allowedRoles: ["board", "ceo", "admin"] }), + + optional("generic_api_request", "test_escape_hatch", ["test:generic_api"], "test_escape_hatch", { kind: "mock_extension", extension: "test.generic_api" }, objectSchema({ method: stringSchema({ minLength: 1 }), path: stringSchema({ minLength: 1 }), headers: { type: "object", additionalProperties: stringSchema() }, body: {} }, ["method", "path"]), { taskModes: ["skill_test"], redaction: [...AUTH_REDACTION] }), +]; + +export const PHASE7_SEMANTIC_TOOL_CATALOG: readonly Phase7SemanticToolDescriptor[] = Object.freeze( + [...ALWAYS_TOOLS, ...OPTIONAL_TOOLS].map((tool) => Object.freeze(tool)), +); + +export const PHASE7_OPTIONAL_TOOL_CATALOGS: Readonly< + Record +> = Object.freeze({ + discovery: toolsInGroup("discovery"), + delegation_dependencies: toolsInGroup("delegation_dependencies"), + governance: toolsInGroup("governance"), + cases: toolsInGroup("cases"), + workspace_runtime: toolsInGroup("workspace_runtime"), + routines: toolsInGroup("routines"), + company_skills: toolsInGroup("company_skills"), + secrets: toolsInGroup("secrets"), + portability_admin: toolsInGroup("portability_admin"), + test_escape_hatch: toolsInGroup("test_escape_hatch"), +}); + +export function phase7SemanticTool(operationId: string): Phase7SemanticToolDescriptor | undefined { + return PHASE7_SEMANTIC_TOOL_CATALOG.find((tool) => tool.operationId === operationId); +} + +function toolsInGroup(group: Phase7OptionalCatalogGroup): readonly Phase7SemanticToolDescriptor[] { + return Object.freeze(OPTIONAL_TOOLS.filter((tool) => tool.optionalGroup === group)); +} diff --git a/packages/paperclip-runner/src/tools/phase7-semantic-tool-runtime.ts b/packages/paperclip-runner/src/tools/phase7-semantic-tool-runtime.ts new file mode 100644 index 00000000000..0ddeea9c1ec --- /dev/null +++ b/packages/paperclip-runner/src/tools/phase7-semantic-tool-runtime.ts @@ -0,0 +1,482 @@ +import type { + Phase7CommandEnvelope, + Phase7JsonValue, + Phase7MockControlPlanePort, + Phase7SemanticCommand, +} from "../mock-core/phase7-control-plane-types.js"; +import { phase7SemanticTool } from "./phase7-semantic-tool-catalog.js"; +import { Phase7ToolAuthorizationEngine } from "./phase7-tool-authorization.js"; +import type { + Phase7AuthorizationRecord, + Phase7JsonSchema, + Phase7PolicyDenial, + Phase7ScenarioToolPolicy, + Phase7SemanticToolDescriptor, + Phase7ToolAuthorizationContext, + Phase7ToolInvocation, + Phase7ToolInvocationResult, + Phase7ToolSuccess, + Phase7VisibleToolSet, +} from "./phase7-semantic-tool-types.js"; + +export interface Phase7SemanticToolRuntimeOptions { + adapter: Phase7MockControlPlanePort; + runId: string; + scenarioGrants?: string[]; + policy?: Phase7ScenarioToolPolicy; + resolveSecretValue?: (name: string) => Promise | string | null; +} + +export class Phase7SemanticToolRuntime { + readonly #adapter: Phase7MockControlPlanePort; + readonly #runId: string; + readonly #scenarioGrants: string[]; + readonly #policy: Phase7ScenarioToolPolicy | undefined; + readonly #resolveSecretValue: Phase7SemanticToolRuntimeOptions["resolveSecretValue"]; + readonly #authorization = new Phase7ToolAuthorizationEngine(); + readonly #operationResults = new Map(); + #resultSequence = 0; + + constructor(options: Phase7SemanticToolRuntimeOptions) { + this.#adapter = options.adapter; + this.#runId = options.runId; + this.#scenarioGrants = [...new Set(options.scenarioGrants ?? [])].sort(); + this.#policy = options.policy === undefined ? undefined : structuredClone(options.policy); + this.#resolveSecretValue = options.resolveSecretValue; + } + + visibleTools(): Phase7VisibleToolSet { + return this.#authorization.computeVisibleTools(this.#context()); + } + + authorizationRecords(): readonly Phase7AuthorizationRecord[] { + return this.#authorization.records(); + } + + async invoke(invocation: Phase7ToolInvocation): Promise { + const context = this.#context(); + const authorization = this.#authorization.authorizeInvocation( + invocation.operationId, + invocation.input, + context, + ); + if (authorization.outcome !== "allowed") { + return this.#denial( + invocation.operationId, + authorization, + authorization.outcome === "absent" ? "operation_absent" : "policy_denied", + ); + } + + const descriptor = phase7SemanticTool(invocation.operationId)!; + const validationIssues = validateJsonSchema(descriptor.inputSchema, invocation.input); + if (validationIssues.length > 0) { + const denied = this.#authorization.denyInvocation( + invocation.operationId, + context, + "input_schema_invalid", + ); + return this.#denial(invocation.operationId, denied, "input_invalid"); + } + if (descriptor.idempotency === "required" && !invocation.idempotencyKey?.trim()) { + const denied = this.#authorization.denyInvocation( + invocation.operationId, + context, + "idempotency_key_required", + ); + return this.#denial(invocation.operationId, denied, "input_invalid"); + } + if (invocation.operationId === "decide_approval" && this.#isSelfApproval(invocation.input)) { + const denied = this.#authorization.denyInvocation( + invocation.operationId, + context, + "self_approval_conflict", + ); + return this.#denial(invocation.operationId, denied, "policy_denied"); + } + + const beforeRevision = this.#adapter.snapshot().revision; + try { + const execution = await this.#execute(descriptor, invocation); + const finalAuthorization = this.#authorization.attachStateChange( + authorization.sequence, + beforeRevision, + this.#adapter.snapshot().revision, + execution.entityRefs, + ); + const resultId = execution.commandResult?.commandId ?? `tool-result-${++this.#resultSequence}`; + const success: Phase7ToolSuccess = { + schema: "paperclip.phase7.tool-result.v1", + ok: true, + operationId: invocation.operationId, + operationResultId: resultId, + value: execution.value, + commandResult: execution.commandResult, + authorization: finalAuthorization, + }; + this.#operationResults.set(resultId, redactStoredResult(descriptor, execution.value)); + return deepFreeze(success); + } catch { + const denied = this.#authorization.denyInvocation( + invocation.operationId, + context, + "mock_operation_rejected", + ); + return this.#denial(invocation.operationId, denied, "operation_unsupported"); + } + } + + async #execute( + descriptor: Phase7SemanticToolDescriptor, + invocation: Phase7ToolInvocation, + ): Promise<{ value: Phase7JsonValue; commandResult: Phase7ToolSuccess["commandResult"]; entityRefs: string[] }> { + const input = asObject(invocation.input); + switch (descriptor.mockCommandMapping.kind) { + case "context_read": + return { value: toJsonValue(this.#adapter.context(this.#runId)), commandResult: null, entityRefs: [] }; + case "snapshot_read": + return { + value: this.#snapshotProjection(descriptor.mockCommandMapping.projection, input), + commandResult: null, + entityRefs: [], + }; + case "operation_result": { + const id = requireString(input.operationResultId); + const result = this.#operationResults.get(id); + if (result === undefined) throw new Error("operation result is not available"); + return { value: structuredClone(result), commandResult: null, entityRefs: [] }; + } + case "semantic_command": { + const command = commandForOperation(descriptor.operationId, input); + const envelope: Phase7CommandEnvelope = { + runId: this.#runId, + idempotencyKey: invocation.idempotencyKey!, + command, + }; + const commandResult = await this.#adapter.applyCommand(envelope); + return { + value: toJsonValue(commandResult), + commandResult, + entityRefs: commandResult.entityRefs, + }; + } + case "mock_extension": + return this.#executeExtension(descriptor, input); + } + } + + async #executeExtension( + descriptor: Phase7SemanticToolDescriptor, + input: Record, + ): Promise<{ value: Phase7JsonValue; commandResult: null; entityRefs: string[] }> { + switch (descriptor.mockCommandMapping.kind === "mock_extension" + ? descriptor.mockCommandMapping.extension + : "") { + case "discovery.projects": + case "discovery.goals": + case "cases.list": + case "routines.list": + case "company_skills.list": + return { value: [], commandResult: null, entityRefs: [] }; + case "secrets.metadata": + return { value: [], commandResult: null, entityRefs: [] }; + case "secrets.value": { + const name = requireString(input.name); + const value = await this.#resolveSecretValue?.(name); + if (value === null || value === undefined) throw new Error("secret is not available"); + return { value: { name, value }, commandResult: null, entityRefs: [] }; + } + case "portability.export": { + const snapshot = this.#adapter.snapshot(); + return { + value: { + schema: "paperclip.phase7.mock-export.v1", + company: { id: snapshot.company.id, name: snapshot.company.name }, + taskCount: snapshot.tasks.length, + actorCount: snapshot.actors.length, + }, + commandResult: null, + entityRefs: [], + }; + } + case "test.generic_api": + return { + value: { + status: 200, + body: null, + warning: "TEST-ONLY mock request; no control plane or network was contacted.", + }, + commandResult: null, + entityRefs: [], + }; + default: + throw new Error("mock extension is not implemented"); + } + } + + #snapshotProjection( + projection: string, + input: Record, + ): Phase7JsonValue { + const snapshot = this.#adapter.snapshot(); + const context = this.#adapter.context(this.#runId); + switch (projection) { + case "active_task_history": + return toJsonValue(snapshot.comments.filter((comment) => comment.taskId === context.activeTask.id)); + case "active_task_documents": + return toJsonValue(snapshot.documents + .filter((document) => document.taskId === context.activeTask.id) + .map(({ id, key, title, format, latestRevisionId, revisions }) => ({ + id, key, title, format, latestRevisionId, revisionCount: revisions.length, + }))); + case "active_task_document": { + const key = requireString(input.key); + const document = snapshot.documents.find( + (candidate) => candidate.taskId === context.activeTask.id && candidate.key === key, + ); + if (document === undefined) throw new Error("document is not available"); + return toJsonValue(document); + } + case "active_task_document_revisions": { + const key = requireString(input.key); + const document = snapshot.documents.find( + (candidate) => candidate.taskId === context.activeTask.id && candidate.key === key, + ); + if (document === undefined) throw new Error("document is not available"); + return toJsonValue(document.revisions); + } + case "company_tasks": { + const query = typeof input.query === "string" ? input.query.toLowerCase() : ""; + const status = typeof input.status === "string" ? input.status : null; + return toJsonValue(snapshot.tasks.filter( + (task) => + (query === "" || `${task.identifier} ${task.title} ${task.description ?? ""}`.toLowerCase().includes(query)) && + (status === null || task.status === status), + )); + } + case "company_actors": + return toJsonValue(snapshot.actors.map(({ id, companyId, name, role, status }) => ({ id, companyId, name, role, status }))); + case "company_approvals": + return toJsonValue(snapshot.approvals); + case "active_task_workspace": + return toJsonValue(snapshot.workspaceServices.filter((service) => service.taskId === context.activeTask.id)); + default: + throw new Error("snapshot projection is not implemented"); + } + } + + #context(): Phase7ToolAuthorizationContext { + const runContext = this.#adapter.context(this.#runId); + return { + runId: this.#runId, + actor: { + id: runContext.actor.id, + role: runContext.actor.role, + capabilityGrants: [...runContext.actor.capabilityGrants], + }, + task: { + id: runContext.activeTask.id, + assigneeActorId: runContext.activeTask.assigneeActorId, + workMode: runContext.activeTask.workMode, + }, + scenarioGrants: [...this.#scenarioGrants], + policy: this.#policy, + }; + } + + #isSelfApproval(input: Phase7JsonValue): boolean { + const approvalId = asObject(input).approvalId; + if (typeof approvalId !== "string") return false; + const actorId = this.#adapter.context(this.#runId).actor.id; + const approval = this.#adapter.snapshot().approvals.find((candidate) => candidate.id === approvalId); + return approval?.requestedByActorId === actorId; + } + + #denial( + operationId: string, + authorization: Phase7AuthorizationRecord, + code: Phase7PolicyDenial["error"]["code"], + ): Phase7PolicyDenial { + return deepFreeze({ + schema: "paperclip.phase7.tool-result.v1", + ok: false, + error: { + code, + message: code === "operation_absent" + ? "The requested semantic operation is not available." + : "The requested semantic operation was not executed.", + operationId, + reason: authorization.reason, + }, + authorization, + }); + } +} + +function commandForOperation( + operationId: string, + input: Record, +): Phase7SemanticCommand { + switch (operationId) { + case "report_progress": + case "answer_status_question": + return { kind: "report_progress", body: requireString(input.body) }; + case "finish_task": + return { kind: "finish_task", summary: requireString(input.summary) }; + case "block_task": + return { + kind: "block_task", + reason: requireString(input.reason), + blockedByTaskIds: optionalStringArray(input.blockedByTaskIds), + }; + case "request_review": + return { kind: "request_review", summary: requireString(input.summary) }; + case "write_document": + return { + kind: "write_document", + key: requireString(input.key), + title: requireString(input.title), + body: requireString(input.body, true), + baseRevisionId: input.baseRevisionId === null ? null : requireString(input.baseRevisionId), + changeSummary: typeof input.changeSummary === "string" ? input.changeSummary : undefined, + }; + case "request_human_input": + return { + kind: "request_human_input", + interactionKind: requireString(input.interactionKind) as never, + title: requireString(input.title), + prompt: requireString(input.prompt), + payload: input.payload, + targetRevisionId: input.targetRevisionId === null || input.targetRevisionId === undefined + ? input.targetRevisionId + : requireString(input.targetRevisionId), + continuationPolicy: requireString(input.continuationPolicy) as never, + }; + case "register_deliverable": + return { + kind: "register_deliverable", + filename: requireString(input.filename), + contentType: requireString(input.contentType), + byteSize: input.byteSize as number, + sha256: requireString(input.sha256), + contentRef: requireString(input.contentRef), + title: requireString(input.title), + }; + case "create_task": + return { + kind: "create_task", + title: requireString(input.title), + description: typeof input.description === "string" ? input.description : undefined, + assigneeActorId: typeof input.assigneeActorId === "string" ? input.assigneeActorId : undefined, + priority: typeof input.priority === "string" ? input.priority as never : undefined, + blockedByTaskIds: optionalStringArray(input.blockedByTaskIds), + }; + case "set_dependencies": + return { kind: "set_dependencies", blockedByTaskIds: optionalStringArray(input.blockedByTaskIds) ?? [] }; + case "request_approval": + return { kind: "request_approval", approvalType: requireString(input.approvalType), payload: input.payload ?? {} }; + case "decide_approval": + return { + kind: "decide_approval", + approvalId: requireString(input.approvalId), + decision: requireString(input.decision) as never, + note: requireString(input.note), + }; + case "comment_on_approval": + return { kind: "comment_on_approval", approvalId: requireString(input.approvalId), body: requireString(input.body) }; + case "control_workspace_service": + return { + kind: "control_workspace_service", + serviceId: requireString(input.serviceId), + action: requireString(input.action) as never, + url: typeof input.url === "string" ? input.url : undefined, + }; + default: + throw new Error("semantic command mapping is not implemented"); + } +} + +export function validateJsonSchema(schema: Phase7JsonSchema, value: Phase7JsonValue, path = "$" ): string[] { + if (schema.oneOf !== undefined) { + return schema.oneOf.some((candidate) => validateJsonSchema(candidate, value, path).length === 0) + ? [] + : [`${path} does not match any allowed shape`]; + } + if (schema.type !== undefined && !matchesType(schema.type, value)) return [`${path} must be ${schema.type}`]; + if (schema.enum !== undefined && !schema.enum.some((candidate) => JSON.stringify(candidate) === JSON.stringify(value))) { + return [`${path} must be an allowed value`]; + } + if (typeof value === "string" && schema.minLength !== undefined && value.length < schema.minLength) { + return [`${path} is too short`]; + } + if (typeof value === "number" && schema.minimum !== undefined && value < schema.minimum) { + return [`${path} is below the minimum`]; + } + if (Array.isArray(value) && schema.items !== undefined) { + return value.flatMap((item, index) => validateJsonSchema(schema.items!, item, `${path}[${index}]`)); + } + if (schema.type === "object" && typeof value === "object" && value !== null && !Array.isArray(value)) { + const object = value as Record; + const issues = (schema.required ?? []) + .filter((key) => !(key in object)) + .map((key) => `${path}.${key} is required`); + for (const [key, child] of Object.entries(object)) { + const childSchema = schema.properties?.[key]; + if (childSchema !== undefined) issues.push(...validateJsonSchema(childSchema, child, `${path}.${key}`)); + else if (schema.additionalProperties === false) issues.push(`${path}.${key} is not allowed`); + else if (typeof schema.additionalProperties === "object") { + issues.push(...validateJsonSchema(schema.additionalProperties, child, `${path}.${key}`)); + } + } + return issues; + } + return []; +} + +function matchesType(type: NonNullable, value: Phase7JsonValue): boolean { + switch (type) { + case "null": return value === null; + case "array": return Array.isArray(value); + case "object": return typeof value === "object" && value !== null && !Array.isArray(value); + case "integer": return typeof value === "number" && Number.isSafeInteger(value); + default: return typeof value === type; + } +} + +function redactStoredResult( + descriptor: Phase7SemanticToolDescriptor, + value: Phase7JsonValue, +): Phase7JsonValue { + if (!descriptor.redaction.some((rule) => rule.appliesTo.includes("output"))) return structuredClone(value); + if (descriptor.operationId === "read_secret_value" && typeof value === "object" && value !== null && !Array.isArray(value)) { + return { ...value, value: "[SECRET_VALUE]" }; + } + return structuredClone(value); +} + +function optionalStringArray(value: Phase7JsonValue | undefined): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error("expected string array"); + return value as string[]; +} + +function requireString(value: Phase7JsonValue | undefined, allowEmpty = false): string { + if (typeof value !== "string" || (!allowEmpty && value.length === 0)) throw new Error("expected string"); + return value; +} + +function asObject(value: Phase7JsonValue): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("expected object input"); + return value; +} + +function toJsonValue(value: unknown): Phase7JsonValue { + return structuredClone(value) as Phase7JsonValue; +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} diff --git a/packages/paperclip-runner/src/tools/phase7-semantic-tool-types.ts b/packages/paperclip-runner/src/tools/phase7-semantic-tool-types.ts new file mode 100644 index 00000000000..0ccc33535a8 --- /dev/null +++ b/packages/paperclip-runner/src/tools/phase7-semantic-tool-types.ts @@ -0,0 +1,179 @@ +import type { + Phase7CommandResult, + Phase7InteractionKind, + Phase7JsonValue, + Phase7SemanticCommand, +} from "../mock-core/phase7-control-plane-types.js"; + +export type { Phase7JsonValue } from "../mock-core/phase7-control-plane-types.js"; + +export type Phase7ToolDisposition = "always_agent_tool" | "optional_agent_tool"; + +export type Phase7OptionalCatalogGroup = + | "discovery" + | "delegation_dependencies" + | "governance" + | "cases" + | "workspace_runtime" + | "routines" + | "company_skills" + | "secrets" + | "portability_admin" + | "test_escape_hatch"; + +export type Phase7SideEffectClass = + | "read" + | "task_write" + | "company_write" + | "governance" + | "workspace_control" + | "secret_read" + | "admin" + | "test_escape_hatch"; + +export type Phase7IdempotencyBehavior = "none" | "required" | "recommended"; + +export type Phase7ToolTaskMode = "standard" | "ask" | "planning" | "skill_test"; + +export interface Phase7JsonSchema { + $schema?: string; + type?: "object" | "array" | "string" | "number" | "integer" | "boolean" | "null"; + title?: string; + description?: string; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | Phase7JsonSchema; + items?: Phase7JsonSchema; + enum?: Phase7JsonValue[]; + minLength?: number; + minimum?: number; + oneOf?: Phase7JsonSchema[]; +} + +export interface Phase7RedactionRule { + path: string; + replacement: "[REDACTED]" | "[SECRET_VALUE]"; + appliesTo: ReadonlyArray<"input" | "output" | "error" | "authorization_record">; +} + +export type Phase7MockCommandMapping = + | { kind: "context_read"; projection: string } + | { kind: "snapshot_read"; projection: string } + | { kind: "semantic_command"; commandKind: Phase7SemanticCommand["kind"] } + | { kind: "operation_result" } + | { kind: "mock_extension"; extension: string }; + +export interface Phase7SemanticToolDescriptor { + operationId: string; + version: 1; + title: string; + description: string; + inputSchema: Phase7JsonSchema; + outputSchema: Phase7JsonSchema; + disposition: Phase7ToolDisposition; + optionalGroup: Phase7OptionalCatalogGroup | null; + requiredClaims: string[]; + allowedRoles?: string[]; + taskModes: Phase7ToolTaskMode[]; + sideEffectClass: Phase7SideEffectClass; + idempotency: Phase7IdempotencyBehavior; + redaction: Phase7RedactionRule[]; + mockCommandMapping: Phase7MockCommandMapping; +} + +export interface Phase7ScenarioToolPolicy { + deniedOperationIds?: string[]; + deniedClaims?: string[]; + allowedInteractionKinds?: Phase7InteractionKind[]; + allowSecretValueAccess?: boolean; + allowGenericEscapeHatch?: boolean; + genericEscapeHatchAllowlist?: Array<{ method: string; path: string }>; +} + +export interface Phase7ToolAuthorizationContext { + runId: string; + actor: { + id: string; + role: string; + capabilityGrants: string[]; + }; + task: { + id: string; + assigneeActorId: string | null; + workMode: Phase7ToolTaskMode; + }; + scenarioGrants: string[]; + policy?: Phase7ScenarioToolPolicy; +} + +export type Phase7AuthorizationOutcome = "exposed" | "allowed" | "denied" | "absent"; + +export interface Phase7AuthorizationRecord { + schema: "paperclip.phase7.authorization-record.v1"; + sequence: number; + runId: string; + actorId: string; + taskId: string; + operationId: string; + phase: "exposure" | "invocation"; + outcome: Phase7AuthorizationOutcome; + consideredClaims: string[]; + grantedClaims: string[]; + missingClaims: string[]; + reason: string; + redactions: Array<{ path: string; replacement: string }>; + resultingStateChange: { + beforeRevision: number | null; + afterRevision: number | null; + entityRefs: string[]; + } | null; +} + +export interface Phase7VisibleToolSet { + schema: "paperclip.phase7.visible-tools.v1"; + tools: Phase7SemanticToolDescriptor[]; + authorizationRecords: Phase7AuthorizationRecord[]; +} + +export interface Phase7PolicyDenial { + schema: "paperclip.phase7.tool-result.v1"; + ok: false; + error: { + code: "policy_denied" | "operation_absent" | "input_invalid" | "operation_unsupported"; + message: string; + operationId: string; + reason: string; + }; + authorization: Phase7AuthorizationRecord; +} + +export interface Phase7ToolSuccess { + schema: "paperclip.phase7.tool-result.v1"; + ok: true; + operationId: string; + operationResultId: string; + value: Phase7JsonValue; + commandResult: Phase7CommandResult | null; + authorization: Phase7AuthorizationRecord; +} + +export type Phase7ToolInvocationResult = Phase7ToolSuccess | Phase7PolicyDenial; + +export interface Phase7ToolInvocation { + operationId: string; + input: Phase7JsonValue; + idempotencyKey?: string; +} + +export const PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS = [ + "checkout_task", + "release_task", + "select_work", + "route_wake", + "enforce_budget", + "append_audit_record", + "persist_run", + "replay_run", + "schedule_blocker_wake", + "reconcile_run", +] as const; diff --git a/packages/paperclip-runner/src/tools/phase7-semantic-tools.test.ts b/packages/paperclip-runner/src/tools/phase7-semantic-tools.test.ts new file mode 100644 index 00000000000..b4475f11ed3 --- /dev/null +++ b/packages/paperclip-runner/src/tools/phase7-semantic-tools.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it } from "vitest"; + +import type { + Phase7FixtureApproval, + Phase7FixtureSeed, + Phase7JsonValue, +} from "../mock-core/phase7-control-plane-types.js"; +import { Phase7MockControlPlaneAdapter } from "../mock-core/phase7-mock-control-plane-adapter.js"; +import { + PHASE7_OPTIONAL_TOOL_CATALOGS, + PHASE7_SEMANTIC_TOOL_CATALOG, + phase7SemanticTool, +} from "./phase7-semantic-tool-catalog.js"; +import { Phase7SemanticToolRuntime, validateJsonSchema } from "./phase7-semantic-tool-runtime.js"; +import { Phase7CodexToolBinding, Phase7FakeAgentToolBinding } from "./phase7-tool-bindings.js"; +import { PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS } from "./phase7-semantic-tool-types.js"; + +const OPEN = { + identity: { + runId: "run-tools", + sessionId: "session-tools", + companyId: "company-1", + issueId: "task-1", + agentId: "actor-1", + }, + backendKind: "mock" as const, + sourceInstanceId: "phase7-tools-test", +}; + +async function runtimeFor(options: { + workMode?: "standard" | "ask" | "planning" | "skill_test"; + role?: string; + actorGrants?: string[]; + scenarioGrants?: string[]; + policy?: ConstructorParameters[0]["policy"]; + seed?: Phase7FixtureSeed; + resolveSecretValue?: (name: string) => string | null; +} = {}) { + const actor = { + id: "actor-1", + companyId: "company-1", + name: "Fixture Actor", + role: options.role ?? "engineer", + status: "active" as const, + budgetId: "budget-actor-1", + capabilityGrants: options.actorGrants ?? [], + }; + const adapter = new Phase7MockControlPlaneAdapter({ + ...options.seed, + actors: [actor], + tasks: options.seed?.tasks ?? [{ + id: "task-1", + companyId: "company-1", + identifier: "MCK-1", + title: "Tool boundary fixture", + description: "Protected task details", + status: "todo", + priority: "high", + workMode: options.workMode ?? "standard", + parentId: null, + assigneeActorId: "actor-1", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }], + }); + await adapter.start(); + await adapter.openFixtureRun(OPEN); + return { + adapter, + runtime: new Phase7SemanticToolRuntime({ + adapter, + runId: OPEN.identity.runId, + scenarioGrants: options.scenarioGrants, + policy: options.policy, + resolveSecretValue: options.resolveSecretValue, + }), + }; +} + +describe("Phase 7 semantic tool catalog", () => { + it("defines a unique versioned provider-neutral catalog and every optional group", () => { + const operationIds = PHASE7_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId); + expect(new Set(operationIds).size).toBe(operationIds.length); + expect(PHASE7_SEMANTIC_TOOL_CATALOG.every((tool) => tool.version === 1)).toBe(true); + expect(PHASE7_SEMANTIC_TOOL_CATALOG.every((tool) => tool.inputSchema.type === "object")).toBe(true); + expect(PHASE7_SEMANTIC_TOOL_CATALOG.every((tool) => tool.outputSchema.type === "object")).toBe(true); + expect(PHASE7_SEMANTIC_TOOL_CATALOG.every((tool) => tool.mockCommandMapping !== undefined)).toBe(true); + + expect(Object.keys(PHASE7_OPTIONAL_TOOL_CATALOGS).sort()).toEqual([ + "cases", + "company_skills", + "delegation_dependencies", + "discovery", + "governance", + "portability_admin", + "routines", + "secrets", + "test_escape_hatch", + "workspace_runtime", + ]); + expect(Object.values(PHASE7_OPTIONAL_TOOL_CATALOGS).every((tools) => tools.length > 0)).toBe(true); + expect([ + "get_task_context", + "get_task_history", + "list_documents", + "read_document", + "list_document_revisions", + "report_progress", + "answer_status_question", + "finish_task", + "block_task", + "request_review", + "write_document", + "request_human_input", + "register_deliverable", + "inspect_operation_result", + ].every((id) => phase7SemanticTool(id)?.disposition === "always_agent_tool")).toBe(true); + }); + + it("keeps control-plane operations absent and binds identical fake-agent and Codex surfaces", async () => { + const { runtime } = await runtimeFor(); + const visible = runtime.visibleTools(); + const fake = new Phase7FakeAgentToolBinding().bind(visible); + const codex = new Phase7CodexToolBinding().bind(visible); + expect(fake.map((tool) => tool.operationId)).toEqual(codex.map((tool) => tool.name)); + expect(codex.every((tool) => tool.type === "function" && tool.strict)).toBe(true); + for (const operationId of PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS) { + expect(visible.tools.some((tool) => tool.operationId === operationId)).toBe(false); + } + expect(JSON.stringify({ fake, codex })).not.toContain("Bearer "); + }); +}); + +describe("Phase 7 exposure and authorization", () => { + it("computes exposure before invocation from task mode, claims, role, and policy", async () => { + const baseline = await runtimeFor(); + const baselineIds = baseline.runtime.visibleTools().tools.map((tool) => tool.operationId); + expect(baselineIds).toContain("finish_task"); + expect(baselineIds).not.toContain("search_tasks"); + + const ask = await runtimeFor({ workMode: "ask" }); + const askIds = ask.runtime.visibleTools().tools.map((tool) => tool.operationId); + expect(askIds).toContain("answer_status_question"); + expect(askIds).not.toContain("finish_task"); + expect(askIds).not.toContain("write_document"); + + const granted = await runtimeFor({ + scenarioGrants: ["discovery:tasks:read", "governance:approvals:decide"], + role: "approver", + policy: { deniedOperationIds: ["search_tasks"] }, + }); + const grantedIds = granted.runtime.visibleTools().tools.map((tool) => tool.operationId); + expect(grantedIds).not.toContain("search_tasks"); + expect(grantedIds).toContain("decide_approval"); + expect(granted.runtime.authorizationRecords().some( + (record) => record.operationId === "search_tasks" && record.reason === "operation_denied_by_policy", + )).toBe(true); + }); + + it("keeps always tools active-task scoped and returns typed denials without protected state", async () => { + const protectedValue = "protected-approval-payload"; + const approval: Phase7FixtureApproval = { + id: "approval-protected", + companyId: "company-1", + taskIds: ["task-1"], + type: "request_board_approval", + status: "pending", + requestedByActorId: null, + payload: { private: protectedValue }, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }; + const { adapter, runtime } = await runtimeFor({ seed: { approvals: [approval] } }); + const revision = adapter.snapshot().revision; + const scoped = await runtime.invoke({ + operationId: "get_task_context", + input: { taskId: "task-other" }, + }); + expect(scoped).toMatchObject({ ok: false, error: { code: "input_invalid" } }); + expect(adapter.snapshot().revision).toBe(revision); + + const denied = await runtime.invoke({ + operationId: "decide_approval", + input: { approvalId: "approval-protected", decision: "approved", note: "approve" }, + idempotencyKey: "decision-1", + }); + expect(denied).toMatchObject({ ok: false, error: { code: "policy_denied" } }); + expect(JSON.stringify(denied)).not.toContain(protectedValue); + + const absent = await runtime.invoke({ operationId: "checkout_task", input: {} }); + expect(absent).toMatchObject({ + ok: false, + error: { code: "operation_absent", reason: "control_plane_owned_operation" }, + }); + expect(JSON.stringify(absent)).not.toContain("Protected task details"); + }); + + it("emits authorization records with decision reasons and resulting state changes", async () => { + const { adapter, runtime } = await runtimeFor(); + const beforeRevision = adapter.snapshot().revision; + const result = await runtime.invoke({ + operationId: "report_progress", + input: { body: "Semantic progress." }, + idempotencyKey: "progress-1", + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected success"); + expect(result.authorization).toMatchObject({ + outcome: "allowed", + reason: "task_scoped_always_tool", + resultingStateChange: { + beforeRevision, + afterRevision: adapter.snapshot().revision, + }, + }); + expect(result.authorization.resultingStateChange?.entityRefs).toEqual( + expect.arrayContaining(["task:task-1"]), + ); + expect(adapter.snapshot().comments.at(-1)?.body).toBe("Semantic progress."); + expect(validateJsonSchema( + phase7SemanticTool("report_progress")!.outputSchema, + structuredClone(result) as unknown as Phase7JsonValue, + )).toEqual([]); + }); + + it("executes the minimum always-tool workflow through the mock command contract", async () => { + const { adapter, runtime } = await runtimeFor(); + const written = await runtime.invoke({ + operationId: "write_document", + input: { + key: "plan", + title: "Plan", + body: "# Semantic plan", + baseRevisionId: null, + }, + idempotencyKey: "write-plan-1", + }); + expect(written.ok).toBe(true); + const revisionId = written.ok + ? written.commandResult?.entityRefs.find((ref) => ref.startsWith("revision:"))?.slice(9) + : undefined; + expect(revisionId).toBeTruthy(); + + const read = await runtime.invoke({ operationId: "read_document", input: { key: "plan" } }); + expect(read).toMatchObject({ ok: true, value: { key: "plan", latestRevisionId: revisionId } }); + const revisions = await runtime.invoke({ + operationId: "list_document_revisions", + input: { key: "plan" }, + }); + expect(revisions).toMatchObject({ ok: true, value: [{ id: revisionId, revision: 1 }] }); + + expect(await runtime.invoke({ + operationId: "request_human_input", + input: { + interactionKind: "confirmation", + title: "Confirm plan", + prompt: "Accept this plan?", + targetRevisionId: revisionId!, + continuationPolicy: "wake_assignee", + }, + idempotencyKey: "confirm-plan-1", + })).toMatchObject({ ok: true }); + expect(await runtime.invoke({ + operationId: "register_deliverable", + input: { + filename: "report.json", + contentType: "application/json", + byteSize: 42, + sha256: "fixture-sha256", + contentRef: "fixture://report.json", + title: "Semantic report", + }, + idempotencyKey: "deliverable-1", + })).toMatchObject({ ok: true }); + expect(await runtime.invoke({ + operationId: "request_review", + input: { summary: "Ready for semantic review." }, + idempotencyKey: "review-1", + })).toMatchObject({ ok: true }); + + expect(adapter.snapshot()).toMatchObject({ + tasks: [{ status: "in_review" }], + documents: [{ key: "plan", revisions: [{ body: "# Semantic plan" }] }], + interactions: [{ kind: "confirmation", status: "pending" }], + artifacts: [{ filename: "report.json" }], + workProducts: [{ type: "artifact" }], + }); + }); +}); + +describe("Phase 7 security policy", () => { + it("redacts secret values from records and stored operation results", async () => { + const secret = "fixture-secret-value"; + const { runtime } = await runtimeFor({ + scenarioGrants: ["secrets:values:read"], + policy: { allowSecretValueAccess: true }, + resolveSecretValue: (name) => name === "DEPLOY_TOKEN" ? secret : null, + }); + expect(runtime.visibleTools().tools.map((tool) => tool.operationId)).toContain("read_secret_value"); + const result = await runtime.invoke({ + operationId: "read_secret_value", + input: { name: "DEPLOY_TOKEN" }, + }); + expect(result).toMatchObject({ ok: true, value: { name: "DEPLOY_TOKEN", value: secret } }); + if (!result.ok) throw new Error("expected secret read success"); + expect(JSON.stringify(runtime.authorizationRecords())).not.toContain(secret); + expect(result.authorization.redactions).toEqual([ + { path: "$.value", replacement: "[SECRET_VALUE]" }, + ]); + + const inspected = await runtime.invoke({ + operationId: "inspect_operation_result", + input: { operationResultId: result.operationResultId }, + }); + expect(inspected).toMatchObject({ ok: true, value: { value: "[SECRET_VALUE]" } }); + + const ungranted = await runtimeFor({ + scenarioGrants: ["secrets:values:read"], + resolveSecretValue: () => secret, + }); + expect(ungranted.runtime.visibleTools().tools.map((tool) => tool.operationId)).not.toContain("read_secret_value"); + }); + + it("prohibits self approval even with the role and explicit decision claim", async () => { + const approval: Phase7FixtureApproval = { + id: "approval-self", + companyId: "company-1", + taskIds: ["task-1"], + type: "request_board_approval", + status: "pending", + requestedByActorId: "actor-1", + payload: { summary: "self requested" }, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }; + const { adapter, runtime } = await runtimeFor({ + role: "approver", + scenarioGrants: ["governance:approvals:decide"], + seed: { approvals: [approval] }, + }); + const result = await runtime.invoke({ + operationId: "decide_approval", + input: { approvalId: "approval-self", decision: "approved", note: "self approve" }, + idempotencyKey: "self-decision", + }); + expect(result).toMatchObject({ + ok: false, + error: { code: "policy_denied", reason: "self_approval_conflict" }, + }); + expect(adapter.snapshot().approvals[0]?.status).toBe("pending"); + }); + + it("keeps the generic escape hatch test-only, explicitly granted, and allowlisted", async () => { + const denied = await runtimeFor({ workMode: "skill_test", scenarioGrants: ["test:generic_api"] }); + expect(denied.runtime.visibleTools().tools.map((tool) => tool.operationId)).not.toContain("generic_api_request"); + + const { runtime } = await runtimeFor({ + workMode: "skill_test", + scenarioGrants: ["test:generic_api"], + policy: { + allowGenericEscapeHatch: true, + genericEscapeHatchAllowlist: [{ method: "GET", path: "/mock/health" }], + }, + }); + expect(runtime.visibleTools().tools.map((tool) => tool.operationId)).toContain("generic_api_request"); + const allowed = await runtime.invoke({ + operationId: "generic_api_request", + input: { method: "GET", path: "/mock/health", headers: { authorization: "Bearer not-forwarded" } }, + idempotencyKey: "escape-1", + }); + expect(allowed).toMatchObject({ ok: true, value: { status: 200, body: null } }); + expect(JSON.stringify(allowed)).not.toContain("not-forwarded"); + + const blocked = await runtime.invoke({ + operationId: "generic_api_request", + input: { method: "POST", path: "/api/companies" }, + idempotencyKey: "escape-2", + }); + expect(blocked).toMatchObject({ + ok: false, + error: { code: "policy_denied", reason: "generic_escape_hatch_target_denied" }, + }); + }); +}); diff --git a/packages/paperclip-runner/src/tools/phase7-tool-authorization.ts b/packages/paperclip-runner/src/tools/phase7-tool-authorization.ts new file mode 100644 index 00000000000..c23d279207e --- /dev/null +++ b/packages/paperclip-runner/src/tools/phase7-tool-authorization.ts @@ -0,0 +1,233 @@ +import { phase7SemanticTool, PHASE7_SEMANTIC_TOOL_CATALOG } from "./phase7-semantic-tool-catalog.js"; +import { + PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS, + type Phase7AuthorizationRecord, + type Phase7JsonValue, + type Phase7SemanticToolDescriptor, + type Phase7ToolAuthorizationContext, + type Phase7VisibleToolSet, +} from "./phase7-semantic-tool-types.js"; + +export class Phase7ToolAuthorizationEngine { + #sequence = 0; + readonly #records: Phase7AuthorizationRecord[] = []; + + computeVisibleTools(context: Phase7ToolAuthorizationContext): Phase7VisibleToolSet { + const tools: Phase7SemanticToolDescriptor[] = []; + const authorizationRecords = PHASE7_SEMANTIC_TOOL_CATALOG.map((tool) => { + const decision = this.#evaluate(tool, context, undefined); + const record = this.#record(context, tool, "exposure", decision.allowed ? "exposed" : "absent", decision.reason); + if (decision.allowed) tools.push(tool); + return record; + }); + return deepFreeze({ + schema: "paperclip.phase7.visible-tools.v1", + tools: clone(tools), + authorizationRecords, + }); + } + + authorizeInvocation( + operationId: string, + input: Phase7JsonValue, + context: Phase7ToolAuthorizationContext, + ): Phase7AuthorizationRecord { + if ((PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS as readonly string[]).includes(operationId)) { + return this.#recordUnknown(context, operationId, "absent", "control_plane_owned_operation"); + } + const tool = phase7SemanticTool(operationId); + if (tool === undefined) { + return this.#recordUnknown(context, operationId, "absent", "unknown_operation"); + } + const decision = this.#evaluate(tool, context, input); + return this.#record( + context, + tool, + "invocation", + decision.allowed ? "allowed" : "denied", + decision.reason, + ); + } + + denyInvocation( + operationId: string, + context: Phase7ToolAuthorizationContext, + reason: string, + ): Phase7AuthorizationRecord { + const tool = phase7SemanticTool(operationId); + return tool === undefined + ? this.#recordUnknown(context, operationId, "absent", reason) + : this.#record(context, tool, "invocation", "denied", reason); + } + + attachStateChange( + sequence: number, + beforeRevision: number, + afterRevision: number, + entityRefs: string[], + ): Phase7AuthorizationRecord { + const record = this.#records.find((candidate) => candidate.sequence === sequence); + if (record === undefined || record.outcome !== "allowed") { + throw new Error(`cannot attach state change to authorization record ${sequence}`); + } + record.resultingStateChange = { + beforeRevision, + afterRevision, + entityRefs: [...entityRefs].sort(), + }; + return deepFreeze(clone(record)); + } + + records(): readonly Phase7AuthorizationRecord[] { + return deepFreeze(clone(this.#records)); + } + + #evaluate( + tool: Phase7SemanticToolDescriptor, + context: Phase7ToolAuthorizationContext, + input: Phase7JsonValue | undefined, + ): { allowed: boolean; reason: string } { + const policy = context.policy ?? {}; + if (policy.deniedOperationIds?.includes(tool.operationId)) { + return { allowed: false, reason: "operation_denied_by_policy" }; + } + if (!tool.taskModes.includes(context.task.workMode)) { + return { allowed: false, reason: "operation_not_available_in_task_mode" }; + } + if ( + tool.allowedRoles !== undefined && + !tool.allowedRoles.map(normalize).includes(normalize(context.actor.role)) + ) { + return { allowed: false, reason: "actor_role_not_authorized" }; + } + const deniedClaims = new Set(policy.deniedClaims ?? []); + if (tool.requiredClaims.some((claim) => deniedClaims.has(claim))) { + return { allowed: false, reason: "required_claim_denied_by_policy" }; + } + const grants = new Set([...context.actor.capabilityGrants, ...context.scenarioGrants]); + if (tool.requiredClaims.some((claim) => !grants.has(claim))) { + return { allowed: false, reason: "required_claim_missing" }; + } + if (tool.operationId === "read_secret_value" && policy.allowSecretValueAccess !== true) { + return { allowed: false, reason: "secret_value_access_disabled" }; + } + if (tool.operationId === "generic_api_request") { + if (policy.allowGenericEscapeHatch !== true) { + return { allowed: false, reason: "generic_escape_hatch_disabled" }; + } + if (input !== undefined && !genericRequestAllowed(input, policy.genericEscapeHatchAllowlist ?? [])) { + return { allowed: false, reason: "generic_escape_hatch_target_denied" }; + } + } + if (tool.operationId === "request_human_input" && input !== undefined) { + const interactionKind = objectValue(input)?.interactionKind; + if ( + typeof interactionKind === "string" && + policy.allowedInteractionKinds !== undefined && + !policy.allowedInteractionKinds.includes(interactionKind as never) + ) { + return { allowed: false, reason: "interaction_kind_denied_by_policy" }; + } + } + return { + allowed: true, + reason: tool.disposition === "always_agent_tool" ? "task_scoped_always_tool" : "required_claims_granted", + }; + } + + #record( + context: Phase7ToolAuthorizationContext, + tool: Phase7SemanticToolDescriptor, + phase: Phase7AuthorizationRecord["phase"], + outcome: Phase7AuthorizationRecord["outcome"], + reason: string, + ): Phase7AuthorizationRecord { + const grants = [...new Set([...context.actor.capabilityGrants, ...context.scenarioGrants])].sort(); + const consideredClaims = [...tool.requiredClaims].sort(); + return this.#store({ + schema: "paperclip.phase7.authorization-record.v1", + sequence: ++this.#sequence, + runId: context.runId, + actorId: context.actor.id, + taskId: context.task.id, + operationId: tool.operationId, + phase, + outcome, + consideredClaims, + grantedClaims: consideredClaims.filter((claim) => grants.includes(claim)), + missingClaims: consideredClaims.filter((claim) => !grants.includes(claim)), + reason, + redactions: tool.redaction.map(({ path, replacement }) => ({ path, replacement })), + resultingStateChange: null, + }); + } + + #recordUnknown( + context: Phase7ToolAuthorizationContext, + operationId: string, + outcome: "absent" | "denied", + reason: string, + ): Phase7AuthorizationRecord { + return this.#store({ + schema: "paperclip.phase7.authorization-record.v1", + sequence: ++this.#sequence, + runId: context.runId, + actorId: context.actor.id, + taskId: context.task.id, + operationId, + phase: "invocation", + outcome, + consideredClaims: [], + grantedClaims: [], + missingClaims: [], + reason, + redactions: [], + resultingStateChange: null, + }); + } + + #store(record: Phase7AuthorizationRecord): Phase7AuthorizationRecord { + this.#records.push(record); + return deepFreeze(clone(record)); + } +} + +function genericRequestAllowed( + input: Phase7JsonValue, + allowlist: Array<{ method: string; path: string }>, +): boolean { + const object = objectValue(input); + if (object === undefined || typeof object.method !== "string" || typeof object.path !== "string") { + return false; + } + const method = object.method.toUpperCase(); + return allowlist.some( + (entry) => entry.method.toUpperCase() === method && pathMatches(entry.path, object.path as string), + ); +} + +function pathMatches(pattern: string, path: string): boolean { + if (!pattern.endsWith("*")) return pattern === path; + return path.startsWith(pattern.slice(0, -1)); +} + +function objectValue(value: Phase7JsonValue): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value + : undefined; +} + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +function clone(value: T): T { + return structuredClone(value); +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} diff --git a/packages/paperclip-runner/src/tools/phase7-tool-bindings.ts b/packages/paperclip-runner/src/tools/phase7-tool-bindings.ts new file mode 100644 index 00000000000..cce448a29e0 --- /dev/null +++ b/packages/paperclip-runner/src/tools/phase7-tool-bindings.ts @@ -0,0 +1,77 @@ +import type { + Phase7JsonSchema, + Phase7SemanticToolDescriptor, + Phase7VisibleToolSet, +} from "./phase7-semantic-tool-types.js"; + +export interface Phase7ToolBinding { + readonly kind: "fake_agent" | "codex"; + bind(visibleTools: Phase7VisibleToolSet): readonly TDefinition[]; + operationId(definition: TDefinition): string; +} + +export interface Phase7FakeAgentToolDefinition { + operationId: string; + description: string; + inputSchema: Phase7JsonSchema; + outputSchema: Phase7JsonSchema; +} + +export interface Phase7CodexToolDefinition { + type: "function"; + name: string; + description: string; + strict: true; + parameters: Phase7JsonSchema; +} + +export class Phase7FakeAgentToolBinding implements Phase7ToolBinding { + readonly kind = "fake_agent" as const; + + bind(visibleTools: Phase7VisibleToolSet): readonly Phase7FakeAgentToolDefinition[] { + return freezeDefinitions(visibleTools.tools.map((tool) => ({ + operationId: tool.operationId, + description: tool.description, + inputSchema: structuredClone(tool.inputSchema), + outputSchema: structuredClone(tool.outputSchema), + }))); + } + + operationId(definition: Phase7FakeAgentToolDefinition): string { + return definition.operationId; + } +} + +export class Phase7CodexToolBinding implements Phase7ToolBinding { + readonly kind = "codex" as const; + + bind(visibleTools: Phase7VisibleToolSet): readonly Phase7CodexToolDefinition[] { + return freezeDefinitions(visibleTools.tools.map(toCodexDefinition)); + } + + operationId(definition: Phase7CodexToolDefinition): string { + return definition.name; + } +} + +function toCodexDefinition(tool: Phase7SemanticToolDescriptor): Phase7CodexToolDefinition { + return { + type: "function", + name: tool.operationId, + description: tool.description, + strict: true, + parameters: structuredClone(tool.inputSchema), + }; +} + +function freezeDefinitions(definitions: T[]): readonly T[] { + for (const definition of definitions) deepFreeze(definition); + return Object.freeze(definitions); +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +}