Skip to content

Commit 62c6f46

Browse files
committed
Gate spawned workers through a reactor-gated permission view
An identity-store ownership flag made the permission plugin worker-aware. A view over the parent gate reports isReactorGated and maps unresolved asks to denials that name the subject.
1 parent 6d809a3 commit 62c6f46

13 files changed

Lines changed: 410 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Fixed
17+
18+
- Spawned workers enforce the parent permission gate. Unresolved worker
19+
approvals deny with a reason that names the permission subject so the parent
20+
can grant and retry, without hanging on operator approval.
21+
1422
## [0.3.18] - 2026-09-08
1523

1624
### Added

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ tool call
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.
380380
- **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.
381+
- **Worker reactor ownership.** `workerPermissionGate` is a reactor-gated view over the parent's live permission gate: grants and policy are shared, not copied or toggled. Worker posix/MCP plugins skip middleware because that view reports `isReactorGated()`. `authorizeCall` on the view never emits `ask` — unresolved approvals become denials that name the permission subject, without invoking an approval callback or suspending, even with an interactive parent; the parent can obtain a grant and retry. Worker control-plane tools (`submit_result`, `ask_director`, and nested fleet verbs other than `spawn_agent`) allow without a parent grant. Authorization and tool execution run under the same async-local worker identity and cwd. Fleet authority remains an independent restriction, not an alternative permission grant.
382382

383383
- **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).
384384
- **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.

src/permission/decline-markers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ export const APPROVER_REJECTION_MARKER = "denied by approver";
3131

3232
/** vendor/intx-inference reactor.ts — timed-out approval suspension result. */
3333
export const APPROVAL_TIMEOUT_RESULT_TEXT = "approval timed out";
34+
35+
/** Worker unresolved-ask deny — parent grants the named subject and retries. */
36+
export const WORKER_CANNOT_COMPLETE_APPROVAL = "workers cannot complete operator approval.";

src/permission/gate.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ export interface PermissionGateOptions {
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
299299
// main session so approved re-dispatches skip the middleware gate. Workers
300-
// own reactor enforcement via their execution identity regardless of this flag.
300+
// receive a reactor-gated view over this same policy (see workerPermissionGate).
301301
// 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
@@ -718,7 +718,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
718718
};
719719

720720
// Middleware path: blocking evaluation used by tool-runner consumers whose
721-
// calls never pass through the reactor (sub-agents, late MCP wrappers).
721+
// calls never pass through the reactor (late MCP wrappers).
722722
// When the gate is reactor-gated this is bypassed entirely — the reactor's
723723
// before-tool authz hook owns the decision (see authorizeCall / gateToolCall).
724724
const evaluate = async (call: ToolCall): Promise<GateVerdict> => {

src/permission/reactor-authorize.test.ts

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
11
import { expect, test } from "bun:test";
22
import { createPermissionGate } from "./gate.js";
3-
import { createReactorAuthorize, createWorkerAuthorize } from "./reactor-authorize.js";
3+
import {
4+
createReactorAuthorize,
5+
createWorkerAuthorize,
6+
workerPermissionGate,
7+
} from "./reactor-authorize.js";
48
import { runWithSubAgentIdentity, getSubAgentIdentity } from "../subagent/identity-context.js";
59
import { gateToolCall } from "../plugins/permission-plugin.js";
10+
import { WORKER_CANNOT_COMPLETE_APPROVAL } from "./decline-markers.js";
611
import type { ToolCall } from "@intx/types/runtime";
712

813
const call: ToolCall = {
914
id: "write-1",
1015
name: "write_file",
1116
arguments: { path: "probe.txt", content: "data" },
1217
};
13-
const gate = () =>
18+
const namedCall = (name: string, args: Record<string, unknown> = {}): ToolCall => ({
19+
id: `${name}-1`,
20+
name,
21+
arguments: args,
22+
});
23+
const gate = (opts?: { interactive?: boolean; auto?: boolean }) =>
1424
createPermissionGate({
1525
cwd: process.cwd(),
1626
approvals: [],
17-
interactive: true,
18-
auto: false,
27+
interactive: opts?.interactive ?? true,
28+
auto: opts?.auto ?? false,
1929
skipPermissions: false,
2030
reactorGated: false,
2131
requestApproval: async () => {
@@ -31,6 +41,14 @@ test("worker maps unresolved ask to deny while main reactor suspends", async ()
3141
expect((await createWorkerAuthorize(policy)("tool:write_file", "invoke", call)).effect).toBe(
3242
"deny",
3343
);
44+
const denied = await workerPermissionGate(policy).authorizeCall(call);
45+
expect(denied.effect).toBe("deny");
46+
if (denied.effect !== "deny") throw new Error("expected deny");
47+
expect(denied.reason).toContain("probe.txt");
48+
expect(denied.reason).toContain(WORKER_CANNOT_COMPLETE_APPROVAL);
49+
const workerDenied = await createWorkerAuthorize(policy)("tool:write_file", "invoke", call);
50+
expect(workerDenied.effect).toBe("deny");
51+
expect(workerDenied.reason).toBe(denied.reason);
3452
policy.setAuto(true);
3553
expect((await createWorkerAuthorize(policy)("tool:write_file", "invoke", call)).effect).toBe(
3654
"allow",
@@ -41,6 +59,54 @@ test("worker maps unresolved ask to deny while main reactor suspends", async ()
4159
);
4260
});
4361

62+
test("leaf worker control-plane tools allow with empty parent approvals", async () => {
63+
for (const mode of [
64+
{ interactive: true, auto: false },
65+
{ interactive: false, auto: true },
66+
] as const) {
67+
const policy = gate(mode);
68+
const authorize = createWorkerAuthorize(policy);
69+
expect(
70+
(await authorize("tool:submit_result", "invoke", namedCall("submit_result"))).effect,
71+
).toBe("allow");
72+
expect((await authorize("tool:ask_director", "invoke", namedCall("ask_director"))).effect).toBe(
73+
"allow",
74+
);
75+
expect((await authorize("tool:wait_agents", "invoke", namedCall("wait_agents"))).effect).toBe(
76+
"allow",
77+
);
78+
}
79+
});
80+
81+
test("nested orchestrator wait_agents allows with only a spawn_agent grant", async () => {
82+
const policy = gate({ interactive: true, auto: false });
83+
policy.setSeededApprovals([{ tool: "spawn_agent", pattern: "*" }]);
84+
const authorize = createWorkerAuthorize(policy);
85+
expect((await authorize("tool:wait_agents", "invoke", namedCall("wait_agents"))).effect).toBe(
86+
"allow",
87+
);
88+
expect((await authorize("tool:list_agents", "invoke", namedCall("list_agents"))).effect).toBe(
89+
"allow",
90+
);
91+
expect((await authorize("tool:spawn_agent", "invoke", namedCall("spawn_agent"))).effect).toBe(
92+
"allow",
93+
);
94+
});
95+
96+
test("worker spawn_agent still needs a parent grant", async () => {
97+
const policy = gate({ interactive: true, auto: false });
98+
expect(
99+
(await createWorkerAuthorize(policy)("tool:spawn_agent", "invoke", namedCall("spawn_agent")))
100+
.effect,
101+
).toBe("deny");
102+
});
103+
104+
test("worker authorizeCall never emits ask", async () => {
105+
const policy = gate({ interactive: true, auto: false });
106+
expect((await workerPermissionGate(policy).authorizeCall(call)).effect).not.toBe("ask");
107+
expect((await policy.authorizeCall(call)).effect).toBe("ask");
108+
});
109+
44110
test("worker bridge rejects malformed context, resource, and action", async () => {
45111
const authorize = createWorkerAuthorize(gate());
46112
await expect(authorize("tool:write_file", "invoke", {})).rejects.toThrow("ToolCall");
@@ -51,22 +117,24 @@ test("worker bridge rejects malformed context, resource, and action", async () =
51117
test("worker reactor is sole owner even if parent middleware mode changes policy before runner", async () => {
52118
const policy = gate();
53119
policy.setAuto(true);
54-
const identity = { description: "worker", cwd: process.cwd(), reactorOwnsPermissions: true };
120+
const identity = { description: "worker", cwd: process.cwd() };
121+
const workerGate = workerPermissionGate(policy);
55122
const authorize = createWorkerAuthorize(policy);
56123
expect(
57124
(await runWithSubAgentIdentity(identity, () => authorize("tool:write_file", "invoke", call)))
58125
.effect,
59126
).toBe("allow");
60127
policy.setAuto(false);
61128
const result = await runWithSubAgentIdentity(identity, () =>
62-
gateToolCall(policy, call, new AbortController().signal, async () => ({
129+
gateToolCall(workerGate, call, new AbortController().signal, async () => ({
63130
callId: call.id,
64131
content: "executed",
65132
isError: false,
66133
})),
67134
);
68135
expect(result.isError).toBe(false);
69136
expect(policy.isReactorGated()).toBe(false);
137+
expect(workerGate.isReactorGated()).toBe(true);
70138
expect(
71139
(await runWithSubAgentIdentity(identity, () => authorize("tool:write_file", "invoke", call)))
72140
.effect,
@@ -88,7 +156,7 @@ test("concurrent authorization preserves each worker cwd across awaited policy e
88156
const authorize = createWorkerAuthorize(policy);
89157
await Promise.all(
90158
["/worker-a", "/worker-b"].map((cwd) =>
91-
runWithSubAgentIdentity({ description: cwd, cwd, reactorOwnsPermissions: true }, () =>
159+
runWithSubAgentIdentity({ description: cwd, cwd }, () =>
92160
authorize("tool:write_file", "invoke", call),
93161
),
94162
),

0 commit comments

Comments
 (0)