@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44import { join } from "node:path" ;
55import { generateSessionId } from "../session/index.js" ;
66import { loadSeededApprovals } from "../session/runtime-assembly.js" ;
7+ import { runWithSubAgentIdentity } from "../subagent/identity-context.js" ;
78import { trustProjectGrants } from "../trust/project-trust.js" ;
89import { createPermissionGate } from "./gate.js" ;
910import type { Approval , PermissionRequest } from "./types.js" ;
@@ -206,4 +207,102 @@ describe("CL-7782: project approvals require grant trust", () => {
206207 expect ( ( await gate . evaluate ( { ...NPM_TEST } ) ) . allowed ) . toBe ( true ) ;
207208 expect ( asked ) . toEqual ( [ ] ) ;
208209 } ) ;
210+
211+ test ( "confirming a planted entry through the pending flow converges the file to the minted shape" , async ( ) => {
212+ const base = await mkdtemp ( join ( tmpdir ( ) , "cl-7782-converge-" ) ) ;
213+ const home = join ( base , "home" ) ;
214+ const cwd = join ( base , "repo" ) ;
215+ await plantProjectApprovals ( cwd , [
216+ { tool : "run_shell" , pattern : "npm test" } ,
217+ ] ) ;
218+ expect ( await loadPendingProjectApprovals ( cwd , home ) ) . toEqual ( [
219+ { tool : "run_shell" , pattern : "npm test" } ,
220+ ] ) ;
221+
222+ // What the gate persist does when the operator confirms the pending entry
223+ // with a project-scope persist: mint {tool, pattern, cwd} and write it.
224+ await saveProjectApproval (
225+ cwd ,
226+ { tool : "run_shell" , pattern : "npm test" , cwd } ,
227+ home ,
228+ ) ;
229+
230+ // The planted twin is displaced by the minted shape — nothing lingers as
231+ // pending, and the grant applies without asking.
232+ expect ( await loadProjectApprovals ( cwd , home ) ) . toEqual ( [
233+ { tool : "run_shell" , pattern : "npm test" , cwd } ,
234+ ] ) ;
235+ expect ( await loadPendingProjectApprovals ( cwd , home ) ) . toEqual ( [ ] ) ;
236+
237+ const { gate, asked } = await driveGate ( cwd , generateSessionId ( ) , home ) ;
238+ expect (
239+ (
240+ await gate . evaluate ( {
241+ id : "npm-test" ,
242+ name : "run_shell" ,
243+ arguments : { command : "npm test" } ,
244+ } )
245+ ) . allowed ,
246+ ) . toBe ( true ) ;
247+ expect ( asked ) . toEqual ( [ ] ) ;
248+ } ) ;
249+
250+ test ( "stripping cwd from a confirmed entry re-surfaces as pending and never cross-repo auto-allows" , async ( ) => {
251+ const base = await mkdtemp ( join ( tmpdir ( ) , "cl-7782-cwd-strip-" ) ) ;
252+ const home = join ( base , "home" ) ;
253+ const cwd = join ( base , "repo" ) ;
254+ const other = join ( base , "other" ) ;
255+ await mkdir ( other , { recursive : true } ) ;
256+
257+ // Operator confirms {tool, pattern, cwd} through the production path.
258+ await saveProjectApproval (
259+ cwd ,
260+ { tool : "run_shell" , pattern : "npm test" , cwd } ,
261+ home ,
262+ ) ;
263+ expect ( await loadProjectApprovals ( cwd , home ) ) . toEqual ( [
264+ { tool : "run_shell" , pattern : "npm test" , cwd } ,
265+ ] ) ;
266+
267+ // Hand-edit drops the cwd key: byte-identical to a planted entry, but the
268+ // confirmation was bound to the cwd-bearing shape, so trust must not
269+ // follow the stripped bytes.
270+ await plantProjectApprovals ( cwd , [
271+ { tool : "run_shell" , pattern : "npm test" } ,
272+ ] ) ;
273+ expect ( await loadProjectApprovals ( cwd , home ) ) . toEqual ( [ ] ) ;
274+ expect ( await loadPendingProjectApprovals ( cwd , home ) ) . toEqual ( [
275+ { tool : "run_shell" , pattern : "npm test" } ,
276+ ] ) ;
277+
278+ // The real gate, seeded after the strip: neither the same-repo request
279+ // nor a cross-repo request (different request cwd) auto-allows.
280+ const asked : string [ ] = [ ] ;
281+ const gate = createPermissionGate ( {
282+ cwd,
283+ interactive : true ,
284+ skipPermissions : false ,
285+ reactorGated : false ,
286+ requestApproval : async ( request : PermissionRequest ) => {
287+ asked . push ( `${ request . tool } :${ request . subject } ` ) ;
288+ return { allow : false } ;
289+ } ,
290+ approvals : await loadSeededApprovals ( cwd , generateSessionId ( ) , home ) ,
291+ } ) ;
292+ const NPM_TEST = {
293+ id : "npm-test" ,
294+ name : "run_shell" ,
295+ arguments : { command : "npm test" } ,
296+ } as const ;
297+ expect ( ( await gate . evaluate ( { ...NPM_TEST } ) ) . allowed ) . toBe ( false ) ;
298+ expect (
299+ (
300+ await runWithSubAgentIdentity (
301+ { description : "other" , cwd : other } ,
302+ ( ) => gate . evaluate ( { ...NPM_TEST } ) ,
303+ )
304+ ) . allowed ,
305+ ) . toBe ( false ) ;
306+ expect ( asked ) . toEqual ( [ "run_shell:npm test" , "run_shell:npm test" ] ) ;
307+ } ) ;
209308} ) ;
0 commit comments