javi-forge 1.32.0 → 1.34.0

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.
@@ -7,15 +7,18 @@
7
7
  * and the component-level doctor. Install/repair are declared but unimplemented
8
8
  * Slice-3 seams — Slice 3 GROWS this file, it does not relocate this code.
9
9
  */
10
+ import { execFile } from "node:child_process";
10
11
  import { createHash, randomBytes } from "node:crypto";
11
- import { lstat, readFile } from "node:fs/promises";
12
+ import { lstat, readdir, readFile } from "node:fs/promises";
13
+ import os from "node:os";
12
14
  import path from "node:path";
13
15
  import { CLAUDE_HOOK_ASSETS_DIR } from "../constants.js";
14
16
  import { ASSET_MANAGED_MARKER, ASSET_NAME, } from "./__fixtures__/claude-hook-ownership.js";
15
- import { buildManagedContainer, classifySettingsEntry, isPlainObject, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, planForceReplace, planLegacyCohortExcision, planManagedClaudeHookMerge, } from "./claude-hook-settings.js";
17
+ import { buildManagedContainer, classifySettingsEntry, isPlainObject, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, planForceReplace, planLegacyCohortExcision, planManagedClaudeHookMerge, scanExecutionFlags, } from "./claude-hook-settings.js";
16
18
  import { safeReadFile } from "./safe-read.js";
17
- import { selectSecureFs } from "./secure-fs-posix.js";
19
+ import { ACL_DETAIL, probeAclCapability, selectSecureFs, } from "./secure-fs-posix.js";
18
20
  import { runTransaction, } from "./secure-fs-transaction.js";
21
+ import { remediationForRefusal } from "./secure-refusal-remediation.js";
19
22
  /** 1 MiB read budget, shared with the runtime's stdin envelope. */
20
23
  const ASSET_MAX_BYTES = 1024 * 1024;
21
24
  const NODE_MINIMUM_MAJOR = 22;
@@ -200,10 +203,282 @@ const REMEDIATION = {
200
203
  function remediationFor(state, component) {
201
204
  return REMEDIATION[state]?.replace("$", component);
202
205
  }
206
+ const NODE_PROBE_TIMEOUT_MS = 2000;
207
+ const NODE_PROBE_MAX_BUFFER = 64 * 1024;
208
+ const NODE_VERSION_LINE = /^v(\d+)\.\d+\.\d+/;
209
+ /**
210
+ * Bounded `node --version` spawn. argv only (never a shell string), `LC_ALL=C`,
211
+ * and a hard timeout — mirroring the ACL adapter's spawn discipline. It is
212
+ * read-only: it starts a process and reads its stdout, and touches no path.
213
+ */
214
+ const defaultNodeSpawn = (cmd, args) => new Promise((resolve) => {
215
+ execFile(cmd, args, {
216
+ timeout: NODE_PROBE_TIMEOUT_MS,
217
+ maxBuffer: NODE_PROBE_MAX_BUFFER,
218
+ encoding: "utf8",
219
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
220
+ }, (error, stdout) => {
221
+ if (!error)
222
+ return resolve({ code: 0, stdout: stdout ?? "" });
223
+ const e = error;
224
+ if (e.code === "ENOENT") {
225
+ return resolve({ spawnError: true, code: null, stdout: "" });
226
+ }
227
+ if (e.killed || e.signal === "SIGTERM") {
228
+ return resolve({ timedOut: true, code: null, stdout: stdout ?? "" });
229
+ }
230
+ const code = typeof e.code === "number" ? e.code : 1;
231
+ return resolve({ code, stdout: stdout ?? "" });
232
+ });
233
+ });
234
+ /**
235
+ * Resolve and run `node --version` from this process' PATH. A spawn ENOENT is
236
+ * `absent` (near-certainly a dead exec-form guard); a timeout, non-zero exit, or
237
+ * unparseable banner is honest ignorance (`unknown`) and NEVER a fabricated
238
+ * version.
239
+ */
240
+ export async function probeNodeOnPath(spawn = defaultNodeSpawn) {
241
+ const res = await spawn("node", ["--version"]);
242
+ if (res.spawnError)
243
+ return { status: "absent" };
244
+ if (res.timedOut) {
245
+ return { status: "unknown", detail: "node --version timeout" };
246
+ }
247
+ if (res.code !== 0) {
248
+ return { status: "unknown", detail: `node --version exit ${res.code}` };
249
+ }
250
+ const banner = res.stdout.trim();
251
+ const match = NODE_VERSION_LINE.exec(banner);
252
+ if (!match) {
253
+ return { status: "unknown", detail: "node --version output unparseable" };
254
+ }
255
+ return { status: "resolved", version: banner, major: Number(match[1]) };
256
+ }
257
+ /** Static managed-settings locations per OS (no fs). WSL reports `linux`. */
258
+ export function resolveManagedSettingsPaths(platform) {
259
+ if (platform === "darwin") {
260
+ const base = "/Library/Application Support/ClaudeCode";
261
+ return {
262
+ file: `${base}/managed-settings.json`,
263
+ dropInDir: `${base}/managed-settings.d`,
264
+ };
265
+ }
266
+ if (platform === "win32") {
267
+ const base = "C:\\Program Files\\ClaudeCode";
268
+ return {
269
+ file: `${base}\\managed-settings.json`,
270
+ dropInDir: `${base}\\managed-settings.d`,
271
+ };
272
+ }
273
+ const base = "/etc/claude-code";
274
+ return {
275
+ file: `${base}/managed-settings.json`,
276
+ dropInDir: `${base}/managed-settings.d`,
277
+ };
278
+ }
279
+ /**
280
+ * Probe one settings source path for the two documented hook-neutralizing
281
+ * flags. Fail-closed: only a genuinely-absent path or a cleanly-parsed source
282
+ * with no flag is `clear`; a symlink, non-regular path, permission/io error,
283
+ * oversized/binary content, or malformed JSON is `unknown` (unreadable ≠
284
+ * absent), never `clear`. `disableAllHooks` is preferred over
285
+ * `allowManagedHooksOnly` when both are set (the former blocks at any source).
286
+ */
287
+ export async function probeExecutionSource(target) {
288
+ const stat = await lstatNoFollow(target);
289
+ if (stat.kind === "enoent")
290
+ return { kind: "clear" };
291
+ if (stat.kind === "symlink")
292
+ return { kind: "unknown", reason: "symlink" };
293
+ if (stat.kind === "non-regular") {
294
+ return { kind: "unknown", reason: "non-regular" };
295
+ }
296
+ if (stat.kind === "error")
297
+ return { kind: "unknown", reason: stat.detail };
298
+ const read = await safeReadFile(target, READ_OPTS);
299
+ if (!read.ok) {
300
+ if (read.reason === "not-found")
301
+ return { kind: "clear" };
302
+ return { kind: "unknown", reason: read.reason };
303
+ }
304
+ let parsed;
305
+ try {
306
+ parsed = JSON.parse(read.content);
307
+ }
308
+ catch {
309
+ return { kind: "unknown", reason: "invalid-json" };
310
+ }
311
+ const flags = scanExecutionFlags(parsed);
312
+ const blocking = (flag, verdict) => ({
313
+ kind: "blocking",
314
+ flag,
315
+ // An INVALID value is blocking per the documented "invalid ⇒ true"
316
+ // semantics; the shape is surfaced so an operator can see why (say) the
317
+ // string "false" did not clear the flag.
318
+ ...(verdict.set && verdict.reason === "invalid"
319
+ ? { detail: `invalid value: ${verdict.shape} → treated as true` }
320
+ : {}),
321
+ });
322
+ if (flags.disableAllHooks.set) {
323
+ return blocking("disableAllHooks", flags.disableAllHooks);
324
+ }
325
+ if (flags.allowManagedHooksOnly.set) {
326
+ return blocking("allowManagedHooksOnly", flags.allowManagedHooksOnly);
327
+ }
328
+ return { kind: "clear" };
329
+ }
330
+ export async function listManagedDropIns(dir, listDir) {
331
+ let entries;
332
+ try {
333
+ entries = listDir ? await listDir(dir) : await readdir(dir);
334
+ }
335
+ catch (error) {
336
+ const code = error.code;
337
+ // Only a genuinely-absent directory is "no drop-ins"; every other error
338
+ // (permission/io/loop/etc.) is an unreadable managed source.
339
+ if (code === "ENOENT" || code === "ENOTDIR")
340
+ return { entries: [] };
341
+ return { unreadable: true, reason: code ?? String(error) };
342
+ }
343
+ return {
344
+ entries: entries
345
+ .filter((name) => name.endsWith(".json"))
346
+ .sort()
347
+ .map((name) => path.join(dir, name)),
348
+ };
349
+ }
350
+ /** CONSTANT honest limits — always rendered, never gate the status. */
351
+ const EXECUTION_RESIDUAL = [
352
+ "server-delivered managed policy can disable hooks and is not observable from local files",
353
+ "session safe-mode (--safe-mode / CLAUDE_CODE_SAFE_MODE) in the diagnosed session is not observable from this process",
354
+ 'the installed hook is exec-form (command: "node"): node is resolved from Claude Code\'s PATH, which this process cannot observe — the node-on-PATH row is a heuristic proxy, never proof the guard will spawn',
355
+ ];
356
+ function isSafeModeTruthy(env) {
357
+ const value = env.CLAUDE_CODE_SAFE_MODE;
358
+ if (value === undefined)
359
+ return false;
360
+ const normalized = value.trim().toLowerCase();
361
+ return normalized !== "" && normalized !== "0" && normalized !== "false";
362
+ }
363
+ /**
364
+ * The fail-closed effective-execution verdict. Gathers all sources in stable
365
+ * order (project, local, user, managed OS file, drop-ins sorted), classifies
366
+ * each with `probeExecutionSource`, applies the managed-only inertness of
367
+ * `allowManagedHooksOnly` (blocks only from a managed source — hooks MERGE
368
+ * elsewhere), folds in the guard-currency blocker and the doctor-process
369
+ * safe-mode observation, then resolves by precedence
370
+ * (`blockers` first, then `unknownSources`, else `runnable`). An `unknown` is
371
+ * NEVER promoted to `runnable`.
372
+ */
373
+ export async function probeExecution(projectDir, componentStates, env = {}) {
374
+ const platform = env.platform ?? process.platform;
375
+ const homeDir = env.homeDir ?? os.homedir();
376
+ const processEnv = env.env ?? process.env;
377
+ const resolved = resolveManagedSettingsPaths(platform);
378
+ const managedFile = env.managedFile !== undefined ? env.managedFile : resolved.file;
379
+ const dropInDir = env.managedDropInDir !== undefined
380
+ ? env.managedDropInDir
381
+ : resolved.dropInDir;
382
+ const specs = [
383
+ {
384
+ label: "project",
385
+ target: path.join(projectDir, ".claude", "settings.json"),
386
+ managed: false,
387
+ },
388
+ {
389
+ label: "local",
390
+ target: path.join(projectDir, ".claude", "settings.local.json"),
391
+ managed: false,
392
+ },
393
+ {
394
+ label: "user",
395
+ target: path.join(homeDir, ".claude", "settings.json"),
396
+ managed: false,
397
+ },
398
+ ];
399
+ if (managedFile) {
400
+ specs.push({ label: "managed", target: managedFile, managed: true });
401
+ }
402
+ // A present-but-unenumerable drop-in dir is an `unknown` managed source, NOT
403
+ // "no drop-ins" — fold it into unknownSources so a blocking drop-in policy we
404
+ // cannot read can never be mistaken for a clear (false `runnable`) verdict.
405
+ let dropInDirUnknown;
406
+ if (dropInDir) {
407
+ const listing = await listManagedDropIns(dropInDir, env.listDir);
408
+ if (listing.unreadable) {
409
+ dropInDirUnknown = `managed:${dropInDir} (${listing.reason})`;
410
+ }
411
+ else {
412
+ for (const target of listing.entries) {
413
+ specs.push({ label: "managed", target, managed: true });
414
+ }
415
+ }
416
+ }
417
+ const blockers = [];
418
+ const unknownSources = [];
419
+ for (const spec of specs) {
420
+ const probe = await probeExecutionSource(spec.target);
421
+ if (probe.kind === "clear")
422
+ continue;
423
+ if (probe.kind === "unknown") {
424
+ unknownSources.push(`${spec.label}:${spec.target} (${probe.reason})`);
425
+ continue;
426
+ }
427
+ // `allowManagedHooksOnly` is inert outside a managed source (hooks merge).
428
+ // An INVALID value there is inert for the same reason: the flag has no
429
+ // authority outside a managed source, so it gains none by being malformed.
430
+ if (probe.flag === "allowManagedHooksOnly" && !spec.managed)
431
+ continue;
432
+ blockers.push(`policy:${probe.flag}@${spec.label}${probe.detail ? ` (${probe.detail})` : ""}`);
433
+ }
434
+ if (dropInDirUnknown)
435
+ unknownSources.push(dropInDirUnknown);
436
+ // Guard-currency: a not-installed / drifted guard cannot fire, so it blocks.
437
+ if (componentStates.asset !== "managed-current") {
438
+ blockers.push(`guard:asset=${componentStates.asset}`);
439
+ }
440
+ if (componentStates.settings !== "managed-current") {
441
+ blockers.push(`guard:settings=${componentStates.settings}`);
442
+ }
443
+ // node-on-PATH heuristic (design Decision 2). The installed handler is
444
+ // exec-form (`command: "node"`), so a `node` that does not resolve means the
445
+ // guard NEVER fires — fail-closed, with the heuristic labelled in the entry.
446
+ // A SUCCESSFUL probe contributes NOTHING: it clears no blocker, removes no
447
+ // unknown source, and adds no confidence, because this process' PATH only
448
+ // proxies the PATH Claude Code will use.
449
+ const nodeOnPath = await (env.nodeProbe ?? probeNodeOnPath)();
450
+ if (nodeOnPath.status === "absent") {
451
+ blockers.push("runtime:node-not-on-PATH (heuristic: this process' PATH)");
452
+ }
453
+ else if (nodeOnPath.status === "resolved" &&
454
+ nodeOnPath.major < NODE_MINIMUM_MAJOR) {
455
+ blockers.push(`runtime:node-on-PATH v${nodeOnPath.major} (<${NODE_MINIMUM_MAJOR}, heuristic)`);
456
+ }
457
+ else if (nodeOnPath.status === "unknown") {
458
+ unknownSources.push(`runtime:node-on-PATH (heuristic: ${nodeOnPath.detail})`);
459
+ }
460
+ // Safe-mode observed in THIS doctor process is a real per-run unknown (the
461
+ // diagnosed session's own safe-mode remains a constant residual).
462
+ if (isSafeModeTruthy(processEnv)) {
463
+ unknownSources.push("safe-mode:CLAUDE_CODE_SAFE_MODE (observed in doctor process only)");
464
+ }
465
+ const status = blockers.length > 0
466
+ ? "blocked"
467
+ : unknownSources.length > 0
468
+ ? "inconclusive"
469
+ : "runnable";
470
+ return {
471
+ status,
472
+ blockers,
473
+ unknownSources,
474
+ residual: [...EXECUTION_RESIDUAL],
475
+ };
476
+ }
203
477
  /**
204
478
  * Assemble the read-only component-level doctor report (no writes). `healthy` is
205
479
  * exactly: both components `managed-current`, matcher and command shape exact,
206
480
  * Node `>=22`. `assetSettingsConsistent` is a reported advisory, NOT part of it.
481
+ * The `execution` verdict is INDEPENDENT of `healthy`.
207
482
  */
208
483
  export async function doctorClaudePreToolUse(projectDir, options) {
209
484
  const manifest = options?.manifest ?? (await readManifest());
@@ -240,6 +515,25 @@ export async function doctorClaudePreToolUse(projectDir, options) {
240
515
  signals.matcherExact &&
241
516
  signals.commandShapeExact &&
242
517
  node.satisfiesMinimum;
518
+ // Probe node ONCE and share the outcome between the always-present report row
519
+ // and the execution matrix, so the doctor never spawns `node` twice per run.
520
+ const executionEnv = options?.execution ?? {};
521
+ const nodeOnPath = await (executionEnv.nodeProbe ?? probeNodeOnPath)();
522
+ const execution = await probeExecution(projectDir, { asset: asset.state, settings: settings.state }, { ...executionEnv, nodeProbe: async () => nodeOnPath });
523
+ // Install-capability section. Read-only, and deliberately OUTSIDE the
524
+ // execution matrix (design Decision 1): the installed `.mjs` guard never
525
+ // spawns `getfacl`, so an absent adapter cannot stop a current guard from
526
+ // firing. Only when a guard-currency blocker ALREADY exists does the
527
+ // remediation join `report.remediation` — there the user must install and
528
+ // cannot.
529
+ const aclCapability = await (options?.aclProbe ?? probeAclCapability)();
530
+ const aclRemediation = aclCapability.status === "absent" && aclCapability.tool === "getfacl"
531
+ ? remediationForRefusal("unsupported-posix-acl", ACL_DETAIL.getfaclAbsent)
532
+ : undefined;
533
+ if (aclRemediation &&
534
+ execution.blockers.some((blocker) => blocker.startsWith("guard:"))) {
535
+ remediation.add(aclRemediation);
536
+ }
243
537
  return {
244
538
  healthy,
245
539
  settings: {
@@ -261,6 +555,12 @@ export async function doctorClaudePreToolUse(projectDir, options) {
261
555
  coverage: COVERAGE,
262
556
  hostResidual: HOST_RESIDUAL,
263
557
  remediation: [...remediation].sort(),
558
+ execution,
559
+ nodeOnPath,
560
+ installCapability: {
561
+ acl: aclCapability,
562
+ ...(aclRemediation ? { remediation: aclRemediation } : {}),
563
+ },
264
564
  };
265
565
  }
266
566
  async function readManifest() {
@@ -269,6 +569,24 @@ async function readManifest() {
269
569
  throw new Error(`unreadable claude-hooks manifest: ${read.reason}`);
270
570
  return JSON.parse(read.content);
271
571
  }
572
+ /**
573
+ * The non-blocking install/repair warning for the exec-form guard's runtime.
574
+ * Mirrors the doctor's heuristic wording: this process' PATH only PROXIES the
575
+ * PATH Claude Code will use to spawn the handler.
576
+ */
577
+ function nodeOnPathWarnings(probe) {
578
+ if (probe.status === "absent") {
579
+ return [
580
+ `node did not resolve on this process' PATH (heuristic): the installed guard is exec-form (command: "node") and will not fire if Claude Code's PATH also lacks it — install Node ${NODE_MINIMUM_MAJOR}+ on PATH`,
581
+ ];
582
+ }
583
+ if (probe.status === "resolved" && probe.major < NODE_MINIMUM_MAJOR) {
584
+ return [
585
+ `node on PATH is ${probe.version} (<${NODE_MINIMUM_MAJOR}, heuristic): the installed guard may fail to run — install Node ${NODE_MINIMUM_MAJOR}+ on PATH`,
586
+ ];
587
+ }
588
+ return [];
589
+ }
272
590
  function refuseMessage(component, state) {
273
591
  const remedy = remediationFor(state, component);
274
592
  return `refuse ${component} in state ${state}${remedy ? ` — ${remedy}` : ""}`;
@@ -400,7 +718,18 @@ export async function _run(projectDir, mode, options, deps) {
400
718
  const assetDestPath = path.join(projectDir, ".claude", "hooks", ASSET_NAME);
401
719
  const settingsPath = path.join(projectDir, ".claude", "settings.json");
402
720
  const assetSrcPath = path.join(CLAUDE_HOOK_ASSETS_DIR, ASSET_NAME);
403
- const doctor = () => doctorClaudePreToolUse(projectDir, { manifest });
721
+ // Probe node ONCE per `_run` and share the sample with the embedded doctor
722
+ // report, so the run never spawns `node --version` twice and the warning, the
723
+ // report row and the verdict can never disagree about the same PATH.
724
+ const nodeOnPath = await (deps.nodeProbe ?? probeNodeOnPath)();
725
+ const doctor = () => doctorClaudePreToolUse(projectDir, {
726
+ manifest,
727
+ aclProbe: deps.aclProbe,
728
+ execution: { nodeProbe: async () => nodeOnPath },
729
+ });
730
+ // Non-blocking runtime notice, computed once and carried by EVERY outcome
731
+ // (success, no-op and refusal alike) — it never gates the mutation.
732
+ const warnings = nodeOnPathWarnings(nodeOnPath);
404
733
  // Windows (or any platform without an adapter) refuses with zero mutation.
405
734
  if (!secureFs) {
406
735
  return {
@@ -408,6 +737,7 @@ export async function _run(projectDir, mode, options, deps) {
408
737
  changed: [],
409
738
  backups: [],
410
739
  errors: ["windows-secure-object-unavailable"],
740
+ warnings,
411
741
  report: await doctor(),
412
742
  };
413
743
  }
@@ -430,6 +760,7 @@ export async function _run(projectDir, mode, options, deps) {
430
760
  changed: [],
431
761
  backups: [],
432
762
  errors: [reason],
763
+ warnings,
433
764
  report: await doctor(),
434
765
  };
435
766
  }
@@ -440,6 +771,7 @@ export async function _run(projectDir, mode, options, deps) {
440
771
  changed: [],
441
772
  backups: [],
442
773
  errors: [],
774
+ warnings,
443
775
  report: await doctor(),
444
776
  };
445
777
  }
@@ -478,6 +810,7 @@ export async function _run(projectDir, mode, options, deps) {
478
810
  changed: tx.committed,
479
811
  backups: tx.backups,
480
812
  errors: tx.errors,
813
+ warnings,
481
814
  report: await doctor(),
482
815
  };
483
816
  }
@@ -40,6 +40,38 @@ export declare function isPlainObject(value: unknown): value is Record<string, u
40
40
  * else is malformed.
41
41
  */
42
42
  export declare function validateSettingsShape(parsed: unknown): boolean;
43
+ /**
44
+ * The two documented scalar flags a settings source can set that neutralize the
45
+ * managed hook. `disableAllHooks: true` at ANY source is a blocker; the
46
+ * source→blocker mapping for `allowManagedHooksOnly` (managed-only) lives in the
47
+ * manager, which knows each source's provenance.
48
+ */
49
+ export interface ExecutionFlagScan {
50
+ disableAllHooks: FlagVerdict;
51
+ allowManagedHooksOnly: FlagVerdict;
52
+ }
53
+ /**
54
+ * One flag's verdict for one source. `set` answers "does this source neutralize
55
+ * the hook?"; `reason` says whether that came from a documented boolean or from
56
+ * the documented fallback for an INVALID value.
57
+ */
58
+ export type FlagVerdict = {
59
+ set: false;
60
+ } | {
61
+ set: true;
62
+ reason: "explicit";
63
+ } | {
64
+ set: true;
65
+ reason: "invalid";
66
+ shape: string;
67
+ };
68
+ /**
69
+ * Classify an already-parsed settings container for the two documented
70
+ * hook-neutralizing flags. A `{ set: false }` here is a definitive "this source
71
+ * does not set the flag", never an "unknown" — unreadability is decided upstream
72
+ * by the fs probe, not here, so a non-object input is "not a flag".
73
+ */
74
+ export declare function scanExecutionFlags(parsed: unknown): ExecutionFlagScan;
43
75
  /**
44
76
  * Replace the trailing 64-hex asset SHA token in a managed `statusMessage` with
45
77
  * the fixed placeholder so settings identity is invariant under asset rotation.
@@ -31,6 +31,46 @@ export function validateSettingsShape(parsed) {
31
31
  return false;
32
32
  return true;
33
33
  }
34
+ const NOT_SET = { set: false };
35
+ const EXPLICIT = { set: true, reason: "explicit" };
36
+ /** Name the observed JSON shape for the operator-facing blocker detail. */
37
+ function shapeOf(value) {
38
+ if (value === null)
39
+ return "null";
40
+ if (Array.isArray(value))
41
+ return "array";
42
+ return typeof value;
43
+ }
44
+ /**
45
+ * Classify one present value per the documented Claude Code semantics: a
46
+ * boolean `true` sets the flag, a boolean `false` (or an absent/`undefined` key)
47
+ * definitively clears it, and ANY other present value is INVALID — which Claude
48
+ * Code treats as `true`, so it sets the flag with the shape named. That includes
49
+ * the counterintuitive string `"false"`: it is not a boolean, so it does not
50
+ * clear.
51
+ */
52
+ function classifyFlag(value) {
53
+ if (value === undefined || value === false)
54
+ return NOT_SET;
55
+ if (value === true)
56
+ return EXPLICIT;
57
+ return { set: true, reason: "invalid", shape: shapeOf(value) };
58
+ }
59
+ /**
60
+ * Classify an already-parsed settings container for the two documented
61
+ * hook-neutralizing flags. A `{ set: false }` here is a definitive "this source
62
+ * does not set the flag", never an "unknown" — unreadability is decided upstream
63
+ * by the fs probe, not here, so a non-object input is "not a flag".
64
+ */
65
+ export function scanExecutionFlags(parsed) {
66
+ if (!isPlainObject(parsed)) {
67
+ return { disableAllHooks: NOT_SET, allowManagedHooksOnly: NOT_SET };
68
+ }
69
+ return {
70
+ disableAllHooks: classifyFlag(parsed.disableAllHooks),
71
+ allowManagedHooksOnly: classifyFlag(parsed.allowManagedHooksOnly),
72
+ };
73
+ }
34
74
  // Canonical identity (Decision ②)
35
75
  const ASSET_SHA_TOKEN = /^[0-9a-f]{64}$/;
36
76
  const VERSION_PATTERN = /^javi-forge-global-pretooluse:v(\d+):sha256:/;
@@ -24,8 +24,51 @@ export interface PosixAclAdapter {
24
24
  /** Run the bounded, LC_ALL=C ACL tool and decide clean|extended|inconclusive. */
25
25
  proveClean(target: string): Promise<SecureResult<void>>;
26
26
  }
27
+ /**
28
+ * The EXACT detail strings the POSIX adapters emit, exported so consumers (the
29
+ * CLI remediation table) can key off a token instead of string-matching prose.
30
+ * These values are frozen: changing one changes an observable refusal detail.
31
+ */
32
+ export declare const ACL_DETAIL: {
33
+ readonly getfaclAbsent: "getfacl absent";
34
+ readonly getfaclTimeout: "getfacl timeout";
35
+ readonly extendedAclEntry: "extended ACL entry";
36
+ readonly macosLsAbsent: "/bin/ls absent";
37
+ readonly macosLsTimeout: "ls timeout";
38
+ readonly macosAclFlag: "ACL present (+ flag)";
39
+ readonly macosAceListed: "ACE listed";
40
+ };
27
41
  export declare function createLinuxAclAdapter(spawn?: SpawnFn): PosixAclAdapter;
28
42
  export declare function createMacosAclAdapter(spawn?: SpawnFn): PosixAclAdapter;
43
+ /**
44
+ * Whether the host's ACL adapter is RESOLVABLE — an install-time capability
45
+ * question, deliberately separate from the per-target prover above. It never
46
+ * decides whether a target is safe and never gates a mutation.
47
+ */
48
+ export type AclCapability = {
49
+ status: "available";
50
+ tool: "getfacl" | "/bin/ls";
51
+ } | {
52
+ status: "absent";
53
+ tool: "getfacl" | "/bin/ls";
54
+ } | {
55
+ status: "unknown";
56
+ tool: string;
57
+ detail: string;
58
+ } | {
59
+ status: "not-applicable";
60
+ tool: "windows-secure-object";
61
+ };
62
+ /**
63
+ * Probe the ACL adapter READ-ONLY: it resolves and runs a version/list argv and
64
+ * inspects nothing on disk. It creates, modifies and removes nothing, and its
65
+ * result NEVER feeds the transactional gate — the prover (`proveClean`) is the
66
+ * only authority on whether a path is safe, and it stays fail-closed.
67
+ *
68
+ * `unknown` (timeout, non-zero exit, unparseable output, unsupported platform)
69
+ * is honest ignorance: the caller reports it, it never becomes `available`.
70
+ */
71
+ export declare function probeAclCapability(spawn?: SpawnFn, platform?: NodeJS.Platform): Promise<AclCapability>;
29
72
  export declare function createPosixSecureFs(acl: PosixAclAdapter): PlatformSecureFs;
30
73
  /**
31
74
  * Select the secure filesystem for the host platform. Linux uses the `getfacl`
@@ -47,6 +47,21 @@ const defaultSpawn = (cmd, args) => new Promise((resolve) => {
47
47
  return resolve({ code, stdout: stdout ?? "" });
48
48
  });
49
49
  });
50
+ // --- stable refusal-detail tokens --------------------------------------------
51
+ /**
52
+ * The EXACT detail strings the POSIX adapters emit, exported so consumers (the
53
+ * CLI remediation table) can key off a token instead of string-matching prose.
54
+ * These values are frozen: changing one changes an observable refusal detail.
55
+ */
56
+ export const ACL_DETAIL = {
57
+ getfaclAbsent: "getfacl absent",
58
+ getfaclTimeout: "getfacl timeout",
59
+ extendedAclEntry: "extended ACL entry",
60
+ macosLsAbsent: "/bin/ls absent",
61
+ macosLsTimeout: "ls timeout",
62
+ macosAclFlag: "ACL present (+ flag)",
63
+ macosAceListed: "ACE listed",
64
+ };
50
65
  // --- Linux getfacl adapter (Algorithm D) -------------------------------------
51
66
  const LINUX_BASE_ENTRY = /^(user|group|other)::/;
52
67
  export function createLinuxAclAdapter(spawn = defaultSpawn) {
@@ -60,9 +75,9 @@ export function createLinuxAclAdapter(spawn = defaultSpawn) {
60
75
  target,
61
76
  ]);
62
77
  if (res.spawnError)
63
- return refuse("unsupported-posix-acl", "getfacl absent");
78
+ return refuse("unsupported-posix-acl", ACL_DETAIL.getfaclAbsent);
64
79
  if (res.timedOut)
65
- return refuse("unsupported-posix-acl", "getfacl timeout");
80
+ return refuse("unsupported-posix-acl", ACL_DETAIL.getfaclTimeout);
66
81
  if (res.code !== 0) {
67
82
  return refuse("unsupported-posix-acl", `getfacl exit ${res.code}`);
68
83
  }
@@ -72,7 +87,7 @@ export function createLinuxAclAdapter(spawn = defaultSpawn) {
72
87
  continue;
73
88
  if (LINUX_BASE_ENTRY.test(line))
74
89
  continue;
75
- return refuse("unsupported-posix-acl", "extended ACL entry");
90
+ return refuse("unsupported-posix-acl", ACL_DETAIL.extendedAclEntry);
76
91
  }
77
92
  return ok();
78
93
  },
@@ -85,24 +100,68 @@ export function createMacosAclAdapter(spawn = defaultSpawn) {
85
100
  async proveClean(target) {
86
101
  const res = await spawn("/bin/ls", ["-lde", "--", target]);
87
102
  if (res.spawnError)
88
- return refuse("unsupported-posix-acl", "/bin/ls absent");
103
+ return refuse("unsupported-posix-acl", ACL_DETAIL.macosLsAbsent);
89
104
  if (res.timedOut)
90
- return refuse("unsupported-posix-acl", "ls timeout");
105
+ return refuse("unsupported-posix-acl", ACL_DETAIL.macosLsTimeout);
91
106
  if (res.code !== 0) {
92
107
  return refuse("unsupported-posix-acl", `ls exit ${res.code}`);
93
108
  }
94
109
  const lines = res.stdout.split("\n");
95
110
  const modeLine = lines[0] ?? "";
96
111
  if (modeLine[10] === "+") {
97
- return refuse("unsupported-posix-acl", "ACL present (+ flag)");
112
+ return refuse("unsupported-posix-acl", ACL_DETAIL.macosAclFlag);
98
113
  }
99
114
  if (lines.some((line) => MACOS_ACE_LINE.test(line))) {
100
- return refuse("unsupported-posix-acl", "ACE listed");
115
+ return refuse("unsupported-posix-acl", ACL_DETAIL.macosAceListed);
101
116
  }
102
117
  return ok();
103
118
  },
104
119
  };
105
120
  }
121
+ /**
122
+ * Probe the ACL adapter READ-ONLY: it resolves and runs a version/list argv and
123
+ * inspects nothing on disk. It creates, modifies and removes nothing, and its
124
+ * result NEVER feeds the transactional gate — the prover (`proveClean`) is the
125
+ * only authority on whether a path is safe, and it stays fail-closed.
126
+ *
127
+ * `unknown` (timeout, non-zero exit, unparseable output, unsupported platform)
128
+ * is honest ignorance: the caller reports it, it never becomes `available`.
129
+ */
130
+ export async function probeAclCapability(spawn = defaultSpawn, platform = process.platform) {
131
+ if (platform === "win32") {
132
+ return { status: "not-applicable", tool: "windows-secure-object" };
133
+ }
134
+ if (platform !== "linux" && platform !== "darwin") {
135
+ return {
136
+ status: "unknown",
137
+ tool: platform,
138
+ detail: `no POSIX ACL adapter for platform ${platform}`,
139
+ };
140
+ }
141
+ const tool = platform === "linux" ? "getfacl" : "/bin/ls";
142
+ const args = platform === "linux" ? ["--version"] : ["-ld", "/"];
143
+ const res = await spawn(tool, args);
144
+ if (res.spawnError)
145
+ return { status: "absent", tool };
146
+ if (res.timedOut) {
147
+ // The argv differs per platform (`getfacl --version` on linux, `/bin/ls -ld /`
148
+ // on darwin), so the detail names the probe, not a hardcoded flag.
149
+ return { status: "unknown", tool, detail: `${tool} probe timeout` };
150
+ }
151
+ if (res.code !== 0) {
152
+ return { status: "unknown", tool, detail: `${tool} exit ${res.code}` };
153
+ }
154
+ // Linux only: a zero exit whose banner does not name getfacl is a foreign
155
+ // binary on PATH, not proof of the adapter — report ignorance, not success.
156
+ if (platform === "linux" && !res.stdout.toLowerCase().includes("getfacl")) {
157
+ return {
158
+ status: "unknown",
159
+ tool,
160
+ detail: `${tool} --version output unparseable`,
161
+ };
162
+ }
163
+ return { status: "available", tool };
164
+ }
106
165
  // --- the POSIX secure filesystem --------------------------------------------
107
166
  const DIR_FLAGS = FS.O_DIRECTORY | FS.O_NOFOLLOW | FS.O_RDONLY;
108
167
  const CAPTURE_FLAGS = FS.O_NOFOLLOW | FS.O_RDONLY;