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: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ Corbits Code defaults to **auto mode** (`auto = true`). Workspace file writes/ed
- Git worktree boundary changes (`add` / `remove` / `prune`; read-only `git worktree list` is fine)
- Shell that references sensitive paths (`.env`, private keys, certs, credential files, …)
- Opaque shell wrappers the policy cannot statically inspect (variable expansion or command substitution in a wrapper payload)
- Paths outside the workspace, writes under `.agent-state`, mutating MCP tools, and unknown built-ins
- Paths outside the workspace, writes under the session state root, mutating MCP tools, and unknown built-ins


### What auto hard-denies (use the file tools instead)

Expand Down
16 changes: 10 additions & 6 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ The director returns actions that shape the loop:

- `capabilities.continue()` — run another inference turn (implicit default).
- `capabilities.reply(text)` — inject a synthetic tool result into the next turn's context.
- `capabilities.checkpoint(label)` — persist a named checkpoint to `.agent-state/`.
- `capabilities.checkpoint(label)` — persist a named checkpoint under the session state root (`~/.corbits/projects/...`).

- `capabilities.done()` — terminate the loop.

### Director-layer termination
Expand Down Expand Up @@ -148,7 +149,8 @@ Workflows are named, ordered recipes the agent follows step by step — a thin l

- `types.ts` — `Workflow`, `WorkflowStep` (`prompt`, `capability`, `agent`, `skill`, `workflow` sub-workflow ref, `optional`, `parallel`, `type: "gate"`), and the `WorkflowState` persistence shape. `MAX_WORKFLOW_DEPTH` bounds nesting.
- `capabilities.ts` — `detectCapabilities` maps the live tool surface to abstract capabilities (`ticket-tracker`, `code-host`, `doc-search`) by name pattern; `resolveStep` decides whether a step runs. A capability override set forces integrations off per run. Adding a capability is a data edit, not a logic change.
- `runtime.ts` — `WorkflowRuntime` drives execution on a call stack: it skips capability-unsatisfied steps, descends into sub-workflow references, emits step lifecycle events, and snapshots `WorkflowState`. `state.ts` persists that snapshot atomically to `.agent-state/workflow.json` for resume.
- `runtime.ts` — `WorkflowRuntime` drives execution on a call stack: it skips capability-unsatisfied steps, descends into sub-workflow references, emits step lifecycle events, and snapshots `WorkflowState`. `state.ts` persists that snapshot atomically to `workflow.json` under the session state root for resume.

- `coordinator.ts` — bridges runtime and director: produces the `[WORKFLOW STEP i/total: label]` directive injected into each turn's system prompt, and advances the runtime when `advance_workflow` (or a `submit_output` tagged `{ step }`) completes. Shared by both directors.
- The built-in recipes: the atomics `update-ticket`, `improve-docs`, `write-tests`, `triage-bug`, `code-review`, `scope-project`, and the `build-feature` composite that chains them.

Expand Down Expand Up @@ -196,8 +198,8 @@ The agent's identity is **Corbits Code**, framed as a senior coding assistant ru
### State Persistence (`src/session/state.ts`)

- `RunState` — `running` | `done` | `failed`, turns used, task, timestamps, error
- Atomic JSON save/load to `.agent-state/run.json`, with schema validation on load
- Conversation context is persisted separately by the git-backed store under `.agent-state/context`
- Atomic JSON save/load to `run.json` under the session state root (`~/.corbits/projects/<project-key>/<session-id>/`), with schema validation on load
- Conversation context is persisted separately by the git-backed store under that session's `context/` directory

### Lifecycle Hooks (`src/session/hooks.ts`)

Expand Down Expand Up @@ -249,7 +251,8 @@ tool call
- **classify** — Read-only tools (`read_file`, `search_files`, `grep`, `list_dir`) are tier `allow`; everything else is tier `ask`. Builds approval requests: shell yields one request for the full command the model asked to run (security still splits under the gate); file tools keyed on the target path; other tools keyed on tool name.
- **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).
- **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, …, git worktree add/remove/prune, shell that references a sensitive path such as `.env` or a private key, and opaque wrappers). 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`, `task`, …) 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 `.agent-state` still ask. Mutating MCP and unknown built-ins are not blanket-allowed. Newly granted scopes are appended in memory and persisted.
- **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`, `task`, …) 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. Mutating MCP and unknown built-ins are not blanket-allowed. Newly granted scopes are appended in memory and persisted.

- **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.
- **store** — Loads/persists approvals scoped to the working directory (Corbits JSON layout; not the package GrantStore).
Expand Down Expand Up @@ -367,7 +370,8 @@ CLI argv
↓ gates / errors
[blocked] → operator resolves → [running]
↓ fatal inference/reactor error
[failed] (TUI may surface and allow retry; context persists under .agent-state/)
[failed] (TUI may surface and allow retry; context persists under the session state root)

```

There is no post-submit `build`/`typecheck`/`test` critique step in the current tree; validation is operator- and hook-driven (`postTurn`/`postRun`) plus explicit `run_shell` during agent work.
Expand Down
15 changes: 11 additions & 4 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOL
| **deny** | Shell file mutation (redirects, `tee`, in-place stream editors, interpreter `-c`/`-e`/heredoc) |
| **ask** | Dependency installs / remote runners, recursive `rm`, git worktree add/remove/prune, sensitive-path references, paths outside the workspace (including through a symlink), opaque unparseable wrappers |

Unmatched shell auto-allows. Writes under `.agent-state`, mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode.
Unmatched shell auto-allows. Writes under the session state root (`~/.corbits/projects/<project-key>/…`, and legacy in-repo `.agent-state` during dual-read), mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode.


Plan approval is handled separately by `use-gates` (`pendingPlan`), independent of auto mode.

Expand Down Expand Up @@ -292,7 +293,8 @@ Positional arguments are joined into the optional initial task delivered when th

### Agent Source

`createAgent` is configured with a single OpenAI-compatible source built from the resolved config, `defaults.maxTokens = 16384`, and a git-backed `contextDir` at `.agent-state/context`.
`createAgent` is configured with a single OpenAI-compatible source built from the resolved config, `defaults.maxTokens = 16384`, and a git-backed `contextDir` at `~/.corbits/projects/<project-key>/<session-id>/context`.


## Protocols and Formats

Expand All @@ -303,8 +305,13 @@ Positional arguments are joined into the optional initial task delivered when th

### State Persistence

- `.agent-state/run.json` — `RunState`
- `.agent-state/context/` — git-backed conversation context (`@intx/storage-isogit`)
Session runtime state lives under the global projects tree (not in the repo):

- `~/.corbits/projects/<project-key>/<session-id>/run.json` — `RunState`
- `~/.corbits/projects/<project-key>/<session-id>/context/` — git-backed conversation context (`@intx/storage-isogit`)
- Project key: slug + short hash of the shared git root (from `--git-common-dir`, so main + linked worktrees share one key; workspace realpath when not a git tree)

- 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

`createOptimizedContextStore` (`src/session/optimized-context-store.ts`) wraps the
Expand Down
6 changes: 4 additions & 2 deletions docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ Continues from the last saved state in the working directory.
- **Denied** (must use `write_file` / `edit_file`): shell file mutations via output redirection, `tee`, `sed -i` / `perl -i`, interpreter inline programs or heredocs.
- **Still asks**: dependency installs and remote runners (npm/yarn/pnpm/bun, pip, cargo, go, brew, `npx`/`bunx`, …), recursive `rm`, git worktree add/remove/prune (list is fine), shell that references sensitive paths, and opaque unparseable wrappers (variable expansion or command substitution).
- **Wrapper peel**: `bash`/`sh`/`zsh -c`, `xargs`, and transparent prefixes (`env`, `nice`, `timeout`, …) are expanded so the same deny/ask rules see the inner payload.
- Paths outside the workspace and writes under `.agent-state` still ask; mutating MCP and unknown tools still prompt.
- Paths outside the workspace and writes under the session state root still ask; mutating MCP and unknown tools still prompt.

- **Path sandboxing** — Tool path arguments are resolved against the working directory; paths that escape it are blocked.
- **Write verification** — After every write/edit the file is re-read and compared to confirm the change actually landed.

Expand All @@ -90,7 +91,8 @@ Config-driven `postTurn` and `postRun` hooks (TypeScript or shell) run automatic

**What the user sees:** The agent stops producing tool calls. After 3 idle turns the run aborts with `Agent stalled: no tool calls for 3 turns.`

**Recovery:** State is saved; inspect `.agent-state/run.json`, adjust the task or prompt, and start a new run.
**Recovery:** State is saved; inspect `~/.corbits/projects/<project-key>/<session-id>/run.json` (or a legacy in-repo `.agent-state/` tree if not yet migrated), adjust the task or prompt, and start a new run.


### Permission denied (exec)

Expand Down
8 changes: 4 additions & 4 deletions docs/perftrace-attribution-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ import { snapshot } from "../src/perf/index.js";
import { dumpSpans } from "../src/perf/dump.js";

const path = await dumpSpans(snapshot(), {
dir: ".agent-state/<sessionId>",
dir: "~/.corbits/projects/<project-key>/<sessionId>",
sessionId: "<sessionId>",
});
// → .agent-state/<sessionId>/perftrace-<sessionId>.json
// → ~/.corbits/projects/<project-key>/<sessionId>/perftrace-<sessionId>.json
```

The dump is privacy-strict (allowlisted tags only). Safe to keep offline or
Expand All @@ -61,13 +61,13 @@ share with teammates without prompts/paths.
From a local dump file alone:

```bash
bun scripts/perf-report.ts .agent-state/<sessionId>/perftrace-<sessionId>.json
bun scripts/perf-report.ts ~/.corbits/projects/<project-key>/<sessionId>/perftrace-<sessionId>.json
```

Machine-readable JSON:

```bash
bun scripts/perf-report.ts --json .agent-state/<sessionId>/perftrace-<sessionId>.json
bun scripts/perf-report.ts --json ~/.corbits/projects/<project-key>/<sessionId>/perftrace-<sessionId>.json
```

Golden multi-tool demo (no dump file needed — uses
Expand Down
6 changes: 4 additions & 2 deletions src/permission/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { runShellAuthzBlockReason, runShellAuthzSegmentBlockReason } from "../sh
const READ_ONLY_TOOLS = new Set(["read_file", "search_files", "grep", "list_dir", "lsp"]);

// Tools that take a single path-like argument the gate should check against
// restriction (outside the workspace boundary, or writes under .agent-state).
// restriction (outside the workspace boundary, or writes under the session state root).

// Covers both read-only tools (dropped from allow to ask) and the mutating
// file tools (dropped from auto-allow to ask in auto mode).
const PATH_ARG_TOOLS = new Set(["read_file", "search_files", "grep", "list_dir", "lsp", "write_file", "edit_file", "delete_file"]);
Expand All @@ -32,7 +33,8 @@ function pathArgKey(toolName: string): string {

// write_file/edit_file/delete_file mutate the target; every other path-arg tool only
// reads it. Restriction policy (see path-restriction.ts) treats reads and
// writes of an .agent-state path differently, so callers need to tell the
// writes of a session-state path differently, so callers need to tell the

// gate which mode a given tool call is in.
function isWriteTool(toolName: string): boolean {
return toolName === "write_file" || toolName === "edit_file" || toolName === "delete_file";
Expand Down
3 changes: 2 additions & 1 deletion src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd;
const isRestrictedHere = bindRestrictedToProcessCwd(isRestricted, effectiveCwd);
// A call targeting a restricted path (outside the workspace, or a write
// under .agent-state) drops from allow to ask, so it never auto-allows on
// under the session state root) drops from allow to ask, so it never auto-allows on

// tier or shell-safety below.
const restricted = callTargetsRestricted(call, isRestrictedHere);
const shellCmd =
Expand Down
49 changes: 49 additions & 0 deletions src/permission/path-restriction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";

import { createPathRestriction } from "./path-restriction.js";
import { projectSessionsRoot } from "../session/project-key.js";

let cwd = "";
let home = "";

beforeEach(async () => {
const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
cwd = join(tmpdir(), `corbits-path-rest-${stamp}`);
home = join(tmpdir(), `corbits-path-rest-home-${stamp}`);
await mkdir(cwd, { recursive: true });
await mkdir(home, { recursive: true });
});

afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
});

test("legacy .agent-state: reads allow, writes restricted", () => {
const r = createPathRestriction(cwd, () => [], home);
expect(r.isRestricted(".agent-state/run.json", false)).toBe(false);
expect(r.isRestricted(".agent-state/run.json", true)).toBe(true);
});

test("global projects session root: reads allow, writes restricted", () => {
const r = createPathRestriction(cwd, () => [], home);
const globalRun = join(projectSessionsRoot(cwd, home), "sess-1", "run.json");
expect(r.isRestricted(globalRun, false)).toBe(false);
expect(r.isRestricted(globalRun, true)).toBe(true);
});

test("other paths under home remain outside-workspace restricted", () => {
const r = createPathRestriction(cwd, () => [], home);
const other = join(home, ".corbits", "settings.json");
expect(r.isRestricted(other, false)).toBe(true);
expect(r.isRestricted(other, true)).toBe(true);
});

test("workspace-relative paths are unrestricted", () => {
const r = createPathRestriction(cwd, () => [], home);
expect(r.isRestricted("src/index.ts", false)).toBe(false);
expect(r.isRestricted("src/index.ts", true)).toBe(false);
});
Loading
Loading