@@ -286,3 +286,192 @@ describe("standing grant covers a later git worktree command (CL-5638)", () => {
286286 expect ( prompts ) . toBe ( 1 ) ;
287287 } ) ;
288288} ) ;
289+
290+ // CL-6824: when a standing grant covers a command but a pre-grant guard still
291+ // forces an ask, the prompt carries PermissionRequest.notice naming the
292+ // guard's reason. Matching semantics are unchanged — every case below still
293+ // asks (and stays deniable); only the prompt gains the why.
294+ describe ( "grant-mismatch asks carry the guard reason as a notice (CL-6824)" , ( ) => {
295+ const root = mkdtempSync ( join ( tmpdir ( ) , "gate-mismatch-notice-" ) ) ;
296+ const sessionCwd = join ( root , "main" ) ;
297+ const git = ( args : string [ ] , cwd : string ) =>
298+ execFileSync ( "git" , args , { cwd, stdio : "ignore" } ) ;
299+ mkdirSync ( sessionCwd ) ;
300+ initTemporaryGitRepo ( sessionCwd , { initArgs : [ "-q" ] } ) ;
301+ writeFileSync ( join ( sessionCwd , "seed.txt" ) , "seed\n" ) ;
302+ git ( [ "add" , "." ] , sessionCwd ) ;
303+ git ( [ "commit" , "-qm" , "seed" ] , sessionCwd ) ;
304+
305+ async function askWithGrants ( command : string , approvals : Approval [ ] ) {
306+ const seen : PermissionRequest [ ] = [ ] ;
307+ const gate = createPermissionGate ( {
308+ approvals,
309+ interactive : true ,
310+ skipPermissions : false ,
311+ reactorGated : false ,
312+ cwd : sessionCwd ,
313+ rootsProvider : ( ) => [ ] ,
314+ requestApproval : async ( request ) => {
315+ seen . push ( request ) ;
316+ return { allow : false } ;
317+ } ,
318+ } ) ;
319+ const verdict = await gate . evaluate ( shellCall ( command ) ) ;
320+ return { verdict, seen } ;
321+ }
322+
323+ const worktreeGrant : Approval [ ] = [
324+ { tool : "run_shell" , pattern : "git worktree *" } ,
325+ ] ;
326+ const catGrant : Approval [ ] = [ { tool : "run_shell" , pattern : "cat *" } ] ;
327+
328+ test ( "force worktree names --force in the notice" , async ( ) => {
329+ const { verdict, seen } = await askWithGrants (
330+ "git worktree add --force ../sib-force" ,
331+ worktreeGrant ,
332+ ) ;
333+ expect ( verdict . allowed ) . toBe ( false ) ;
334+ expect ( seen ) . toHaveLength ( 1 ) ;
335+ expect ( seen [ 0 ] ?. notice ) . toBe (
336+ "A standing grant matches this command, but it uses --force, so it still needs approval." ,
337+ ) ;
338+ } ) ;
339+
340+ test ( "--force=<value> is still force in the notice" , async ( ) => {
341+ const { verdict, seen } = await askWithGrants (
342+ "git worktree add --force=true ../sib-force-eq" ,
343+ worktreeGrant ,
344+ ) ;
345+ expect ( verdict . allowed ) . toBe ( false ) ;
346+ expect ( seen ) . toHaveLength ( 1 ) ;
347+ expect ( seen [ 0 ] ?. notice ) . toBe (
348+ "A standing grant matches this command, but it uses --force, so it still needs approval." ,
349+ ) ;
350+ } ) ;
351+
352+ test ( "short -f=<value> is still force in the notice" , async ( ) => {
353+ const { verdict, seen } = await askWithGrants (
354+ "git worktree add -f=true ../sib-force-short-eq" ,
355+ worktreeGrant ,
356+ ) ;
357+ expect ( verdict . allowed ) . toBe ( false ) ;
358+ expect ( seen ) . toHaveLength ( 1 ) ;
359+ expect ( seen [ 0 ] ?. notice ) . toBe (
360+ "A standing grant matches this command, but it uses --force, so it still needs approval." ,
361+ ) ;
362+ } ) ;
363+
364+ test ( "glued -f<val> is still force in the notice" , async ( ) => {
365+ const { verdict, seen } = await askWithGrants (
366+ "git worktree remove -ftrue ../sib-force-glued" ,
367+ worktreeGrant ,
368+ ) ;
369+ expect ( verdict . allowed ) . toBe ( false ) ;
370+ expect ( seen ) . toHaveLength ( 1 ) ;
371+ expect ( seen [ 0 ] ?. notice ) . toBe (
372+ "A standing grant matches this command, but it uses --force, so it still needs approval." ,
373+ ) ;
374+ } ) ;
375+
376+ test ( "uncontained add destination names the approved locations" , async ( ) => {
377+ // A direct child of tmpdir() is not a permitted sibling of sessionCwd
378+ // (only direct children of root/ are), so the restricted guard trips.
379+ const outside = join ( tmpdir ( ) , "gate-6824-outside" ) ;
380+ const { verdict, seen } = await askWithGrants (
381+ `git worktree add ${ outside } ` ,
382+ worktreeGrant ,
383+ ) ;
384+ expect ( verdict . allowed ) . toBe ( false ) ;
385+ expect ( seen ) . toHaveLength ( 1 ) ;
386+ expect ( seen [ 0 ] ?. notice ) . toBe (
387+ "A standing grant matches this command, but the worktree destination is outside the approved locations, so it still needs approval." ,
388+ ) ;
389+ } ) ;
390+
391+ test ( "uncontained remove names the worktree, not a destination" , async ( ) => {
392+ // `remove` names an existing worktree — there is no destination — so the
393+ // notice drops the destination noun the `add` case uses.
394+ const outside = join ( tmpdir ( ) , "gate-6824-outside-remove" ) ;
395+ const { verdict, seen } = await askWithGrants (
396+ `git worktree remove ${ outside } ` ,
397+ worktreeGrant ,
398+ ) ;
399+ expect ( verdict . allowed ) . toBe ( false ) ;
400+ expect ( seen ) . toHaveLength ( 1 ) ;
401+ expect ( seen [ 0 ] ?. notice ) . toBe (
402+ "A standing grant matches this command, but the worktree is outside the approved locations, so it still needs approval." ,
403+ ) ;
404+ } ) ;
405+
406+ test ( "secret reference names the sensitive path" , async ( ) => {
407+ const { verdict, seen } = await askWithGrants ( "cat .env" , catGrant ) ;
408+ expect ( verdict . allowed ) . toBe ( false ) ;
409+ expect ( seen ) . toHaveLength ( 1 ) ;
410+ expect ( seen [ 0 ] ?. notice ) . toBe (
411+ "A standing grant matches this command, but it references a sensitive path, so it still needs approval." ,
412+ ) ;
413+ // The secret ask still strips grant scopes; the notice survives it.
414+ expect ( seen [ 0 ] ?. scopes ) . toEqual ( [ ] ) ;
415+ } ) ;
416+
417+ test ( "restricted target names the workspace" , async ( ) => {
418+ const { verdict, seen } = await askWithGrants ( "cat /etc/passwd" , catGrant ) ;
419+ expect ( verdict . allowed ) . toBe ( false ) ;
420+ expect ( seen ) . toHaveLength ( 1 ) ;
421+ expect ( seen [ 0 ] ?. notice ) . toBe (
422+ "A standing grant matches this command, but it targets a path outside the workspace, so it still needs approval." ,
423+ ) ;
424+ } ) ;
425+
426+ test ( "an ask with no matching grant carries no notice" , async ( ) => {
427+ const { verdict, seen } = await askWithGrants (
428+ "git worktree add --force ../sib-nogrant" ,
429+ [ ] ,
430+ ) ;
431+ expect ( verdict . allowed ) . toBe ( false ) ;
432+ expect ( seen ) . toHaveLength ( 1 ) ;
433+ expect ( seen [ 0 ] ?. notice ) . toBeUndefined ( ) ;
434+ } ) ;
435+
436+ test ( "a covered command still allows with no prompt and no notice" , async ( ) => {
437+ const seen : PermissionRequest [ ] = [ ] ;
438+ const gate = createPermissionGate ( {
439+ approvals : worktreeGrant ,
440+ interactive : true ,
441+ skipPermissions : false ,
442+ reactorGated : false ,
443+ cwd : sessionCwd ,
444+ rootsProvider : ( ) => [ ] ,
445+ requestApproval : async ( request ) => {
446+ seen . push ( request ) ;
447+ return { allow : false } ;
448+ } ,
449+ } ) ;
450+ const verdict = await gate . evaluate (
451+ shellCall ( "git worktree add ../sib-plain -b br-plain" ) ,
452+ ) ;
453+ expect ( verdict . allowed ) . toBe ( true ) ;
454+ expect ( seen ) . toHaveLength ( 0 ) ;
455+ } ) ;
456+
457+ test ( "a --no-force command still allows with no prompt and no notice" , async ( ) => {
458+ const seen : PermissionRequest [ ] = [ ] ;
459+ const gate = createPermissionGate ( {
460+ approvals : worktreeGrant ,
461+ interactive : true ,
462+ skipPermissions : false ,
463+ reactorGated : false ,
464+ cwd : sessionCwd ,
465+ rootsProvider : ( ) => [ ] ,
466+ requestApproval : async ( request ) => {
467+ seen . push ( request ) ;
468+ return { allow : false } ;
469+ } ,
470+ } ) ;
471+ const verdict = await gate . evaluate (
472+ shellCall ( "git worktree add --no-force ../sib-noforce -b br-noforce" ) ,
473+ ) ;
474+ expect ( verdict . allowed ) . toBe ( true ) ;
475+ expect ( seen ) . toHaveLength ( 0 ) ;
476+ } ) ;
477+ } ) ;
0 commit comments