Skip to content

Commit aa88595

Browse files
Honor skip-permissions through pre-gate workspace sandboxes (#436)
Under --dangerously-skip-permissions the gate already auto-allows, but path-escape, delete_file, list_dir, and shell cwd retention still hard- denied outside-workspace paths. Thread getSkipPermissions into those sandboxes so yolo mode can reach other repos and retain out-of-tree shell cwd, while secret-guard and authz hard denies stay in force.
1 parent 7ce7609 commit aa88595

18 files changed

Lines changed: 286 additions & 27 deletions

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ tool call
285285
- **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.
286286
- **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).
287287
- **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`.
288-
- **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.
288+
- **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.
289289

290290
- **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).
291291
- **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/IMPLEMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ Providers and credentials are read exclusively from settings files: the global `
282282
| `--model <id>` | provider default | Select a model for the active provider |
283283

284284
| `--force` | false | Override an existing run state |
285-
| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer |
285+
| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain) |
286286
| `--auto` | true (default) | Force auto mode on (workspace writes + unconstrained shell without prompts) |
287287
| `--no-auto` | false | Start with auto mode off (ask on every consequential action); no in-session key toggles it |
288288
| `--no-workflow` | false | Deprecated no-op; workflows are manual slash commands only |

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,63 @@ describe("buildCorePosixToolPlugins", () => {
7878
}
7979
});
8080

81+
test("skipPermissions allows reading a path outside the workspace", async () => {
82+
const cwd = await mkdtemp(join(tmpdir(), "ic-posix-skip-in-"));
83+
const outside = await mkdtemp(join(tmpdir(), "ic-posix-skip-out-"));
84+
try {
85+
const target = join(outside, "other.txt");
86+
await writeFile(target, "from-other-repo", "utf8");
87+
const gate = createPermissionGate({
88+
approvals: [],
89+
interactive: false,
90+
skipPermissions: true,
91+
cwd,
92+
});
93+
const runner = createPosixTools({
94+
cwd,
95+
plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }),
96+
});
97+
const result = await runner.run(
98+
{ id: "out-1", name: "read_file", arguments: { path: target } },
99+
new AbortController().signal,
100+
);
101+
expect(result.isError).not.toBe(true);
102+
expect(String(result.content)).toContain("from-other-repo");
103+
} finally {
104+
await rm(cwd, { recursive: true, force: true });
105+
await rm(outside, { recursive: true, force: true });
106+
}
107+
});
108+
109+
test("without skipPermissions, path-escape still blocks outside-workspace reads", async () => {
110+
const cwd = await mkdtemp(join(tmpdir(), "ic-posix-bound-in-"));
111+
const outside = await mkdtemp(join(tmpdir(), "ic-posix-bound-out-"));
112+
try {
113+
const target = join(outside, "secret.txt");
114+
await writeFile(target, "secret", "utf8");
115+
const gate = createPermissionGate({
116+
approvals: [],
117+
interactive: false,
118+
skipPermissions: false,
119+
auto: true,
120+
cwd,
121+
});
122+
const runner = createPosixTools({
123+
cwd,
124+
plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }),
125+
});
126+
const result = await runner.run(
127+
{ id: "bound-1", name: "read_file", arguments: { path: target } },
128+
new AbortController().signal,
129+
);
130+
expect(result.isError).toBe(true);
131+
expect(String(result.content)).toMatch(/escapes working directory/);
132+
} finally {
133+
await rm(cwd, { recursive: true, force: true });
134+
await rm(outside, { recursive: true, force: true });
135+
}
136+
});
137+
81138
test("reads bounded tool-output spills when session blob reader is wired", async () => {
82139
const cwd = await mkdtemp(join(tmpdir(), "ic-posix-tool-output-"));
83140
try {

src/agent/posix-tool-plugins.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,20 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
6565
readFileGuard = {},
6666
shellEnv,
6767
} = args;
68+
// Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell
69+
// cwd are not hard-denied after the gate already auto-allows. Secret-guard and
70+
// authz still hard-deny regardless.
71+
const allowOutside = permissionGate.getSkipPermissions();
6872
return [
6973
resultTruncationPlugin(),
7074
toolResultSecretScrubPlugin(),
71-
pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd)),
72-
deleteFilePlugin(cwd),
75+
pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd), { allowOutside }),
76+
deleteFilePlugin(cwd, { allowOutside }),
7377
toolOutputUriPlugin(),
7478
secretGuardPlugin(),
7579
authzPlugin(),
7680
permissionPlugin(permissionGate),
77-
shellGuardPlugin(cwd, shellTimeout, shellEnv),
81+
shellGuardPlugin(cwd, shellTimeout, shellEnv, { allowOutsideCwd: allowOutside }),
7882
readFileGuardPlugin(cwd, readFileGuard),
7983
ripgrepPlugin(cwd),
8084
// Verify wraps the line-range short-circuit (composeMiddleware runs plugins

src/agent/tools.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,9 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
213213
advertiseShellGuardTimeout(tool.definition, shellTimeout?.defaultMs),
214214
),
215215
})),
216-
createListDirTool(cwd),
216+
createListDirTool(cwd, {
217+
allowOutside: permissionGate.getSkipPermissions(),
218+
}),
217219
createUseSkillTool(cwd, skillDirs, args.telemetry),
218220
createWebFetchTool(),
219221
createWebSearchTool(),

src/list-dir.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,22 @@ describe("listDirectory", () => {
4545
expect(out).toContain("outside the workspace");
4646
expect(out).not.toContain("secret.txt");
4747
});
48+
49+
test("allowOutside lists a path outside the workspace", async () => {
50+
const dir = await fixture();
51+
const outside = await mkdtemp(join(tmpdir(), "list-dir-yolo-"));
52+
await writeFile(join(outside, "other.txt"), "");
53+
const out = await listDirectory(dir, outside, { allowOutside: true });
54+
expect(out.split("\n")).toContain("other.txt");
55+
expect(out).not.toContain("outside the workspace");
56+
});
57+
58+
test("allowOutside follows a symlink that resolves outside the workspace", async () => {
59+
const dir = await fixture();
60+
const outside = await mkdtemp(join(tmpdir(), "list-dir-yolo-link-"));
61+
await writeFile(join(outside, "secret.txt"), "");
62+
await symlink(outside, join(dir, "escape"));
63+
const out = await listDirectory(dir, "escape", { allowOutside: true });
64+
expect(out.split("\n")).toContain("secret.txt");
65+
});
4866
});

src/permission/gate.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,10 @@ export type PermissionGate = {
256256
// Turn auto mode on or off for the rest of the session. SHIFT+TAB in the TUI
257257
// wires the toggle here so a switch takes effect on the next tool call.
258258
setAuto: (value: boolean) => void;
259+
// Whether --dangerously-skip-permissions is active for this session. Immutable
260+
// after gate construction; pre-gate sandboxes (path-escape, shell cwd bounds)
261+
// consult this so outside-workspace access is not hard-denied under yolo mode.
262+
getSkipPermissions: () => boolean;
259263
// Grant a session-only approval outside the normal ask flow, e.g. when the
260264
// operator already approved a literal command through ask_operator — so the
261265
// matching run_shell call that follows does not prompt a second time. The
@@ -610,6 +614,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
610614
setAuto: (value: boolean) => {
611615
auto = value;
612616
},
617+
getSkipPermissions: () => skipPermissions,
613618
preApprove,
614619
registerMcpClient,
615620
unregisterMcpServer,

src/permission/permission.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,50 @@ describe("createPermissionGate", () => {
814814
expect((await gate.evaluate(shellCall("curl x"))).allowed).toBe(true);
815815
});
816816

817+
test("skipPermissions auto-allows out-of-workspace path tools without asking", async () => {
818+
let asked = 0;
819+
const outside = mkdtempSync(join(tmpdir(), "corbits-skip-outside-"));
820+
const target = join(outside, "other.ts");
821+
writeFileSync(target, "");
822+
const gate = createPermissionGate({
823+
approvals: [],
824+
cwd: process.cwd(),
825+
requestApproval: async () => {
826+
asked++;
827+
return { allow: false };
828+
},
829+
interactive: true,
830+
skipPermissions: true,
831+
});
832+
const verdict = await gate.evaluate({
833+
id: "c",
834+
name: "read_file",
835+
arguments: { path: target },
836+
});
837+
expect(verdict.allowed).toBe(true);
838+
expect(asked).toBe(0);
839+
expect(gate.getSkipPermissions()).toBe(true);
840+
});
841+
842+
test("skipPermissions auto-allows git clone without asking", async () => {
843+
let asked = 0;
844+
const gate = createPermissionGate({
845+
approvals: [],
846+
cwd: process.cwd(),
847+
requestApproval: async () => {
848+
asked++;
849+
return { allow: false };
850+
},
851+
interactive: true,
852+
skipPermissions: true,
853+
});
854+
const verdict = await gate.evaluate(
855+
shellCall("git clone https://example.com/org/repo.git /tmp/repo"),
856+
);
857+
expect(verdict.allowed).toBe(true);
858+
expect(asked).toBe(0);
859+
});
860+
817861
test("non-interactive denies an unapproved consequential call", async () => {
818862
const gate = createPermissionGate({ approvals: [], interactive: false, skipPermissions: false });
819863
const verdict = await gate.evaluate(shellCall("curl x"));

src/plugins/delete-file-plugin.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,20 @@ describe("deleteFilePlugin", () => {
9595
await rm(outside, { recursive: true, force: true });
9696
});
9797

98+
test("allowOutside deletes a file outside the working directory", async () => {
99+
const outside = await mkdtemp(join(tmpdir(), "corbits-delete-yolo-"));
100+
const path = join(outside, "gone.txt");
101+
await writeFile(path, "gone");
102+
const tool = deleteFilePlugin(cwd, { allowOutside: true }).tools?.[0];
103+
if (tool === undefined) throw new Error("delete_file tool was not registered");
104+
105+
const result = await tool.handler(call(path), new AbortController().signal);
106+
107+
expect(result).toEqual({ callId: "delete-call", content: `Deleted file: ${path}` });
108+
expect(await exists(path)).toBe(false);
109+
await rm(outside, { recursive: true, force: true });
110+
});
111+
98112
test("permission denial prevents deletion", async () => {
99113
const path = join(cwd, "keep.txt");
100114
await writeFile(path, "keep");

src/plugins/delete-file-plugin.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ function isWithin(root: string, path: string): boolean {
4242
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
4343
}
4444

45-
export function deleteFilePlugin(cwd: string): ToolPlugin {
45+
export function deleteFilePlugin(
46+
cwd: string,
47+
options: { allowOutside?: boolean } = {},
48+
): ToolPlugin {
49+
const allowOutside = options.allowOutside === true;
4650
const tool: ExtraTool = {
4751
definition: DELETE_FILE_DEFINITION,
4852
handler: async (call: ToolCall): Promise<ToolResult> => {
@@ -54,7 +58,7 @@ export function deleteFilePlugin(cwd: string): ToolPlugin {
5458
const target = resolve(cwd, args.path);
5559
try {
5660
const [physicalRoot, physicalParent] = await Promise.all([realpath(cwd), realpath(dirname(target))]);
57-
if (!isWithin(physicalRoot, physicalParent)) {
61+
if (!allowOutside && !isWithin(physicalRoot, physicalParent)) {
5862
return errorResult(call.id, `${args.path} resolves outside the working directory`);
5963
}
6064
const info = await lstat(target);

0 commit comments

Comments
 (0)