Skip to content

Commit 6d809a3

Browse files
committed
Enforce inherited permissions for spawned workers
1 parent 6ea5969 commit 6d809a3

9 files changed

Lines changed: 672 additions & 24 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,8 @@ tool call
377377
- **command** — Splits chained commands for security classification and derives command-shape approval scopes. Multi-segment chains only offer an exact-command persist pattern (a prefix like `npm *` must not cover `npm i && rm -rf /` later).
378378
- **auto-shell-policy** — Constrains `run_shell` even when auto mode would otherwise rubber-stamp it. Before matching, `expandShellSubjects` peels `bash`/`sh`/`zsh -c`, `xargs` utility tails, and transparent prefixes (`env`, `nice`, `timeout`, …) so rules see the real payload; an unparseable wrapper (variable expansion or command substitution) sets an opaque flag that forces `ask`. Effects: `deny` blocks outright (file mutations through ad-hoc tooling — output redirection, `tee`, `sed -i`/`perl -i`, interpreter inline programs or heredocs — which must instead go through `write_file`/`edit_file`); `ask` declines to auto-allow and falls through to the operator prompt (recursive `rm`, dependency installs and remote runners: npm/yarn/pnpm/bun, pip, cargo, go, brew, npx/bunx, …, force or uncontained `git worktree` ops, shell that references a sensitive path such as `.env` or a private key, and opaque wrappers). Contained non-force `git worktree add`/`remove`/`prune` and read-only `list` auto-allow (sibling destinations like `../corbits-dispatch-wts/…` included; absolute outside, `~`, globs, and credential basenames still ask). Deny beats ask when multiple subjects match. Quoted spans are stripped before pattern matching so a quoted `>` or install word in an argument is not flagged, and program names are matched only in command position. Adding a table category is a one-line rule append in `AUTO_SHELL_RULES`.
379379
- **gate** — Evaluates a call: `skipPermissions` allows everything; `allow`-tier passes; for `ask`-tier, checks persisted approvals, otherwise requests operator approval. Shell security classifies each chain segment (`||` / `&&` / `|` / `;` / newlines), but the operator is prompted once for the full command block — any unapproved segment fails the whole block, and execution always runs the unsplit original. Safe pipeline tails and pure shell no-ops (`true` / `false` / `:` and bare control-flow keywords stranded by chain-splitting) skip without a prompt. In a non-interactive run an unresolved `ask` becomes a denial. In auto mode: non-shell built-ins in `AUTO_ALLOWED_TOOLS` (writes/edits/deletes, `manage_tasks`, `spawn_agent`, `wait_agents`, …) auto-allow when not path-restricted; for `run_shell` the gate consults the auto-shell policy — a `deny` rule fails the call, an `ask` rule skips the auto-allow shortcut and proceeds to the normal approval flow, and anything unmatched is auto-allowed. Paths outside the workspace and writes under the session state root (`~/.corbits/projects/...` and legacy `.agent-state`) still ask under auto mode. Under `--dangerously-skip-permissions` (forces this process) or `/yolo` (persists as the user-global default via `setSkipPermissions`), the gate auto-allows those same cases, and pre-gate sandboxes (path-escape, shell session cwd retention, `list_dir` / `delete_file` workspace bounds) honor `getSkipPermissions()` live so outside-workspace access is not hard-denied after the gate already allowed it — without rebuilding the plugin stack. Secret-guard path denies and authorization hard blocks still apply. Mutating MCP and unknown built-ins are not blanket-allowed outside skip. Newly granted scopes are appended in memory and persisted.
380-
- **Reactor-gated sessions (main session; `reactorGated: true`).** The gate's decision logic lives in one `decide()` used by both consumers: `evaluate()` (the middleware path below, still used by sub-agents) and `authorizeCall()`, which expresses the decision as the vendored reactor's before-tool authz effect (`src/permission/reactor-authorize.ts` bridges it into `env.authorize`). An `ask` there suspends the call as a reactor `PendingOperation` keyed by a correlationId (persisted through the context store's existing `pendingOperations`); `send()` settles as `suspended` and `src/session/approval-resume.ts` rebuilds the operator request from the approval snapshot, resolves it through the same `requestApproval` seam the TUI overlay uses, and delivers the decision to the reactor on the correlationId signal channel — an approved decision grants a one-shot bypass and the exact parked call re-dispatches; a rejected one answers it with an error result. Under reactor gating the middleware/MCP `gateToolCall` bypasses the gate so an approved re-dispatch never re-asks. The headless denial and the stricter chained-command hard-deny are preserved as deny effects (upstream `block`s) decided inside the same `decide()`.
380+
- **Reactor-gated main session (`reactorGated: true`).** The gate's decision logic lives in one `decide()` used by both consumers: `evaluate()` (the middleware path) and `authorizeCall()`, which expresses the decision as the vendored reactor's before-tool authz effect (`src/permission/reactor-authorize.ts` bridges it into `env.authorize`). An `ask` there suspends the call as a reactor `PendingOperation` keyed by a correlationId (persisted through the context store's existing `pendingOperations`); `send()` settles as `suspended` and `src/session/approval-resume.ts` rebuilds the operator request from the approval snapshot, resolves it through the same `requestApproval` seam the TUI overlay uses, and delivers the decision to the reactor on the correlationId signal channel — an approved decision grants a one-shot bypass and the exact parked call re-dispatches; a rejected one answers it with an error result. Under reactor gating the middleware/MCP `gateToolCall` bypasses the gate so an approved re-dispatch never re-asks. The headless denial and the stricter chained-command hard-deny are preserved as deny effects (upstream `block`s) decided inside the same `decide()`.
381+
- **Worker reactor ownership.** `createWorkerAuthorize` reuses the validated reactor authorization bridge to the parent's live permission gate: grants and policy are shared, not copied or toggled. Authorization and tool execution run under the same async-local worker identity and cwd. A worker-local reactor ownership marker suppresses duplicate middleware evaluation even when the shared parent gate has `reactorGated: false`. Unresolved `ask` decisions become denials without invoking an approval callback or suspending, even with an interactive parent; the parent can obtain a grant and retry. Fleet authority remains an independent restriction, not an alternative permission grant.
381382

382383
- **matcher** — Approval pattern matching via `@intx/authz` `matchPattern` (`*` wildcards). Exact-command grants store a backslash before each metacharacter; those patterns match by equality after unescape (the package has no escape syntax).
383384
- **authz-grants** — Maps stored approvals into `@intx/authz` `GrantRule`s and evaluates them with `evaluateGrants` (allow-only; Corbits cwd/provider-model filters applied first). Exact-escaped grants bypass the package path and use equality.

docs/IMPLEMENTATION.md

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,19 @@ Session runtime state lives under the global projects tree (not in the repo):
392392
- Migration: if a session exists only under in-repo `.agent-state/<session-id>/`, it is moved into the global tree on open/list
393393
- Atomic JSON writes with schema validation on load
394394

395+
**Worker audit persistence.** Workers initialize a real `@intx/storage-isogit`
396+
`AuditStore` at `<worker-workdir>/audit-store` (`src/subagent/run.ts`), separate
397+
from the native context store's Git index. Initialization failure prevents worker
398+
execution. The existing agent-owned audit and error collectors persist at
399+
checkpoint and shutdown; retained worker sessions flush at checkpoint/resume and
400+
close. The parent still supplies `noopAuditStore()`: collectors exist there too,
401+
but the parent does not durably store their records.
402+
403+
Audit storage is not transactional with tool execution. A runtime `commitAudit`
404+
failure after an authorized side effect can lose the drained audit record while
405+
allowing the worker to complete. It emits a reactor error persisted by the existing
406+
error collector; there is no added retry subsystem or side-effect rollback.
407+
395408
`createOptimizedContextStore` (`src/session/optimized-context-store.ts`) wraps the
396409
Interchange git store to keep per-checkpoint cost independent of session length.
397410
Checkpoint commits go through system git and use the operator's global
@@ -464,15 +477,15 @@ Corbits Code v0.3 memory and stall hardening is implemented under `src/`, `tests
464477

465478
### Bounded audit collector retention between checkpoints
466479

467-
| Field | Detail |
468-
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
469-
| **Status** | Not applicable on the default path; deferred until real audit persistence is enabled |
470-
| **Risk** | A live audit collector that buffers full tool results in memory until `flush()` on checkpoint/shutdown can grow without bound on long, checkpoint-sparse runs. |
471-
| **Why Corbits Code-only scope cannot close it** | Production agent setup wires `noopAuditStore()` from `@intx/agent/testing` in `src/tui/runner.ts` and `src/subagent/index.ts`. No `AuditCollector` from `@intx/inference` is instantiated, so bounding `completed` retention in `audit-collector` does not change shipped behavior today. |
472-
| **Upstream owner** | `@intx/inference` audit collector (`audit-collector` module): opportunistic flush or capped result bodies while preserving metadata. |
473-
| **Future Corbits Code work** | If settings later select a persistent audit store, add a bounded wrapper or configuration in `src/` and re-run hardening tests; until then, document the noop path only. |
480+
Agent-owned audit collectors buffer completed tool results until checkpoint or
481+
shutdown flush, including when a noop store is supplied. Workers use the durable
482+
store described under State Persistence; the parent's noop store does not make
483+
collector retention inapplicable. Long, checkpoint-sparse runs can retain
484+
unbounded results. Bounded retention remains owned by the `@intx/inference`
485+
audit collector: opportunistic flushing or capped result bodies must preserve
486+
metadata.
474487

475-
Other wave items (read bounds, shell truncation, process-group kill, grep caps, plugin spawn mitigation, per-tool watchdog, inference retry UX) are implemented or partially mitigated in `src/` with co-located tests; only the two rows above remain upstream or product-gated.
488+
Other wave items (read bounds, shell truncation, process-group kill, grep caps, plugin spawn mitigation, per-tool watchdog, inference retry UX) are implemented or partially mitigated in `src/` with co-located tests; the two items above remain upstream-owned.
476489

477490
## Build and Validation
478491

src/permission/gate.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,9 +296,9 @@ export interface PermissionGateOptions {
296296
telemetry?: Telemetry | undefined;
297297
// This gate's decisions are consumed by the reactor's before-tool authz
298298
// seam (env.authorize) instead of the tool-runner middleware. Set for the
299-
// main session so approved re-dispatches skip the middleware gate; kept
300-
// false for sub-agents, which still gate in the middleware. Required so a
301-
// caller cannot silently fall back to middleware gating by omitting it.
299+
// main session so approved re-dispatches skip the middleware gate. Workers
300+
// own reactor enforcement via their execution identity regardless of this flag.
301+
// Required so callers cannot silently fall back to middleware by omitting it.
302302
reactorGated: boolean;
303303
// Ask/settle event log (see approval-log.ts): one record per consequential
304304
// decision, auto or interactive. Defaults to a no-op so nothing depends on
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { expect, test } from "bun:test";
2+
import { createPermissionGate } from "./gate.js";
3+
import { createReactorAuthorize, createWorkerAuthorize } from "./reactor-authorize.js";
4+
import { runWithSubAgentIdentity, getSubAgentIdentity } from "../subagent/identity-context.js";
5+
import { gateToolCall } from "../plugins/permission-plugin.js";
6+
import type { ToolCall } from "@intx/types/runtime";
7+
8+
const call: ToolCall = {
9+
id: "write-1",
10+
name: "write_file",
11+
arguments: { path: "probe.txt", content: "data" },
12+
};
13+
const gate = () =>
14+
createPermissionGate({
15+
cwd: process.cwd(),
16+
approvals: [],
17+
interactive: true,
18+
auto: false,
19+
skipPermissions: false,
20+
reactorGated: false,
21+
requestApproval: async () => {
22+
throw new Error("worker must never ask");
23+
},
24+
});
25+
26+
test("worker maps unresolved ask to deny while main reactor suspends", async () => {
27+
const policy = gate();
28+
expect((await createReactorAuthorize(policy)("tool:write_file", "invoke", call)).effect).toBe(
29+
"ask",
30+
);
31+
expect((await createWorkerAuthorize(policy)("tool:write_file", "invoke", call)).effect).toBe(
32+
"deny",
33+
);
34+
policy.setAuto(true);
35+
expect((await createWorkerAuthorize(policy)("tool:write_file", "invoke", call)).effect).toBe(
36+
"allow",
37+
);
38+
policy.setAuto(false);
39+
expect((await createWorkerAuthorize(policy)("tool:write_file", "invoke", call)).effect).toBe(
40+
"deny",
41+
);
42+
});
43+
44+
test("worker bridge rejects malformed context, resource, and action", async () => {
45+
const authorize = createWorkerAuthorize(gate());
46+
await expect(authorize("tool:write_file", "invoke", {})).rejects.toThrow("ToolCall");
47+
await expect(authorize("tool:read_file", "invoke", call)).rejects.toThrow("does not match");
48+
await expect(authorize("tool:write_file", "read", call)).rejects.toThrow("unexpected action");
49+
});
50+
51+
test("worker reactor is sole owner even if parent middleware mode changes policy before runner", async () => {
52+
const policy = gate();
53+
policy.setAuto(true);
54+
const identity = { description: "worker", cwd: process.cwd(), reactorOwnsPermissions: true };
55+
const authorize = createWorkerAuthorize(policy);
56+
expect(
57+
(await runWithSubAgentIdentity(identity, () => authorize("tool:write_file", "invoke", call)))
58+
.effect,
59+
).toBe("allow");
60+
policy.setAuto(false);
61+
const result = await runWithSubAgentIdentity(identity, () =>
62+
gateToolCall(policy, call, new AbortController().signal, async () => ({
63+
callId: call.id,
64+
content: "executed",
65+
isError: false,
66+
})),
67+
);
68+
expect(result.isError).toBe(false);
69+
expect(policy.isReactorGated()).toBe(false);
70+
expect(
71+
(await runWithSubAgentIdentity(identity, () => authorize("tool:write_file", "invoke", call)))
72+
.effect,
73+
).toBe("deny");
74+
expect(getSubAgentIdentity()).toBeUndefined();
75+
});
76+
77+
test("concurrent authorization preserves each worker cwd across awaited policy evaluation", async () => {
78+
const policy = gate();
79+
const seen: string[] = [];
80+
const realAuthorize = policy.authorizeCall;
81+
policy.authorizeCall = async (toolCall) => {
82+
await new Promise((resolve) => setTimeout(resolve, 2));
83+
const identity = getSubAgentIdentity();
84+
if (identity === undefined) throw new Error("missing worker identity");
85+
seen.push(identity.cwd);
86+
return realAuthorize(toolCall);
87+
};
88+
const authorize = createWorkerAuthorize(policy);
89+
await Promise.all(
90+
["/worker-a", "/worker-b"].map((cwd) =>
91+
runWithSubAgentIdentity({ description: cwd, cwd, reactorOwnsPermissions: true }, () =>
92+
authorize("tool:write_file", "invoke", call),
93+
),
94+
),
95+
);
96+
expect(seen.sort()).toEqual(["/worker-a", "/worker-b"]);
97+
expect(getSubAgentIdentity()).toBeUndefined();
98+
});

src/permission/reactor-authorize.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { type } from "arktype";
1818

1919
import type { AuthzCallResult } from "@intx/inference";
2020
import type { PermissionGate } from "./gate.js";
21+
import { getSubAgentIdentity } from "../subagent/identity-context.js";
2122

2223
const logger = getLogger([LOG_NAMESPACE_ROOT, "authz"]);
2324

@@ -55,3 +56,13 @@ export function createReactorAuthorize(
5556
}
5657
};
5758
}
59+
60+
export function createWorkerAuthorize(gate: PermissionGate) {
61+
const authorize = createReactorAuthorize(gate);
62+
return async (resource: string, action: string, context: unknown): Promise<AuthzCallResult> => {
63+
const verdict = await authorize(resource, action, context);
64+
if (verdict.effect !== "ask") return verdict;
65+
logger.warn`worker authz denied unresolved approval worker=${getSubAgentIdentity()} resource=${resource}; parent must grant permission and retry`;
66+
return { ...verdict, effect: "deny" };
67+
};
68+
}

src/plugins/permission-plugin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ToolPlugin } from "@intx/tools-posix";
22
import type { ToolCall, ToolResult } from "@intx/types/runtime";
33
import { BLOCKED_BY_POLICY_PREFIX } from "../permission/decline-markers.js";
44
import type { PermissionGate } from "../permission/gate.js";
5+
import { getSubAgentIdentity } from "../subagent/identity-context.js";
56

67
// Run a tool call past the gate, invoking `next` only if it is allowed. Shared by
78
// the posix middleware and the late-connected MCP tools (which are not part of
@@ -18,7 +19,7 @@ export async function gateToolCall(
1819
signal: AbortSignal,
1920
next: (call: ToolCall, signal: AbortSignal) => Promise<ToolResult>,
2021
): Promise<ToolResult> {
21-
if (gate.isReactorGated()) {
22+
if (gate.isReactorGated() || getSubAgentIdentity()?.reactorOwnsPermissions === true) {
2223
return next(call, signal);
2324
}
2425
const verdict = await gate.evaluate(call);

src/subagent/identity-context.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import { AsyncLocalStorage } from "node:async_hooks";
22

3-
// Identifies which sub-agent a tool call belongs to, so the permission gate
4-
// can attribute an approval prompt to the agent that raised it (its dispatch
5-
// description) and the working directory it is operating in. Set once per
6-
// sub-agent around its own tool-call dispatch (see run.ts's toolsFactory) so
7-
// every awaited call within that sub-agent's turn — including the permission
8-
// gate and its operator prompt — can read it back via getSubAgentIdentity().
3+
// Authorization and tool dispatch share this async-local identity so concurrent
4+
// workers resolve relative permission subjects against their own cwd.
95
export interface SubAgentIdentity {
106
description: string;
117
cwd: string;
8+
// Worker reactor authorization must not be repeated by parent middleware.
9+
reactorOwnsPermissions?: boolean;
1210
}
1311

1412
const subAgentIdentityAls = new AsyncLocalStorage<SubAgentIdentity>();

0 commit comments

Comments
 (0)