Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions docs/VENDORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/agent/retry-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
73 changes: 73 additions & 0 deletions src/config/source-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
// 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");
});
});
28 changes: 26 additions & 2 deletions src/config/source-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,32 @@ export interface SourceCredentialRecord {

const cell = new Map<string, SourceCredentialRecord>();

// 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(
Expand All @@ -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;
}

Expand Down
32 changes: 30 additions & 2 deletions src/session/approval-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 } }),
});
Expand Down Expand Up @@ -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=<none>");
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();
Expand Down
7 changes: 6 additions & 1 deletion src/session/approval-provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand Down Expand Up @@ -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 ?? "<none>"} 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 ?? "<none>"} type=${message.headers.interchangeType}`;
return;
}
inner.deliver(message);
Expand Down
25 changes: 19 additions & 6 deletions src/session/approval-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,6 +35,8 @@ import {
LOCAL_APPROVAL_FROM,
LOCAL_APPROVAL_MAILBOX,
} from "./approval-provenance.js";
import { redactedCorrelationId } from "./redacted-correlation.js";
import { snapshotArgumentsMatch } from "./pending-snapshot-scrub.js";

const logger = getLogger([LOG_NAMESPACE_ROOT, "approval-resume"]);

Expand Down Expand Up @@ -141,7 +142,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)}`;
},
);
},
Expand Down Expand Up @@ -287,8 +288,20 @@ export async function resolveSuspendedApprovalFromStore(
if (
parked.name !== parsedPersisted.name ||
parked.name !== parsedCaptured.name ||
!isDeepStrictEqual(parked.arguments, parsedPersisted.arguments ?? {}) ||
!isDeepStrictEqual(parked.arguments, parsedCaptured.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,
) ||
!snapshotArgumentsMatch(
parked.arguments,
parsedCaptured.arguments ?? {},
false,
)
)
return { ok: false, code: "snapshot-mismatch" };
return { ok: true, parkedCallId: parked.id };
Expand Down Expand Up @@ -524,7 +537,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(
Expand Down Expand Up @@ -555,7 +568,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);
Expand Down
Loading
Loading