javi-forge 1.32.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -4,11 +4,14 @@
4
4
  * three lib fns to human output + exit codes; it adds NO new security logic and
5
5
  * never touches `runTransaction`/secure-fs directly.
6
6
  *
7
- * Honest-execution constraint (spec Requirement "…never fabricates execution
8
- * status"): the doctor renderer reports effective execution as `inconclusive`
9
- * only. It MUST NOT print, imply, or default to `RUNNABLE` real host-probing
10
- * is Slice 4b. Exit 2 (INCONCLUSIVE gate semantics) is likewise reserved for 4b
11
- * and never emitted here.
7
+ * Honest-execution gate (Slice 4b): the doctor renderer prints the real
8
+ * `execution` verdict computed by the library `runnable`, `blocked`, or
9
+ * `inconclusive` plus its `blockers`, `unknownSources`, and the constant
10
+ * `residual` caveats, in committed stable order. The doctor exit code follows
11
+ * `execution.status` (runnable 0, blocked → 1, inconclusive → 2), independent
12
+ * of `report.healthy`. The renderer never fabricates `runnable`: the library
13
+ * only reports it when every relevant local source read clear and the guard is
14
+ * current.
12
15
  */
13
16
  import { doctorClaudePreToolUse, installClaudePreToolUse, repairClaudePreToolUse } from "../lib/claude-hook-manager.js";
14
17
  export type ClaudeHookSub = "install" | "doctor" | "repair";
@@ -24,7 +27,8 @@ export interface ClaudeHookCmdDeps {
24
27
  * Run one Claude-hook subcommand against `projectDir`. Returns the process exit
25
28
  * code (the dispatcher calls `process.exit`, not this fn):
26
29
  * - install/repair: 0 when `ok`, 1 on refusal/failure.
27
- * - doctor: always 0 (informational).
30
+ * - doctor: follows `execution.status` — runnable → 0, blocked → 1,
31
+ * inconclusive → 2.
28
32
  */
29
33
  export declare function runClaudeHookCommand(sub: ClaudeHookSub, projectDir: string, opts: {
30
34
  force?: boolean;
@@ -4,13 +4,28 @@
4
4
  * three lib fns to human output + exit codes; it adds NO new security logic and
5
5
  * never touches `runTransaction`/secure-fs directly.
6
6
  *
7
- * Honest-execution constraint (spec Requirement "…never fabricates execution
8
- * status"): the doctor renderer reports effective execution as `inconclusive`
9
- * only. It MUST NOT print, imply, or default to `RUNNABLE` real host-probing
10
- * is Slice 4b. Exit 2 (INCONCLUSIVE gate semantics) is likewise reserved for 4b
11
- * and never emitted here.
7
+ * Honest-execution gate (Slice 4b): the doctor renderer prints the real
8
+ * `execution` verdict computed by the library `runnable`, `blocked`, or
9
+ * `inconclusive` plus its `blockers`, `unknownSources`, and the constant
10
+ * `residual` caveats, in committed stable order. The doctor exit code follows
11
+ * `execution.status` (runnable 0, blocked → 1, inconclusive → 2), independent
12
+ * of `report.healthy`. The renderer never fabricates `runnable`: the library
13
+ * only reports it when every relevant local source read clear and the guard is
14
+ * current.
12
15
  */
13
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
+ }
14
29
  function renderMutation(verb, result, log, logError) {
15
30
  if (result.ok) {
16
31
  log(`${verb} claude: ok`);
@@ -27,11 +42,19 @@ function renderMutation(verb, result, log, logError) {
27
42
  for (const p of result.backups)
28
43
  log(` ${p}`);
29
44
  }
45
+ renderWarnings(result.warnings, log);
30
46
  return 0;
31
47
  }
32
48
  logError(`${verb} claude: refused`);
33
- 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) {
34
52
  logError(` ${e}`);
53
+ const remediation = remediationForMessage(e);
54
+ if (remediation)
55
+ logError(` → ${remediation}`);
56
+ }
57
+ renderWarnings(result.warnings, log);
35
58
  return 1;
36
59
  }
37
60
  function renderDoctor(report, log) {
@@ -39,23 +62,62 @@ function renderDoctor(report, log) {
39
62
  log(` settings: ${report.settings.state} — ${report.settings.detail}`);
40
63
  log(` asset: ${report.asset.state} — ${report.asset.detail}`);
41
64
  log(` node: ${report.node.version ?? "unavailable"} (min-satisfied: ${report.node.satisfiesMinimum})`);
42
- // Honest stub: 4a cannot confirm the guard is live. Never RUNNABLE.
43
- log(" execution: inconclusive (effective-execution probe deferred to 4b)");
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)`);
75
+ const execution = report.execution;
76
+ log(` execution: ${execution.status}`);
77
+ if (execution.blockers.length > 0) {
78
+ log(" blockers:");
79
+ for (const b of execution.blockers)
80
+ log(` - ${b}`);
81
+ }
82
+ if (execution.unknownSources.length > 0) {
83
+ log(" unknown-sources:");
84
+ for (const u of execution.unknownSources)
85
+ log(` - ${u}`);
86
+ }
87
+ if (execution.residual.length > 0) {
88
+ log(" execution-residual:");
89
+ for (const r of execution.residual)
90
+ log(` - ${r}`);
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
+ }
44
101
  log(` host-residual: ${report.hostResidual}`);
45
102
  if (report.remediation.length > 0) {
46
103
  log(" remediation:");
47
104
  for (const r of report.remediation)
48
105
  log(` - ${r}`);
49
106
  }
50
- // Doctor is informational, not a gate always exit 0 (spec Scenario
51
- // "Doctor reports component health").
107
+ // The doctor exit code follows the effective-execution verdict, independent
108
+ // of component health: runnable → 0, blocked → 1, inconclusive → 2.
109
+ if (execution.status === "blocked")
110
+ return 1;
111
+ if (execution.status === "inconclusive")
112
+ return 2;
52
113
  return 0;
53
114
  }
54
115
  /**
55
116
  * Run one Claude-hook subcommand against `projectDir`. Returns the process exit
56
117
  * code (the dispatcher calls `process.exit`, not this fn):
57
118
  * - install/repair: 0 when `ok`, 1 on refusal/failure.
58
- * - doctor: always 0 (informational).
119
+ * - doctor: follows `execution.status` — runnable → 0, blocked → 1,
120
+ * inconclusive → 2.
59
121
  */
60
122
  export async function runClaudeHookCommand(sub, projectDir, opts, deps = {}) {
61
123
  const log = deps.log ?? ((m) => console.log(m));
@@ -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 {
@@ -51,6 +52,24 @@ export interface ClaudeHookDoctorReport {
51
52
  coverage: typeof COVERAGE;
52
53
  hostResidual: string;
53
54
  remediation: readonly string[];
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;
54
73
  }
55
74
  /**
56
75
  * Classify the asset into one of nine states from observed bytes only. Never
@@ -66,14 +85,128 @@ export declare function detectNode(nodeVersion: string | undefined): {
66
85
  version?: string;
67
86
  satisfiesMinimum: boolean;
68
87
  };
88
+ /**
89
+ * The honest effective-execution verdict. `status` is derived by precedence
90
+ * (blocked > inconclusive > runnable); `runnable` is reached only when every
91
+ * relevant local source read clear AND the managed guard is current. `residual`
92
+ * carries CONSTANT honest caveats (server-delivered policy, session safe-mode)
93
+ * that are always shown but never gate the status — otherwise `runnable` would
94
+ * be unreachable.
95
+ */
96
+ export interface ExecutionReport {
97
+ status: "runnable" | "blocked" | "inconclusive";
98
+ blockers: string[];
99
+ unknownSources: string[];
100
+ residual: string[];
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>;
124
+ /** Injectable seams so units never hard-read real `/etc` or `/Library`. */
125
+ export interface ExecutionProbeEnv {
126
+ platform?: NodeJS.Platform;
127
+ homeDir?: string;
128
+ env?: NodeJS.ProcessEnv;
129
+ /** Override the managed OS file (null → no managed file at all). */
130
+ managedFile?: string | null;
131
+ /** Override the managed drop-in dir (null → no drop-ins). */
132
+ managedDropInDir?: string | null;
133
+ /** Override the drop-in directory listing (defaults to a confined readdir). */
134
+ listDir?: (dir: string) => Promise<string[]>;
135
+ /** Injectable node-on-PATH heuristic (defaults to the real bounded probe). */
136
+ nodeProbe?: () => Promise<NodeOnPathProbe>;
137
+ }
138
+ /** Per-source read outcome; never promotes an unobservable source to clear. */
139
+ export type ExecutionSourceProbe = {
140
+ kind: "clear";
141
+ } | {
142
+ kind: "blocking";
143
+ flag: "disableAllHooks" | "allowManagedHooksOnly";
144
+ /** Present only for a PRESENT-but-INVALID value; names the observed shape. */
145
+ detail?: string;
146
+ } | {
147
+ kind: "unknown";
148
+ reason: string;
149
+ };
150
+ /** The already-computed component states the guard-currency check consumes. */
151
+ export interface ExecutionComponentStates {
152
+ asset: ClaudeHookComponentState;
153
+ settings: ClaudeHookComponentState;
154
+ }
155
+ export interface ManagedSettingsPaths {
156
+ file: string;
157
+ dropInDir: string;
158
+ }
159
+ /** Static managed-settings locations per OS (no fs). WSL reports `linux`. */
160
+ export declare function resolveManagedSettingsPaths(platform: NodeJS.Platform): ManagedSettingsPaths;
161
+ /**
162
+ * Probe one settings source path for the two documented hook-neutralizing
163
+ * flags. Fail-closed: only a genuinely-absent path or a cleanly-parsed source
164
+ * with no flag is `clear`; a symlink, non-regular path, permission/io error,
165
+ * oversized/binary content, or malformed JSON is `unknown` (unreadable ≠
166
+ * absent), never `clear`. `disableAllHooks` is preferred over
167
+ * `allowManagedHooksOnly` when both are set (the former blocks at any source).
168
+ */
169
+ export declare function probeExecutionSource(target: string): Promise<ExecutionSourceProbe>;
170
+ /**
171
+ * Confined readdir result of the managed drop-in dir, fail-closed and mirroring
172
+ * `probeExecutionSource`'s errno classification: a genuinely-absent directory
173
+ * (ENOENT/ENOTDIR) is the ONLY empty/clear case, so it yields `{ entries: [] }`
174
+ * (no drop-ins). ANY other readdir failure (EACCES/EIO/ELOOP/…) means the
175
+ * directory is present-but-unenumerable and MUST NOT be treated as empty — it
176
+ * yields `{ unreadable: true }` so the caller can degrade the verdict, never
177
+ * silently report "no drop-ins" (a false `runnable`).
178
+ */
179
+ export type ManagedDropInListing = {
180
+ unreadable?: false;
181
+ entries: string[];
182
+ } | {
183
+ unreadable: true;
184
+ reason: string;
185
+ };
186
+ export declare function listManagedDropIns(dir: string, listDir?: (dir: string) => Promise<string[]>): Promise<ManagedDropInListing>;
187
+ /**
188
+ * The fail-closed effective-execution verdict. Gathers all sources in stable
189
+ * order (project, local, user, managed OS file, drop-ins sorted), classifies
190
+ * each with `probeExecutionSource`, applies the managed-only inertness of
191
+ * `allowManagedHooksOnly` (blocks only from a managed source — hooks MERGE
192
+ * elsewhere), folds in the guard-currency blocker and the doctor-process
193
+ * safe-mode observation, then resolves by precedence
194
+ * (`blockers` first, then `unknownSources`, else `runnable`). An `unknown` is
195
+ * NEVER promoted to `runnable`.
196
+ */
197
+ export declare function probeExecution(projectDir: string, componentStates: ExecutionComponentStates, env?: ExecutionProbeEnv): Promise<ExecutionReport>;
69
198
  /**
70
199
  * Assemble the read-only component-level doctor report (no writes). `healthy` is
71
200
  * exactly: both components `managed-current`, matcher and command shape exact,
72
201
  * Node `>=22`. `assetSettingsConsistent` is a reported advisory, NOT part of it.
202
+ * The `execution` verdict is INDEPENDENT of `healthy`.
73
203
  */
74
204
  export declare function doctorClaudePreToolUse(projectDir: string, options?: {
75
205
  manifest?: Manifest;
76
206
  nodeVersion?: string;
207
+ execution?: ExecutionProbeEnv;
208
+ /** Injectable read-only ACL capability probe (defaults to the real one). */
209
+ aclProbe?: () => Promise<AclCapability>;
77
210
  }): Promise<ClaudeHookDoctorReport>;
78
211
  export interface ClaudeHookMutationResult {
79
212
  ok: boolean;
@@ -81,6 +214,12 @@ export interface ClaudeHookMutationResult {
81
214
  backups: string[];
82
215
  report: ClaudeHookDoctorReport;
83
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[];
84
223
  }
85
224
  /** Injectable deps so tests drive `_run` with a fake `PlatformSecureFs`. */
86
225
  export interface ClaudeHookRunDeps {
@@ -89,6 +228,14 @@ export interface ClaudeHookRunDeps {
89
228
  nonce?: () => string;
90
229
  manifest?: Manifest;
91
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>;
92
239
  }
93
240
  /** Internal deps-taking entry; tests drive it with a fake `PlatformSecureFs`. */
94
241
  export declare function _run(projectDir: string, mode: "install" | "repair", options: {