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
|
@@ -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
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Which FILES a shell command hands to a program to execute — the parser-backed
|
|
4
|
+
* answer to "is the guard this hook names even on disk?".
|
|
5
|
+
*
|
|
6
|
+
* 🔴 WHY THIS EXISTS, and it is the loudest lie the sweep could tell. A hook
|
|
7
|
+
* registered as `python3 .claude/hooks/guard.py` whose script is not there does
|
|
8
|
+
* not fail in a way anyone can see: `python3` exits **2** on a file it cannot
|
|
9
|
+
* open, and 2 is Claude Code's DENY code. So `decideHook` reads a block, and
|
|
10
|
+
* `experimental_verifyPluginGuards` reports the hook as blocking every disaster
|
|
11
|
+
* in the battery — a perfect score for a guard that does not exist. Measured on
|
|
12
|
+
* this repo's own build:
|
|
13
|
+
*
|
|
14
|
+
* ```
|
|
15
|
+
* hook `python3 .claude/hooks/guard.py`, script absent
|
|
16
|
+
* → MEASURED blocks=7/7 exits=2,2,2,2,2,2,2
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* No malformed config is needed to reach it — a relative script path is the
|
|
20
|
+
* commonest shape in the wild — which makes it strictly worse than the
|
|
21
|
+
* uncompilable-matcher lie fixed alongside it, and it is the same false
|
|
22
|
+
* confidence the product sells against, produced by the product.
|
|
23
|
+
*
|
|
24
|
+
* THE EXIT CODE CANNOT DECIDE IT, and that is not a guess: `run-script.ts`
|
|
25
|
+
* already writes down the same fact for its own coverage attribution — "`sh
|
|
26
|
+
* <missing>` exits **2** under dash, which is Claude Code's BLOCK code —
|
|
27
|
+
* indistinguishable from a gate legitimately denying, so it cannot be encoded".
|
|
28
|
+
* The shell's own 126/127 catch a program that never launched; nothing catches a
|
|
29
|
+
* program that launched and could not find its script. That is what this module
|
|
30
|
+
* is for, and why the two halves are both needed rather than either alone.
|
|
31
|
+
*
|
|
32
|
+
* ## What counts as a file reference (MEASURED, not assumed)
|
|
33
|
+
*
|
|
34
|
+
* A word is only reported when it is UNAMBIGUOUSLY a script the command runs:
|
|
35
|
+
* fully resolved (no unexpanded `$`, no substitution, no glob, no whitespace),
|
|
36
|
+
* not a flag, not a `NAME=value`, not a URL, not the inline program text of a
|
|
37
|
+
* `-c`/`-e`, and either the head is a known interpreter or the word carries a
|
|
38
|
+
* script extension UNDER A HEAD THAT COULD EXECUTE IT. The OR is what keeps
|
|
39
|
+
* either list from being load-bearing on its own: an interpreter missing from
|
|
40
|
+
* the table is still caught by the extension, and an extensionless script is
|
|
41
|
+
* still caught by the interpreter.
|
|
42
|
+
*
|
|
43
|
+
* The `UNDER A HEAD` clause is the correction: an extension is evidence about
|
|
44
|
+
* the FILE, not about the command, so on its own it read `rm -f /tmp/stale.sh`
|
|
45
|
+
* — an ordinary cleanup hook whose file is meant to be absent — as a missing
|
|
46
|
+
* script. {@link DATA_ONLY_HEADS} is the set of heads that never execute an
|
|
47
|
+
* operand, and it silences the extension branch for them.
|
|
48
|
+
*
|
|
49
|
+
* The narrowing is measured, not taste. Over the 107 hook registrations in
|
|
50
|
+
* `davila7/claude-code-templates` (the corpus the defect was found in):
|
|
51
|
+
*
|
|
52
|
+
* | rule | hits | wrong |
|
|
53
|
+
* | ------------------------------------------- | ---: | ----: |
|
|
54
|
+
* | any path-shaped operand | 31 | 8 |
|
|
55
|
+
* | interpreter head OR script extension | 23 | 0 |
|
|
56
|
+
*
|
|
57
|
+
* The eight the wide rule got wrong are files a hook WRITES or later reads
|
|
58
|
+
* (`rm ~/.claude/session_start.tmp`, `mv ~/.claude/performance.csv`) and one
|
|
59
|
+
* outright absurdity (`echo N/A`, which contains a slash). Reporting those would
|
|
60
|
+
* be crying wolf on hooks that are perfectly fine, and a check read once and
|
|
61
|
+
* distrusted is a check that is off.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ THE `wrong: 0` ROW IS WHAT THE `DATA_ONLY_HEADS` CLAUSE CORRECTS, and the
|
|
64
|
+
* two measurements are of different corpora — say so rather than quoting one
|
|
65
|
+
* number. `rm -f /tmp/stale.sh` is exactly the eighth shape above wearing a
|
|
66
|
+
* script extension, and it was not in the pinned 107. Re-measured 2026-09-08
|
|
67
|
+
* against today's `davila7/claude-code-templates` (134 registrations, 120
|
|
68
|
+
* distinct commands — the repo has moved since the pin, so this is a second
|
|
69
|
+
* sample, not a reproduction of the first):
|
|
70
|
+
*
|
|
71
|
+
* | rule | hits | lost to the clause |
|
|
72
|
+
* | ------------------------------------------- | ---: | -----------------: |
|
|
73
|
+
* | interpreter head OR script extension | 5 | 0 |
|
|
74
|
+
* | …with the `DATA_ONLY_HEADS` clause | 5 | 0 |
|
|
75
|
+
*
|
|
76
|
+
* All five are true positives reached by an INTERPRETER head or a head that is
|
|
77
|
+
* itself a path, so the extension-under-an-unknown-head branch contributes zero
|
|
78
|
+
* hits on this sample and narrowing it costs nothing measurable. That is the
|
|
79
|
+
* evidence the clause does not trade a real detection for a false-positive fix;
|
|
80
|
+
* it is not evidence the branch is dead, which is why it is kept.
|
|
81
|
+
*
|
|
82
|
+
* CONSERVATIVE IN THE OTHER DIRECTION THAN {@link shellVarReads}, on purpose.
|
|
83
|
+
* Over-reporting a missing FILE costs a real guard its measurement AND accuses
|
|
84
|
+
* its author of shipping a broken hook, so here silence is the safe error: a
|
|
85
|
+
* word this module cannot fully resolve is skipped, never guessed at. The
|
|
86
|
+
* missing-program half of the same question is answered after the fact by the
|
|
87
|
+
* shell's 126/127, so a false negative here is not the last line of defence.
|
|
88
|
+
*/
|
|
89
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
90
|
+
exports.commandFileRefs = commandFileRefs;
|
|
91
|
+
const bash_effects_js_1 = require("./bash-effects.js");
|
|
92
|
+
// mvdan-sh is a CJS package (GopherJS build) with no bundled TypeScript types —
|
|
93
|
+
// the same require() `core/shell-vars.ts` and `core/bash-effects.ts` use, for
|
|
94
|
+
// the same parser. The shared thing is the dependency, not the code: this module
|
|
95
|
+
// reads WORDS (expanding what it can), which neither of those two models.
|
|
96
|
+
const _sh = require("mvdan-sh");
|
|
97
|
+
const sh = _sh;
|
|
98
|
+
/**
|
|
99
|
+
* Programs whose job is to RUN A FILE named on their command line. Deliberately
|
|
100
|
+
* small: every entry is a language runtime whose first non-flag operand is a
|
|
101
|
+
* script, and nothing else. It is not a closed set and does not need to be — a
|
|
102
|
+
* runtime missing from it is still reached through {@link SCRIPT_EXTENSION}, and
|
|
103
|
+
* a wrong entry can only cost a measurement, never invent a score.
|
|
104
|
+
*/
|
|
105
|
+
const INTERPRETERS = new Set([
|
|
106
|
+
"sh",
|
|
107
|
+
"bash",
|
|
108
|
+
"dash",
|
|
109
|
+
"zsh",
|
|
110
|
+
"ksh",
|
|
111
|
+
"fish",
|
|
112
|
+
"pwsh",
|
|
113
|
+
"powershell",
|
|
114
|
+
"python",
|
|
115
|
+
"python2",
|
|
116
|
+
"python3",
|
|
117
|
+
"node",
|
|
118
|
+
"deno",
|
|
119
|
+
"bun",
|
|
120
|
+
"ruby",
|
|
121
|
+
"perl",
|
|
122
|
+
"php",
|
|
123
|
+
"lua",
|
|
124
|
+
"Rscript",
|
|
125
|
+
"osascript",
|
|
126
|
+
"tsx",
|
|
127
|
+
"ts-node",
|
|
128
|
+
]);
|
|
129
|
+
/** Extensions that name a script whatever runs it. */
|
|
130
|
+
const SCRIPT_EXTENSION = /\.(sh|bash|zsh|fish|ps1|py|js|cjs|mjs|ts|mts|cts|rb|pl|php|lua|R|applescript|scpt)$/;
|
|
131
|
+
/**
|
|
132
|
+
* Heads whose operands are DATA — they name a file, they never run one.
|
|
133
|
+
*
|
|
134
|
+
* 🔴 THE EXTENSION RULE NEEDS A HEAD, and without one it accused working hooks.
|
|
135
|
+
* `SCRIPT_EXTENSION` is evidence about the FILE ("this is a script"), not about
|
|
136
|
+
* the COMMAND ("this command executes it"), and on its own it reported
|
|
137
|
+
* `rm -f /tmp/stale.sh` — an ordinary cleanup hook — as running a file that is
|
|
138
|
+
* INTENTIONALLY absent. The whole justification for the extension branch is "the
|
|
139
|
+
* head might be an interpreter we did not list", so it must not fire for a head
|
|
140
|
+
* we DID list and know is not one.
|
|
141
|
+
*
|
|
142
|
+
* The reach is deliberately asymmetric with {@link INTERPRETERS}. A missing
|
|
143
|
+
* entry there costs a measurement; a missing entry HERE costs a false
|
|
144
|
+
* accusation, which the module header names as the error it will not make. So
|
|
145
|
+
* the set is generous: `git bisect run g.sh` really does execute its operand and
|
|
146
|
+
* is silenced by the `git` entry, and that is the correct trade — silence is
|
|
147
|
+
* this module's safe error, and the shell's own 127 still answers after the
|
|
148
|
+
* fact.
|
|
149
|
+
*
|
|
150
|
+
* A head that is neither here nor in `INTERPRETERS` is unknown, and the
|
|
151
|
+
* extension rule still speaks for it.
|
|
152
|
+
*/
|
|
153
|
+
const DATA_ONLY_HEADS = new Set([
|
|
154
|
+
// Naming or moving a file, never executing it.
|
|
155
|
+
"rm",
|
|
156
|
+
"mv",
|
|
157
|
+
"cp",
|
|
158
|
+
"ln",
|
|
159
|
+
"touch",
|
|
160
|
+
"mkdir",
|
|
161
|
+
"rmdir",
|
|
162
|
+
"chmod",
|
|
163
|
+
"chown",
|
|
164
|
+
"chgrp",
|
|
165
|
+
"stat",
|
|
166
|
+
"ls",
|
|
167
|
+
"shred",
|
|
168
|
+
"truncate",
|
|
169
|
+
"install",
|
|
170
|
+
"realpath",
|
|
171
|
+
"dirname",
|
|
172
|
+
"basename",
|
|
173
|
+
// Reading or writing its contents.
|
|
174
|
+
"cat",
|
|
175
|
+
"head",
|
|
176
|
+
"tail",
|
|
177
|
+
"echo",
|
|
178
|
+
"printf",
|
|
179
|
+
"tee",
|
|
180
|
+
"wc",
|
|
181
|
+
"sort",
|
|
182
|
+
"uniq",
|
|
183
|
+
"cut",
|
|
184
|
+
"tr",
|
|
185
|
+
"diff",
|
|
186
|
+
"cmp",
|
|
187
|
+
"grep",
|
|
188
|
+
"egrep",
|
|
189
|
+
"fgrep",
|
|
190
|
+
"rg",
|
|
191
|
+
"ag",
|
|
192
|
+
"sed",
|
|
193
|
+
"jq",
|
|
194
|
+
"yq",
|
|
195
|
+
"md5sum",
|
|
196
|
+
"sha256sum",
|
|
197
|
+
// Moving it somewhere else.
|
|
198
|
+
"tar",
|
|
199
|
+
"zip",
|
|
200
|
+
"unzip",
|
|
201
|
+
"gzip",
|
|
202
|
+
"gunzip",
|
|
203
|
+
"scp",
|
|
204
|
+
"rsync",
|
|
205
|
+
"curl",
|
|
206
|
+
"wget",
|
|
207
|
+
"git",
|
|
208
|
+
]);
|
|
209
|
+
/**
|
|
210
|
+
* Flags whose VALUE is not a path — program text (`sh -c '…'`, `node -e '…'`)
|
|
211
|
+
* or a module name (`python3 -m json.tool`). The operand after one of these is
|
|
212
|
+
* something the shell never looks for on disk, so testing it as a file would be
|
|
213
|
+
* a category error. (Most program bodies are already excluded by carrying
|
|
214
|
+
* whitespace; this covers the one-word ones, e.g. `bash -c exit`.)
|
|
215
|
+
*/
|
|
216
|
+
const NON_PATH_FLAGS = new Set([
|
|
217
|
+
"-c",
|
|
218
|
+
"-e",
|
|
219
|
+
"-E",
|
|
220
|
+
"-m",
|
|
221
|
+
"--command",
|
|
222
|
+
"--eval",
|
|
223
|
+
"--module",
|
|
224
|
+
]);
|
|
225
|
+
/**
|
|
226
|
+
* Anything that makes a word something other than one plain filename: shell or
|
|
227
|
+
* glob syntax, whitespace, a quote that survived expansion. A word carrying any
|
|
228
|
+
* of these is not resolvable to a single path here, so it is skipped.
|
|
229
|
+
*/
|
|
230
|
+
const NOT_A_PLAIN_PATH = /[*?[\]{}()$`|;&<>\s'"\\]/;
|
|
231
|
+
/** A word's text with the variables we know expanded, or null when we cannot. */
|
|
232
|
+
function wordText(word, values) {
|
|
233
|
+
let text = "";
|
|
234
|
+
for (const part of word?.Parts ?? []) {
|
|
235
|
+
const kind = sh.syntax.NodeType(part);
|
|
236
|
+
if (kind === "Lit" || kind === "SglQuoted")
|
|
237
|
+
text += part.Value ?? "";
|
|
238
|
+
else if (kind === "DblQuoted") {
|
|
239
|
+
const inner = wordText(part, values);
|
|
240
|
+
if (inner === null)
|
|
241
|
+
return null;
|
|
242
|
+
text += inner;
|
|
243
|
+
}
|
|
244
|
+
else if (kind === "ParamExp") {
|
|
245
|
+
// A parameter whose value we do not know makes the WHOLE word unresolved.
|
|
246
|
+
// Substituting a placeholder would manufacture a path and then report it
|
|
247
|
+
// missing — an accusation built out of our own guess.
|
|
248
|
+
const name = part.Param?.Value;
|
|
249
|
+
const value = name === undefined ? undefined : values[name];
|
|
250
|
+
if (value === undefined)
|
|
251
|
+
return null;
|
|
252
|
+
text += value;
|
|
253
|
+
}
|
|
254
|
+
else
|
|
255
|
+
return null; // command substitution, arithmetic, process subst, …
|
|
256
|
+
}
|
|
257
|
+
return text;
|
|
258
|
+
}
|
|
259
|
+
/** Could this word be a filename at all — before asking whose script it is? */
|
|
260
|
+
function isPlainPath(word) {
|
|
261
|
+
if (word === "")
|
|
262
|
+
return false;
|
|
263
|
+
if (word.startsWith("-"))
|
|
264
|
+
return false;
|
|
265
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word))
|
|
266
|
+
return false;
|
|
267
|
+
if (word.includes("://"))
|
|
268
|
+
return false;
|
|
269
|
+
return !NOT_A_PLAIN_PATH.test(word);
|
|
270
|
+
}
|
|
271
|
+
/** The last path segment, for matching a head like `/usr/bin/python3`. */
|
|
272
|
+
function basename(head) {
|
|
273
|
+
const cut = head.lastIndexOf("/");
|
|
274
|
+
return cut === -1 ? head : head.slice(cut + 1);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* A word this module could not resolve, standing in for it while the wrapper
|
|
278
|
+
* prefix is measured. Chosen so it can be neither a wrapper head, a flag, nor an
|
|
279
|
+
* `env` assignment word: an unresolvable word therefore STOPS the unwrapping
|
|
280
|
+
* rather than being unwrapped through, which is the conservative direction.
|
|
281
|
+
*/
|
|
282
|
+
const UNRESOLVED_WORD = "\u0000";
|
|
283
|
+
/**
|
|
284
|
+
* The script references of one simple command.
|
|
285
|
+
*
|
|
286
|
+
* TWO independent reasons a word is a script, and the difference in how far each
|
|
287
|
+
* reaches is deliberate. A SCRIPT EXTENSION speaks for itself, so any operand
|
|
288
|
+
* carrying one is reported. An INTERPRETER HEAD speaks only for its FIRST
|
|
289
|
+
* non-flag operand — everything after that is the script's own arguments, and
|
|
290
|
+
* reporting `bash hooks/g.sh production` as naming a missing file `production`
|
|
291
|
+
* would accuse a working hook.
|
|
292
|
+
*
|
|
293
|
+
* 🔴 AND THE HEAD IS THE REAL HEAD, NOT THE WRAPPER. `env python3 guard` hands
|
|
294
|
+
* the script to `python3`, but reading `args[0]` finds `env` — not an
|
|
295
|
+
* interpreter — and `guard` carries no extension, so the command was reported as
|
|
296
|
+
* naming NO file. That is the quiet half of this module's own opening: the
|
|
297
|
+
* pre-flight sees a clean command, runs it, python cannot open `guard`, exits 2,
|
|
298
|
+
* and the sweep prints a perfect score for a guard that never ran. Which words
|
|
299
|
+
* are a pass-through wrapper is {@link stripWrappers}'s question and it already
|
|
300
|
+
* answers it for the effect classifier (`env`, `command`, `nice`, `timeout`,
|
|
301
|
+
* `sudo`, `xargs`, `nohup`, recursively, skipping each wrapper's own options and
|
|
302
|
+
* `NAME=value` words) — so this ASKS it rather than keeping a second list that
|
|
303
|
+
* would fall behind the first.
|
|
304
|
+
*
|
|
305
|
+
* ⚠️ NAMED, NOT FIXED: `exec` is a pass-through the shared list does not carry,
|
|
306
|
+
* so `exec python3 guard` still reports nothing here — and `stripWrappers` has
|
|
307
|
+
* its own recorded remainder, an escaped head behind a wrapper (`sudo g\it push`).
|
|
308
|
+
* Adding either belongs in that module, where the safety matcher reads the same
|
|
309
|
+
* list, not in a private copy here.
|
|
310
|
+
*
|
|
311
|
+
* ⚠️ A WRAPPER THAT CHANGES DIRECTORY REPORTS NOTHING. `env -C /elsewhere python3
|
|
312
|
+
* guard` runs `guard` relative to a directory this module was not told about, so
|
|
313
|
+
* every relative operand would be tested against the wrong tree. Saying nothing
|
|
314
|
+
* costs the hook its measurement; naming a path we cannot resolve would accuse a
|
|
315
|
+
* working guard.
|
|
316
|
+
*/
|
|
317
|
+
function leafRefs(allArgs, values, into) {
|
|
318
|
+
// Basenamed for the probe only, because that is the form the shared list keys
|
|
319
|
+
// on (`bash-effects` reduces a head to its basename before asking), so
|
|
320
|
+
// `/usr/bin/env python3 guard` unwraps like the bare spelling. Flags and
|
|
321
|
+
// `NAME=value` words are passed through untouched — they are what tells
|
|
322
|
+
// `stripWrappers` which words a wrapper consumes. The result is used ONLY to
|
|
323
|
+
// count the prefix, so the words themselves are read from `allArgs` after.
|
|
324
|
+
const texts = allArgs.map((w) => {
|
|
325
|
+
const text = wordText(w, values);
|
|
326
|
+
if (text === null)
|
|
327
|
+
return UNRESOLVED_WORD;
|
|
328
|
+
return text.startsWith("-") || text.includes("=") ? text : basename(text);
|
|
329
|
+
});
|
|
330
|
+
const stripped = (0, bash_effects_js_1.stripWrappers)(texts);
|
|
331
|
+
if (stripped.chdir !== null)
|
|
332
|
+
return;
|
|
333
|
+
const args = allArgs.slice(texts.length - stripped.argv.length);
|
|
334
|
+
const head = wordText(args[0], values);
|
|
335
|
+
const headName = head === null ? null : basename(head);
|
|
336
|
+
let interpreterOperand = headName !== null && INTERPRETERS.has(headName) ? "pending" : "no";
|
|
337
|
+
// The extension rule stands in for an interpreter we failed to list, so it
|
|
338
|
+
// speaks only where the head could BE one. A head we know hands its operands
|
|
339
|
+
// to nothing silences it; an unknown head still carries it.
|
|
340
|
+
const headMayExecute = headName === null || !DATA_ONLY_HEADS.has(headName);
|
|
341
|
+
let skipNext = false;
|
|
342
|
+
for (const word of args.slice(1)) {
|
|
343
|
+
const text = wordText(word, values);
|
|
344
|
+
if (skipNext) {
|
|
345
|
+
skipNext = false;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (text !== null && NON_PATH_FLAGS.has(text)) {
|
|
349
|
+
skipNext = true;
|
|
350
|
+
// The interpreter is running program text or a module, not a file: it has
|
|
351
|
+
// no script operand left to name.
|
|
352
|
+
interpreterOperand = "no";
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (text !== null && text.startsWith("-"))
|
|
356
|
+
continue;
|
|
357
|
+
const script = text !== null &&
|
|
358
|
+
isPlainPath(text) &&
|
|
359
|
+
((headMayExecute && SCRIPT_EXTENSION.test(text)) ||
|
|
360
|
+
interpreterOperand === "pending");
|
|
361
|
+
// The first non-flag operand IS the interpreter's script, whether or not we
|
|
362
|
+
// could resolve it — everything after it belongs to the script, not to us.
|
|
363
|
+
if (interpreterOperand === "pending")
|
|
364
|
+
interpreterOperand = "no";
|
|
365
|
+
if (script && text !== null)
|
|
366
|
+
into.add(text);
|
|
367
|
+
}
|
|
368
|
+
// A head that is itself a path (`./hooks/guard.sh`, `"$ROOT"/g.sh`) is a file
|
|
369
|
+
// the shell must find. A BARE head is deliberately not reported: a shell
|
|
370
|
+
// builtin or function (`echo`, `true`, `command`) has no file at all, and
|
|
371
|
+
// calling those missing would flag most hooks in the corpus. The shell's own
|
|
372
|
+
// exit 127 answers that half after the fact.
|
|
373
|
+
if (head !== null && head.includes("/") && !NOT_A_PLAIN_PATH.test(head))
|
|
374
|
+
into.add(head);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* The files `command` would hand to a program to execute.
|
|
378
|
+
*
|
|
379
|
+
* @param command - the shell command, exactly as the hook registers it.
|
|
380
|
+
* @param values - variables whose value is known at run time, for expansion. A
|
|
381
|
+
* word naming anything absent from this map is skipped, not guessed.
|
|
382
|
+
*/
|
|
383
|
+
function commandFileRefs(command, values = {}) {
|
|
384
|
+
let file;
|
|
385
|
+
try {
|
|
386
|
+
file = sh.syntax.NewParser().Parse(command, "hook.sh");
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return { refs: [], parsed: false };
|
|
390
|
+
}
|
|
391
|
+
const refs = new Set();
|
|
392
|
+
sh.syntax.Walk(file, (node) => {
|
|
393
|
+
const kind = sh.syntax.NodeType(node);
|
|
394
|
+
// 🔴 NEVER DESCEND INTO A SUBSTITUTION. `bash $(which guard.sh)` runs
|
|
395
|
+
// `which`, which does not execute `guard.sh` — it prints where it lives. The
|
|
396
|
+
// inner command's operands are the OUTER command's data, so reading them as
|
|
397
|
+
// files it runs invents a reference nobody named. Returning false stops the
|
|
398
|
+
// walk at this node, which is the only reason the callback returns a boolean.
|
|
399
|
+
if (kind === "CmdSubst" || kind === "ProcSubst")
|
|
400
|
+
return false;
|
|
401
|
+
if (kind === "CallExpr" && node.Args)
|
|
402
|
+
leafRefs(node.Args, values, refs);
|
|
403
|
+
return true;
|
|
404
|
+
});
|
|
405
|
+
return { refs: [...refs], parsed: true };
|
|
406
|
+
}
|
|
407
|
+
//# sourceMappingURL=command-files.js.map
|
|
@@ -105,4 +105,54 @@ export interface HookMatcherEntry {
|
|
|
105
105
|
* correct.
|
|
106
106
|
*/
|
|
107
107
|
export declare function hookMatcherIssues(entries: readonly HookMatcherEntry[], declaredServers: readonly string[], dialect: HarnessDialect): HookMatcherFinding[];
|
|
108
|
+
/**
|
|
109
|
+
* How a matcher relates to one tool call — the answer a boolean could not carry.
|
|
110
|
+
*
|
|
111
|
+
* - `"selects"` — the harness spawns the hook for this call.
|
|
112
|
+
* - `"misses"` — it does not, because the matcher names something else.
|
|
113
|
+
* - `"uncompilable"` — it does not, because the harness cannot BUILD the matcher.
|
|
114
|
+
*
|
|
115
|
+
* The last two both mean "the hook does not run", and a caller that only asks
|
|
116
|
+
* `=== "selects"` still lands on the safe answer; they are separate because only
|
|
117
|
+
* the third is a DEFECT worth naming in a report.
|
|
118
|
+
*/
|
|
119
|
+
export type MatcherReach = "selects" | "misses" | "uncompilable";
|
|
120
|
+
/**
|
|
121
|
+
* Would this matcher select a call to `tool` — i.e. does the harness spawn the
|
|
122
|
+
* hook at all?
|
|
123
|
+
*
|
|
124
|
+
* The same two MEASURED facts the module header pins, asked as a question rather
|
|
125
|
+
* than as a defect: on a harness whose matchers are tool names (Claude Code), a
|
|
126
|
+
* matcher with no regex metacharacter is compared by string EQUALITY and one
|
|
127
|
+
* with metacharacters is an UNANCHORED regex. It lives here and not in the
|
|
128
|
+
* caller so those semantics have one home (one-detector-no-drift) —
|
|
129
|
+
* `hookMatcherIssues` judges a matcher, this one applies it.
|
|
130
|
+
*
|
|
131
|
+
* FAIL-OPEN WHERE THE HARNESS IS, AND NOT ONE STEP FURTHER. An absent matcher or
|
|
132
|
+
* a match-all really does select every tool, so answering `"selects"` there
|
|
133
|
+
* states a fact — the same direction `decideHookCondition`
|
|
134
|
+
* (`core/hook-condition.ts`) fails open, and for the same reason it gives: where
|
|
135
|
+
* Claude Code cannot tell, it RUNS the hook, so mirroring it can only ever add a
|
|
136
|
+
* run, never invent a skip.
|
|
137
|
+
*
|
|
138
|
+
* 🔴 THAT REASONING DOES NOT REACH AN UNCOMPILABLE MATCHER, and this function
|
|
139
|
+
* used to apply it there anyway. `Bash(` is what the `invalid-regex` finding
|
|
140
|
+
* above already reports as "the harness can't compile it, so the hook never
|
|
141
|
+
* fires" — the harness fails CLOSED. Answering `"selects"` therefore does not
|
|
142
|
+
* add a run the harness makes, it MANUFACTURES one: a caller feeds the hook a
|
|
143
|
+
* battery it would never have been handed, and an unconditional-deny body scores
|
|
144
|
+
* a full pass for a hook that cannot run. That is the false-confidence class
|
|
145
|
+
* this module exists to remove, so an uncompilable matcher gets its own answer
|
|
146
|
+
* and the caller declines to score it.
|
|
147
|
+
*
|
|
148
|
+
* @param matcher - the registration's matcher, or `null` when it declares none.
|
|
149
|
+
* @param tool - the tool named by the call, e.g. `"Bash"`.
|
|
150
|
+
* @param style - the active harness's `HookProtocol.matcherStyle`. `"exact"`
|
|
151
|
+
* (the default, Claude Code) applies the literal-equality rule above;
|
|
152
|
+
* `"regex"` (Codex) compiles EVERY matcher, so `ash` matches `Bash` and the
|
|
153
|
+
* glob spellings `*` / `**` — which are Claude Code's documented match-all,
|
|
154
|
+
* not regexes — come back `"uncompilable"` rather than being assumed to be
|
|
155
|
+
* special-cased by a harness nobody measured.
|
|
156
|
+
*/
|
|
157
|
+
export declare function hookMatcherReach(matcher: string | null, tool: string, style?: "exact" | "regex"): MatcherReach;
|
|
108
158
|
//# sourceMappingURL=hook-matcher.d.ts.map
|