Skip to content

Commit 14318f1

Browse files
committed
Merge remote-tracking branch 'origin/main' into cl-7303-vendor-the-past-head-intx-packages-step-1-needs-with-ledger
# Conflicts: # docs/VENDORING.md
2 parents cd44cc7 + befd158 commit 14318f1

53 files changed

Lines changed: 1352 additions & 121 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1616
### Changed
1717

1818
- ChatGPT Codex uses the Corbits system prompt as Responses `instructions`, without fetching or injecting the official GPT-5 Codex prompt.
19+
- Main-session permission approvals ride the reactor's approval-suspend primitive: an ask-tier call parks as a PendingOperation and the operator's decision resumes it (previously held open through the middleware gate). Ask denials now surface as `denied by approver: <reason>`, and the model responds to the reason; policy hard-denies remain plain tool errors the model adapts to. Late decisions arriving after the approval timed out are dropped instead of being injected into the conversation, and gate deny reasons are written to the structured authz log.
1920
- Builder now bakes compact Ponytail guidance with default lite mode and uses a smaller native-runtime skill instead of the broad native-integration and TypeScript bodies by default.
2021

2122
## [0.3.17] - 2026-09-05

docs/ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ 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()`.
380381

381382
- **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).
382383
- **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/VENDORING.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,12 @@ the old root export had, so this is an import-path change, not a
205205
behavioral one. The package also picked up two new dependencies
206206
(`@isomorphic-git/lightning-fs`, `buffer`, both used only by the new
207207
`./browser` runtime, which nothing here imports) and a new
208-
`@intx/crypto` dev dependency for its own test suite, pinned to `0.2.2`
208+
`@intx/crypto` dev dependency for its own test suite, pinned to `0.3.0`
209209
the same "stay on published npm for a package we don't vendor" pattern as
210-
the `@intx/log` dependency on the other vendored packages; `@intx/mime`
211-
joined the vendored set in the 2026-09-07 step-1 pass, and its consumers
212-
now resolve it through the root `workspace:*` override.
210+
the `@intx/log` dependency on the other vendored packages, kept aligned
211+
with the root's published `0.3.0` pin so the lockfile never nests duplicate
212+
copies; `@intx/mime` joined the vendored set in the 2026-09-07 step-1 pass,
213+
and its consumers now resolve it through the root `workspace:*` override.
213214

214215
One new upstream test, `browser-bundle.test.ts`, is excluded via
215216
`bunfig.toml`'s `pathIgnorePatterns`. It bundles `browser.ts` with

src/agent/apply-patch-diff.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ async function makeApplyPatch(
3232
approvals: [],
3333
interactive: false,
3434
skipPermissions: options.skipPermissions ?? true,
35+
reactorGated: false,
3536
auto: false,
3637
cwd,
3738
});

src/agent/director.ts

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import { isInternalRecoveryAbortRaw } from "../inference-abort.js";
2121
import { LOG_NAMESPACE_ROOT } from "../branding.js";
2222
import { resolveModelFamilyPolicy, type ModelFamilyPolicy } from "./model-family-policy.js";
2323
import { PRESENT_VIEW_PRIMITIVES_GUIDANCE } from "./tool-schema-normalize.js";
24+
import {
25+
APPROVER_REJECTION_MARKER,
26+
DENIED_BY_POLICY_MARKER,
27+
NO_MATCHING_GRANTS_MARKER,
28+
OPERATOR_DECLINED_MARKER,
29+
} from "../permission/decline-markers.js";
2430

2531
const logger = getLogger([LOG_NAMESPACE_ROOT, "agent", "director"]);
2632

@@ -269,16 +275,52 @@ export const submitOutputDefinition: ToolDefinition = {
269275
},
270276
};
271277

272-
function isOperatorDeclinedToolResult(result: { content: unknown; isError?: boolean }): boolean {
273-
return (
274-
result.isError === true &&
275-
typeof result.content === "string" &&
276-
result.content.includes("Blocked by permission policy: Operator declined:")
277-
);
278+
// Classification of a failed tool call's model-facing text. Two flows produce
279+
// these texts: the middleware path (permission-plugin prefixing the gate's
280+
// "Operator declined: …" reason, still used by sub-agents) and the reactor
281+
// path, where a rejected approval decision answers the parked call with
282+
// upstream's "denied by approver…" error result and a deny/no-grant effect
283+
// arrives as a block ("Denied by policy: …" / "No matching grants for …"). A
284+
// policy deny is not an operator decision at all — the model adapts to the
285+
// deny text as it would to any tool error — while an approver rejection
286+
// either carries a reason the model should respond to or doesn't (canned
287+
// reply stands). The marker strings themselves live in
288+
// permission/decline-markers.ts alongside their producing seams.
289+
type DeclinedToolResult = { kind: "approver-rejection"; reason?: string } | { kind: "policy-deny" };
290+
291+
const POLICY_DENY_MARKERS = [DENIED_BY_POLICY_MARKER, NO_MATCHING_GRANTS_MARKER] as const;
292+
293+
function isPolicyDeny(content: string): boolean {
294+
return POLICY_DENY_MARKERS.some((marker) => content.includes(marker));
278295
}
279296

280-
function operatorDeclinedHasMessage(result: { content: unknown }): boolean {
281-
return typeof result.content === "string" && / .+/.test(result.content);
297+
// The middleware path appends the operator's reason after an em-dash; the
298+
// reactor path after "denied by approver: ". Both are undefined when the
299+
// operator declined without a reason.
300+
function approverRejectionReason(content: string): string | undefined {
301+
const reactor = content.match(new RegExp(`${APPROVER_REJECTION_MARKER}: (.+)`));
302+
if (reactor !== null) return reactor[1];
303+
if (content.includes(OPERATOR_DECLINED_MARKER)) {
304+
const separator = content.indexOf(" — ");
305+
if (separator !== -1) return content.slice(separator + 3);
306+
}
307+
return undefined;
308+
}
309+
310+
function classifyDeclinedToolResult(result: {
311+
content: unknown;
312+
isError?: boolean;
313+
}): DeclinedToolResult | null {
314+
if (result.isError !== true || typeof result.content !== "string") return null;
315+
const content = result.content;
316+
if (isPolicyDeny(content)) return { kind: "policy-deny" };
317+
if (content.includes(APPROVER_REJECTION_MARKER) || content.includes(OPERATOR_DECLINED_MARKER)) {
318+
const reason = approverRejectionReason(content);
319+
return reason === undefined
320+
? { kind: "approver-rejection" }
321+
: { kind: "approver-rejection", reason };
322+
}
323+
return null;
282324
}
283325

284326
const CODE_FILE_EXT =
@@ -715,24 +757,32 @@ class ChatDirectorImpl extends DefaultDirector {
715757
if (!event.result.isError) this.onActivateTools?.(["lsp"]);
716758
}
717759

718-
if (event.type === "tool.done" && isOperatorDeclinedToolResult(event.result)) {
719-
if (operatorDeclinedHasMessage(event.result)) {
720-
return super.decide(event, state, capabilities);
721-
}
722-
if (hasActiveTasks(this.tasks)) {
723-
if (this.declinedTerminationNudges < MAX_DECLINED_OPEN_TASK_NUDGES) {
724-
this.declinedTerminationNudges++;
725-
return [
726-
capabilities.checkpoint("operator-declined"),
727-
inferWithNudge(capabilities, DECLINED_OPEN_TASK_NUDGE),
728-
];
760+
if (event.type === "tool.done") {
761+
const declined = classifyDeclinedToolResult(event.result);
762+
// Reason-less approver rejections are the only canned case: the model
763+
// has no reason to respond to. Reason-bearing approver rejections and
764+
// policy denies re-infer below — the model responds to the reason or
765+
// adapts to the deny text.
766+
if (
767+
declined !== null &&
768+
declined.kind === "approver-rejection" &&
769+
declined.reason === undefined
770+
) {
771+
if (hasActiveTasks(this.tasks)) {
772+
if (this.declinedTerminationNudges < MAX_DECLINED_OPEN_TASK_NUDGES) {
773+
this.declinedTerminationNudges++;
774+
return [
775+
capabilities.checkpoint("operator-declined"),
776+
inferWithNudge(capabilities, DECLINED_OPEN_TASK_NUDGE),
777+
];
778+
}
779+
this.logTerminationWithOpenTasks("operator-declined");
729780
}
730-
this.logTerminationWithOpenTasks("operator-declined");
781+
return [
782+
capabilities.checkpoint("operator-declined"),
783+
capabilities.reply("Tool call rejected by operator."),
784+
];
731785
}
732-
return [
733-
capabilities.checkpoint("operator-declined"),
734-
capabilities.reply("Tool call rejected by operator."),
735-
];
736786
}
737787

738788
// Keep the running local estimate current on every cycle (tool results and

src/agent/exa-web-fetch-alias.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,12 @@ const { resolveMcpServers } = await import("../config/index.js");
9696
const { coreSubAgentWebTools } = await import("../subagent/run.js");
9797

9898
function permissionGate() {
99-
return createPermissionGate({ approvals: [], interactive: false, skipPermissions: true });
99+
return createPermissionGate({
100+
approvals: [],
101+
interactive: false,
102+
skipPermissions: true,
103+
reactorGated: false,
104+
});
100105
}
101106

102107
async function makeToolset(

src/agent/posix-tool-plugins.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe("buildCorePosixToolPlugins", () => {
3939
approvals: [],
4040
interactive: false,
4141
skipPermissions: false,
42+
reactorGated: false,
4243
auto: false,
4344
cwd,
4445
});
@@ -58,6 +59,7 @@ describe("buildCorePosixToolPlugins", () => {
5859
approvals: [],
5960
interactive: false,
6061
skipPermissions: true,
62+
reactorGated: false,
6163
cwd,
6264
});
6365
const allowedRunner = createPosixTools({
@@ -88,6 +90,7 @@ describe("buildCorePosixToolPlugins", () => {
8890
approvals: [],
8991
interactive: false,
9092
skipPermissions: true,
93+
reactorGated: false,
9194
cwd,
9295
});
9396
const runner = createPosixTools({
@@ -116,6 +119,7 @@ describe("buildCorePosixToolPlugins", () => {
116119
approvals: [],
117120
interactive: false,
118121
skipPermissions: false,
122+
reactorGated: false,
119123
auto: true,
120124
cwd,
121125
});
@@ -145,6 +149,7 @@ describe("buildCorePosixToolPlugins", () => {
145149
approvals: [],
146150
interactive: false,
147151
skipPermissions: false,
152+
reactorGated: false,
148153
auto: true,
149154
cwd,
150155
});
@@ -187,6 +192,7 @@ describe("buildCorePosixToolPlugins", () => {
187192
approvals: [],
188193
interactive: false,
189194
skipPermissions: true,
195+
reactorGated: false,
190196
cwd,
191197
});
192198
const runner = createPosixTools({
@@ -234,6 +240,7 @@ describe("buildCorePosixToolPlugins", () => {
234240
approvals: [],
235241
interactive: false,
236242
skipPermissions: true,
243+
reactorGated: false,
237244
cwd,
238245
});
239246
const runner = createPosixTools({
@@ -299,6 +306,7 @@ describe("buildCorePosixToolPlugins", () => {
299306
approvals: [],
300307
interactive: false,
301308
skipPermissions: true,
309+
reactorGated: false,
302310
cwd,
303311
});
304312
const plugins = buildCorePosixToolPlugins({ cwd, permissionGate: gate });
@@ -324,6 +332,7 @@ describe("buildCorePosixToolPlugins", () => {
324332
approvals: [],
325333
interactive: false,
326334
skipPermissions: true,
335+
reactorGated: false,
327336
cwd,
328337
});
329338
const runner = createPosixTools({
@@ -403,6 +412,7 @@ describe("buildCorePosixToolPlugins", () => {
403412
approvals: [],
404413
interactive: false,
405414
skipPermissions: true,
415+
reactorGated: false,
406416
cwd,
407417
});
408418
const runner = createPosixTools({
@@ -455,6 +465,7 @@ describe("buildCorePosixToolPlugins", () => {
455465
approvals: [],
456466
interactive: false,
457467
skipPermissions: true,
468+
reactorGated: false,
458469
cwd: "/tmp",
459470
});
460471
const plugins = buildCorePosixToolPlugins({ cwd: "/tmp", permissionGate: gate });
@@ -511,6 +522,7 @@ describe("buildCorePosixToolPlugins", () => {
511522
approvals: [],
512523
interactive: false,
513524
skipPermissions: true,
525+
reactorGated: false,
514526
cwd: "/tmp",
515527
});
516528
const plugins = buildCorePosixToolPlugins({ cwd: "/tmp", permissionGate: gate });

src/agent/tools-mcp-disconnect.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ const { createAgentToolset } = await import("./tools.js");
6060
const { resolveMcpServers } = await import("../config/index.js");
6161

6262
function permissionGate() {
63-
return createPermissionGate({ approvals: [], interactive: false, skipPermissions: true });
63+
return createPermissionGate({
64+
approvals: [],
65+
interactive: false,
66+
skipPermissions: true,
67+
reactorGated: false,
68+
});
6469
}
6570

6671
async function makeToolset() {

src/director.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,69 @@ describe("operator declined tool calls", () => {
114114
expect(hasDone(actions)).toBe(false);
115115
expect(hasInfer(actions)).toBe(false);
116116
});
117+
118+
// Reactor path: a reason-bearing approver rejection must re-infer so the
119+
// model can respond to the reason — never the canned decline.
120+
test("reason-bearing approver rejection re-infers on the reason", async () => {
121+
const director = createChatDirector("", [], { onTasksChange: () => {} });
122+
const actions = actionsArray(
123+
await director.decide(
124+
makeToolErrorEvent("c", "denied by approver: never touch /etc"),
125+
mockState,
126+
mockCapabilities,
127+
),
128+
);
129+
expect(hasInfer(actions)).toBe(true);
130+
expect(hasDeclineReply(actions)).toBe(false);
131+
expect(hasCheckpoint(actions)).toBe(false);
132+
});
133+
134+
test("middleware rejection with a reason re-infers on the reason", async () => {
135+
const director = createChatDirector("", [], { onTasksChange: () => {} });
136+
const actions = actionsArray(
137+
await director.decide(
138+
makeToolErrorEvent("c", `${declined} — only run it in the build sandbox`),
139+
mockState,
140+
mockCapabilities,
141+
),
142+
);
143+
expect(hasInfer(actions)).toBe(true);
144+
expect(hasDeclineReply(actions)).toBe(false);
145+
expect(hasCheckpoint(actions)).toBe(false);
146+
});
147+
148+
// Reactor path: a reason-less approver rejection has nothing for the model
149+
// to respond to; the canned reply stands.
150+
test("reason-less approver rejection takes the canned path", async () => {
151+
const director = createChatDirector("", [], { onTasksChange: () => {} });
152+
const actions = actionsArray(
153+
await director.decide(
154+
makeToolErrorEvent("c", "denied by approver"),
155+
mockState,
156+
mockCapabilities,
157+
),
158+
);
159+
expect(hasCheckpoint(actions)).toBe(true);
160+
expect(hasDeclineReply(actions)).toBe(true);
161+
expect(hasInfer(actions)).toBe(false);
162+
});
163+
164+
// Policy denies and no-grant blocks are not operator decisions: the model
165+
// adapts to the deny text like any tool error.
166+
test("policy deny is not classified as an operator decline", async () => {
167+
const director = createChatDirector("", [], { onTasksChange: () => {} });
168+
for (const content of [
169+
"Denied by policy: tool:run_shell/invoke",
170+
"No matching grants for tool:run_shell/invoke",
171+
]) {
172+
const actions = actionsArray(
173+
await director.decide(makeToolErrorEvent("c", content), mockState, mockCapabilities),
174+
);
175+
expect(hasInfer(actions)).toBe(true);
176+
expect(hasDeclineReply(actions)).toBe(false);
177+
expect(hasCheckpoint(actions)).toBe(false);
178+
}
179+
});
117180
});
118181

119182
describe("open-task termination guard", () => {
@@ -753,6 +816,7 @@ describe("updateToolDefinitions rewrites infer tools", () => {
753816
approvals: [],
754817
interactive: false,
755818
skipPermissions: true,
819+
reactorGated: false,
756820
}),
757821
onOperatorGate: async () => ({ kind: "cancel" }),
758822
});
@@ -836,6 +900,7 @@ describe("submit_output workflow handler", () => {
836900
approvals: [],
837901
interactive: false,
838902
skipPermissions: true,
903+
reactorGated: false,
839904
}),
840905
onOperatorGate: async () => ({ kind: "cancel" }),
841906
isWorkflowActive: opts.isWorkflowActive,

0 commit comments

Comments
 (0)