javi-forge 1.33.0 → 1.34.1

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.
package/README.md CHANGED
@@ -375,6 +375,8 @@ npx javi-forge doctor
375
375
  ## Requirements
376
376
 
377
377
  - **Node.js** >= 22 (required by ink 7; previous versions ran on >= 18)
378
+ - **Linux only** — the `acl` package (provides `getfacl`) is required to install or repair the Claude PreToolUse guard: the transactional installer proves every controlling directory carries no extended ACL, and refuses fail-closed when `getfacl` is unresolvable. Install with `apt install acl`, `apk add acl`, or `dnf install acl` (slim container images usually omit it). An already-installed guard keeps working without it — `javi-forge hooks doctor claude` reports the capability as its own row.
379
+ - **`node` on `PATH`** — Claude Code spawns the guard in exec form, so `node` must resolve on the `PATH` Claude Code itself uses, not only inside javi-forge.
378
380
 
379
381
  ## Ecosystem
380
382
 
@@ -19,7 +19,7 @@ export declare const CI_HELP_TEXT = "\n Usage\n $ javi-forge ci [subcommand]
19
19
  * Per-command help for `hooks`, shown by `javi-forge hooks --help` (or when
20
20
  * `hooks` is given an unknown subcommand). Whitespace is significant.
21
21
  */
22
- export declare const HOOKS_HELP_TEXT = "\n Usage\n $ javi-forge hooks run <pre-commit|pre-push>\n $ javi-forge hooks <install|doctor|repair> claude [--force]\n\n Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed\n cheap\u2192expensive order, fail-fast. With no hooks: config the default is the\n quick native CI gate (setup + lint + compile + gates \u2014 no tests, no coverage).\n\n Subcommands\n run pre-commit Run the composed pre-commit sections\n run pre-push Run the composed pre-push sections\n install claude Install the managed Claude PreToolUse guard (.claude/)\n doctor claude Report Claude PreToolUse guard health (informational)\n repair claude Repair the managed guard; --force overwrites edited assets\n\n Notes\n A blocking section failure exits non-zero and blocks the commit/push.\n A broken .javi-forge/ci.yaml exits 1 (fail-closed \u2014 never skips a gate).\n To skip: git commit --no-verify (pre-push: git push --no-verify)\n doctor claude is informational (always exits 0); install/repair exit 0 on\n success, non-zero on refusal/failure. Use repair claude --force to overwrite\n a locally edited managed asset.\n\n Examples\n $ javi-forge hooks run pre-commit\n $ javi-forge hooks run pre-push\n $ javi-forge hooks install claude\n $ javi-forge hooks doctor claude\n $ javi-forge hooks repair claude --force\n";
22
+ export declare const HOOKS_HELP_TEXT = "\n Usage\n $ javi-forge hooks run <pre-commit|pre-push>\n $ javi-forge hooks <install|doctor|repair> claude [--force]\n\n Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed\n cheap\u2192expensive order, fail-fast. With no hooks: config the default is the\n quick native CI gate (setup + lint + compile + gates \u2014 no tests, no coverage).\n\n Subcommands\n run pre-commit Run the composed pre-commit sections\n run pre-push Run the composed pre-push sections\n install claude Install the managed Claude PreToolUse guard (.claude/)\n doctor claude Report Claude PreToolUse guard health (informational)\n repair claude Repair the managed guard; --force overwrites edited assets\n\n Notes\n A blocking section failure exits non-zero and blocks the commit/push.\n A broken .javi-forge/ci.yaml exits 1 (fail-closed \u2014 never skips a gate).\n To skip: git commit --no-verify (pre-push: git push --no-verify)\n doctor claude is informational (always exits 0); install/repair exit 0 on\n success, non-zero on refusal/failure. Use repair claude --force to overwrite\n a locally edited managed asset.\n Linux: install/repair claude need the acl package (getfacl) to prove the\n parent chain \u2014 apt install acl / apk add acl / dnf install acl. Without it\n they refuse fail-closed; an already-installed guard keeps firing, and\n doctor claude reports the acl capability as its own row.\n Claude Code spawns the guard with node from ITS path, so node must resolve\n there, not only inside javi-forge.\n\n Examples\n $ javi-forge hooks run pre-commit\n $ javi-forge hooks run pre-push\n $ javi-forge hooks install claude\n $ javi-forge hooks doctor claude\n $ javi-forge hooks repair claude --force\n";
23
23
  export declare const FLAGS_SCHEMA: {
24
24
  readonly help: {
25
25
  readonly type: "boolean";
package/dist/cli/help.js CHANGED
@@ -186,6 +186,12 @@ export const HOOKS_HELP_TEXT = `
186
186
  doctor claude is informational (always exits 0); install/repair exit 0 on
187
187
  success, non-zero on refusal/failure. Use repair claude --force to overwrite
188
188
  a locally edited managed asset.
189
+ Linux: install/repair claude need the acl package (getfacl) to prove the
190
+ parent chain — apt install acl / apk add acl / dnf install acl. Without it
191
+ they refuse fail-closed; an already-installed guard keeps firing, and
192
+ doctor claude reports the acl capability as its own row.
193
+ Claude Code spawns the guard with node from ITS path, so node must resolve
194
+ there, not only inside javi-forge.
189
195
 
190
196
  Examples
191
197
  $ javi-forge hooks run pre-commit
@@ -14,6 +14,18 @@
14
14
  * current.
15
15
  */
16
16
  import { doctorClaudePreToolUse, installClaudePreToolUse, repairClaudePreToolUse, } from "../lib/claude-hook-manager.js";
17
+ import { remediationForMessage } from "../lib/secure-refusal-remediation.js";
18
+ /**
19
+ * Warnings are NON-BLOCKING notices, so they go to stdout under their own
20
+ * heading — never to the error stream and never into the exit code.
21
+ */
22
+ function renderWarnings(warnings, log) {
23
+ if (warnings.length === 0)
24
+ return;
25
+ log("warnings:");
26
+ for (const w of warnings)
27
+ log(` ${w}`);
28
+ }
17
29
  function renderMutation(verb, result, log, logError) {
18
30
  if (result.ok) {
19
31
  log(`${verb} claude: ok`);
@@ -30,11 +42,19 @@ function renderMutation(verb, result, log, logError) {
30
42
  for (const p of result.backups)
31
43
  log(` ${p}`);
32
44
  }
45
+ renderWarnings(result.warnings, log);
33
46
  return 0;
34
47
  }
35
48
  logError(`${verb} claude: refused`);
36
- for (const e of result.errors)
49
+ // A refusal whose detail maps to a remediation is never rendered bare: the
50
+ // mapping is a CLI-layer lookup, so the adapter's refusal codes stay stable.
51
+ for (const e of result.errors) {
37
52
  logError(` ${e}`);
53
+ const remediation = remediationForMessage(e);
54
+ if (remediation)
55
+ logError(` → ${remediation}`);
56
+ }
57
+ renderWarnings(result.warnings, log);
38
58
  return 1;
39
59
  }
40
60
  function renderDoctor(report, log) {
@@ -42,6 +62,16 @@ function renderDoctor(report, log) {
42
62
  log(` settings: ${report.settings.state} — ${report.settings.detail}`);
43
63
  log(` asset: ${report.asset.state} — ${report.asset.detail}`);
44
64
  log(` node: ${report.node.version ?? "unavailable"} (min-satisfied: ${report.node.satisfiesMinimum})`);
65
+ // The node-on-PATH row is ALWAYS printed (satisfied included) and is always
66
+ // labelled a heuristic: this process' PATH only proxies the PATH Claude Code
67
+ // will use to spawn the exec-form handler.
68
+ const onPath = report.nodeOnPath;
69
+ const onPathDetail = onPath.status === "resolved"
70
+ ? ` ${onPath.version}`
71
+ : onPath.status === "unknown"
72
+ ? ` — ${onPath.detail}`
73
+ : "";
74
+ log(` node-on-PATH: ${onPath.status}${onPathDetail} (heuristic: this process' PATH)`);
45
75
  const execution = report.execution;
46
76
  log(` execution: ${execution.status}`);
47
77
  if (execution.blockers.length > 0) {
@@ -59,6 +89,15 @@ function renderDoctor(report, log) {
59
89
  for (const r of execution.residual)
60
90
  log(` - ${r}`);
61
91
  }
92
+ // Install-capability is its OWN section, always printed so an absent adapter
93
+ // is never silent — and it never changes the exit code, which follows
94
+ // `execution.status` alone.
95
+ const acl = report.installCapability.acl;
96
+ const aclDetail = "detail" in acl ? ` — ${acl.detail}` : "";
97
+ log(` acl-capability: ${acl.status} (${acl.tool})${aclDetail}`);
98
+ if (report.installCapability.remediation) {
99
+ log(` → ${report.installCapability.remediation}`);
100
+ }
62
101
  log(` host-residual: ${report.hostResidual}`);
63
102
  if (report.remediation.length > 0) {
64
103
  log(" remediation:");
@@ -10,6 +10,8 @@ import type { StepFn } from "../types.js";
10
10
  * `claude-settings-security.json` scaffold is RETIRED — the managed
11
11
  * installer owns `.claude/settings.json` + the hook asset with proper
12
12
  * ownership markers.
13
+ * A guard refusal/failure is REPORTED but does NOT abort the step: the
14
+ * profile merge below is an independent outcome (Linux hardening Slice A).
13
15
  * 2. Merges the `hooks:` security sections for the selected reliability
14
16
  * profile into `.javi-forge/ci.yaml` via `setHookFeature` (creating a
15
17
  * minimal `version: 2` config when absent). The dispatcher composes these
@@ -1,5 +1,6 @@
1
1
  import { setHookFeature } from "../../../lib/ci-config.js";
2
2
  import { installClaudePreToolUse } from "../../../lib/claude-hook-manager.js";
3
+ import { remediationForMessage } from "../../../lib/secure-refusal-remediation.js";
3
4
  import { report } from "../report.js";
4
5
  /**
5
6
  * Hook-feature preset per reliability profile (hook-consolidation S4).
@@ -28,6 +29,8 @@ const PROFILE_PRESET = {
28
29
  * `claude-settings-security.json` scaffold is RETIRED — the managed
29
30
  * installer owns `.claude/settings.json` + the hook asset with proper
30
31
  * ownership markers.
32
+ * A guard refusal/failure is REPORTED but does NOT abort the step: the
33
+ * profile merge below is an independent outcome (Linux hardening Slice A).
31
34
  * 2. Merges the `hooks:` security sections for the selected reliability
32
35
  * profile into `.javi-forge/ci.yaml` via `setHookFeature` (creating a
33
36
  * minimal `version: 2` config when absent). The dispatcher composes these
@@ -41,6 +44,11 @@ export const stepSecurityHooks = async (ctx) => {
41
44
  const { securityHooks, hookProfile, claudePreToolUseGuard } = options;
42
45
  const stepId = "security-hooks";
43
46
  report(onStep, stepId, "Scaffold security hooks", "running");
47
+ // Declared OUTSIDE the try so a throw from the profile merge below cannot
48
+ // swallow a guard refusal that already happened: the outer catch reports
49
+ // BOTH failures (Linux hardening Slice A — a captured refusal and its
50
+ // remediation are never silently lost).
51
+ let guardError;
44
52
  try {
45
53
  if (!securityHooks) {
46
54
  report(onStep, stepId, "Scaffold security hooks", "skipped", "not selected");
@@ -57,14 +65,30 @@ export const stepSecurityHooks = async (ctx) => {
57
65
  }
58
66
  // 1. Install the managed Claude PreToolUse guard (transactional; owns
59
67
  // .claude/settings.json + the hook asset). Retires the legacy copy.
68
+ // A refusal (or a throw) is CAPTURED, not returned on: the guard and
69
+ // the hook-profile merge below are independent outcomes, so a host
70
+ // that cannot install the guard must not silently lose its
71
+ // secrets/permissions/deps wiring. The captured failure still drives a
72
+ // terminal status of "error" — visibility is never downgraded.
60
73
  let guardNote = "";
61
74
  if (claudePreToolUseGuard) {
62
- const result = await installClaudePreToolUse(projectDir);
63
- if (!result.ok) {
64
- report(onStep, stepId, "Scaffold security hooks", "error", `Claude guard install refused: ${result.errors.join("; ")}`);
65
- return;
75
+ try {
76
+ const result = await installClaudePreToolUse(projectDir);
77
+ if (result.ok) {
78
+ guardNote = "; Claude guard installed";
79
+ }
80
+ else {
81
+ guardError = `Claude guard install refused: ${result.errors.join("; ")}`;
82
+ const remediation = result.errors
83
+ .map((e) => remediationForMessage(e))
84
+ .find((line) => line !== undefined);
85
+ if (remediation)
86
+ guardError += ` → ${remediation}`;
87
+ }
88
+ }
89
+ catch (e) {
90
+ guardError = `Claude guard install failed: ${String(e)}`;
66
91
  }
67
- guardNote = "; Claude guard installed";
68
92
  }
69
93
  // 2. Merge the profile's security sections into .javi-forge/ci.yaml.
70
94
  for (const feature of preset.preCommit) {
@@ -80,10 +104,16 @@ export const stepSecurityHooks = async (ctx) => {
80
104
  const presetNote = merged.length > 0
81
105
  ? `${profile} preset: ${merged.join(", ")}`
82
106
  : `${profile} preset: CI gate only (no security sections)`;
83
- report(onStep, stepId, "Scaffold security hooks", "done", `${presetNote}${guardNote}`);
107
+ // ONE terminal report. On a captured guard failure the status stays
108
+ // "error" and the detail names BOTH the refusal (+ remediation) AND the
109
+ // preset that WAS merged, so a refused guard is never read as installed
110
+ // and a merged profile is never read as lost.
111
+ report(onStep, stepId, "Scaffold security hooks", guardError ? "error" : "done", guardError
112
+ ? `${guardError}; ${presetNote} merged`
113
+ : `${presetNote}${guardNote}`);
84
114
  }
85
115
  catch (e) {
86
- report(onStep, stepId, "Scaffold security hooks", "error", String(e));
116
+ report(onStep, stepId, "Scaffold security hooks", "error", guardError ? `${guardError}; ${String(e)}` : String(e));
87
117
  }
88
118
  };
89
119
  //# sourceMappingURL=security.js.map
@@ -27,6 +27,11 @@ export interface FakeFaults {
27
27
  writeRefuse?: (name: string, callIndex: number) => boolean;
28
28
  /** Refuse renameInDir when the destination base name matches. */
29
29
  renameRefuse?: (to: string) => boolean;
30
+ /**
31
+ * Refuse applyExactMode for a target (chmod fault). Lets a test drive the
32
+ * rollback's prior-mode restore into failure without touching the bytes.
33
+ */
34
+ applyModeRefuse?: (target: string) => boolean;
30
35
  /**
31
36
  * Refuse proveManagedContainer for a path on its Nth call (a foreign
32
37
  * add/delete-child ACE on a managed container we own — the win32 CREATE_PARENT_DIR
@@ -152,6 +152,8 @@ export function makeFakeSecureFs() {
152
152
  return ok();
153
153
  },
154
154
  async applyExactMode(target, mode) {
155
+ if (fake.faults.applyModeRefuse?.(target))
156
+ return unsafe(`applyMode ${target}`);
155
157
  const file = files.get(target);
156
158
  if (!file)
157
159
  return unsafe(`applyMode enoent ${target}`);
@@ -8,6 +8,7 @@
8
8
  * Slice-3 seams — Slice 3 GROWS this file, it does not relocate this code.
9
9
  */
10
10
  import { type ClaudeHookComponentState, type SettingsClassification, type SettingsIdentityManifest } from "./claude-hook-settings.js";
11
+ import { type AclCapability, type SpawnFn } from "./secure-fs-posix.js";
11
12
  import { type PlatformSecureFs } from "./secure-fs-transaction.js";
12
13
  declare const COVERAGE: readonly ["Bash", "PowerShell", "Read", "Write", "Edit"];
13
14
  export interface AssetManifestEntry {
@@ -52,6 +53,23 @@ export interface ClaudeHookDoctorReport {
52
53
  hostResidual: string;
53
54
  remediation: readonly string[];
54
55
  execution: ExecutionReport;
56
+ /**
57
+ * Host INSTALL-capability, reported as its own section. It answers "could
58
+ * install/repair run here?", NOT "will the installed guard fire?" — so it
59
+ * never feeds `execution`, `healthy`, or the exit code. A current, firing
60
+ * guard on an image without `getfacl` stays `runnable`.
61
+ */
62
+ installCapability: {
63
+ acl: AclCapability;
64
+ remediation?: string;
65
+ };
66
+ /**
67
+ * The node-on-PATH HEURISTIC row, always present so an absence is never
68
+ * silent. It is DISTINCT from `node` (which measures this process'
69
+ * `process.versions.node`) and it never inflates confidence: a `resolved`
70
+ * row clears no blocker and no unknown source.
71
+ */
72
+ nodeOnPath: NodeOnPathProbe;
55
73
  }
56
74
  /**
57
75
  * Classify the asset into one of nine states from observed bytes only. Never
@@ -81,6 +99,28 @@ export interface ExecutionReport {
81
99
  unknownSources: string[];
82
100
  residual: string[];
83
101
  }
102
+ /**
103
+ * Whether a `node` executable resolves on THIS process' PATH, independently of
104
+ * `process.versions.node`. It is a HEURISTIC proxy for the PATH Claude Code will
105
+ * use to spawn the exec-form handler — never proof of it.
106
+ */
107
+ export type NodeOnPathProbe = {
108
+ status: "resolved";
109
+ version: string;
110
+ major: number;
111
+ } | {
112
+ status: "absent";
113
+ } | {
114
+ status: "unknown";
115
+ detail: string;
116
+ };
117
+ /**
118
+ * Resolve and run `node --version` from this process' PATH. A spawn ENOENT is
119
+ * `absent` (near-certainly a dead exec-form guard); a timeout, non-zero exit, or
120
+ * unparseable banner is honest ignorance (`unknown`) and NEVER a fabricated
121
+ * version.
122
+ */
123
+ export declare function probeNodeOnPath(spawn?: SpawnFn): Promise<NodeOnPathProbe>;
84
124
  /** Injectable seams so units never hard-read real `/etc` or `/Library`. */
85
125
  export interface ExecutionProbeEnv {
86
126
  platform?: NodeJS.Platform;
@@ -92,6 +132,8 @@ export interface ExecutionProbeEnv {
92
132
  managedDropInDir?: string | null;
93
133
  /** Override the drop-in directory listing (defaults to a confined readdir). */
94
134
  listDir?: (dir: string) => Promise<string[]>;
135
+ /** Injectable node-on-PATH heuristic (defaults to the real bounded probe). */
136
+ nodeProbe?: () => Promise<NodeOnPathProbe>;
95
137
  }
96
138
  /** Per-source read outcome; never promotes an unobservable source to clear. */
97
139
  export type ExecutionSourceProbe = {
@@ -99,6 +141,8 @@ export type ExecutionSourceProbe = {
99
141
  } | {
100
142
  kind: "blocking";
101
143
  flag: "disableAllHooks" | "allowManagedHooksOnly";
144
+ /** Present only for a PRESENT-but-INVALID value; names the observed shape. */
145
+ detail?: string;
102
146
  } | {
103
147
  kind: "unknown";
104
148
  reason: string;
@@ -161,6 +205,8 @@ export declare function doctorClaudePreToolUse(projectDir: string, options?: {
161
205
  manifest?: Manifest;
162
206
  nodeVersion?: string;
163
207
  execution?: ExecutionProbeEnv;
208
+ /** Injectable read-only ACL capability probe (defaults to the real one). */
209
+ aclProbe?: () => Promise<AclCapability>;
164
210
  }): Promise<ClaudeHookDoctorReport>;
165
211
  export interface ClaudeHookMutationResult {
166
212
  ok: boolean;
@@ -168,6 +214,12 @@ export interface ClaudeHookMutationResult {
168
214
  backups: string[];
169
215
  report: ClaudeHookDoctorReport;
170
216
  errors: string[];
217
+ /**
218
+ * NON-BLOCKING operator notices. A warning never changes `ok`: refusing to
219
+ * install because `node` may not resolve would leave the host with NO guard,
220
+ * which is strictly worse than an exec-form guard that may not resolve.
221
+ */
222
+ warnings: string[];
171
223
  }
172
224
  /** Injectable deps so tests drive `_run` with a fake `PlatformSecureFs`. */
173
225
  export interface ClaudeHookRunDeps {
@@ -176,6 +228,14 @@ export interface ClaudeHookRunDeps {
176
228
  nonce?: () => string;
177
229
  manifest?: Manifest;
178
230
  platform?: NodeJS.Platform;
231
+ /**
232
+ * Injectable read-only ACL capability probe, forwarded to the doctor report
233
+ * this run embeds. Same seam as `secureFs`/`clock`: it keeps unit tests from
234
+ * spawning the real `getfacl` (defaults to the real probe in production).
235
+ */
236
+ aclProbe?: () => Promise<AclCapability>;
237
+ /** Injectable node-on-PATH heuristic, shared by the warning and the report. */
238
+ nodeProbe?: () => Promise<NodeOnPathProbe>;
179
239
  }
180
240
  /** Internal deps-taking entry; tests drive it with a fake `PlatformSecureFs`. */
181
241
  export declare function _run(projectDir: string, mode: "install" | "repair", options: {
@@ -7,6 +7,7 @@
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
12
  import { lstat, readdir, readFile } from "node:fs/promises";
12
13
  import os from "node:os";
@@ -15,8 +16,9 @@ import { CLAUDE_HOOK_ASSETS_DIR } from "../constants.js";
15
16
  import { ASSET_MANAGED_MARKER, ASSET_NAME, } from "./__fixtures__/claude-hook-ownership.js";
16
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";
17
18
  import { safeReadFile } from "./safe-read.js";
18
- import { selectSecureFs } from "./secure-fs-posix.js";
19
+ import { ACL_DETAIL, probeAclCapability, selectSecureFs, } from "./secure-fs-posix.js";
19
20
  import { runTransaction, } from "./secure-fs-transaction.js";
21
+ import { remediationForRefusal } from "./secure-refusal-remediation.js";
20
22
  /** 1 MiB read budget, shared with the runtime's stdin envelope. */
21
23
  const ASSET_MAX_BYTES = 1024 * 1024;
22
24
  const NODE_MINIMUM_MAJOR = 22;
@@ -189,10 +191,12 @@ function settingsSignals(value, classification, currentAssetSha) {
189
191
  };
190
192
  }
191
193
  const REMEDIATION = {
192
- absent: "install the managed $ (Slice 3)",
193
- "released-outdated": "upgrade the managed $ (Slice 3)",
194
- "exact-legacy": "migrate the legacy $ (Slice 3)",
195
- "edited-managed": "repair the managed $ with --force (Slice 3)",
194
+ // User-facing remediation: name the exact command to run, never an internal
195
+ // SDD slice number (which means nothing outside this repo's planning docs).
196
+ absent: "install the managed $ with: javi-forge hooks install claude",
197
+ "released-outdated": "upgrade the managed $ with: javi-forge hooks install claude",
198
+ "exact-legacy": "migrate the legacy $ with: javi-forge hooks install claude",
199
+ "edited-managed": "repair the managed $ with: javi-forge hooks repair claude --force",
196
200
  foreign: "manually review the $",
197
201
  symlink: "manually review the $",
198
202
  "non-regular": "manually review the $",
@@ -201,6 +205,57 @@ const REMEDIATION = {
201
205
  function remediationFor(state, component) {
202
206
  return REMEDIATION[state]?.replace("$", component);
203
207
  }
208
+ const NODE_PROBE_TIMEOUT_MS = 2000;
209
+ const NODE_PROBE_MAX_BUFFER = 64 * 1024;
210
+ const NODE_VERSION_LINE = /^v(\d+)\.\d+\.\d+/;
211
+ /**
212
+ * Bounded `node --version` spawn. argv only (never a shell string), `LC_ALL=C`,
213
+ * and a hard timeout — mirroring the ACL adapter's spawn discipline. It is
214
+ * read-only: it starts a process and reads its stdout, and touches no path.
215
+ */
216
+ const defaultNodeSpawn = (cmd, args) => new Promise((resolve) => {
217
+ execFile(cmd, args, {
218
+ timeout: NODE_PROBE_TIMEOUT_MS,
219
+ maxBuffer: NODE_PROBE_MAX_BUFFER,
220
+ encoding: "utf8",
221
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
222
+ }, (error, stdout) => {
223
+ if (!error)
224
+ return resolve({ code: 0, stdout: stdout ?? "" });
225
+ const e = error;
226
+ if (e.code === "ENOENT") {
227
+ return resolve({ spawnError: true, code: null, stdout: "" });
228
+ }
229
+ if (e.killed || e.signal === "SIGTERM") {
230
+ return resolve({ timedOut: true, code: null, stdout: stdout ?? "" });
231
+ }
232
+ const code = typeof e.code === "number" ? e.code : 1;
233
+ return resolve({ code, stdout: stdout ?? "" });
234
+ });
235
+ });
236
+ /**
237
+ * Resolve and run `node --version` from this process' PATH. A spawn ENOENT is
238
+ * `absent` (near-certainly a dead exec-form guard); a timeout, non-zero exit, or
239
+ * unparseable banner is honest ignorance (`unknown`) and NEVER a fabricated
240
+ * version.
241
+ */
242
+ export async function probeNodeOnPath(spawn = defaultNodeSpawn) {
243
+ const res = await spawn("node", ["--version"]);
244
+ if (res.spawnError)
245
+ return { status: "absent" };
246
+ if (res.timedOut) {
247
+ return { status: "unknown", detail: "node --version timeout" };
248
+ }
249
+ if (res.code !== 0) {
250
+ return { status: "unknown", detail: `node --version exit ${res.code}` };
251
+ }
252
+ const banner = res.stdout.trim();
253
+ const match = NODE_VERSION_LINE.exec(banner);
254
+ if (!match) {
255
+ return { status: "unknown", detail: "node --version output unparseable" };
256
+ }
257
+ return { status: "resolved", version: banner, major: Number(match[1]) };
258
+ }
204
259
  /** Static managed-settings locations per OS (no fs). WSL reports `linux`. */
205
260
  export function resolveManagedSettingsPaths(platform) {
206
261
  if (platform === "darwin") {
@@ -256,11 +311,21 @@ export async function probeExecutionSource(target) {
256
311
  return { kind: "unknown", reason: "invalid-json" };
257
312
  }
258
313
  const flags = scanExecutionFlags(parsed);
259
- if (flags.disableAllHooks) {
260
- return { kind: "blocking", flag: "disableAllHooks" };
314
+ const blocking = (flag, verdict) => ({
315
+ kind: "blocking",
316
+ flag,
317
+ // An INVALID value is blocking per the documented "invalid ⇒ true"
318
+ // semantics; the shape is surfaced so an operator can see why (say) the
319
+ // string "false" did not clear the flag.
320
+ ...(verdict.set && verdict.reason === "invalid"
321
+ ? { detail: `invalid value: ${verdict.shape} → treated as true` }
322
+ : {}),
323
+ });
324
+ if (flags.disableAllHooks.set) {
325
+ return blocking("disableAllHooks", flags.disableAllHooks);
261
326
  }
262
- if (flags.allowManagedHooksOnly) {
263
- return { kind: "blocking", flag: "allowManagedHooksOnly" };
327
+ if (flags.allowManagedHooksOnly.set) {
328
+ return blocking("allowManagedHooksOnly", flags.allowManagedHooksOnly);
264
329
  }
265
330
  return { kind: "clear" };
266
331
  }
@@ -288,6 +353,7 @@ export async function listManagedDropIns(dir, listDir) {
288
353
  const EXECUTION_RESIDUAL = [
289
354
  "server-delivered managed policy can disable hooks and is not observable from local files",
290
355
  "session safe-mode (--safe-mode / CLAUDE_CODE_SAFE_MODE) in the diagnosed session is not observable from this process",
356
+ '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',
291
357
  ];
292
358
  function isSafeModeTruthy(env) {
293
359
  const value = env.CLAUDE_CODE_SAFE_MODE;
@@ -361,9 +427,11 @@ export async function probeExecution(projectDir, componentStates, env = {}) {
361
427
  continue;
362
428
  }
363
429
  // `allowManagedHooksOnly` is inert outside a managed source (hooks merge).
430
+ // An INVALID value there is inert for the same reason: the flag has no
431
+ // authority outside a managed source, so it gains none by being malformed.
364
432
  if (probe.flag === "allowManagedHooksOnly" && !spec.managed)
365
433
  continue;
366
- blockers.push(`policy:${probe.flag}@${spec.label}`);
434
+ blockers.push(`policy:${probe.flag}@${spec.label}${probe.detail ? ` (${probe.detail})` : ""}`);
367
435
  }
368
436
  if (dropInDirUnknown)
369
437
  unknownSources.push(dropInDirUnknown);
@@ -374,6 +442,23 @@ export async function probeExecution(projectDir, componentStates, env = {}) {
374
442
  if (componentStates.settings !== "managed-current") {
375
443
  blockers.push(`guard:settings=${componentStates.settings}`);
376
444
  }
445
+ // node-on-PATH heuristic (design Decision 2). The installed handler is
446
+ // exec-form (`command: "node"`), so a `node` that does not resolve means the
447
+ // guard NEVER fires — fail-closed, with the heuristic labelled in the entry.
448
+ // A SUCCESSFUL probe contributes NOTHING: it clears no blocker, removes no
449
+ // unknown source, and adds no confidence, because this process' PATH only
450
+ // proxies the PATH Claude Code will use.
451
+ const nodeOnPath = await (env.nodeProbe ?? probeNodeOnPath)();
452
+ if (nodeOnPath.status === "absent") {
453
+ blockers.push("runtime:node-not-on-PATH (heuristic: this process' PATH)");
454
+ }
455
+ else if (nodeOnPath.status === "resolved" &&
456
+ nodeOnPath.major < NODE_MINIMUM_MAJOR) {
457
+ blockers.push(`runtime:node-on-PATH v${nodeOnPath.major} (<${NODE_MINIMUM_MAJOR}, heuristic)`);
458
+ }
459
+ else if (nodeOnPath.status === "unknown") {
460
+ unknownSources.push(`runtime:node-on-PATH (heuristic: ${nodeOnPath.detail})`);
461
+ }
377
462
  // Safe-mode observed in THIS doctor process is a real per-run unknown (the
378
463
  // diagnosed session's own safe-mode remains a constant residual).
379
464
  if (isSafeModeTruthy(processEnv)) {
@@ -432,7 +517,25 @@ export async function doctorClaudePreToolUse(projectDir, options) {
432
517
  signals.matcherExact &&
433
518
  signals.commandShapeExact &&
434
519
  node.satisfiesMinimum;
435
- const execution = await probeExecution(projectDir, { asset: asset.state, settings: settings.state }, options?.execution ?? {});
520
+ // Probe node ONCE and share the outcome between the always-present report row
521
+ // and the execution matrix, so the doctor never spawns `node` twice per run.
522
+ const executionEnv = options?.execution ?? {};
523
+ const nodeOnPath = await (executionEnv.nodeProbe ?? probeNodeOnPath)();
524
+ const execution = await probeExecution(projectDir, { asset: asset.state, settings: settings.state }, { ...executionEnv, nodeProbe: async () => nodeOnPath });
525
+ // Install-capability section. Read-only, and deliberately OUTSIDE the
526
+ // execution matrix (design Decision 1): the installed `.mjs` guard never
527
+ // spawns `getfacl`, so an absent adapter cannot stop a current guard from
528
+ // firing. Only when a guard-currency blocker ALREADY exists does the
529
+ // remediation join `report.remediation` — there the user must install and
530
+ // cannot.
531
+ const aclCapability = await (options?.aclProbe ?? probeAclCapability)();
532
+ const aclRemediation = aclCapability.status === "absent" && aclCapability.tool === "getfacl"
533
+ ? remediationForRefusal("unsupported-posix-acl", ACL_DETAIL.getfaclAbsent)
534
+ : undefined;
535
+ if (aclRemediation &&
536
+ execution.blockers.some((blocker) => blocker.startsWith("guard:"))) {
537
+ remediation.add(aclRemediation);
538
+ }
436
539
  return {
437
540
  healthy,
438
541
  settings: {
@@ -455,6 +558,11 @@ export async function doctorClaudePreToolUse(projectDir, options) {
455
558
  hostResidual: HOST_RESIDUAL,
456
559
  remediation: [...remediation].sort(),
457
560
  execution,
561
+ nodeOnPath,
562
+ installCapability: {
563
+ acl: aclCapability,
564
+ ...(aclRemediation ? { remediation: aclRemediation } : {}),
565
+ },
458
566
  };
459
567
  }
460
568
  async function readManifest() {
@@ -463,6 +571,24 @@ async function readManifest() {
463
571
  throw new Error(`unreadable claude-hooks manifest: ${read.reason}`);
464
572
  return JSON.parse(read.content);
465
573
  }
574
+ /**
575
+ * The non-blocking install/repair warning for the exec-form guard's runtime.
576
+ * Mirrors the doctor's heuristic wording: this process' PATH only PROXIES the
577
+ * PATH Claude Code will use to spawn the handler.
578
+ */
579
+ function nodeOnPathWarnings(probe) {
580
+ if (probe.status === "absent") {
581
+ return [
582
+ `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`,
583
+ ];
584
+ }
585
+ if (probe.status === "resolved" && probe.major < NODE_MINIMUM_MAJOR) {
586
+ return [
587
+ `node on PATH is ${probe.version} (<${NODE_MINIMUM_MAJOR}, heuristic): the installed guard may fail to run — install Node ${NODE_MINIMUM_MAJOR}+ on PATH`,
588
+ ];
589
+ }
590
+ return [];
591
+ }
466
592
  function refuseMessage(component, state) {
467
593
  const remedy = remediationFor(state, component);
468
594
  return `refuse ${component} in state ${state}${remedy ? ` — ${remedy}` : ""}`;
@@ -594,7 +720,18 @@ export async function _run(projectDir, mode, options, deps) {
594
720
  const assetDestPath = path.join(projectDir, ".claude", "hooks", ASSET_NAME);
595
721
  const settingsPath = path.join(projectDir, ".claude", "settings.json");
596
722
  const assetSrcPath = path.join(CLAUDE_HOOK_ASSETS_DIR, ASSET_NAME);
597
- const doctor = () => doctorClaudePreToolUse(projectDir, { manifest });
723
+ // Probe node ONCE per `_run` and share the sample with the embedded doctor
724
+ // report, so the run never spawns `node --version` twice and the warning, the
725
+ // report row and the verdict can never disagree about the same PATH.
726
+ const nodeOnPath = await (deps.nodeProbe ?? probeNodeOnPath)();
727
+ const doctor = () => doctorClaudePreToolUse(projectDir, {
728
+ manifest,
729
+ aclProbe: deps.aclProbe,
730
+ execution: { nodeProbe: async () => nodeOnPath },
731
+ });
732
+ // Non-blocking runtime notice, computed once and carried by EVERY outcome
733
+ // (success, no-op and refusal alike) — it never gates the mutation.
734
+ const warnings = nodeOnPathWarnings(nodeOnPath);
598
735
  // Windows (or any platform without an adapter) refuses with zero mutation.
599
736
  if (!secureFs) {
600
737
  return {
@@ -602,6 +739,7 @@ export async function _run(projectDir, mode, options, deps) {
602
739
  changed: [],
603
740
  backups: [],
604
741
  errors: ["windows-secure-object-unavailable"],
742
+ warnings,
605
743
  report: await doctor(),
606
744
  };
607
745
  }
@@ -624,6 +762,7 @@ export async function _run(projectDir, mode, options, deps) {
624
762
  changed: [],
625
763
  backups: [],
626
764
  errors: [reason],
765
+ warnings,
627
766
  report: await doctor(),
628
767
  };
629
768
  }
@@ -634,6 +773,7 @@ export async function _run(projectDir, mode, options, deps) {
634
773
  changed: [],
635
774
  backups: [],
636
775
  errors: [],
776
+ warnings,
637
777
  report: await doctor(),
638
778
  };
639
779
  }
@@ -672,6 +812,7 @@ export async function _run(projectDir, mode, options, deps) {
672
812
  changed: tx.committed,
673
813
  backups: tx.backups,
674
814
  errors: tx.errors,
815
+ warnings,
675
816
  report: await doctor(),
676
817
  };
677
818
  }
@@ -47,15 +47,29 @@ export declare function validateSettingsShape(parsed: unknown): boolean;
47
47
  * manager, which knows each source's provenance.
48
48
  */
49
49
  export interface ExecutionFlagScan {
50
- disableAllHooks: boolean;
51
- allowManagedHooksOnly: boolean;
50
+ disableAllHooks: FlagVerdict;
51
+ allowManagedHooksOnly: FlagVerdict;
52
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
+ };
53
68
  /**
54
69
  * Classify an already-parsed settings container for the two documented
55
- * hook-neutralizing flags. Strict `=== true` only: a truthy-but-not-`true`
56
- * value (`"true"`, `1`), a missing key, or a non-object input is NOT a flag.
57
- * A false here is a definitive "this source does not set the flag", never an
58
- * "unknown" — unreadability is decided upstream by the fs probe, not here.
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".
59
73
  */
60
74
  export declare function scanExecutionFlags(parsed: unknown): ExecutionFlagScan;
61
75
  /**
@@ -31,20 +31,44 @@ 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
+ }
34
59
  /**
35
60
  * Classify an already-parsed settings container for the two documented
36
- * hook-neutralizing flags. Strict `=== true` only: a truthy-but-not-`true`
37
- * value (`"true"`, `1`), a missing key, or a non-object input is NOT a flag.
38
- * A false here is a definitive "this source does not set the flag", never an
39
- * "unknown" — unreadability is decided upstream by the fs probe, not here.
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".
40
64
  */
41
65
  export function scanExecutionFlags(parsed) {
42
66
  if (!isPlainObject(parsed)) {
43
- return { disableAllHooks: false, allowManagedHooksOnly: false };
67
+ return { disableAllHooks: NOT_SET, allowManagedHooksOnly: NOT_SET };
44
68
  }
45
69
  return {
46
- disableAllHooks: parsed.disableAllHooks === true,
47
- allowManagedHooksOnly: parsed.allowManagedHooksOnly === true,
70
+ disableAllHooks: classifyFlag(parsed.disableAllHooks),
71
+ allowManagedHooksOnly: classifyFlag(parsed.allowManagedHooksOnly),
48
72
  };
49
73
  }
50
74
  // Canonical identity (Decision ②)
@@ -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;
@@ -286,7 +286,16 @@ export async function runTransaction(input) {
286
286
  errors.push(`STOP: cannot stage rollback for ${entry.path}`);
287
287
  return;
288
288
  }
289
- await secureFs.applyExactMode(path.join(entry.dir.path, rName), entry.prior.mode);
289
+ // Prior-mode restore is BEST-EFFORT (JD-B-003): a chmod miss fails
290
+ // toward MORE restrictive perms (the 0600 staging mode), so it is not
291
+ // a security regression and must not halt the rollback — restoring the
292
+ // prior BYTES matters more than the prior mode. Record an
293
+ // INFORMATIONAL note (never `STOP:`, which signals a halted rollback)
294
+ // and continue to the rename.
295
+ const remoded = await secureFs.applyExactMode(path.join(entry.dir.path, rName), entry.prior.mode);
296
+ if (!remoded.ok) {
297
+ errors.push(`note: restored ${entry.path}; prior-mode restore failed, verify permissions (${remoded.detail ?? remoded.refusal ?? "unknown"})`);
298
+ }
290
299
  const restored = await secureFs.renameInDir(entry.dir, rName, base);
291
300
  if (!restored.ok) {
292
301
  errors.push(`STOP: cannot restore ${entry.path}; prior payload staged at ${path.join(entry.dir.path, rName)} for manual recovery`);
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pure refusal → remediation table for the CLI layer (Linux hardening, Slice A).
3
+ *
4
+ * The secure-fs adapters stay PROVERS: they emit stable `SecureRefusal` codes
5
+ * plus a stable detail token and carry ZERO user copy. This module is the only
6
+ * place that turns one of those tokens into an actionable next step, so the
7
+ * proof algorithms and their refusal identities never move when the copy does.
8
+ *
9
+ * The table is deliberately narrow. A remediation is emitted ONLY when the user
10
+ * can actually fix the cause: an unresolvable `getfacl` is fixed by installing
11
+ * the `acl` package, while a REAL extended ACL (the adapter ran and found a
12
+ * named-user entry) is not — suggesting a package install there would be
13
+ * actively misleading. No I/O, no rendering, no Ink.
14
+ */
15
+ import type { SecureRefusal } from "./secure-fs-transaction.js";
16
+ /** The one actionable line for a host whose POSIX ACL adapter is missing. */
17
+ export declare const ACL_PACKAGE_REMEDIATION = "install the acl package (provides getfacl): apt install acl \u00B7 apk add acl \u00B7 dnf install acl";
18
+ /**
19
+ * The actionable line for a refusal + detail pair, or `undefined` when nothing
20
+ * the user can do would change the outcome.
21
+ */
22
+ export declare function remediationForRefusal(refusal: SecureRefusal, detail?: string): string | undefined;
23
+ /**
24
+ * The same lookup for an already-rendered refusal MESSAGE (the transaction
25
+ * flattens `refusal`/`detail` into strings such as `acl /path: getfacl absent`).
26
+ * Matching is anchored to the message TAIL so prose that merely mentions the
27
+ * token never triggers a wrong hint.
28
+ */
29
+ export declare function remediationForMessage(message: string): string | undefined;
30
+ //# sourceMappingURL=secure-refusal-remediation.d.ts.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Pure refusal → remediation table for the CLI layer (Linux hardening, Slice A).
3
+ *
4
+ * The secure-fs adapters stay PROVERS: they emit stable `SecureRefusal` codes
5
+ * plus a stable detail token and carry ZERO user copy. This module is the only
6
+ * place that turns one of those tokens into an actionable next step, so the
7
+ * proof algorithms and their refusal identities never move when the copy does.
8
+ *
9
+ * The table is deliberately narrow. A remediation is emitted ONLY when the user
10
+ * can actually fix the cause: an unresolvable `getfacl` is fixed by installing
11
+ * the `acl` package, while a REAL extended ACL (the adapter ran and found a
12
+ * named-user entry) is not — suggesting a package install there would be
13
+ * actively misleading. No I/O, no rendering, no Ink.
14
+ */
15
+ import { ACL_DETAIL } from "./secure-fs-posix.js";
16
+ /** The one actionable line for a host whose POSIX ACL adapter is missing. */
17
+ export const ACL_PACKAGE_REMEDIATION = "install the acl package (provides getfacl): apt install acl · apk add acl · dnf install acl";
18
+ /** Detail tokens that map to a remediation, keyed by refusal code. */
19
+ const REMEDIATION_TABLE = {
20
+ "unsupported-posix-acl": {
21
+ [ACL_DETAIL.getfaclAbsent]: ACL_PACKAGE_REMEDIATION,
22
+ },
23
+ };
24
+ /**
25
+ * The actionable line for a refusal + detail pair, or `undefined` when nothing
26
+ * the user can do would change the outcome.
27
+ */
28
+ export function remediationForRefusal(refusal, detail) {
29
+ if (detail === undefined)
30
+ return undefined;
31
+ return REMEDIATION_TABLE[refusal]?.[detail];
32
+ }
33
+ /**
34
+ * The same lookup for an already-rendered refusal MESSAGE (the transaction
35
+ * flattens `refusal`/`detail` into strings such as `acl /path: getfacl absent`).
36
+ * Matching is anchored to the message TAIL so prose that merely mentions the
37
+ * token never triggers a wrong hint.
38
+ */
39
+ export function remediationForMessage(message) {
40
+ for (const details of Object.values(REMEDIATION_TABLE)) {
41
+ for (const [detail, line] of Object.entries(details)) {
42
+ if (message.endsWith(detail))
43
+ return line;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+ //# sourceMappingURL=secure-refusal-remediation.js.map
package/dist/ui/App.js CHANGED
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { Box } from "ink";
3
3
  import React, { useState } from "react";
4
4
  import { initProject } from "../commands/init.js";
5
+ import { buildInitOptions } from "./build-init-options.js";
5
6
  import CISelector from "./CISelector.js";
6
7
  import Header from "./Header.js";
7
8
  import HookProfileSelector from "./HookProfileSelector.js";
@@ -80,29 +81,15 @@ export default function App({ dryRun = false, presetStack, presetCI, presetMemor
80
81
  };
81
82
  const runInit = async (opts) => {
82
83
  setStage("running");
83
- await initProject({
84
+ await initProject(buildInitOptions(opts, {
84
85
  projectName,
85
86
  projectDir,
86
87
  stack,
87
88
  ciProvider,
88
89
  memory,
89
- aiSync: opts.aiSync,
90
- sdd: opts.sdd,
91
- ghagga: opts.ghagga,
92
- contextDir: opts.contextDir,
93
- claudeMd: opts.claudeMd,
94
- securityHooks: opts.securityHooks,
95
- hookProfile: opts.hookProfile,
96
- // Derived from securityHooks alone — ALL profiles incl. Minimal
97
- // install the managed guard when security hooks are enabled.
98
- claudePreToolUseGuard: opts.securityHooks,
99
- codeGraph: opts.codeGraph,
100
- localAi: opts.localAi,
101
- dockerDeploy: false,
102
- dockerServiceName: "app",
103
90
  mock: presetMock,
104
91
  dryRun,
105
- }, (step) => setSteps((prev) => {
92
+ }), (step) => setSteps((prev) => {
106
93
  const idx = prev.findIndex((s) => s.id === step.id);
107
94
  if (idx >= 0) {
108
95
  const next = [...prev];
@@ -0,0 +1,44 @@
1
+ import type { CIProvider, HookProfile, InitOptions, MemoryOption, Stack } from "../types/index.js";
2
+ /**
3
+ * Wizard-collected toggles for an init run — the subset the user answers
4
+ * interactively (or via presets). Mirrors the `runInit` opts parameter.
5
+ */
6
+ export interface InitOptionsWizardResult {
7
+ aiSync: boolean;
8
+ sdd: boolean;
9
+ contextDir: boolean;
10
+ claudeMd: boolean;
11
+ ghagga: boolean;
12
+ securityHooks: boolean;
13
+ codeGraph: boolean;
14
+ localAi: boolean;
15
+ hookProfile: HookProfile;
16
+ }
17
+ /**
18
+ * Surrounding context resolved before the wizard finishes — project identity,
19
+ * stack/CI/memory choices, and run flags (mock/dryRun).
20
+ */
21
+ export interface InitOptionsContext {
22
+ projectName: string;
23
+ projectDir: string;
24
+ stack: Stack;
25
+ ciProvider: CIProvider;
26
+ memory: MemoryOption;
27
+ mock: boolean;
28
+ dryRun: boolean;
29
+ }
30
+ /**
31
+ * Pure mapping from wizard result + surrounding context to the full
32
+ * {@link InitOptions} contract passed to `initProject`.
33
+ *
34
+ * Extracted from the single inline object literal in `App.tsx#runInit` so the
35
+ * derivation is unit-testable. Behavior-identical: field values must match the
36
+ * previous inline literal exactly.
37
+ *
38
+ * Notably, `claudePreToolUseGuard` is derived from `securityHooks` alone — ALL
39
+ * hook profiles (including "minimal") install the managed guard when security
40
+ * hooks are enabled. A regression here silently disables managed-guard
41
+ * installation, so it is pinned by build-init-options.test.ts.
42
+ */
43
+ export declare function buildInitOptions(opts: InitOptionsWizardResult, ctx: InitOptionsContext): InitOptions;
44
+ //# sourceMappingURL=build-init-options.d.ts.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Pure mapping from wizard result + surrounding context to the full
3
+ * {@link InitOptions} contract passed to `initProject`.
4
+ *
5
+ * Extracted from the single inline object literal in `App.tsx#runInit` so the
6
+ * derivation is unit-testable. Behavior-identical: field values must match the
7
+ * previous inline literal exactly.
8
+ *
9
+ * Notably, `claudePreToolUseGuard` is derived from `securityHooks` alone — ALL
10
+ * hook profiles (including "minimal") install the managed guard when security
11
+ * hooks are enabled. A regression here silently disables managed-guard
12
+ * installation, so it is pinned by build-init-options.test.ts.
13
+ */
14
+ export function buildInitOptions(opts, ctx) {
15
+ return {
16
+ projectName: ctx.projectName,
17
+ projectDir: ctx.projectDir,
18
+ stack: ctx.stack,
19
+ ciProvider: ctx.ciProvider,
20
+ memory: ctx.memory,
21
+ aiSync: opts.aiSync,
22
+ sdd: opts.sdd,
23
+ ghagga: opts.ghagga,
24
+ contextDir: opts.contextDir,
25
+ claudeMd: opts.claudeMd,
26
+ securityHooks: opts.securityHooks,
27
+ hookProfile: opts.hookProfile,
28
+ // Derived from securityHooks alone — ALL profiles incl. Minimal
29
+ // install the managed guard when security hooks are enabled.
30
+ claudePreToolUseGuard: opts.securityHooks,
31
+ codeGraph: opts.codeGraph,
32
+ localAi: opts.localAi,
33
+ dockerDeploy: false,
34
+ dockerServiceName: "app",
35
+ mock: ctx.mock,
36
+ dryRun: ctx.dryRun,
37
+ };
38
+ }
39
+ //# sourceMappingURL=build-init-options.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.33.0",
3
+ "version": "1.34.1",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {