From 154bd9d904a474003fbd6e8ce430c3408d7a2f32 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 9 Aug 2026 12:52:16 +0000 Subject: [PATCH] fix(runner): isolate secret tool model delivery Co-Authored-By: Paperclip --- .../src/tools/phase7-semantic-tool-runtime.ts | 134 +++++++++++++++--- .../src/tools/phase7-semantic-tool-types.ts | 31 ++++ .../src/tools/phase7-semantic-tools.test.ts | 80 ++++++++++- 3 files changed, 220 insertions(+), 25 deletions(-) diff --git a/packages/paperclip-runner/src/tools/phase7-semantic-tool-runtime.ts b/packages/paperclip-runner/src/tools/phase7-semantic-tool-runtime.ts index 0ddeea9c1ec..91493a9f7a8 100644 --- a/packages/paperclip-runner/src/tools/phase7-semantic-tool-runtime.ts +++ b/packages/paperclip-runner/src/tools/phase7-semantic-tool-runtime.ts @@ -9,6 +9,9 @@ import { Phase7ToolAuthorizationEngine } from "./phase7-tool-authorization.js"; import type { Phase7AuthorizationRecord, Phase7JsonSchema, + Phase7ModelToolDelivery, + Phase7ModelToolInvocationResult, + Phase7ModelToolSuccess, Phase7PolicyDenial, Phase7ScenarioToolPolicy, Phase7SemanticToolDescriptor, @@ -53,7 +56,36 @@ export class Phase7SemanticToolRuntime { return this.#authorization.records(); } + /** Returns only the result shape allowed to cross observable boundaries. */ async invoke(invocation: Phase7ToolInvocation): Promise { + return (await this.#invoke(invocation, false)).observableResult; + } + + /** + * Invokes a tool for provider delivery. The raw result remains in a capsule + * that serializes and clones as the redacted observable result. + */ + async invokeForModel(invocation: Phase7ToolInvocation): Promise { + const result = await this.#invoke(invocation, true); + const modelResult = result.modelResult !== undefined + ? result.modelResult + : !result.observableResult.ok + ? result.observableResult + : undefined; + if (modelResult === undefined) throw new Error("model delivery result was not produced"); + return new Phase7ModelToolDeliveryImpl( + result.observableResult, + modelResult, + ); + } + + async #invoke( + invocation: Phase7ToolInvocation, + includeModelResult: boolean, + ): Promise<{ + observableResult: Phase7ToolInvocationResult; + modelResult?: Phase7ModelToolInvocationResult; + }> { const context = this.#context(); const authorization = this.#authorization.authorizeInvocation( invocation.operationId, @@ -61,11 +93,13 @@ export class Phase7SemanticToolRuntime { context, ); if (authorization.outcome !== "allowed") { - return this.#denial( - invocation.operationId, - authorization, - authorization.outcome === "absent" ? "operation_absent" : "policy_denied", - ); + return { + observableResult: this.#denial( + invocation.operationId, + authorization, + authorization.outcome === "absent" ? "operation_absent" : "policy_denied", + ), + }; } const descriptor = phase7SemanticTool(invocation.operationId)!; @@ -76,7 +110,7 @@ export class Phase7SemanticToolRuntime { context, "input_schema_invalid", ); - return this.#denial(invocation.operationId, denied, "input_invalid"); + return { observableResult: this.#denial(invocation.operationId, denied, "input_invalid") }; } if (descriptor.idempotency === "required" && !invocation.idempotencyKey?.trim()) { const denied = this.#authorization.denyInvocation( @@ -84,7 +118,7 @@ export class Phase7SemanticToolRuntime { context, "idempotency_key_required", ); - return this.#denial(invocation.operationId, denied, "input_invalid"); + return { observableResult: this.#denial(invocation.operationId, denied, "input_invalid") }; } if (invocation.operationId === "decide_approval" && this.#isSelfApproval(invocation.input)) { const denied = this.#authorization.denyInvocation( @@ -92,7 +126,7 @@ export class Phase7SemanticToolRuntime { context, "self_approval_conflict", ); - return this.#denial(invocation.operationId, denied, "policy_denied"); + return { observableResult: this.#denial(invocation.operationId, denied, "policy_denied") }; } const beforeRevision = this.#adapter.snapshot().revision; @@ -105,24 +139,39 @@ export class Phase7SemanticToolRuntime { execution.entityRefs, ); const resultId = execution.commandResult?.commandId ?? `tool-result-${++this.#resultSequence}`; + const observableValue = redactForBoundary(descriptor, execution.value, "output"); + const observableCommandResult = execution.commandResult === null + ? null + : redactForBoundary(descriptor, toJsonValue(execution.commandResult), "output") as Phase7ToolSuccess["commandResult"]; const success: Phase7ToolSuccess = { schema: "paperclip.phase7.tool-result.v1", ok: true, operationId: invocation.operationId, operationResultId: resultId, - value: execution.value, - commandResult: execution.commandResult, + value: observableValue, + commandResult: observableCommandResult, authorization: finalAuthorization, }; - this.#operationResults.set(resultId, redactStoredResult(descriptor, execution.value)); - return deepFreeze(success); + this.#operationResults.set(resultId, structuredClone(observableValue)); + const observableResult = deepFreeze(success); + if (!includeModelResult) return { observableResult }; + const modelResult: Phase7ModelToolSuccess = deepFreeze({ + schema: "paperclip.phase7.model-tool-result.v1", + ok: true, + operationId: invocation.operationId, + operationResultId: resultId, + value: structuredClone(execution.value), + commandResult: execution.commandResult === null ? null : structuredClone(execution.commandResult), + authorization: finalAuthorization, + }); + return { observableResult, modelResult }; } catch { const denied = this.#authorization.denyInvocation( invocation.operationId, context, "mock_operation_rejected", ); - return this.#denial(invocation.operationId, denied, "operation_unsupported"); + return { observableResult: this.#denial(invocation.operationId, denied, "operation_unsupported") }; } } @@ -443,15 +492,42 @@ function matchesType(type: NonNullable, value: Phase7J } } -function redactStoredResult( +function redactForBoundary( descriptor: Phase7SemanticToolDescriptor, value: Phase7JsonValue, + boundary: Phase7SemanticToolDescriptor["redaction"][number]["appliesTo"][number], ): 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]" }; + let redacted = structuredClone(value); + for (const rule of descriptor.redaction) { + if (!rule.appliesTo.includes(boundary)) continue; + redacted = replaceJsonPath(redacted, rule.path, rule.replacement); } - return structuredClone(value); + return redacted; +} + +function replaceJsonPath( + value: Phase7JsonValue, + path: string, + replacement: Phase7JsonValue, +): Phase7JsonValue { + if (path === "$") return replacement; + if (!path.startsWith("$.")) return value; + const segments = path.slice(2).split("."); + if (segments.some((segment) => segment.length === 0)) return value; + + const root = structuredClone(value); + let cursor: Phase7JsonValue = root; + for (const segment of segments.slice(0, -1)) { + if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) return root; + const next: Phase7JsonValue | undefined = cursor[segment]; + if (next === undefined) return root; + cursor = next; + } + if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) return root; + const leaf = segments.at(-1)!; + if (!(leaf in cursor)) return root; + cursor[leaf] = replacement; + return root; } function optionalStringArray(value: Phase7JsonValue | undefined): string[] | undefined { @@ -480,3 +556,25 @@ function deepFreeze(value: T): T { for (const child of Object.values(value)) deepFreeze(child); return value; } + +class Phase7ModelToolDeliveryImpl implements Phase7ModelToolDelivery { + readonly observableResult: Phase7ToolInvocationResult; + readonly #modelResult: Phase7ModelToolInvocationResult; + + constructor( + observableResult: Phase7ToolInvocationResult, + modelResult: Phase7ModelToolInvocationResult, + ) { + this.observableResult = observableResult; + this.#modelResult = modelResult; + Object.freeze(this); + } + + readModelResult(): Phase7ModelToolInvocationResult { + return deepFreeze(structuredClone(this.#modelResult)); + } + + toJSON(): Phase7ToolInvocationResult { + return this.observableResult; + } +} diff --git a/packages/paperclip-runner/src/tools/phase7-semantic-tool-types.ts b/packages/paperclip-runner/src/tools/phase7-semantic-tool-types.ts index 0ccc33535a8..a920e0a6471 100644 --- a/packages/paperclip-runner/src/tools/phase7-semantic-tool-types.ts +++ b/packages/paperclip-runner/src/tools/phase7-semantic-tool-types.ts @@ -159,6 +159,37 @@ export interface Phase7ToolSuccess { export type Phase7ToolInvocationResult = Phase7ToolSuccess | Phase7PolicyDenial; +/** + * A result intended only for the provider/model tool-response channel. + * + * The distinct schema prevents this value from being assigned to an observable + * tool-result sink. Callers receive it only by explicitly opening a + * {@link Phase7ModelToolDelivery}; the delivery capsule itself serializes to + * its redacted observable result. + */ +export interface Phase7ModelToolSuccess { + schema: "paperclip.phase7.model-tool-result.v1"; + ok: true; + operationId: string; + operationResultId: string; + value: Phase7JsonValue; + commandResult: Phase7CommandResult | null; + authorization: Phase7AuthorizationRecord; +} + +export type Phase7ModelToolInvocationResult = Phase7ModelToolSuccess | Phase7PolicyDenial; + +export interface Phase7ModelToolDelivery { + /** Safe for traces, snapshots, persistence, errors, and browser payloads. */ + readonly observableResult: Phase7ToolInvocationResult; + + /** Explicitly opens the narrowly scoped provider/model delivery value. */ + readModelResult(): Phase7ModelToolInvocationResult; + + /** Accidental JSON serialization is always the observable redacted form. */ + toJSON(): Phase7ToolInvocationResult; +} + export interface Phase7ToolInvocation { operationId: string; input: Phase7JsonValue; diff --git a/packages/paperclip-runner/src/tools/phase7-semantic-tools.test.ts b/packages/paperclip-runner/src/tools/phase7-semantic-tools.test.ts index b4475f11ed3..fd06384cc76 100644 --- a/packages/paperclip-runner/src/tools/phase7-semantic-tools.test.ts +++ b/packages/paperclip-runner/src/tools/phase7-semantic-tools.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import type { Phase7FixtureApproval, @@ -13,7 +13,11 @@ import { } 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"; +import { + PHASE7_CONTROL_PLANE_OWNED_OPERATION_IDS, + type Phase7ModelToolSuccess, + type Phase7ToolSuccess, +} from "./phase7-semantic-tool-types.js"; const OPEN = { identity: { @@ -293,8 +297,10 @@ describe("Phase 7 exposure and authorization", () => { }); describe("Phase 7 security policy", () => { - it("redacts secret values from records and stored operation results", async () => { - const secret = "fixture-secret-value"; + it("separates model-only secret delivery from every observable result boundary", async () => { + expectTypeOf().not.toMatchTypeOf(); + + const secret = "PHASE7_SECRET_SENTINEL_f42d4fbc"; const { runtime } = await runtimeFor({ scenarioGrants: ["secrets:values:read"], policy: { allowSecretValueAccess: true }, @@ -305,9 +311,11 @@ describe("Phase 7 security policy", () => { operationId: "read_secret_value", input: { name: "DEPLOY_TOKEN" }, }); - expect(result).toMatchObject({ ok: true, value: { name: "DEPLOY_TOKEN", value: secret } }); + expect(result).toMatchObject({ + ok: true, + value: { name: "DEPLOY_TOKEN", value: "[SECRET_VALUE]" }, + }); 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]" }, ]); @@ -318,11 +326,69 @@ describe("Phase 7 security policy", () => { }); expect(inspected).toMatchObject({ ok: true, value: { value: "[SECRET_VALUE]" } }); + const modelDelivery = await runtime.invokeForModel({ + operationId: "read_secret_value", + input: { name: "DEPLOY_TOKEN" }, + }); + expect(modelDelivery.readModelResult()).toMatchObject({ + schema: "paperclip.phase7.model-tool-result.v1", + ok: true, + value: { name: "DEPLOY_TOKEN", value: secret }, + }); + expect(modelDelivery.observableResult).toMatchObject({ + schema: "paperclip.phase7.tool-result.v1", + ok: true, + value: { name: "DEPLOY_TOKEN", value: "[SECRET_VALUE]" }, + }); + + const observablePayloads = { + publicInvocation: result, + trace: { toolResult: modelDelivery }, + snapshot: structuredClone(modelDelivery), + browser: { toolResult: modelDelivery.observableResult }, + authorization: runtime.authorizationRecords(), + }; + expect(JSON.stringify(observablePayloads)).not.toContain(secret); + + const failing = await runtimeFor({ + scenarioGrants: ["secrets:values:read"], + policy: { allowSecretValueAccess: true }, + resolveSecretValue: () => { + throw new Error(`resolver rejected ${secret}`); + }, + }); + const failedDelivery = await failing.runtime.invokeForModel({ + operationId: "read_secret_value", + input: { name: "DEPLOY_TOKEN" }, + }); + expect(failedDelivery.observableResult).toMatchObject({ + ok: false, + error: { code: "operation_unsupported", reason: "mock_operation_rejected" }, + }); + expect(JSON.stringify({ + error: failedDelivery, + authorization: failing.runtime.authorizationRecords(), + })).not.toContain(secret); + + let unclaimedResolverCalls = 0; const ungranted = await runtimeFor({ scenarioGrants: ["secrets:values:read"], - resolveSecretValue: () => secret, + resolveSecretValue: () => { + unclaimedResolverCalls += 1; + return secret; + }, }); expect(ungranted.runtime.visibleTools().tools.map((tool) => tool.operationId)).not.toContain("read_secret_value"); + const unclaimedDelivery = await ungranted.runtime.invokeForModel({ + operationId: "read_secret_value", + input: { name: "DEPLOY_TOKEN" }, + }); + expect(unclaimedDelivery.readModelResult()).toMatchObject({ + ok: false, + error: { code: "policy_denied", reason: "secret_value_access_disabled" }, + }); + expect(unclaimedResolverCalls).toBe(0); + expect(JSON.stringify(unclaimedDelivery)).not.toContain(secret); }); it("prohibits self approval even with the role and explicit decision claim", async () => {