@@ -12,6 +12,7 @@ import {
1212 isSensitivePath ,
1313 isSensitiveShellToken ,
1414 commandReferencesSensitivePath ,
15+ expandShellToken ,
1516} from "./secret-guard-plugin.js" ;
1617
1718const next = async ( call : ToolCall ) : Promise < ToolResult > => ( {
@@ -271,6 +272,11 @@ describe("commandReferencesSensitivePath", () => {
271272 // Relative-dot prefixes resolve to the same anchored match as a raw token.
272273 "cat ./.env" ,
273274 "cat ./secrets/.env" ,
275+ // `?`/`[…]` globs read a secret the matcher only sees as a pattern.
276+ "cat .en?" ,
277+ "cat .e?v" ,
278+ "cat .en[v]" ,
279+ "head -c 100 .en?" ,
274280 // Runtime env-file loaders — detected so the gate can ask, not hard-deny.
275281 "bun --env-file=../../.env.staging run bin/publish.ts" ,
276282 "bun --env-file=.env run -e 'console.log(1)'" ,
@@ -308,6 +314,10 @@ describe("commandReferencesSensitivePath", () => {
308314 "sed --f=.envrc input.txt" ,
309315 "grep --fil=.envrc needle" ,
310316 "bun test" ,
317+ // `*` stays an accepted residual: it cannot resolve without running the
318+ // shell, and prompting on it would fire on every benign `cat *`.
319+ "cat *" ,
320+ "cat *.txt" ,
311321 ] ;
312322 for ( const c of allowed ) {
313323 test ( `allows: ${ c } ` , ( ) =>
@@ -319,19 +329,29 @@ describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", ()
319329 const CFG_VALUE = "/tmp/cl-8999-cfg/.corbits" ;
320330 let savedCFG : string | undefined ;
321331 let savedUnknown : string | undefined ;
332+ let savedPort : string | undefined ;
333+ let savedEmpty : string | undefined ;
322334
323335 beforeEach ( ( ) => {
324336 savedCFG = process . env . CFG ;
325337 savedUnknown = process . env . UNKNOWN_X ;
338+ savedPort = process . env . PORT ;
339+ savedEmpty = process . env . EMPTY_X ;
326340 process . env . CFG = CFG_VALUE ;
327341 delete process . env . UNKNOWN_X ;
342+ delete process . env . PORT ;
343+ delete process . env . EMPTY_X ;
328344 } ) ;
329345
330346 afterEach ( ( ) => {
331347 if ( savedCFG === undefined ) delete process . env . CFG ;
332348 else process . env . CFG = savedCFG ;
333349 if ( savedUnknown === undefined ) delete process . env . UNKNOWN_X ;
334350 else process . env . UNKNOWN_X = savedUnknown ;
351+ if ( savedPort === undefined ) delete process . env . PORT ;
352+ else process . env . PORT = savedPort ;
353+ if ( savedEmpty === undefined ) delete process . env . EMPTY_X ;
354+ else process . env . EMPTY_X = savedEmpty ;
335355 } ) ;
336356
337357 const expandedSensitive = [
@@ -341,6 +361,8 @@ describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", ()
341361 "cat ${CFG}/settings.json" ,
342362 "cat $CFG/settings.json" ,
343363 "cat $UNKNOWN_X/.env" ,
364+ "cat ${UNKNOWN_X:-$CFG/settings.json}" ,
365+ "cat ${UNKNOWN_X:=.env}" ,
344366 ] ;
345367 for ( const c of expandedSensitive ) {
346368 test ( `flags: ${ c } ` , ( ) =>
@@ -355,7 +377,66 @@ describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", ()
355377 expect ( isSensitiveShellToken ( "$CFG/settings.json" ) ) . toBe ( true ) ;
356378 } ) ;
357379
358- const expandedBenign = [ "cat $HOME/README.md" , "cat Makefile" ] ;
380+ test ( "resolves := without prompting when the default is benign" , ( ) => {
381+ expect ( isSensitiveShellToken ( "${UNKNOWN_X:=fallback.txt}" ) ) . toBe ( false ) ;
382+ } ) ;
383+
384+ test ( "allows := / :+ port defaults without a prompt" , ( ) => {
385+ expect (
386+ commandReferencesSensitivePath ( "bun --port ${PORT:=3000} run x" ) ,
387+ ) . toBeUndefined ( ) ;
388+ process . env . PORT = "4000" ;
389+ expect (
390+ commandReferencesSensitivePath ( "bun --port ${PORT:=3000} run x" ) ,
391+ ) . toBeUndefined ( ) ;
392+ delete process . env . PORT ;
393+ expect (
394+ commandReferencesSensitivePath ( "bun --port ${PORT:+3000} run x" ) ,
395+ ) . toBeUndefined ( ) ;
396+ } ) ;
397+
398+ test ( "expands := like :- for unset and empty variables" , ( ) => {
399+ expect ( expandShellToken ( "${UNKNOWN_X:=dflt}" ) ) . toEqual ( {
400+ expanded : "dflt" ,
401+ expandable : true ,
402+ } ) ;
403+ expect ( expandShellToken ( "${CFG:=dflt}" ) . expanded ) . toBe ( CFG_VALUE ) ;
404+ process . env . EMPTY_X = "" ;
405+ expect ( expandShellToken ( "${EMPTY_X:=dflt}" ) . expanded ) . toBe ( "dflt" ) ;
406+ } ) ;
407+
408+ test ( "expands :+ and + only when the variable is set" , ( ) => {
409+ expect ( expandShellToken ( "${CFG:+alt}" ) . expanded ) . toBe ( "alt" ) ;
410+ expect ( expandShellToken ( "${CFG+alt}" ) . expanded ) . toBe ( "alt" ) ;
411+ expect ( expandShellToken ( "${UNKNOWN_X:+alt}" ) ) . toEqual ( {
412+ expanded : "" ,
413+ expandable : true ,
414+ } ) ;
415+ expect ( expandShellToken ( "${UNKNOWN_X+alt}" ) . expanded ) . toBe ( "" ) ;
416+ process . env . EMPTY_X = "" ;
417+ expect ( expandShellToken ( "${EMPTY_X:+alt}" ) . expanded ) . toBe ( "" ) ;
418+ expect ( expandShellToken ( "${EMPTY_X+alt}" ) . expanded ) . toBe ( "alt" ) ;
419+ } ) ;
420+
421+ test ( "keeps :?, #, %, / and offsets fail-closed" , ( ) => {
422+ for ( const token of [
423+ "${CFG:?must be set}" ,
424+ "${CFG#prefix}" ,
425+ "${CFG%post}" ,
426+ "${CFG/a/b}" ,
427+ "${CFG:1}" ,
428+ "${CFG:1:2}" ,
429+ "${UNKNOWN_X:-${BROKEN}" ,
430+ ] ) {
431+ expect ( expandShellToken ( token ) . expandable ) . toBe ( false ) ;
432+ }
433+ } ) ;
434+
435+ const expandedBenign = [
436+ "cat $HOME/README.md" ,
437+ "cat Makefile" ,
438+ "cat ${UNKNOWN_X:-prefix}" ,
439+ ] ;
359440 for ( const c of expandedBenign ) {
360441 test ( `allows: ${ c } ` , ( ) =>
361442 expect ( commandReferencesSensitivePath ( c ) ) . toBeUndefined ( ) ) ;
0 commit comments