1- import { isAbsolute , resolve as resolvePath } from "node:path" ;
1+ import { lstatSync } from "node:fs" ;
2+ import { homedir } from "node:os" ;
3+ import {
4+ isAbsolute ,
5+ join as joinPath ,
6+ resolve as resolvePath ,
7+ } from "node:path" ;
28import type { ToolPlugin } from "@intx/tools-posix" ;
39import {
410 realpathNearestOr ,
@@ -132,19 +138,24 @@ function shellPathTokens(command: string): string[] {
132138// dynamic construction of a path the matcher never sees as one token — e.g.
133139// indirection through an unrelated variable (`F=.en; cat ${F}v`), character-by-
134140// character assembly (`printf`), or reading via an interpreter that builds the
135- // name at runtime. Perfect shell sandboxing is out of scope; the goal is to
136- // force a prompt for the trivial, single-token references that make exfiltration
137- // easy. Tool-result secret scrub still redacts credential-shaped output.
141+ // name at runtime. Unexpanded globs are the same class: `cat *` can open a
142+ // symlink the matcher only ever saw as `*`. Perfect shell sandboxing is out
143+ // of scope; the goal is to force a prompt for the trivial, single-token
144+ // references that make exfiltration easy. Tool-result secret scrub still redacts credential-shaped output.
138145// Programs that only print directory names / metadata — listing a name never
139146// dumps file contents. Single owner for this set: the resolve-leg skip below
140147// and classify.ts's pure-listing exemption both read it, so a new names-only
141148// program cannot drift into one list without the other.
142149export const PURE_DIRECTORY_LISTING_PROGRAMS = new Set ( [ "ls" , "tree" ] ) ;
143150
144151// Worth spending a realpath on: shaped like a path the shell could open
145- // (a slash, an extension dot, or absolute), not a flag, variable, glob, or
146- // fd number — those can never resolve into a secret file, so they skip the
147- // stat and the hot auto-allow path stays syscall-free for them.
152+ // (a slash, an extension dot, or absolute), not a flag, variable, or fd
153+ // number — those can never name a file the shell opens, so they skip the
154+ // stat and the hot auto-allow path stays syscall-free for them. Globs are
155+ // skipped here for a different reason: the matcher only sees the unexpanded
156+ // pattern, so `cat *.txt` cannot resolve without running the shell — but a
157+ // glob CAN expand into a symlink at runtime, which stays a stated residual
158+ // (see the threat model below), not something this filter disproves.
148159function isPathLikeShellToken ( token : string ) : boolean {
149160 if (
150161 token . startsWith ( "-" ) ||
@@ -161,13 +172,52 @@ function isPathLikeShellToken(token: string): boolean {
161172 ) ;
162173}
163174
175+ // `~` / `~/…` mean the operator's home to the shell, not a literal
176+ // cwd-relative name — expand before both matcher legs so `cat ~/notes`
177+ // resolves the home symlink instead of a (usually missing) cwd child.
178+ // classify.ts's outside-workspace rule would ask anyway; the expansion fixes
179+ // the *reason* (sensitive-path) rather than relying on that coincidence.
180+ function expandHome ( token : string ) : string {
181+ if ( token === "~" ) return homedir ( ) ;
182+ if ( token . startsWith ( "~/" ) ) return joinPath ( homedir ( ) , token . slice ( 2 ) ) ;
183+ return token ;
184+ }
185+
186+ // A bare token the shell could open as a cwd-relative file: not a flag,
187+ // variable, glob, or command substitution — same exclusions as the path-like
188+ // filter, minus the dot/slash shape requirement, so extensionless names
189+ // (`notes`, or `notes` split out of `--file=notes` / `cat -n notes`) still
190+ // get an existence probe below.
191+ function isBareProbeCandidate ( token : string ) : boolean {
192+ return (
193+ token . length > 0 &&
194+ ! token . startsWith ( "-" ) &&
195+ ! token . includes ( "$" ) &&
196+ ! token . includes ( "*" ) &&
197+ ! token . includes ( "`" )
198+ ) ;
199+ }
200+
164201// CL-7790: the ONE shell-token matcher both secret-guard call sites share —
165202// commandReferencesSensitivePath below and classify.ts's per-arg sensitive
166203// check. The cheap lexical denylist runs first so the hot auto-allow path
167- // never touches the filesystem; only path-like survivors pay for a realpath
168- // via the CL-6971 helper, which catches a benign-named symlink into a secret
169- // file (notes.txt -> .env) exactly like the secret name itself. Relative
170- // tokens resolve against cwd first because the helper takes absolute paths.
204+ // never touches the filesystem; only survivors pay for filesystem access, in
205+ // two bounded tiers: path-like tokens pay for a realpath via the CL-6971
206+ // helper, which catches a benign-named symlink into a secret file (notes.txt
207+ // -> .env) exactly like the secret name itself, while bare extensionless
208+ // tokens first pay a single lstat existence probe against the cwd-resolved
209+ // path — a miss (the common `cat Makefile` case) costs exactly that one
210+ // lstat and skips the resolve, a hit (file or symlink, dangling included)
211+ // pays the realpath and matches on the target. Flags, variables, globs, and
212+ // backticks never probe, so the worst case per command is one lstat per bare
213+ // token plus one realpath per existing entry. Relative tokens resolve
214+ // against cwd first because the helper takes absolute paths; `~` expands to
215+ // the home directory before resolving for the same reason. That cwd is the
216+ // session/process cwd, not a `cd` prefix inside the command —
217+ // `cd sub && cat notes.txt` resolves `notes.txt` against the session cwd
218+ // (absent) rather than cwd/sub (present). The chain still fails closed
219+ // because `cd` is not a safe program, but no secret reason fires;
220+ // per-segment `cd` modeling is deliberately out of scope.
171221// Pass resolveSymlinks=false for pure name-listings: listing a name is not
172222// dumping its contents (CL-5420), so `ls notes.txt` still lists freely while
173223// `cat notes.txt` asks.
@@ -176,10 +226,21 @@ export function isSensitiveShellToken(
176226 cwd : string = process . cwd ( ) ,
177227 resolveSymlinks = true ,
178228) : boolean {
179- if ( isSensitivePath ( token ) ) return true ;
180- if ( ! resolveSymlinks || ! isPathLikeShellToken ( token ) ) return false ;
181- if ( isAbsolute ( token ) ) return isSensitivePathResolved ( token ) ;
182- return isSensitivePathResolved ( resolvePath ( cwd , token ) ) ;
229+ const expanded = expandHome ( token ) ;
230+ if ( isSensitivePath ( expanded ) ) return true ;
231+ if ( ! resolveSymlinks ) return false ;
232+ if ( isPathLikeShellToken ( expanded ) ) {
233+ if ( isAbsolute ( expanded ) ) return isSensitivePathResolved ( expanded ) ;
234+ return isSensitivePathResolved ( resolvePath ( cwd , expanded ) ) ;
235+ }
236+ if ( ! isBareProbeCandidate ( expanded ) ) return false ;
237+ const abs = isAbsolute ( expanded ) ? expanded : resolvePath ( cwd , expanded ) ;
238+ try {
239+ lstatSync ( abs ) ;
240+ } catch {
241+ return false ;
242+ }
243+ return isSensitivePathResolved ( abs ) ;
183244}
184245
185246export function commandReferencesSensitivePath (
0 commit comments