diff --git a/docs/VENDORING.md b/docs/VENDORING.md index 6c93d8a27..44f799be8 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -253,6 +253,39 @@ paths) are verbatim copies of upstream — no modifications. A diff against a pristine upstream checkout at the same commit and paths will show 100% upstream-authored lines for those eight. +## Bearer correlation IDs and approval provenance + +On the approval rail, a correlation ID is a bearer capability. Interchange +removed the `expectedFrom` sender check upstream, so `tryCorrelate` +(`vendor/intx-inference/src/reactor.ts`) resolves any inbound message whose +`interchangeCorrelationId` matches a registered pending operation: a learned +ID plus a forged `approval.granted` would grant the parked call's one-shot +bypass, and a forged `approval.denied` would inject a denial into history. +Upstream's seam for sender authentication is the `CorrelationValidator` +interface (`vendor/intx-inference/src/correlation.ts`), injected through +reactor config — but the vendored `@intx/agent` layer forwards no such +validator into `createReactorAssembly` and its env contract has no field to +carry one, so production wiring happens on the app side. + +Corbits Code therefore authenticates approval provenance at its inbound +delivery boundary (`src/session/approval-provenance.ts`, wired in +`src/agent/live-tool-dispatch.ts` around every agent the app constructs): +typed approvals (`approval.granted` / `approval.denied`) are accepted only +from the local approval-delivery path — sender `approval@local` on mailbox +`approval`, the exact provenance `src/session/approval-resume.ts` +`decisionMessage` stamps — and any other ingress (mailbox or system) is +discarded with a warn receipt before the reactor can correlate it. The same +policy is exposed as a `CorrelationValidator` implementation so the vendored +injection point can consume it verbatim (see +`e2e/reactor-approval-acceptance.test.ts`) when a vendor seam to forward it +exists. Decision binding to the gate is untouched and remains in the vendored +reactor: `tryCorrelate`'s `isLiveApproval` requires the pending operation's +gate to still hold the correlation ID and be unexpired, and the local +`reactor-ts-atomic-approval-acceptance` patch keeps a discarded typed +approval from collapsing the pending operation. The two provenance markers +and the policy live in one module so the authentic sender cannot drift from +the allowlist; `decisionMessage` draws its sender/mailbox from that module. + ## Notable upstream shape changes carried by the 2026-08-22 sync Upstream commit `5f798bea` ("Inject the runtime into iso-git storage") diff --git a/e2e/reactor-approval-acceptance.test.ts b/e2e/reactor-approval-acceptance.test.ts index 7b6927cc2..4691cb95d 100644 --- a/e2e/reactor-approval-acceptance.test.ts +++ b/e2e/reactor-approval-acceptance.test.ts @@ -14,6 +14,7 @@ import { } from "../vendor/intx-inference/src/reactor.js"; import { createDefaultDependencies } from "../vendor/intx-inference/src/providers/index.js"; import { createSessionOperationQueue } from "../src/tui/delivery-queue.js"; +import { createApprovalCorrelationValidator } from "../src/session/approval-provenance.js"; const call = { id: "parked-call", name: "test_tool", arguments: {} }; const epoch = 100_000; @@ -204,6 +205,23 @@ function accepted(h: Awaited>) { function received(h: Awaited>) { return h.events.filter((e) => e.type === "message.received"); } +// The operator decision's provenance markers, exactly as the local +// approval-delivery path (src/session/approval-resume.ts decisionMessage) +// stamps them. +function operatorDecision( + outcome: "approved" | "rejected" = "approved", +): InboundMessage { + const message = createInboundMessage({ + from: "approval@local", + to: "agent@local", + correlationId: "approval", + content: JSON.stringify({ outcome }), + ref: { mailbox: "approval" }, + }); + message.headers.interchangeType = + outcome === "approved" ? "approval.granted" : "approval.denied"; + return message; +} function resumes(h: Awaited>) { return h.directed.filter( (e) => e.type === "resume.execute_tools" || e.type === "resume.tool_result", @@ -519,3 +537,28 @@ describe("atomic typed approval acceptance", () => { expect(JSON.stringify(h.snapshots.at(-1)?.turns)).toContain("approved"); }); }); + +// Foreign-from/mailbox ingress repro (CL-10678): a typed approval whose ID +// matches a live pending operation but whose sender is not the local +// approval-delivery path must be discarded, then a real local decision must +// still succeed against the same pending operation. +describe("approval provenance on typed approvals", () => { + test("foreign ingress with a live correlation id is discarded and the real operator approval still succeeds", async () => { + const h = await harness({ + validator: createApprovalCorrelationValidator(), + }); + // Forgery probe: valid correlation id, foreign sender, mailbox ingress. + h.reactor.deliver(decision()); + await drain(); + expectDiscard(h); + expect(resumes(h)).toHaveLength(0); + // The real operator decision must still land after the probe. + h.reactor.deliver(operatorDecision()); + await drain(); + expect(accepted(h)).toHaveLength(1); + expect(received(h)).toHaveLength(0); + expect(h.grants).toHaveLength(1); + expect(h.tools).toHaveLength(1); + expect(resumes(h)).toHaveLength(1); + }); +}); diff --git a/src/agent/live-tool-dispatch.ts b/src/agent/live-tool-dispatch.ts index 9447eebcd..1995f1c16 100644 --- a/src/agent/live-tool-dispatch.ts +++ b/src/agent/live-tool-dispatch.ts @@ -5,6 +5,8 @@ import { type BaseEnv, } from "@intx/agent"; +import { createApprovalProvenanceGate } from "../session/approval-provenance.js"; + // @intx/agent resolveTools snapshots `byName` from each bundle's definitions // at createAgent and never consults a live getter. MCP tools arrive later via // DynamicToolRunner.addTools (servers connect after the TUI is up; one @@ -79,5 +81,10 @@ export function createAgentWithLiveToolDispatch( def: AgentDefinition, env: EnvReq, ): Promise { - return withLiveToolDispatchMap(() => createAgent(def, env)); + return withLiveToolDispatchMap(async () => + // The vendored @intx/agent forwards no correlationValidator into the + // reactor assembly, so approval provenance is enforced at the app's + // inbound-delivery boundary (see src/session/approval-provenance.ts). + createApprovalProvenanceGate(await createAgent(def, env)), + ); } diff --git a/src/session/approval-provenance.test.ts b/src/session/approval-provenance.test.ts new file mode 100644 index 000000000..2b9165495 --- /dev/null +++ b/src/session/approval-provenance.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createInboundMessage } from "@intx/mime"; +import type { InboundMessage } from "@intx/types/runtime"; +import { configureSync, getConfig, resetSync } from "@intx/log"; + +import { + createApprovalCorrelationValidator, + createApprovalProvenanceGate, + isForeignApprovalIngress, + LOCAL_APPROVAL_FROM, + LOCAL_APPROVAL_MAILBOX, +} from "./approval-provenance.js"; + +interface MessageInput { + from: string; + mailbox?: string; +} + +function approvalMessage( + args: MessageInput = { + from: LOCAL_APPROVAL_FROM, + mailbox: LOCAL_APPROVAL_MAILBOX, + }, + outcome: "approved" | "rejected" = "approved", +): InboundMessage { + const message = createInboundMessage({ + from: args.from, + to: "agent@local", + correlationId: "corr-1", + content: JSON.stringify({ outcome }), + ...(args.mailbox === undefined ? {} : { ref: { mailbox: args.mailbox } }), + }); + message.headers.interchangeType = + outcome === "approved" ? "approval.granted" : "approval.denied"; + return message; +} + +describe("isForeignApprovalIngress", () => { + test("the local approval-delivery path is not foreign", () => { + expect(isForeignApprovalIngress(approvalMessage())).toBe(false); + }); + test("a foreign sender is foreign even on the local mailbox", () => { + expect( + isForeignApprovalIngress( + approvalMessage({ from: "watchdog@foreign", mailbox: "approval" }), + ), + ).toBe(true); + }); + test("mailbox ingress is foreign even with the local sender", () => { + expect( + isForeignApprovalIngress( + approvalMessage({ from: LOCAL_APPROVAL_FROM, mailbox: "INBOX" }), + ), + ).toBe(true); + }); + test("a typed approval with no mailbox fails closed as foreign", () => { + // The declared MessageRef always names a mailbox, but the gate must still + // fail closed when a ref arrives without one (upstream transport parses + // external bytes before this type guarantees anything). + const { mailbox: _omitted, ...refWithoutMailbox } = approvalMessage().ref; + const message = { + ...approvalMessage(), + ref: refWithoutMailbox, + } as unknown as InboundMessage; + expect(isForeignApprovalIngress(message)).toBe(true); + }); + test("a typed approval with no ref at all fails closed as foreign", () => { + // What fail-closed is for: a type-illegal shape the declared ref cannot + // produce must not flip the default to trusted merely by existing. + const message = { + ...approvalMessage({ from: "webhook@foreign" }), + ref: undefined, + } as unknown as InboundMessage; + expect(isForeignApprovalIngress(message)).toBe(true); + }); +}); + +describe("createApprovalCorrelationValidator", () => { + test("authenticates only local approval-delivery provenance", async () => { + const validator = createApprovalCorrelationValidator(); + expect(await validator.validate({} as never, approvalMessage())).toBe(true); + expect( + await validator.validate( + {} as never, + approvalMessage({ from: "watchdog@foreign", mailbox: "INBOX" }), + ), + ).toBe(false); + }); + test("authenticates foreign ingress for denied decisions too", async () => { + const validator = createApprovalCorrelationValidator(); + expect( + await validator.validate( + {} as never, + approvalMessage({ from: "user@local", mailbox: "INBOX" }, "rejected"), + ), + ).toBe(false); + }); +}); + +describe("createApprovalProvenanceGate", () => { + test("drops a foreign typed approval before the agent can correlate it", () => { + const deliver = mock((_message: InboundMessage): void => undefined); + const gate = createApprovalProvenanceGate({ deliver }); + gate.deliver( + approvalMessage({ from: "webhook@foreign", mailbox: "INBOX" }), + ); + expect(deliver).not.toHaveBeenCalled(); + }); + test("passes the local approval-delivery path through unchanged", () => { + const deliver = mock((_message: InboundMessage): void => undefined); + const gate = createApprovalProvenanceGate({ deliver }); + const message = approvalMessage(); + gate.deliver(message); + expect(deliver).toHaveBeenCalledWith(message); + }); + test("leaves nonapproval inbound routing untouched", () => { + const deliver = mock((_message: InboundMessage): void => undefined); + const gate = createApprovalProvenanceGate({ deliver }); + const message = createInboundMessage({ + from: "user@local", + to: "agent@local", + content: "hello", + }); + gate.deliver(message); + expect(deliver).toHaveBeenCalledWith(message); + }); + test("a type-illegal ref-less foreign approval is discarded, not thrown", () => { + // The gate must not convert a forge probe into a deliver() throw: the + // receipt path dereferences the same optional ref the policy checked. + const deliver = mock((_message: InboundMessage): void => undefined); + const gate = createApprovalProvenanceGate({ deliver }); + const message = { + ...approvalMessage({ from: "webhook@foreign" }), + ref: undefined, + } as unknown as InboundMessage; + gate.deliver(message); + expect(deliver).not.toHaveBeenCalled(); + }); + test("discarded forgeries get a structured receipt naming provenance", () => { + const records: { level: string; text: string }[] = []; + const previous = getConfig(); + configureSync({ + reset: true, + sinks: { + test: (record) => { + records.push({ + level: record.level, + text: record.message.map(String).join(""), + }); + }, + }, + loggers: [ + { category: [], lowestLevel: "warning", sinks: ["test"] }, + { + category: ["logtape", "meta"], + lowestLevel: "error", + sinks: ["test"], + }, + ], + }); + try { + const deliver = mock((_message: InboundMessage): void => undefined); + const gate = createApprovalProvenanceGate({ deliver }); + gate.deliver( + approvalMessage({ + from: "watchdog@foreign", + mailbox: "INBOX", + }), + ); + // The same probe, but with the ref missing entirely: the receipt must + // render the missing mailbox as instead of throwing. + gate.deliver({ + ...approvalMessage({ from: "webhook@foreign" }), + ref: undefined, + } as unknown as InboundMessage); + const receipts = records.filter( + (record) => + record.level === "warning" && + record.text.includes("forged approval discarded"), + ); + expect(receipts).toHaveLength(2); + expect(receipts[0]?.text).toContain("correlationId=corr-1"); + 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="); + expect(deliver).not.toHaveBeenCalled(); + } finally { + if (previous === null) resetSync(); + else configureSync({ ...previous, reset: true }); + } + }); + test("wraps deliver without disturbing the rest of the surface", async () => { + const deliver = mock((_message: InboundMessage): void => undefined); + const send = mock(async (_content: string) => undefined); + const gate = createApprovalProvenanceGate({ deliver, send }); + gate.deliver(approvalMessage({ from: "watchdog@foreign" })); + expect(deliver).not.toHaveBeenCalled(); + await gate.send("continue"); + expect(send).toHaveBeenCalledWith("continue"); + }); +}); diff --git a/src/session/approval-provenance.ts b/src/session/approval-provenance.ts new file mode 100644 index 000000000..f7de08c5f --- /dev/null +++ b/src/session/approval-provenance.ts @@ -0,0 +1,93 @@ +// Provenance authentication for typed approval messages on the inbound rail. +// +// A correlation ID is a bearer capability: the vendored reactor resolves any +// inbound message whose interchangeCorrelationId matches a registered pending +// operation, with no sender check (vendor/intx-inference/src/reactor.ts +// tryCorrelate; upstream deleted `expectedFrom`). A learned ID plus a forged +// approval.granted would grant one-shot bypass and re-dispatch the parked +// call; a forged approval.denied injects denials into history. Upstream's +// injection point is the `CorrelationValidator` interface it consumes inside +// tryCorrelate, but the vendored `@intx/agent` layer forwards no such +// validator to `createReactorAssembly` and its env contract exposes none, so +// the app enforces the same policy at its inbound-delivery seam: only the +// local approval-delivery path (src/session/approval-resume.ts +// decisionMessage) produces typed approvals, and every other ingress is +// foreign. +// +// The gateId half of the decision binding stays in the vendored reactor: +// tryCorrelate's isLiveApproval requires the gate named by the pending +// operation to still hold the correlation id and be unexpired, and an +// app-side gate cannot see that gate table. + +import type { CorrelationValidator } from "@intx/inference"; +import type { InboundMessage } from "@intx/types/runtime"; +import { getLogger } from "@intx/log"; + +import { LOG_NAMESPACE_ROOT } from "../branding.js"; + +const logger = getLogger([LOG_NAMESPACE_ROOT, "approval-provenance"]); + +/** Sender identity the local approval-delivery path stamps on decisions. */ +export const LOCAL_APPROVAL_FROM = "approval@local"; +/** Mailbox the local approval-delivery path ships decisions from. */ +export const LOCAL_APPROVAL_MAILBOX = "approval"; + +export function typedApprovalType( + interchangeType: string | undefined, +): "approval.granted" | "approval.denied" | undefined { + return interchangeType === "approval.granted" || + interchangeType === "approval.denied" + ? interchangeType + : undefined; +} + +/** + * True for a typed approval that did not originate on the local + * approval-delivery path. Fails closed: a message whose ref names no mailbox + * (or a wrong sender) never matches, so mailbox and system ingress are + * foreign by default. + */ +export function isForeignApprovalIngress(message: InboundMessage): boolean { + if (typedApprovalType(message.headers.interchangeType) === undefined) + return false; + return ( + message.headers.from !== LOCAL_APPROVAL_FROM || + message.ref?.mailbox !== LOCAL_APPROVAL_MAILBOX + ); +} + +/** + * The app's correlation validator, expressed on the vendored + * `CorrelationValidator` interface the reactor consumes inside tryCorrelate. + * Only provenance is checked here: gate id, liveness and deadline binding + * belong to the reactor, which re-checks them around the validator. A false + * return discards a typed approval without receipts or history, leaving the + * pending operation live for the real operator's decision. + */ +export function createApprovalCorrelationValidator(): CorrelationValidator { + return { + validate: (_pending, message) => + Promise.resolve(!isForeignApprovalIngress(message)), + }; +} + +/** + * Wrap an agent-shaped inbound-delivery surface so typed approvals outside + * the local approval-delivery path are dropped before the reactor can + * correlate them. Dropped forgeries get a warn receipt; nonapproval traffic + * (operator input, completions, continuations) is untouched. + */ +export function createApprovalProvenanceGate< + T extends { deliver: (message: InboundMessage) => void }, +>(inner: T): T { + return { + ...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}`; + return; + } + inner.deliver(message); + }, + }; +} diff --git a/src/session/approval-resume.ts b/src/session/approval-resume.ts index f3d788262..1269b12c2 100644 --- a/src/session/approval-resume.ts +++ b/src/session/approval-resume.ts @@ -32,6 +32,10 @@ import { APPROVAL_TIMEOUT_RESULT_TEXT } from "../permission/decline-markers.js"; import { buildRequests } from "../permission/classify.js"; import type { PermissionGate } from "../permission/gate.js"; import type { PermissionRequest } from "../permission/types.js"; +import { + LOCAL_APPROVAL_FROM, + LOCAL_APPROVAL_MAILBOX, +} from "./approval-provenance.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "approval-resume"]); @@ -355,9 +359,9 @@ function decisionMessage( }; if (message !== undefined && message.length > 0) body.message = message; return { - ref: { uid: 0, mailbox: "approval" }, + ref: { uid: 0, mailbox: LOCAL_APPROVAL_MAILBOX }, headers: { - from: "approval@local", + from: LOCAL_APPROVAL_FROM, to: ["agent@local"], date: new Date().toISOString(), messageId: `approval-${correlationId}`,