@bitkyc08/opencodex 2.7.25 → 2.7.26-preview.20260719

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,16 +14,21 @@
14
14
  * hardenSecretPath(path, { required: false }) — non-fatal read-path mode.
15
15
  * Never throws. Returns { ok, diagnostics? }.
16
16
  * hardenSecretPath(path, { required: true }) — write-path mode.
17
- * Throws a sanitized error (no raw path) on Windows ACL failure.
17
+ * Throws a sanitized error (no raw path) on Windows ACL failure — EXCEPT a
18
+ * genuine icacls timeout, which soft-fails (warn + ok:false) so a hung/slow
19
+ * icacls cannot block OAuth logins or token refresh (field report: Kimi auth
20
+ * stuck behind ETIMEDOUT). Real EPERM/EACCES/exit-code failures still throw:
21
+ * availability never silently overrides confidentiality for those.
18
22
  * hardenSecretDir — same contract for directories.
19
23
  */
20
24
 
21
- import { execFileSync } from "node:child_process";
22
25
  import { existsSync } from "node:fs";
23
26
  import { env, platform } from "node:process";
24
27
 
25
28
  const hardenedDirectories = new Set<string>();
26
29
  const hardenedPaths = new Set<string>();
30
+ /** Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them. */
31
+ const timedOutPaths = new Set<string>();
27
32
 
28
33
  export interface HardenResult {
29
34
  ok: boolean;
@@ -34,6 +39,92 @@ export interface HardenOptions {
34
39
  required: boolean;
35
40
  }
36
41
 
42
+ /**
43
+ * Total icacls budget per harden call — ALL steps share it, including the single
44
+ * timeout retry and the diagnostic verification pass (no per-attempt fresh budget:
45
+ * loadConfig hardens dir+config+auth sequentially, so per-attempt budgets stack
46
+ * into multi-minute startup stalls). Override with OPENCODEX_ACL_TIMEOUT_MS
47
+ * (integer ms, clamped to [1000, 60000]; invalid values fall back to 5000).
48
+ */
49
+ const HARDEN_DEADLINE_DEFAULT_MS = 5_000;
50
+ const HARDEN_DEADLINE_MIN_MS = 1_000;
51
+ const HARDEN_DEADLINE_MAX_MS = 60_000;
52
+
53
+ /** Resolve the total harden budget once per call (env mutation cannot change it midway). */
54
+ function resolveHardenDeadlineMs(): number {
55
+ const raw = env["OPENCODEX_ACL_TIMEOUT_MS"]?.trim();
56
+ if (!raw) return HARDEN_DEADLINE_DEFAULT_MS;
57
+ const parsed = Number(raw);
58
+ if (!Number.isSafeInteger(parsed)) return HARDEN_DEADLINE_DEFAULT_MS;
59
+ return Math.min(HARDEN_DEADLINE_MAX_MS, Math.max(HARDEN_DEADLINE_MIN_MS, parsed));
60
+ }
61
+
62
+ export interface IcaclsResult {
63
+ success: boolean;
64
+ exitCode: number | null;
65
+ timedOut: boolean;
66
+ stdout: string;
67
+ }
68
+
69
+ type IcaclsRunner = (args: string[], timeoutMs: number) => IcaclsResult;
70
+
71
+ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {
72
+ // Bun.spawnSync with windowsHide: Node execFileSync has hung under the GUI/proxy even
73
+ // with windowsHide, and console-subsystem tools flash a visible window otherwise.
74
+ const result = Bun.spawnSync(["icacls.exe", ...args], {
75
+ stdin: "ignore",
76
+ stdout: "pipe",
77
+ stderr: "ignore",
78
+ timeout: timeoutMs,
79
+ windowsHide: true,
80
+ });
81
+ return {
82
+ success: result.success,
83
+ exitCode: result.exitCode,
84
+ timedOut: result.exitedDueToTimeout ?? false,
85
+ stdout: result.stdout ? result.stdout.toString() : "",
86
+ };
87
+ }
88
+
89
+ let icaclsRunner: IcaclsRunner = defaultIcaclsRunner;
90
+ let platformOverride: string | null = null;
91
+ let nowFn: () => number = Date.now;
92
+
93
+ /** Test seam: replace the icacls process runner. Pass null to restore the default. */
94
+ export function setIcaclsRunnerForTests(runner: IcaclsRunner | null): void {
95
+ icaclsRunner = runner ?? defaultIcaclsRunner;
96
+ }
97
+
98
+ /** Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. */
99
+ export function setPlatformForTests(value: string | null): void {
100
+ platformOverride = value;
101
+ }
102
+
103
+ /** Test seam: injectable clock for deadline tests (no real sleeps). */
104
+ export function setNowForTests(fn: (() => number) | null): void {
105
+ nowFn = fn ?? Date.now;
106
+ }
107
+
108
+ /** Test seam: clear memo/failure caches between cases. */
109
+ export function resetHardenedStateForTests(): void {
110
+ hardenedDirectories.clear();
111
+ hardenedPaths.clear();
112
+ timedOutPaths.clear();
113
+ }
114
+
115
+ function effectivePlatform(): string {
116
+ return platformOverride ?? platform;
117
+ }
118
+
119
+ /** Error carrying an honest code: ETIMEDOUT only for real timeouts, EICACLS otherwise. */
120
+ function icaclsError(step: string, result: IcaclsResult): NodeJS.ErrnoException {
121
+ const err = new Error(
122
+ result.timedOut ? `icacls ${step} timed out` : `icacls ${step} exited ${result.exitCode ?? "null"}`,
123
+ ) as NodeJS.ErrnoException;
124
+ err.code = result.timedOut ? "ETIMEDOUT" : "EICACLS";
125
+ return err;
126
+ }
127
+
37
128
  /**
38
129
  * Return the current Windows username from the environment.
39
130
  * Falls back to USERDOMAIN\USERNAME if USERNAME alone is ambiguous.
@@ -58,39 +149,52 @@ function currentWindowsUser(): string | undefined {
58
149
  *
59
150
  * Throws the raw child_process error on failure (caller sanitizes).
60
151
  */
61
- function runIcacls(targetPath: string, directory: boolean): void {
152
+ const BROAD_SIDS = ["*S-1-1-0", "*S-1-5-11", "*S-1-5-32-545"] as const;
153
+
154
+ function runIcacls(targetPath: string, directory: boolean, deadline: number): void {
62
155
  const user = currentWindowsUser();
63
156
  if (!user) {
64
157
  throw new Error("Cannot determine current Windows user for ACL hardening");
65
158
  }
66
159
 
160
+ // The deadline is owned by hardenEntry (total budget incl. retry + verification).
161
+ const run = (step: string, args: string[]): IcaclsResult => {
162
+ const remaining = deadline - nowFn();
163
+ if (remaining <= 0) {
164
+ throw icaclsError(step, { success: false, exitCode: null, timedOut: true, stdout: "" });
165
+ }
166
+ return icaclsRunner(args, remaining);
167
+ };
168
+ const runOrThrow = (step: string, args: string[]): void => {
169
+ const result = run(step, args);
170
+ if (!result.success) throw icaclsError(step, result);
171
+ };
172
+
67
173
  // Step 1: disable inheritance and remove inherited ACEs
68
- execFileSync("icacls.exe", [targetPath, "/inheritance:r"], {
69
- stdio: ["ignore", "pipe", "ignore"],
70
- timeout: 5000,
71
- shell: false,
72
- });
174
+ runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]);
73
175
 
74
176
  // Step 2: remove broad explicit grants using stable SIDs (not localized names).
75
- execFileSync("icacls.exe", [
76
- targetPath,
77
- "/remove:g",
78
- "*S-1-1-0",
79
- "*S-1-5-11",
80
- "*S-1-5-32-545",
81
- ], {
82
- stdio: ["ignore", "pipe", "ignore"],
83
- timeout: 5000,
84
- shell: false,
85
- });
177
+ // Missing ACEs can yield a non-zero exit; verify with locale-independent /findsid
178
+ // before accepting the failure as harmless — a swallowed real failure would leave
179
+ // Everyone/Users/Authenticated Users grants while reporting hardened.
180
+ const removal = run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]);
181
+ if (!removal.success) {
182
+ if (removal.timedOut) throw icaclsError("/remove:g", removal);
183
+ for (const sid of BROAD_SIDS) {
184
+ const found = run("/findsid", [targetPath, "/findsid", sid]);
185
+ if (!found.success) throw icaclsError("/findsid", found);
186
+ // icacls /findsid echoes the target path in its "SID Found" line only when the SID
187
+ // still holds an ACE; the summary lines carry only counts. Matching the path echo —
188
+ // not the (localized) prose — keeps the check locale-independent.
189
+ if (found.stdout.includes(targetPath)) {
190
+ throw icaclsError("/remove:g", removal);
191
+ }
192
+ }
193
+ }
86
194
 
87
195
  // Step 3: grant current user full control.
88
196
  const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
89
- execFileSync("icacls.exe", [targetPath, "/grant:r", grant], {
90
- stdio: ["ignore", "pipe", "ignore"],
91
- timeout: 5000,
92
- shell: false,
93
- });
197
+ runOrThrow("/grant:r", [targetPath, "/grant:r", grant]);
94
198
  }
95
199
 
96
200
  /**
@@ -99,44 +203,105 @@ function runIcacls(targetPath: string, directory: boolean): void {
99
203
  * sensitive username components or PII from the home directory path).
100
204
  */
101
205
  function sanitizeDiagnostics(error: unknown): string {
102
- // We do not expose the raw error message or any path-like fragments.
103
- // Just describe what failed generically.
206
+ // We do not expose the raw error message or any path-like fragments
207
+ // just an honest, code-specific cause (issue #160: a transient icacls stall
208
+ // must not read like filesystem non-support).
104
209
  const code = error instanceof Error && "code" in error ? String((error as NodeJS.ErrnoException).code) : "";
105
- const codePart = code ? ` (${code})` : "";
106
- return `ACL hardening failed${codePart} — filesystem may not support per-user NTFS ACLs`;
210
+ switch (code) {
211
+ case "ETIMEDOUT":
212
+ return "ACL hardening timed out (ETIMEDOUT) — transient icacls stall; the volume may still support per-user NTFS ACLs";
213
+ case "EPERM":
214
+ case "EACCES":
215
+ return `ACL hardening failed (${code}) — permission denied running icacls`;
216
+ case "EICACLS":
217
+ return "ACL hardening failed (EICACLS) — icacls command error; filesystem may not support per-user NTFS ACLs";
218
+ default:
219
+ return `ACL hardening failed${code ? ` (${code})` : ""} — filesystem may not support per-user NTFS ACLs`;
220
+ }
221
+ }
222
+
223
+ function isTimeoutError(error: unknown): boolean {
224
+ return error instanceof Error && "code" in error
225
+ && String((error as NodeJS.ErrnoException).code) === "ETIMEDOUT";
107
226
  }
108
227
 
109
228
  /**
110
- * Harden a single file path with per-user NTFS ACLs on Windows.
111
- * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
112
- *
113
- * @param targetPath Absolute path to the file to harden.
114
- * @param opts { required: boolean } — required:true throws on failure.
229
+ * Diagnostic-only post-timeout probe (never promotes to ok:true a clean /findsid
230
+ * does not prove inheritance was disabled or the user grant ran; only a fully
231
+ * completed harden sequence may enter the hardened cache). Bounded by the remaining
232
+ * total budget; returns a short state note for the soft-fail diagnostic.
115
233
  */
116
- export function hardenSecretPath(targetPath: string, opts: HardenOptions): HardenResult {
117
- // Skip for missing files — we cannot harden what does not exist yet.
118
- if (!existsSync(targetPath)) {
119
- return { ok: true };
234
+ function describeAclStateAfterTimeout(targetPath: string, deadline: number): string {
235
+ try {
236
+ for (const sid of BROAD_SIDS) {
237
+ const remaining = deadline - nowFn();
238
+ if (remaining <= 0) return "ACL state unverified (budget exhausted)";
239
+ const found = icaclsRunner([targetPath, "/findsid", sid], remaining);
240
+ if (!found.success) return "ACL state unverified (probe failed)";
241
+ if (found.stdout.includes(targetPath)) return "broad ACL grants still present";
242
+ }
243
+ return "no broad ACL grants detected (hardening still incomplete)";
244
+ } catch {
245
+ return "ACL state unverified (probe failed)";
120
246
  }
247
+ }
121
248
 
122
- // Non-Windows: no NTFS ACLs; caller handles chmod.
123
- if (platform !== "win32") {
124
- return { ok: true };
249
+ /**
250
+ * Shared harden flow for files and directories: one total budget (env-configurable)
251
+ * covering the initial attempt, ONE timeout retry, and the diagnostic verification.
252
+ * Real EPERM/EACCES/EICACLS failures stay fail-closed on required paths; only
253
+ * genuine timeouts soft-fail, with an honest state-annotated diagnostic.
254
+ */
255
+ function hardenEntry(
256
+ targetPath: string,
257
+ directory: boolean,
258
+ opts: HardenOptions,
259
+ cache: Set<string>,
260
+ ): HardenResult {
261
+ if (!existsSync(targetPath)) return { ok: true };
262
+ if (effectivePlatform() !== "win32") return { ok: true };
263
+ if (cache.has(targetPath)) return { ok: true };
264
+ if (timedOutPaths.has(targetPath)) {
265
+ return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" };
125
266
  }
126
267
 
127
- if (hardenedPaths.has(targetPath)) return { ok: true };
128
-
129
- try {
130
- runIcacls(targetPath, false);
131
- hardenedPaths.add(targetPath);
132
- return { ok: true };
133
- } catch (err) {
134
- const diagnostics = sanitizeDiagnostics(err);
135
- if (opts.required) {
136
- throw new Error(diagnostics);
268
+ const deadline = nowFn() + resolveHardenDeadlineMs();
269
+ let lastErr: unknown;
270
+ for (let attempt = 0; attempt < 2; attempt++) {
271
+ if (attempt > 0 && deadline - nowFn() <= 0) break; // retry only while budget remains
272
+ try {
273
+ runIcacls(targetPath, directory, deadline);
274
+ cache.add(targetPath);
275
+ return { ok: true };
276
+ } catch (err) {
277
+ lastErr = err;
278
+ if (!isTimeoutError(err)) break; // real failures do not retry
137
279
  }
138
- return { ok: false, diagnostics };
139
280
  }
281
+
282
+ const diagnostics = sanitizeDiagnostics(lastErr);
283
+ if (isTimeoutError(lastErr)) {
284
+ timedOutPaths.add(targetPath);
285
+ const state = describeAclStateAfterTimeout(targetPath, deadline);
286
+ const annotated = `${diagnostics}; ${state}`;
287
+ // Timeout-only soft-fail: a hung icacls must not block OAuth/token writes.
288
+ // chmod is still applied by the caller.
289
+ console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`);
290
+ return { ok: false, diagnostics: annotated };
291
+ }
292
+ if (opts.required) throw new Error(diagnostics);
293
+ return { ok: false, diagnostics };
294
+ }
295
+
296
+ /**
297
+ * Harden a single file path with per-user NTFS ACLs on Windows.
298
+ * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
299
+ *
300
+ * @param targetPath Absolute path to the file to harden.
301
+ * @param opts { required: boolean } — required:true throws on failure.
302
+ */
303
+ export function hardenSecretPath(targetPath: string, opts: HardenOptions): HardenResult {
304
+ return hardenEntry(targetPath, false, opts, hardenedPaths);
140
305
  }
141
306
 
142
307
  /**
@@ -147,27 +312,5 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde
147
312
  * @param opts { required: boolean } — required:true throws on failure.
148
313
  */
149
314
  export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult {
150
- // Skip for missing directories — we cannot harden what does not exist yet.
151
- if (!existsSync(targetPath)) {
152
- return { ok: true };
153
- }
154
-
155
- // Non-Windows: no NTFS ACLs; caller handles chmod.
156
- if (platform !== "win32") {
157
- return { ok: true };
158
- }
159
-
160
- if (hardenedDirectories.has(targetPath)) return { ok: true };
161
-
162
- try {
163
- runIcacls(targetPath, true);
164
- hardenedDirectories.add(targetPath);
165
- return { ok: true };
166
- } catch (err) {
167
- const diagnostics = sanitizeDiagnostics(err);
168
- if (opts.required) {
169
- throw new Error(diagnostics);
170
- }
171
- return { ok: false, diagnostics };
172
- }
315
+ return hardenEntry(targetPath, true, opts, hardenedDirectories);
173
316
  }