vigiles 26.1.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/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-matcher.d.ts +50 -0
- package/dist/core/hook-matcher.js +77 -2
- package/dist/core/hook-normalize.d.ts +33 -0
- package/dist/core/hook-normalize.js +45 -0
- package/dist/core/shell-vars.d.ts +74 -0
- package/dist/core/shell-vars.js +270 -0
- package/dist/guardrail-check.d.ts +15 -0
- package/dist/guardrail-check.js +43 -9
- package/dist/run-script.d.ts +94 -0
- package/dist/run-script.js +47 -26
- package/dist/test.d.ts +2 -0
- package/dist/test.js +14 -2
- package/dist/verify-plugin-guards.d.ts +194 -0
- package/dist/verify-plugin-guards.js +822 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.hookMatcherIssues = hookMatcherIssues;
|
|
4
|
+
exports.hookMatcherReach = hookMatcherReach;
|
|
4
5
|
const tool_contract_js_1 = require("./tool-contract.js");
|
|
5
6
|
const mcp_tool_js_1 = require("./mcp-tool.js");
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -32,8 +33,31 @@ const REAL_SHAPE_PROBES = [
|
|
|
32
33
|
];
|
|
33
34
|
/** The widest correct MCP matcher β what a too-narrow one should become. */
|
|
34
35
|
const WIDE_MCP_MATCHER = "mcp__.*__.*";
|
|
35
|
-
/**
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Match-all matchers the harness special-cases (and `*` isn't even a regex).
|
|
38
|
+
*
|
|
39
|
+
* π΄ MEASURED, AND `**` IS NOT ONE OF THEM. It sat in this set on no evidence
|
|
40
|
+
* while the table at the top of this file β the measured one β never listed it.
|
|
41
|
+
* Against a real `claude` 2.1.263, one hook per run, marker file as the oracle,
|
|
42
|
+
* 3 runs of each:
|
|
43
|
+
*
|
|
44
|
+
* | matcher | a `Bash` call | fired |
|
|
45
|
+
* | ------- | ------------- | ----- |
|
|
46
|
+
* | `*` | `Bash` | yes |
|
|
47
|
+
* | `.*` | `Bash` | yes |
|
|
48
|
+
* | `""` | `Bash` | yes |
|
|
49
|
+
* | `**` | `Bash` | NO |
|
|
50
|
+
*
|
|
51
|
+
* The direction of that mistake is the expensive one: believing `**` selects
|
|
52
|
+
* everything makes a guard registered under it come back "measured, allows
|
|
53
|
+
* 0/7" β an ACCUSATION that a repo's guard let seven disasters through, when
|
|
54
|
+
* the harness never invoked it once. The mirror image of scoring a hook that
|
|
55
|
+
* could not start. `**` now falls through to the regex path, where it does not
|
|
56
|
+
* compile, and both the sweep and the `hook-matcher` rule report a hook that
|
|
57
|
+
* never fires. Pinned by `src/hook-matcher-delivery.test.ts` so the day Claude
|
|
58
|
+
* Code starts honouring it, the claim goes red instead of quietly rotting.
|
|
59
|
+
*/
|
|
60
|
+
const MATCH_ALL = new Set(["", "*", ".*"]);
|
|
37
61
|
/** Cap on segments harvested from a matcher β bounds the probe corpus. */
|
|
38
62
|
const MAX_DERIVED_SEGMENTS = 4;
|
|
39
63
|
// ---------------------------------------------------------------------------
|
|
@@ -293,4 +317,55 @@ function hookMatcherIssues(entries, declaredServers, dialect) {
|
|
|
293
317
|
}
|
|
294
318
|
return findings;
|
|
295
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Would this matcher select a call to `tool` β i.e. does the harness spawn the
|
|
322
|
+
* hook at all?
|
|
323
|
+
*
|
|
324
|
+
* The same two MEASURED facts the module header pins, asked as a question rather
|
|
325
|
+
* than as a defect: on a harness whose matchers are tool names (Claude Code), a
|
|
326
|
+
* matcher with no regex metacharacter is compared by string EQUALITY and one
|
|
327
|
+
* with metacharacters is an UNANCHORED regex. It lives here and not in the
|
|
328
|
+
* caller so those semantics have one home (one-detector-no-drift) β
|
|
329
|
+
* `hookMatcherIssues` judges a matcher, this one applies it.
|
|
330
|
+
*
|
|
331
|
+
* FAIL-OPEN WHERE THE HARNESS IS, AND NOT ONE STEP FURTHER. An absent matcher or
|
|
332
|
+
* a match-all really does select every tool, so answering `"selects"` there
|
|
333
|
+
* states a fact β the same direction `decideHookCondition`
|
|
334
|
+
* (`core/hook-condition.ts`) fails open, and for the same reason it gives: where
|
|
335
|
+
* Claude Code cannot tell, it RUNS the hook, so mirroring it can only ever add a
|
|
336
|
+
* run, never invent a skip.
|
|
337
|
+
*
|
|
338
|
+
* π΄ THAT REASONING DOES NOT REACH AN UNCOMPILABLE MATCHER, and this function
|
|
339
|
+
* used to apply it there anyway. `Bash(` is what the `invalid-regex` finding
|
|
340
|
+
* above already reports as "the harness can't compile it, so the hook never
|
|
341
|
+
* fires" β the harness fails CLOSED. Answering `"selects"` therefore does not
|
|
342
|
+
* add a run the harness makes, it MANUFACTURES one: a caller feeds the hook a
|
|
343
|
+
* battery it would never have been handed, and an unconditional-deny body scores
|
|
344
|
+
* a full pass for a hook that cannot run. That is the false-confidence class
|
|
345
|
+
* this module exists to remove, so an uncompilable matcher gets its own answer
|
|
346
|
+
* and the caller declines to score it.
|
|
347
|
+
*
|
|
348
|
+
* @param matcher - the registration's matcher, or `null` when it declares none.
|
|
349
|
+
* @param tool - the tool named by the call, e.g. `"Bash"`.
|
|
350
|
+
* @param style - the active harness's `HookProtocol.matcherStyle`. `"exact"`
|
|
351
|
+
* (the default, Claude Code) applies the literal-equality rule above;
|
|
352
|
+
* `"regex"` (Codex) compiles EVERY matcher, so `ash` matches `Bash` and the
|
|
353
|
+
* glob spellings `*` / `**` β which are Claude Code's documented match-all,
|
|
354
|
+
* not regexes β come back `"uncompilable"` rather than being assumed to be
|
|
355
|
+
* special-cased by a harness nobody measured.
|
|
356
|
+
*/
|
|
357
|
+
function hookMatcherReach(matcher, tool, style = "exact") {
|
|
358
|
+
if (matcher === null || matcher === "")
|
|
359
|
+
return "selects";
|
|
360
|
+
if (style === "exact") {
|
|
361
|
+
if (MATCH_ALL.has(matcher))
|
|
362
|
+
return "selects";
|
|
363
|
+
if (isLiteralMatcher(matcher))
|
|
364
|
+
return matcher === tool ? "selects" : "misses";
|
|
365
|
+
}
|
|
366
|
+
const re = compileMatcher(matcher);
|
|
367
|
+
if (re === null)
|
|
368
|
+
return "uncompilable";
|
|
369
|
+
return re.test(tool) ? "selects" : "misses";
|
|
370
|
+
}
|
|
296
371
|
//# sourceMappingURL=hook-matcher.js.map
|
|
@@ -56,6 +56,39 @@ export interface HookRegistration {
|
|
|
56
56
|
* for any non-object / malformed input β never throws.
|
|
57
57
|
*/
|
|
58
58
|
export declare function normalizeHooks(raw: unknown): HookRegistration[];
|
|
59
|
+
/**
|
|
60
|
+
* A declared hook action that carries no command β `prompt`, `http`, `mcp_tool`
|
|
61
|
+
* or `agent`. Real, supported actions; simply not shell processes, so no tier
|
|
62
|
+
* that drives a shell can measure one.
|
|
63
|
+
*/
|
|
64
|
+
export interface NonCommandHookAction {
|
|
65
|
+
/** The event it registers under. */
|
|
66
|
+
readonly event: string;
|
|
67
|
+
/** The tool/path matcher of the entry it sits in, or `null`. */
|
|
68
|
+
readonly matcher: string | null;
|
|
69
|
+
/** Its declared `type`, e.g. `"prompt"`. Never `"command"`. */
|
|
70
|
+
readonly type: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The declared actions {@link normalizeHooks} does NOT return, and why anyone
|
|
74
|
+
* should care.
|
|
75
|
+
*
|
|
76
|
+
* π΄ SILENCE HERE READ AS "NO HOOKS DECLARED", which is the exact false-empty a
|
|
77
|
+
* guard sweep exists to prevent. Claude Code supports five action types
|
|
78
|
+
* (command / http / mcp_tool / prompt / agent) and `normalizeHooks` keeps only
|
|
79
|
+
* the first, correctly β the others are not shell processes and nothing that
|
|
80
|
+
* spawns a shell can drive them. But a repository whose PreToolUse hooks are all
|
|
81
|
+
* `prompt` actions then produced an empty registration list, and a caller that
|
|
82
|
+
* reads only the length cannot tell "this repo declared no guards" from "this
|
|
83
|
+
* repo declared four guards I cannot run". The first is an accusation; the
|
|
84
|
+
* second is a limit of the tier. So the dropped actions are RETURNED rather than
|
|
85
|
+
* discarded, and the caller reports them as declared-but-not-measured.
|
|
86
|
+
*
|
|
87
|
+
* A holder counts only when it declares a `type` that is not `"command"`. An
|
|
88
|
+
* entry with neither a type nor a command is malformed config, not an action,
|
|
89
|
+
* and calling it one would invent a hook the repository never declared.
|
|
90
|
+
*/
|
|
91
|
+
export declare function nonCommandHookActions(raw: unknown): NonCommandHookAction[];
|
|
59
92
|
/** Distinct event names present in the raw hooks object (object-keyed shape). */
|
|
60
93
|
export declare function hookEventNames(raw: unknown): string[];
|
|
61
94
|
//# sourceMappingURL=hook-normalize.d.ts.map
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
25
|
exports.normalizeHooks = normalizeHooks;
|
|
26
|
+
exports.nonCommandHookActions = nonCommandHookActions;
|
|
26
27
|
exports.hookEventNames = hookEventNames;
|
|
27
28
|
/**
|
|
28
29
|
* The config key a hook's condition is written under. Read here rather than from
|
|
@@ -86,6 +87,50 @@ function normalizeHooks(raw) {
|
|
|
86
87
|
}
|
|
87
88
|
return out;
|
|
88
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* The declared actions {@link normalizeHooks} does NOT return, and why anyone
|
|
92
|
+
* should care.
|
|
93
|
+
*
|
|
94
|
+
* π΄ SILENCE HERE READ AS "NO HOOKS DECLARED", which is the exact false-empty a
|
|
95
|
+
* guard sweep exists to prevent. Claude Code supports five action types
|
|
96
|
+
* (command / http / mcp_tool / prompt / agent) and `normalizeHooks` keeps only
|
|
97
|
+
* the first, correctly β the others are not shell processes and nothing that
|
|
98
|
+
* spawns a shell can drive them. But a repository whose PreToolUse hooks are all
|
|
99
|
+
* `prompt` actions then produced an empty registration list, and a caller that
|
|
100
|
+
* reads only the length cannot tell "this repo declared no guards" from "this
|
|
101
|
+
* repo declared four guards I cannot run". The first is an accusation; the
|
|
102
|
+
* second is a limit of the tier. So the dropped actions are RETURNED rather than
|
|
103
|
+
* discarded, and the caller reports them as declared-but-not-measured.
|
|
104
|
+
*
|
|
105
|
+
* A holder counts only when it declares a `type` that is not `"command"`. An
|
|
106
|
+
* entry with neither a type nor a command is malformed config, not an action,
|
|
107
|
+
* and calling it one would invent a hook the repository never declared.
|
|
108
|
+
*/
|
|
109
|
+
function nonCommandHookActions(raw) {
|
|
110
|
+
if (!isRecord(raw))
|
|
111
|
+
return [];
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const [event, arr] of Object.entries(raw)) {
|
|
114
|
+
if (!Array.isArray(arr))
|
|
115
|
+
continue;
|
|
116
|
+
for (const entry of arr) {
|
|
117
|
+
if (!isRecord(entry))
|
|
118
|
+
continue;
|
|
119
|
+
const matcher = entryMatcher(entry);
|
|
120
|
+
const nested = entry.hooks;
|
|
121
|
+
const holders = Array.isArray(nested) ? nested : [entry];
|
|
122
|
+
for (const h of holders) {
|
|
123
|
+
if (!isRecord(h))
|
|
124
|
+
continue;
|
|
125
|
+
const type = h.type;
|
|
126
|
+
if (typeof type !== "string" || type === "" || type === "command")
|
|
127
|
+
continue;
|
|
128
|
+
out.push({ event, matcher, type });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
89
134
|
/** Distinct event names present in the raw hooks object (object-keyed shape). */
|
|
90
135
|
function hookEventNames(raw) {
|
|
91
136
|
return isRecord(raw) ? Object.keys(raw) : [];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which environment variables a shell command actually DEPENDS ON β the
|
|
3
|
+
* parser-backed answer to "would this command run the same program here?".
|
|
4
|
+
*
|
|
5
|
+
* π΄ WHY A PARSER AND NOT A REGEX, measured. The caller
|
|
6
|
+
* (`experimental_verifyPluginGuards`) refuses to run a hook whose command names
|
|
7
|
+
* a variable nothing has set, because running it would measure a different
|
|
8
|
+
* program than the harness runs. Deciding that with `/\$\{?NAME\}?/` gets two
|
|
9
|
+
* ordinary shapes wrong, in the direction that costs a measurement:
|
|
10
|
+
*
|
|
11
|
+
* | command | the shell | a raw regex |
|
|
12
|
+
* | -------------------------------- | -------------------- | ------------- |
|
|
13
|
+
* | `GUARD=hooks/g.sh; "$GUARD"` | sets it, then expands| "unset GUARD" |
|
|
14
|
+
* | `echo '$NOT_A_VAR'` | no expansion at all | "unset β¦" |
|
|
15
|
+
*
|
|
16
|
+
* Both are self-contained commands reported as unresolvable, so a real guard
|
|
17
|
+
* goes unmeasured for a reason that is not true of it. This is the
|
|
18
|
+
* `parse-structured-input-with-a-real-parser` rule applied to the same shell
|
|
19
|
+
* grammar `core/bash-effects.ts` already parses: an ASSIGNMENT and a SINGLE-
|
|
20
|
+
* QUOTED literal are nodes, so once the command is an AST the two mistakes above
|
|
21
|
+
* are not expressible.
|
|
22
|
+
*
|
|
23
|
+
* π΄ AND THE PARSER REMOVED TWO WAYS TO BE WRONG WHILE ADDING A THIRD, in the
|
|
24
|
+
* worse direction. Subtracting every assigned name GLOBALLY excused a read the
|
|
25
|
+
* assignment never reached, so the sweep ran a differently-configured program
|
|
26
|
+
* and gave it a score. Measured against `/bin/sh` with the name exported first:
|
|
27
|
+
*
|
|
28
|
+
* ```
|
|
29
|
+
* export X=ambient; echo "$X"; X=1 β ambient (read comes FIRST)
|
|
30
|
+
* export FOO=ambient; FOO=1 sh -c "echo $FOO" β ambient (prefix assign does
|
|
31
|
+
* not reach its own
|
|
32
|
+
* command's words)
|
|
33
|
+
* (G=inner); printf '[%s]' "$G" β [] (subshell-scoped)
|
|
34
|
+
* G=dominates; printf '[%s]' "$G" β dominates (this one persists)
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* So the rule is DOMINANCE, not membership, and CONTROL FLOW, not source order:
|
|
38
|
+
* an assignment excuses a read only when it is an unconditional top-level
|
|
39
|
+
* statement (see {@link persistingAssigns}) AND sits before that read. A prefix,
|
|
40
|
+
* subshell, function-body, backgrounded, conditional or pipelined assignment
|
|
41
|
+
* excuses nothing at all. Where dominance is not provable, the read stands.
|
|
42
|
+
*
|
|
43
|
+
* It does NOT reach into `bash-effects.ts` for the parse: that module's `sh`
|
|
44
|
+
* handle and node types are private to it, and its types model EFFECTS
|
|
45
|
+
* (redirections, wrapper heads, flag tables) rather than expansions. The shared
|
|
46
|
+
* thing is the dependency, not the code β both `require("mvdan-sh")`.
|
|
47
|
+
*
|
|
48
|
+
* CONSERVATIVE, ON PURPOSE, IN ONE DIRECTION. Over-reporting a dependency costs
|
|
49
|
+
* a hook its measurement (the caller says so and names the variable);
|
|
50
|
+
* under-reporting one lets a differently-configured program be measured and
|
|
51
|
+
* scored. So where the parser cannot decide, this reports MORE:
|
|
52
|
+
*
|
|
53
|
+
* - a parse failure falls back to the regex scan and says `parsed: false`;
|
|
54
|
+
* - `${FOO:-default}` and `${FOO:?msg}` count as reads even though the first
|
|
55
|
+
* always resolves β reading the expansion operator is a further step, and its
|
|
56
|
+
* only effect would be to measure more hooks;
|
|
57
|
+
* - a `for f in β¦` loop variable is a read (nothing binds it in the AST the way
|
|
58
|
+
* an `Assign` does).
|
|
59
|
+
*
|
|
60
|
+
* `$1` / `$@` / `$?` are never reads: they are positional and special
|
|
61
|
+
* parameters, not environment the caller could set.
|
|
62
|
+
*/
|
|
63
|
+
/** What a command reads from its environment. */
|
|
64
|
+
export interface ShellVarReads {
|
|
65
|
+
/** Names it expands and does not itself assign, first-seen order. */
|
|
66
|
+
readonly reads: readonly string[];
|
|
67
|
+
/**
|
|
68
|
+
* Whether the shell parser accepted the command. `false` means `reads` came
|
|
69
|
+
* from the regex fallback and may name a variable the command sets itself.
|
|
70
|
+
*/
|
|
71
|
+
readonly parsed: boolean;
|
|
72
|
+
}
|
|
73
|
+
export declare function shellVarReads(command: string): ShellVarReads;
|
|
74
|
+
//# sourceMappingURL=shell-vars.d.ts.map
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Which environment variables a shell command actually DEPENDS ON β the
|
|
4
|
+
* parser-backed answer to "would this command run the same program here?".
|
|
5
|
+
*
|
|
6
|
+
* π΄ WHY A PARSER AND NOT A REGEX, measured. The caller
|
|
7
|
+
* (`experimental_verifyPluginGuards`) refuses to run a hook whose command names
|
|
8
|
+
* a variable nothing has set, because running it would measure a different
|
|
9
|
+
* program than the harness runs. Deciding that with `/\$\{?NAME\}?/` gets two
|
|
10
|
+
* ordinary shapes wrong, in the direction that costs a measurement:
|
|
11
|
+
*
|
|
12
|
+
* | command | the shell | a raw regex |
|
|
13
|
+
* | -------------------------------- | -------------------- | ------------- |
|
|
14
|
+
* | `GUARD=hooks/g.sh; "$GUARD"` | sets it, then expands| "unset GUARD" |
|
|
15
|
+
* | `echo '$NOT_A_VAR'` | no expansion at all | "unset β¦" |
|
|
16
|
+
*
|
|
17
|
+
* Both are self-contained commands reported as unresolvable, so a real guard
|
|
18
|
+
* goes unmeasured for a reason that is not true of it. This is the
|
|
19
|
+
* `parse-structured-input-with-a-real-parser` rule applied to the same shell
|
|
20
|
+
* grammar `core/bash-effects.ts` already parses: an ASSIGNMENT and a SINGLE-
|
|
21
|
+
* QUOTED literal are nodes, so once the command is an AST the two mistakes above
|
|
22
|
+
* are not expressible.
|
|
23
|
+
*
|
|
24
|
+
* π΄ AND THE PARSER REMOVED TWO WAYS TO BE WRONG WHILE ADDING A THIRD, in the
|
|
25
|
+
* worse direction. Subtracting every assigned name GLOBALLY excused a read the
|
|
26
|
+
* assignment never reached, so the sweep ran a differently-configured program
|
|
27
|
+
* and gave it a score. Measured against `/bin/sh` with the name exported first:
|
|
28
|
+
*
|
|
29
|
+
* ```
|
|
30
|
+
* export X=ambient; echo "$X"; X=1 β ambient (read comes FIRST)
|
|
31
|
+
* export FOO=ambient; FOO=1 sh -c "echo $FOO" β ambient (prefix assign does
|
|
32
|
+
* not reach its own
|
|
33
|
+
* command's words)
|
|
34
|
+
* (G=inner); printf '[%s]' "$G" β [] (subshell-scoped)
|
|
35
|
+
* G=dominates; printf '[%s]' "$G" β dominates (this one persists)
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* So the rule is DOMINANCE, not membership, and CONTROL FLOW, not source order:
|
|
39
|
+
* an assignment excuses a read only when it is an unconditional top-level
|
|
40
|
+
* statement (see {@link persistingAssigns}) AND sits before that read. A prefix,
|
|
41
|
+
* subshell, function-body, backgrounded, conditional or pipelined assignment
|
|
42
|
+
* excuses nothing at all. Where dominance is not provable, the read stands.
|
|
43
|
+
*
|
|
44
|
+
* It does NOT reach into `bash-effects.ts` for the parse: that module's `sh`
|
|
45
|
+
* handle and node types are private to it, and its types model EFFECTS
|
|
46
|
+
* (redirections, wrapper heads, flag tables) rather than expansions. The shared
|
|
47
|
+
* thing is the dependency, not the code β both `require("mvdan-sh")`.
|
|
48
|
+
*
|
|
49
|
+
* CONSERVATIVE, ON PURPOSE, IN ONE DIRECTION. Over-reporting a dependency costs
|
|
50
|
+
* a hook its measurement (the caller says so and names the variable);
|
|
51
|
+
* under-reporting one lets a differently-configured program be measured and
|
|
52
|
+
* scored. So where the parser cannot decide, this reports MORE:
|
|
53
|
+
*
|
|
54
|
+
* - a parse failure falls back to the regex scan and says `parsed: false`;
|
|
55
|
+
* - `${FOO:-default}` and `${FOO:?msg}` count as reads even though the first
|
|
56
|
+
* always resolves β reading the expansion operator is a further step, and its
|
|
57
|
+
* only effect would be to measure more hooks;
|
|
58
|
+
* - a `for f in β¦` loop variable is a read (nothing binds it in the AST the way
|
|
59
|
+
* an `Assign` does).
|
|
60
|
+
*
|
|
61
|
+
* `$1` / `$@` / `$?` are never reads: they are positional and special
|
|
62
|
+
* parameters, not environment the caller could set.
|
|
63
|
+
*/
|
|
64
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
65
|
+
exports.shellVarReads = shellVarReads;
|
|
66
|
+
// mvdan-sh is a CJS package (GopherJS build) with no bundled TypeScript types β
|
|
67
|
+
// the same require() `core/bash-effects.ts` uses, for the same parser.
|
|
68
|
+
const _sh = require("mvdan-sh");
|
|
69
|
+
const sh = _sh;
|
|
70
|
+
/** A `$NAME` / `${NAME}` reference β not `$(β¦)`, `$1` or `$@`. */
|
|
71
|
+
const VAR_REF = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g;
|
|
72
|
+
/** An environment-variable name: what a caller could put in `env`. */
|
|
73
|
+
const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
74
|
+
/** The fallback for a command the shell parser rejects: every `$NAME` in it. */
|
|
75
|
+
function scanRefs(command) {
|
|
76
|
+
const names = new Set();
|
|
77
|
+
for (const [, name] of command.matchAll(VAR_REF))
|
|
78
|
+
names.add(name);
|
|
79
|
+
return [...names];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The environment variables `command` expands and does not set for itself.
|
|
83
|
+
*
|
|
84
|
+
* @param command - the shell command, exactly as the hook registers it.
|
|
85
|
+
*/
|
|
86
|
+
/** A node's byte offset in the source, or `null` when the binding withholds it. */
|
|
87
|
+
function offsetOf(node) {
|
|
88
|
+
try {
|
|
89
|
+
return node.Pos().Offset();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Where an assignment's effect BEGINS β one byte past its last, not at its first.
|
|
97
|
+
*
|
|
98
|
+
* π΄ THE WHOLE OF THE SELF-REFERENTIAL BUG. The shell expands an assignment's
|
|
99
|
+
* RHS and only THEN binds the name, so `MODE="$MODE"` reads the environment and
|
|
100
|
+
* a read inside an initializer can never be dominated by the assignment
|
|
101
|
+
* containing it. Keyed on the assignment's START, it was: the walk is preorder,
|
|
102
|
+
* so the `Assign` node was recorded before its own `ParamExp` was visited, the
|
|
103
|
+
* assignment's offset was the smaller one, and the genuine ambient read
|
|
104
|
+
* disappeared. `MODE="$MODE"; [ "$MODE" = strict ] && exit 2` therefore reported
|
|
105
|
+
* NO dependency β so under confinement the sweep cleared `MODE` without saying
|
|
106
|
+
* so and scored whichever branch the empty value took.
|
|
107
|
+
*
|
|
108
|
+
* Keyed on the END, the containment falls out of the arithmetic rather than
|
|
109
|
+
* needing a special case: a read inside the initializer has a smaller offset
|
|
110
|
+
* than the assignment's end, so it stands; a read after the statement has a
|
|
111
|
+
* larger one, so it is excused. Both directions are pinned in the tests.
|
|
112
|
+
*/
|
|
113
|
+
function endOfOrNull(node) {
|
|
114
|
+
try {
|
|
115
|
+
return node.End().Offset();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Declaring keywords whose assignment persists into the rest of the SCRIPT.
|
|
123
|
+
*
|
|
124
|
+
* `local` is deliberately absent: it is meaningful only inside a function, and
|
|
125
|
+
* a function body is precisely the scope this walker never treats as reached.
|
|
126
|
+
*/
|
|
127
|
+
const PERSISTING_DECLARATIONS = new Set([
|
|
128
|
+
"export",
|
|
129
|
+
"readonly",
|
|
130
|
+
"declare",
|
|
131
|
+
"typeset",
|
|
132
|
+
]);
|
|
133
|
+
/**
|
|
134
|
+
* Offsets of the assignments that excuse a later read β and it is an ALLOWLIST,
|
|
135
|
+
* which is the whole of the fix. Two shapes are recognized, both TOP-LEVEL
|
|
136
|
+
* statements of the script and neither detached with `&`: a bare `NAME=value`,
|
|
137
|
+
* and a {@link PERSISTING_DECLARATIONS} keyword (`export NAME=value`), which the
|
|
138
|
+
* parser models as a different node for the same persisting statement.
|
|
139
|
+
*
|
|
140
|
+
* π΄ SOURCE ORDER IS NOT CONTROL FLOW, and reading it as such was a third way to
|
|
141
|
+
* measure a differently-configured program. The earlier version marked the
|
|
142
|
+
* assignments it could prove do not PERSIST (subshell, function body, command
|
|
143
|
+
* prefix) and excused every other one that merely appeared earlier in the text β
|
|
144
|
+
* so an assignment the shell may never execute silently excused the read it
|
|
145
|
+
* sits before. Measured against `/bin/sh` with the name exported first:
|
|
146
|
+
*
|
|
147
|
+
* ```
|
|
148
|
+
* MODE=safe; [ "$MODE" = safe ] β runs, and excuses the read
|
|
149
|
+
* false && MODE=safe; [ "$MODE" = safe ] β the AMBIENT value is read
|
|
150
|
+
* if false; then MODE=safe; fi; [ "$MODE" = x ] β the AMBIENT value is read
|
|
151
|
+
* MODE=safe & [ "$MODE" = safe ] β detached: never reaches it
|
|
152
|
+
* true | MODE=safe; [ "$MODE" = safe ] β pipeline subshell, discarded
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* Four of those five excused the read under the old rule while the real shell
|
|
156
|
+
* went on reading the environment. Under confinement the sweep clears that
|
|
157
|
+
* environment, so it would have run the `MODE`-unset arm of a guard and scored
|
|
158
|
+
* whatever that arm happens to do.
|
|
159
|
+
*
|
|
160
|
+
* An allowlist rather than a longer denylist because the denylist can only ever
|
|
161
|
+
* be as complete as the grammar we remembered: `Subshell` and `FuncDecl` were on
|
|
162
|
+
* it, `BinaryCmd`, `IfClause`, `WhileClause`, `ForClause`, `CaseClause`, `Block`
|
|
163
|
+
* and a backgrounded `Stmt` were not, and the next construct would not be
|
|
164
|
+
* either. Inverting it makes the unlisted case default to "excuses nothing",
|
|
165
|
+
* which is the direction this module already errs in (see the header): an
|
|
166
|
+
* assignment we cannot place costs a measurement, never a false score.
|
|
167
|
+
*
|
|
168
|
+
* The cost is named rather than hidden: `{ MODE=safe; }; echo "$MODE"` does run
|
|
169
|
+
* unconditionally in this shell and is no longer excused. A brace group at the
|
|
170
|
+
* top of a hook command is rare, and reporting `MODE` as a dependency there ends
|
|
171
|
+
* in `unresolved` with the name printed β the safe error.
|
|
172
|
+
*/
|
|
173
|
+
function persistingAssigns(file) {
|
|
174
|
+
// start offset β the offset its binding takes effect at (see endOfOrNull).
|
|
175
|
+
const unconditional = new Map();
|
|
176
|
+
const take = (node) => {
|
|
177
|
+
const at = offsetOf(node);
|
|
178
|
+
const effective = endOfOrNull(node);
|
|
179
|
+
// A binding whose extent the parser withheld cannot be shown to dominate
|
|
180
|
+
// anything, so it is not recorded and every read of the name stands.
|
|
181
|
+
if (at !== null && effective !== null)
|
|
182
|
+
unconditional.set(at, effective);
|
|
183
|
+
};
|
|
184
|
+
for (const stmt of file.Stmts ?? []) {
|
|
185
|
+
// `&` detaches into a subshell, so the assignment never reaches this one.
|
|
186
|
+
if (stmt.Background === true)
|
|
187
|
+
continue;
|
|
188
|
+
const cmd = stmt.Cmd;
|
|
189
|
+
if (!cmd)
|
|
190
|
+
continue;
|
|
191
|
+
const kind = sh.syntax.NodeType(cmd);
|
|
192
|
+
// `export NAME=value` is a DeclClause, not a CallExpr β a separate node for
|
|
193
|
+
// the same persisting, unconditional statement. Its `Args` ARE the `Assign`
|
|
194
|
+
// nodes, so walking it reaches exactly them.
|
|
195
|
+
if (kind === "DeclClause") {
|
|
196
|
+
if (!PERSISTING_DECLARATIONS.has(cmd.Variant?.Value ?? ""))
|
|
197
|
+
continue;
|
|
198
|
+
sh.syntax.Walk(cmd, (node) => {
|
|
199
|
+
if (node && sh.syntax.NodeType(node) === "Assign")
|
|
200
|
+
take(node);
|
|
201
|
+
return true;
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (kind !== "CallExpr")
|
|
206
|
+
continue;
|
|
207
|
+
// A `CallExpr` with WORDS carries prefix assignments (`FOO=1 cmd`), which do
|
|
208
|
+
// not outlive their own command; one with no words IS the assignment
|
|
209
|
+
// statement (`FOO=1`), which persists into the rest of the script.
|
|
210
|
+
if ((cmd.Args?.length ?? 0) > 0)
|
|
211
|
+
continue;
|
|
212
|
+
for (const assign of cmd.Assigns ?? [])
|
|
213
|
+
take(assign);
|
|
214
|
+
}
|
|
215
|
+
return unconditional;
|
|
216
|
+
}
|
|
217
|
+
function shellVarReads(command) {
|
|
218
|
+
let file;
|
|
219
|
+
try {
|
|
220
|
+
file = sh.syntax.NewParser().Parse(command, "hook.sh");
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return { reads: scanRefs(command), parsed: false };
|
|
224
|
+
}
|
|
225
|
+
const unconditional = persistingAssigns(file);
|
|
226
|
+
const assignedAt = new Map();
|
|
227
|
+
const reads = [];
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
sh.syntax.Walk(file, (node) => {
|
|
230
|
+
if (!node)
|
|
231
|
+
return true;
|
|
232
|
+
const kind = sh.syntax.NodeType(node);
|
|
233
|
+
const at = offsetOf(node);
|
|
234
|
+
if (kind === "Assign") {
|
|
235
|
+
const name = node.Name?.Value;
|
|
236
|
+
// The FIRST unconditional assignment is the only one that can dominate
|
|
237
|
+
// a read; a later one cannot reach backwards. What is stored is where the
|
|
238
|
+
// binding TAKES EFFECT (the assignment's end), not where it is written β
|
|
239
|
+
// see {@link endOfOrNull}.
|
|
240
|
+
const effective = at === null ? undefined : unconditional.get(at);
|
|
241
|
+
if (name !== undefined && name !== "" && effective !== undefined)
|
|
242
|
+
if (!assignedAt.has(name))
|
|
243
|
+
assignedAt.set(name, effective);
|
|
244
|
+
}
|
|
245
|
+
else if (kind === "ParamExp") {
|
|
246
|
+
const name = node.Param?.Value;
|
|
247
|
+
if (name !== undefined && VAR_NAME.test(name) && !seen.has(name)) {
|
|
248
|
+
// π΄ DOMINANCE, NOT MEMBERSHIP, AND CONTROL FLOW, NOT SOURCE ORDER.
|
|
249
|
+
// Subtracting every assigned name globally excused `echo "$GUARD";
|
|
250
|
+
// GUARD=hooks/g.sh` β where the expansion runs FIRST and really does
|
|
251
|
+
// read the environment. Counting an earlier OFFSET as dominance excused
|
|
252
|
+
// `false && MODE=safe; [ "$MODE" = safe ]`, which the shell also reads
|
|
253
|
+
// from the environment. Both let a differently-configured program be
|
|
254
|
+
// measured and scored, so an assignment excuses a read only when
|
|
255
|
+
// {@link persistingAssigns} proved it runs, and runs first β where
|
|
256
|
+
// "first" means it has FINISHED, because `MODE="$MODE"` expands its RHS
|
|
257
|
+
// before it binds the name (see {@link endOfOrNull}).
|
|
258
|
+
const assigned = assignedAt.get(name);
|
|
259
|
+
// `at === null` means the binding withheld the position: excuse nothing.
|
|
260
|
+
if (assigned === undefined || at === null || assigned > at) {
|
|
261
|
+
seen.add(name);
|
|
262
|
+
reads.push(name);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return true;
|
|
267
|
+
});
|
|
268
|
+
return { reads, parsed: true };
|
|
269
|
+
}
|
|
270
|
+
//# sourceMappingURL=shell-vars.js.map
|
|
@@ -109,6 +109,21 @@ export declare function unblockedDisasters(results: readonly GuardrailResult[]):
|
|
|
109
109
|
* build instead of failing in production.
|
|
110
110
|
*/
|
|
111
111
|
export declare function assertBlocksDisasters(hookCommand: string, opts?: VerifyGuardrailOptions): void;
|
|
112
|
+
/**
|
|
113
|
+
* One battery event as a report line, WITHOUT leading indentation so each caller
|
|
114
|
+
* nests it where its own layout needs.
|
|
115
|
+
*
|
|
116
|
+
* THREE outcomes, not two. "never run" is not a weaker "allows": the harness
|
|
117
|
+
* would not invoke this hook for that call at all, so the guard has no opinion
|
|
118
|
+
* to report. Printing it as `allows` is what made a conditional guard look like
|
|
119
|
+
* it had considered β and permitted β commands it can never see.
|
|
120
|
+
*
|
|
121
|
+
* @internal Shared by {@link formatGuardrailReport} and the directory-level
|
|
122
|
+
* sweep's formatter (`experimental_formatPluginGuardReport`), so the two renderers
|
|
123
|
+
* cannot drift into two vocabularies for the same three outcomes. Not part of the
|
|
124
|
+
* public API β a caller wanting these lines wants one of the two reports.
|
|
125
|
+
*/
|
|
126
|
+
export declare function guardrailRow(result: GuardrailResult): string;
|
|
112
127
|
/**
|
|
113
128
|
* Render a coverage report (informational, NEUTRAL). It reports what the
|
|
114
129
|
* hook blocks WITHOUT judging it: a hook that allows these may simply not be a
|
package/dist/guardrail-check.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.experimental_alternateSpellings = experimental_alternateSpellings;
|
|
|
5
5
|
exports.verifyGuardrail = verifyGuardrail;
|
|
6
6
|
exports.unblockedDisasters = unblockedDisasters;
|
|
7
7
|
exports.assertBlocksDisasters = assertBlocksDisasters;
|
|
8
|
+
exports.guardrailRow = guardrailRow;
|
|
8
9
|
exports.formatGuardrailReport = formatGuardrailReport;
|
|
9
10
|
/**
|
|
10
11
|
* Guardrail verification β "prove your safety hook ACTUALLY blocks."
|
|
@@ -32,6 +33,7 @@ exports.formatGuardrailReport = formatGuardrailReport;
|
|
|
32
33
|
*/
|
|
33
34
|
const bash_equivalents_js_1 = require("./core/bash-equivalents.js");
|
|
34
35
|
const run_hook_js_1 = require("./run-hook.js");
|
|
36
|
+
const run_script_js_1 = require("./run-script.js");
|
|
35
37
|
/**
|
|
36
38
|
* The curated battery. Deliberately small and high-signal: each is a textbook
|
|
37
39
|
* destructive action a real safety hook in the wild claims to stop. Extend with
|
|
@@ -173,6 +175,27 @@ function verifyGuardrail(hookCommand, opts = {}) {
|
|
|
173
175
|
tool_name: event.tool,
|
|
174
176
|
tool_input: event.input,
|
|
175
177
|
}, opts);
|
|
178
|
+
// π΄ A PROGRAM THE SHELL NEVER LAUNCHED HAS NO OPINION, so it must not be
|
|
179
|
+
// reported as one. 126 ("found, not executable") and 127 ("not found") are
|
|
180
|
+
// the SHELL's own codes β not a language's exit convention and not a guess
|
|
181
|
+
// about stderr text β and they arrive when the interpreter is missing, the
|
|
182
|
+
// file is not executable, or the shebang is wrong. Left alone, they read as
|
|
183
|
+
// `ran and allowed`, which accuses a guard of letting a disaster through
|
|
184
|
+
// when it was never asked. Folding them into the existing not-run channel
|
|
185
|
+
// means `assertBlocksDisasters` and both renderers say WHY for free, and the
|
|
186
|
+
// verdict is unchanged: a guard that cannot start still protects nothing.
|
|
187
|
+
//
|
|
188
|
+
// β οΈ ONLY WHEN NOTHING BLOCKED. `echo '{"β¦denyβ¦}'; ./missing` exits 127 with
|
|
189
|
+
// a real deny on stdout; reclassifying that would hide a decision the hook
|
|
190
|
+
// genuinely made. The exit code loses to the decision, never the reverse.
|
|
191
|
+
if (!r.blocked && r.ran && (0, run_script_js_1.shellNeverLaunched)(r.exitCode))
|
|
192
|
+
return {
|
|
193
|
+
event,
|
|
194
|
+
blocked: false,
|
|
195
|
+
exitCode: r.exitCode,
|
|
196
|
+
ran: false,
|
|
197
|
+
reason: `the shell never launched this hook (exit ${String(r.exitCode)}: ${r.exitCode === 127 ? "command not found" : "not executable"}) β nothing here is the guard's decision`,
|
|
198
|
+
};
|
|
176
199
|
return {
|
|
177
200
|
event,
|
|
178
201
|
blocked: r.blocked,
|
|
@@ -203,6 +226,25 @@ function assertBlocksDisasters(hookCommand, opts = {}) {
|
|
|
203
226
|
: ` β ${m.event.label} β NOT RUN: ${m.reason}`);
|
|
204
227
|
throw new Error(`Guardrail \`${hookCommand}\` did NOT block ${misses.length} dangerous action(s):\n${lines.join("\n")}\nA hook that doesn't block these is false confidence β fix it (PreToolUse + exit 2).`);
|
|
205
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* One battery event as a report line, WITHOUT leading indentation so each caller
|
|
231
|
+
* nests it where its own layout needs.
|
|
232
|
+
*
|
|
233
|
+
* THREE outcomes, not two. "never run" is not a weaker "allows": the harness
|
|
234
|
+
* would not invoke this hook for that call at all, so the guard has no opinion
|
|
235
|
+
* to report. Printing it as `allows` is what made a conditional guard look like
|
|
236
|
+
* it had considered β and permitted β commands it can never see.
|
|
237
|
+
*
|
|
238
|
+
* @internal Shared by {@link formatGuardrailReport} and the directory-level
|
|
239
|
+
* sweep's formatter (`experimental_formatPluginGuardReport`), so the two renderers
|
|
240
|
+
* cannot drift into two vocabularies for the same three outcomes. Not part of the
|
|
241
|
+
* public API β a caller wanting these lines wants one of the two reports.
|
|
242
|
+
*/
|
|
243
|
+
function guardrailRow(result) {
|
|
244
|
+
if (!result.ran)
|
|
245
|
+
return `β not run ${result.event.label} β ${result.reason}`;
|
|
246
|
+
return `${result.blocked ? "β
blocks" : "Β· allows"} ${result.event.label}`;
|
|
247
|
+
}
|
|
206
248
|
/**
|
|
207
249
|
* Render a coverage report (informational, NEUTRAL). It reports what the
|
|
208
250
|
* hook blocks WITHOUT judging it: a hook that allows these may simply not be a
|
|
@@ -214,15 +256,7 @@ function formatGuardrailReport(hookCommand, results) {
|
|
|
214
256
|
const blocked = results.filter((r) => r.blocked).length;
|
|
215
257
|
const skipped = results.filter((r) => !r.ran).length;
|
|
216
258
|
const head = `Guardrail coverage for \`${hookCommand}\` β blocks ${blocked}/${results.length} of the dangerous battery`;
|
|
217
|
-
const rows = results.map((r) => {
|
|
218
|
-
// THREE outcomes, not two. "never run" is not a weaker "allows": the harness
|
|
219
|
-
// would not invoke this hook for that call at all, so the guard has no opinion
|
|
220
|
-
// to report. Printing it as `allows` is what made a conditional guard look
|
|
221
|
-
// like it had considered β and permitted β commands it can never see.
|
|
222
|
-
if (!r.ran)
|
|
223
|
-
return ` β not run ${r.event.label} β ${r.reason}`;
|
|
224
|
-
return ` ${r.blocked ? "β
blocks" : "Β· allows"} ${r.event.label}`;
|
|
225
|
-
});
|
|
259
|
+
const rows = results.map((r) => ` ${guardrailRow(r)}`);
|
|
226
260
|
const foot = [
|
|
227
261
|
blocked < results.length
|
|
228
262
|
? "\nAllows β a bug unless this guard is MEANT to block them β gate intent with\nassertBlocksDisasters(cmd, { categories: [...] })."
|