Skip to content

Commit 341d486

Browse files
authored
fix(windows): async ACL harden for response-state writes (lidge-jun#645)
Fixes lidge-jun#612. Async ACL runner for response-state writes; destination-path timeout memo; owner-first grant. Conflict-resolved onto current dig.
1 parent a2981c8 commit 341d486

6 files changed

Lines changed: 378 additions & 26 deletions

File tree

src/config.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
1212
} from "./codex/account-namespace-match";
1313
import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
14-
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
14+
import { hardenSecretDir, hardenSecretPath, hardenSecretPathAsync } from "./lib/windows-secret-acl";
1515
import { providerDestinationConfigError } from "./lib/destination-policy";
1616
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
1717
import {
@@ -135,6 +135,90 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
135135
}
136136
}
137137

138+
/** Async atomic-write I/O: harden may await icacls without blocking the event loop (#612). */
139+
export interface AtomicWriteAsyncIO {
140+
write: (path: string, content: string) => void | Promise<void>;
141+
harden: (path: string) => void | Promise<void>;
142+
rename: (source: string, destination: string) => void | Promise<void>;
143+
truncate: (path: string) => void | Promise<void>;
144+
unlink: (path: string) => void | Promise<void>;
145+
}
146+
147+
async function renameAtomicFileAsync(source: string, destination: string): Promise<void> {
148+
for (let attempt = 0; ; attempt += 1) {
149+
try {
150+
renameSync(source, destination);
151+
return;
152+
} catch (error) {
153+
const code = (error as NodeJS.ErrnoException).code;
154+
const transientWindowsError = process.platform === "win32"
155+
&& (code === "EBUSY" || code === "EPERM" || code === "EACCES");
156+
if (!transientWindowsError || attempt >= 2) throw error;
157+
await Bun.sleep(25 * (attempt + 1));
158+
}
159+
}
160+
}
161+
162+
/**
163+
* Async atomic write (#612): same temp+harden+rename and residual-temp policy as
164+
* atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed
165+
* by the final destination path (not the unique temp, not the parent directory).
166+
*/
167+
export async function atomicWriteFileAsync(
168+
path: string,
169+
content: string,
170+
io?: AtomicWriteAsyncIO,
171+
): Promise<void> {
172+
const effective: AtomicWriteAsyncIO = io ?? {
173+
write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
174+
harden: async target => {
175+
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
176+
if (process.platform === "win32") {
177+
await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path });
178+
}
179+
},
180+
rename: renameAtomicFileAsync,
181+
truncate: target => truncateSync(target, 0),
182+
unlink: unlinkSync,
183+
};
184+
const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
185+
let hardened = false;
186+
try {
187+
await effective.write(tmp, content);
188+
await effective.harden(tmp);
189+
hardened = true;
190+
await effective.rename(tmp, path);
191+
} catch (cause) {
192+
let scrubbed = false;
193+
try {
194+
await effective.truncate(tmp);
195+
scrubbed = true;
196+
} catch (error) {
197+
if (isMissingPathError(error)) scrubbed = true;
198+
else {
199+
try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
200+
}
201+
}
202+
let removed = false;
203+
try {
204+
await effective.unlink(tmp);
205+
removed = true;
206+
} catch (error) {
207+
if (isMissingPathError(error)) removed = true;
208+
else {
209+
try { await effective.unlink(tmp); removed = true; }
210+
catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
211+
}
212+
}
213+
if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
214+
if (!removed && !hardened) {
215+
try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
216+
}
217+
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
218+
throw cause;
219+
}
220+
}
221+
138222
export class OpenAiTierBackupCleanupError extends Error {
139223
constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
140224
}

src/lib/windows-secret-acl.ts

Lines changed: 173 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
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

4347
export 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

7485
type IcaclsRunner = (args: string[], timeoutMs: number) => IcaclsResult;
86+
type AsyncIcaclsRunner = (args: string[], timeoutMs: number) => Promise<IcaclsResult>;
7587

7688
function 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+
94141
let icaclsRunner: IcaclsRunner = defaultIcaclsRunner;
142+
let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner;
95143
let platformOverride: string | null = null;
96144
let 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. */
104157
export function setPlatformForTests(value: string | null): void {
105158
platformOverride = value;
@@ -156,6 +209,10 @@ function currentWindowsUser(): string | undefined {
156209
*/
157210
const 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+
159216
function 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
322484
export 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

Comments
 (0)