2424 * icacls cannot block OAuth logins or token refresh (field report: Kimi auth
2525 * stuck behind ETIMEDOUT). Real EPERM/EACCES/exit-code failures still throw:
2626 * availability never silently overrides confidentiality for those.
27+ * hardenSecretPathAsync / hardenSecretDirAsync — same policy, async icacls
28+ * runner so the event loop is not held for the child lifetime (#612).
29+ * HardenOptions.timeoutMemoKey — optional destination-path key for the
30+ * timeout memo (atomic writers mint unique temps; never a parent directory).
2731 * hardenSecretDir — same contract for directories.
2832 */
2933
@@ -42,6 +46,13 @@ export interface HardenResult {
4246
4347export interface HardenOptions {
4448 required : boolean ;
49+ /**
50+ * Optional timeout-memo key distinct from `targetPath` (issue #612).
51+ * Atomic writers mint a fresh `.tmp` path per write; keying the timeout cache by the
52+ * final destination path prevents re-stalling the event loop on every subsequent temp.
53+ * Must NOT be a parent directory — directory ACLs are not authoritative for new files.
54+ */
55+ timeoutMemoKey ?: string ;
4556}
4657
4758/**
@@ -72,6 +83,7 @@ export interface IcaclsResult {
7283}
7384
7485type IcaclsRunner = ( args : string [ ] , timeoutMs : number ) => IcaclsResult ;
86+ type AsyncIcaclsRunner = ( args : string [ ] , timeoutMs : number ) => Promise < IcaclsResult > ;
7587
7688function defaultIcaclsRunner ( args : string [ ] , timeoutMs : number ) : IcaclsResult {
7789 // Bun.spawnSync with windowsHide: Node execFileSync has hung under the GUI/proxy even
@@ -91,7 +103,43 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {
91103 } ;
92104}
93105
106+ /**
107+ * Async icacls runner (#612): yields the event loop while waiting for the child.
108+ * Timeout provenance is recorded by our timer (async Subprocess has no exitedDueToTimeout);
109+ * we still await process exit before classifying so settlement is confirmed.
110+ */
111+ async function defaultAsyncIcaclsRunner ( args : string [ ] , timeoutMs : number ) : Promise < IcaclsResult > {
112+ const proc = Bun . spawn ( [ "icacls.exe" , ...args ] , {
113+ stdin : "ignore" ,
114+ stdout : "pipe" ,
115+ stderr : "ignore" ,
116+ windowsHide : true ,
117+ } ) ;
118+ let timedOutByUs = false ;
119+ const timer = setTimeout ( ( ) => {
120+ timedOutByUs = true ;
121+ try { proc . kill ( ) ; } catch { /* already exited */ }
122+ } , Math . max ( 1 , timeoutMs ) ) ;
123+ let exitCode : number | null = null ;
124+ try {
125+ exitCode = await proc . exited ;
126+ } finally {
127+ clearTimeout ( timer ) ;
128+ }
129+ const stdout = proc . stdout
130+ ? await new Response ( proc . stdout ) . text ( ) . catch ( ( ) => "" )
131+ : "" ;
132+ const timedOut = timedOutByUs ;
133+ return {
134+ success : ! timedOut && exitCode === 0 ,
135+ exitCode : timedOut ? null : exitCode ,
136+ timedOut,
137+ stdout,
138+ } ;
139+ }
140+
94141let icaclsRunner : IcaclsRunner = defaultIcaclsRunner ;
142+ let asyncIcaclsRunner : AsyncIcaclsRunner = defaultAsyncIcaclsRunner ;
95143let platformOverride : string | null = null ;
96144let nowFn : ( ) => number = Date . now ;
97145
@@ -100,6 +148,11 @@ export function setIcaclsRunnerForTests(runner: IcaclsRunner | null): void {
100148 icaclsRunner = runner ?? defaultIcaclsRunner ;
101149}
102150
151+ /** Test seam: replace the async icacls runner. Pass null to restore the default. */
152+ export function setAsyncIcaclsRunnerForTests ( runner : AsyncIcaclsRunner | null ) : void {
153+ asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner ;
154+ }
155+
103156/** Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. */
104157export function setPlatformForTests ( value : string | null ) : void {
105158 platformOverride = value ;
@@ -156,6 +209,10 @@ function currentWindowsUser(): string | undefined {
156209 */
157210const BROAD_SIDS = [ "*S-1-1-0" , "*S-1-5-11" , "*S-1-5-32-545" ] as const ;
158211
212+ function grantAce ( user : string , directory : boolean ) : string {
213+ return directory ? `${ user } :(OI)(CI)(F)` : `${ user } :(F)` ;
214+ }
215+
159216function runIcacls ( targetPath : string , directory : boolean , deadline : number ) : void {
160217 const user = currentWindowsUser ( ) ;
161218 if ( ! user ) {
@@ -177,8 +234,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
177234
178235 // Step 1: grant current user full control BEFORE any destructive ACL change.
179236 // If this fails, inheritance is untouched and the writer keeps inherited access.
180- const grant = directory ? `${ user } :(OI)(CI)(F)` : `${ user } :(F)` ;
181- runOrThrow ( "/grant:r" , [ targetPath , "/grant:r" , grant ] ) ;
237+ runOrThrow ( "/grant:r" , [ targetPath , "/grant:r" , grantAce ( user , directory ) ] ) ;
182238
183239 // Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE
184240 // from step 1 survives this transition, so a later failure still leaves cleanup access.
@@ -205,6 +261,41 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
205261 }
206262}
207263
264+ /** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */
265+ async function runIcaclsAsync ( targetPath : string , directory : boolean , deadline : number ) : Promise < void > {
266+ const user = currentWindowsUser ( ) ;
267+ if ( ! user ) {
268+ throw new Error ( "Cannot determine current Windows user for ACL hardening" ) ;
269+ }
270+
271+ const run = async ( step : string , args : string [ ] ) : Promise < IcaclsResult > => {
272+ const remaining = deadline - nowFn ( ) ;
273+ if ( remaining <= 0 ) {
274+ throw icaclsError ( step , { success : false , exitCode : null , timedOut : true , stdout : "" } ) ;
275+ }
276+ return asyncIcaclsRunner ( args , remaining ) ;
277+ } ;
278+ const runOrThrow = async ( step : string , args : string [ ] ) : Promise < void > => {
279+ const result = await run ( step , args ) ;
280+ if ( ! result . success ) throw icaclsError ( step , result ) ;
281+ } ;
282+
283+ await runOrThrow ( "/grant:r" , [ targetPath , "/grant:r" , grantAce ( user , directory ) ] ) ;
284+ await runOrThrow ( "/inheritance:r" , [ targetPath , "/inheritance:r" ] ) ;
285+
286+ const removal = await run ( "/remove:g" , [ targetPath , "/remove:g" , ...BROAD_SIDS ] ) ;
287+ if ( ! removal . success ) {
288+ if ( removal . timedOut ) throw icaclsError ( "/remove:g" , removal ) ;
289+ for ( const sid of BROAD_SIDS ) {
290+ const found = await run ( "/findsid" , [ targetPath , "/findsid" , sid ] ) ;
291+ if ( ! found . success ) throw icaclsError ( "/findsid" , found ) ;
292+ if ( found . stdout . includes ( targetPath ) ) {
293+ throw icaclsError ( "/remove:g" , removal ) ;
294+ }
295+ }
296+ }
297+ }
298+
208299/**
209300 * Sanitize an error from a failed ACL operation into a safe diagnostic string.
210301 * The raw path must not appear in the returned string (it may contain
@@ -254,6 +345,27 @@ function describeAclStateAfterTimeout(targetPath: string, deadline: number): str
254345 }
255346}
256347
348+ async function describeAclStateAfterTimeoutAsync ( targetPath : string , deadline : number ) : Promise < string > {
349+ try {
350+ for ( const sid of BROAD_SIDS ) {
351+ const remaining = deadline - nowFn ( ) ;
352+ if ( remaining <= 0 ) return "ACL state unverified (budget exhausted)" ;
353+ const found = await asyncIcaclsRunner ( [ targetPath , "/findsid" , sid ] , remaining ) ;
354+ if ( ! found . success ) return "ACL state unverified (probe failed)" ;
355+ if ( found . stdout . includes ( targetPath ) ) return "broad ACL grants still present" ;
356+ }
357+ return "no broad ACL grants detected (hardening still incomplete)" ;
358+ } catch {
359+ return "ACL state unverified (probe failed)" ;
360+ }
361+ }
362+
363+ function timeoutMemoKey ( targetPath : string , opts : HardenOptions ) : string {
364+ // Destination-path memo only (issue #612). Never a parent directory — directory ACLs
365+ // are not authoritative for newly created temps.
366+ return opts . timeoutMemoKey ?? targetPath ;
367+ }
368+
257369/**
258370 * Shared harden flow for files and directories: one total budget (env-configurable)
259371 * covering the initial attempt, ONE timeout retry, and the diagnostic verification.
@@ -269,7 +381,8 @@ function hardenEntry(
269381 if ( ! existsSync ( targetPath ) ) return { ok : true } ;
270382 if ( effectivePlatform ( ) !== "win32" ) return { ok : true } ;
271383 if ( cache . has ( targetPath ) ) return { ok : true } ;
272- if ( timedOutPaths . has ( targetPath ) ) {
384+ const memoKey = timeoutMemoKey ( targetPath , opts ) ;
385+ if ( timedOutPaths . has ( memoKey ) ) {
273386 return { ok : false , diagnostics : "ACL hardening skipped — previous attempt timed out" } ;
274387 }
275388
@@ -289,7 +402,7 @@ function hardenEntry(
289402
290403 const diagnostics = sanitizeDiagnostics ( lastErr ) ;
291404 if ( isTimeoutError ( lastErr ) ) {
292- timedOutPaths . add ( targetPath ) ;
405+ timedOutPaths . add ( memoKey ) ;
293406 const state = describeAclStateAfterTimeout ( targetPath , deadline ) ;
294407 const annotated = `${ diagnostics } ; ${ state } ` ;
295408 // Timeout-only soft-fail: a hung icacls must not block OAuth/token writes.
@@ -301,6 +414,47 @@ function hardenEntry(
301414 return { ok : false , diagnostics } ;
302415}
303416
417+ /** Async counterpart of hardenEntry — yields while waiting on icacls (#612). */
418+ async function hardenEntryAsync (
419+ targetPath : string ,
420+ directory : boolean ,
421+ opts : HardenOptions ,
422+ cache : Set < string > ,
423+ ) : Promise < HardenResult > {
424+ if ( ! existsSync ( targetPath ) ) return { ok : true } ;
425+ if ( effectivePlatform ( ) !== "win32" ) return { ok : true } ;
426+ if ( cache . has ( targetPath ) ) return { ok : true } ;
427+ const memoKey = timeoutMemoKey ( targetPath , opts ) ;
428+ if ( timedOutPaths . has ( memoKey ) ) {
429+ return { ok : false , diagnostics : "ACL hardening skipped — previous attempt timed out" } ;
430+ }
431+
432+ const deadline = nowFn ( ) + resolveHardenDeadlineMs ( ) ;
433+ let lastErr : unknown ;
434+ for ( let attempt = 0 ; attempt < 2 ; attempt ++ ) {
435+ if ( attempt > 0 && deadline - nowFn ( ) <= 0 ) break ;
436+ try {
437+ await runIcaclsAsync ( targetPath , directory , deadline ) ;
438+ cache . add ( targetPath ) ;
439+ return { ok : true } ;
440+ } catch ( err ) {
441+ lastErr = err ;
442+ if ( ! isTimeoutError ( err ) ) break ;
443+ }
444+ }
445+
446+ const diagnostics = sanitizeDiagnostics ( lastErr ) ;
447+ if ( isTimeoutError ( lastErr ) ) {
448+ timedOutPaths . add ( memoKey ) ;
449+ const state = await describeAclStateAfterTimeoutAsync ( targetPath , deadline ) ;
450+ const annotated = `${ diagnostics } ; ${ state } ` ;
451+ console . warn ( `[opencodex] ${ annotated } — continuing without NTFS ACL harden` ) ;
452+ return { ok : false , diagnostics : annotated } ;
453+ }
454+ if ( opts . required ) throw new Error ( diagnostics ) ;
455+ return { ok : false , diagnostics } ;
456+ }
457+
304458/**
305459 * Harden a single file path with per-user NTFS ACLs on Windows.
306460 * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
@@ -312,6 +466,14 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde
312466 return hardenEntry ( targetPath , false , opts , hardenedPaths ) ;
313467}
314468
469+ /**
470+ * Async harden for write paths that must not block the event loop (#612).
471+ * Same success/timeout/error policy as hardenSecretPath.
472+ */
473+ export function hardenSecretPathAsync ( targetPath : string , opts : HardenOptions ) : Promise < HardenResult > {
474+ return hardenEntryAsync ( targetPath , false , opts , hardenedPaths ) ;
475+ }
476+
315477/**
316478 * Harden a directory path with per-user NTFS ACLs on Windows.
317479 * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
@@ -322,3 +484,10 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde
322484export function hardenSecretDir ( targetPath : string , opts : HardenOptions ) : HardenResult {
323485 return hardenEntry ( targetPath , true , opts , hardenedDirectories ) ;
324486}
487+
488+ /**
489+ * Async directory harden (#612). Same policy as hardenSecretDir.
490+ */
491+ export function hardenSecretDirAsync ( targetPath : string , opts : HardenOptions ) : Promise < HardenResult > {
492+ return hardenEntryAsync ( targetPath , true , opts , hardenedDirectories ) ;
493+ }
0 commit comments