Skip to content

Commit ecc266c

Browse files
Merge pull request #388 from corbitsdev/cl-5671-unify-workspace-containment
Unify shell auto-allow and path-restriction workspace containment
2 parents 54a40ed + 2ae3e8c commit ecc266c

3 files changed

Lines changed: 133 additions & 30 deletions

File tree

src/permission/classify.ts

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { resolve, sep } from "node:path";
2-
import { realpathSync } from "node:fs";
31
import type { ToolCall } from "@intx/types/runtime";
42
import type { ApprovalScope, PermissionRequest } from "./types.js";
53
import { splitChainedCommand, deriveCommandScopes, tokenize, isShellCommentOnly, isShellNoOp } from "./command.js";
@@ -10,6 +8,8 @@ import {
108
isSensitivePath,
119
} from "../plugins/secret-guard-plugin.js";
1210
import { runShellAuthzBlockReason, runShellAuthzSegmentBlockReason } from "../shell/run-shell-authz.js";
11+
import { resolveWorkspacePath } from "./path-restriction.js";
12+
import type { RootsProvider } from "./worktree-roots.js";
1313

1414
// Read-only tools never need approval as long as they don't touch a restricted
1515
// path; they cannot change the workspace. `lsp` is included here even though
@@ -235,18 +235,15 @@ const EXEC_FLAG = /^(--pre|--pre-glob|--hostname-bin|--search-zip|-z)(=|$)/;
235235
// keys) additionally never auto-allow; the permission gate asks so the operator
236236
// can approve legitimate shell uses (e.g. `--env-file`). Path-keyed secret
237237
// reads remain a hard deny in secret-guard.
238-
function realpathOr(path: string): string {
239-
try {
240-
return realpathSync(path);
241-
} catch {
242-
return path;
243-
}
244-
}
245-
246-
function escapesWorkspace(token: string, realCwd: string): boolean {
238+
// Containment is delegated to path-restriction.ts's resolveWorkspacePath —
239+
// the same authority gate.ts's restriction check uses — so a path inside a
240+
// registered worktree root is never auto-allow-eligible under a stricter (or
241+
// looser) rule than the one that judges it restricted. `rootsProvider`
242+
// defaults to no extra roots, so callers that don't pass one keep exactly
243+
// today's cwd-only behavior.
244+
function escapesWorkspace(token: string, cwd: string, rootsProvider: RootsProvider): boolean {
247245
if (token.startsWith("~")) return true;
248-
const realTarget = realpathOr(resolve(realCwd, token));
249-
return realTarget !== realCwd && !realTarget.startsWith(realCwd + sep);
246+
return resolveWorkspacePath(cwd, token, rootsProvider) === undefined;
250247
}
251248

252249
// grep/rg read a file through a flag value (`--file=PATH`, `-fPATH`), so a path
@@ -261,26 +258,34 @@ function flagPathValue(token: string): string | null {
261258
return glued !== null ? (glued[1] ?? null) : null;
262259
}
263260

264-
function argEscapesWorkspace(token: string, realCwd: string): boolean {
265-
if (!token.startsWith("-")) return escapesWorkspace(token, realCwd);
261+
function argEscapesWorkspace(token: string, cwd: string, rootsProvider: RootsProvider): boolean {
262+
if (!token.startsWith("-")) return escapesWorkspace(token, cwd, rootsProvider);
266263
const value = flagPathValue(token);
267-
return value !== null && value.length > 0 && escapesWorkspace(value, realCwd);
264+
return value !== null && value.length > 0 && escapesWorkspace(value, cwd, rootsProvider);
268265
}
269266

267+
// No-extra-roots default: callers that don't pass a rootsProvider (existing
268+
// tests, callers with no worktree registry) keep exactly today's cwd-only
269+
// containment behavior.
270+
const NO_ROOTS: RootsProvider = () => [];
271+
270272
// Segment-only allowlist check (no authz policy). Used when a pipeline segment is
271273
// judged in isolation — authz applies to the full command string, not each stage.
272-
export function isAutoAllowedShellSegment(segment: string, cwd: string = process.cwd()): boolean {
274+
export function isAutoAllowedShellSegment(
275+
segment: string,
276+
cwd: string = process.cwd(),
277+
rootsProvider: RootsProvider = NO_ROOTS,
278+
): boolean {
273279
const trimmed = segment.trim();
274280
// Empty is not auto-allowed as a "command"; full-line comments and pure shell
275281
// no-ops (true/false/: and bare control-flow keywords) never need approval.
276282
if (trimmed.length === 0) return false;
277283
if (isShellCommentOnly(trimmed) || isShellNoOp(trimmed)) return true;
278284
if (runShellAuthzSegmentBlockReason(trimmed) !== undefined) return false;
279-
const realCwd = realpathOr(cwd);
280-
return isAutoAllowedSegment(segment, realCwd);
285+
return isAutoAllowedSegment(segment, cwd, rootsProvider);
281286
}
282287

283-
function isAutoAllowedSegment(segment: string, realCwd: string): boolean {
288+
function isAutoAllowedSegment(segment: string, cwd: string, rootsProvider: RootsProvider): boolean {
284289
const trimmed = segment.trim();
285290
if (trimmed.length === 0) return false;
286291
if (isShellCommentOnly(trimmed) || isShellNoOp(trimmed)) return true;
@@ -310,13 +315,17 @@ function isAutoAllowedSegment(segment: string, realCwd: string): boolean {
310315
if (args.some((token) => isSensitivePath(token))) return false;
311316
// Pure directory listing may target outside-workspace paths (names only).
312317
// Content readers must stay inside the workspace.
313-
if (!pureListing && args.some((token) => argEscapesWorkspace(token, realCwd))) {
318+
if (!pureListing && args.some((token) => argEscapesWorkspace(token, cwd, rootsProvider))) {
314319
return false;
315320
}
316321
return true;
317322
}
318323

319-
export function isAutoAllowedShellCommand(command: string, cwd: string = process.cwd()): boolean {
324+
export function isAutoAllowedShellCommand(
325+
command: string,
326+
cwd: string = process.cwd(),
327+
rootsProvider: RootsProvider = NO_ROOTS,
328+
): boolean {
320329
const trimmed = command.trim();
321330
if (trimmed.length === 0) return false;
322331
// Single-line full comments and pure shell no-ops are inert.
@@ -332,16 +341,17 @@ export function isAutoAllowedShellCommand(command: string, cwd: string = process
332341
if (DANGEROUS_METACHARACTERS.test(trimmed)) return false;
333342

334343
// Split on pipe and require every segment to be a safe read-only program.
335-
// The workspace realpath is constant across every path token in the command,
336-
// so resolve it once here rather than per token inside escapesWorkspace.
337-
const realCwd = realpathOr(cwd);
338344
const segments = trimmed.split("|");
339-
return segments.every((seg) => isAutoAllowedSegment(seg, realCwd));
345+
return segments.every((seg) => isAutoAllowedSegment(seg, cwd, rootsProvider));
340346
}
341347

342-
export function isAutoAllowedShellCall(call: ToolCall, cwd: string = process.cwd()): boolean {
348+
export function isAutoAllowedShellCall(
349+
call: ToolCall,
350+
cwd: string = process.cwd(),
351+
rootsProvider: RootsProvider = NO_ROOTS,
352+
): boolean {
343353
if (call.name !== "run_shell") return false;
344-
return isAutoAllowedShellCommand(stringArg(call, "command"), cwd);
354+
return isAutoAllowedShellCommand(stringArg(call, "command"), cwd, rootsProvider);
345355
}
346356

347357
// File scopes intentionally stop at the directory level. There is no "every

src/permission/gate.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
346346
if (!restricted && classifyTool(call.name, mcpTiers) === "allow") {
347347
return { allowed: true };
348348
}
349-
if (!restricted && !shellReferencesSecret && isAutoAllowedShellCall(call, effectiveCwd)) {
349+
if (!restricted && !shellReferencesSecret && isAutoAllowedShellCall(call, effectiveCwd, rootsProvider)) {
350350
return { allowed: true };
351351
}
352352
if (auto) {
@@ -452,7 +452,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
452452
}
453453
// Safe pipeline tails (`| sort`) and pure no-ops (`|| true`) skip.
454454
// Containment is judged against the process cwd, not the session cwd.
455-
if (isAutoAllowedShellSegment(segment, effectiveCwd)) {
455+
if (isAutoAllowedShellSegment(segment, effectiveCwd, rootsProvider)) {
456456
continue;
457457
}
458458
needsOperator = true;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { afterEach, beforeEach, expect, test } from "bun:test";
2+
import { mkdir, rm, symlink, writeFile } from "node:fs/promises";
3+
import { realpathSync } from "node:fs";
4+
import { join } from "node:path";
5+
import { tmpdir } from "node:os";
6+
import type { ToolCall } from "@intx/types/runtime";
7+
8+
import { isAutoAllowedShellCall } from "./classify.js";
9+
import { createPathRestriction } from "./path-restriction.js";
10+
11+
let cwd = "";
12+
let worktree = "";
13+
let evilWorktree = "";
14+
let outside = "";
15+
let home = "";
16+
17+
const shellCall = (command: string): ToolCall => ({ id: "c", name: "run_shell", arguments: { command } });
18+
19+
beforeEach(async () => {
20+
const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
21+
cwd = join(tmpdir(), `corbits-containment-${stamp}`);
22+
worktree = join(tmpdir(), `corbits-containment-wt1-${stamp}`);
23+
evilWorktree = join(tmpdir(), `corbits-containment-wt1-${stamp}-evil`);
24+
outside = join(tmpdir(), `corbits-containment-outside-${stamp}`);
25+
home = join(tmpdir(), `corbits-containment-home-${stamp}`);
26+
await mkdir(cwd, { recursive: true });
27+
await mkdir(worktree, { recursive: true });
28+
await mkdir(evilWorktree, { recursive: true });
29+
await mkdir(outside, { recursive: true });
30+
await mkdir(home, { recursive: true });
31+
await mkdir(join(worktree, "sub"), { recursive: true });
32+
});
33+
34+
afterEach(async () => {
35+
await rm(cwd, { recursive: true, force: true });
36+
await rm(worktree, { recursive: true, force: true });
37+
await rm(evilWorktree, { recursive: true, force: true });
38+
await rm(outside, { recursive: true, force: true });
39+
await rm(home, { recursive: true, force: true });
40+
});
41+
42+
test("a path in a registered sibling worktree gets the same verdict from auto-allow and restriction", () => {
43+
const rootsProvider = () => [realpathSync(worktree)];
44+
const target = join(worktree, "sub", "file.txt");
45+
46+
const autoAllowed = isAutoAllowedShellCall(shellCall(`cat ${target}`), cwd, rootsProvider);
47+
const restriction = createPathRestriction(cwd, rootsProvider, home);
48+
const restricted = restriction.isRestricted(target, false);
49+
50+
// The worktree path is inside the workspace boundary: restriction must
51+
// clear it (not restricted), and auto-allow must agree.
52+
expect(restricted).toBe(false);
53+
expect(autoAllowed).toBe(true);
54+
});
55+
56+
test("a path genuinely outside the workspace and its worktrees is refused by both", () => {
57+
const rootsProvider = () => [realpathSync(worktree)];
58+
const target = join(outside, "secret.txt");
59+
60+
const autoAllowed = isAutoAllowedShellCall(shellCall(`cat ${target}`), cwd, rootsProvider);
61+
const restriction = createPathRestriction(cwd, rootsProvider, home);
62+
const restricted = restriction.isRestricted(target, false);
63+
64+
expect(autoAllowed).toBe(false);
65+
expect(restricted).toBe(true);
66+
});
67+
68+
test("a prefix-spoofing sibling directory is refused by both", () => {
69+
const rootsProvider = () => [realpathSync(worktree)];
70+
const target = join(evilWorktree, "file.txt");
71+
72+
const autoAllowed = isAutoAllowedShellCall(shellCall(`cat ${target}`), cwd, rootsProvider);
73+
const restriction = createPathRestriction(cwd, rootsProvider, home);
74+
const restricted = restriction.isRestricted(target, false);
75+
76+
expect(autoAllowed).toBe(false);
77+
expect(restricted).toBe(true);
78+
});
79+
80+
test("a symlink pointing outside the workspace is refused, even for a not-yet-existing target under it", async () => {
81+
const rootsProvider = () => [];
82+
const link = join(cwd, "link");
83+
await symlink(outside, link);
84+
await writeFile(join(outside, "secret.txt"), "s");
85+
const target = join(link, "secret.txt");
86+
87+
const autoAllowed = isAutoAllowedShellCall(shellCall(`cat ${target}`), cwd, rootsProvider);
88+
const restriction = createPathRestriction(cwd, rootsProvider, home);
89+
const restricted = restriction.isRestricted(target, false);
90+
91+
expect(autoAllowed).toBe(false);
92+
expect(restricted).toBe(true);
93+
});

0 commit comments

Comments
 (0)