1- import { resolve , sep } from "node:path" ;
2- import { realpathSync } from "node:fs" ;
31import type { ToolCall } from "@intx/types/runtime" ;
42import type { ApprovalScope , PermissionRequest } from "./types.js" ;
53import { splitChainedCommand , deriveCommandScopes , tokenize , isShellCommentOnly , isShellNoOp } from "./command.js" ;
@@ -10,6 +8,8 @@ import {
108 isSensitivePath ,
119} from "../plugins/secret-guard-plugin.js" ;
1210import { 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
0 commit comments