fix(runner): isolate secret tool model delivery

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-09 12:52:16 +00:00
1 parent 3f20bf4626
commit 154bd9d904
3 files changed
+220 -25

No files matched your search

@@ -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<Phase7ToolInvocationResult> {
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<Phase7ModelToolDelivery> {
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<Phase7JsonSchema["type"]>, 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<T>(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;
}
}
@@ -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;
@@ -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<Phase7ModelToolSuccess>().not.toMatchTypeOf<Phase7ToolSuccess>();
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 () => {