@@ -203,7 +203,9 @@ export function createExtraDeniedPathMatcher(
203203// Path-keyed tools stay hard-denied below.
204204//
205205// RESIDUAL THREAT MODEL: shell detection is best-effort. Token matching defeats
206- // quoting/escaping and the common env-assignment and redirection forms, but not
206+ // quoting/escaping, the common env-assignment and redirection forms, and direct
207+ // variable references — `$VAR`, `${VAR}`, `${VAR:-default}`, and `~` expand
208+ // against process.env before matching, so `cat $HOME/.env` prompts — but not
207209// dynamic construction of a path the matcher never sees as one token — e.g.
208210// indirection through an unrelated variable (`F=.en; cat ${F}v`), character-by-
209211// character assembly (`printf`), or reading via an interpreter that builds the
@@ -218,20 +220,18 @@ export function createExtraDeniedPathMatcher(
218220export const PURE_DIRECTORY_LISTING_PROGRAMS = new Set ( [ "ls" , "tree" ] ) ;
219221
220222// Worth spending a realpath on: shaped like a path the shell could open
221- // (a slash, an extension dot, or absolute), not a flag, variable , or fd
223+ // (a slash, an extension dot, or absolute), not a flag, glob , or fd
222224// number — those can never name a file the shell opens, so they skip the
223- // stat and the hot auto-allow path stays syscall-free for them. Globs are
224- // skipped here for a different reason: the matcher only sees the unexpanded
225- // pattern, so `cat *.txt` cannot resolve without running the shell — but a
226- // glob CAN expand into a symlink at runtime, which stays a stated residual
227- // (see the threat model below), not something this filter disproves.
225+ // stat and the hot auto-allow path stays syscall-free for them. Shell
226+ // variables reach here already expanded (see expandShellToken), so there is
227+ // no `$` exemption: an unexpandable token fails closed before this filter.
228+ // Globs are skipped here for a different reason: the matcher only sees the
229+ // unexpanded pattern, so `cat *.txt` cannot resolve without running the
230+ // shell — but a glob CAN expand into a symlink at runtime, which stays a
231+ // stated residual (see the threat model below), not something this filter
232+ // disproves.
228233function isPathLikeShellToken ( token : string ) : boolean {
229- if (
230- token . startsWith ( "-" ) ||
231- token . includes ( "$" ) ||
232- token . includes ( "*" ) ||
233- token . includes ( "`" )
234- )
234+ if ( token . startsWith ( "-" ) || token . includes ( "*" ) || token . includes ( "`" ) )
235235 return false ;
236236 return (
237237 isAbsolute ( token ) ||
@@ -252,16 +252,105 @@ export function expandHome(token: string): string {
252252 return token ;
253253}
254254
255+ // Expand a shell token's `~` and `$` references against process.env only —
256+ // never shells out. Handles `$VAR`, `${VAR}`, `${VAR:-default}` /
257+ // `${VAR-default}`, and a single layer of surrounding quotes; `\$` is a
258+ // literal dollar and unset variables expand to empty. A `$` followed by any
259+ // other character (or at end of token) is a literal dollar, matching shell
260+ // behavior for `$.`, `$"`, and friends. A backtick or `$(` the tokenizer left
261+ // whole comes from single quotes, where the shell never substitutes — it is
262+ // matched as literal text. Returns expandable=false only when the token
263+ // cannot be resolved statically: a malformed `${…}` or an unsupported
264+ // operator (`:=`, `:?`, `:+`, `#`, `%`, `/`). Callers fail closed on
265+ // expandable=false: the shell would compute the value at runtime, so the
266+ // matcher must assume the worst.
267+ export interface ExpandedShellToken {
268+ expanded : string ;
269+ expandable : boolean ;
270+ }
271+
272+ const SHELL_VAR_NAME = / ^ [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * / ;
273+ const SHELL_BRACED_VAR = / ^ ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * ) ( : - ( .* ) | - ( .* ) | ) $ / s;
274+
275+ export function expandShellToken (
276+ token : string ,
277+ dialect : ShellDialect = "posix" ,
278+ ) : ExpandedShellToken {
279+ let text = token ;
280+ if (
281+ text . length >= 2 &&
282+ ( ( text . startsWith ( '"' ) && text . endsWith ( '"' ) ) ||
283+ ( text . startsWith ( "'" ) && text . endsWith ( "'" ) ) )
284+ ) {
285+ text = text . slice ( 1 , - 1 ) ;
286+ }
287+ if ( text . includes ( "`" ) || text . includes ( "$(" ) ) {
288+ return { expanded : text , expandable : true } ;
289+ }
290+ if ( text === "~" ) text = homedir ( ) ;
291+ else if ( text . startsWith ( "~/" ) ) text = joinPath ( homedir ( ) , text . slice ( 2 ) ) ;
292+ // In cmd `$` is literal (`type .flaskenv::$DATA` names the default ADS
293+ // stream — there is no `$VAR` expansion, only `%VAR%`), so expanding would
294+ // corrupt the token before matching. Only posix-style dialects expand.
295+ if ( dialect === "cmd" ) return { expanded : text , expandable : true } ;
296+ let expanded = "" ;
297+ for ( let i = 0 ; i < text . length ; ) {
298+ const char = text [ i ] ?? "" ;
299+ if ( char === "\\" && text [ i + 1 ] === "$" ) {
300+ expanded += "$" ;
301+ i += 2 ;
302+ continue ;
303+ }
304+ if ( char !== "$" ) {
305+ expanded += char ;
306+ i += 1 ;
307+ continue ;
308+ }
309+ const rest = text . slice ( i + 1 ) ;
310+ if ( rest . startsWith ( "{" ) ) {
311+ const close = text . indexOf ( "}" , i + 2 ) ;
312+ if ( close === - 1 ) return { expanded : token , expandable : false } ;
313+ const match = SHELL_BRACED_VAR . exec ( text . slice ( i + 2 , close ) ) ;
314+ if ( match === null ) return { expanded : token , expandable : false } ;
315+ const value = process . env [ match [ 1 ] ?? "" ] ;
316+ const fallback = match [ 3 ] ?? match [ 4 ] ;
317+ if ( fallback === undefined ) {
318+ expanded += value ?? "" ;
319+ } else if (
320+ value === undefined ||
321+ ( match [ 3 ] !== undefined && value === "" )
322+ ) {
323+ const inner = expandShellToken ( fallback , dialect ) ;
324+ if ( ! inner . expandable ) return { expanded : token , expandable : false } ;
325+ expanded += inner . expanded ;
326+ } else {
327+ expanded += value ;
328+ }
329+ i = close + 1 ;
330+ continue ;
331+ }
332+ const name = SHELL_VAR_NAME . exec ( rest ) ?. [ 0 ] ;
333+ if ( name !== undefined ) {
334+ expanded += process . env [ name ] ?? "" ;
335+ i += 1 + name . length ;
336+ continue ;
337+ }
338+ expanded += "$" ;
339+ i += 1 ;
340+ }
341+ return { expanded, expandable : true } ;
342+ }
343+
255344// A bare token the shell could open as a cwd-relative file: not a flag,
256- // variable, glob, or command substitution — same exclusions as the path-like
345+ // glob, or command substitution — same exclusions as the path-like
257346// filter, minus the dot/slash shape requirement, so extensionless names
258347// (`notes`, or `notes` split out of `--file=notes` / `cat -n notes`) still
259- // get an existence probe below.
348+ // get an existence probe below. Shell variables reach here already expanded,
349+ // so there is no `$` exemption (see expandShellToken).
260350function isBareProbeCandidate ( token : string ) : boolean {
261351 return (
262352 token . length > 0 &&
263353 ! token . startsWith ( "-" ) &&
264- ! token . includes ( "$" ) &&
265354 ! token . includes ( "*" ) &&
266355 ! token . includes ( "`" )
267356 ) ;
@@ -277,11 +366,12 @@ function isBareProbeCandidate(token: string): boolean {
277366// tokens first pay a single lstat existence probe against the cwd-resolved
278367// path — a miss (the common `cat Makefile` case) costs exactly that one
279368// lstat and skips the resolve, a hit (file or symlink, dangling included)
280- // pays the realpath and matches on the target. Flags, variables, globs, and
369+ // pays the realpath and matches on the target. Flags, globs, and
281370// backticks never probe, so the worst case per command is one lstat per bare
282371// token plus one realpath per existing entry. Relative tokens resolve
283- // against cwd first because the helper takes absolute paths; `~` expands to
284- // the home directory before resolving for the same reason. That cwd is the
372+ // against cwd first because the helper takes absolute paths; `~` and
373+ // `$VAR`/`${VAR}` expand against process.env before resolving (see
374+ // expandShellToken) for the same reason. That cwd is the
285375// session/process cwd, not a `cd` prefix inside the command —
286376// `cd sub && cat notes.txt` resolves `notes.txt` against the session cwd
287377// (absent) rather than cwd/sub (present). The chain still fails closed
@@ -303,7 +393,8 @@ export function isSensitiveShellToken(
303393 isExtraDenied : ( value : string ) => boolean = ( ) => false ,
304394 dialect : ShellDialect = nativeShellDialect ( process . platform ) ,
305395) : boolean {
306- const expanded = expandHome ( token ) ;
396+ const { expanded, expandable } = expandShellToken ( token , dialect ) ;
397+ if ( ! expandable ) return true ;
307398 if ( isSensitivePath ( expanded , dialect ) ) return true ;
308399 if ( isExtraDenied ( expanded ) ) return true ;
309400 if ( ! resolveSymlinks ) {
0 commit comments