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
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ 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, …, 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`, `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.
- **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 under auto mode. Under `--dangerously-skip-permissions`, 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()` so outside-workspace access is not hard-denied after the gate already allowed it. 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.

- **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
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ Providers and credentials are read exclusively from settings files: the global `
| `--model <id>` | provider default | Select a model for the active provider |

| `--force` | false | Override an existing run state |
| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer |
| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain) |
| `--auto` | true (default) | Force auto mode on (workspace writes + unconstrained shell without prompts) |
| `--no-auto` | false | Start with auto mode off (ask on every consequential action); no in-session key toggles it |
| `--no-workflow` | false | Deprecated no-op; workflows are manual slash commands only |
Expand Down
57 changes: 57 additions & 0 deletions src/agent/posix-tool-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,63 @@ describe("buildCorePosixToolPlugins", () => {
}
});

test("skipPermissions allows reading a path outside the workspace", async () => {
const cwd = await mkdtemp(join(tmpdir(), "ic-posix-skip-in-"));
const outside = await mkdtemp(join(tmpdir(), "ic-posix-skip-out-"));
try {
const target = join(outside, "other.txt");
await writeFile(target, "from-other-repo", "utf8");
const gate = createPermissionGate({
approvals: [],
interactive: false,
skipPermissions: true,
cwd,
});
const runner = createPosixTools({
cwd,
plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }),
});
const result = await runner.run(
{ id: "out-1", name: "read_file", arguments: { path: target } },
new AbortController().signal,
);
expect(result.isError).not.toBe(true);
expect(String(result.content)).toContain("from-other-repo");
} finally {
await rm(cwd, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
}
});

test("without skipPermissions, path-escape still blocks outside-workspace reads", async () => {
const cwd = await mkdtemp(join(tmpdir(), "ic-posix-bound-in-"));
const outside = await mkdtemp(join(tmpdir(), "ic-posix-bound-out-"));
try {
const target = join(outside, "secret.txt");
await writeFile(target, "secret", "utf8");
const gate = createPermissionGate({
approvals: [],
interactive: false,
skipPermissions: false,
auto: true,
cwd,
});
const runner = createPosixTools({
cwd,
plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }),
});
const result = await runner.run(
{ id: "bound-1", name: "read_file", arguments: { path: target } },
new AbortController().signal,
);
expect(result.isError).toBe(true);
expect(String(result.content)).toMatch(/escapes working directory/);
} finally {
await rm(cwd, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
}
});

test("reads bounded tool-output spills when session blob reader is wired", async () => {
const cwd = await mkdtemp(join(tmpdir(), "ic-posix-tool-output-"));
try {
Expand Down
10 changes: 7 additions & 3 deletions src/agent/posix-tool-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,20 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
readFileGuard = {},
shellEnv,
} = args;
// Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell
// cwd are not hard-denied after the gate already auto-allows. Secret-guard and
// authz still hard-deny regardless.
const allowOutside = permissionGate.getSkipPermissions();
return [
resultTruncationPlugin(),
toolResultSecretScrubPlugin(),
pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd)),
deleteFilePlugin(cwd),
pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd), { allowOutside }),
deleteFilePlugin(cwd, { allowOutside }),
toolOutputUriPlugin(),
secretGuardPlugin(),
authzPlugin(),
permissionPlugin(permissionGate),
shellGuardPlugin(cwd, shellTimeout, shellEnv),
shellGuardPlugin(cwd, shellTimeout, shellEnv, { allowOutsideCwd: allowOutside }),
readFileGuardPlugin(cwd, readFileGuard),
ripgrepPlugin(cwd),
// Verify wraps the line-range short-circuit (composeMiddleware runs plugins
Expand Down
4 changes: 3 additions & 1 deletion src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
advertiseShellGuardTimeout(tool.definition, shellTimeout?.defaultMs),
),
})),
createListDirTool(cwd),
createListDirTool(cwd, {
allowOutside: permissionGate.getSkipPermissions(),
}),
createUseSkillTool(cwd, skillDirs, args.telemetry),
createWebFetchTool(),
createWebSearchTool(),
Expand Down
18 changes: 18 additions & 0 deletions src/list-dir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,22 @@ describe("listDirectory", () => {
expect(out).toContain("outside the workspace");
expect(out).not.toContain("secret.txt");
});

test("allowOutside lists a path outside the workspace", async () => {
const dir = await fixture();
const outside = await mkdtemp(join(tmpdir(), "list-dir-yolo-"));
await writeFile(join(outside, "other.txt"), "");
const out = await listDirectory(dir, outside, { allowOutside: true });
expect(out.split("\n")).toContain("other.txt");
expect(out).not.toContain("outside the workspace");
});

test("allowOutside follows a symlink that resolves outside the workspace", async () => {
const dir = await fixture();
const outside = await mkdtemp(join(tmpdir(), "list-dir-yolo-link-"));
await writeFile(join(outside, "secret.txt"), "");
await symlink(outside, join(dir, "escape"));
const out = await listDirectory(dir, "escape", { allowOutside: true });
expect(out.split("\n")).toContain("secret.txt");
});
});
5 changes: 5 additions & 0 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ export type PermissionGate = {
// Turn auto mode on or off for the rest of the session. SHIFT+TAB in the TUI
// wires the toggle here so a switch takes effect on the next tool call.
setAuto: (value: boolean) => void;
// Whether --dangerously-skip-permissions is active for this session. Immutable
// after gate construction; pre-gate sandboxes (path-escape, shell cwd bounds)
// consult this so outside-workspace access is not hard-denied under yolo mode.
getSkipPermissions: () => boolean;
// Grant a session-only approval outside the normal ask flow, e.g. when the
// operator already approved a literal command through ask_operator — so the
// matching run_shell call that follows does not prompt a second time. The
Expand Down Expand Up @@ -610,6 +614,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
setAuto: (value: boolean) => {
auto = value;
},
getSkipPermissions: () => skipPermissions,
preApprove,
registerMcpClient,
unregisterMcpServer,
Expand Down
44 changes: 44 additions & 0 deletions src/permission/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,50 @@ describe("createPermissionGate", () => {
expect((await gate.evaluate(shellCall("curl x"))).allowed).toBe(true);
});

test("skipPermissions auto-allows out-of-workspace path tools without asking", async () => {
let asked = 0;
const outside = mkdtempSync(join(tmpdir(), "corbits-skip-outside-"));
const target = join(outside, "other.ts");
writeFileSync(target, "");
const gate = createPermissionGate({
approvals: [],
cwd: process.cwd(),
requestApproval: async () => {
asked++;
return { allow: false };
},
interactive: true,
skipPermissions: true,
});
const verdict = await gate.evaluate({
id: "c",
name: "read_file",
arguments: { path: target },
});
expect(verdict.allowed).toBe(true);
expect(asked).toBe(0);
expect(gate.getSkipPermissions()).toBe(true);
});

test("skipPermissions auto-allows git clone without asking", async () => {
let asked = 0;
const gate = createPermissionGate({
approvals: [],
cwd: process.cwd(),
requestApproval: async () => {
asked++;
return { allow: false };
},
interactive: true,
skipPermissions: true,
});
const verdict = await gate.evaluate(
shellCall("git clone https://example.com/org/repo.git /tmp/repo"),
);
expect(verdict.allowed).toBe(true);
expect(asked).toBe(0);
});

test("non-interactive denies an unapproved consequential call", async () => {
const gate = createPermissionGate({ approvals: [], interactive: false, skipPermissions: false });
const verdict = await gate.evaluate(shellCall("curl x"));
Expand Down
14 changes: 14 additions & 0 deletions src/plugins/delete-file-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ describe("deleteFilePlugin", () => {
await rm(outside, { recursive: true, force: true });
});

test("allowOutside deletes a file outside the working directory", async () => {
const outside = await mkdtemp(join(tmpdir(), "corbits-delete-yolo-"));
const path = join(outside, "gone.txt");
await writeFile(path, "gone");
const tool = deleteFilePlugin(cwd, { allowOutside: true }).tools?.[0];
if (tool === undefined) throw new Error("delete_file tool was not registered");

const result = await tool.handler(call(path), new AbortController().signal);

expect(result).toEqual({ callId: "delete-call", content: `Deleted file: ${path}` });
expect(await exists(path)).toBe(false);
await rm(outside, { recursive: true, force: true });
});

test("permission denial prevents deletion", async () => {
const path = join(cwd, "keep.txt");
await writeFile(path, "keep");
Expand Down
8 changes: 6 additions & 2 deletions src/plugins/delete-file-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ function isWithin(root: string, path: string): boolean {
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
}

export function deleteFilePlugin(cwd: string): ToolPlugin {
export function deleteFilePlugin(
cwd: string,
options: { allowOutside?: boolean } = {},
): ToolPlugin {
const allowOutside = options.allowOutside === true;
const tool: ExtraTool = {
definition: DELETE_FILE_DEFINITION,
handler: async (call: ToolCall): Promise<ToolResult> => {
Expand All @@ -54,7 +58,7 @@ export function deleteFilePlugin(cwd: string): ToolPlugin {
const target = resolve(cwd, args.path);
try {
const [physicalRoot, physicalParent] = await Promise.all([realpath(cwd), realpath(dirname(target))]);
if (!isWithin(physicalRoot, physicalParent)) {
if (!allowOutside && !isWithin(physicalRoot, physicalParent)) {
return errorResult(call.id, `${args.path} resolves outside the working directory`);
}
const info = await lstat(target);
Expand Down
32 changes: 32 additions & 0 deletions src/plugins/path-escape-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,36 @@ describe("pathEscapePlugin", () => {
expect(result.isError).toBe(true);
expect(result.content).toMatch(/escapes working directory/);
});

test("allowOutside passes outside paths through as absolute", async () => {
const plugin = pathEscapePlugin("/project", () => [], { allowOutside: true });
const next = async (call: ToolCall): Promise<ToolResult> => ({
callId: call.id,
content: JSON.stringify(call.arguments),
});
const handler = plugin.middleware ? plugin.middleware(next) : next;
const result = await handler(
makeCall("read_file", { path: "../other-repo/README.md" }),
new AbortController().signal,
);
expect(result.isError).not.toBe(true);
const args = JSON.parse(String(result.content)) as { path: string };
expect(args.path).toBe("/other-repo/README.md");
});

test("allowOutside still leaves in-bounds paths absolute under cwd", async () => {
const plugin = pathEscapePlugin("/project", () => [], { allowOutside: true });
const next = async (call: ToolCall): Promise<ToolResult> => ({
callId: call.id,
content: JSON.stringify(call.arguments),
});
const handler = plugin.middleware ? plugin.middleware(next) : next;
const result = await handler(
makeCall("read_file", { path: "src/index.ts" }),
new AbortController().signal,
);
expect(result.isError).not.toBe(true);
const args = JSON.parse(String(result.content)) as { path: string };
expect(args.path).toBe("/project/src/index.ts");
});
});
Loading
Loading