Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
sentinel. Spacer-only replies are incomplete and stay on open-task and
workflow rails. Frozen-prefix matching ignores model-emitted copies of the
marker.
- Spawned workers enforce the parent permission gate. Unresolved worker
approvals deny with a reason that names the permission subject so the parent
can grant and retry, without hanging on operator approval.

### Changed

Expand Down
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ tool call
- **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`.
- **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.
- **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` is an execution backstop, not a second copy of `env.authorize`: it consumes the `authorizeCall` verdict only when id, name, and arguments match, and does not re-decide. Deny still blocks and does not call `next`; an `ask` or `allow` skips the middleware prompt so an approved re-dispatch never re-asks. A reused `codex-proxy` id cannot apply an outer `shell` allow to an inner `run_shell` deny. Inner posix runs whose outer tool is not `run_shell` (Codex `apply_patch` proxy) never pass `env.authorize`, so `gateToolCall` decides on that cache miss and still blocks a deny. The headless denial and the stricter chained-command hard-deny are preserved as deny effects (upstream `block`s) decided inside the same `decide()`.
- **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 plugins and inherited MCP tools are bound to that view at worker start, so they take the reactor-gated `gateToolCall` path because the view reports `isReactorGated()` — they do not close over the parent's middleware-gated `isReactorGated()`. Deny still blocks; ask/allow skip the middleware prompt. `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.

- **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).
- **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.
Expand Down
29 changes: 21 additions & 8 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,19 @@ Session runtime state lives under the global projects tree (not in the repo):
- Migration: if a session exists only under in-repo `.agent-state/<session-id>/`, it is moved into the global tree on open/list
- Atomic JSON writes with schema validation on load

**Worker audit persistence.** Workers initialize a real `@intx/storage-isogit`
`AuditStore` at `<worker-workdir>/audit-store` (`src/subagent/run.ts`), separate
from the native context store's Git index. Initialization failure prevents worker
execution. The existing agent-owned audit and error collectors persist at
checkpoint and shutdown; retained worker sessions flush at checkpoint/resume and
close. The parent still supplies `noopAuditStore()`: collectors exist there too,
but the parent does not durably store their records.

Audit storage is not transactional with tool execution. A runtime `commitAudit`
failure after an authorized side effect can lose the drained audit record while
allowing the worker to complete. It emits a reactor error persisted by the existing
error collector; there is no added retry subsystem or side-effect rollback.

`createOptimizedContextStore` (`src/session/optimized-context-store.ts`) wraps the
Interchange git store to keep per-checkpoint cost independent of session length.
Checkpoint commits go through system git and use the operator's global
Expand Down Expand Up @@ -470,15 +483,15 @@ Corbits Code v0.3 memory and stall hardening is implemented under `src/`, `tests

### Bounded audit collector retention between checkpoints

| Field | Detail |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Status** | Not applicable on the default path; deferred until real audit persistence is enabled |
| **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. |
| **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. |
| **Upstream owner** | `@intx/inference` audit collector (`audit-collector` module): opportunistic flush or capped result bodies while preserving metadata. |
| **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. |
Agent-owned audit collectors buffer completed tool results until checkpoint or
shutdown flush, including when a noop store is supplied. Workers use the durable
store described under State Persistence; the parent's noop store does not make
collector retention inapplicable. Long, checkpoint-sparse runs can retain
unbounded results. Bounded retention remains owned by the `@intx/inference`
audit collector: opportunistic flushing or capped result bodies must preserve
metadata.

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.
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.

## Build and Validation

Expand Down
9 changes: 5 additions & 4 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ import {
EXA_MCP_SERVER_NAME,
isBuiltinExaMCPServer,
} from "../mcp/exa.js";
import { mcpClientToAgentTools } from "../mcp/plugin.js";
import { mcpClientTools } from "../mcp/plugin.js";
import { parseMcpToolName } from "../mcp/tool-name.js";
import { gateAgentTools } from "../plugins/permission-plugin.js";
import { createDynamicToolRunner, type DynamicToolRunner } from "../tui/dynamic-tool-runner.js";
import type { MCPServerConfig, Settings } from "../config/settings.js";
import {
Expand Down Expand Up @@ -436,7 +437,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
fleetSessionsForDispose = fleetSessions;
const fleetDeps = {
permissionGate,
inheritMcpTools: () => inheritedMcpTools,
inheritMcpTools: (gate: PermissionGate) => gateAgentTools(inheritedMcpTools, gate),
...(shellTimeout !== undefined ? { shellTimeout } : {}),
...(shellEnv !== undefined ? { shellEnv } : {}),
...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}),
Expand Down Expand Up @@ -836,12 +837,12 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
return;
}
permissionGate.registerMcpClient(result.client);
const mcpTools = mcpClientToAgentTools(result.client, permissionGate, {
const mcpTools = mcpClientTools(result.client, {
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
...(getContextDir !== undefined ? { getContextDir } : {}),
...(isBuiltinExaMCPServer(config) ? { excludeToolNames: ["web_fetch_exa"] } : {}),
});
dynamicRunner.addTools(mcpTools);
dynamicRunner.addTools(gateAgentTools(mcpTools, permissionGate));
inheritedMcpTools.push(...mcpTools);
connectedClients.set(config.name, result.client);
} catch (err) {
Expand Down
67 changes: 35 additions & 32 deletions src/mcp/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AgentTool } from "@intx/agent";
import type { ToolCall, ToolResult } from "@intx/types/runtime";
import type { PermissionGate } from "../permission/gate.js";
import { gateToolCall } from "../plugins/permission-plugin.js";
import { gateAgentTools } from "../plugins/permission-plugin.js";
import { scrubSecretShapedContent } from "../plugins/tool-result-secret-scrub.js";
import {
truncateToolResultContent,
Expand All @@ -27,14 +27,7 @@ function sanitizeMcpResultContent(
return truncateToolResultContent(scrubSecretShapedContent(content), undefined, spill);
}

// Convert a connected client's tools into AgentTools for the dynamic runner used
// by the TUI. These tools live in a separate runner from the posix tool plugin
// chain, so each handler is wrapped with the permission gate directly.
export function mcpClientToAgentTools(
client: MCPClient,
gate: PermissionGate,
spillOptions: McpSpillOptions = {},
): AgentTool[] {
export function mcpClientTools(client: MCPClient, spillOptions: McpSpillOptions = {}): AgentTool[] {
const { getBlobWriter, getContextDir, excludeToolNames = [] } = spillOptions;
const excluded = new Set(excludeToolNames);

Expand All @@ -47,28 +40,38 @@ export function mcpClientToAgentTools(
description: `[${client.serverName}] ${tool.description}`,
inputSchema: tool.inputSchema,
},
handler: (call: ToolCall, signal: AbortSignal): Promise<ToolResult> =>
gateToolCall(gate, call, signal, async () => {
try {
const content = await client.call(tool.name, call.arguments, signal);
const writeBlob = getBlobWriter?.();
const contextDir = getContextDir?.();
const spill =
writeBlob !== undefined
? {
callId: call.id,
writeBlob,
...(contextDir !== undefined ? { contextDir } : {}),
}
: undefined;
return { callId: call.id, content: await sanitizeMcpResultContent(content, spill) };
} catch (err) {
return {
callId: call.id,
content: err instanceof Error ? err.message : String(err),
isError: true,
};
}
}),
handler: async (call: ToolCall, signal: AbortSignal): Promise<ToolResult> => {
try {
const content = await client.call(tool.name, call.arguments, signal);
const writeBlob = getBlobWriter?.();
const contextDir = getContextDir?.();
const spill =
writeBlob !== undefined
? {
callId: call.id,
writeBlob,
...(contextDir !== undefined ? { contextDir } : {}),
}
: undefined;
return { callId: call.id, content: await sanitizeMcpResultContent(content, spill) };
} catch (err) {
return {
callId: call.id,
content: err instanceof Error ? err.message : String(err),
isError: true,
};
}
},
}));
}

// Convert a connected client's tools into AgentTools for the dynamic runner used
// by the TUI. These tools live in a separate runner from the posix tool plugin
// chain, so each handler is wrapped with the permission gate directly.
export function mcpClientToAgentTools(
client: MCPClient,
gate: PermissionGate,
spillOptions: McpSpillOptions = {},
): AgentTool[] {
return gateAgentTools(mcpClientTools(client, spillOptions), gate);
}
3 changes: 3 additions & 0 deletions src/permission/decline-markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ export const APPROVER_REJECTION_MARKER = "denied by approver";

/** vendor/intx-inference reactor.ts — timed-out approval suspension result. */
export const APPROVAL_TIMEOUT_RESULT_TEXT = "approval timed out";

/** Worker unresolved-ask deny — parent grants the named subject and retries. */
export const WORKER_CANNOT_COMPLETE_APPROVAL = "workers cannot complete operator approval.";
Loading
Loading