diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index dba0c09b66..b66e45a521 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -13,12 +13,13 @@ a short-lived lease. The Rust runner now includes a Codex-only app-server provider bridge with durable thread resume, cancellation, structured questions, and provider-neutral event normalization. No server code starts or invokes it. The package also publishes the canonical semantic action declarations and -their input and output schemas. It does not add a server adapter, -semantic-action authorization, dispatch, or production Paperclip behavior. +their input and output schemas. Its package-local dispatcher projects only +bound, run-authorized actions and emits redacted semantic receipts. It does not +add application bindings, a server adapter, or production Paperclip behavior. The first and only installed provider is Codex. Dynamic semantic tools remain -undiscoverable because catalog membership does not grant authority and the -run-scoped authorization layer has not landed. See +undiscoverable because no production application binding or server authority +has landed. Catalog membership alone does not grant authority. See [`SEMANTIC_ACTIONS.md`](SEMANTIC_ACTIONS.md) for the catalog boundary. The root export is intentionally narrow. The `./testing` entry point and package diff --git a/packages/paperclip-runner/SEMANTIC_ACTIONS.md b/packages/paperclip-runner/SEMANTIC_ACTIONS.md index d61f807629..25551bb1d9 100644 --- a/packages/paperclip-runner/SEMANTIC_ACTIONS.md +++ b/packages/paperclip-runner/SEMANTIC_ACTIONS.md @@ -6,10 +6,12 @@ required claims, supported task modes, an effect class, and JSON Schema input and output contracts. The catalog is descriptive. Importing it or finding an operation in it does not -grant permission to show or call that operation. This change deliberately adds -no run-scoped projection, authorization decision, dispatcher, application -binding, credential, or server route. Until those layers land, Codex receives -no dynamic Paperclip tools. +grant permission to show or call that operation. A run-scoped dispatcher can +project only actions that have current actor, task, company, claim, mode, and +application-binding authority. It rechecks that authority before each call. +The package still adds no application binding, credential, server route, or +Codex tool installation. Until a later server integration supplies those +bindings, Codex receives no dynamic Paperclip tools. The initial catalog excludes scenario-only and lab operations, other-provider extensions, and a generic API escape hatch. Those additions need their own @@ -29,6 +31,17 @@ const writeDocument = paperclipSemanticAction("write_document"); `PAPERCLIP_SEMANTIC_ACTION_CATALOG` and every nested declaration are frozen. `paperclipSemanticAction` returns `undefined` for unknown operation IDs. +## Run-scoped authority + +`PaperclipSemanticDispatcher` accepts a current-context provider and an +explicit list of application bindings. Unbound actions are absent. Actor claims +and run-delegated claims are intersected. Optional discovery returns only bound +actions that pass the same authorization check. Mutation actions also require +an atomic idempotency store. The store must provide an idempotent recovery path +for a mutation that succeeds before its primary receipt commit fails. Raw tool +content never enters semantic receipts; receipts contain a digest and +allowlisted references only. + ## Generated inventory `generated/semantic-action-catalog.json` is a deterministic projection of the diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 5dc91dff73..27d6eb7b86 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -5,4 +5,5 @@ export * from "./protocol/replay-contract.js"; export * from "./protocol/replay-loader.js"; export * from "./protocol/result-normalization.js"; export * from "./reducer/session-reducer.js"; +export * from "./semantic-tools/index.js"; export * from "./tracer/replay.js"; diff --git a/packages/paperclip-runner/src/semantic-tools/authorization.ts b/packages/paperclip-runner/src/semantic-tools/authorization.ts new file mode 100644 index 0000000000..134076ed2c --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/authorization.ts @@ -0,0 +1,226 @@ +import type { PaperclipSemanticActionDescriptor } from "../catalog/semantic-action-types.js"; +import type { + PaperclipSemanticAuthorizationDecision, + PaperclipSemanticAuthorizationPhase, + PaperclipSemanticDenialCode, + PaperclipSemanticRunContext, +} from "./types.js"; + +const TERMINAL_TASK_STATES = new Set(["done", "cancelled", "canceled"]); + +export function decidePaperclipSemanticAuthorization( + descriptor: PaperclipSemanticActionDescriptor, + context: PaperclipSemanticRunContext, + phase: PaperclipSemanticAuthorizationPhase, + requestedRunId: string, + input?: unknown, +): PaperclipSemanticAuthorizationDecision { + if (!validAuthorityContext(context, requestedRunId)) { + return { + allowed: false, + phase, + operationId: descriptor.operationId, + code: "authority_context_invalid", + reason: "The run authority context is malformed.", + effectiveClaims: [], + }; + } + const effectiveClaims = intersectClaims( + context.actor.claims, + context.delegatedClaims, + ); + const base = { + phase, + operationId: descriptor.operationId, + effectiveClaims, + } as const; + const deny = ( + code: PaperclipSemanticDenialCode, + reason: string, + ): PaperclipSemanticAuthorizationDecision => ({ + ...base, + allowed: false, + code, + reason, + }); + + if (context.runId !== requestedRunId) { + return deny( + "run_mismatch", + "The authority context belongs to another run.", + ); + } + if ( + context.actor.companyId !== context.companyId || + context.activeTask.companyId !== context.companyId + ) { + return deny( + "company_mismatch", + "The actor and active task must belong to the run company.", + ); + } + if (context.actor.status !== "active") { + return deny("actor_inactive", "The run actor is not active."); + } + if (context.activeTask.assigneeActorId !== context.actor.id) { + return deny( + "task_ownership_denied", + "The run actor no longer owns the active task.", + ); + } + if (context.activeTask.executionRunId !== context.runId) { + return deny( + "task_ownership_denied", + "The active task is no longer bound to this run.", + ); + } + if (!descriptor.allowedModes.includes(context.activeTask.workMode)) { + return deny( + "task_mode_denied", + "The action is unavailable in the active task mode.", + ); + } + if ( + descriptor.effect !== "read" && + TERMINAL_TASK_STATES.has(context.activeTask.status) + ) { + return deny("task_state_denied", "The active task is already terminal."); + } + if ( + context.policy?.deniedOperationIds?.includes(descriptor.operationId) === + true + ) { + return deny("policy_denied", "Run policy denies this action."); + } + if ( + descriptor.allowedRoles !== undefined && + !descriptor.allowedRoles + .map(normalize) + .includes(normalize(context.actor.role)) + ) { + return deny("actor_role_denied", "The actor role cannot use this action."); + } + if ( + descriptor.requiredClaims.some( + (required) => !effectiveClaims.includes(required), + ) + ) { + return deny( + "required_claim_missing", + "The run lacks an explicitly delegated actor claim required by this action.", + ); + } + if ( + phase === "invocation" && + descriptor.operationId === "request_human_input" && + context.policy?.allowedInteractionKinds !== undefined + ) { + const interactionKind = stringProperty(input, "interactionKind"); + if ( + interactionKind !== undefined && + !context.policy.allowedInteractionKinds.includes(interactionKind) + ) { + return deny( + "interaction_kind_denied", + "Run policy denies this interaction kind.", + ); + } + } + + return { + ...base, + allowed: true, + code: "allowed", + reason: "The current run authority allows this action.", + }; +} + +function validAuthorityContext( + context: unknown, + requestedRunId: string, +): context is PaperclipSemanticRunContext { + if ( + !isRecord(context) || + !isRecord(context.actor) || + !isRecord(context.activeTask) || + (context.policy !== undefined && !isRecord(context.policy)) + ) { + return false; + } + const actor = context.actor; + const task = context.activeTask; + const policy = context.policy; + const stableIds = [ + requestedRunId, + context.runId, + context.companyId, + actor.id, + actor.companyId, + task.id, + task.companyId, + ...(task.assigneeActorId === null ? [] : [task.assigneeActorId]), + ...(task.executionRunId === null ? [] : [task.executionRunId]), + ]; + return ( + stableIds.every(isStableId) && + isBoundedString(actor.status) && + isBoundedString(actor.role) && + isBoundedString(task.status) && + isBoundedString(task.workMode) && + validStringList(actor.claims) && + validStringList(context.delegatedClaims) && + (policy?.deniedOperationIds === undefined || + validStringList(policy.deniedOperationIds)) && + (policy?.allowedInteractionKinds === undefined || + validStringList(policy.allowedInteractionKinds)) + ); +} + +function isStableId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length >= 1 && + value.length <= 240 && + /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value) + ); +} + +function validStringList(value: unknown): value is readonly string[] { + return ( + Array.isArray(value) && + value.length <= 1_000 && + value.every(isBoundedString) + ); +} + +function isBoundedString(value: unknown): value is string { + return typeof value === "string" && value.length >= 1 && value.length <= 240; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function intersectClaims( + actorClaims: readonly string[], + delegatedClaims: readonly string[], +): readonly string[] { + const actor = new Set(actorClaims); + return Object.freeze( + [...new Set(delegatedClaims)] + .filter((claim) => actor.has(claim)) + .sort((left, right) => left.localeCompare(right)), + ); +} + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +function stringProperty(value: unknown, key: string): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const candidate = (value as Record)[key]; + return typeof candidate === "string" ? candidate : undefined; +} diff --git a/packages/paperclip-runner/src/semantic-tools/discovery.ts b/packages/paperclip-runner/src/semantic-tools/discovery.ts new file mode 100644 index 0000000000..cedc57fc57 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/discovery.ts @@ -0,0 +1,179 @@ +import { PAPERCLIP_SEMANTIC_ACTION_CATALOG } from "../catalog/semantic-action-catalog.js"; +import type { + PaperclipSemanticActionDescriptor, + PaperclipSemanticActionId, +} from "../catalog/semantic-action-types.js"; +import { decidePaperclipSemanticAuthorization } from "./authorization.js"; +import type { + PaperclipSemanticDiscoveryResult, + PaperclipSemanticRunContext, + PaperclipSemanticToolDefinition, +} from "./types.js"; + +const NAMESPACE: Readonly> = + Object.freeze({ + get_task_context: "active_task", + get_task_history: "active_task", + list_documents: "documents", + read_document: "documents", + list_document_revisions: "documents", + report_progress: "active_task", + answer_status_question: "active_task", + write_document: "documents", + request_human_input: "documents", + register_deliverable: "documents", + finish_task: "active_task", + block_task: "active_task", + request_review: "active_task", + list_agents: "discovery", + get_agent: "discovery", + search_tasks: "discovery", + list_approvals: "governance", + get_approval: "governance", + get_approval_context: "governance", + get_workspace_runtime: "workspace", + control_workspace_service: "workspace", + set_dependencies: "delegation", + create_task: "delegation", + request_approval: "governance", + decide_approval: "governance", + comment_on_approval: "governance", + schedule_wake: "continuation", + }); + +export function paperclipSemanticActionNamespace( + operationId: PaperclipSemanticActionId, +): string { + return NAMESPACE[operationId]; +} + +export function projectPaperclipSemanticTools(input: { + readonly runId: string; + readonly context: PaperclipSemanticRunContext; + readonly boundOperationIds: ReadonlySet; + readonly placement?: PaperclipSemanticActionDescriptor["placement"]; +}): readonly PaperclipSemanticToolDefinition[] { + return deepFreeze(authorizedBoundDescriptors(input).map(toToolDefinition)); +} + +export function discoverPaperclipSemanticTools(input: { + readonly runId: string; + readonly context: PaperclipSemanticRunContext; + readonly boundOperationIds: ReadonlySet; + readonly query: string; + readonly namespace?: string; + readonly limit?: number; +}): PaperclipSemanticDiscoveryResult { + const normalized = input.query.trim().toLowerCase(); + if (normalized.length === 0 || normalized.length > 500) { + throw new Error("semantic_discovery_query_invalid"); + } + const namespace = input.namespace?.trim().toLowerCase(); + if (namespace !== undefined && !/^[a-z][a-z0-9_]{0,63}$/.test(namespace)) { + throw new Error("semantic_discovery_namespace_invalid"); + } + const limit = Math.max(1, Math.min(Math.floor(input.limit ?? 5), 8)); + const tokens = normalized + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 1); + const candidates = authorizedBoundDescriptors({ + ...input, + placement: "optional", + }) + .filter( + (descriptor) => + namespace === undefined || + paperclipSemanticActionNamespace(descriptor.operationId) === namespace, + ) + .map((descriptor) => ({ + descriptor, + score: scoreDescriptor(descriptor, normalized, tokens), + })) + .filter((candidate) => candidate.score > 0) + .sort( + (left, right) => + right.score - left.score || + left.descriptor.operationId.localeCompare(right.descriptor.operationId), + ); + + return deepFreeze({ + schema: "paperclip.semantic-discovery.v1", + query: input.query, + namespace: namespace ?? null, + operations: candidates + .slice(0, limit) + .map(({ descriptor }) => toToolDefinition(descriptor)), + truncated: candidates.length > limit, + }); +} + +export function toPaperclipSemanticToolDefinition( + descriptor: PaperclipSemanticActionDescriptor, +): PaperclipSemanticToolDefinition { + return deepFreeze(toToolDefinition(descriptor)); +} + +function authorizedBoundDescriptors(input: { + readonly runId: string; + readonly context: PaperclipSemanticRunContext; + readonly boundOperationIds: ReadonlySet; + readonly placement?: PaperclipSemanticActionDescriptor["placement"]; +}): PaperclipSemanticActionDescriptor[] { + return PAPERCLIP_SEMANTIC_ACTION_CATALOG.filter( + (descriptor) => + input.boundOperationIds.has(descriptor.operationId) && + (input.placement === undefined || + descriptor.placement === input.placement) && + decidePaperclipSemanticAuthorization( + descriptor, + input.context, + "exposure", + input.runId, + ).allowed, + ); +} + +function scoreDescriptor( + descriptor: PaperclipSemanticActionDescriptor, + query: string, + tokens: readonly string[], +): number { + const operationId = descriptor.operationId.toLowerCase(); + const namespace = paperclipSemanticActionNamespace(descriptor.operationId); + const haystack = + `${operationId} ${descriptor.title} ${descriptor.description} ${namespace}`.toLowerCase(); + let score = operationId === query ? 100 : namespace === query ? 50 : 0; + for (const token of tokens) { + if (operationId.includes(token)) score += 12; + if (namespace.includes(token)) score += 8; + if (haystack.includes(token)) score += 3; + } + return score; +} + +function toToolDefinition( + descriptor: PaperclipSemanticActionDescriptor, +): PaperclipSemanticToolDefinition { + return { + name: descriptor.operationId, + description: descriptor.description, + inputSchema: descriptor.inputSchema, + outputSchema: descriptor.outputSchema, + annotations: { + semanticContract: descriptor.schema, + version: descriptor.version, + placement: descriptor.placement, + effect: descriptor.effect, + requiredClaims: descriptor.requiredClaims, + }, + }; +} + +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/semantic-tools/dispatcher.ts b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts new file mode 100644 index 0000000000..a95713ab03 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts @@ -0,0 +1,897 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { ValidateFunction } from "ajv/dist/2020.js"; + +import { + PAPERCLIP_SEMANTIC_ACTION_CATALOG, + paperclipSemanticAction, +} from "../catalog/semantic-action-catalog.js"; +import type { + PaperclipJsonValue, + PaperclipSemanticActionDescriptor, + PaperclipSemanticActionId, +} from "../catalog/semantic-action-types.js"; +import { decidePaperclipSemanticAuthorization } from "./authorization.js"; +import { + discoverPaperclipSemanticTools, + projectPaperclipSemanticTools, +} from "./discovery.js"; +import { + createPaperclipSemanticInputReceipt, + createPaperclipSemanticResultReceipt, + denialRetryable, + digestPaperclipSemanticContent, + isPaperclipSemanticStableId, + normalizePaperclipSemanticReferences, + paperclipSemanticAuthorizationBoundary, + paperclipSemanticOutcome, +} from "./receipts.js"; +import { + inspectPaperclipSemanticValue, + redactPaperclipSemanticValue, +} from "./redaction.js"; +import type { + PaperclipSemanticActionBinding, + PaperclipSemanticAuthorizationDecision, + PaperclipSemanticAuthorizationRecord, + PaperclipSemanticBindingResult, + PaperclipSemanticContextProvider, + PaperclipSemanticDenialCode, + PaperclipSemanticDiscoveryResult, + PaperclipSemanticIdempotencyClaim, + PaperclipSemanticIdempotencyStore, + PaperclipSemanticRunContext, + PaperclipSemanticStoredOutcome, + PaperclipSemanticToolCall, + PaperclipSemanticToolDefinition, + PaperclipSemanticToolDenial, + PaperclipSemanticToolResult, + PaperclipSemanticToolSuccess, +} from "./types.js"; + +export interface PaperclipSemanticDispatcherOptions { + readonly contextProvider: PaperclipSemanticContextProvider; + readonly bindings: readonly PaperclipSemanticActionBinding[]; + readonly idempotencyStore?: PaperclipSemanticIdempotencyStore; + readonly maxAuthorizationRecords?: number; +} + +interface ClaimedMutation { + readonly token: string; + readonly inputDigest: string; + readonly idempotencyKey: string; +} + +export class PaperclipSemanticDispatcher { + readonly #contextProvider: PaperclipSemanticContextProvider; + readonly #bindings = new Map< + PaperclipSemanticActionId, + PaperclipSemanticActionBinding + >(); + readonly #boundOperationIds: ReadonlySet; + readonly #inputValidators = new Map< + PaperclipSemanticActionId, + ValidateFunction + >(); + readonly #outputValidators = new Map< + PaperclipSemanticActionId, + ValidateFunction + >(); + readonly #idempotencyStore: PaperclipSemanticIdempotencyStore | undefined; + readonly #maxAuthorizationRecords: number; + readonly #authorizationRecords: PaperclipSemanticAuthorizationRecord[] = []; + #recordSequence = 0; + + constructor(options: PaperclipSemanticDispatcherOptions) { + this.#contextProvider = options.contextProvider; + this.#idempotencyStore = options.idempotencyStore; + this.#maxAuthorizationRecords = Math.max( + 1, + Math.min(Math.floor(options.maxAuthorizationRecords ?? 1_000), 10_000), + ); + for (const binding of options.bindings) { + if (this.#bindings.has(binding.operationId)) { + throw new Error( + `duplicate semantic action binding: ${binding.operationId}`, + ); + } + if (paperclipSemanticAction(binding.operationId) === undefined) { + throw new Error( + `unknown semantic action binding: ${binding.operationId}`, + ); + } + this.#bindings.set(binding.operationId, binding); + } + this.#boundOperationIds = new Set(this.#bindings.keys()); + + const ajv = new Ajv2020({ + allErrors: true, + allowUnionTypes: true, + strict: true, + }); + for (const descriptor of PAPERCLIP_SEMANTIC_ACTION_CATALOG) { + this.#inputValidators.set( + descriptor.operationId, + ajv.compile(descriptor.inputSchema), + ); + this.#outputValidators.set( + descriptor.operationId, + ajv.compile(descriptor.outputSchema), + ); + } + } + + async listAlwaysAvailableTools( + runId: string, + ): Promise { + const context = await this.#contextProvider(runId); + this.#recordExposureDecisions(runId, context, "always"); + return projectPaperclipSemanticTools({ + runId, + context, + boundOperationIds: this.#boundOperationIds, + placement: "always", + }); + } + + async discoverTools(input: { + readonly runId: string; + readonly query: string; + readonly namespace?: string; + readonly limit?: number; + }): Promise { + const context = await this.#contextProvider(input.runId); + this.#recordExposureDecisions(input.runId, context, "optional"); + return discoverPaperclipSemanticTools({ + ...input, + context, + boundOperationIds: this.#boundOperationIds, + }); + } + + authorizationRecords(): readonly PaperclipSemanticAuthorizationRecord[] { + return deepFreeze(structuredClone(this.#authorizationRecords)); + } + + async dispatch( + call: PaperclipSemanticToolCall, + ): Promise { + if (!validCallIdentity(call)) { + return this.#identityDenial(call); + } + const descriptor = paperclipSemanticAction(call.operationId); + const binding = descriptor && this.#bindings.get(descriptor.operationId); + if (descriptor === undefined || binding === undefined) { + return this.#denial(call, "operation_absent", null, null); + } + + let context: PaperclipSemanticRunContext; + try { + context = await this.#contextProvider(call.runId); + } catch { + return this.#denial(call, "binding_failed", descriptor, null); + } + let decision = decidePaperclipSemanticAuthorization( + descriptor, + context, + "invocation", + call.runId, + call.input, + ); + if (!decision.allowed) { + return this.#denial( + call, + denialCode(decision), + descriptor, + context, + decision, + ); + } + + const inputSafety = inspectPaperclipSemanticValue(call.input); + if (!inputSafety.withinBounds) { + decision = deniedDecision( + decision, + "input_invalid", + "Tool input exceeds safe bounds.", + ); + return this.#denial(call, "input_invalid", descriptor, context, decision); + } + if (inputSafety.containsProtectedData) { + decision = deniedDecision( + decision, + "protected_data_denied", + "Protected data is not accepted by semantic actions.", + ); + return this.#denial( + call, + "protected_data_denied", + descriptor, + context, + decision, + true, + ); + } + const inputValidator = this.#inputValidators.get(descriptor.operationId); + const idempotencyKey = stringProperty(call.input, "idempotencyKey"); + if (descriptor.effect !== "read" && idempotencyKey === undefined) { + decision = deniedDecision( + decision, + "idempotency_required", + "Mutation actions require an idempotency key.", + ); + return this.#denial( + call, + "idempotency_required", + descriptor, + context, + decision, + ); + } + if (inputValidator === undefined || !inputValidator(call.input)) { + decision = deniedDecision( + decision, + "input_invalid", + formatValidationError(inputValidator), + ); + return this.#denial(call, "input_invalid", descriptor, context, decision); + } + + const inputReceipt = createPaperclipSemanticInputReceipt({ + operationId: descriptor.operationId, + callId: call.callId, + correlation: call.correlation, + idempotencyKey: idempotencyKey ?? null, + content: call.input, + }); + const inputDigest = digestPaperclipSemanticContent(call.input); + let claim: ClaimedMutation | undefined; + if (descriptor.effect !== "read") { + if (this.#idempotencyStore === undefined) { + decision = deniedDecision( + decision, + "receipt_store_unavailable", + "No durable idempotency store is configured for mutation actions.", + ); + return this.#denial( + call, + "receipt_store_unavailable", + descriptor, + context, + decision, + ); + } + const scope = digestPaperclipSemanticContent([ + context.companyId, + call.runId, + descriptor.operationId, + idempotencyKey, + ]); + let claimed: PaperclipSemanticIdempotencyClaim; + try { + claimed = await this.#idempotencyStore.claim({ + scope, + operationId: descriptor.operationId, + inputDigest, + }); + } catch { + decision = deniedDecision( + decision, + "receipt_store_unavailable", + "The durable idempotency store is unavailable.", + ); + return this.#denial( + call, + "receipt_store_unavailable", + descriptor, + context, + decision, + ); + } + if (claimed.kind === "conflict") { + decision = deniedDecision( + decision, + "idempotency_conflict", + "The idempotency key was already used with different input.", + ); + return this.#denial( + call, + "idempotency_conflict", + descriptor, + context, + decision, + ); + } + if (claimed.kind === "in_progress") { + decision = deniedDecision( + decision, + "idempotency_in_progress", + "The original mutation is still in progress.", + ); + return this.#denial( + call, + "idempotency_in_progress", + descriptor, + context, + decision, + ); + } + if (claimed.kind === "duplicate") { + return this.#duplicate( + call, + descriptor, + context, + decision, + inputReceipt, + inputDigest, + claimed.outcome, + ); + } + claim = { + token: claimed.token, + inputDigest, + idempotencyKey: idempotencyKey!, + }; + } + + // Re-read authority after any durable claim and immediately before the + // application binding. A stale projection can never authorize execution. + let currentContext: PaperclipSemanticRunContext; + try { + currentContext = await this.#contextProvider(call.runId); + } catch { + if (claim !== undefined && !(await this.#releaseClaim(claim.token))) { + return this.#denial( + call, + "receipt_store_unavailable", + descriptor, + context, + ); + } + return this.#denial(call, "binding_failed", descriptor, context); + } + decision = decidePaperclipSemanticAuthorization( + descriptor, + currentContext, + "invocation", + call.runId, + call.input, + ); + if (!decision.allowed) { + if (claim !== undefined && !(await this.#releaseClaim(claim.token))) { + return this.#denial( + call, + "receipt_store_unavailable", + descriptor, + currentContext, + deniedDecision( + decision, + "receipt_store_unavailable", + "The unused mutation claim could not be released.", + ), + ); + } + return this.#denial( + call, + denialCode(decision), + descriptor, + currentContext, + decision, + ); + } + + let executed: PaperclipSemanticBindingResult; + try { + executed = await binding.execute({ + runId: call.runId, + companyId: currentContext.companyId, + actorId: currentContext.actor.id, + taskId: currentContext.activeTask.id, + callId: call.callId, + operationId: descriptor.operationId, + input: call.input as Readonly>, + }); + } catch { + // A mutation may have crossed the application boundary. Keep its claim + // reserved so an uncertain retry cannot execute it twice. + decision = deniedDecision( + decision, + "binding_failed", + "The action binding failed safely.", + ); + return this.#denial( + call, + "binding_failed", + descriptor, + currentContext, + decision, + ); + } + + if (!isBindingResult(executed)) { + decision = deniedDecision( + decision, + "binding_output_invalid", + "The action binding returned an invalid result.", + ); + return this.#denial( + call, + "binding_output_invalid", + descriptor, + currentContext, + decision, + ); + } + const outputSafety = inspectPaperclipSemanticValue(executed.value); + const safeValue = redactPaperclipSemanticValue(executed.value); + const outputValidator = this.#outputValidators.get(descriptor.operationId); + if ( + !outputSafety.withinBounds || + !validOptionalCode(executed.code) || + !validOptionalRevision(executed.stateRevision) || + !validOptionalStableId(executed.auditReceiptId) || + outputValidator === undefined || + !outputValidator(safeValue) + ) { + decision = deniedDecision( + decision, + "binding_output_invalid", + "The action binding returned an invalid result.", + ); + return this.#denial( + call, + "binding_output_invalid", + descriptor, + currentContext, + decision, + outputSafety.containsProtectedData, + ); + } + + const code = executed.code ?? "ok"; + const references = normalizePaperclipSemanticReferences( + executed.references, + ); + const resultReceipt = createPaperclipSemanticResultReceipt({ + operationId: descriptor.operationId, + callId: call.callId, + correlation: call.correlation, + idempotencyKey: idempotencyKey ?? null, + content: safeValue, + references, + redacted: outputSafety.containsProtectedData, + outcome: paperclipSemanticOutcome({ ok: true, code }), + code, + retryable: false, + authorizationBoundary: paperclipSemanticAuthorizationBoundary(code), + ...(validRevision(executed.stateRevision) + ? { currentRevision: executed.stateRevision } + : {}), + ...(executed.auditReceiptId !== undefined + ? { auditReceiptId: executed.auditReceiptId } + : {}), + }); + const operationReceiptId = String(resultReceipt.operationReceiptId); + + if (claim !== undefined) { + const stored: PaperclipSemanticStoredOutcome = { + operationId: descriptor.operationId, + inputDigest, + operationReceiptId, + value: safeValue, + code, + ...(validRevision(executed.stateRevision) + ? { stateRevision: executed.stateRevision } + : {}), + references, + ...(executed.auditReceiptId !== undefined + ? { auditReceiptId: executed.auditReceiptId } + : {}), + }; + try { + await this.#idempotencyStore!.complete(claim.token, stored); + } catch { + try { + await this.#idempotencyStore!.recover(claim.token, stored); + } catch { + // The application effect may have happened. Keep the claim reserved + // and stop automatic retries. The store's operator recovery path can + // commit the same sanitized outcome without repeating the effect. + decision = deniedDecision( + decision, + "receipt_recovery_failed", + "The mutation receipt needs operator recovery.", + ); + return this.#denial( + call, + "receipt_recovery_failed", + descriptor, + currentContext, + decision, + ); + } + } + } + + this.#record( + currentContext, + decision, + call.callId, + inputDigest, + operationReceiptId, + ); + return deepFreeze({ + ok: true, + operationId: descriptor.operationId, + callId: call.callId, + value: safeValue, + code, + duplicate: false, + ...(validRevision(executed.stateRevision) + ? { stateRevision: executed.stateRevision } + : {}), + inputReceipt, + resultReceipt, + } satisfies PaperclipSemanticToolSuccess); + } + + #recordExposureDecisions( + runId: string, + context: PaperclipSemanticRunContext, + placement: PaperclipSemanticActionDescriptor["placement"], + ): void { + for (const descriptor of PAPERCLIP_SEMANTIC_ACTION_CATALOG) { + if ( + descriptor.placement !== placement || + !this.#boundOperationIds.has(descriptor.operationId) + ) { + continue; + } + const decision = decidePaperclipSemanticAuthorization( + descriptor, + context, + "exposure", + runId, + ); + this.#record(context, decision, null, null, null); + } + } + + #duplicate( + call: PaperclipSemanticToolCall, + descriptor: PaperclipSemanticActionDescriptor, + context: PaperclipSemanticRunContext, + decision: PaperclipSemanticAuthorizationDecision, + inputReceipt: PaperclipSemanticToolSuccess["inputReceipt"], + inputDigest: string, + stored: PaperclipSemanticStoredOutcome, + ): PaperclipSemanticToolResult { + if (!isStoredOutcome(stored)) { + const denied = deniedDecision( + decision, + "binding_output_invalid", + "The stored mutation receipt is invalid.", + ); + return this.#denial( + call, + "binding_output_invalid", + descriptor, + context, + denied, + ); + } + const outputValidator = this.#outputValidators.get(descriptor.operationId); + const outputSafety = inspectPaperclipSemanticValue(stored.value); + const safeValue = redactPaperclipSemanticValue(stored.value); + if ( + stored.operationId !== descriptor.operationId || + stored.inputDigest !== inputDigest || + !isPaperclipSemanticStableId(stored.operationReceiptId) || + !validCode(stored.code) || + !validOptionalRevision(stored.stateRevision) || + !validOptionalStableId(stored.auditReceiptId) || + !outputSafety.withinBounds || + outputValidator === undefined || + !outputValidator(safeValue) + ) { + const denied = deniedDecision( + decision, + "binding_output_invalid", + "The stored mutation receipt is invalid.", + ); + return this.#denial( + call, + "binding_output_invalid", + descriptor, + context, + denied, + ); + } + const references = normalizePaperclipSemanticReferences(stored.references); + const resultReceipt = createPaperclipSemanticResultReceipt({ + operationId: descriptor.operationId, + callId: call.callId, + correlation: call.correlation, + idempotencyKey: stringProperty(call.input, "idempotencyKey") ?? null, + content: safeValue, + references, + redacted: outputSafety.containsProtectedData, + outcome: "duplicate", + code: stored.code, + retryable: false, + authorizationBoundary: paperclipSemanticAuthorizationBoundary( + stored.code, + ), + operationReceiptId: stored.operationReceiptId, + duplicateOfReceiptId: stored.operationReceiptId, + ...(validRevision(stored.stateRevision) + ? { currentRevision: stored.stateRevision } + : {}), + ...(stored.auditReceiptId !== undefined + ? { auditReceiptId: stored.auditReceiptId } + : {}), + }); + this.#record( + context, + decision, + call.callId, + inputDigest, + stored.operationReceiptId, + ); + return deepFreeze({ + ok: true, + operationId: descriptor.operationId, + callId: call.callId, + value: safeValue, + code: stored.code, + duplicate: true, + ...(validRevision(stored.stateRevision) + ? { stateRevision: stored.stateRevision } + : {}), + inputReceipt, + resultReceipt, + } satisfies PaperclipSemanticToolSuccess); + } + + async #releaseClaim(token: string): Promise { + try { + await this.#idempotencyStore!.release(token); + return true; + } catch { + return false; + } + } + + #identityDenial( + call: PaperclipSemanticToolCall, + ): PaperclipSemanticToolDenial { + return deepFreeze({ + ok: false, + operationId: safeIdentity(call.operationId), + callId: safeIdentity(call.callId), + error: { + code: "input_invalid", + message: "The semantic call identity is invalid.", + retryable: false, + }, + inputReceipt: null, + resultReceipt: null, + }); + } + + #denial( + call: PaperclipSemanticToolCall, + code: PaperclipSemanticDenialCode, + descriptor: PaperclipSemanticActionDescriptor | null, + context: PaperclipSemanticRunContext | null, + decision?: PaperclipSemanticAuthorizationDecision, + redacted = false, + ): PaperclipSemanticToolDenial { + const idempotencyKey = stringProperty(call.input, "idempotencyKey") ?? null; + const inputReceipt = createPaperclipSemanticInputReceipt({ + operationId: call.operationId, + callId: call.callId, + correlation: call.correlation, + idempotencyKey, + content: call.input, + redacted, + }); + const resultReceipt = createPaperclipSemanticResultReceipt({ + operationId: call.operationId, + callId: call.callId, + correlation: call.correlation, + idempotencyKey, + content: { code }, + redacted, + outcome: paperclipSemanticOutcome({ ok: false, code }), + code, + retryable: denialRetryable(code), + authorizationBoundary: paperclipSemanticAuthorizationBoundary(code), + }); + if (descriptor !== null && context !== null) { + const finalDecision = + decision ?? + deniedDecision( + decidePaperclipSemanticAuthorization( + descriptor, + context, + "invocation", + call.runId, + call.input, + ), + code, + denialMessage(code), + ); + this.#record( + context, + finalDecision, + call.callId, + digestPaperclipSemanticContent(call.input), + String(resultReceipt.operationReceiptId), + ); + } + return deepFreeze({ + ok: false, + operationId: call.operationId, + callId: call.callId, + error: { + code, + message: denialMessage(code), + retryable: denialRetryable(code), + }, + inputReceipt, + resultReceipt, + }); + } + + #record( + context: PaperclipSemanticRunContext, + decision: PaperclipSemanticAuthorizationDecision, + callId: string | null, + inputDigest: string | null, + operationReceiptId: string | null, + ): void { + if (decision.code === "authority_context_invalid") return; + this.#recordSequence += 1; + this.#authorizationRecords.push( + deepFreeze({ + schema: "paperclip.semantic-authorization-record.v1", + id: `semantic_auth:${String(this.#recordSequence).padStart(8, "0")}`, + runId: context.runId, + companyId: context.companyId, + actorId: context.actor.id, + taskId: context.activeTask.id, + callId, + ...decision, + inputDigest, + operationReceiptId, + }), + ); + if (this.#authorizationRecords.length > this.#maxAuthorizationRecords) { + this.#authorizationRecords.splice( + 0, + this.#authorizationRecords.length - this.#maxAuthorizationRecords, + ); + } + } +} + +function validCallIdentity(call: PaperclipSemanticToolCall): boolean { + return ( + call.correlation.runId === call.runId && + [ + call.runId, + call.callId, + call.operationId, + call.correlation.normalizedSessionId, + call.correlation.turnId, + call.correlation.itemId, + ...(call.correlation.requestId === undefined + ? [] + : [call.correlation.requestId]), + ].every(isPaperclipSemanticStableId) + ); +} + +function isBindingResult( + value: unknown, +): value is PaperclipSemanticBindingResult { + return typeof value === "object" && value !== null && "value" in value; +} + +function isStoredOutcome( + value: unknown, +): value is PaperclipSemanticStoredOutcome { + return ( + typeof value === "object" && + value !== null && + "operationId" in value && + "inputDigest" in value && + "operationReceiptId" in value && + "value" in value && + "code" in value && + Array.isArray((value as { references?: unknown }).references) + ); +} + +function deniedDecision( + decision: PaperclipSemanticAuthorizationDecision, + code: PaperclipSemanticDenialCode, + reason: string, +): PaperclipSemanticAuthorizationDecision { + return { ...decision, allowed: false, code, reason }; +} + +function denialCode( + decision: PaperclipSemanticAuthorizationDecision, +): PaperclipSemanticDenialCode { + if (decision.code === "allowed") { + throw new Error("allowed authorization decision cannot create a denial"); + } + return decision.code; +} + +function denialMessage(code: PaperclipSemanticDenialCode): string { + switch (code) { + case "operation_absent": + return "The requested semantic action is not available."; + case "idempotency_in_progress": + return "The original mutation is still in progress."; + case "binding_failed": + return "The semantic action could not complete safely."; + default: + return "The requested semantic action was not executed."; + } +} + +function formatValidationError( + validator: ValidateFunction | undefined, +): string { + const issue = validator?.errors?.[0]; + if (issue === undefined) + return "Tool input does not match its action schema."; + return `Tool input ${issue.instancePath || "/"} ${issue.message ?? "is invalid"}.`; +} + +function stringProperty(value: unknown, key: string): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const candidate = (value as Record)[key]; + return typeof candidate === "string" ? candidate : undefined; +} + +function validCode(value: unknown): value is string { + return typeof value === "string" && /^[a-z][a-z0-9_.:-]{0,159}$/.test(value); +} + +function validOptionalCode(value: unknown): value is string | undefined { + return value === undefined || validCode(value); +} + +function validRevision(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function validOptionalRevision(value: unknown): value is number | undefined { + return value === undefined || validRevision(value); +} + +function validOptionalStableId(value: unknown): value is string | undefined { + return ( + value === undefined || + (typeof value === "string" && isPaperclipSemanticStableId(value)) + ); +} + +function safeIdentity(value: string): string { + return isPaperclipSemanticStableId(value) ? value : "invalid"; +} + +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/semantic-tools/index.ts b/packages/paperclip-runner/src/semantic-tools/index.ts new file mode 100644 index 0000000000..a0d2159788 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/index.ts @@ -0,0 +1,6 @@ +export * from "./authorization.js"; +export * from "./discovery.js"; +export * from "./dispatcher.js"; +export * from "./receipts.js"; +export * from "./redaction.js"; +export * from "./types.js"; diff --git a/packages/paperclip-runner/src/semantic-tools/receipts.ts b/packages/paperclip-runner/src/semantic-tools/receipts.ts new file mode 100644 index 0000000000..43a72e68d1 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/receipts.ts @@ -0,0 +1,203 @@ +import { createHash } from "node:crypto"; + +import type { PrpSemanticToolEnvelope } from "../protocol/replay-contract.js"; +import { redactPaperclipSemanticValue } from "./redaction.js"; +import type { + PaperclipSemanticCorrelation, + PaperclipSemanticDenialCode, + PaperclipSemanticSafeReference, +} from "./types.js"; + +export type PaperclipSemanticAuthorizationBoundary = + | "company" + | "actor" + | "active_task" + | "grant" + | "governed_action" + | "lock" + | "revision"; + +export type PaperclipSemanticToolOutcome = + "succeeded" | "denied" | "conflict" | "duplicate" | "unavailable" | "failed"; + +interface SemanticReceiptBase { + readonly operationId: string; + readonly callId: string; + readonly correlation: PaperclipSemanticCorrelation; + readonly idempotencyKey?: string | null; + readonly content: unknown; + readonly references?: readonly PaperclipSemanticSafeReference[]; + readonly redacted?: boolean; +} + +interface SemanticResultReceiptInput extends SemanticReceiptBase { + readonly outcome: PaperclipSemanticToolOutcome; + readonly code: string; + readonly retryable: boolean; + readonly authorizationBoundary: PaperclipSemanticAuthorizationBoundary; + readonly operationReceiptId?: string; + readonly auditReceiptId?: string; + readonly currentRevision?: number | string; + readonly duplicateOfReceiptId?: string; +} + +export function createPaperclipSemanticInputReceipt( + input: SemanticReceiptBase, +): PrpSemanticToolEnvelope { + return { + schema: "paperclip.prp.semantic_tool.v1", + schemaVersion: 1, + phase: "input", + operationId: input.operationId, + callId: input.callId, + correlation: { ...input.correlation }, + idempotencyKey: input.idempotencyKey ?? null, + content: safeContent(input), + } as PrpSemanticToolEnvelope; +} + +export function createPaperclipSemanticResultReceipt( + input: SemanticResultReceiptInput, +): PrpSemanticToolEnvelope { + const operationReceiptId = + input.operationReceiptId ?? derivedOperationReceiptId(input); + return { + schema: "paperclip.prp.semantic_tool.v1", + schemaVersion: 1, + phase: "result", + operationId: input.operationId, + callId: input.callId, + correlation: { ...input.correlation }, + idempotencyKey: input.idempotencyKey ?? null, + content: safeContent(input), + outcome: input.outcome, + code: input.code, + retryable: input.retryable, + authorizationBoundary: input.authorizationBoundary, + operationReceiptId, + ...(input.auditReceiptId === undefined + ? {} + : { auditReceiptId: input.auditReceiptId }), + ...(input.currentRevision === undefined + ? {} + : { currentRevision: input.currentRevision }), + ...(input.duplicateOfReceiptId === undefined + ? {} + : { duplicateOfReceiptId: input.duplicateOfReceiptId }), + } as PrpSemanticToolEnvelope; +} + +export function digestPaperclipSemanticContent(value: unknown): string { + const safeValue = redactPaperclipSemanticValue(value); + return `sha256:${createHash("sha256").update(canonicalJson(safeValue)).digest("hex")}`; +} + +export function paperclipSemanticAuthorizationBoundary( + code: string, +): PaperclipSemanticAuthorizationBoundary { + if (code.includes("company")) return "company"; + if (code.includes("actor") || code.includes("role")) return "actor"; + if (code.includes("claim") || code.includes("absent")) return "grant"; + if ( + code.includes("idempotency") || + code.includes("revision") || + code.includes("receipt") + ) { + return "revision"; + } + if (code.includes("ownership") || code.includes("run_mismatch")) { + return "lock"; + } + if (code.includes("interaction") || code.includes("governance")) { + return "governed_action"; + } + return "active_task"; +} + +export function paperclipSemanticOutcome(input: { + readonly ok: boolean; + readonly code: string; + readonly duplicate?: boolean; +}): PaperclipSemanticToolOutcome { + if (input.ok) return input.duplicate === true ? "duplicate" : "succeeded"; + if (input.code.includes("conflict")) return "conflict"; + if (input.code === "operation_absent") return "unavailable"; + if (input.code.includes("binding") || input.code.includes("recovery")) { + return "failed"; + } + return "denied"; +} + +export function isPaperclipSemanticStableId(value: string): boolean { + return ( + value.length >= 1 && + value.length <= 240 && + /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value) + ); +} + +export function normalizePaperclipSemanticReferences( + references: readonly PaperclipSemanticSafeReference[] | undefined, +): readonly PaperclipSemanticSafeReference[] { + if (!Array.isArray(references)) return Object.freeze([]); + const allowedKinds = new Set([ + "task", + "document_revision", + "interaction", + "approval", + "decision", + "artifact", + "work_product", + "wake", + "monitor", + "audit", + "operation", + ]); + const unique = new Map(); + for (const reference of references.slice(0, 200)) { + if ( + typeof reference === "object" && + reference !== null && + allowedKinds.has(reference.kind) && + isPaperclipSemanticStableId(reference.id) + ) { + unique.set(`${reference.kind}:${reference.id}`, { + kind: reference.kind, + id: reference.id, + }); + } + } + return Object.freeze([...unique.values()]); +} + +function safeContent(input: SemanticReceiptBase) { + return { + digest: digestPaperclipSemanticContent(input.content), + redactionDisposition: input.redacted === true ? "redacted" : "digest_only", + references: [...normalizePaperclipSemanticReferences(input.references)], + }; +} + +function derivedOperationReceiptId(input: SemanticResultReceiptInput): string { + const identity = + input.idempotencyKey === undefined || input.idempotencyKey === null + ? `${input.correlation.runId}:${input.operationId}:${input.callId}` + : `${input.correlation.runId}:${input.operationId}:${input.idempotencyKey}`; + return `semantic_receipt:${createHash("sha256").update(identity).digest("hex").slice(0, 32)}`; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object" && value !== null) { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +export function denialRetryable(code: PaperclipSemanticDenialCode): boolean { + return code === "idempotency_in_progress" || code === "binding_failed"; +} diff --git a/packages/paperclip-runner/src/semantic-tools/redaction.ts b/packages/paperclip-runner/src/semantic-tools/redaction.ts new file mode 100644 index 0000000000..cc81772582 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/redaction.ts @@ -0,0 +1,161 @@ +import type { PaperclipJsonValue } from "../catalog/semantic-action-types.js"; + +const SENSITIVE_KEY = + /(?:authorization|cookie|credential|password|passwd|private.?key|secret|token|api.?key|connection.?string)/i; +const SECRET_VALUE = + /(?:\bBearer\s+[A-Za-z0-9._~+/=-]{8,}|\b(?:sk|pk|pcgw|ghp|github_pat)_[A-Za-z0-9_-]{8,}|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/gi; +const SECRET_QUERY = + /([?&](?:code|key|secret|state|token|api[_-]?key|access[_-]?token)=)[^&#\s]+/gi; +const SECRET_QUERY_DETECT = + /[?&](?:code|key|secret|state|token|api[_-]?key|access[_-]?token)=[^&#\s]+/i; + +const MAX_DEPTH = 16; +const MAX_NODES = 10_000; +const MAX_ARRAY_ITEMS = 512; +const MAX_OBJECT_KEYS = 512; +const MAX_STRING_LENGTH = 200_000; + +export const PAPERCLIP_SEMANTIC_REDACTED = "[REDACTED]"; +export const PAPERCLIP_SEMANTIC_TRUNCATED = "[TRUNCATED]"; + +export interface PaperclipSemanticValueSafety { + readonly containsProtectedData: boolean; + readonly withinBounds: boolean; +} + +export function inspectPaperclipSemanticValue( + value: unknown, +): PaperclipSemanticValueSafety { + const state = { nodes: 0, protected: false, withinBounds: true }; + inspect(value, "", 0, state, new Set()); + return Object.freeze({ + containsProtectedData: state.protected, + withinBounds: state.withinBounds, + }); +} + +export function redactPaperclipSemanticValue( + value: unknown, +): PaperclipJsonValue { + const state = { nodes: 0 }; + return redact(value, "", 0, state, new Set()); +} + +function inspect( + value: unknown, + key: string, + depth: number, + state: { nodes: number; protected: boolean; withinBounds: boolean }, + ancestors: Set, +): void { + state.nodes += 1; + if (state.nodes > MAX_NODES || depth > MAX_DEPTH) { + state.withinBounds = false; + return; + } + if (SENSITIVE_KEY.test(key)) state.protected = true; + if (typeof value === "string") { + if (value.length > MAX_STRING_LENGTH) state.withinBounds = false; + SECRET_VALUE.lastIndex = 0; + if (SECRET_VALUE.test(value) || SECRET_QUERY_DETECT.test(value)) { + state.protected = true; + } + SECRET_VALUE.lastIndex = 0; + return; + } + if (Array.isArray(value)) { + if (value.length > MAX_ARRAY_ITEMS) state.withinBounds = false; + if (ancestors.has(value)) { + state.withinBounds = false; + return; + } + ancestors.add(value); + for (const child of value.slice(0, MAX_ARRAY_ITEMS)) { + inspect(child, "", depth + 1, state, ancestors); + } + ancestors.delete(value); + return; + } + if (typeof value === "object" && value !== null) { + if (ancestors.has(value)) { + state.withinBounds = false; + return; + } + const entries = Object.entries(value); + if (entries.length > MAX_OBJECT_KEYS) state.withinBounds = false; + ancestors.add(value); + for (const [childKey, child] of entries.slice(0, MAX_OBJECT_KEYS)) { + inspect(child, childKey, depth + 1, state, ancestors); + } + ancestors.delete(value); + return; + } + if ( + value !== null && + typeof value !== "number" && + typeof value !== "boolean" && + typeof value !== "undefined" + ) { + state.withinBounds = false; + } +} + +function redact( + value: unknown, + key: string, + depth: number, + state: { nodes: number }, + ancestors: Set, +): PaperclipJsonValue { + state.nodes += 1; + if (state.nodes > MAX_NODES || depth > MAX_DEPTH) { + return PAPERCLIP_SEMANTIC_TRUNCATED; + } + if (SENSITIVE_KEY.test(key)) return PAPERCLIP_SEMANTIC_REDACTED; + if (typeof value === "string") { + SECRET_VALUE.lastIndex = 0; + const redacted = value + .replace(SECRET_VALUE, PAPERCLIP_SEMANTIC_REDACTED) + .replace(SECRET_QUERY, `$1${PAPERCLIP_SEMANTIC_REDACTED}`); + SECRET_VALUE.lastIndex = 0; + return redacted.length <= MAX_STRING_LENGTH + ? redacted + : `${redacted.slice(0, MAX_STRING_LENGTH)}${PAPERCLIP_SEMANTIC_TRUNCATED}`; + } + if (Array.isArray(value)) { + if (ancestors.has(value)) return PAPERCLIP_SEMANTIC_TRUNCATED; + ancestors.add(value); + const result = value + .slice(0, MAX_ARRAY_ITEMS) + .map((child) => redact(child, "", depth + 1, state, ancestors)); + ancestors.delete(value); + if (value.length > MAX_ARRAY_ITEMS) { + result.push(PAPERCLIP_SEMANTIC_TRUNCATED); + } + return result; + } + if (typeof value === "object" && value !== null) { + if (ancestors.has(value)) return PAPERCLIP_SEMANTIC_TRUNCATED; + ancestors.add(value); + const entries = Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .slice(0, MAX_OBJECT_KEYS) + .map( + ([childKey, child]) => + [ + childKey, + redact(child, childKey, depth + 1, state, ancestors), + ] as const, + ); + ancestors.delete(value); + const result: Record = + Object.fromEntries(entries); + if (Object.keys(value).length > MAX_OBJECT_KEYS) { + result.__paperclip_truncated__ = PAPERCLIP_SEMANTIC_TRUNCATED; + } + return result; + } + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "boolean" || value === null) return value; + return PAPERCLIP_SEMANTIC_TRUNCATED; +} diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts new file mode 100644 index 0000000000..e8e4b45fa0 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts @@ -0,0 +1,594 @@ +import { describe, expect, it } from "vitest"; + +import type { PaperclipSemanticActionBinding } from "./types.js"; +import { PaperclipSemanticDispatcher } from "./dispatcher.js"; +import type { + PaperclipSemanticIdempotencyClaim, + PaperclipSemanticIdempotencyStore, + PaperclipSemanticRunContext, + PaperclipSemanticStoredOutcome, + PaperclipSemanticToolCall, +} from "./types.js"; +import { PAPERCLIP_SEMANTIC_REDACTED } from "./redaction.js"; +import { + validatePrpEvent, + type PrpEvent, + type PrpSemanticToolEnvelope, +} from "../protocol/replay-contract.js"; + +const correlation = { + runId: "run_semantic_test", + normalizedSessionId: "session_semantic_test", + turnId: "turn_semantic_test", + itemId: "item_semantic_test", +}; + +describe("run-scoped semantic tool authority", () => { + it("projects only bound and currently authorized actions", async () => { + let context = runContext({ + actorClaims: ["discovery:tasks:read", "discovery:agents:read"], + delegatedClaims: ["discovery:tasks:read"], + }); + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => context, + bindings: [ + binding("get_task_context", { task: { id: "task_semantic_test" } }), + binding("search_tasks", { tasks: [] }), + ], + }); + + await expect( + dispatcher.listAlwaysAvailableTools(correlation.runId), + ).resolves.toMatchObject([{ name: "get_task_context" }]); + await expect( + dispatcher.discoverTools({ runId: correlation.runId, query: "tasks" }), + ).resolves.toMatchObject({ + operations: [{ name: "search_tasks" }], + truncated: false, + }); + + context = runContext({ + actorClaims: ["discovery:tasks:read"], + delegatedClaims: [], + }); + await expect( + dispatcher.discoverTools({ runId: correlation.runId, query: "tasks" }), + ).resolves.toMatchObject({ operations: [] }); + expect(JSON.stringify(dispatcher.authorizationRecords())).not.toContain( + "list_agents", + ); + }); + + it("rechecks ownership after projection and before invocation", async () => { + let context = runContext(); + let executions = 0; + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => context, + bindings: [ + { + operationId: "get_task_context", + execute: () => { + executions += 1; + return { value: { task: "visible" } }; + }, + }, + ], + }); + + expect( + await dispatcher.listAlwaysAvailableTools(correlation.runId), + ).toHaveLength(1); + context = runContext({ executionRunId: "run_replaced" }); + const result = await dispatcher.dispatch(call("get_task_context", {})); + + expect(result).toMatchObject({ + ok: false, + error: { code: "task_ownership_denied" }, + }); + expect(executions).toBe(0); + }); + + it("rejects forged scope and protected input before a binding executes", async () => { + let executions = 0; + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => + runContext({ + actorClaims: ["discovery:tasks:read"], + delegatedClaims: ["discovery:tasks:read"], + }), + bindings: [ + { + operationId: "search_tasks", + execute: () => { + executions += 1; + return { value: { tasks: [] } }; + }, + }, + ], + }); + + await expect( + dispatcher.dispatch(call("search_tasks", { companyId: "forged" })), + ).resolves.toMatchObject({ ok: false, error: { code: "input_invalid" } }); + await expect( + dispatcher.dispatch( + call("search_tasks", { query: "work", apiKey: "sk_not-for-a-tool" }), + ), + ).resolves.toMatchObject({ + ok: false, + error: { code: "protected_data_denied" }, + }); + expect(executions).toBe(0); + }); + + it("redacts binding output and emits schema-valid digest-only receipts", async () => { + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + bindings: [ + binding("get_task_context", { + task: { id: "task_semantic_test" }, + accessToken: "sk_should-never-cross", + }), + ], + }); + const result = await dispatcher.dispatch(call("get_task_context", {})); + + expect(result).toMatchObject({ + ok: true, + value: { accessToken: PAPERCLIP_SEMANTIC_REDACTED }, + duplicate: false, + }); + expect(JSON.stringify(result)).not.toContain("sk_should-never-cross"); + if (!result.ok) throw new Error("expected success"); + expect( + validatePrpEvent(event("mcp_app.tool_input", result.inputReceipt)).ok, + ).toBe(true); + expect( + validatePrpEvent(event("mcp_app.tool_result", result.resultReceipt)).ok, + ).toBe(true); + }); + + it("fails mutations closed when no idempotency store is configured", async () => { + let executions = 0; + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + bindings: [ + { + operationId: "write_document", + execute: () => { + executions += 1; + return { value: mutationReceipt("write-command") }; + }, + }, + ], + }); + + const result = await dispatcher.dispatch( + call("write_document", writeDocumentInput()), + ); + expect(result).toMatchObject({ + ok: false, + error: { code: "receipt_store_unavailable" }, + }); + expect(executions).toBe(0); + }); + + it("replays exact mutation retries and rejects key reuse with changed input", async () => { + let executions = 0; + const store = new MemoryIdempotencyStore(); + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + idempotencyStore: store, + bindings: [ + { + operationId: "write_document", + execute: () => { + executions += 1; + return { + value: mutationReceipt("write-command"), + code: "document_written", + stateRevision: 7, + references: [ + { kind: "document_revision", id: "revision_semantic_test" }, + ], + }; + }, + }, + ], + }); + + const first = await dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_write_first"), + ); + const retry = await dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_write_retry"), + ); + const conflict = await dispatcher.dispatch( + call( + "write_document", + { ...writeDocumentInput(), body: "Different body" }, + "call_write_conflict", + ), + ); + + expect(first).toMatchObject({ + ok: true, + duplicate: false, + stateRevision: 7, + }); + expect(retry).toMatchObject({ + ok: true, + duplicate: true, + stateRevision: 7, + }); + expect(conflict).toMatchObject({ + ok: false, + error: { code: "idempotency_conflict" }, + }); + expect(executions).toBe(1); + if (!first.ok || !retry.ok) throw new Error("expected successes"); + expect(retry.resultReceipt.operationReceiptId).toBe( + first.resultReceipt.operationReceiptId, + ); + expect(retry.resultReceipt.duplicateOfReceiptId).toBe( + first.resultReceipt.operationReceiptId, + ); + }); + + it("recovers a completed mutation when the primary receipt commit fails", async () => { + let executions = 0; + const store = new MemoryIdempotencyStore({ failCompleteOnce: true }); + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + idempotencyStore: store, + bindings: [ + { + operationId: "write_document", + execute: () => { + executions += 1; + return { value: mutationReceipt("write-recovered") }; + }, + }, + ], + }); + + const first = await dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_recovery_first"), + ); + const retry = await dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_recovery_retry"), + ); + + expect(first).toMatchObject({ ok: true, duplicate: false }); + expect(retry).toMatchObject({ ok: true, duplicate: true }); + expect(executions).toBe(1); + expect(store.recoveryCount).toBe(1); + }); + + it("reports an in-progress retry without running a concurrent mutation", async () => { + const store = new MemoryIdempotencyStore(); + let releaseExecution!: () => void; + const blocked = new Promise((resolve) => { + releaseExecution = resolve; + }); + let executions = 0; + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + idempotencyStore: store, + bindings: [ + { + operationId: "write_document", + execute: async () => { + executions += 1; + await blocked; + return { value: mutationReceipt("write-concurrent") }; + }, + }, + ], + }); + + const first = dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_concurrent_first"), + ); + await store.claimed; + const retry = await dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_concurrent_retry"), + ); + expect(retry).toMatchObject({ + ok: false, + error: { code: "idempotency_in_progress", retryable: true }, + }); + expect(executions).toBe(1); + releaseExecution(); + await expect(first).resolves.toMatchObject({ ok: true }); + }); + + it("releases an unused mutation claim when authority changes before execution", async () => { + const store = new MemoryIdempotencyStore(); + let lookups = 0; + let executions = 0; + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => { + lookups += 1; + return runContext({ + executionRunId: lookups === 2 ? "run_reassigned" : correlation.runId, + }); + }, + idempotencyStore: store, + bindings: [ + { + operationId: "write_document", + execute: () => { + executions += 1; + return { value: mutationReceipt("write-after-recheck") }; + }, + }, + ], + }); + + await expect( + dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_stale_authority"), + ), + ).resolves.toMatchObject({ + ok: false, + error: { code: "task_ownership_denied" }, + }); + await expect( + dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_fresh_authority"), + ), + ).resolves.toMatchObject({ ok: true, duplicate: false }); + expect(executions).toBe(1); + }); + + it("fails closed when durable storage or binding metadata is invalid", async () => { + let executions = 0; + const unavailableStore = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + idempotencyStore: { + claim: () => { + throw new Error("store unavailable"); + }, + complete: () => undefined, + recover: () => undefined, + release: () => undefined, + }, + bindings: [ + { + operationId: "write_document", + execute: () => { + executions += 1; + return { value: mutationReceipt("must-not-run") }; + }, + }, + ], + }); + await expect( + unavailableStore.dispatch(call("write_document", writeDocumentInput())), + ).resolves.toMatchObject({ + ok: false, + error: { code: "receipt_store_unavailable" }, + }); + + const invalidBinding = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + bindings: [ + { + operationId: "get_task_context", + execute: () => ({ value: {}, code: "invalid code" }) as never, + }, + ], + }); + await expect( + invalidBinding.dispatch(call("get_task_context", {})), + ).resolves.toMatchObject({ + ok: false, + error: { code: "binding_output_invalid" }, + }); + expect(executions).toBe(0); + }); + + it("denies cross-company contexts and malformed protocol identities", async () => { + const crossCompany = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext({ actorCompanyId: "company_other" }), + bindings: [binding("get_task_context", {})], + }); + await expect( + crossCompany.listAlwaysAvailableTools(correlation.runId), + ).resolves.toEqual([]); + await expect( + crossCompany.dispatch(call("get_task_context", {})), + ).resolves.toMatchObject({ + ok: false, + error: { code: "company_mismatch" }, + }); + + const malformed = await crossCompany.dispatch({ + ...call("get_task_context", {}), + callId: "bad call id", + }); + expect(malformed).toMatchObject({ + ok: false, + error: { code: "input_invalid" }, + inputReceipt: null, + resultReceipt: null, + }); + + const malformedAuthority = new PaperclipSemanticDispatcher({ + contextProvider: () => + ({ ...runContext(), companyId: "bad company id" }) as never, + bindings: [binding("get_task_context", {})], + }); + await expect( + malformedAuthority.listAlwaysAvailableTools(correlation.runId), + ).resolves.toEqual([]); + await expect( + malformedAuthority.dispatch(call("get_task_context", {})), + ).resolves.toMatchObject({ + ok: false, + error: { code: "authority_context_invalid" }, + }); + }); +}); + +function runContext( + overrides: { + actorClaims?: readonly string[]; + delegatedClaims?: readonly string[]; + actorCompanyId?: string; + executionRunId?: string; + } = {}, +): PaperclipSemanticRunContext { + return { + runId: correlation.runId, + companyId: "company_semantic_test", + actor: { + id: "actor_semantic_test", + companyId: overrides.actorCompanyId ?? "company_semantic_test", + status: "active", + role: "engineer", + claims: overrides.actorClaims ?? [], + }, + activeTask: { + id: "task_semantic_test", + companyId: "company_semantic_test", + assigneeActorId: "actor_semantic_test", + executionRunId: overrides.executionRunId ?? correlation.runId, + status: "in_progress", + workMode: "standard", + }, + delegatedClaims: overrides.delegatedClaims ?? [], + }; +} + +function binding( + operationId: PaperclipSemanticActionBinding["operationId"], + value: Record, +): PaperclipSemanticActionBinding { + return { + operationId, + execute: () => ({ value: value as never }), + }; +} + +function call( + operationId: string, + input: unknown, + callId = `call_${operationId}`, +): PaperclipSemanticToolCall { + return { runId: correlation.runId, callId, operationId, correlation, input }; +} + +function writeDocumentInput() { + return { + idempotencyKey: "write_semantic_test", + key: "plan", + title: "Plan", + body: "Bounded body", + baseRevisionId: null, + }; +} + +function mutationReceipt(commandId: string) { + return { + commandId, + disposition: "applied", + stateRevision: 7, + entityRefs: ["task_semantic_test"], + scheduledWakeIds: [], + }; +} + +function event( + eventType: "mcp_app.tool_input" | "mcp_app.tool_result", + receipt: PrpSemanticToolEnvelope, +): PrpEvent { + return { + schema: "paperclip.prp.event.v1", + sourceEventId: `${eventType}:semantic_test`, + sourceSeq: eventType === "mcp_app.tool_input" ? 1 : 2, + sourceInstanceId: "runner_semantic_test", + sourceKind: "runner", + runId: correlation.runId, + normalizedSessionId: correlation.normalizedSessionId, + turnId: correlation.turnId, + itemId: correlation.itemId, + eventType, + schemaVersion: 1, + priority: 1, + emittedAt: "2026-08-24T12:00:00.000Z", + payload: { semantic_tool: receipt }, + } as PrpEvent; +} + +class MemoryIdempotencyStore implements PaperclipSemanticIdempotencyStore { + readonly #entries = new Map< + string, + { + digest: string; + token: string; + outcome?: PaperclipSemanticStoredOutcome; + } + >(); + readonly #tokenToScope = new Map(); + #sequence = 0; + #failCompleteOnce: boolean; + recoveryCount = 0; + readonly claimed: Promise; + #resolveClaimed!: () => void; + + constructor(options: { failCompleteOnce?: boolean } = {}) { + this.#failCompleteOnce = options.failCompleteOnce ?? false; + this.claimed = new Promise((resolve) => { + this.#resolveClaimed = resolve; + }); + } + + claim(input: { + scope: string; + operationId: PaperclipSemanticStoredOutcome["operationId"]; + inputDigest: string; + }): PaperclipSemanticIdempotencyClaim { + const existing = this.#entries.get(input.scope); + if (existing !== undefined) { + if (existing.digest !== input.inputDigest) return { kind: "conflict" }; + return existing.outcome === undefined + ? { kind: "in_progress" } + : { kind: "duplicate", outcome: structuredClone(existing.outcome) }; + } + const token = `claim:${++this.#sequence}`; + this.#entries.set(input.scope, { digest: input.inputDigest, token }); + this.#tokenToScope.set(token, input.scope); + this.#resolveClaimed(); + return { kind: "claimed", token }; + } + + complete(token: string, outcome: PaperclipSemanticStoredOutcome): void { + if (this.#failCompleteOnce) { + this.#failCompleteOnce = false; + throw new Error("primary receipt commit failed"); + } + this.#storeOutcome(token, outcome); + } + + recover(token: string, outcome: PaperclipSemanticStoredOutcome): void { + this.recoveryCount += 1; + this.#storeOutcome(token, outcome); + } + + #storeOutcome(token: string, outcome: PaperclipSemanticStoredOutcome): void { + const scope = this.#tokenToScope.get(token); + const entry = scope === undefined ? undefined : this.#entries.get(scope); + if (scope === undefined || entry?.token !== token) { + throw new Error("unknown claim token"); + } + entry.outcome = structuredClone(outcome); + } + + release(token: string): void { + const scope = this.#tokenToScope.get(token); + if (scope !== undefined) this.#entries.delete(scope); + this.#tokenToScope.delete(token); + } +} diff --git a/packages/paperclip-runner/src/semantic-tools/types.ts b/packages/paperclip-runner/src/semantic-tools/types.ts new file mode 100644 index 0000000000..ab39ca0725 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/types.ts @@ -0,0 +1,239 @@ +import type { + PaperclipJsonSchema, + PaperclipJsonValue, + PaperclipSemanticActionDescriptor, + PaperclipSemanticActionEffect, + PaperclipSemanticActionId, + PaperclipSemanticActionMode, +} from "../catalog/semantic-action-types.js"; +import type { PrpSemanticToolEnvelope } from "../protocol/replay-contract.js"; + +export interface PaperclipSemanticRunContext { + readonly runId: string; + readonly companyId: string; + readonly actor: { + readonly id: string; + readonly companyId: string; + readonly status: string; + readonly role: string; + readonly claims: readonly string[]; + }; + readonly activeTask: { + readonly id: string; + readonly companyId: string; + readonly assigneeActorId: string | null; + readonly executionRunId: string | null; + readonly status: string; + readonly workMode: PaperclipSemanticActionMode; + }; + /** Claims explicitly delegated to this run. Actor claims can only narrow them. */ + readonly delegatedClaims: readonly string[]; + readonly policy?: { + readonly deniedOperationIds?: readonly PaperclipSemanticActionId[]; + readonly allowedInteractionKinds?: readonly string[]; + }; +} + +export type PaperclipSemanticContextProvider = ( + runId: string, +) => PaperclipSemanticRunContext | Promise; + +export interface PaperclipSemanticToolDefinition { + readonly name: PaperclipSemanticActionId; + readonly description: string; + readonly inputSchema: PaperclipJsonSchema; + readonly outputSchema: PaperclipJsonSchema; + readonly annotations: { + readonly semanticContract: "paperclip.semantic-action.v1"; + readonly version: 1; + readonly placement: PaperclipSemanticActionDescriptor["placement"]; + readonly effect: PaperclipSemanticActionEffect; + readonly requiredClaims: readonly string[]; + }; +} + +export interface PaperclipSemanticDiscoveryResult { + readonly schema: "paperclip.semantic-discovery.v1"; + readonly query: string; + readonly namespace: string | null; + readonly operations: readonly PaperclipSemanticToolDefinition[]; + readonly truncated: boolean; +} + +export type PaperclipSemanticAuthorizationPhase = "exposure" | "invocation"; + +export type PaperclipSemanticDenialCode = + | "operation_absent" + | "authority_context_invalid" + | "run_mismatch" + | "company_mismatch" + | "actor_inactive" + | "task_mode_denied" + | "task_state_denied" + | "task_ownership_denied" + | "required_claim_missing" + | "actor_role_denied" + | "policy_denied" + | "interaction_kind_denied" + | "protected_data_denied" + | "input_invalid" + | "idempotency_required" + | "idempotency_conflict" + | "idempotency_in_progress" + | "receipt_store_unavailable" + | "receipt_recovery_failed" + | "binding_failed" + | "binding_output_invalid"; + +export interface PaperclipSemanticAuthorizationDecision { + readonly allowed: boolean; + readonly phase: PaperclipSemanticAuthorizationPhase; + readonly operationId: PaperclipSemanticActionId; + readonly code: "allowed" | PaperclipSemanticDenialCode; + readonly reason: string; + readonly effectiveClaims: readonly string[]; +} + +export interface PaperclipSemanticAuthorizationRecord extends PaperclipSemanticAuthorizationDecision { + readonly schema: "paperclip.semantic-authorization-record.v1"; + readonly id: string; + readonly runId: string; + readonly companyId: string; + readonly actorId: string; + readonly taskId: string; + readonly callId: string | null; + readonly inputDigest: string | null; + readonly operationReceiptId: string | null; +} + +export interface PaperclipSemanticSafeReference { + readonly kind: + | "task" + | "document_revision" + | "interaction" + | "approval" + | "decision" + | "artifact" + | "work_product" + | "wake" + | "monitor" + | "audit" + | "operation"; + readonly id: string; +} + +export interface PaperclipSemanticBindingResult { + readonly value: PaperclipJsonValue; + readonly code?: string; + readonly stateRevision?: number; + readonly references?: readonly PaperclipSemanticSafeReference[]; + readonly auditReceiptId?: string; +} + +export interface PaperclipAuthorizedSemanticInvocation { + readonly runId: string; + readonly companyId: string; + readonly actorId: string; + readonly taskId: string; + readonly callId: string; + readonly operationId: PaperclipSemanticActionId; + readonly input: Readonly>; +} + +export interface PaperclipSemanticActionBinding { + readonly operationId: PaperclipSemanticActionId; + execute( + invocation: PaperclipAuthorizedSemanticInvocation, + ): PaperclipSemanticBindingResult | Promise; +} + +export interface PaperclipSemanticCorrelation { + readonly runId: string; + readonly normalizedSessionId: string; + readonly turnId: string; + readonly itemId: string; + readonly requestId?: string; +} + +export interface PaperclipSemanticToolCall { + readonly runId: string; + readonly callId: string; + readonly operationId: string; + readonly correlation: PaperclipSemanticCorrelation; + readonly input: unknown; +} + +export interface PaperclipSemanticStoredOutcome { + readonly operationId: PaperclipSemanticActionId; + readonly inputDigest: string; + readonly operationReceiptId: string; + readonly value: PaperclipJsonValue; + readonly code: string; + readonly stateRevision?: number; + readonly references: readonly PaperclipSemanticSafeReference[]; + readonly auditReceiptId?: string; +} + +export type PaperclipSemanticIdempotencyClaim = + | { readonly kind: "claimed"; readonly token: string } + | { + readonly kind: "duplicate"; + readonly outcome: PaperclipSemanticStoredOutcome; + } + | { readonly kind: "conflict" } + | { readonly kind: "in_progress" }; + +/** + * The claim operation must be atomic. Production bindings must persist this + * store before they expose mutation actions. `complete` is the primary commit + * path. `recover` is a required, idempotent fallback that must durably resolve + * a claim to the same outcome when the primary commit reports an ambiguous or + * transient failure. A store without an independent recovery path cannot be + * used to expose mutation actions. + */ +export interface PaperclipSemanticIdempotencyStore { + claim(input: { + readonly scope: string; + readonly operationId: PaperclipSemanticActionId; + readonly inputDigest: string; + }): + | PaperclipSemanticIdempotencyClaim + | Promise; + complete( + token: string, + outcome: PaperclipSemanticStoredOutcome, + ): void | Promise; + recover( + token: string, + outcome: PaperclipSemanticStoredOutcome, + ): void | Promise; + release(token: string): void | Promise; +} + +export interface PaperclipSemanticToolSuccess { + readonly ok: true; + readonly operationId: PaperclipSemanticActionId; + readonly callId: string; + readonly value: PaperclipJsonValue; + readonly code: string; + readonly duplicate: boolean; + readonly stateRevision?: number; + readonly inputReceipt: PrpSemanticToolEnvelope; + readonly resultReceipt: PrpSemanticToolEnvelope; +} + +export interface PaperclipSemanticToolDenial { + readonly ok: false; + readonly operationId: string; + readonly callId: string; + readonly error: { + readonly code: PaperclipSemanticDenialCode; + readonly message: string; + readonly retryable: boolean; + }; + readonly inputReceipt: PrpSemanticToolEnvelope | null; + readonly resultReceipt: PrpSemanticToolEnvelope | null; +} + +export type PaperclipSemanticToolResult = + PaperclipSemanticToolSuccess | PaperclipSemanticToolDenial;