javi-forge 1.33.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.
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
@@ -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;
@@ -201,6 +203,57 @@ const REMEDIATION = {
201
203
  function remediationFor(state, component) {
202
204
  return REMEDIATION[state]?.replace("$", component);
203
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
+ }
204
257
  /** Static managed-settings locations per OS (no fs). WSL reports `linux`. */
205
258
  export function resolveManagedSettingsPaths(platform) {
206
259
  if (platform === "darwin") {
@@ -256,11 +309,21 @@ export async function probeExecutionSource(target) {
256
309
  return { kind: "unknown", reason: "invalid-json" };
257
310
  }
258
311
  const flags = scanExecutionFlags(parsed);
259
- if (flags.disableAllHooks) {
260
- return { kind: "blocking", flag: "disableAllHooks" };
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);
261
324
  }
262
- if (flags.allowManagedHooksOnly) {
263
- return { kind: "blocking", flag: "allowManagedHooksOnly" };
325
+ if (flags.allowManagedHooksOnly.set) {
326
+ return blocking("allowManagedHooksOnly", flags.allowManagedHooksOnly);
264
327
  }
265
328
  return { kind: "clear" };
266
329
  }
@@ -288,6 +351,7 @@ export async function listManagedDropIns(dir, listDir) {
288
351
  const EXECUTION_RESIDUAL = [
289
352
  "server-delivered managed policy can disable hooks and is not observable from local files",
290
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',
291
355
  ];
292
356
  function isSafeModeTruthy(env) {
293
357
  const value = env.CLAUDE_CODE_SAFE_MODE;
@@ -361,9 +425,11 @@ export async function probeExecution(projectDir, componentStates, env = {}) {
361
425
  continue;
362
426
  }
363
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.
364
430
  if (probe.flag === "allowManagedHooksOnly" && !spec.managed)
365
431
  continue;
366
- blockers.push(`policy:${probe.flag}@${spec.label}`);
432
+ blockers.push(`policy:${probe.flag}@${spec.label}${probe.detail ? ` (${probe.detail})` : ""}`);
367
433
  }
368
434
  if (dropInDirUnknown)
369
435
  unknownSources.push(dropInDirUnknown);
@@ -374,6 +440,23 @@ export async function probeExecution(projectDir, componentStates, env = {}) {
374
440
  if (componentStates.settings !== "managed-current") {
375
441
  blockers.push(`guard:settings=${componentStates.settings}`);
376
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
+ }
377
460
  // Safe-mode observed in THIS doctor process is a real per-run unknown (the
378
461
  // diagnosed session's own safe-mode remains a constant residual).
379
462
  if (isSafeModeTruthy(processEnv)) {
@@ -432,7 +515,25 @@ export async function doctorClaudePreToolUse(projectDir, options) {
432
515
  signals.matcherExact &&
433
516
  signals.commandShapeExact &&
434
517
  node.satisfiesMinimum;
435
- const execution = await probeExecution(projectDir, { asset: asset.state, settings: settings.state }, options?.execution ?? {});
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
+ }
436
537
  return {
437
538
  healthy,
438
539
  settings: {
@@ -455,6 +556,11 @@ export async function doctorClaudePreToolUse(projectDir, options) {
455
556
  hostResidual: HOST_RESIDUAL,
456
557
  remediation: [...remediation].sort(),
457
558
  execution,
559
+ nodeOnPath,
560
+ installCapability: {
561
+ acl: aclCapability,
562
+ ...(aclRemediation ? { remediation: aclRemediation } : {}),
563
+ },
458
564
  };
459
565
  }
460
566
  async function readManifest() {
@@ -463,6 +569,24 @@ async function readManifest() {
463
569
  throw new Error(`unreadable claude-hooks manifest: ${read.reason}`);
464
570
  return JSON.parse(read.content);
465
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
+ }
466
590
  function refuseMessage(component, state) {
467
591
  const remedy = remediationFor(state, component);
468
592
  return `refuse ${component} in state ${state}${remedy ? ` — ${remedy}` : ""}`;
@@ -594,7 +718,18 @@ export async function _run(projectDir, mode, options, deps) {
594
718
  const assetDestPath = path.join(projectDir, ".claude", "hooks", ASSET_NAME);
595
719
  const settingsPath = path.join(projectDir, ".claude", "settings.json");
596
720
  const assetSrcPath = path.join(CLAUDE_HOOK_ASSETS_DIR, ASSET_NAME);
597
- 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);
598
733
  // Windows (or any platform without an adapter) refuses with zero mutation.
599
734
  if (!secureFs) {
600
735
  return {
@@ -602,6 +737,7 @@ export async function _run(projectDir, mode, options, deps) {
602
737
  changed: [],
603
738
  backups: [],
604
739
  errors: ["windows-secure-object-unavailable"],
740
+ warnings,
605
741
  report: await doctor(),
606
742
  };
607
743
  }
@@ -624,6 +760,7 @@ export async function _run(projectDir, mode, options, deps) {
624
760
  changed: [],
625
761
  backups: [],
626
762
  errors: [reason],
763
+ warnings,
627
764
  report: await doctor(),
628
765
  };
629
766
  }
@@ -634,6 +771,7 @@ export async function _run(projectDir, mode, options, deps) {
634
771
  changed: [],
635
772
  backups: [],
636
773
  errors: [],
774
+ warnings,
637
775
  report: await doctor(),
638
776
  };
639
777
  }
@@ -672,6 +810,7 @@ export async function _run(projectDir, mode, options, deps) {
672
810
  changed: tx.committed,
673
811
  backups: tx.backups,
674
812
  errors: tx.errors,
813
+ warnings,
675
814
  report: await doctor(),
676
815
  };
677
816
  }
@@ -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;
@@ -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.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {