/** * Windows per-user NTFS ACL hardening for secret files and directories. * * On Windows, `chmod` only controls POSIX-style bits in the ACE list and does NOT remove * inherited permissions from other users. Real per-user isolation requires icacls to: * 1. Grant the current user full control (icacls path /grant:r "CURRENTUSER:(F)") * 2. Disable inheritance (icacls path /inheritance:r) * 3. Strip broad explicit grants by SID (Everyone, Users, Authenticated Users) * * Owner grant MUST precede `/inheritance:r`. That flag is destructive: it drops inherited * ACEs immediately. If a later step times out after inheritance is removed but before an * explicit owner ACE exists, the temp is left with a protected empty DACL — owned by the * current user yet unreadable/ununlinkable until Full Control is restored (issue #596). * * On non-Windows platforms the helpers fall through to the caller's existing chmod-based * behaviour: they return ok:true without invoking any external process. * * Design: * hardenSecretPath(path, { required: false }) — non-fatal read-path mode. * Never throws. Returns { ok, diagnostics? }. * hardenSecretPath(path, { required: true }) — write-path mode. * Throws a sanitized error (no raw path) on every Windows ACL failure, * including genuine icacls timeouts. Required secret publication therefore * fails closed; only required:false read-path probes may soft-fail. * hardenSecretPathAsync / hardenSecretDirAsync — same policy, async icacls * runner so the event loop is not held for the child lifetime (#612). * HardenOptions.timeoutMemoKey — optional destination-path key for the * timeout memo (atomic writers mint unique temps; never a parent directory). * hardenSecretDir — same contract for directories. * hardenElevatedStagePath / hardenElevatedStageDir — a second, deliberately * separate ACL shape for NON-secret payloads an elevated process must read * (issue #4779). It keeps the owner grant and the broad-SID strip but also * grants read to SYSTEM and BUILTIN\Administrators. It is a distinct export * — not an option on the secret functions — so a secret call site cannot * reach the wider shape by accident, and its success memos live in their * own caches so one shape never satisfies the other's lookup. */ import { existsSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { env, platform } from "node:process"; import { SUBPROCESS_KILL_GRACE_MS, waitForSubprocessExit, type SubprocessDeadlineScheduler, } from "./bounded-subprocess"; import { resolveTrustedWindowsIcaclsExe } from "./windows-elevation"; import { cachedCurrentWindowsIdentity, resolveCurrentWindowsPrincipal, resolveCurrentWindowsPrincipalAsync, setSyntheticWindowsPrincipalForTests, } from "./windows-user-principal"; const hardenedDirectories = new Map(); const hardenedPaths = new Map(); /** Elevated-stage memos are kept apart from the secret ones: the ACL shapes differ. */ const hardenedStageDirectories = new Map(); const hardenedStagePaths = new Map(); /** * Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them. * `consumed: false` means one explicitly authorized recovery attempt remains; * `consumed: true` means that attempt was consumed at `consumedAt`. * * A consumed memo re-arms once per `TIMEOUT_MEMO_REARM_MS` window, and only for a * caller carrying `retryTimedOutOnce` (issue #3522). The destination-keyed memo for a * STABLE path — the response-spill directory — used to refuse every later harden for * the life of the process: one transient icacls outage consumed the single recovery * and durable publication never succeeded again, while the process reported healthy. * The window keeps anti-restall bounded (at most one real attempt per window) while a * recovered runner can clear the memo and resume publication without a restart. */ const timedOutPaths = new Map(); /** * Quiet period before a consumed timeout memo admits one caller-owned recovery * attempt. Far above the per-harden deadline cap (60s) so a still-stalled icacls * produces at most one bounded probe per window and every other refusal stays * instant; short enough that a recovered icacls resumes same-process hardening * without waiting for a restart. */ export const TIMEOUT_MEMO_REARM_MS = 5 * 60_000; /** Compatibility slack before the outer belt releases a caller whose killed child has not reaped. */ const ASYNC_ICACLS_BELT_MARGIN_MS = 250; interface RemovalIdentity { readonly lexical: string; readonly canonical: string | undefined } interface AsyncAclWork { readonly identity: RemovalIdentity; readonly reap: Promise } const pendingAsyncIcaclsReaps = new Map>(); const activeAsyncAclWork = new Map>(); const scheduleAsyncIcaclsBelt: SubprocessDeadlineScheduler = (callback, milliseconds) => { const timer = setTimeout(callback, milliseconds); return () => clearTimeout(timer); }; let asyncIcaclsBeltScheduler: SubprocessDeadlineScheduler = scheduleAsyncIcaclsBelt; /** * The memo value: the `object` plus `freshness` of a file a harden was actually * attributed to. * * There is deliberately no null member. An observation that cannot be read is * not stored at all — the entry is deleted — because a "recorded as unverifiable" * value was dead code the moment attribution became a before/after comparison, * and a branch nothing can reach is a branch no test can defend. * * It is the observation itself rather than a joined string so that the two * questions stay separately askable after storage. `reattributeHardenedSecretPath` * has to compare the object while deliberately ignoring the freshness, and * recovering one half out of `dev:ino:ctimeNs` by counting colons would make that * comparison depend on a format nothing declares. */ type HardenedIdentity = PathObservation; /** * What a stat can tell us about WHICH OBJECT is at a path. * * Two fields, deliberately separated, because conflating them shipped a bug: * * - `object` — `dev:ino`. Answers "is this the same file". Survives an ACL or * permission change, which is exactly what we need across an icacls call. * - `freshness` — `ctimeNs`. Answers "has this file's metadata moved since". It * distinguishes an unlink/recreate that ext4 gave the same inode back for, and * it MOVES when permissions change. * * The first version used `dev:ino:ctimeNs` for both jobs. Since chmod bumps ctime * — probed, `{ctimeChangedByChmod: true}` — and icacls is a permission change, the * before/after comparison would have rejected its own successful harden and failed * closed on every first harden on Windows. Requiring the identity to be unchanged * across an operation whose entire purpose is to change it is not a strict check; * it is a broken one. */ interface PathObservation { readonly object: string; readonly freshness: string; } /** * Observe which object is at a path, and how fresh it is. * * `dev:ino` alone is not enough to detect a replacement, which a Linux CI run * proved: ext4 reuses the inode of an unlinked file immediately — 100 of 100 * unlink/recreate cycles produced the SAME `ino`, while macOS reused none in 200 * and happily reported the earlier fix working. That is why `freshness` exists; * `ctimeNs` differed in 100 of 100 of those same cycles. * * `bigint: true` is used because `ctimeNs` exists only in that variant. * * Test seam: `setStatForTests` replaces this reader so a test can vary `dev`, * `ino`, and `ctimeNs` independently. Mirroring the implementation's string * format in test setup proves nothing about which components production uses — * an audit removed `dev` and all forty tests still passed. * * UNVERIFIED: the plain `ino` is reported to be 0 on NTFS while the bigint form * carries the file index, and the zero-ino guard exists for that case. Neither * Darwin nor Linux CI can confirm it and no pinned-Bun Windows probe has run. * It is defensive code, not a demonstrated platform fact. */ type StatReader = (path: string) => { dev: bigint; ino: bigint; ctimeNs: bigint }; const defaultStatReader: StatReader = path => { const s = statSync(path, { bigint: true }); return { dev: s.dev, ino: s.ino, ctimeNs: s.ctimeNs }; }; let statReader: StatReader = defaultStatReader; /** Test seam: drive dev / ino / ctime independently. */ export function setStatForTests(reader: StatReader | null): void { statReader = reader ?? defaultStatReader; } function observe(targetPath: string): PathObservation | null { try { const s = statReader(targetPath); if (s.ino === 0n) return null; return { object: `${s.dev}:${s.ino}`, freshness: `${s.ctimeNs}` }; } catch { return null; } } function sameObservation(a: PathObservation, b: PathObservation): boolean { return a.object === b.object && a.freshness === b.freshness; } /** * True only when this exact FILE was hardened, not merely this pathname. * * The memo used to be a `Set` of paths. A stable destination — such as * the coordinator database `hardenStableLockFile` hardens — can be unlinked and * recreated at the same name, and the replacement inherited the previous file's * hardening while never having been through icacls. Ephemeral temps escaped this * only because atomic writers call `forgetEphemeralSecretPath` once the temp is * gone; nothing does that for a stable path. */ function memoSatisfied(cache: Map, targetPath: string): boolean { const remembered = cache.get(targetPath); if (remembered === undefined) return false; const current = observe(targetPath); // Unreadable now is not "unchanged": re-harden rather than trust a value we // cannot confirm still describes what is there. // // A miss RETIRES the entry rather than leaving it. Keeping it left the cache in // a state nothing could justify: after a mismatch and a failed re-harden, the // stale value survived, so restoring the old identity would satisfy it again // without any ACL work. That needs exact-identity ABA to bite — outside the // proof bound this unit claims — but "the consequence is out of scope" is not a // reason to keep an entry we have just proven does not describe what is there. if (current === null || !sameObservation(current, remembered)) { cache.delete(targetPath); return false; } return true; } /** * Record a harden ONLY if the file we hardened is still the file at that path. * * Reading identity after the ACL sequence returns answers "what is there now", * which is not the same question as "what did icacls operate on". A replacement * landing mid-sequence — probed by swapping the file during the final * `/remove:g` — made the memo remember the REPLACEMENT as hardened, so the next * acquisition skipped ACL work on a file that had never seen it: * * {identityChangedDuringHarden: true, callsForOriginal: 3, totalCalls: 3, * replacementWasHardened: false} * * So the OBJECT is captured before the sequence and compared after it. Only the * object — `dev:ino` — because icacls changes permissions, and `ctimeNs` moves * when permissions change (probed: `{ctimeChangedByChmod: true}`). Comparing the * full identity across the call would have rejected every successful harden and * failed closed on the first harden on Windows: the check would have been * demanding that an operation not do the thing it exists to do. * * The memo then stores the object plus the freshness read AFTER hardening, which * is the state a later lookup should match. * * A changed object, or an unreadable observation at either end, means we cannot * say what was hardened: the memo is cleared rather than written, and required * callers fail closed. An optional caller soft-fails, as it does for every other * unproven ACL. * * Returns true when the harden may be reported successful. */ function recordHarden( cache: Map, targetPath: string, before: PathObservation | null, ): boolean { const after = observe(targetPath); if (before === null || after === null || before.object !== after.object) { // Never leave a memo behind for a file we cannot vouch for, including one // written by an earlier successful harden of a now-replaced file. cache.delete(targetPath); return false; } cache.set(targetPath, after); return true; } const SUBSTITUTED_DIAGNOSTIC = "ACL hardening could not be attributed — the file at this path changed during hardening"; export interface HardenResult { ok: boolean; diagnostics?: string; } export interface HardenOptions { required: boolean; /** * Explicit total budget for this harden call. Shutdown recovery uses a reduced * caller-owned slice instead of opening the normal 30-second window. */ deadlineMs?: number; /** * Optional timeout-memo key distinct from `targetPath` (issue #612). * Atomic writers mint a fresh `.tmp` path per write; keying the timeout cache by the * final destination path prevents re-stalling the event loop on every subsequent temp. * Must NOT be a parent directory — directory ACLs are not authoritative for new files. */ timeoutMemoKey?: string; /** * Consume the one recovery attempt for a previously timed-out memo key. * Only a caller that owns its own single-flight and bounded retry policy should * set this. It never clears or bypasses an already-consumed timeout memo before * its re-arm window (`TIMEOUT_MEMO_REARM_MS`) has elapsed. */ retryTimedOutOnce?: boolean; } /** * Total icacls budget per harden call — ALL steps share it, including the single * timeout retry and the diagnostic verification pass (no per-attempt fresh budget: * loadConfig hardens dir+config+auth sequentially, so per-attempt budgets stack * into multi-minute startup stalls). Override with OPENCODEX_ACL_TIMEOUT_MS * (integer ms, clamped to [1000, 60000]; invalid values fall back to 30000). * * The default was 5s until #1156. One envelope has to cover the whole sequence — * `/grant:r`, `/inheritance:r`, `/remove:g`, plus the conditional `/findsid` * verification — and on machines where icacls is slow (Defender real-time scanning, * roaming profiles, a domain-controller round trip) 5s ran out mid-sequence. The * harden then failed closed, the native-main owner published a permanent * `unavailable`, and every native request returned 503 until restart. A slow start * is recoverable; that is not. * * The cost is honest and worth stating: because loadConfig hardens three paths * sequentially, the timeout-path worst case at load is ~90s, and the owner path * (initial call + one recovery) is ~60.25s. Both require icacls to be * pathologically slow on every call; a healthy machine finishes in milliseconds * and sees no change. Operators who prefer the old bound can set the env override. */ const HARDEN_DEADLINE_DEFAULT_MS = 30_000; const HARDEN_DEADLINE_MIN_MS = 1_000; const HARDEN_DEADLINE_MAX_MS = 60_000; /** Resolve the total harden budget once per call (env mutation cannot change it midway). */ function resolveHardenDeadlineMs(overrideMs?: number): number { if (overrideMs !== undefined) { if (!Number.isSafeInteger(overrideMs) || overrideMs <= 0) return 1; return Math.min(HARDEN_DEADLINE_MAX_MS, overrideMs); } const raw = env["OPENCODEX_ACL_TIMEOUT_MS"]?.trim(); if (!raw) return HARDEN_DEADLINE_DEFAULT_MS; const parsed = Number(raw); if (!Number.isSafeInteger(parsed)) return HARDEN_DEADLINE_DEFAULT_MS; return Math.min(HARDEN_DEADLINE_MAX_MS, Math.max(HARDEN_DEADLINE_MIN_MS, parsed)); } export interface IcaclsResult { success: boolean; exitCode: number | null; timedOut: boolean; stdout: string; /** * Diagnostics only: how a failure was produced when it is not a child * result. `spawn_failed` means the spawn call itself threw — no process * existed; `runner_rejected` means the runner promise rejected without a * result; `runner_watchdog` means the caller-side belt fired before the * runner settled — a killed child may still be reaping. Absent means * `child_result` (a spawned child produced a verdict, including timeouts). */ readonly failureStage?: "spawn_failed" | "runner_rejected" | "runner_watchdog"; /** * Allowlisted OS code from the failed spawn (e.g. ENOENT), never an error * object, message, or path. Present only alongside `failureStage === * "spawn_failed"`. */ readonly spawnErrorCode?: string; } type IcaclsRunner = (args: string[], timeoutMs: number) => IcaclsResult; type AsyncIcaclsRunner = (args: string[], timeoutMs: number) => Promise; /** The only icacls operations this module runs; argv is never derived from it. */ export type IcaclsStep = "/grant:r" | "/inheritance:r" | "/remove:g" | "/findsid"; const ICACLS_STEPS: ReadonlySet = new Set(["/grant:r", "/inheritance:r", "/remove:g", "/findsid"]); /** * Fixed failure stages for `aclFailure` diagnostics: the shared deadline ran * out before a spawn was attempted, the spawn itself threw, the runner * rejected, the caller-side watchdog fired before the runner settled, or a * child process produced a result. Enum literals only — never parsed from * errors, and each value is written only by the code that can prove it. */ export type AclFailureStage = | "deadline_before_spawn" | "spawn_failed" | "runner_rejected" | "runner_watchdog" | "child_result"; /** * Bounded, allowlisted failure metadata carried on a sanitized ACL error. * Every field is a fixed enum literal, a boolean, or a finite number — no * path, argv, SID, stdout/stderr, message or stack ever crosses this boundary, * so a caller may log or branch on it without re-sanitizing. */ export interface AclFailureDiagnostics { /** The icacls step being attempted — one of the fixed literals used by runIcacls. */ readonly step: IcaclsStep; readonly stage: AclFailureStage; /** * For spawned steps, the measured runner time; for `deadline_before_spawn`, * how much of the shared sequence had already elapsed. */ readonly elapsedMs: number; /** The exact remaining budget handed to the runner (<= 0 when refused pre-spawn). */ readonly budgetMs: number; readonly success: boolean; readonly exitCode: number | null; readonly timedOut: boolean; readonly spawnErrorCode?: string; } function resolveIcaclsExecutable(): string { // Same authority as schtasks/powershell: never take icacls from PATH. // A bun-shim or stripped PATH makes `icacls.exe` throw ENOENT, which used to // surface as "filesystem may not support per-user NTFS ACLs". return resolveTrustedWindowsIcaclsExe(); } /** * OS spawn codes safe to surface in diagnostics: fixed symbolic names that * say WHY the child could not start. Anything else — messages, paths, * platform-specific strings — stays inside the process. */ const ALLOWED_SPAWN_ERROR_CODES: ReadonlySet = new Set([ "ENOENT", "EACCES", "EPERM", "EAGAIN", "ENOMEM", ]); function allowlistedSpawnErrorCode(code: unknown): string | undefined { return typeof code === "string" && ALLOWED_SPAWN_ERROR_CODES.has(code) ? code : undefined; } /** Extract an allowlisted spawn code from a thrown spawn error; never the error itself. */ function spawnErrorCodeOf(error: unknown): string | undefined { if (error && typeof error === "object" && "code" in error) { return allowlistedSpawnErrorCode((error as { code?: unknown }).code); } return undefined; } function spawnFailedResult(spawnErrorCode?: string): IcaclsResult { return { success: false, exitCode: null, timedOut: false, stdout: "", failureStage: "spawn_failed", ...(spawnErrorCode !== undefined ? { spawnErrorCode } : {}), }; } /** * The async runner rejected instead of producing a result. That is not spawn * evidence — nothing proves a child ever existed — so it keeps its own stage * and carries no spawnErrorCode. */ function runnerRejectedResult(): IcaclsResult { return { success: false, exitCode: null, timedOut: false, stdout: "", failureStage: "runner_rejected", }; } /** * Spawn icacls asynchronously, or return null when the executable cannot be * launched. The pipe/ignore stdio literals stay inferred here so `stdout` keeps * its `ReadableStream` type instead of widening to the generic default. */ function trySpawnIcacls(args: string[]) { try { const proc = Bun.spawn([resolveIcaclsExecutable(), ...args], { stdin: "ignore", stdout: "pipe", stderr: "ignore", windowsHide: true, }); return { proc, spawnErrorCode: undefined }; } catch (error) { return { proc: null, spawnErrorCode: spawnErrorCodeOf(error) }; } } function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult { // Bun.spawnSync with windowsHide: Node execFileSync has hung under the GUI/proxy even // with windowsHide, and console-subsystem tools flash a visible window otherwise. try { const result = Bun.spawnSync([resolveIcaclsExecutable(), ...args], { stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: timeoutMs, windowsHide: true, }); return { success: result.success, exitCode: result.exitCode, timedOut: result.exitedDueToTimeout ?? false, stdout: result.stdout ? result.stdout.toString() : "", }; } catch (error) { return spawnFailedResult(spawnErrorCodeOf(error)); } } /** * Async icacls runner (#612): yields the event loop while waiting for the child. * Async Subprocess has no exitedDueToTimeout, so the shared settlement helper * classifies the deadline and keeps waiting for a killed child to actually exit. */ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise { const spawned = trySpawnIcacls(args); if (!spawned.proc) return spawnFailedResult(spawned.spawnErrorCode); const proc = spawned.proc; const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs); const stdout = !timedOut && proc.stdout ? await new Response(proc.stdout).text().catch(() => "") : ""; return { success: !timedOut && exitCode === 0, exitCode: timedOut ? null : exitCode, timedOut, stdout, }; } function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise { return new Promise(resolve => { let settled = false; let cancelBelt: (() => void) | undefined; const finish = (result: IcaclsResult): void => { if (settled) return; settled = true; cancelBelt?.(); resolve(result); }; const run = () => asyncIcaclsRunner(args, timeoutMs).then( result => { finish(result); }, () => { finish(runnerRejectedResult()); }, ); // Capture and register ownership before the child can start or mutate its alias. const tracked = args[0] ? startTrackedAsyncAclWork(args[0], run) : undefined; if (!tracked) run(); // The belt has to outlast the runner it is guarding, or it is not a belt -- it is the // deadline. The runner may now legitimately outlive it while a killed child is reaped. The // caller is still released, but the target is registered so removal can wait for the distinct // handle-release question instead of treating flight settlement as proof that the child died. cancelBelt = asyncIcaclsBeltScheduler( () => { const targetPath = args[0]; if (targetPath && tracked) registerPendingAsyncIcaclsReap(targetPath, tracked.entry); finish({ success: false, exitCode: null, timedOut: true, stdout: "", failureStage: "runner_watchdog", }); }, Math.max(1, timeoutMs) + SUBPROCESS_KILL_GRACE_MS + ASYNC_ICACLS_BELT_MARGIN_MS, ); }); } function registerPendingAsyncIcaclsReap( targetPath: string, entry: AsyncAclWork, registry = pendingAsyncIcaclsReaps, ): void { let pending = registry.get(targetPath); if (!pending) { pending = new Set(); registry.set(targetPath, pending); } pending.add(entry); void entry.reap.then(() => { pending!.delete(entry); if (pending!.size === 0) registry.delete(targetPath); }); } /** Register the captured identity before invoking a work/child factory. */ function startTrackedAsyncAclWork(targetPath: string, start: () => Promise): { work: Promise; entry: AsyncAclWork } { const identity = captureRemovalIdentity(targetPath); let release!: () => void; const entry: AsyncAclWork = { identity, reap: new Promise(done => { release = done; }) }; registerPendingAsyncIcaclsReap(targetPath, entry, activeAsyncAclWork); try { const work = start(); void work.then(release, release); return { work, entry }; } catch (error) { release(); throw error; } } function canonicalRemovalPath(path: string): string | undefined { const missing: string[] = []; let current = resolve(path); while (true) { try { return resolve(realpathSync.native(current), ...missing); } catch (error) { if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return undefined; const parent = dirname(current); if (parent === current) return undefined; missing.unshift(basename(current)); current = parent; } } } function captureRemovalIdentity(path: string): RemovalIdentity { return { lexical: resolve(path), canonical: canonicalRemovalPath(path) }; } function pathIsAtOrBelow(target: string, root: string): boolean { const suffix = relative(root, target); return suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix); } function retainedWorkIsAtOrBelow(entry: AsyncAclWork, root: RemovalIdentity, includeAncestors = false): boolean { const target = entry.identity; // Failure at capture stays conservative, even if the alias later becomes readable. if (target.canonical === undefined || root.canonical === undefined) return true; const current = canonicalRemovalPath(target.lexical); if (current === undefined) return true; const targets = [target.lexical, target.canonical, current]; return [root.lexical, root.canonical].some(rootPath => targets.some(targetPath => pathIsAtOrBelow(targetPath, rootPath) || (includeAncestors && pathIsAtOrBelow(rootPath, targetPath)))); } /** Admission can refuse without surrendering the registry's retained-child cleanup ownership. */ export class WindowsSecretAclSettleTimeoutError extends Error { readonly code = "ETIMEDOUT"; readonly aclFailureOrigin = "acl_settle_timeout"; constructor() { super("ACL settlement timed out"); this.name = "WindowsSecretAclSettleTimeoutError"; } } /** Run a synchronous mutation after overlapping ACL work exits, within one harden budget. */ export async function withWindowsSecretAclSettled(rootPath: string, mutate: () => T): Promise { const deadline = nowFn() + resolveHardenDeadlineMs(); const root = captureRemovalIdentity(rootPath); let cancelDeadline: (() => void) | undefined; let timeout: Promise | undefined; let expired = false; try { while (true) { if (expired || nowFn() >= deadline) throw new WindowsSecretAclSettleTimeoutError(); const pending = [...activeAsyncAclWork.values(), ...pendingAsyncIcaclsReaps.values()] .flatMap(entries => [...entries]) .filter(entry => retainedWorkIsAtOrBelow(entry, root, true)) .map(entry => entry.reap); // No await between this final observation and the mutation. A newly queued hardener's // microtasks cannot run icacls until the synchronous callback has completely returned. if (pending.length === 0) return mutate(); timeout ??= new Promise((_resolve, reject) => { cancelDeadline = asyncIcaclsBeltScheduler(() => { expired = true; reject(new WindowsSecretAclSettleTimeoutError()); }, Math.max(1, deadline - nowFn())); }); await Promise.race([Promise.all(pending), timeout]); } } finally { cancelDeadline?.(); } } /** True while an async icacls runner still owns this exact path after its caller's belt fired. */ export function windowsSecretAclReapPendingForPath(targetPath: string): boolean { return (pendingAsyncIcaclsReaps.get(targetPath)?.size ?? 0) > 0; } /** Non-blocking removal guard for callers that must refuse rather than wait for a stuck child. */ export function windowsSecretAclReapPendingAtOrBelow(rootPath: string): boolean { const root = captureRemovalIdentity(rootPath); return [...activeAsyncAclWork.values(), ...pendingAsyncIcaclsReaps.values()] .some(entries => [...entries].some(entry => retainedWorkIsAtOrBelow(entry, root))); } /** * Removal barrier for a file or tree held by a normal or timed-out async icacls child. * * This wait is deliberately separate from ordinary startup and shutdown: a genuinely stuck child * must not defeat the caller-facing belt. Code that chooses to remove the target has the stricter * contract and must not proceed until every registered runner at or below it has actually reaped. */ export async function flushWindowsSecretAclReapsBeforeRemoval(rootPath: string): Promise { const root = captureRemovalIdentity(rootPath); while (true) { const pending = [...activeAsyncAclWork.values(), ...pendingAsyncIcaclsReaps.values()] .flatMap(entries => [...entries]) .filter(entry => retainedWorkIsAtOrBelow(entry, root)) .map(entry => entry.reap); if (pending.length === 0) return; await Promise.all(pending); } } let icaclsRunner: IcaclsRunner = defaultIcaclsRunner; let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner; let platformOverride: string | null = null; let nowFn: () => number = Date.now; /** Test seam: replace the icacls process runner. Pass null to restore the default. */ export function setIcaclsRunnerForTests(runner: IcaclsRunner | null): void { icaclsRunner = runner ?? defaultIcaclsRunner; } /** Test seam: replace the async icacls runner. Pass null to restore the default. */ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null): void { asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner; } /** Test seam: drive child belts and admission deadlines without sleeping. */ export function setAsyncIcaclsBeltSchedulerForTests( scheduler: SubprocessDeadlineScheduler | null, ): void { asyncIcaclsBeltScheduler = scheduler ?? scheduleAsyncIcaclsBelt; } /** * Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. * * Faking win32 on a host without System32 also has to supply a principal, or * every forced-branch test would fail on the identity lookup instead of * exercising icacls. The synthetic value is registered with the resolver, not * chosen here, so a test that injects its own runner still wins. */ const SYNTHETIC_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001"; export function setPlatformForTests(value: string | null): void { platformOverride = value; setSyntheticWindowsPrincipalForTests( value === "win32" && platform !== "win32" ? SYNTHETIC_TEST_PRINCIPAL : null, ); } /** Test seam: injectable clock for deadline tests (no real sleeps). */ export function setNowForTests(fn: (() => number) | null): void { nowFn = fn ?? Date.now; } /** Test seam: clear memo/failure caches between cases. */ export function resetHardenedStateForTests(): void { hardenedDirectories.clear(); hardenedPaths.clear(); hardenedStageDirectories.clear(); hardenedStagePaths.clear(); timedOutPaths.clear(); } /** Forget a successful harden only after this exact ephemeral path is gone. */ export function forgetHardenedSecretPath(targetPath: string): void { hardenedPaths.delete(targetPath); } /** * Re-attribute an existing file memo to the SAME object after the caller wrote * content to it through a descriptor whose identity it verified. * * This exists because `freshness` is `ctimeNs`, and on Windows libuv reports * `st_ctim` from the NTFS ChangeTime, which moves when file DATA is written. An * atomic writer therefore invalidated its own memo between the harden that * protects the empty temp and the harden before the rename, and paid a second * full `/grant:r` + `/inheritance:r` + `/remove:g` sequence to reapply the ACL * that was already on the file. Every secret write on Windows paid it twice. * * Only the freshness moves, and only for an unchanged object: a different object * retires the entry instead. A caller must have proven, immediately beforehand, * that `targetPath` resolves to the object its own descriptor refers to. * * The cost of this is worth stating exactly, because `PathObservation` documents * that freshness also moves when PERMISSIONS change, and this call cannot tell * the two apart. So a DACL change landing between the harden and this call is * absorbed instead of forcing a re-harden. That window is the caller's own * content write; every permission change after this call still moves ctime * again and still misses the memo, so the detection this memo provides is * relocated, not removed. * * What makes the absorbed window acceptable is who can be in it. Once the harden * has run, the DACL is an explicit owner-only ACE with inheritance removed, so * no other principal can open the file for `WRITE_DAC` at all. The one principal * who can still rewrite that DACL is one holding a handle opened BEFORE the * harden, and Windows keeps the access granted to an open handle: that principal * can equally rewrite the DACL after any later harden, and after the rename, on * the same object. A second mutation pass never bounded that capability — it * stripped an ACE the holder could immediately re-add — so declining to repeat * it removes no guarantee anyone had. * * Refusal is cheap and safe in either direction: an unmoved memo simply means the * caller's next harden runs in full. * * Returns whether the memo now describes what is at the path. */ export function reattributeHardenedSecretPath(targetPath: string): boolean { const remembered = hardenedPaths.get(targetPath); if (remembered === undefined) return false; const current = observe(targetPath); // Unreadable, or a different object: this is exactly the case the memo must // not cover. Retire it so the next harden is a real one. if (current === null || current.object !== remembered.object) { hardenedPaths.delete(targetPath); return false; } hardenedPaths.set(targetPath, current); return true; } /** * Ephemeral-path lifecycle release: clears the success memo AND any timeout * memo keyed by THIS TEMP path in both namespaces. Call only after the temp is * proven absent (successful rename, successful unlink, ENOENT, or an explicit * non-existence check). Never pass a stable destination: destination-keyed * timeout memos are intentional anti-restall state and are not touched here. */ export function forgetEphemeralSecretPath(tempPath: string): void { hardenedPaths.delete(tempPath); hardenedStagePaths.delete(tempPath); timedOutPaths.delete(`required:${tempPath}`); timedOutPaths.delete(`optional:${tempPath}`); } /** Directory counterpart for a proven-absent ephemeral staging root. */ export function forgetEphemeralSecretDir(tempPath: string): void { hardenedDirectories.delete(tempPath); hardenedStageDirectories.delete(tempPath); timedOutPaths.delete(`required:${tempPath}`); timedOutPaths.delete(`optional:${tempPath}`); } /** Test seam: timeout memo sets return to baseline after ephemeral cleanup. */ export function timedOutSecretPathCountForTests(): number { return timedOutPaths.size; } /** Test seam for proving ephemeral success memos do not grow across replacements. */ export function hardenedSecretPathCountForTests(): number { return hardenedPaths.size; } /** * Directory counterpart. It had no seam, and that absence hid a real gap: a * directory-only pathname memo passed every file-based test in this suite. */ export function hardenedSecretDirCountForTests(): number { return hardenedDirectories.size; } function effectivePlatform(): string { return platformOverride ?? platform; } /** Test-aware platform predicate for callers that must avoid even a no-op ACL call. */ export function windowsSecretAclApplies(): boolean { return (platformOverride ?? process.platform) === "win32"; } /** * Per-spawn observation measured by run(): which stage produced the result, * how long it took, and the exact budget it was given. Measured at the call * site — not inside the runner — so injected runners are observed identically. */ interface IcaclsRunMeta { readonly stage: AclFailureStage; readonly elapsedMs: number; readonly budgetMs: number; } /** Runner-reported failure provenance; anything else is treated as a child result. */ const RUNNER_FAILURE_STAGES: ReadonlySet = new Set([ "spawn_failed", "runner_rejected", "runner_watchdog", ]); /** * An injected runner may write any string into failureStage, so the enum is * re-validated at the boundary instead of trusting the declared type. */ function runnerFailureStageOf(result: IcaclsResult): AclFailureStage { return typeof result.failureStage === "string" && RUNNER_FAILURE_STAGES.has(result.failureStage) ? result.failureStage : "child_result"; } function finiteOrZero(value: number): number { return Number.isFinite(value) ? value : 0; } /** Error carrying an honest code: ETIMEDOUT only for real timeouts, EICACLS otherwise. */ function icaclsError(step: IcaclsStep, result: IcaclsResult, meta: IcaclsRunMeta): NodeJS.ErrnoException { // Coerce once so the error code and the diagnostics record can never disagree. // Boolean() keeps the original truthy semantics the memo/retry callers relied // on — a mistyped truthy timedOut still means "timeout", not "child ok". const timedOut = Boolean(result.timedOut); const err = new Error( timedOut ? `icacls ${step} timed out` : `icacls ${step} exited ${result.exitCode ?? "null"}`, ) as NodeJS.ErrnoException & { aclFailure: AclFailureDiagnostics }; err.code = timedOut ? "ETIMEDOUT" : "EICACLS"; // Injected runners are untrusted: every field is coerced back to its declared // enum/number/boolean shape so only allowlisted values reach the boundary. const spawnErrorCode = result.failureStage === "spawn_failed" ? allowlistedSpawnErrorCode(result.spawnErrorCode) : undefined; err.aclFailure = { step, stage: meta.stage, elapsedMs: finiteOrZero(meta.elapsedMs), budgetMs: finiteOrZero(meta.budgetMs), success: Boolean(result.success), exitCode: typeof result.exitCode === "number" && Number.isFinite(result.exitCode) ? result.exitCode : null, timedOut, ...(spawnErrorCode !== undefined ? { spawnErrorCode } : {}), }; return err; } /** * The ACL principal is the effective token SID and nothing else. * * There is no name-shaped fallback here, and that absence is the fix for #1149 * rather than an omission. `USERDOMAIN\USERNAME` has the right shape but is not * evidence of the current token's subject, and both variables are writable by * the process that launched us. Granting Full Control to a wrong principal and * then running `/inheritance:r` is destructive in both directions: another * account can be left holding the secret, or the file can be left with no ACE * the current user can use. When the SID cannot be resolved we decline. * * Non-Windows hosts that force this branch through `setPlatformForTests` get * their principal from `setSyntheticWindowsPrincipalForTests`, which lives with * the resolver so an injected runner can still take precedence over it. */ function currentWindowsPrincipal(deadline: number): string { return resolveCurrentWindowsPrincipal(deadline - nowFn()); } async function currentWindowsPrincipalAsync(deadline: number): Promise { return resolveCurrentWindowsPrincipalAsync(deadline - nowFn()); } /** * Run icacls to harden a single file system entry. * Order is intentional (issue #596): grant the owner ACE first so a later * `/inheritance:r` or `/remove:g` timeout cannot strand a protected zero-ACE DACL. * * We do NOT use a shell string; all arguments are passed as an array so no * shell injection is possible even for paths with unusual characters. * * Throws the raw child_process error on failure (caller sanitizes). */ const BROAD_SIDS = ["*S-1-1-0", "*S-1-5-11", "*S-1-5-32-545"] as const; /** * Well-known SIDs an elevated process may run as (#4779). * * An over-the-shoulder UAC prompt answered with a DIFFERENT administrator's * credentials produces an elevated token whose subject is that admin — not the * account that staged the payload. BUILTIN\Administrators covers that token; * SYSTEM covers an elevation launched from a service context. These SIDs are * deliberately absent from BROAD_SIDS: the staged payload must stay readable * by them after /remove:g runs. */ const ELEVATED_STAGE_READ_SIDS = ["*S-1-5-18", "*S-1-5-32-544"] as const; function grantAce(user: string, directory: boolean): string { return directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`; } /** * Read-only ACEs for the elevated-stage shape: read on a file, read+traverse * inherited by children on a directory. Read only — tamper-evidence comes from * the pinned handles and the SHA-256 the elevated script verifies, not from the * DACL, so no elevated principal needs write. */ function elevatedStageReadAces(directory: boolean): string[] { const rights = directory ? "(OI)(CI)(RX)" : "(R)"; return ELEVATED_STAGE_READ_SIDS.map(sid => `${sid}:${rights}`); } function existingAclIsCompliant( targetPath: string, directory: boolean, stdout: string, ownerName: string, ): boolean { const lines = stdout.replaceAll("\r", "").split("\n"); const first = lines.shift(); if (!first || first.slice(0, targetPath.length).toLowerCase() !== targetPath.toLowerCase()) { return false; } const separator = first[targetPath.length]; if (separator !== undefined && !/\s/.test(separator)) return false; const aceLines: string[] = []; const firstAce = first.slice(targetPath.length).trim(); if (firstAce) aceLines.push(firstAce); for (const line of lines) { if (!line.trim()) break; // Localized summary text is not indented like a continuation ACE. if (!/^\s/.test(line)) break; aceLines.push(line.trim()); } if (aceLines.length !== 1) return false; const match = /^([^:]+):((?:\([A-Z]+\))+)$/.exec(aceLines[0]!); if (!match || match[1]!.trim().toLowerCase() !== ownerName.toLowerCase()) return false; const rights = [...match[2]!.matchAll(/\(([A-Z]+)\)/g)].map(part => part[1]); const expected = directory ? ["OI", "CI", "F"] : ["F"]; return rights.length === expected.length && rights.every((right, index) => right === expected[index]); } function shouldVerifyExistingAcl(): boolean { return env["OPENCODEX_ACL_VERIFY_EXISTING"] === "1"; } function existingAclAlreadyCompliant(targetPath: string, directory: boolean, deadline: number): boolean { if (!shouldVerifyExistingAcl()) return false; const identity = cachedCurrentWindowsIdentity(); if (!identity) return false; try { const remaining = deadline - nowFn(); if (remaining <= 0) return false; const result = icaclsRunner([targetPath], remaining); return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name); } catch { return false; } } async function existingAclAlreadyCompliantAsync( targetPath: string, directory: boolean, deadline: number, ): Promise { if (!shouldVerifyExistingAcl()) return false; const identity = cachedCurrentWindowsIdentity(); if (!identity) return false; try { const remaining = deadline - nowFn(); if (remaining <= 0) return false; const result = await awaitAsyncIcaclsRunner([targetPath], remaining); return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name); } catch { return false; } } function runIcacls(targetPath: string, directory: boolean, deadline: number, extraReadAces: readonly string[]): void { const principal = currentWindowsPrincipal(deadline); const sequenceStartedAt = nowFn(); // The deadline is owned by hardenEntry (total budget incl. retry + verification). const run = (step: IcaclsStep, args: string[]): { result: IcaclsResult; meta: IcaclsRunMeta } => { const remaining = deadline - nowFn(); if (remaining <= 0) { throw icaclsError( step, { success: false, exitCode: null, timedOut: true, stdout: "" }, { stage: "deadline_before_spawn", elapsedMs: Math.max(0, nowFn() - sequenceStartedAt), budgetMs: remaining, }, ); } const started = nowFn(); const result = icaclsRunner(args, remaining); return { result, meta: { stage: runnerFailureStageOf(result), elapsedMs: Math.max(0, nowFn() - started), budgetMs: remaining, }, }; }; const runOrThrow = (step: IcaclsStep, args: string[]): void => { const { result, meta } = run(step, args); if (!result.success) throw icaclsError(step, result, meta); }; // Step 1: grant current user full control BEFORE any destructive ACL change. // If this fails, inheritance is untouched and the writer keeps inherited access. runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory), ...extraReadAces]); // Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE // from step 1 survives this transition, so a later failure still leaves cleanup access. runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]); // Step 3: remove broad explicit grants using stable SIDs (not localized names). // Missing ACEs can yield a non-zero exit; verify with locale-independent /findsid // before accepting the failure as harmless — a swallowed real failure would leave // Everyone/Users/Authenticated Users grants while reporting hardened. // `/remove:g` cannot remove the explicit current-user ACE installed in step 1. const { result: removal, meta: removalMeta } = run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]); if (!removal.success) { if (removal.timedOut) throw icaclsError("/remove:g", removal, removalMeta); for (const sid of BROAD_SIDS) { const { result: found, meta: foundMeta } = run("/findsid", [targetPath, "/findsid", sid]); if (!found.success) throw icaclsError("/findsid", found, foundMeta); // icacls /findsid echoes the target path in its "SID Found" line only when the SID // still holds an ACE; the summary lines carry only counts. Matching the path echo — // not the (localized) prose — keeps the check locale-independent. if (found.stdout.includes(targetPath)) { throw icaclsError("/remove:g", removal, removalMeta); } } } } /** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number, extraReadAces: readonly string[]): Promise { const principal = await currentWindowsPrincipalAsync(deadline); const sequenceStartedAt = nowFn(); const run = async (step: IcaclsStep, args: string[]): Promise<{ result: IcaclsResult; meta: IcaclsRunMeta }> => { const remaining = deadline - nowFn(); if (remaining <= 0) { throw icaclsError( step, { success: false, exitCode: null, timedOut: true, stdout: "" }, { stage: "deadline_before_spawn", elapsedMs: Math.max(0, nowFn() - sequenceStartedAt), budgetMs: remaining, }, ); } const started = nowFn(); const result = await awaitAsyncIcaclsRunner(args, remaining); return { result, meta: { stage: runnerFailureStageOf(result), elapsedMs: Math.max(0, nowFn() - started), budgetMs: remaining, }, }; }; const runOrThrow = async (step: IcaclsStep, args: string[]): Promise => { const { result, meta } = await run(step, args); if (!result.success) throw icaclsError(step, result, meta); }; await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory), ...extraReadAces]); await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]); const { result: removal, meta: removalMeta } = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]); if (!removal.success) { if (removal.timedOut) throw icaclsError("/remove:g", removal, removalMeta); for (const sid of BROAD_SIDS) { const { result: found, meta: foundMeta } = await run("/findsid", [targetPath, "/findsid", sid]); if (!found.success) throw icaclsError("/findsid", found, foundMeta); if (found.stdout.includes(targetPath)) { throw icaclsError("/remove:g", removal, removalMeta); } } } } /** * Sanitize an error from a failed ACL operation into a safe diagnostic string. * The raw path must not appear in the returned string (it may contain * sensitive username components or PII from the home directory path). */ function sanitizeDiagnostics(error: unknown): string { // We do not expose the raw error message or any path-like fragments — // just an honest, code-specific cause (issue #160: a transient icacls stall // must not read like filesystem non-support). const code = error instanceof Error && "code" in error ? String((error as NodeJS.ErrnoException).code) : ""; switch (code) { case "ETIMEDOUT": return "ACL hardening timed out (ETIMEDOUT) — transient icacls stall; the volume may still support per-user NTFS ACLs"; case "EPERM": case "EACCES": return `ACL hardening failed (${code}) — permission denied running icacls`; case "EICACLS": return "ACL hardening failed (EICACLS) — icacls command error; filesystem may not support per-user NTFS ACLs"; case "EACLIDENTITY": return "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved"; default: return `ACL hardening failed${code ? ` (${code})` : ""} — filesystem may not support per-user NTFS ACLs`; } } function isTimeoutError(error: unknown): boolean { return error instanceof Error && "code" in error && String((error as NodeJS.ErrnoException).code) === "ETIMEDOUT"; } /** * Field-by-field allowlist copy of an aclFailure record from an untrusted cause. * A malformed field invalidates the whole record except `spawnErrorCode`, * which is optional and simply omitted when it is not an allowlisted code. */ function sanitizeAclFailure(cause: unknown): AclFailureDiagnostics | undefined { if (!cause || typeof cause !== "object" || !("aclFailure" in cause)) return undefined; const raw = (cause as { aclFailure?: unknown }).aclFailure; if (!raw || typeof raw !== "object") return undefined; const f = raw as Record; if (typeof f.step !== "string" || !ICACLS_STEPS.has(f.step)) return undefined; if ( f.stage !== "deadline_before_spawn" && f.stage !== "spawn_failed" && f.stage !== "runner_rejected" && f.stage !== "runner_watchdog" && f.stage !== "child_result" ) { return undefined; } if (typeof f.elapsedMs !== "number" || !Number.isFinite(f.elapsedMs)) return undefined; if (typeof f.budgetMs !== "number" || !Number.isFinite(f.budgetMs)) return undefined; if (typeof f.success !== "boolean" || typeof f.timedOut !== "boolean") return undefined; if (f.exitCode !== null && (typeof f.exitCode !== "number" || !Number.isFinite(f.exitCode))) { return undefined; } const spawnErrorCode = allowlistedSpawnErrorCode(f.spawnErrorCode); return { step: f.step as IcaclsStep, stage: f.stage, elapsedMs: f.elapsedMs, budgetMs: f.budgetMs, success: f.success, exitCode: f.exitCode as number | null, timedOut: f.timedOut, ...(spawnErrorCode !== undefined ? { spawnErrorCode } : {}), }; } /** Preserve only the bounded machine-readable cause on a sanitized public error. */ function sanitizedAclError(diagnostics: string, cause: unknown): NodeJS.ErrnoException { const error = new Error(diagnostics) as NodeJS.ErrnoException & { aclFailure?: AclFailureDiagnostics }; const code = cause && typeof cause === "object" && "code" in cause ? String((cause as { code?: unknown }).code) : ""; // EACLIDENTITY belongs here for the same reason as the rest: a caller that // catches a required-mode failure has to tell "the SID could not be resolved" // apart from "icacls stalled". Without it the code was dropped and only the // message carried the cause, which no caller can branch on. if ( code === "ETIMEDOUT" || code === "EICACLS" || code === "EACCES" || code === "EPERM" || code === "EACLIDENTITY" ) { error.code = code; } const aclFailure = sanitizeAclFailure(cause); if (aclFailure) error.aclFailure = aclFailure; return error; } type TimeoutMemoRefusalError = NodeJS.ErrnoException & { aclFailureOrigin: "timeout_memo_refusal"; }; function previousTimeoutError(retryConsumed: boolean): TimeoutMemoRefusalError { if (retryConsumed) { const error = new Error( "ACL hardening skipped — the previous timeout recovery was already consumed", ) as NodeJS.ErrnoException; error.code = "EACLRETRYEXHAUSTED"; return Object.assign(error, { aclFailureOrigin: "timeout_memo_refusal" as const }); } return Object.assign(sanitizedAclError( "ACL hardening skipped — previous attempt timed out", Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }), ), { aclFailureOrigin: "timeout_memo_refusal" as const }); } /** * Consume the single explicit recovery attempt for this key. A consumed key re-arms * only after `TIMEOUT_MEMO_REARM_MS` of quiet, and only for `retryTimedOutOnce` — * flagless callers always get an instant refusal. */ function timeoutMemoErrorIfBlocked( memoKey: string, opts: HardenOptions, ): NodeJS.ErrnoException | null { const entry = timedOutPaths.get(memoKey); if (entry === undefined) return null; // A re-armed memo reports the plain previous-timeout refusal to flagless callers // (so a retry-owning caller that saw it comes back carrying the flag) but admits // one fresh attempt only to `retryTimedOutOnce`. const rearmed = entry.consumed && nowFn() - entry.consumedAt >= TIMEOUT_MEMO_REARM_MS; if (opts.retryTimedOutOnce && (!entry.consumed || rearmed)) { timedOutPaths.set(memoKey, { consumed: true, consumedAt: nowFn() }); return null; } return previousTimeoutError(entry.consumed && !rearmed); } function recordTimeout(memoKey: string): void { if (!timedOutPaths.has(memoKey)) timedOutPaths.set(memoKey, { consumed: false, consumedAt: 0 }); } /** * Diagnostic-only post-timeout probe (never promotes to ok:true — a clean /findsid * does not prove inheritance was disabled or the user grant ran; only a fully * completed harden sequence may enter the hardened cache). Bounded by the remaining * total budget; returns a short state note for the soft-fail diagnostic. */ function describeAclStateAfterTimeout(targetPath: string, deadline: number): string { try { for (const sid of BROAD_SIDS) { const remaining = deadline - nowFn(); if (remaining <= 0) return "ACL state unverified (budget exhausted)"; const found = icaclsRunner([targetPath, "/findsid", sid], remaining); if (!found.success) return "ACL state unverified (probe failed)"; if (found.stdout.includes(targetPath)) return "broad ACL grants still present"; } return "no broad ACL grants detected (hardening still incomplete)"; } catch { return "ACL state unverified (probe failed)"; } } async function describeAclStateAfterTimeoutAsync(targetPath: string, deadline: number): Promise { try { for (const sid of BROAD_SIDS) { const remaining = deadline - nowFn(); if (remaining <= 0) return "ACL state unverified (budget exhausted)"; const found = await awaitAsyncIcaclsRunner([targetPath, "/findsid", sid], remaining); if (!found.success) return "ACL state unverified (probe failed)"; if (found.stdout.includes(targetPath)) return "broad ACL grants still present"; } return "no broad ACL grants detected (hardening still incomplete)"; } catch { return "ACL state unverified (probe failed)"; } } function timeoutMemoKey(targetPath: string, opts: HardenOptions): string { // Destination-path memo only (issue #612). Never a parent directory — directory ACLs // are not authoritative for newly created temps. // // Namespace by required-ness (#766): a soft `required:false` timeout during loadConfig // must not poison a later `required:true` management-token harden of the same path. const base = opts.timeoutMemoKey ?? targetPath; return `${opts.required ? "required" : "optional"}:${base}`; } /** * Shared harden flow for files and directories: one total budget (env-configurable) * covering the initial attempt, ONE timeout retry, and the diagnostic verification. * Required paths fail closed for every hardening failure, including genuine * timeouts. Optional read paths soft-fail with an honest diagnostic. */ function hardenEntry( targetPath: string, directory: boolean, opts: HardenOptions, cache: Map, extraReadAces: readonly string[], ): HardenResult { // Observed absence retires the memo. Leaving it would let a later file at this // path satisfy the cache if the filesystem ever hands back a matching identity. if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; } if (effectivePlatform() !== "win32") return { ok: true }; if (memoSatisfied(cache, targetPath)) return { ok: true }; const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs); // The owner-only compliance probe cannot vouch for a shape that also needs read ACEs. if (extraReadAces.length === 0 && existingAclAlreadyCompliant(targetPath, directory, deadline)) return { ok: true }; const memoKey = timeoutMemoKey(targetPath, opts); const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts); if (timeoutMemoError) { if (opts.required) throw timeoutMemoError; return { ok: false, diagnostics: timeoutMemoError.message }; } let lastErr: unknown; for (let attempt = 0; attempt < 2; attempt++) { if (attempt > 0 && deadline - nowFn() <= 0) break; // retry only while budget remains try { // Captured BEFORE the sequence: this is the file we are about to harden. const before = observe(targetPath); runIcacls(targetPath, directory, deadline, extraReadAces); if (!recordHarden(cache, targetPath, before)) { if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC); return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC }; } timedOutPaths.delete(memoKey); return { ok: true }; } catch (err) { // A substitution is not a transient icacls stall; do not spend the retry on it. if (err instanceof Error && err.message === SUBSTITUTED_DIAGNOSTIC) throw err; lastErr = err; if (!isTimeoutError(err)) break; // real failures do not retry } } const diagnostics = sanitizeDiagnostics(lastErr); if (isTimeoutError(lastErr)) { recordTimeout(memoKey); const state = describeAclStateAfterTimeout(targetPath, deadline); const annotated = `${diagnostics}; ${state}`; if (opts.required) throw sanitizedAclError(annotated, lastErr); console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`); return { ok: false, diagnostics: annotated }; } if (opts.required) throw sanitizedAclError(diagnostics, lastErr); return { ok: false, diagnostics }; } /** Retain removal ownership across principal lookup, every ACL command and diagnostics. */ function hardenEntryAsync( targetPath: string, directory: boolean, opts: HardenOptions, cache: Map, extraReadAces: readonly string[], ): Promise { if (!existsSync(targetPath)) { cache.delete(targetPath); return Promise.resolve({ ok: true }); } if (effectivePlatform() !== "win32" || memoSatisfied(cache, targetPath)) return Promise.resolve({ ok: true }); return startTrackedAsyncAclWork(targetPath, () => runHardenEntryAsync(targetPath, directory, opts, cache, extraReadAces)).work; } /** Async counterpart of hardenEntry — yields while waiting on icacls (#612). */ async function runHardenEntryAsync( targetPath: string, directory: boolean, opts: HardenOptions, cache: Map, extraReadAces: readonly string[], ): Promise { const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs); if (extraReadAces.length === 0 && await existingAclAlreadyCompliantAsync(targetPath, directory, deadline)) return { ok: true }; const memoKey = timeoutMemoKey(targetPath, opts); const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts); if (timeoutMemoError) { if (opts.required) throw timeoutMemoError; return { ok: false, diagnostics: timeoutMemoError.message }; } let lastErr: unknown; for (let attempt = 0; attempt < 2; attempt++) { if (attempt > 0 && deadline - nowFn() <= 0) break; try { const before = observe(targetPath); await runIcaclsAsync(targetPath, directory, deadline, extraReadAces); if (!recordHarden(cache, targetPath, before)) { if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC); return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC }; } timedOutPaths.delete(memoKey); return { ok: true }; } catch (err) { if (err instanceof Error && err.message === SUBSTITUTED_DIAGNOSTIC) throw err; lastErr = err; if (!isTimeoutError(err)) break; } } const diagnostics = sanitizeDiagnostics(lastErr); if (isTimeoutError(lastErr)) { recordTimeout(memoKey); const state = await describeAclStateAfterTimeoutAsync(targetPath, deadline); const annotated = `${diagnostics}; ${state}`; if (opts.required) throw sanitizedAclError(annotated, lastErr); console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`); return { ok: false, diagnostics: annotated }; } if (opts.required) throw sanitizedAclError(diagnostics, lastErr); return { ok: false, diagnostics }; } /** * Harden a single file path with per-user NTFS ACLs on Windows. * On non-Windows platforms, returns ok:true immediately (caller owns chmod). * * @param targetPath Absolute path to the file to harden. * @param opts { required: boolean } — required:true throws on failure. */ export function hardenSecretPath(targetPath: string, opts: HardenOptions): HardenResult { return hardenEntry(targetPath, false, opts, hardenedPaths, []); } /** * Async harden for write paths that must not block the event loop (#612). * Same success/timeout/error policy as hardenSecretPath. */ export function hardenSecretPathAsync(targetPath: string, opts: HardenOptions): Promise { return hardenEntryAsync(targetPath, false, opts, hardenedPaths, []); } /** * Harden a directory path with per-user NTFS ACLs on Windows. * On non-Windows platforms, returns ok:true immediately (caller owns chmod). * * @param targetPath Absolute path to the directory to harden. * @param opts { required: boolean } — required:true throws on failure. */ export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult { return hardenEntry(targetPath, true, opts, hardenedDirectories, []); } /** * Async directory harden (#612). Same policy as hardenSecretDir. */ export function hardenSecretDirAsync(targetPath: string, opts: HardenOptions): Promise { return hardenEntryAsync(targetPath, true, opts, hardenedDirectories, []); } /** * Harden a NON-secret staged payload that an elevated process must read (#4779). * * Same isolation discipline as {@link hardenSecretPath} — owner full control * granted before inheritance is stripped, broad SIDs removed — plus a read * grant for SYSTEM and BUILTIN\Administrators. An owner-only shape is correct * for secrets and wrong here: a split-token elevation of the staging account * reads either way, but an over-the-shoulder UAC elevation answered with a * different administrator's credentials runs as that admin and could not open * an owner-only payload. * * This is a separate export, not an option on the secret functions, so a * secret call site cannot reach the wider shape by accident. Use it only for * payloads that carry no credentials; their tamper-evidence must come from a * digest verified by the elevated reader, not from the DACL. */ export function hardenElevatedStagePath(targetPath: string, opts: HardenOptions): HardenResult { return hardenEntry(targetPath, false, opts, hardenedStagePaths, elevatedStageReadAces(false)); } /** Directory counterpart of {@link hardenElevatedStagePath}. */ export function hardenElevatedStageDir(targetPath: string, opts: HardenOptions): HardenResult { return hardenEntry(targetPath, true, opts, hardenedStageDirectories, elevatedStageReadAces(true)); }