vigiles 26.0.1 → 26.2.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 +5 -4
- package/dist/adapters/claude-code/hook-condition.d.ts +46 -0
- package/dist/adapters/claude-code/hook-condition.js +142 -0
- package/dist/adapters/claude-code/hook-protocol.js +5 -0
- package/dist/audit-report.template.html +2 -2
- package/dist/cli.js +67 -115
- package/dist/core/bash-effects.d.ts +22 -0
- package/dist/core/bash-effects.js +10 -0
- package/dist/core/command-files.d.ts +107 -0
- package/dist/core/command-files.js +407 -0
- package/dist/core/hook-condition.d.ts +96 -0
- package/dist/core/hook-condition.js +63 -0
- package/dist/core/hook-matcher.d.ts +50 -0
- package/dist/core/hook-matcher.js +77 -2
- package/dist/core/hook-normalize.d.ts +51 -0
- package/dist/core/hook-normalize.js +61 -1
- package/dist/core/hook-program.d.ts +62 -1
- package/dist/core/hook-program.js +15 -1
- package/dist/core/hook-protocol.d.ts +16 -0
- package/dist/core/linters.js +97 -58
- package/dist/core/shell-vars.d.ts +74 -0
- package/dist/core/shell-vars.js +270 -0
- package/dist/core/skill-resources.d.ts +22 -1
- package/dist/core/skill-resources.js +2 -1
- package/dist/doc-test-script-coverage.d.ts +52 -0
- package/dist/doc-test-script-coverage.js +66 -0
- package/dist/guardrail-check.d.ts +29 -0
- package/dist/guardrail-check.js +69 -10
- package/dist/harness-assert.d.ts +8 -5
- package/dist/harness-assert.js +8 -5
- package/dist/harness-resolve-hooks.mjs +14 -37
- package/dist/hook-state-store.d.ts +143 -0
- package/dist/hook-state-store.js +241 -0
- package/dist/hook.d.ts +3 -1
- package/dist/hook.js +3 -1
- package/dist/run-hook.d.ts +33 -1
- package/dist/run-hook.js +46 -2
- package/dist/run-script.d.ts +94 -0
- package/dist/run-script.js +47 -26
- package/dist/scan-core.js +21 -3
- package/dist/score-core.d.ts +21 -1
- package/dist/score-core.js +30 -6
- package/dist/self-resolve.d.mts +20 -0
- package/dist/self-resolve.mjs +75 -0
- package/dist/spec-hooks.d.mts +10 -0
- package/dist/spec-hooks.mjs +17 -0
- package/dist/test.d.ts +5 -0
- package/dist/test.js +23 -2
- package/dist/verify-plugin-guards.d.ts +194 -0
- package/dist/verify-plugin-guards.js +822 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -66,7 +66,11 @@ const merge_conflict_js_1 = require("./core/merge-conflict.js");
|
|
|
66
66
|
const hook_install_js_1 = require("./hook-install.js");
|
|
67
67
|
const hook_providers_js_1 = require("./core/hook-providers.js");
|
|
68
68
|
const toml_1 = require("@iarna/toml");
|
|
69
|
-
|
|
69
|
+
// The named-state STORE. Lifted out of this file so a TEST can seed a fact
|
|
70
|
+
// through the SAME writer the runtime uses (`experimental_hookState`) — the
|
|
71
|
+
// private path a stateful hook's test used to hard-code is what kept the hook
|
|
72
|
+
// vocabulary experimental. Same move as `loadHook`, for the same reason.
|
|
73
|
+
const hook_state_store_js_1 = require("./hook-state-store.js");
|
|
70
74
|
const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
|
|
71
75
|
const observe_js_1 = require("./observe.js");
|
|
72
76
|
const effect_region_js_1 = require("./adapters/claude-code/effect-region.js");
|
|
@@ -137,10 +141,21 @@ let host = null;
|
|
|
137
141
|
function hostEntry() {
|
|
138
142
|
return (0, node_path_1.resolve)(__dirname, "spec-host.mjs");
|
|
139
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* This package's own root — `dist/`'s parent, since this file compiles to
|
|
146
|
+
* `dist/cli.js`. Handed to the host so its resolve hook can serve the spec's
|
|
147
|
+
* `vigiles/spec` import from OUR install (see `src/self-resolve.mts`), which is
|
|
148
|
+
* what lets a repo with no `node_modules/vigiles` — a Python or Rust repo has
|
|
149
|
+
* no `package.json` to install into at all — compile a spec.
|
|
150
|
+
*/
|
|
151
|
+
function selfRoot() {
|
|
152
|
+
return (0, node_path_1.resolve)(__dirname, "..");
|
|
153
|
+
}
|
|
140
154
|
function startHost() {
|
|
141
155
|
const child = (0, node_child_process_1.spawn)(process.execPath, [hostEntry()], {
|
|
142
156
|
cwd: process.cwd(),
|
|
143
157
|
stdio: ["pipe", "pipe", "pipe"],
|
|
158
|
+
env: { ...process.env, VIGILES_SELF_ROOT: selfRoot() },
|
|
144
159
|
});
|
|
145
160
|
const h = { child, pending: new Map(), started: null, buffered: "" };
|
|
146
161
|
child.stdout.setEncoding("utf-8");
|
|
@@ -2973,56 +2988,24 @@ async function setupPillar1(detected, targetValue, harnesses) {
|
|
|
2973
2988
|
// user reviews the generated spec and runs `vigiles compile` themselves to
|
|
2974
2989
|
// switch it to spec-managed (non-destructive by default; the compile is
|
|
2975
2990
|
// byte-faithful, but it's the user's call to make, with a diff to review).
|
|
2976
|
-
//
|
|
2977
|
-
//
|
|
2978
|
-
//
|
|
2979
|
-
|
|
2991
|
+
//
|
|
2992
|
+
// There is no longer an "only if vigiles resolves" gate here. The spec host
|
|
2993
|
+
// serves a spec's `vigiles/spec` import from the CLI's OWN install (the
|
|
2994
|
+
// resolve hook in src/self-resolve.mts), so compiling needs no
|
|
2995
|
+
// `node_modules/vigiles` in the user's repo — and a repo with no
|
|
2996
|
+
// `package.json` at all (Python, Rust, Go) has no install to run.
|
|
2980
2997
|
const initConfig = (0, validate_js_1.loadConfig)();
|
|
2981
2998
|
const excludes = (0, exclude_js_1.excludeSet)(cwd, initConfig.exclude);
|
|
2982
|
-
const specs =
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
})
|
|
2987
|
-
: [];
|
|
2999
|
+
const specs = findSpecs(excludes).filter((s) => {
|
|
3000
|
+
const tf = (0, node_path_1.resolve)(cwd, s.replace(/\.spec\.ts$/, ""));
|
|
3001
|
+
return !(0, node_fs_1.existsSync)(tf) || targetHasHash(tf);
|
|
3002
|
+
});
|
|
2988
3003
|
if (specs.length > 0) {
|
|
2989
3004
|
console.log("\nCompiling specs...");
|
|
2990
3005
|
await compile(specs, initConfig, excludes);
|
|
2991
3006
|
}
|
|
2992
|
-
else if (!canCompile) {
|
|
2993
|
-
// Honest, project-type-aware guidance. A JS repo just needs `npm install`
|
|
2994
|
-
// (init already added the devDep). A repo with NO package.json (Python, Rust,
|
|
2995
|
-
// …) can't resolve the npm package at all, so point at the no-install paths
|
|
2996
|
-
// instead of a misleading `npm install`.
|
|
2997
|
-
if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(cwd, "package.json"))) {
|
|
2998
|
-
console.log("\n Skipping compile — run `npm install` (to fetch the vigiles dep just added), then `npx vigiles compile`.");
|
|
2999
|
-
}
|
|
3000
|
-
else {
|
|
3001
|
-
console.log("\n No package.json here, so the typed-spec compile isn't available yet.\n" +
|
|
3002
|
-
" • `npx vigiles lint` verifies your instruction files right now — no install needed.\n" +
|
|
3003
|
-
" • To spec-manage them, add a package.json first: `npm init -y && npm i -D vigiles`, then `npx vigiles compile`.");
|
|
3004
|
-
}
|
|
3005
|
-
}
|
|
3006
3007
|
return { specTargets: targets, written, adopted };
|
|
3007
3008
|
}
|
|
3008
|
-
/**
|
|
3009
|
-
* Whether `vigiles/spec` will resolve for a spec compiled from `cwd` — true when
|
|
3010
|
-
* vigiles is installed locally (`node_modules/vigiles`) or `cwd` IS the vigiles
|
|
3011
|
-
* package itself (the in-repo dogfood / a monorepo workspace). A fresh user repo
|
|
3012
|
-
* that hasn't run `npm install` yet returns false, so `init` defers the compile
|
|
3013
|
-
* instead of emitting a resolution error.
|
|
3014
|
-
*/
|
|
3015
|
-
function canResolveVigiles(cwd) {
|
|
3016
|
-
if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(cwd, "node_modules", "vigiles")))
|
|
3017
|
-
return true;
|
|
3018
|
-
try {
|
|
3019
|
-
const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.resolve)(cwd, "package.json"), "utf-8"));
|
|
3020
|
-
return pkg.name === "vigiles";
|
|
3021
|
-
}
|
|
3022
|
-
catch {
|
|
3023
|
-
return false;
|
|
3024
|
-
}
|
|
3025
|
-
}
|
|
3026
3009
|
/** Whether a harness binary (`claude`, `codex`) is on PATH. */
|
|
3027
3010
|
function harnessBinaryPresent(bin) {
|
|
3028
3011
|
try {
|
|
@@ -3278,22 +3261,16 @@ function printDetection(detected, harnesses) {
|
|
|
3278
3261
|
function printSetupSummary(opts) {
|
|
3279
3262
|
const { plan, strict, targets, adopted, written } = opts;
|
|
3280
3263
|
const specPathsList = targets.map((t) => `${t}.spec.ts`);
|
|
3281
|
-
// A repo with no package.json (Python/Rust/…) can't resolve the npm package,
|
|
3282
|
-
// so the typed-spec compile path needs an install first — give honest steps.
|
|
3283
|
-
const hasPkg = (0, node_fs_1.existsSync)((0, node_path_1.resolve)(process.cwd(), "package.json"));
|
|
3284
3264
|
console.log("\n---\nSetup complete.\n");
|
|
3285
|
-
// Next steps in
|
|
3286
|
-
//
|
|
3265
|
+
// Next steps in the order a reader should do them. `npm install` is NOT a
|
|
3266
|
+
// prerequisite of `compile` any more (the spec host resolves `vigiles/spec`
|
|
3267
|
+
// from the CLI's own install) — it is listed because we just declared the
|
|
3268
|
+
// devDep, so installing it is what gives the editor the spec's types.
|
|
3287
3269
|
const nextSteps = [];
|
|
3288
3270
|
if (written.includes("package.json")) {
|
|
3289
3271
|
nextSteps.push("Run `npm install` to fetch the vigiles dev dependency");
|
|
3290
3272
|
}
|
|
3291
|
-
if (adopted.length > 0
|
|
3292
|
-
// Non-JS repo: compile needs a local install. Point at the no-install verify
|
|
3293
|
-
// path + how to enable specs, instead of a compile that would fail.
|
|
3294
|
-
nextSteps.push(`Verify now with \`npx vigiles lint\` (no install). To spec-manage ${adopted.join(", ")}, add a package.json first (\`npm init -y && npm i -D vigiles\`), then \`npx vigiles compile\` and review the diff`);
|
|
3295
|
-
}
|
|
3296
|
-
else if (adopted.length > 0) {
|
|
3273
|
+
if (adopted.length > 0) {
|
|
3297
3274
|
// Adoption is NON-DESTRUCTIVE: the file is untouched until you compile, so
|
|
3298
3275
|
// the diff to review is what compile WOULD produce (byte-faithful).
|
|
3299
3276
|
nextSteps.push(`Run \`npx vigiles compile\` to put ${adopted.join(", ")} under spec management — it reproduces the file + adds an integrity header, so review the diff (\`vigiles eject\` reverses it)`);
|
|
@@ -5726,64 +5703,6 @@ async function compileProviders() {
|
|
|
5726
5703
|
function hookStampPath(file) {
|
|
5727
5704
|
return (0, node_path_1.resolve)(process.cwd(), ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
|
|
5728
5705
|
}
|
|
5729
|
-
/**
|
|
5730
|
-
* The directory a hook's recorded facts live in — the SCOPE of `state()`/`record()`.
|
|
5731
|
-
*
|
|
5732
|
-
* Derived from the hook's own location and never from anything the hook said, so
|
|
5733
|
-
* a key cannot address another owner's store: hooks shipped in the same directory
|
|
5734
|
-
* share their facts (the requirement — one hook records, another reads), a
|
|
5735
|
-
* vendored plugin's hooks get their own. The layout MIRRORS the hook's directory
|
|
5736
|
-
* rather than slugging it, which keeps it injective and lets a human debugging a
|
|
5737
|
-
* hook find the fact by walking the path they already know:
|
|
5738
|
-
*
|
|
5739
|
-
* .claude/hooks/calendar-sync-record.hook.ts
|
|
5740
|
-
* → .vigiles/state/.claude/hooks/calendar.synced.json
|
|
5741
|
-
*
|
|
5742
|
-
* A hook outside the project (an absolute path elsewhere) falls back to a hash of
|
|
5743
|
-
* its directory: still stable and still isolated, just not readable — which is the
|
|
5744
|
-
* right trade for a case that should not happen in a project's own harness.
|
|
5745
|
-
*/
|
|
5746
|
-
function hookStateDir(file) {
|
|
5747
|
-
const dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(process.cwd(), file));
|
|
5748
|
-
const rel = (0, node_path_1.relative)(process.cwd(), dir);
|
|
5749
|
-
const inside = rel !== "" && !rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel);
|
|
5750
|
-
return (0, node_path_1.resolve)(process.cwd(), ".vigiles/state", inside ? rel : `external-${(0, hash_js_1.sha256short)(dir)}`);
|
|
5751
|
-
}
|
|
5752
|
-
/** Read one recorded fact for a hook, or `null` if it was never recorded. */
|
|
5753
|
-
function readHookState(file, key) {
|
|
5754
|
-
try {
|
|
5755
|
-
const raw = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(hookStateDir(file), key + ".json"), "utf-8");
|
|
5756
|
-
const parsed = JSON.parse(raw);
|
|
5757
|
-
return typeof parsed.value === "string" && typeof parsed.at === "string"
|
|
5758
|
-
? parsed
|
|
5759
|
-
: null;
|
|
5760
|
-
}
|
|
5761
|
-
catch {
|
|
5762
|
-
// Never recorded, unreadable, or corrupt — all "no fact", which `stateFact`
|
|
5763
|
-
// turns into an infinite age, so the reading hook SPEAKS. Failing toward
|
|
5764
|
-
// noise is the whole point; a store problem must never look like freshness.
|
|
5765
|
-
return null;
|
|
5766
|
-
}
|
|
5767
|
-
}
|
|
5768
|
-
/**
|
|
5769
|
-
* Record one fact. Atomic: written to a temp file in the same directory and
|
|
5770
|
-
* `rename()`d over, so a concurrent reader sees the whole old entry or the whole
|
|
5771
|
-
* new one — never one write's value with another's timestamp. Distinct keys are
|
|
5772
|
-
* distinct files and never interact at all.
|
|
5773
|
-
*/
|
|
5774
|
-
function writeHookState(file, w) {
|
|
5775
|
-
const dir = hookStateDir(file);
|
|
5776
|
-
const target = (0, node_path_1.resolve)(dir, w.name + ".json");
|
|
5777
|
-
const entry = {
|
|
5778
|
-
value: w.value,
|
|
5779
|
-
at: new Date().toISOString(),
|
|
5780
|
-
by: (0, hook_install_js_1.normalizeHookRef)(file),
|
|
5781
|
-
};
|
|
5782
|
-
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
5783
|
-
const tmp = `${target}.${String(process.pid)}.tmp`;
|
|
5784
|
-
(0, node_fs_1.writeFileSync)(tmp, JSON.stringify(entry, null, 2) + "\n");
|
|
5785
|
-
(0, node_fs_1.renameSync)(tmp, target);
|
|
5786
|
-
}
|
|
5787
5706
|
/**
|
|
5788
5707
|
* Perform the state writes a hook declared, after its output has been emitted.
|
|
5789
5708
|
* A refused write (a hand-built record object with a key `record()` would have
|
|
@@ -5797,7 +5716,7 @@ function applyHookWrites(file, outcome) {
|
|
|
5797
5716
|
}
|
|
5798
5717
|
for (const w of ok) {
|
|
5799
5718
|
try {
|
|
5800
|
-
writeHookState(file, w);
|
|
5719
|
+
(0, hook_state_store_js_1.writeHookState)(file, w);
|
|
5801
5720
|
}
|
|
5802
5721
|
catch (e) {
|
|
5803
5722
|
console.error(`vigiles: could not record ${w.name} from ${file}: ${String(e)}`);
|
|
@@ -5856,7 +5775,19 @@ async function installHookFile(file, adapter, registeredProviders = []) {
|
|
|
5856
5775
|
const injectable = adapter.hookProtocol?.injectableEvents ?? [];
|
|
5857
5776
|
const matcher = (0, hook_program_js_1.hookRouting)(program).matcher;
|
|
5858
5777
|
let warning;
|
|
5859
|
-
|
|
5778
|
+
// A react on an event this harness does NOT inject can still call `notice()`,
|
|
5779
|
+
// and that text would reach NOBODY (stderr at exit 0 is the debug log only).
|
|
5780
|
+
// Say so at INSTALL time: a runtime warning would go to the same stderr the
|
|
5781
|
+
// notice is stuck in, which is the defect one level up.
|
|
5782
|
+
if (role === "react" && !injectable.includes(event)) {
|
|
5783
|
+
warning =
|
|
5784
|
+
`a react on "${event}" may emit notice(...), but ${adapter.name} does not ` +
|
|
5785
|
+
`honor additionalContext for that event — the text would reach NOBODY ` +
|
|
5786
|
+
`(stderr at exit 0 goes to the debug log, not the transcript, and the model ` +
|
|
5787
|
+
`never sees it). Injectable here: ${injectable.join(", ") || "(none)"}. ` +
|
|
5788
|
+
`Move the hook to one of those events, or use run() if you meant an action.`;
|
|
5789
|
+
}
|
|
5790
|
+
else if (adapter.name !== "claude-code") {
|
|
5860
5791
|
if (role === "inject" && !injectable.includes(event)) {
|
|
5861
5792
|
warning =
|
|
5862
5793
|
`this inject hook targets "${event}", which ${adapter.name} does not ` +
|
|
@@ -5975,7 +5906,7 @@ async function gatherHookContext(program, file) {
|
|
|
5975
5906
|
isCI,
|
|
5976
5907
|
// The namespace is bound HERE, from the hook's own path — core never sees
|
|
5977
5908
|
// it, so no key a hook can spell reaches another owner's store.
|
|
5978
|
-
readState: (key) => readHookState(file, key),
|
|
5909
|
+
readState: (key) => (0, hook_state_store_js_1.readHookState)(file, key),
|
|
5979
5910
|
now: Date.now(),
|
|
5980
5911
|
}, registry);
|
|
5981
5912
|
}
|
|
@@ -6311,6 +6242,27 @@ async function runHookProgramCommand(file) {
|
|
|
6311
6242
|
const ctx = await gatherHookContext(program, file);
|
|
6312
6243
|
warnIfPathUndecidable(event, projectRoot);
|
|
6313
6244
|
const reaction = (0, hook_program_js_1.runReact)(program, event, ctx, projectRoot);
|
|
6245
|
+
// A notice has to REACH someone. stderr at exit 0 goes to the debug log
|
|
6246
|
+
// and nothing else (the host's docs are explicit: "Claude never sees it"),
|
|
6247
|
+
// and a react always exits 0 because its type has no `deny` — so stderr
|
|
6248
|
+
// alone delivered nowhere. Emit the same `additionalContext` shape the
|
|
6249
|
+
// shipped refs/eval-lock nudges use, gated on the ACTIVE adapter's
|
|
6250
|
+
// `injectableEvents` so this is per-harness fact, not a CC literal.
|
|
6251
|
+
const injectable = (0, adapter_registry_js_1.resolveAdapter)(projectRoot ?? process.cwd()).hookProtocol
|
|
6252
|
+
?.injectableEvents ?? [];
|
|
6253
|
+
const delivery = (0, hook_program_js_1.noticeDelivery)(reaction, program.on, injectable);
|
|
6254
|
+
if (delivery.kind === "inject") {
|
|
6255
|
+
process.stdout.write(JSON.stringify({
|
|
6256
|
+
hookSpecificOutput: {
|
|
6257
|
+
hookEventName: program.on,
|
|
6258
|
+
additionalContext: delivery.context,
|
|
6259
|
+
},
|
|
6260
|
+
}) + "\n");
|
|
6261
|
+
}
|
|
6262
|
+
// The stderr copy STAYS, deliberately. It is what the debug log and every
|
|
6263
|
+
// `runHook`-based probe already read, it costs nothing, and on an event
|
|
6264
|
+
// this harness does not inject it is the only trace that exists at all.
|
|
6265
|
+
// Removing it would break existing consumers to gain nothing.
|
|
6314
6266
|
if (reaction.kind === "notice")
|
|
6315
6267
|
console.error(reaction.message);
|
|
6316
6268
|
applyHookWrites(file, { kind: "reaction", reaction });
|
|
@@ -144,6 +144,28 @@ export declare const SHORT_TO_LONG: Readonly<Record<string, string>>;
|
|
|
144
144
|
export declare const LONG_TO_SHORT: Readonly<Record<string, string>>;
|
|
145
145
|
/** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
|
|
146
146
|
export declare const WRAPPER_HEADS: Set<string>;
|
|
147
|
+
/**
|
|
148
|
+
* Resolve a wrapped command through one or more wrapper layers. Given a leaf's
|
|
149
|
+
* full normalized argv (`[head, ...args]`), returns the effective argv of the
|
|
150
|
+
* REAL command plus any `NAME=value` words an `env` wrapper consumed (so the
|
|
151
|
+
* caller can fold them into the leaf's assignment map). If the head is not a
|
|
152
|
+
* wrapper, or the wrapper has no following command, the argv is returned
|
|
153
|
+
* unchanged with an empty assignment set.
|
|
154
|
+
*
|
|
155
|
+
* @internal — the SINGLE answer to "which word is the real command head". Read
|
|
156
|
+
* by `bash-equivalents.ts` (to generate wrapper-prefixed spellings) and by
|
|
157
|
+
* `core/command-files.ts` (to find the interpreter behind a wrapper, so
|
|
158
|
+
* `env python3 guard` names its script). That second reader had its own idea of
|
|
159
|
+
* what a wrapper was — namely none — so it reported no script at all for the
|
|
160
|
+
* wrapped form, and a missing script that is never named is a hook that runs,
|
|
161
|
+
* exits 2, and is scored as a block. One list, or the next wrapper is missed in
|
|
162
|
+
* whichever copy nobody remembered.
|
|
163
|
+
*/
|
|
164
|
+
export declare function stripWrappers(argv: readonly string[]): {
|
|
165
|
+
argv: readonly string[];
|
|
166
|
+
envAssigns: Map<string, string | null>;
|
|
167
|
+
chdir: string | null;
|
|
168
|
+
};
|
|
147
169
|
/**
|
|
148
170
|
* Extract every simple command as a {@link NormalizedLeaf} — the operation-level
|
|
149
171
|
* twin of {@link leafCommands}. Same AST-backed structural coverage (a leaf nested
|
|
@@ -28,6 +28,7 @@ exports.WRAPPER_HEADS = exports.LONG_TO_SHORT = exports.SHORT_TO_LONG = void 0;
|
|
|
28
28
|
exports.classifyBashCommand = classifyBashCommand;
|
|
29
29
|
exports.isReadOnlyBash = isReadOnlyBash;
|
|
30
30
|
exports.leafCommands = leafCommands;
|
|
31
|
+
exports.stripWrappers = stripWrappers;
|
|
31
32
|
exports.leafCommandsNormalized = leafCommandsNormalized;
|
|
32
33
|
exports.leafArgvSource = leafArgvSource;
|
|
33
34
|
exports.commandWords = commandWords;
|
|
@@ -698,6 +699,15 @@ function splitAssignmentWord(word) {
|
|
|
698
699
|
* caller can fold them into the leaf's assignment map). If the head is not a
|
|
699
700
|
* wrapper, or the wrapper has no following command, the argv is returned
|
|
700
701
|
* unchanged with an empty assignment set.
|
|
702
|
+
*
|
|
703
|
+
* @internal — the SINGLE answer to "which word is the real command head". Read
|
|
704
|
+
* by `bash-equivalents.ts` (to generate wrapper-prefixed spellings) and by
|
|
705
|
+
* `core/command-files.ts` (to find the interpreter behind a wrapper, so
|
|
706
|
+
* `env python3 guard` names its script). That second reader had its own idea of
|
|
707
|
+
* what a wrapper was — namely none — so it reported no script at all for the
|
|
708
|
+
* wrapped form, and a missing script that is never named is a hook that runs,
|
|
709
|
+
* exits 2, and is scored as a block. One list, or the next wrapper is missed in
|
|
710
|
+
* whichever copy nobody remembered.
|
|
701
711
|
*/
|
|
702
712
|
function stripWrappers(argv) {
|
|
703
713
|
const envAssigns = new Map();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which FILES a shell command hands to a program to execute — the parser-backed
|
|
3
|
+
* answer to "is the guard this hook names even on disk?".
|
|
4
|
+
*
|
|
5
|
+
* 🔴 WHY THIS EXISTS, and it is the loudest lie the sweep could tell. A hook
|
|
6
|
+
* registered as `python3 .claude/hooks/guard.py` whose script is not there does
|
|
7
|
+
* not fail in a way anyone can see: `python3` exits **2** on a file it cannot
|
|
8
|
+
* open, and 2 is Claude Code's DENY code. So `decideHook` reads a block, and
|
|
9
|
+
* `experimental_verifyPluginGuards` reports the hook as blocking every disaster
|
|
10
|
+
* in the battery — a perfect score for a guard that does not exist. Measured on
|
|
11
|
+
* this repo's own build:
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* hook `python3 .claude/hooks/guard.py`, script absent
|
|
15
|
+
* → MEASURED blocks=7/7 exits=2,2,2,2,2,2,2
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* No malformed config is needed to reach it — a relative script path is the
|
|
19
|
+
* commonest shape in the wild — which makes it strictly worse than the
|
|
20
|
+
* uncompilable-matcher lie fixed alongside it, and it is the same false
|
|
21
|
+
* confidence the product sells against, produced by the product.
|
|
22
|
+
*
|
|
23
|
+
* THE EXIT CODE CANNOT DECIDE IT, and that is not a guess: `run-script.ts`
|
|
24
|
+
* already writes down the same fact for its own coverage attribution — "`sh
|
|
25
|
+
* <missing>` exits **2** under dash, which is Claude Code's BLOCK code —
|
|
26
|
+
* indistinguishable from a gate legitimately denying, so it cannot be encoded".
|
|
27
|
+
* The shell's own 126/127 catch a program that never launched; nothing catches a
|
|
28
|
+
* program that launched and could not find its script. That is what this module
|
|
29
|
+
* is for, and why the two halves are both needed rather than either alone.
|
|
30
|
+
*
|
|
31
|
+
* ## What counts as a file reference (MEASURED, not assumed)
|
|
32
|
+
*
|
|
33
|
+
* A word is only reported when it is UNAMBIGUOUSLY a script the command runs:
|
|
34
|
+
* fully resolved (no unexpanded `$`, no substitution, no glob, no whitespace),
|
|
35
|
+
* not a flag, not a `NAME=value`, not a URL, not the inline program text of a
|
|
36
|
+
* `-c`/`-e`, and either the head is a known interpreter or the word carries a
|
|
37
|
+
* script extension UNDER A HEAD THAT COULD EXECUTE IT. The OR is what keeps
|
|
38
|
+
* either list from being load-bearing on its own: an interpreter missing from
|
|
39
|
+
* the table is still caught by the extension, and an extensionless script is
|
|
40
|
+
* still caught by the interpreter.
|
|
41
|
+
*
|
|
42
|
+
* The `UNDER A HEAD` clause is the correction: an extension is evidence about
|
|
43
|
+
* the FILE, not about the command, so on its own it read `rm -f /tmp/stale.sh`
|
|
44
|
+
* — an ordinary cleanup hook whose file is meant to be absent — as a missing
|
|
45
|
+
* script. {@link DATA_ONLY_HEADS} is the set of heads that never execute an
|
|
46
|
+
* operand, and it silences the extension branch for them.
|
|
47
|
+
*
|
|
48
|
+
* The narrowing is measured, not taste. Over the 107 hook registrations in
|
|
49
|
+
* `davila7/claude-code-templates` (the corpus the defect was found in):
|
|
50
|
+
*
|
|
51
|
+
* | rule | hits | wrong |
|
|
52
|
+
* | ------------------------------------------- | ---: | ----: |
|
|
53
|
+
* | any path-shaped operand | 31 | 8 |
|
|
54
|
+
* | interpreter head OR script extension | 23 | 0 |
|
|
55
|
+
*
|
|
56
|
+
* The eight the wide rule got wrong are files a hook WRITES or later reads
|
|
57
|
+
* (`rm ~/.claude/session_start.tmp`, `mv ~/.claude/performance.csv`) and one
|
|
58
|
+
* outright absurdity (`echo N/A`, which contains a slash). Reporting those would
|
|
59
|
+
* be crying wolf on hooks that are perfectly fine, and a check read once and
|
|
60
|
+
* distrusted is a check that is off.
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ THE `wrong: 0` ROW IS WHAT THE `DATA_ONLY_HEADS` CLAUSE CORRECTS, and the
|
|
63
|
+
* two measurements are of different corpora — say so rather than quoting one
|
|
64
|
+
* number. `rm -f /tmp/stale.sh` is exactly the eighth shape above wearing a
|
|
65
|
+
* script extension, and it was not in the pinned 107. Re-measured 2026-09-08
|
|
66
|
+
* against today's `davila7/claude-code-templates` (134 registrations, 120
|
|
67
|
+
* distinct commands — the repo has moved since the pin, so this is a second
|
|
68
|
+
* sample, not a reproduction of the first):
|
|
69
|
+
*
|
|
70
|
+
* | rule | hits | lost to the clause |
|
|
71
|
+
* | ------------------------------------------- | ---: | -----------------: |
|
|
72
|
+
* | interpreter head OR script extension | 5 | 0 |
|
|
73
|
+
* | …with the `DATA_ONLY_HEADS` clause | 5 | 0 |
|
|
74
|
+
*
|
|
75
|
+
* All five are true positives reached by an INTERPRETER head or a head that is
|
|
76
|
+
* itself a path, so the extension-under-an-unknown-head branch contributes zero
|
|
77
|
+
* hits on this sample and narrowing it costs nothing measurable. That is the
|
|
78
|
+
* evidence the clause does not trade a real detection for a false-positive fix;
|
|
79
|
+
* it is not evidence the branch is dead, which is why it is kept.
|
|
80
|
+
*
|
|
81
|
+
* CONSERVATIVE IN THE OTHER DIRECTION THAN {@link shellVarReads}, on purpose.
|
|
82
|
+
* Over-reporting a missing FILE costs a real guard its measurement AND accuses
|
|
83
|
+
* its author of shipping a broken hook, so here silence is the safe error: a
|
|
84
|
+
* word this module cannot fully resolve is skipped, never guessed at. The
|
|
85
|
+
* missing-program half of the same question is answered after the fact by the
|
|
86
|
+
* shell's 126/127, so a false negative here is not the last line of defence.
|
|
87
|
+
*/
|
|
88
|
+
/** What a command would execute, as far as this module can resolve it. */
|
|
89
|
+
export interface CommandFileRefs {
|
|
90
|
+
/**
|
|
91
|
+
* Files the command hands to a program to run, in first-seen order, exactly
|
|
92
|
+
* as the shell would spell them (relative stays relative — the caller resolves
|
|
93
|
+
* against the cwd the hook will run in).
|
|
94
|
+
*/
|
|
95
|
+
readonly refs: readonly string[];
|
|
96
|
+
/** Whether the shell parser accepted the command. `false` ⇒ `refs` is empty. */
|
|
97
|
+
readonly parsed: boolean;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The files `command` would hand to a program to execute.
|
|
101
|
+
*
|
|
102
|
+
* @param command - the shell command, exactly as the hook registers it.
|
|
103
|
+
* @param values - variables whose value is known at run time, for expansion. A
|
|
104
|
+
* word naming anything absent from this map is skipped, not guessed.
|
|
105
|
+
*/
|
|
106
|
+
export declare function commandFileRefs(command: string, values?: Readonly<Record<string, string>>): CommandFileRefs;
|
|
107
|
+
//# sourceMappingURL=command-files.d.ts.map
|