@@ -209,10 +209,17 @@ export function createExtraDeniedPathMatcher(
209209// dynamic construction of a path the matcher never sees as one token — e.g.
210210// indirection through an unrelated variable (`F=.en; cat ${F}v`), character-by-
211211// character assembly (`printf`), or reading via an interpreter that builds the
212- // name at runtime. Unexpanded globs are the same class: `cat *` can open a
213- // symlink the matcher only ever saw as `*`. Perfect shell sandboxing is out
214- // of scope; the goal is to force a prompt for the trivial, single-token
215- // references that make exfiltration easy. Tool-result secret scrub still redacts credential-shaped output.
212+ // name at runtime. Unexpanded globs are narrowed, not closed: `?`/`[` prompt
213+ // only on file-operand-shaped tokens — URLs (`…?q=…`), regex operands
214+ // (`grep -E colou?r`), and bare `[`/`]` test syntax are exempt, and a pattern
215+ // with no `.`, `/`, or `\` cannot match a dotfile secret anyway — while `*`
216+ // prompts only when dotfile-rooted (`.*`, `.env*`) or lexically sensitive
217+ // (`*.pem`). What stays allowed, and why: bare `*` / `*.txt` cannot match a
218+ // leading dot and would fire on every benign `cat *`; non-dotfile-rooted `*`
219+ // (`.config/*`) and dotless-secret `?`/`[` forms (`id_rs?`) are already
220+ // reachable through bare `*`, so closing them alone buys nothing. Perfect
221+ // shell sandboxing is out of scope; the goal is to force a prompt for the
222+ // trivial, single-token references that make exfiltration easy. Tool-result secret scrub still redacts credential-shaped output.
216223// Programs that only print directory names / metadata — listing a name never
217224// dumps file contents. Single owner for this set: the resolve-leg skip below
218225// and classify.ts's pure-listing exemption both read it, so a new names-only
@@ -229,9 +236,13 @@ export const PURE_DIRECTORY_LISTING_PROGRAMS = new Set(["ls", "tree"]);
229236// unexpanded pattern, so `cat *.txt` cannot resolve without running the
230237// shell — but a glob CAN expand into a symlink at runtime, which stays a
231238// stated residual (see the threat model above), not something this filter
232- // disproves. `?` and `[…]` patterns are not skipped: `cat .en?` reads `.env`
233- // while the matcher only ever sees the pattern, so they fail closed to a
234- // prompt in isSensitiveShellToken instead of resolving here.
239+ // disproves. The one exception lives in isSensitiveShellToken: dotfile-rooted
240+ // `*` patterns (`.*`, `.env*`) deterministically match `.env`, so they fail
241+ // closed to a prompt there. `?` and `[…]` patterns are likewise not skipped:
242+ // `cat .en?` reads `.env` while the matcher only ever sees the pattern, so
243+ // file-operand-shaped ones fail closed to a prompt in isSensitiveShellToken
244+ // instead of resolving here (URLs, regex operands, and bare `[`/`]` test
245+ // syntax are exempt — see that check).
235246function isPathLikeShellToken ( token : string ) : boolean {
236247 if ( token . startsWith ( "-" ) || token . includes ( "*" ) || token . includes ( "`" ) )
237248 return false ;
@@ -370,6 +381,16 @@ function isBareProbeCandidate(token: string): boolean {
370381 ) ;
371382}
372383
384+ // Final path segment starts with a literal dot and holds a `*`: `.*`,
385+ // `.env*`, `sub/.*`. Bare `*` / `*.txt` never match a leading dot under
386+ // default shell semantics, so they stay out — as does anything rooted outside
387+ // a dotfile name (`.config/*`).
388+ function isDotfileRootedGlob ( token : string ) : boolean {
389+ if ( ! token . includes ( "*" ) ) return false ;
390+ const segment = token . split ( / [ / \\ ] / ) . at ( - 1 ) ?? token ;
391+ return segment . startsWith ( "." ) && segment . includes ( "*" ) ;
392+ }
393+
373394// CL-7790: the ONE shell-token matcher both secret-guard call sites share —
374395// commandReferencesSensitivePath below and classify.ts's per-arg sensitive
375396// check. The cheap lexical denylist runs first so the hot auto-allow path
@@ -421,9 +442,38 @@ export function isSensitiveShellToken(
421442 // A `?` or `[` glob expands at runtime into whatever names match, so the
422443 // matcher only ever sees the pattern while the shell can open a secret
423444 // (`cat .en?` and `cat .en[v]` both read `.env`). Fail closed to a prompt —
424- // the dual of the `*` exclusions in the filters above, which stay untouched.
425- // After the cmd device-path exemption so `\\?\…` names keep working.
426- if ( expanded . includes ( "?" ) || expanded . includes ( "[" ) ) return true ;
445+ // but only for file-operand-shaped tokens. The unscoped rule fired on
446+ // non-file operands: query strings (`curl …/search?q=term`), regex operands
447+ // (`grep -E colou?r`, `grep [0-9]`), and the `[` test builtin itself
448+ // (`[ -f Makefile ]`). Three exemptions, each too narrow to reopen a
449+ // bypass: tokens containing `://` are URLs, never a local file the shell
450+ // opens (the lexical denylist above still catches `file://…/.env`); bare
451+ // `[`/`]`/`[[`/`]]` are test syntax, not globs; and a `?`/`[` pattern with
452+ // no `.`, `/`, or `\` cannot name a dotfile secret — `?`/`[…]` never match
453+ // a leading dot under default shell semantics, so the literal dot must be
454+ // present. Dotless secrets (`id_rsa`, `Cookies`) stay reachable through the
455+ // accepted bare-`*` residual below, so exempting their `?`/`[` forms adds
456+ // no new bypass. After the cmd device-path exemption so `\\?\…` names keep
457+ // working.
458+ if (
459+ ( expanded . includes ( "?" ) || expanded . includes ( "[" ) ) &&
460+ ! expanded . includes ( "://" ) &&
461+ expanded !== "[" &&
462+ expanded !== "]" &&
463+ expanded !== "[[" &&
464+ expanded !== "]]" &&
465+ ( expanded . includes ( "." ) ||
466+ expanded . includes ( "/" ) ||
467+ expanded . includes ( "\\" ) )
468+ )
469+ return true ;
470+ // Dotfile-rooted `*` globs (`.*`, `.env*`) deterministically match `.env`
471+ // in any realistic cwd, so they prompt — the carve-out from the `*`
472+ // exclusions in the filters above. Bare `*` / `*.txt` cannot match a
473+ // leading dot and stay allowed, as do `*` globs rooted outside a dotfile
474+ // name (`.config/*`). Runs post-expansion, so `${UNKNOWN_X:=.env*}`
475+ // prompts while `${UNKNOWN_X:=fallback.txt}` stays free.
476+ if ( isDotfileRootedGlob ( expanded ) ) return true ;
427477 if ( isPathLikeShellToken ( expanded ) ) {
428478 if ( isAbsolute ( expanded ) ) {
429479 return (
0 commit comments