@@ -286,3 +286,132 @@ 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 ( "uncontained destination names the approved locations" , async ( ) => {
353+ // A direct child of tmpdir() is not a permitted sibling of sessionCwd
354+ // (only direct children of root/ are), so the restricted guard trips.
355+ const outside = join ( tmpdir ( ) , "gate-6824-outside" ) ;
356+ const { verdict, seen } = await askWithGrants (
357+ `git worktree add ${ outside } ` ,
358+ worktreeGrant ,
359+ ) ;
360+ expect ( verdict . allowed ) . toBe ( false ) ;
361+ expect ( seen ) . toHaveLength ( 1 ) ;
362+ expect ( seen [ 0 ] ?. notice ) . toBe (
363+ "A standing grant matches this command, but the worktree destination is outside the approved locations, so it still needs approval." ,
364+ ) ;
365+ } ) ;
366+
367+ test ( "secret reference names the sensitive path" , async ( ) => {
368+ const { verdict, seen } = await askWithGrants ( "cat .env" , catGrant ) ;
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 references a sensitive path, so it still needs approval." ,
373+ ) ;
374+ // The secret ask still strips grant scopes; the notice survives it.
375+ expect ( seen [ 0 ] ?. scopes ) . toEqual ( [ ] ) ;
376+ } ) ;
377+
378+ test ( "restricted target names the workspace" , async ( ) => {
379+ const { verdict, seen } = await askWithGrants ( "cat /etc/passwd" , catGrant ) ;
380+ expect ( verdict . allowed ) . toBe ( false ) ;
381+ expect ( seen ) . toHaveLength ( 1 ) ;
382+ expect ( seen [ 0 ] ?. notice ) . toBe (
383+ "A standing grant matches this command, but it targets a path outside the workspace, so it still needs approval." ,
384+ ) ;
385+ } ) ;
386+
387+ test ( "an ask with no matching grant carries no notice" , async ( ) => {
388+ const { verdict, seen } = await askWithGrants (
389+ "git worktree add --force ../sib-nogrant" ,
390+ [ ] ,
391+ ) ;
392+ expect ( verdict . allowed ) . toBe ( false ) ;
393+ expect ( seen ) . toHaveLength ( 1 ) ;
394+ expect ( seen [ 0 ] ?. notice ) . toBeUndefined ( ) ;
395+ } ) ;
396+
397+ test ( "a covered command still allows with no prompt and no notice" , async ( ) => {
398+ const seen : PermissionRequest [ ] = [ ] ;
399+ const gate = createPermissionGate ( {
400+ approvals : worktreeGrant ,
401+ interactive : true ,
402+ skipPermissions : false ,
403+ reactorGated : false ,
404+ cwd : sessionCwd ,
405+ rootsProvider : ( ) => [ ] ,
406+ requestApproval : async ( request ) => {
407+ seen . push ( request ) ;
408+ return { allow : false } ;
409+ } ,
410+ } ) ;
411+ const verdict = await gate . evaluate (
412+ shellCall ( "git worktree add ../sib-plain -b br-plain" ) ,
413+ ) ;
414+ expect ( verdict . allowed ) . toBe ( true ) ;
415+ expect ( seen ) . toHaveLength ( 0 ) ;
416+ } ) ;
417+ } ) ;
0 commit comments