Skip to content

Commit 310f671

Browse files
committed
Merge remote-tracking branch 'origin/main' into cl-10658-pin-bump-bundle-types-bun-oxlintoxfmt-hono-drizzle-safe
# Conflicts: # bun.lock
2 parents 857ece7 + 80907e9 commit 310f671

35 files changed

Lines changed: 2343 additions & 147 deletions

‎bun.lock‎

Lines changed: 18 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/IMPLEMENTATION.md‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,22 @@ Provider and model configuration lives in JSON settings files. The global file h
278278
- `timeoutMs` — default bound when foreground `run_shell` omits `timeout`. Unset keeps the built-in 120000ms default. A positive per-call `timeout` is the bound with no ceiling.
279279
- `maxTimeoutMs` — clamps that default path only; it does not clamp a per-call override.
280280

281+
Optional `shell.sandbox` opts shell spawns and stdio MCP servers into an OS-level sandbox via `@anthropic-ai/sandbox-runtime` (path sandboxing on macOS `sandbox-exec`, bubblewrap on Linux). **Default off**; with the sandbox disabled the spawn paths run exactly as before, byte-identical:
282+
283+
```json
284+
"shell": {
285+
"sandbox": {
286+
"enabled": true,
287+
"network": { "allowedHosts": ["api.github.com", "*.example.com"] }
288+
}
289+
}
290+
```
291+
292+
- `enabled` — default **false**. When true, foreground + background `run_shell`, stdio MCP server launches, and lifecycle hooks (discovered under the sandbox-writable session `.corbits/hooks`) wrap into the sandbox: writes are allowed only inside the workspace, the OS tmpdir, and `/tmp/claude`; reads deny the secret-guard denylist classes (`.env*`, `.ssh/`, `.aws/`, key material, credential stores, browser cookie stores, shell history, ...) plus the active `--config` settings path; known provider API-key env vars are unset inside the sandbox. A sandboxed command that cannot be wrapped (missing/broken runtime) **fails closed** — the command never runs — with a `shell sandbox is enabled but unavailable` error; disable the setting to restore raw spawns. Linux additionally needs bubblewrap + socat + ripgrep installed.
293+
- `network.allowedHosts` — operator pre-approved domain allowlist (supports `*.wildcards`). Every other host asks through the normal permission gate (auto mode and yolo auto-allow it as they would a `web_fetch`; yolo relaxes the _asks_, never the sandbox itself).
294+
295+
One sandboxed composition per process: sandbox-runtime's manager is a process-global singleton, so a second `shell.sandbox` composition with a different workspace/allowlist while one is live fails loudly instead of inheriting it. Matching compositions share the singleton by refcount; the last session's teardown resets it.
296+
281297
Optional `tools` block to arm the outer per-tool wall-clock budget (unset leaves the watchdog unarmed):
282298

283299
```json
@@ -313,14 +329,16 @@ Provider and model configuration lives in JSON settings files. The global file h
313329

314330
All `tools.*` keys live in the global settings file only — there is no per-repo override in `.corbits/settings.json` (unlike `sessionMode`).
315331

316-
| Key | Default | Effect |
317-
| ----------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
318-
| `tools.timeoutMs` | unset (watchdog unarmed) | Outer wall-clock budget per tool `run()` when set |
319-
| `tools.maxTimeoutMs` | unset | Cap on the outer budget when set; does not cap a longer requested `run_shell` |
320-
| `tools.waitForApproval` | `true` | Freeze the budget while a permission prompt is open (freeze capped at 30 min); `false` keeps the clock ticking and auto-dismisses the prompt on expiry |
321-
| `mcp.timeoutMs` | **300000** (5 min) — armed even when unset | Outer wall-clock budget for `mcp__*` tool calls specifically; capped by `tools.maxTimeoutMs` when set |
322-
| `shell.timeoutMs` | **120000** (2 min) foreground default | Foreground `run_shell` bound when the call omits `timeout`; background has no default; a per-call `timeout` is the bound with no ceiling |
323-
| `shell.maxTimeoutMs` | unset | Clamps the foreground default path only; does not clamp a per-call `run_shell` timeout |
332+
| Key | Default | Effect |
333+
| ------------------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
334+
| `tools.timeoutMs` | unset (watchdog unarmed) | Outer wall-clock budget per tool `run()` when set |
335+
| `tools.maxTimeoutMs` | unset | Cap on the outer budget when set; does not cap a longer requested `run_shell` |
336+
| `tools.waitForApproval` | `true` | Freeze the budget while a permission prompt is open (freeze capped at 30 min); `false` keeps the clock ticking and auto-dismisses the prompt on expiry |
337+
| `mcp.timeoutMs` | **300000** (5 min) — armed even when unset | Outer wall-clock budget for `mcp__*` tool calls specifically; capped by `tools.maxTimeoutMs` when set |
338+
| `shell.timeoutMs` | **120000** (2 min) foreground default | Foreground `run_shell` bound when the call omits `timeout`; background has no default; a per-call `timeout` is the bound with no ceiling |
339+
| `shell.maxTimeoutMs` | unset | Clamps the foreground default path only; does not clamp a per-call `run_shell` timeout |
340+
| `shell.sandbox.enabled` | `false` (opt-in, fail closed on runtime errors) | OS sandbox for `run_shell` and stdio MCP launches via `@anthropic-ai/sandbox-runtime` (workspace+tmp writes, secret denylist reads) |
341+
| `shell.sandbox.network.allowedHosts` | `[]` (every other host asks) | Operator pre-approved host allowlist; unmatched hosts ask through the permission gate — yolo auto-allows the ask, the OS sandbox stays on |
324342

325343
The `waitForApproval` default is resolved once at the watchdog boundary (`resolveWaitForApproval`); toggling **Settings → Tools** updates the live config for the next tool call and persists the value here.
326344

‎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+
});

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@
8282
"hono": "^4.11.9",
8383
"isomorphic-git": "^1.27.2",
8484
"semver": "^7.7.2",
85-
"ssri": "^12.0.0",
8685
"tar": "^7.5.1"
8786
},
8887
"dependencies": {
88+
"@anthropic-ai/sandbox-runtime": "0.0.79",
8989
"@corbits/codex-provider": "0.1.1",
9090
"@corbits/oauth-core": "0.2.0",
9191
"@corbits/openai-responses": "0.2.1",

‎src/agent/compaction.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,21 @@ describe("handoff arming (/handoff)", () => {
12021202
).toBeNull();
12031203
});
12041204

1205+
test("a failed handoff compact does not schedule the post-compact infer", () => {
1206+
const governor = createCompactionGovernor(undefined);
1207+
governor.syncFromTurns(tenTurns);
1208+
expect(governor.requestHandoff("now do the UI audit")).toBe("armed");
1209+
const actions = governor.interceptIdleContinuation(
1210+
pivot("now do the UI audit"),
1211+
capabilities,
1212+
);
1213+
expect(actions?.some((a) => a.type === "compact")).toBe(true);
1214+
expect(governor.hasOutstandingContinuation()).toBe(true);
1215+
governor.noteKeptPrior();
1216+
expect(governor.hasOutstandingContinuation()).toBe(false);
1217+
expect(governor.resumeAfterCompact(emptyMessage())).toBeNull();
1218+
});
1219+
12051220
test("instructions stay sticky for the summary after the fold fires", () => {
12061221
const governor = createCompactionGovernor(undefined);
12071222
governor.syncFromTurns(tenTurns);

0 commit comments

Comments
 (0)