mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix(runner): isolate secret tool model delivery
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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 () => {
|
||||
|
||||
Reference in new issue
Block a user