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 +2 -0
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +6 -0
- package/dist/commands/claude-hooks.js +40 -1
- package/dist/commands/init/steps/security.d.ts +2 -0
- package/dist/commands/init/steps/security.js +37 -7
- package/dist/lib/claude-hook-manager.d.ts +60 -0
- package/dist/lib/claude-hook-manager.js +147 -8
- package/dist/lib/claude-hook-settings.d.ts +20 -6
- package/dist/lib/claude-hook-settings.js +31 -7
- package/dist/lib/secure-fs-posix.d.ts +43 -0
- package/dist/lib/secure-fs-posix.js +66 -7
- package/dist/lib/secure-refusal-remediation.d.ts +30 -0
- package/dist/lib/secure-refusal-remediation.js +48 -0
- package/dist/ui/App.js +3 -16
- package/dist/ui/build-init-options.d.ts +44 -0
- package/dist/ui/build-init-options.js +39 -0
- package/package.json +1 -1
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
|
|
package/dist/cli/help.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
51
|
-
allowManagedHooksOnly:
|
|
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.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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:
|
|
67
|
+
return { disableAllHooks: NOT_SET, allowManagedHooksOnly: NOT_SET };
|
|
44
68
|
}
|
|
45
69
|
return {
|
|
46
|
-
disableAllHooks: parsed.disableAllHooks
|
|
47
|
-
allowManagedHooksOnly: parsed.allowManagedHooksOnly
|
|
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",
|
|
78
|
+
return refuse("unsupported-posix-acl", ACL_DETAIL.getfaclAbsent);
|
|
64
79
|
if (res.timedOut)
|
|
65
|
-
return refuse("unsupported-posix-acl",
|
|
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",
|
|
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",
|
|
103
|
+
return refuse("unsupported-posix-acl", ACL_DETAIL.macosLsAbsent);
|
|
89
104
|
if (res.timedOut)
|
|
90
|
-
return refuse("unsupported-posix-acl",
|
|
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",
|
|
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",
|
|
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
|