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