From 8b3cf2c92fed44847e372d0ccda0b2756c9dd2d1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 14 Oct 2026 09:18:43 -0700 Subject: [PATCH 1/2] fix(authz): redact capability ids and scrub persisted approval args Correlation ids are bearer capabilities (see the provenance gate): log and receipt sites now emit a truncated+hashed form, so a log reader no longer becomes a forgery actor. Approval-snapshot arguments are secret- scrubbed at the persisted-metadata boundary (live calls untouched), and credential headers freeze at the source cell. The vendored raw-warn sites are unreachable from the app path and are dispositioned in the vendor ledger; retry policies are documented trusted-first-party-only. --- docs/VENDORING.md | 72 ++++++++++ src/agent/retry-policy.ts | 10 ++ src/config/source-credentials.test.ts | 73 ++++++++++ src/config/source-credentials.ts | 28 +++- src/session/approval-provenance.test.ts | 32 ++++- src/session/approval-provenance.ts | 7 +- src/session/approval-resume.ts | 22 ++- src/session/optimized-context-store.test.ts | 47 +++++++ src/session/optimized-context-store.ts | 15 +- src/session/pending-snapshot-scrub.test.ts | 146 ++++++++++++++++++++ src/session/pending-snapshot-scrub.ts | 61 ++++++++ src/session/redacted-correlation.test.ts | 31 +++++ src/session/redacted-correlation.ts | 22 +++ src/subagent/run-suspended-send.test.ts | 13 +- src/subagent/run.ts | 5 +- src/tui/approval-delivery.test.ts | 7 +- src/tui/approval-delivery.ts | 6 +- 17 files changed, 578 insertions(+), 19 deletions(-) create mode 100644 src/config/source-credentials.test.ts create mode 100644 src/session/pending-snapshot-scrub.test.ts create mode 100644 src/session/pending-snapshot-scrub.ts create mode 100644 src/session/redacted-correlation.test.ts create mode 100644 src/session/redacted-correlation.ts diff --git a/docs/VENDORING.md b/docs/VENDORING.md index 44f799be8..6b3113528 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -331,6 +331,78 @@ recorded SHA to print exactly the lines that are ours. A correspondence test (`scripts/vendor-patch-ledger.test.ts`) fails if a marker anchor does not resolve to a ledger heading, or if a ledger heading has no marker. +## Approval rail hygiene audit (CL-10679) + +App-side redaction built on CL-10678's provenance gate, audited against the +CL-10678 base (`950a3e37`). Nothing in `vendor/` was modified for this audit; +the vendored findings below are recorded as suppression/disposition notes. + +**Vendored validator-throw log (`reactor.ts:687`, +`logger.warn`Correlation validator threw for ${correlationId}...``) — +unreachable in production wiring.** `tryCorrelate` only calls a validator when +one is injected (`correlationValidator !== undefined`), and both the vendored +`@intx/agent` env contract and every production call site forward none (see +the "Bearer correlation IDs and approval provenance" section above and the +comment at `src/agent/live-tool-dispatch.ts`). Foreign ingress is dropped the +line before it could ever be correlated (`createApprovalProvenanceGate` wraps +the app's `createAgent` deliver seam, fail-closed on unknown mailboxes), so +this vendored warn can log a bearer correlation ID only if a future wiring +injects a validator — and then only when that validator throws. When a seam +forwards the app validator, keep `createApprovalCorrelationValidator`'s +`validate` non-throwing: it returns `Promise.resolve(...)`, never throws, so +the death of this branch stays guaranteed even hot-swapped. **Second vendored +full-ID site, promotion candidate:** the `processDelivery` fatal path +(`reactor.ts` ~1906-1923 — `logger.error`Correlation dispatch failed: ${cause}`` +plus the `closeMessageRun` failure text) can carry a full correlation ID a +correlation-path invariant failure embeds in its message (the registry's +double-registration throw names the ID). The deliver-gate pre-drop makes that +fatal branch effectively unreachable today (a throw can arise inside +`tryCorrelate`'s patched acceptance/correlation region, but no untrusted +delivery reaches it without first injecting a validator); recorded here so a +future wiring that can reach it gets an ID-free receipt at the next sync. + +**Vendored retry-policy throw log (`harness.ts:1749`) — app-side +disposition, no vendor edit.** The vendored `runInference` catch sanitizes the +terminal error it shows the caller and passes policies pre-sanitized inputs, +but the warn about a policy's own exception interpolates raw +(`error=${cause.message}`). The app keeps its vendor tree untouched here and +answers the exposure contractually instead: `RetryPolicy` implementations are +trusted first-party code and must never embed credential-bearing text in an +exception — documented on `createCorbitsRetryPolicy` +(`src/agent/retry-policy.ts`). The vendored line is a promotion candidate if +upstream adopts a sanitized clone for policy-exception logging; the raw +terminal `inference.error` event keeps its existing sanitization. + +**App-side correlation-ID receipt redaction.** All app-side log sites that +interpolated a correlation ID now emit the truncated, hash-suffixed form +(`src/session/redacted-correlation.ts`): the parked-approval, +late-decision and duplicate-resume warns in `src/session/approval-resume.ts`, +the forged-approval discard receipt in `src/session/approval-provenance.ts`, +and the acceptance-timeout warn in `src/tui/approval-delivery.ts`. The file +log sink (`src/logging/sink.ts`) records debug level too, so nothing app-side +carries the full ID at any log level; the full ID lives only on the in-memory +transport that consumes it (message headers, gate table, operator surface). + +**Event-sink audit.** Correlation IDs do not reach model-visible history or +tool-output-readable surfaces: `run.json` excludes correlation IDs (status/task/turn +counts and usage only), the audit collector records `tool.start`/`tool.done` +keyed by callId — not correlation ID — and `message.correlated` / +`reactor.gate.blocked` events fan out to in-memory consumers (TUI occupancy +wait, runtime bridge, watchdog recovery) without being persisted. The +`metadata.json` checkpoint persists pending operations with their correlation +IDs by design (the restart-resume path re-registers parked calls) — that is +the credential cell's own storage, not a log or event sink. + +**Approval snapshot args at rest (CL-10679 R5).** The app's context-store +write boundary (`src/session/pending-snapshot-scrub.ts`, wired into +`src/session/optimized-context-store.ts`) now runs the result-side secret-shape +scrubber (`scrubSecretShapedValue`) over every persisted +`approvalSnapshot.arguments`. The re-dispatch artifact (`suspendedCall`) +stays raw because the approved call must re-run with the arguments the +operator saw; residual at-rest exposure in `suspendedCall.arguments` inside +`metadata.json` is documented as a known gap for the vendored re-dispatch +owner, not silently widened here. + ## Re-syncing a vendored package to a newer upstream commit 1. In the read-only upstream clone, confirm the commit to sync to and note diff --git a/src/agent/retry-policy.ts b/src/agent/retry-policy.ts index a14ee77a5..2aba62b6b 100644 --- a/src/agent/retry-policy.ts +++ b/src/agent/retry-policy.ts @@ -48,6 +48,16 @@ export interface CorbitsRetryPolicyOptions { * before `normalizeInferenceErrorForRetry` so known-provider remappers (xAI * short 429 → retryable, Go, Codex) can gate on context the harness does not * attach to InferenceError today. + * + * Trust model: a `RetryPolicy` is trusted first-party code. The vendored + * harness sanitizes the `situation.error` it passes here (the terminal error + * is run through `sanitizeCredentialDiagnostic` before the policy call) and + * still sanitizes a same-source recovered diagnostic, but the warn it emits + * when the policy itself throws has no sanitizer — a policy exception is + * logged raw (CL-10679 ledger note, + * docs/VENDORING.md#approval-rail-hygiene-audit). Never embed bearer-shaped + * or otherwise credential-bearing text in a policy exception; reach for the + * pre-sanitized `situation.error` instead. */ export function createCorbitsRetryPolicy( options?: CorbitsRetryPolicyOptions, diff --git a/src/config/source-credentials.test.ts b/src/config/source-credentials.test.ts new file mode 100644 index 000000000..8c6ae205c --- /dev/null +++ b/src/config/source-credentials.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { CredentialMaterial } from "@intx/types"; + +import { + clearSourceCredentials, + findSourceCredentialRecord, + registerSourceCredentialRecord, + rotateSourceCredentialMaterialIfCurrent, +} from "./source-credentials.js"; + +const material: CredentialMaterial = { + secret: "secret-value", + headers: { "chatgpt-account-id": "account-a" }, +}; + +afterEach(() => { + clearSourceCredentials(); +}); + +describe("credential material freeze at the source cell", () => { + test("registered material and its headers are frozen", () => { + registerSourceCredentialRecord("c1", { + provenance: { kind: "api-key" }, + material, + }); + const record = findSourceCredentialRecord("c1"); + expect(Object.isFrozen(record?.material)).toBe(true); + expect(Object.isFrozen(record?.material.headers)).toBe(true); + }); + + test("the caller's headers object is copied, not aliased into the cell", () => { + const headers = { "chatgpt-account-id": "account-a" }; + registerSourceCredentialRecord("c1", { + provenance: { kind: "api-key" }, + material: { secret: "secret-value", headers }, + }); + headers["chatgpt-account-id"] = "attacker-a"; + expect(findSourceCredentialRecord("c1")?.material.headers).toEqual({ + "chatgpt-account-id": "account-a", + }); + }); + + test("an in-place mutation attempt after register never reaches the cell", () => { + registerSourceCredentialRecord("c1", { + provenance: { kind: "api-key" }, + material, + }); + const stored = findSourceCredentialRecord("c1"); + const headers = stored?.material.headers as Record; + // Frozen headers make the mutation fail loudly in strict mode. + expect(() => { + headers["chatgpt-account-id"] = "attacker-a"; + }).toThrow(); + expect(stored?.material.headers).toEqual({ + "chatgpt-account-id": "account-a", + }); + }); + + test("a rotation stores a fresh frozen snapshot and releases the old record", () => { + registerSourceCredentialRecord("c1", { + provenance: { kind: "api-key" }, + material, + }); + const expected = findSourceCredentialRecord("c1"); + const rotated = { secret: "next-token" }; + expect( + rotateSourceCredentialMaterialIfCurrent("c1", expected as never, rotated), + ).toBe(true); + const record = findSourceCredentialRecord("c1"); + expect(Object.isFrozen(record?.material)).toBe(true); + expect(record?.material.secret).toBe("next-token"); + }); +}); diff --git a/src/config/source-credentials.ts b/src/config/source-credentials.ts index f91ef198a..c8ef7db66 100644 --- a/src/config/source-credentials.ts +++ b/src/config/source-credentials.ts @@ -19,11 +19,32 @@ export interface SourceCredentialRecord { const cell = new Map(); +// The material rides process memory long after registration (per-use resolver +// reads, subagent failover chains). Freezing at the cell boundary means every +// downstream holder sees an immutable snapshot: a mutated "material" never +// masquerades as the registered secret, and an attempted header mutation fails +// loudly instead of silently diverging from what the provider sent. Secrets +// are strings; the headers record is the mutable part being contained. +function freezeCredentialMaterial( + material: CredentialMaterial, +): CredentialMaterial { + const frozen: CredentialMaterial = { + ...material, + ...(material.headers !== undefined + ? { headers: Object.freeze({ ...material.headers }) } + : {}), + }; + return Object.freeze(frozen); +} + export function registerSourceCredentialRecord( credentialId: string, record: SourceCredentialRecord, ): void { - cell.set(credentialId, record); + cell.set(credentialId, { + ...record, + material: freezeCredentialMaterial(record.material), + }); } export function rotateSourceCredentialMaterialIfCurrent( @@ -32,7 +53,10 @@ export function rotateSourceCredentialMaterialIfCurrent( material: CredentialMaterial, ): boolean { if (cell.get(credentialId) !== expected) return false; - cell.set(credentialId, { provenance: expected.provenance, material }); + cell.set(credentialId, { + provenance: expected.provenance, + material: freezeCredentialMaterial(material), + }); return true; } diff --git a/src/session/approval-provenance.test.ts b/src/session/approval-provenance.test.ts index 2b9165495..2ad54657d 100644 --- a/src/session/approval-provenance.test.ts +++ b/src/session/approval-provenance.test.ts @@ -10,10 +10,12 @@ import { LOCAL_APPROVAL_FROM, LOCAL_APPROVAL_MAILBOX, } from "./approval-provenance.js"; +import { redactedCorrelationId } from "./redacted-correlation.js"; interface MessageInput { from: string; mailbox?: string; + correlationId?: string; } function approvalMessage( @@ -26,7 +28,7 @@ function approvalMessage( const message = createInboundMessage({ from: args.from, to: "agent@local", - correlationId: "corr-1", + correlationId: args.correlationId ?? "corr-1", content: JSON.stringify({ outcome }), ...(args.mailbox === undefined ? {} : { ref: { mailbox: args.mailbox } }), }); @@ -179,12 +181,38 @@ describe("createApprovalProvenanceGate", () => { record.text.includes("forged approval discarded"), ); expect(receipts).toHaveLength(2); - expect(receipts[0]?.text).toContain("correlationId=corr-1"); + // The warn receipt carries the truncated-and-hashed form, never the + // live bearer capability (see src/session/redacted-correlation.ts). + expect(receipts[0]?.text).toContain( + `correlationId=${redactedCorrelationId("corr-1")}`, + ); + expect(receipts[0]?.text).not.toContain("correlationId=corr-1 type"); expect(receipts[0]?.text).toContain("from=watchdog@foreign"); expect(receipts[0]?.text).toContain("mailbox=INBOX"); expect(receipts[0]?.text).toContain("type=approval.granted"); expect(receipts[1]?.text).toContain("from=webhook@foreign"); expect(receipts[1]?.text).toContain("mailbox="); + const uuid = "6f4f7c16-2e21-4a2d-9f4b-7c1e93a88b41"; + gate.deliver( + approvalMessage({ + from: "probe@foreign", + mailbox: "INBOX", + correlationId: uuid, + }), + ); + const longReceipts = records.filter( + (record) => + record.level === "warning" && + record.text.includes("forged approval discarded") && + record.text.includes("probe@foreign"), + ); + expect(longReceipts).toHaveLength(1); + // A full-length ID never appears in the receipt: only its prefix and + // the hash suffix do. + expect(longReceipts[0]?.text).toContain( + `correlationId=${redactedCorrelationId(uuid)}`, + ); + expect(longReceipts[0]?.text).not.toContain(uuid); expect(deliver).not.toHaveBeenCalled(); } finally { if (previous === null) resetSync(); diff --git a/src/session/approval-provenance.ts b/src/session/approval-provenance.ts index f7de08c5f..b6b2881f1 100644 --- a/src/session/approval-provenance.ts +++ b/src/session/approval-provenance.ts @@ -24,6 +24,7 @@ import type { InboundMessage } from "@intx/types/runtime"; import { getLogger } from "@intx/log"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; +import { redactedCorrelationId } from "./redacted-correlation.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "approval-provenance"]); @@ -84,7 +85,11 @@ export function createApprovalProvenanceGate< ...inner, deliver(message: InboundMessage): void { if (isForeignApprovalIngress(message)) { - logger.warn`forged approval discarded, foreign ingress correlationId=${message.headers.interchangeCorrelationId} from=${message.headers.from} mailbox=${message.ref?.mailbox ?? ""} type=${message.headers.interchangeType}`; + // The log sink (src/logging/sink.ts) records debug level too, so the + // receipt trail must not carry the full bearer capability at any + // level — warn and debug both use the redacted form here, and the + // full ID stays only on the in-memory transport that consumes it. + logger.warn`forged approval discarded, foreign ingress correlationId=${redactedCorrelationId(message.headers.interchangeCorrelationId ?? "?")} from=${message.headers.from} mailbox=${message.ref?.mailbox ?? ""} type=${message.headers.interchangeType}`; return; } inner.deliver(message); diff --git a/src/session/approval-resume.ts b/src/session/approval-resume.ts index 1269b12c2..693f38229 100644 --- a/src/session/approval-resume.ts +++ b/src/session/approval-resume.ts @@ -36,6 +36,8 @@ import { LOCAL_APPROVAL_FROM, LOCAL_APPROVAL_MAILBOX, } from "./approval-provenance.js"; +import { redactedCorrelationId } from "./redacted-correlation.js"; +import { canonicalSnapshotScrub } from "./pending-snapshot-scrub.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "approval-resume"]); @@ -141,7 +143,7 @@ export function createSuspendedApprovalRecovery(args: { if (candidate === owned) candidate = undefined; }, () => { - logger.warn`parked approval not presented correlation=${correlationId}`; + logger.warn`parked approval not presented correlation=${redactedCorrelationId(correlationId)}`; }, ); }, @@ -287,8 +289,18 @@ export async function resolveSuspendedApprovalFromStore( if ( parked.name !== parsedPersisted.name || parked.name !== parsedCaptured.name || - !isDeepStrictEqual(parked.arguments, parsedPersisted.arguments ?? {}) || - !isDeepStrictEqual(parked.arguments, parsedCaptured.arguments ?? {}) + // Both sides of each comparison run through the same canonical scrub, so + // the persisted snapshot (scrubbed at the metadata write boundary) still + // matches the live raw snapshot: secret-shaped values are redacted + // identically on both sides; benign values are unchanged. + !isDeepStrictEqual( + canonicalSnapshotScrub(parked.arguments), + canonicalSnapshotScrub(parsedPersisted.arguments ?? {}), + ) || + !isDeepStrictEqual( + canonicalSnapshotScrub(parked.arguments), + canonicalSnapshotScrub(parsedCaptured.arguments ?? {}), + ) ) return { ok: false, code: "snapshot-mismatch" }; return { ok: true, parkedCallId: parked.id }; @@ -524,7 +536,7 @@ export function createApprovalResume(args: { return true; } if (timedOut) { - logger.warn`late approval decision dropped correlation=${correlationId} timeoutCall=${parkedCallId} outcome=${outcome?.allow === true ? "approved" : "rejected"}`; + logger.warn`late approval decision dropped correlation=${redactedCorrelationId(correlationId)} timeoutCall=${parkedCallId} outcome=${outcome?.allow === true ? "approved" : "rejected"}`; return true; } await deliverDecision( @@ -555,7 +567,7 @@ export function createApprovalResume(args: { if (result.type !== "suspended") return Promise.resolve(false); const { correlationId } = result; if (handedOver.has(correlationId)) { - logger.warn`duplicate approval resume ignored correlation=${correlationId}`; + logger.warn`duplicate approval resume ignored correlation=${redactedCorrelationId(correlationId)}`; return Promise.resolve(true); } const ongoing = inflight.get(correlationId); diff --git a/src/session/optimized-context-store.test.ts b/src/session/optimized-context-store.test.ts index c6bfd9835..b5eb10ec1 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -68,6 +68,53 @@ function turnTexts(turns: ConversationTurn[]): string[] { return turns.map((t) => (t.content[0] as { text: string }).text); } +describe("createOptimizedContextStore writeMetadata approval snapshot scrubbing", () => { + test("persists approvalSnapshot args redacted while suspendedCall keeps raw args", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + const secretArg = { api_key: "sk-test-0123456789abcdefghij" }; + const op = { + correlationId: "corr-1", + kind: "approval" as const, + registeredAt: 1_700_000_000_000, + gateId: "gate-1", + suspendedCall: { id: "call-1", name: "run_shell", arguments: secretArg }, + approvalSnapshot: { + name: "run_shell", + description: "runs a shell command", + inputSchema: {}, + arguments: secretArg, + }, + }; + await store.writeMetadata({ + pendingOperations: [op], + tokenUsage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }, + }); + await store.commit({ message: "checkpoint: pending" }); + + const persisted = JSON.parse( + fs.readFileSync(path.join(dir, "metadata.json"), "utf8"), + ) as { pendingOperations: { approvalSnapshot: { arguments: object } }[] }; + const persistedSubset = JSON.stringify( + persisted.pendingOperations[0]?.approvalSnapshot?.arguments, + ); + // The approver-facing snapshot is redacted at rest ... + expect(persistedSubset).not.toContain("sk-test-0123456789abcdefghij"); + // ... while the re-dispatch artifact round-trips raw: the approved call + // runs with the same arguments the operator saw when approving. + const loaded = await store.load(); + expect(loaded.pendingOperations[0]?.suspendedCall?.arguments).toEqual( + secretArg, + ); + }); +}); + describe("createOptimizedContextStore load", () => { test("resumes turns spread across multiple segments in order", async () => { const dir = tempDir(); diff --git a/src/session/optimized-context-store.ts b/src/session/optimized-context-store.ts index 5558ca5ea..328a2308d 100644 --- a/src/session/optimized-context-store.ts +++ b/src/session/optimized-context-store.ts @@ -26,6 +26,7 @@ import type { ContextStore } from "@intx/types/runtime"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; import { loadOrCreateCommitSigner } from "./commit-signer.js"; import { withResolvedDirLock } from "./session-dir-lock.js"; +import { scrubPersistedPendingOperations } from "./pending-snapshot-scrub.js"; const TURNS_FILE = "turns.jsonl"; const PROMPT_FILE = "prompt.jsonl"; @@ -1221,7 +1222,19 @@ export async function createSessionStores( writeResponse: (turn, signal) => base.writeResponse(turn, signal), writeManifest: (records, signal) => base.writeManifest(records, signal), writeTurns: (turns) => writeTurnsLiveOrStage(turns), - writeMetadata: (metadata, signal) => base.writeMetadata(metadata, signal), + writeMetadata: (metadata, signal) => + // Approval snapshot args persist into metadata.json in the clear; scrub + // them at the app-owned write boundary (suspendedCall stays raw for the + // approved call's re-dispatch). + base.writeMetadata( + { + ...metadata, + pendingOperations: scrubPersistedPendingOperations( + metadata.pendingOperations, + ), + }, + signal, + ), readManifestHistory: (limit, signal) => base.readManifestHistory(limit, signal), async writeBlob(key, bytes, contentType, signal) { diff --git a/src/session/pending-snapshot-scrub.test.ts b/src/session/pending-snapshot-scrub.test.ts new file mode 100644 index 000000000..22db3bdb9 --- /dev/null +++ b/src/session/pending-snapshot-scrub.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { SendResult } from "@intx/agent"; +import type { PendingOperation } from "@intx/types/runtime"; + +import { resolveSuspendedApprovalFromStore } from "./approval-resume.js"; +import { createOptimizedContextStore } from "./optimized-context-store.js"; +import { scrubPersistedPendingOperations } from "./pending-snapshot-scrub.js"; + +const SECRET = "sk-proj-0123456789abcdefghij0123"; + +function approvalOp(args: Record): PendingOperation { + return { + correlationId: "corr-1", + kind: "approval", + registeredAt: 1, + gateId: "gate-1", + suspendedCall: { id: "call-1", name: "run_shell", arguments: args }, + approvalSnapshot: { + name: "run_shell", + description: "runs a shell command", + inputSchema: {}, + arguments: args, + }, + }; +} + +describe("scrubPersistedPendingOperations", () => { + test("redacts credential-shaped snapshot args while suspendedCall stays raw", () => { + const [op] = scrubPersistedPendingOperations([ + approvalOp({ api_key: SECRET, mode: "fast" }), + ]); + expect(op).toBeDefined(); + expect(JSON.stringify(op?.approvalSnapshot?.arguments)).not.toContain( + SECRET, + ); + expect(op?.approvalSnapshot?.arguments.mode).toBe("fast"); + // The re-dispatch artifact keeps the live arguments untouched. + expect(op?.suspendedCall?.arguments).toEqual({ + api_key: SECRET, + mode: "fast", + }); + // The live in-memory object handed in is not mutated. + expect(op?.approvalSnapshot?.arguments.api_key === SECRET).toBe(false); + }); + + test("nested credential shapes deep inside args are redacted too", () => { + const [op] = scrubPersistedPendingOperations([ + approvalOp({ body: { Authorization: `Bearer ${SECRET}` } }), + ]); + expect(JSON.stringify(op?.approvalSnapshot)).not.toContain(SECRET); + }); + + test("ops without snapshots pass through untouched", () => { + const noSnapshot: PendingOperation = { + correlationId: "corr-2", + kind: "approval", + registeredAt: 1, + gateId: "gate-2", + }; + const out = scrubPersistedPendingOperations([noSnapshot]); + expect(out[0]).toBe(noSnapshot); + }); + + test("non-JSON-safe args fail closed with no unscrubbed value persisted", () => { + const args = { api_key: SECRET, nested: new Map([["x", SECRET]]) }; + const [op] = scrubPersistedPendingOperations([approvalOp(args)]); + expect(JSON.stringify(op?.approvalSnapshot?.arguments)).not.toContain( + SECRET, + ); + expect(JSON.stringify(op?.approvalSnapshot).length).toBeGreaterThan(0); + }); + + test("a secret-carrying parked approval still resumes from the scrubbed store", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scrub-resume-")); + const store = await createOptimizedContextStore(dir); + const args = { api_key: SECRET, mode: "fast" }; + const op: PendingOperation = { + correlationId: "corr-9", + kind: "approval", + registeredAt: 1_700_000_000_000, + gateId: "gate-9", + timeoutAt: Date.now() + 60_000, + suspendedCall: { id: "call-9", name: "run_shell", arguments: args }, + approvalSnapshot: { + name: "run_shell", + description: "runs a shell command", + inputSchema: {}, + arguments: args, + }, + }; + await store.writeMetadata({ + pendingOperations: [op], + tokenUsage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }, + }); + await store.commit({ message: "checkpoint: pending" }); + + // Redacted at rest ... + const persistedArgs = JSON.stringify( + ( + JSON.parse( + fs.readFileSync(path.join(dir, "metadata.json"), "utf8"), + ) as { + pendingOperations: { approvalSnapshot: { arguments: object } }[]; + } + ).pendingOperations[0]?.approvalSnapshot?.arguments, + ); + expect(persistedArgs).not.toContain(SECRET); + + // ... yet the stalled-approval recovery still proves the live suspended + // result against the persisted (scrubbed) snapshot: same canonical scrub + // on both sides of the comparison. + const resolution = await resolveSuspendedApprovalFromStore( + { load: () => store.load() }, + { + type: "suspended", + correlationId: "corr-9", + approvalSnapshot: op.approvalSnapshot, + } as Extract, + ); + expect(resolution).toEqual({ ok: true, parkedCallId: "call-9" }); + + // A genuinely different call still fails closed: benign-arg drift is + // catchable through the same canonical transform. + const drifted = await resolveSuspendedApprovalFromStore( + { load: () => store.load() }, + { + type: "suspended", + correlationId: "corr-9", + approvalSnapshot: { + ...op.approvalSnapshot, + arguments: { ...args, mode: "slow" }, + }, + } as Extract, + ); + expect(drifted).toEqual({ ok: false, code: "snapshot-mismatch" }); + }); +}); diff --git a/src/session/pending-snapshot-scrub.ts b/src/session/pending-snapshot-scrub.ts new file mode 100644 index 000000000..a006a06fe --- /dev/null +++ b/src/session/pending-snapshot-scrub.ts @@ -0,0 +1,61 @@ +// Approval snapshot args at rest. metadata.json (git-backed session state +// under .agent-state) persists each PendingOperation verbatim, +// `approvalSnapshot.arguments` included — the raw model arguments of an ask +// the operator has not answered yet. The result-side secret scrubber never +// sees these, so a parked call carrying a bearer-shaped argument persisted it +// into the session store in the clear. Scrub the snapshot copy at the app's +// metadata write boundary; `suspendedCall` stays untouched because the +// re-dispatch of an approved call must see the raw arguments. +// +// Snapshot equality checks (`resolveSuspendedApprovalFromStore`) run through +// the same canonical transform on BOTH sides, so a scrubbed persisted snapshot +// still matches the live raw snapshot exactly the way the unscrubbed copies +// used to — one transform, symmetrically applied. + +import type { PendingOperation } from "@intx/types/runtime"; + +import { + scrubSecretShapedContent, + scrubSecretShapedValue, +} from "../plugins/tool-result-secret-scrub.js"; + +// A single canonical form for a snapshot value: secret-shape scrubbed into +// JSON-plain objects (the store round-trips through JSON, so prototype shape +// must not leak into an equality check). Values the scrubber refuses to prove +// JSON-safe fall back to a scrubbed text stub — fail-closed on both the +// at-rest copy and the comparison form. +export function canonicalSnapshotScrub(value: unknown): unknown { + try { + return JSON.parse(JSON.stringify(scrubSecretShapedValue(value))); + } catch { + try { + return { + scrubbed: scrubSecretShapedContent(JSON.stringify(value)), + }; + } catch { + return { scrubbed: "[unscrubbable approval arguments]" }; + } + } +} + +export function scrubPersistedPendingOperations( + pendingOperations: readonly PendingOperation[], +): PendingOperation[] { + return pendingOperations.map((op) => { + const snapshot = op.approvalSnapshot; + if (snapshot === undefined) return op; + // Brand-new plain object; nothing else holds this copy, so the live + // call's args (suspendedCall and the pending op still in the reactor's + // state manager) stay raw. + return { + ...op, + approvalSnapshot: { + ...snapshot, + arguments: canonicalSnapshotScrub(snapshot.arguments) as Record< + string, + unknown + >, + }, + }; + }); +} diff --git a/src/session/redacted-correlation.test.ts b/src/session/redacted-correlation.test.ts new file mode 100644 index 000000000..da5d97587 --- /dev/null +++ b/src/session/redacted-correlation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; + +import { redactedCorrelationId } from "./redacted-correlation.js"; + +describe("redactedCorrelationId", () => { + test("never exposes the full ID", () => { + const id = "6f4f7c16-2e21-4a2d-9f4b-7c1e93a88b41"; + const redacted = redactedCorrelationId(id); + expect(redacted).not.toBe(id); + expect(redacted.length).toBeLessThan(id.length); + expect(redacted).not.toContain(id.slice(8)); + expect(redacted).toStartWith(id.slice(0, 8)); + expect(redacted).toContain("#"); + }); + + test("is stable for the same id and distinct across ids", () => { + expect(redactedCorrelationId("corr-a")).toBe( + redactedCorrelationId("corr-a"), + ); + expect(redactedCorrelationId("corr-a")).not.toBe( + redactedCorrelationId("corr-b"), + ); + }); + + test("ids whose prefixes collide remain distinct through the hash suffix", () => { + const a = "12345678-1111-4a2d-9f4b-7c1e93a88b41"; + const b = "12345678-2222-4a2d-9f4b-7c1e93a88b41"; + expect(a.slice(0, 8)).toBe(b.slice(0, 8)); + expect(redactedCorrelationId(a)).not.toBe(redactedCorrelationId(b)); + }); +}); diff --git a/src/session/redacted-correlation.ts b/src/session/redacted-correlation.ts new file mode 100644 index 000000000..ba93f3bd8 --- /dev/null +++ b/src/session/redacted-correlation.ts @@ -0,0 +1,22 @@ +import { createHash } from "node:crypto"; + +// Log lines land in ~/.corbits settings logs unauthenticated. A correlation ID +// on the approval rail is a bearer capability: a leaked live ID plus a forged +// decision re-grants a parked call (see src/session/approval-provenance.ts). +// Warn-level log lines therefore carry only a truncated prefix plus a short +// hash — enough to tie receipts across lines, not enough to reconstruct the +// live ID (UUIDv4's ~122 random bits leave ~90 unknown after an 8-hex-char +// prefix is shown, so the shown prefix alone cannot reconstruct the ID; the +// hash suffix exists for cross-line correlation, not for secrecy). The full +// ID still flows on the in-memory transport that consumes it: message +// headers, the gate table and the operator approval surface. The file sink +// (src/logging/sink.ts) records debug level too, so no log line app-side +// carries the full ID at any level. +const PREFIX_CHARS = 8; +const HASH_CHARS = 8; + +/** Truncated, hash-suffixed form of a correlation ID for log lines. */ +export function redactedCorrelationId(correlationId: string): string { + const digest = createHash("sha256").update(correlationId).digest("hex"); + return `${correlationId.slice(0, PREFIX_CHARS)}#${digest.slice(0, HASH_CHARS)}`; +} diff --git a/src/subagent/run-suspended-send.test.ts b/src/subagent/run-suspended-send.test.ts index 4a9d1f7c7..672d00f53 100644 --- a/src/subagent/run-suspended-send.test.ts +++ b/src/subagent/run-suspended-send.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { SendResult } from "@intx/agent"; import { assertReplySend } from "./run.js"; +import { redactedCorrelationId } from "../session/redacted-correlation.js"; describe("assertReplySend", () => { test("passes through a reply result", () => { @@ -14,9 +15,10 @@ describe("assertReplySend", () => { }); test("carries the suspension type and correlationId on the thrown error", () => { + const correlationId = "6f4f7c16-2e21-4a2d-9f4b-7c1e93a88b41"; const result: SendResult = { type: "suspended", - correlationId: "corr-123", + correlationId, }; let thrown: unknown; try { @@ -29,10 +31,13 @@ describe("assertReplySend", () => { suspendedType?: string; correlationId?: string; }; - expect(error.message).toContain("suspended"); - expect(error.message).toContain("corr-123"); + // Message text is redacted; the structured property keeps the full ID. + expect(error.message).toContain( + `correlationId=${redactedCorrelationId(correlationId)}`, + ); + expect(error.message).not.toContain(correlationId); expect(error.suspendedType).toBe("suspended"); - expect(error.correlationId).toBe("corr-123"); + expect(error.correlationId).toBe(correlationId); }); test("reports when the suspension carries an approval snapshot", () => { diff --git a/src/subagent/run.ts b/src/subagent/run.ts index bb10f40d2..e45e5104a 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -22,6 +22,7 @@ import { workerPermissionGate, } from "../permission/reactor-authorize.js"; import { createSessionStores } from "../session/optimized-context-store.js"; +import { redactedCorrelationId } from "../session/redacted-correlation.js"; import { createShellOutputFeedMap } from "../session/shell-output-feed.js"; import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js"; import { type } from "arktype"; @@ -218,9 +219,11 @@ export function assertReplySend( result: SendResult, ): asserts result is Extract { if (result.type === "reply") return; + // Error text carries only the redacted form; the live capability stays on + // the structured .correlationId property. const error = new Error( `Sub-agent send returned a suspended result ` + - `(correlationId=${result.correlationId}` + + `(correlationId=${redactedCorrelationId(result.correlationId)}` + `${result.approvalSnapshot !== undefined ? ", approvalSnapshot present" : ""})`, ); Object.assign(error, { diff --git a/src/tui/approval-delivery.test.ts b/src/tui/approval-delivery.test.ts index e5c51034d..ce37b8f6d 100644 --- a/src/tui/approval-delivery.test.ts +++ b/src/tui/approval-delivery.test.ts @@ -6,6 +6,7 @@ import { ApprovalDeliveryTimeoutError, createApprovalDeliverer, } from "./approval-delivery.js"; +import { redactedCorrelationId } from "../session/redacted-correlation.js"; function decisionMessage( correlationId: string | undefined, @@ -85,7 +86,11 @@ describe("approval delivery acceptance bound", () => { expect(err.stage).toBe("reactor-acceptance"); expect(err.timeoutMs).toBe(15); expect(err.mayStillApply).toBe(true); - expect(err.message).toContain("corr-stuck"); + // Message text is redacted; the structured property keeps the full ID. + expect(err.message).toContain( + `correlationId=${redactedCorrelationId("corr-stuck")}`, + ); + expect(err.message).not.toContain("corr-stuck,"); expect(err.message).toContain("reactor-acceptance"); expect(tailAdvanced).toBe(true); expect(Date.now() - started).toBeLessThan(5000); diff --git a/src/tui/approval-delivery.ts b/src/tui/approval-delivery.ts index 2e2202d4c..c192e7d55 100644 --- a/src/tui/approval-delivery.ts +++ b/src/tui/approval-delivery.ts @@ -19,6 +19,7 @@ import type { InboundMessage } from "@intx/types/runtime"; import { getLogger } from "@intx/log"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; +import { redactedCorrelationId } from "../session/redacted-correlation.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "approval-delivery"]); @@ -52,7 +53,8 @@ export class ApprovalDeliveryTimeoutError extends Error { : "the decision may still be applied"; super( `approval delivery timed out waiting for reactor acceptance ` + - `(correlationId=${args.correlationId}, stage=reactor-acceptance, ` + + // Redacted in text; the full ID stays on the structured property. + `(correlationId=${redactedCorrelationId(args.correlationId)}, stage=reactor-acceptance, ` + `timeoutMs=${args.timeoutMs}, outcome=${args.outcome}). ` + `The decision was handed to the reactor but no acceptance event was ` + `observed, so ${action}. Do not re-deliver: the delivery tail has ` + @@ -119,7 +121,7 @@ export function createApprovalDeliverer( timeoutMs, outcome, }); - logger.warn`approval acceptance timed out correlation=${correlationId} timeoutMs=${timeoutMs} outcome=${outcome}`; + logger.warn`approval acceptance timed out correlation=${redactedCorrelationId(correlationId)} timeoutMs=${timeoutMs} outcome=${outcome}`; deps.onTimeout?.(err); reject(err); }, timeoutMs); From 4aac8f8eb051955dbec03d535cd3c66ae070a74a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 14 Oct 2026 22:10:06 -0700 Subject: [PATCH 2/2] fix(session): reject swapped credentials in approval snapshot compare Integrity compare no longer collapses every secret to one redaction token. Redacted-at-rest snapshots still match the live call; raw re-dispatch arguments must match exactly. --- src/session/approval-resume.ts | 25 ++++---- src/session/pending-snapshot-scrub.test.ts | 43 ++++++++++++++ src/session/pending-snapshot-scrub.ts | 66 ++++++++++++++++++++-- 3 files changed, 118 insertions(+), 16 deletions(-) diff --git a/src/session/approval-resume.ts b/src/session/approval-resume.ts index 693f38229..3fc0478ae 100644 --- a/src/session/approval-resume.ts +++ b/src/session/approval-resume.ts @@ -16,7 +16,6 @@ import type { ContextStore, InboundMessage, } from "@intx/types/runtime"; -import { isDeepStrictEqual } from "node:util"; import { type } from "arktype"; import { getLogger } from "@intx/log"; @@ -37,7 +36,7 @@ import { LOCAL_APPROVAL_MAILBOX, } from "./approval-provenance.js"; import { redactedCorrelationId } from "./redacted-correlation.js"; -import { canonicalSnapshotScrub } from "./pending-snapshot-scrub.js"; +import { snapshotArgumentsMatch } from "./pending-snapshot-scrub.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "approval-resume"]); @@ -289,17 +288,19 @@ export async function resolveSuspendedApprovalFromStore( if ( parked.name !== parsedPersisted.name || parked.name !== parsedCaptured.name || - // Both sides of each comparison run through the same canonical scrub, so - // the persisted snapshot (scrubbed at the metadata write boundary) still - // matches the live raw snapshot: secret-shaped values are redacted - // identically on both sides; benign values are unchanged. - !isDeepStrictEqual( - canonicalSnapshotScrub(parked.arguments), - canonicalSnapshotScrub(parsedPersisted.arguments ?? {}), + // The persisted copy is redacted at rest, so its compare still accepts the + // canonical scrub when that copy is fully redacted. The re-dispatch + // snapshot is compared raw: different credential values are a mismatch + // even though both would scrub to the same token. + !snapshotArgumentsMatch( + parked.arguments, + parsedPersisted.arguments ?? {}, + true, ) || - !isDeepStrictEqual( - canonicalSnapshotScrub(parked.arguments), - canonicalSnapshotScrub(parsedCaptured.arguments ?? {}), + !snapshotArgumentsMatch( + parked.arguments, + parsedCaptured.arguments ?? {}, + false, ) ) return { ok: false, code: "snapshot-mismatch" }; diff --git a/src/session/pending-snapshot-scrub.test.ts b/src/session/pending-snapshot-scrub.test.ts index 22db3bdb9..474376d8c 100644 --- a/src/session/pending-snapshot-scrub.test.ts +++ b/src/session/pending-snapshot-scrub.test.ts @@ -143,4 +143,47 @@ describe("scrubPersistedPendingOperations", () => { ); expect(drifted).toEqual({ ok: false, code: "snapshot-mismatch" }); }); + + test("a swapped credential in the re-dispatch payload is a snapshot mismatch", async () => { + const keyA = "sk-proj-aaaaaaaaaaaaaaaaaaaa"; + const keyB = "sk-proj-bbbbbbbbbbbbbbbbbbbb"; + const parkedArgs = { api_key: keyA, mode: "fast" }; + const swappedArgs = { api_key: keyB, mode: "fast" }; + const op: PendingOperation = { + correlationId: "corr-9", + kind: "approval", + registeredAt: 1_700_000_000_000, + gateId: "gate-9", + timeoutAt: Date.now() + 60_000, + suspendedCall: { id: "call-9", name: "run_shell", arguments: parkedArgs }, + approvalSnapshot: { + name: "run_shell", + description: "runs a shell command", + inputSchema: {}, + arguments: swappedArgs, + }, + }; + const swapped = await resolveSuspendedApprovalFromStore( + { + load: async () => ({ + turns: [], + pendingOperations: [op], + tokenUsage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }, + connectorState: null, + }), + }, + { + type: "suspended", + correlationId: "corr-9", + approvalSnapshot: op.approvalSnapshot, + } as Extract, + ); + expect(swapped).toEqual({ ok: false, code: "snapshot-mismatch" }); + }); }); diff --git a/src/session/pending-snapshot-scrub.ts b/src/session/pending-snapshot-scrub.ts index a006a06fe..f95b41bab 100644 --- a/src/session/pending-snapshot-scrub.ts +++ b/src/session/pending-snapshot-scrub.ts @@ -7,10 +7,15 @@ // metadata write boundary; `suspendedCall` stays untouched because the // re-dispatch of an approved call must see the raw arguments. // -// Snapshot equality checks (`resolveSuspendedApprovalFromStore`) run through -// the same canonical transform on BOTH sides, so a scrubbed persisted snapshot -// still matches the live raw snapshot exactly the way the unscrubbed copies -// used to — one transform, symmetrically applied. +// Snapshot equality checks (`resolveSuspendedApprovalFromStore`) must not +// collapse every secret to one redaction token before comparing: that made +// two different credentials compare equal. Persisted snapshots are redacted +// at the metadata write, so a fully redacted copy still matches the live raw +// snapshot through the canonical scrub. A copy that still carries raw secret +// material — and the in-memory re-dispatch snapshot — compare without that +// lossy collapse. + +import { isDeepStrictEqual } from "node:util"; import type { PendingOperation } from "@intx/types/runtime"; @@ -38,6 +43,59 @@ export function canonicalSnapshotScrub(value: unknown): unknown { } } +function jsonPlain(value: unknown): unknown { + return JSON.parse(JSON.stringify(value)) as unknown; +} + +// True when `value` is already the redacted canonical form (the scrub is a +// no-op). A snapshot that still holds a raw credential fails this check. +function isRedactedCanonical(value: unknown): boolean { + try { + return isDeepStrictEqual(jsonPlain(value), canonicalSnapshotScrub(value)); + } catch { + return false; + } +} + +function rawArgumentsEqual(left: unknown, right: unknown): boolean { + try { + return isDeepStrictEqual(jsonPlain(left), jsonPlain(right)); + } catch { + // Non-JSON-safe args cannot be compared raw. Fall back to the shared + // scrub stub so a parked call still lines up with its own snapshot; + // JSON-safe credentials never take this path. + return isDeepStrictEqual( + canonicalSnapshotScrub(left), + canonicalSnapshotScrub(right), + ); + } +} + +/** + * Integrity compare for approval arguments. + * + * `persisted` is the on-disk snapshot. When that copy was redacted at rest, + * secret fields are the stable redaction token and the canonical scrub still + * lines up with the live raw args. When it still contains a raw credential, + * compare the values themselves so a different secret is a mismatch. + * + * The in-memory re-dispatch snapshot (`persisted: false`) is never collapsed: + * two different credential values must not compare equal. + */ +export function snapshotArgumentsMatch( + parked: unknown, + other: unknown, + persisted: boolean, +): boolean { + if (persisted && isRedactedCanonical(other)) { + return isDeepStrictEqual( + canonicalSnapshotScrub(parked), + canonicalSnapshotScrub(other), + ); + } + return rawArgumentsEqual(parked, other); +} + export function scrubPersistedPendingOperations( pendingOperations: readonly PendingOperation[], ): PendingOperation[] {