Skip to content

Commit 950a3e3

Browse files
committed
fix(authz): discard typed approvals from non-local ingress
A correlation ID on the approval rail is a bearer capability: upstream deleted expectedFrom and shipped a validator seam no production layer injected into, so any message carrying a live correlation id re-granted a parked call. Approval delivery is now provenance-authenticated at the app's single inbound funnel, fail-closed on unknown mailboxes, with a forged-message receipt so probes are visible; the same policy ships as a conforming CorrelationValidator for the vendored injection point.
1 parent 4dd0ee0 commit 950a3e3

6 files changed

Lines changed: 386 additions & 3 deletions

File tree

‎docs/VENDORING.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,39 @@ paths) are verbatim copies of upstream — no modifications. A diff against a
253253
pristine upstream checkout at the same commit and paths will show 100%
254254
upstream-authored lines for those eight.
255255

256+
## Bearer correlation IDs and approval provenance
257+
258+
On the approval rail, a correlation ID is a bearer capability. Interchange
259+
removed the `expectedFrom` sender check upstream, so `tryCorrelate`
260+
(`vendor/intx-inference/src/reactor.ts`) resolves any inbound message whose
261+
`interchangeCorrelationId` matches a registered pending operation: a learned
262+
ID plus a forged `approval.granted` would grant the parked call's one-shot
263+
bypass, and a forged `approval.denied` would inject a denial into history.
264+
Upstream's seam for sender authentication is the `CorrelationValidator`
265+
interface (`vendor/intx-inference/src/correlation.ts`), injected through
266+
reactor config — but the vendored `@intx/agent` layer forwards no such
267+
validator into `createReactorAssembly` and its env contract has no field to
268+
carry one, so production wiring happens on the app side.
269+
270+
Corbits Code therefore authenticates approval provenance at its inbound
271+
delivery boundary (`src/session/approval-provenance.ts`, wired in
272+
`src/agent/live-tool-dispatch.ts` around every agent the app constructs):
273+
typed approvals (`approval.granted` / `approval.denied`) are accepted only
274+
from the local approval-delivery path — sender `approval@local` on mailbox
275+
`approval`, the exact provenance `src/session/approval-resume.ts`
276+
`decisionMessage` stamps — and any other ingress (mailbox or system) is
277+
discarded with a warn receipt before the reactor can correlate it. The same
278+
policy is exposed as a `CorrelationValidator` implementation so the vendored
279+
injection point can consume it verbatim (see
280+
`e2e/reactor-approval-acceptance.test.ts`) when a vendor seam to forward it
281+
exists. Decision binding to the gate is untouched and remains in the vendored
282+
reactor: `tryCorrelate`'s `isLiveApproval` requires the pending operation's
283+
gate to still hold the correlation ID and be unexpired, and the local
284+
`reactor-ts-atomic-approval-acceptance` patch keeps a discarded typed
285+
approval from collapsing the pending operation. The two provenance markers
286+
and the policy live in one module so the authentic sender cannot drift from
287+
the allowlist; `decisionMessage` draws its sender/mailbox from that module.
288+
256289
## Notable upstream shape changes carried by the 2026-08-22 sync
257290

258291
Upstream commit `5f798bea` ("Inject the runtime into iso-git storage")

‎e2e/reactor-approval-acceptance.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "../vendor/intx-inference/src/reactor.js";
1515
import { createDefaultDependencies } from "../vendor/intx-inference/src/providers/index.js";
1616
import { createSessionOperationQueue } from "../src/tui/delivery-queue.js";
17+
import { createApprovalCorrelationValidator } from "../src/session/approval-provenance.js";
1718

1819
const call = { id: "parked-call", name: "test_tool", arguments: {} };
1920
const epoch = 100_000;
@@ -204,6 +205,23 @@ function accepted(h: Awaited<ReturnType<typeof harness>>) {
204205
function received(h: Awaited<ReturnType<typeof harness>>) {
205206
return h.events.filter((e) => e.type === "message.received");
206207
}
208+
// The operator decision's provenance markers, exactly as the local
209+
// approval-delivery path (src/session/approval-resume.ts decisionMessage)
210+
// stamps them.
211+
function operatorDecision(
212+
outcome: "approved" | "rejected" = "approved",
213+
): InboundMessage {
214+
const message = createInboundMessage({
215+
from: "approval@local",
216+
to: "agent@local",
217+
correlationId: "approval",
218+
content: JSON.stringify({ outcome }),
219+
ref: { mailbox: "approval" },
220+
});
221+
message.headers.interchangeType =
222+
outcome === "approved" ? "approval.granted" : "approval.denied";
223+
return message;
224+
}
207225
function resumes(h: Awaited<ReturnType<typeof harness>>) {
208226
return h.directed.filter(
209227
(e) => e.type === "resume.execute_tools" || e.type === "resume.tool_result",
@@ -519,3 +537,28 @@ describe("atomic typed approval acceptance", () => {
519537
expect(JSON.stringify(h.snapshots.at(-1)?.turns)).toContain("approved");
520538
});
521539
});
540+
541+
// Foreign-from/mailbox ingress repro (CL-10678): a typed approval whose ID
542+
// matches a live pending operation but whose sender is not the local
543+
// approval-delivery path must be discarded, then a real local decision must
544+
// still succeed against the same pending operation.
545+
describe("approval provenance on typed approvals", () => {
546+
test("foreign ingress with a live correlation id is discarded and the real operator approval still succeeds", async () => {
547+
const h = await harness({
548+
validator: createApprovalCorrelationValidator(),
549+
});
550+
// Forgery probe: valid correlation id, foreign sender, mailbox ingress.
551+
h.reactor.deliver(decision());
552+
await drain();
553+
expectDiscard(h);
554+
expect(resumes(h)).toHaveLength(0);
555+
// The real operator decision must still land after the probe.
556+
h.reactor.deliver(operatorDecision());
557+
await drain();
558+
expect(accepted(h)).toHaveLength(1);
559+
expect(received(h)).toHaveLength(0);
560+
expect(h.grants).toHaveLength(1);
561+
expect(h.tools).toHaveLength(1);
562+
expect(resumes(h)).toHaveLength(1);
563+
});
564+
});

‎src/agent/live-tool-dispatch.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
type BaseEnv,
66
} from "@intx/agent";
77

8+
import { createApprovalProvenanceGate } from "../session/approval-provenance.js";
9+
810
// @intx/agent resolveTools snapshots `byName` from each bundle's definitions
911
// at createAgent and never consults a live getter. MCP tools arrive later via
1012
// DynamicToolRunner.addTools (servers connect after the TUI is up; one
@@ -79,5 +81,10 @@ export function createAgentWithLiveToolDispatch<EnvReq extends BaseEnv>(
7981
def: AgentDefinition<EnvReq>,
8082
env: EnvReq,
8183
): Promise<Agent> {
82-
return withLiveToolDispatchMap(() => createAgent(def, env));
84+
return withLiveToolDispatchMap(async () =>
85+
// The vendored @intx/agent forwards no correlationValidator into the
86+
// reactor assembly, so approval provenance is enforced at the app's
87+
// inbound-delivery boundary (see src/session/approval-provenance.ts).
88+
createApprovalProvenanceGate(await createAgent(def, env)),
89+
);
8390
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { describe, expect, mock, test } from "bun:test";
2+
import { createInboundMessage } from "@intx/mime";
3+
import type { InboundMessage } from "@intx/types/runtime";
4+
import { configureSync, getConfig, resetSync } from "@intx/log";
5+
6+
import {
7+
createApprovalCorrelationValidator,
8+
createApprovalProvenanceGate,
9+
isForeignApprovalIngress,
10+
LOCAL_APPROVAL_FROM,
11+
LOCAL_APPROVAL_MAILBOX,
12+
} from "./approval-provenance.js";
13+
14+
interface MessageInput {
15+
from: string;
16+
mailbox?: string;
17+
}
18+
19+
function approvalMessage(
20+
args: MessageInput = {
21+
from: LOCAL_APPROVAL_FROM,
22+
mailbox: LOCAL_APPROVAL_MAILBOX,
23+
},
24+
outcome: "approved" | "rejected" = "approved",
25+
): InboundMessage {
26+
const message = createInboundMessage({
27+
from: args.from,
28+
to: "agent@local",
29+
correlationId: "corr-1",
30+
content: JSON.stringify({ outcome }),
31+
...(args.mailbox === undefined ? {} : { ref: { mailbox: args.mailbox } }),
32+
});
33+
message.headers.interchangeType =
34+
outcome === "approved" ? "approval.granted" : "approval.denied";
35+
return message;
36+
}
37+
38+
describe("isForeignApprovalIngress", () => {
39+
test("the local approval-delivery path is not foreign", () => {
40+
expect(isForeignApprovalIngress(approvalMessage())).toBe(false);
41+
});
42+
test("a foreign sender is foreign even on the local mailbox", () => {
43+
expect(
44+
isForeignApprovalIngress(
45+
approvalMessage({ from: "watchdog@foreign", mailbox: "approval" }),
46+
),
47+
).toBe(true);
48+
});
49+
test("mailbox ingress is foreign even with the local sender", () => {
50+
expect(
51+
isForeignApprovalIngress(
52+
approvalMessage({ from: LOCAL_APPROVAL_FROM, mailbox: "INBOX" }),
53+
),
54+
).toBe(true);
55+
});
56+
test("a typed approval with no mailbox fails closed as foreign", () => {
57+
// The declared MessageRef always names a mailbox, but the gate must still
58+
// fail closed when a ref arrives without one (upstream transport parses
59+
// external bytes before this type guarantees anything).
60+
const { mailbox: _omitted, ...refWithoutMailbox } = approvalMessage().ref;
61+
const message = {
62+
...approvalMessage(),
63+
ref: refWithoutMailbox,
64+
} as unknown as InboundMessage;
65+
expect(isForeignApprovalIngress(message)).toBe(true);
66+
});
67+
test("a typed approval with no ref at all fails closed as foreign", () => {
68+
// What fail-closed is for: a type-illegal shape the declared ref cannot
69+
// produce must not flip the default to trusted merely by existing.
70+
const message = {
71+
...approvalMessage({ from: "webhook@foreign" }),
72+
ref: undefined,
73+
} as unknown as InboundMessage;
74+
expect(isForeignApprovalIngress(message)).toBe(true);
75+
});
76+
});
77+
78+
describe("createApprovalCorrelationValidator", () => {
79+
test("authenticates only local approval-delivery provenance", async () => {
80+
const validator = createApprovalCorrelationValidator();
81+
expect(await validator.validate({} as never, approvalMessage())).toBe(true);
82+
expect(
83+
await validator.validate(
84+
{} as never,
85+
approvalMessage({ from: "watchdog@foreign", mailbox: "INBOX" }),
86+
),
87+
).toBe(false);
88+
});
89+
test("authenticates foreign ingress for denied decisions too", async () => {
90+
const validator = createApprovalCorrelationValidator();
91+
expect(
92+
await validator.validate(
93+
{} as never,
94+
approvalMessage({ from: "user@local", mailbox: "INBOX" }, "rejected"),
95+
),
96+
).toBe(false);
97+
});
98+
});
99+
100+
describe("createApprovalProvenanceGate", () => {
101+
test("drops a foreign typed approval before the agent can correlate it", () => {
102+
const deliver = mock((_message: InboundMessage): void => undefined);
103+
const gate = createApprovalProvenanceGate({ deliver });
104+
gate.deliver(
105+
approvalMessage({ from: "webhook@foreign", mailbox: "INBOX" }),
106+
);
107+
expect(deliver).not.toHaveBeenCalled();
108+
});
109+
test("passes the local approval-delivery path through unchanged", () => {
110+
const deliver = mock((_message: InboundMessage): void => undefined);
111+
const gate = createApprovalProvenanceGate({ deliver });
112+
const message = approvalMessage();
113+
gate.deliver(message);
114+
expect(deliver).toHaveBeenCalledWith(message);
115+
});
116+
test("leaves nonapproval inbound routing untouched", () => {
117+
const deliver = mock((_message: InboundMessage): void => undefined);
118+
const gate = createApprovalProvenanceGate({ deliver });
119+
const message = createInboundMessage({
120+
from: "user@local",
121+
to: "agent@local",
122+
content: "hello",
123+
});
124+
gate.deliver(message);
125+
expect(deliver).toHaveBeenCalledWith(message);
126+
});
127+
test("a type-illegal ref-less foreign approval is discarded, not thrown", () => {
128+
// The gate must not convert a forge probe into a deliver() throw: the
129+
// receipt path dereferences the same optional ref the policy checked.
130+
const deliver = mock((_message: InboundMessage): void => undefined);
131+
const gate = createApprovalProvenanceGate({ deliver });
132+
const message = {
133+
...approvalMessage({ from: "webhook@foreign" }),
134+
ref: undefined,
135+
} as unknown as InboundMessage;
136+
gate.deliver(message);
137+
expect(deliver).not.toHaveBeenCalled();
138+
});
139+
test("discarded forgeries get a structured receipt naming provenance", () => {
140+
const records: { level: string; text: string }[] = [];
141+
const previous = getConfig();
142+
configureSync({
143+
reset: true,
144+
sinks: {
145+
test: (record) => {
146+
records.push({
147+
level: record.level,
148+
text: record.message.map(String).join(""),
149+
});
150+
},
151+
},
152+
loggers: [
153+
{ category: [], lowestLevel: "warning", sinks: ["test"] },
154+
{
155+
category: ["logtape", "meta"],
156+
lowestLevel: "error",
157+
sinks: ["test"],
158+
},
159+
],
160+
});
161+
try {
162+
const deliver = mock((_message: InboundMessage): void => undefined);
163+
const gate = createApprovalProvenanceGate({ deliver });
164+
gate.deliver(
165+
approvalMessage({
166+
from: "watchdog@foreign",
167+
mailbox: "INBOX",
168+
}),
169+
);
170+
// The same probe, but with the ref missing entirely: the receipt must
171+
// render the missing mailbox as <none> instead of throwing.
172+
gate.deliver({
173+
...approvalMessage({ from: "webhook@foreign" }),
174+
ref: undefined,
175+
} as unknown as InboundMessage);
176+
const receipts = records.filter(
177+
(record) =>
178+
record.level === "warning" &&
179+
record.text.includes("forged approval discarded"),
180+
);
181+
expect(receipts).toHaveLength(2);
182+
expect(receipts[0]?.text).toContain("correlationId=corr-1");
183+
expect(receipts[0]?.text).toContain("from=watchdog@foreign");
184+
expect(receipts[0]?.text).toContain("mailbox=INBOX");
185+
expect(receipts[0]?.text).toContain("type=approval.granted");
186+
expect(receipts[1]?.text).toContain("from=webhook@foreign");
187+
expect(receipts[1]?.text).toContain("mailbox=<none>");
188+
expect(deliver).not.toHaveBeenCalled();
189+
} finally {
190+
if (previous === null) resetSync();
191+
else configureSync({ ...previous, reset: true });
192+
}
193+
});
194+
test("wraps deliver without disturbing the rest of the surface", async () => {
195+
const deliver = mock((_message: InboundMessage): void => undefined);
196+
const send = mock(async (_content: string) => undefined);
197+
const gate = createApprovalProvenanceGate({ deliver, send });
198+
gate.deliver(approvalMessage({ from: "watchdog@foreign" }));
199+
expect(deliver).not.toHaveBeenCalled();
200+
await gate.send("continue");
201+
expect(send).toHaveBeenCalledWith("continue");
202+
});
203+
});

0 commit comments

Comments
 (0)