@marcomigozzi/trailhead 0.14.0 → 0.15.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.it.md +118 -30
- package/README.md +107 -29
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +1 -1
- package/bin/lib/codex-projection.test.js +41 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +341 -1
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +38 -6
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +168 -15
- package/plugins/trailhead/hooks/mod/map-pane.tsx +275 -106
- package/plugins/trailhead/hooks/mod/register.tsx +15 -2
- package/plugins/trailhead/hooks/mod/work-state-view.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/skills/_shared/choices.md +4 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
- package/plugins/trailhead/skills/_shared/substrate.md +6 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +16 -7
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +14 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -4
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +26 -10
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/map-pane-ui.test.ts +566 -19
- package/plugins/trailhead/tests/map-pane-view.test.ts +304 -16
- package/plugins/trailhead/tests/work-state-view.test.ts +25 -1
- package/plugins/trailhead/types/index.d.ts +37 -3
|
@@ -66,6 +66,23 @@ const stableJson = (value: unknown): string => {
|
|
|
66
66
|
return `{${keys.map(k => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// The state belongs to this session (a state with no session counts as its own, like the orphan rule).
|
|
70
|
+
const isOwn = (state: WorkState | null, session: string): boolean =>
|
|
71
|
+
state !== null && (state.session === null || state.session === session)
|
|
72
|
+
|
|
73
|
+
// The ticket this session owns across a work-state file shared with other sessions. A state it wrote
|
|
74
|
+
// sets it; one another session wrote leaves it; a missing file clears it only when the last state read
|
|
75
|
+
// was this session's (its own Resolve), so another session's cleanup never drops the mark.
|
|
76
|
+
export const nextOwnTicket = (
|
|
77
|
+
prev: number | null,
|
|
78
|
+
lastState: WorkState | null,
|
|
79
|
+
nextState: WorkState | null,
|
|
80
|
+
session: string,
|
|
81
|
+
): number | null => {
|
|
82
|
+
if (nextState !== null) return isOwn(nextState, session) ? nextState.ticket : prev
|
|
83
|
+
return isOwn(lastState, session) ? null : prev
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
export const sameValue = (a: unknown, b: unknown): boolean => stableJson(a) === stableJson(b)
|
|
70
87
|
|
|
71
88
|
export const statusLine = (state: WorkState | null, isOrphaned: boolean): string | undefined => {
|
|
@@ -8,14 +8,28 @@
|
|
|
8
8
|
// package age, download counts, repo metadata) and checkpoint the human on a
|
|
9
9
|
// suspicious result (block-and-ask). A vetted install proceeds by re-running the
|
|
10
10
|
// command prefixed with the sentinel `TRAILHEAD_VERIFIED_INSTALL=1`.
|
|
11
|
-
// Only a NAMED install is gated:
|
|
12
|
-
// `npm ci`, `
|
|
13
|
-
//
|
|
11
|
+
// Only a NAMED install is gated: installs with no typed name (bare `npm install`,
|
|
12
|
+
// `npm ci`, `install --frozen-lockfile` / `--immutable` with no package, `pip install -r`,
|
|
13
|
+
// `cargo build`, `go mod download`, `bundle install`) and local paths / URLs /
|
|
14
|
+
// VCS refs carry no typed name and pass through. A file-like name (`foo.txt`,
|
|
15
|
+
// `x.tgz`) passes only when it is a path (`./x`, `sub/x`, `/x`, `~/x`) or exists
|
|
16
|
+
// on disk (relative to the command's cwd); otherwise it is a typed name and is gated.
|
|
17
|
+
// The on-disk check resolves against the hook's cwd; it does not follow a
|
|
18
|
+
// preceding `cd` in the same command. Command wrappers (`sudo`, `time`, `env`,
|
|
19
|
+
// `nohup`, `xargs`, `timeout`, `nice`, `stdbuf`, `setsid`, `ionice`, `doas`,
|
|
20
|
+
// `command`, `exec`) are peeled, with their own flags, so `sudo npm install foo`
|
|
21
|
+
// is gated like the bare form. Parsing is quote-aware (shared
|
|
22
|
+
// lib/shell-scan.js): text inside quotes or a heredoc (`echo "x; npm i foo"`) is
|
|
23
|
+
// never a command, and redirections (`2>&1`, `> log`) are never package names.
|
|
14
24
|
// Blocks with exit 2 + {"decision":"block",...}; allows everything else.
|
|
15
25
|
// Crash-safe: any error → exit 0 (never wedge the user's workflow).
|
|
16
|
-
// Self-contained
|
|
26
|
+
// Self-contained (its only dependency is the sibling lib/ module). Pure functions (incl. evaluate) exported for tests
|
|
17
27
|
// and the merged trailhead-bash-guards.js; runs as a hook when executed directly.
|
|
18
28
|
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const { basename, splitStatements, tokenize, stripQuotes, stripRedirections } = require('./lib/shell-scan.js');
|
|
32
|
+
|
|
19
33
|
const SENTINEL = /(?:^|\s)TRAILHEAD_VERIFIED_INSTALL=1(?:\s|$)/;
|
|
20
34
|
|
|
21
35
|
// Per-ecosystem: the binaries that own it, and the install verbs that fetch a
|
|
@@ -37,22 +51,129 @@ const VALUE_FLAGS = new Set([
|
|
|
37
51
|
'-t', '--target', '--prefix', '--root', '--python', '-d', '--dest',
|
|
38
52
|
'-w', '--workspace', '--registry', '-C', '--directory', '--chdir',
|
|
39
53
|
'--no-binary', '--only-binary',
|
|
54
|
+
'--filter', '-F', // pnpm workspace selector: its value is a project, not a package
|
|
40
55
|
]);
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
// Command wrappers that run the rest of the line as a command, mapped to the
|
|
58
|
+
// flags that consume a SEPARATE-TOKEN value (so it is not read as the command).
|
|
59
|
+
// Attached forms (`-uroot`, `--user=root`, GNU xargs `-i`/`-e`/`-l`) need no entry.
|
|
60
|
+
const WRAPPERS = {
|
|
61
|
+
sudo: new Set([
|
|
62
|
+
'-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--close-from',
|
|
63
|
+
'-U', '--other-user', '-r', '--role', '-t', '--type', '-D', '--chdir', '-R', '--chroot',
|
|
64
|
+
'-T', '--command-timeout',
|
|
65
|
+
]),
|
|
66
|
+
env: new Set(['-u', '--unset', '-C', '--chdir', '-a', '--argv0']), // -S handled apart
|
|
67
|
+
time: new Set(['-f', '--format', '-o', '--output']),
|
|
68
|
+
nohup: new Set(),
|
|
69
|
+
xargs: new Set([
|
|
70
|
+
'-n', '--max-args', '-I', '-a', '--arg-file', '-d', '--delimiter', '-E', '-L', '--max-lines',
|
|
71
|
+
'-P', '--max-procs', '-s', '--max-chars',
|
|
72
|
+
]),
|
|
73
|
+
timeout: new Set(['-s', '--signal', '-k', '--kill-after']), // + one DURATION operand
|
|
74
|
+
nice: new Set(['-n', '--adjustment']),
|
|
75
|
+
stdbuf: new Set(['-i', '-o', '-e', '--input', '--output', '--error']),
|
|
76
|
+
setsid: new Set(),
|
|
77
|
+
ionice: new Set(['-c', '--class', '-n', '--classdata']),
|
|
78
|
+
doas: new Set(['-a', '-C', '-u']),
|
|
79
|
+
command: new Set(), // -v / -V only look a name up: see isLookupFlag
|
|
80
|
+
exec: new Set(['-a']),
|
|
81
|
+
};
|
|
82
|
+
const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
83
|
+
|
|
84
|
+
// How a wrapper flag consumes its value. Returns { next } (value is the NEXT
|
|
85
|
+
// token), { attached } (value is inside this token) or {} (flagless). A short
|
|
86
|
+
// cluster is read like getopt: the first value-taking letter ends it, so `-nu root`
|
|
87
|
+
// takes the next token and `-uroot` carries its value attached.
|
|
88
|
+
function flagValue(f, valueFlags) {
|
|
89
|
+
if (f.startsWith('--')) {
|
|
90
|
+
const eq = f.indexOf('=');
|
|
91
|
+
if (eq !== -1) return { attached: f.slice(eq + 1) };
|
|
92
|
+
return valueFlags.has(f) ? { next: true } : {};
|
|
93
|
+
}
|
|
94
|
+
for (let k = 1; k < f.length; k++) {
|
|
95
|
+
if (!valueFlags.has('-' + f[k])) continue;
|
|
96
|
+
return k === f.length - 1 ? { next: true } : { attached: f.slice(k + 1) };
|
|
97
|
+
}
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `env -S` / `--split-string`: its payload IS the command line, not a flag value.
|
|
102
|
+
function isSplitFlag(f) {
|
|
103
|
+
if (f.startsWith('--')) return f === '--split-string' || f.startsWith('--split-string=');
|
|
104
|
+
for (let k = 1; k < f.length; k++) {
|
|
105
|
+
if (f[k] === 'S') return true;
|
|
106
|
+
if ('uCa'.includes(f[k])) return false; // value letter first: S is part of its value
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// `command -v` / `-V` (also inside a cluster like `-pv`) only describe a name and
|
|
112
|
+
// execute nothing, so nothing follows that can be an install.
|
|
113
|
+
function isLookupFlag(f) {
|
|
114
|
+
return !f.startsWith('--') && /[vV]/.test(f);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The text of an `env -S` payload token. A double-quoted payload (outer quotes
|
|
118
|
+
// stripped) also loses its bash escapes (`\$`, `\``, `\"`, `\\`); single-quoted
|
|
119
|
+
// text is literal. Local on purpose: the shared stripQuotes is left as is.
|
|
120
|
+
function payloadText(payload) {
|
|
121
|
+
const p = payload || '';
|
|
122
|
+
const stripped = stripQuotes(p);
|
|
123
|
+
const dq = p.length >= 2 && p.startsWith('"') && p.endsWith('"');
|
|
124
|
+
return dq ? stripped.replace(/\\([$`"\\])/g, '$1') : stripped;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The real command, past leading `NAME=value` assignments and any (nested) wrapper
|
|
128
|
+
// with its flags. Returns { toks, i }: toks may be rewritten when an `env -S`
|
|
129
|
+
// payload is re-tokenized; i is toks.length when nothing is left.
|
|
130
|
+
function skipWrappers(input) {
|
|
131
|
+
let toks = input;
|
|
132
|
+
let i = 0;
|
|
133
|
+
for (;;) {
|
|
134
|
+
while (i < toks.length && ENV_ASSIGN.test(toks[i])) i++;
|
|
135
|
+
const wrapper = basename(toks[i] || '');
|
|
136
|
+
const valueFlags = WRAPPERS[wrapper];
|
|
137
|
+
if (!valueFlags) return { toks, i };
|
|
138
|
+
i++; // past the wrapper
|
|
139
|
+
while (i < toks.length && toks[i].startsWith('-')) {
|
|
140
|
+
const f = toks[i++];
|
|
141
|
+
if (f === '--') break; // end of the wrapper's options
|
|
142
|
+
if (wrapper === 'env' && isSplitFlag(f)) {
|
|
143
|
+
const eq = f.indexOf('=');
|
|
144
|
+
const sIdx = f.indexOf('S');
|
|
145
|
+
let payload;
|
|
146
|
+
if (f.startsWith('--')) payload = eq !== -1 ? f.slice(eq + 1) : toks[i++];
|
|
147
|
+
else payload = sIdx === f.length - 1 ? toks[i++] : f.slice(sIdx + 1);
|
|
148
|
+
toks = [...tokenize(payloadText(payload)), ...toks.slice(i)];
|
|
149
|
+
i = 0; // the payload and the rest now form the command line
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
if (wrapper === 'command' && isLookupFlag(f)) return { toks, i: toks.length };
|
|
153
|
+
if (flagValue(f, valueFlags).next) i++; // consume its separate-token value
|
|
154
|
+
}
|
|
155
|
+
if (wrapper === 'timeout' && i < toks.length) i++; // the DURATION operand
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A file-like name is local only when it is a path or exists on disk. A leading
|
|
160
|
+
// `@` is a scoped npm name, never a path.
|
|
161
|
+
function isLocalFile(t, opts) {
|
|
162
|
+
if (t.startsWith('@')) return false;
|
|
163
|
+
if (t.includes('/')) return true;
|
|
164
|
+
const exists = (opts && opts.exists) || fs.existsSync;
|
|
165
|
+
return exists(path.resolve((opts && opts.cwd) || process.cwd(), t));
|
|
45
166
|
}
|
|
46
167
|
|
|
47
168
|
// A positional that is NOT a public-registry name: local path, url, vcs ref, or a
|
|
48
169
|
// file artifact. Go module paths (example.com/foo) look url-ish but ARE the named
|
|
49
170
|
// module, so for go only a leading . / ~ disqualifies.
|
|
50
|
-
function isLocalOrUrl(t, ecosystem) {
|
|
171
|
+
function isLocalOrUrl(t, ecosystem, opts) {
|
|
51
172
|
if (t.startsWith('.') || t.startsWith('/') || t.startsWith('~')) return true;
|
|
52
173
|
if (ecosystem === 'go') return false; // a bare module path is the name; gate it
|
|
53
174
|
if (t.includes('://')) return true;
|
|
54
175
|
if (/^(?:git\+|file:|https?:|hg\+|svn\+|bzr\+)/.test(t)) return true;
|
|
55
|
-
if (/\.(?:tgz|tar\.gz|whl|gz|zip|txt|toml|lock|cfg)$/.test(t)) return
|
|
176
|
+
if (/\.(?:tgz|tar\.gz|whl|gz|zip|txt|toml|lock|cfg)$/.test(t)) return isLocalFile(t, opts);
|
|
56
177
|
return false;
|
|
57
178
|
}
|
|
58
179
|
|
|
@@ -70,12 +191,29 @@ function bareName(t) {
|
|
|
70
191
|
return n;
|
|
71
192
|
}
|
|
72
193
|
|
|
194
|
+
// Positional package names among the arguments after the verb (redirections
|
|
195
|
+
// already removed). Quotes are stripped so `"lodash"` is the name `lodash`.
|
|
196
|
+
function collectPackages(args, ecosystem, opts) {
|
|
197
|
+
const packages = [];
|
|
198
|
+
for (let j = 0; j < args.length; j++) {
|
|
199
|
+
const t = args[j];
|
|
200
|
+
if (t.startsWith('-')) {
|
|
201
|
+
if (VALUE_FLAGS.has(t)) j++; // consume its separate-token value
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const name = stripQuotes(t);
|
|
205
|
+
if (!name || name === '&' || isLocalOrUrl(name, ecosystem, opts)) continue;
|
|
206
|
+
const bare = bareName(name);
|
|
207
|
+
if (bare) packages.push(bare);
|
|
208
|
+
}
|
|
209
|
+
return packages;
|
|
210
|
+
}
|
|
211
|
+
|
|
73
212
|
// Detect a public-registry install that NAMES at least one package, in a single
|
|
74
|
-
// shell
|
|
75
|
-
function detectSegment(
|
|
76
|
-
const toks =
|
|
77
|
-
let i =
|
|
78
|
-
while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
|
|
213
|
+
// shell statement. Returns { ecosystem, packages: [names] } or null.
|
|
214
|
+
function detectSegment(stmt, opts) {
|
|
215
|
+
const { toks, i: start } = skipWrappers(tokenize(stmt));
|
|
216
|
+
let i = start; // env prefix + sudo/time/env/nohup/xargs
|
|
79
217
|
if (i >= toks.length) return null;
|
|
80
218
|
|
|
81
219
|
const bin = basename(toks[i]);
|
|
@@ -102,27 +240,21 @@ function detectSegment(cmd) {
|
|
|
102
240
|
if (!ECOSYSTEMS[ecosystem].verbs.includes(verb)) return null;
|
|
103
241
|
i++;
|
|
104
242
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (VALUE_FLAGS.has(t)) j++; // consume its separate-token value
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (isLocalOrUrl(t, ecosystem)) continue;
|
|
113
|
-
packages.push(bareName(t));
|
|
114
|
-
}
|
|
243
|
+
const args = stripRedirections(toks.slice(i));
|
|
244
|
+
// A lockfile install (`--frozen-lockfile`, `--immutable`) carries no positional,
|
|
245
|
+
// so it yields no package here; a flag never excuses a name typed beside it.
|
|
246
|
+
const packages = collectPackages(args, ecosystem, opts);
|
|
115
247
|
if (packages.length === 0) return null; // lockfile/manifest install: nothing typed
|
|
116
248
|
return { ecosystem, packages };
|
|
117
249
|
}
|
|
118
250
|
|
|
119
251
|
// Agents chain commands (`cd app && npm install foo`), so check every shell
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// Returns the first
|
|
123
|
-
function detectInstall(cmd) {
|
|
124
|
-
for (const
|
|
125
|
-
const hit = detectSegment(
|
|
252
|
+
// statement, not just the first. splitStatements is quote- and heredoc-aware, so
|
|
253
|
+
// a separator or install text inside quotes / a heredoc body is not a statement.
|
|
254
|
+
// Returns the first statement's hit, or null.
|
|
255
|
+
function detectInstall(cmd, opts) {
|
|
256
|
+
for (const stmt of splitStatements(cmd)) {
|
|
257
|
+
const hit = detectSegment(stmt, opts);
|
|
126
258
|
if (hit) return hit;
|
|
127
259
|
}
|
|
128
260
|
return null;
|
|
@@ -147,10 +279,10 @@ function blockVerdict(hit) {
|
|
|
147
279
|
|
|
148
280
|
// Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
|
|
149
281
|
// exits or writes; any internal error falls through to allow.
|
|
150
|
-
function evaluate(cmd) {
|
|
282
|
+
function evaluate(cmd, opts) {
|
|
151
283
|
try {
|
|
152
284
|
if (isVerified(cmd)) return { kind: 'allow' }; // agent has vetted these
|
|
153
|
-
const hit = detectInstall(cmd);
|
|
285
|
+
const hit = detectInstall(cmd, { cwd: opts && opts.cwd });
|
|
154
286
|
if (hit) return blockVerdict(hit);
|
|
155
287
|
} catch {
|
|
156
288
|
// fall through to allow: a guard must never wedge the workflow
|
|
@@ -160,12 +292,15 @@ function evaluate(cmd) {
|
|
|
160
292
|
|
|
161
293
|
function run(data) {
|
|
162
294
|
let cmd = '';
|
|
295
|
+
let cwd;
|
|
163
296
|
try {
|
|
164
|
-
|
|
297
|
+
const input = JSON.parse(data);
|
|
298
|
+
cmd = (input.tool_input || {}).command || '';
|
|
299
|
+
cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
|
|
165
300
|
} catch {
|
|
166
301
|
process.exit(0); // unparseable input: nothing to gate
|
|
167
302
|
}
|
|
168
|
-
const v = evaluate(cmd);
|
|
303
|
+
const v = evaluate(cmd, { cwd });
|
|
169
304
|
if (v.kind === 'block') {
|
|
170
305
|
process.stdout.write(JSON.stringify(v.payload));
|
|
171
306
|
process.exit(2);
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
const assert = require('assert');
|
|
5
5
|
const { execFileSync } = require('child_process');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
7
9
|
|
|
8
10
|
// TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
|
|
9
11
|
const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-install-guard.js');
|
|
10
|
-
const { detectInstall, isVerified } = require('./trailhead-install-guard.js');
|
|
12
|
+
const { detectInstall, isVerified, evaluate } = require('./trailhead-install-guard.js');
|
|
11
13
|
|
|
12
14
|
let passed = 0;
|
|
13
15
|
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
@@ -86,6 +88,176 @@ ok('detects install after ; chain', (() => { const d = detectInstall('echo hi; p
|
|
|
86
88
|
ok('detects install in a piped tail', (() => { const d = detectInstall('true | cargo add serde'); return d && d.packages.includes('serde'); })());
|
|
87
89
|
ok('compound with no install stays null', detectInstall('cd app && npm run build && ls') === null);
|
|
88
90
|
|
|
91
|
+
// --- unit: lockfile installs, redirections, quoted text (#239) ---
|
|
92
|
+
const heredocDoc = ['cat > /tmp/x.md <<\'EOF\'', '| `pnpm install a 2>&1` | x |', 'EOF'].join('\n');
|
|
93
|
+
const neg239 = [
|
|
94
|
+
'pnpm install --frozen-lockfile --offline 2>&1',
|
|
95
|
+
'cd app && pnpm install --frozen-lockfile --offline 2>&1 | tail -5',
|
|
96
|
+
'pnpm install --frozen-lockfile > /tmp/log',
|
|
97
|
+
'pnpm install --frozen-lockfile --filter web',
|
|
98
|
+
'pnpm install --frozen-lockfile',
|
|
99
|
+
'yarn install --immutable',
|
|
100
|
+
'bun install --frozen-lockfile',
|
|
101
|
+
'yarn install --frozen-lockfile',
|
|
102
|
+
'npm install 2>&1',
|
|
103
|
+
'echo "done; pnpm install lodash"',
|
|
104
|
+
'echo "a && npm install foo"',
|
|
105
|
+
'echo "next: pnpm install then build"',
|
|
106
|
+
'git commit -m "fix: pnpm install guard | npm i x"',
|
|
107
|
+
heredocDoc,
|
|
108
|
+
];
|
|
109
|
+
for (const cmd of neg239) {
|
|
110
|
+
ok(`ignores: ${cmd.split('\n')[0]}`, detectInstall(cmd) === null);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Redirections and quoting must not hide a real named install, nor leak into the name.
|
|
114
|
+
const pos239 = [
|
|
115
|
+
['npm install lodash 2>&1', 'npm', ['lodash']],
|
|
116
|
+
['pnpm add vite > /tmp/log', 'npm', ['vite']],
|
|
117
|
+
['npm install "lodash"', 'npm', ['lodash']],
|
|
118
|
+
['echo ok && npm install lodash', 'npm', ['lodash']],
|
|
119
|
+
['echo ok; pip install requests', 'pip', ['requests']],
|
|
120
|
+
['pnpm add --filter web vite', 'npm', ['vite']],
|
|
121
|
+
];
|
|
122
|
+
for (const [cmd, eco, names] of pos239) {
|
|
123
|
+
const d = detectInstall(cmd);
|
|
124
|
+
ok(`detects exactly: ${cmd}`, d && d.ecosystem === eco && JSON.stringify(d.packages) === JSON.stringify(names));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A lockfile flag never excuses a typed name: the named package is still gated.
|
|
128
|
+
const posLock = [
|
|
129
|
+
['npm i --frozen-lockfile evilpkg', 'evilpkg'],
|
|
130
|
+
['pnpm install --frozen-lockfile evilpkg', 'evilpkg'],
|
|
131
|
+
['yarn install --immutable evilpkg', 'evilpkg'],
|
|
132
|
+
['npm install evilpkg --frozen', 'evilpkg'],
|
|
133
|
+
];
|
|
134
|
+
for (const [cmd, name] of posLock) {
|
|
135
|
+
const d = detectInstall(cmd);
|
|
136
|
+
ok(`detects despite lockfile flag: ${cmd}`, d && d.packages.includes(name));
|
|
137
|
+
const r = runHook(cmd);
|
|
138
|
+
ok(`blocks despite lockfile flag: ${cmd}`, r.code === 2 && new RegExp(name).test(r.out));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A bare `&` (background) is never listed as a package name.
|
|
142
|
+
ok('background & is not a package', JSON.stringify((detectInstall('npm install lodash &') || {}).packages) === JSON.stringify(['lodash']));
|
|
143
|
+
|
|
144
|
+
// --- unit: wrapped installs and file-like names (#255) ---
|
|
145
|
+
const posWrap = [
|
|
146
|
+
['sudo npm install lodash', 'lodash'],
|
|
147
|
+
['time pip install requests', 'requests'],
|
|
148
|
+
['env FOO=1 npm i lodash', 'lodash'],
|
|
149
|
+
['nohup npm install lodash', 'lodash'],
|
|
150
|
+
['xargs npm install lodash', 'lodash'],
|
|
151
|
+
['sudo -u bob npm install lodash', 'lodash'], // flag with a separate-token value
|
|
152
|
+
['sudo -- npm i lodash', 'lodash'],
|
|
153
|
+
['sudo -E npm i lodash', 'lodash'],
|
|
154
|
+
['env -u HOME npm i lodash', 'lodash'],
|
|
155
|
+
['env -C /tmp FOO=1 npm i lodash', 'lodash'],
|
|
156
|
+
['time -p pip install requests', 'requests'],
|
|
157
|
+
['time -o /tmp/t pip install requests', 'requests'],
|
|
158
|
+
['xargs -n 1 npm install lodash', 'lodash'],
|
|
159
|
+
['xargs -I {} pip install requests', 'requests'],
|
|
160
|
+
['xargs --max-args=1 npm i lodash', 'lodash'],
|
|
161
|
+
['sudo env FOO=1 nohup npm i lodash', 'lodash'], // chain
|
|
162
|
+
['FOO=1 sudo -u root time -p pip install requests', 'requests'],
|
|
163
|
+
['cd app && sudo npm install lodash', 'lodash'],
|
|
164
|
+
['/usr/bin/sudo npm install lodash', 'lodash'],
|
|
165
|
+
['sudo -nu root npm i lodash', 'lodash'], // bundled short flags, value flag last
|
|
166
|
+
['sudo -Eu bob npm i lodash', 'lodash'],
|
|
167
|
+
['sudo -iu root npm i lodash', 'lodash'],
|
|
168
|
+
['sudo -uroot npm i lodash', 'lodash'], // attached value
|
|
169
|
+
['sudo -nE npm i lodash', 'lodash'], // cluster with no value flag
|
|
170
|
+
['env -S "npm i lodash"', 'lodash'], // split-string payload is the command
|
|
171
|
+
['env -S npm i lodash', 'lodash'],
|
|
172
|
+
["env -S 'npm i lodash'", 'lodash'],
|
|
173
|
+
['env --split-string="npm i lodash"', 'lodash'],
|
|
174
|
+
['env --split-string npm i lodash', 'lodash'],
|
|
175
|
+
['env -a name npm i lodash', 'lodash'], // argv0 value, command follows
|
|
176
|
+
['env -S "FOO=1 sudo npm i lodash"', 'lodash'],
|
|
177
|
+
];
|
|
178
|
+
for (const [cmd, name] of posWrap) {
|
|
179
|
+
const d = detectInstall(cmd);
|
|
180
|
+
ok(`detects wrapped: ${cmd}`, d && d.packages.includes(name));
|
|
181
|
+
}
|
|
182
|
+
const negWrap = [
|
|
183
|
+
'sudo npm install',
|
|
184
|
+
'sudo npm ci',
|
|
185
|
+
'time npm install --frozen-lockfile',
|
|
186
|
+
'env FOO=1 npm install',
|
|
187
|
+
'xargs echo npm install foo', // xargs runs echo
|
|
188
|
+
'sudo -u bob ls',
|
|
189
|
+
'sudo',
|
|
190
|
+
'env',
|
|
191
|
+
'sudo -u',
|
|
192
|
+
'sudo -nu root ls',
|
|
193
|
+
'env -S "ls -la"',
|
|
194
|
+
'env -S "npm install"',
|
|
195
|
+
];
|
|
196
|
+
for (const cmd of negWrap) {
|
|
197
|
+
ok(`ignores wrapped: ${cmd}`, detectInstall(cmd) === null);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A file-like suffix is local only for a path or an existing file.
|
|
201
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'install-guard-'));
|
|
202
|
+
fs.writeFileSync(path.join(tmp, 'foo.tgz'), '');
|
|
203
|
+
const inTmp = { cwd: tmp };
|
|
204
|
+
const posFile = ['pip install foo.txt', 'npm install left-pad.toml', 'npm install nope.tgz', 'pip install pkg.whl'];
|
|
205
|
+
for (const cmd of posFile) {
|
|
206
|
+
const d = detectInstall(cmd, inTmp);
|
|
207
|
+
ok(`gates absent file-like name: ${cmd}`, d && d.packages.length === 1);
|
|
208
|
+
}
|
|
209
|
+
const negFile = [
|
|
210
|
+
'pip install ./foo.txt',
|
|
211
|
+
'pip install sub/req.txt',
|
|
212
|
+
'pip install /abs/req.txt',
|
|
213
|
+
'pip install ~/req.txt',
|
|
214
|
+
'npm install foo.tgz', // exists in cwd
|
|
215
|
+
];
|
|
216
|
+
for (const cmd of negFile) {
|
|
217
|
+
ok(`allows path or existing file: ${cmd}`, detectInstall(cmd, inTmp) === null);
|
|
218
|
+
}
|
|
219
|
+
ok('existing file resolved against cwd, not process cwd', detectInstall('npm install foo.tgz', { cwd: os.tmpdir() }) !== null);
|
|
220
|
+
ok('injectable exists check', detectInstall('npm install x.tgz', { exists: () => true }) === null);
|
|
221
|
+
ok('scoped name is never a path', (() => { const d = detectInstall('npm install @scope/pkg.zip', inTmp); return d && d.packages[0] === '@scope/pkg.zip'; })());
|
|
222
|
+
ok('evaluate tolerates null opts', evaluate('npm install lodash', null).kind === 'block');
|
|
223
|
+
const wf = evaluate('npm install foo.tgz', { cwd: tmp });
|
|
224
|
+
ok('evaluate threads cwd (existing file allowed)', wf.kind === 'allow');
|
|
225
|
+
ok('evaluate blocks absent file-like name', evaluate('pip install foo.txt', { cwd: tmp }).kind === 'block');
|
|
226
|
+
const wrapRun = runHook('sudo npm install lodash');
|
|
227
|
+
ok('blocks a sudo-wrapped install end-to-end (exit 2)', wrapRun.code === 2 && /lodash/.test(wrapRun.out));
|
|
228
|
+
const cwdRun = (() => {
|
|
229
|
+
const input = JSON.stringify({ cwd: tmp, tool_input: { command: 'npm install foo.tgz' } });
|
|
230
|
+
try { return { code: 0, out: execFileSync('node', [HOOK], { input }).toString() }; } catch (e) { return { code: e.status }; }
|
|
231
|
+
})();
|
|
232
|
+
ok('hook honours input.cwd for the on-disk check (exit 0)', cwdRun.code === 0);
|
|
233
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
234
|
+
|
|
235
|
+
// Wrappers peeled with their flags: timeout (+ DURATION), nice, stdbuf, setsid, ionice, doas, command, exec.
|
|
236
|
+
const wrapHits = [
|
|
237
|
+
'timeout 10 npm i x', 'timeout -s KILL 10 npm i x', 'timeout --signal=KILL 5s npm i x',
|
|
238
|
+
'timeout -k 1 10 npm i x', 'nice npm i x', 'nice -n 5 npm i x', 'nice -n5 npm i x', 'nice -5 npm i x',
|
|
239
|
+
'command npm i x', 'command -p npm i x', 'exec npm i x', 'exec -a foo npm i x',
|
|
240
|
+
'doas npm i x', 'doas -u root npm i x', 'setsid npm i x', 'setsid -w npm i x',
|
|
241
|
+
'ionice -c 3 npm i x', 'stdbuf -oL npm i x', 'stdbuf -o L npm i x',
|
|
242
|
+
'sudo timeout 10 nice npm i x', 'xargs timeout 10 npm i x', 'timeout -- 10 npm i x',
|
|
243
|
+
'env -S "env -S \\"npm i lodash\\""',
|
|
244
|
+
'env -S "env -S \\"env -S \\\\\\"npm i lodash\\\\\\"\\""',
|
|
245
|
+
"env -S 'npm i lodash'",
|
|
246
|
+
];
|
|
247
|
+
for (const c of wrapHits) ok(`wrapper hit: ${c}`, detectInstall(c) && detectInstall(c).packages.length > 0);
|
|
248
|
+
const wrapMisses = [
|
|
249
|
+
'timeout 10 ls', 'nice ls', 'command -v npm', 'command -v npm i x', 'command -V npm i x',
|
|
250
|
+
'command -pv npm i x', 'exec 3>&1', 'exec -a npm ls', 'doas ls',
|
|
251
|
+
"env -S 'echo \"npm i lodash\"'", 'env -S "echo \\"npm i lodash\\""',
|
|
252
|
+
];
|
|
253
|
+
for (const c of wrapMisses) ok(`wrapper miss: ${c}`, detectInstall(c) === null);
|
|
254
|
+
|
|
255
|
+
// Known unmodelled gaps (documented, NOT asserted): this guard reads one plain
|
|
256
|
+
// command per statement plus the wrappers it peels, so an install behind
|
|
257
|
+
// `bash -c "..."` / `sh -c "..."`, `$( )`, `eval`, other wrappers (`chrt`,
|
|
258
|
+
// `taskset`, `flock`, `su -c`), or a GNU long-option prefix abbreviation
|
|
259
|
+
// taking a separate value (`timeout --sig KILL 5 ...`) is not seen.
|
|
260
|
+
|
|
89
261
|
// --- unit: isVerified ---
|
|
90
262
|
ok('sentinel recognised', isVerified('TRAILHEAD_VERIFIED_INSTALL=1 npm install lodash'));
|
|
91
263
|
ok('no sentinel', !isVerified('npm install lodash'));
|
|
@@ -107,6 +279,13 @@ ok('non-install allowed (exit 0)', a3.code === 0 && a3.out.trim() === '');
|
|
|
107
279
|
const a4 = runHook('pip install -r requirements.txt');
|
|
108
280
|
ok('requirements-file install allowed (exit 0)', a4.code === 0 && a4.out.trim() === '');
|
|
109
281
|
|
|
282
|
+
const a5 = runHook('cd app && pnpm install --frozen-lockfile --offline 2>&1 | tail -5');
|
|
283
|
+
ok('lockfile install with redirection allowed (exit 0)', a5.code === 0 && a5.out.trim() === '');
|
|
284
|
+
const a6 = runHook('echo "done; pnpm install lodash"');
|
|
285
|
+
ok('install text inside echo allowed (exit 0)', a6.code === 0 && a6.out.trim() === '');
|
|
286
|
+
const b3 = runHook('npm install lodash 2>&1');
|
|
287
|
+
ok('named install with redirection still blocks (exit 2)', b3.code === 2 && /lodash/.test(b3.out));
|
|
288
|
+
|
|
110
289
|
// --- end-to-end: unparseable stdin allowed ---
|
|
111
290
|
const u = (() => {
|
|
112
291
|
try {
|
|
@@ -24,6 +24,21 @@
|
|
|
24
24
|
// cannot see. Blocking a lone `cd` would be far too aggressive (a legitimate,
|
|
25
25
|
// common shape) so it is deliberately left to the prose directive.
|
|
26
26
|
//
|
|
27
|
+
// Redirections are not reads of a relative path: before a verb's args are
|
|
28
|
+
// walked, output redirections (`>`, `>>`, `2>`, `&>`, glued or with a separate
|
|
29
|
+
// target), fd duplications (`2>&1`) and heredoc/herestring introducers are
|
|
30
|
+
// dropped together with their target, so `cat /abs/a > out.txt` or
|
|
31
|
+
// `tail /abs/log 2>/dev/null` are not reported. An input redirection `< f` IS a
|
|
32
|
+
// read: only the operator is dropped and `f` stays in the arg list, so
|
|
33
|
+
// `cd app && cat < rel.txt` still blocks while `wc -l < /abs/a` passes.
|
|
34
|
+
//
|
|
35
|
+
// cwd exemption: when the hook payload carries the session `cwd` (absolute), a
|
|
36
|
+
// joined `cd X`/`pushd X` whose literal target resolves to exactly that cwd is
|
|
37
|
+
// a no-op, so it does not make later relative reads non-static. Dynamic or
|
|
38
|
+
// special targets (`$VAR`, backticks, `~`, `-`, a bare `cd`) always count as a
|
|
39
|
+
// directory change, and an unknown cwd behaves as before. The exemption only
|
|
40
|
+
// avoids ARMING: once a real cd has armed the guard, nothing disarms it.
|
|
41
|
+
//
|
|
27
42
|
// Blocks with exit 2 + {"decision":"block","code":...,"reason":...}; allows
|
|
28
43
|
// everything else. Crash-safe: any error -> exit 0 (never wedge the user's
|
|
29
44
|
// workflow). Self-contained (its own dependency is the sibling lib/ module,
|
|
@@ -36,12 +51,14 @@
|
|
|
36
51
|
// machinery (SEARCH_VERB_CONFIG, scanArgsSkippingPattern) are shared with
|
|
37
52
|
// trailhead-secret-read-guard.js via lib/shell-scan.js (#135 follow-up), so
|
|
38
53
|
// the two guards can never drift into two divergent tokenizers.
|
|
54
|
+
const path = require('path');
|
|
39
55
|
const {
|
|
40
56
|
basename,
|
|
41
57
|
stripQuotes,
|
|
42
58
|
splitStatements,
|
|
43
59
|
tokenize,
|
|
44
60
|
skipEnvPrefix,
|
|
61
|
+
stripRedirections,
|
|
45
62
|
SEARCH_VERB_CONFIG,
|
|
46
63
|
scanArgsSkippingPattern,
|
|
47
64
|
} = require('./lib/shell-scan.js');
|
|
@@ -114,7 +131,7 @@ const VERB_CONFIG = {
|
|
|
114
131
|
// Walk a verb's argument tokens (verb itself already stripped) for the first
|
|
115
132
|
// relative FILE argument found, via the shared pattern/flag-skipping walker.
|
|
116
133
|
function scanArgsForRelativeFile(args, config) {
|
|
117
|
-
return scanArgsSkippingPattern(args, config, isRelativeFileArg);
|
|
134
|
+
return scanArgsSkippingPattern(stripRedirections(args), config, isRelativeFileArg);
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
// `git grep`/`git log`/`git show`/`git diff` WITHOUT a `-C` (or --work-tree):
|
|
@@ -177,6 +194,22 @@ function isCdOrPushdStatement(stmt) {
|
|
|
177
194
|
return bin === 'cd' || bin === 'pushd';
|
|
178
195
|
}
|
|
179
196
|
|
|
197
|
+
// Does this cd/pushd statement provably stay in the session cwd? Only with a
|
|
198
|
+
// known absolute cwd and exactly one static literal target resolving to it.
|
|
199
|
+
function cdTargetsSessionCwd(stmt, cwd) {
|
|
200
|
+
if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
|
|
201
|
+
const tokens = normalizeStatementTokens(tokenize(stmt));
|
|
202
|
+
let i = skipEnvPrefix(tokens) + 1; // past cd/pushd
|
|
203
|
+
while (i < tokens.length && tokens[i].startsWith('-') && tokens[i] !== '-') {
|
|
204
|
+
if (tokens[i++] === '--') break; // option flags (-P/-L/--) before the target
|
|
205
|
+
}
|
|
206
|
+
if (i !== tokens.length - 1) return false; // bare cd goes to $HOME; extra targets are unsure
|
|
207
|
+
const target = stripQuotes(tokens[i]);
|
|
208
|
+
if (!target || target === '-' || target.startsWith('~')) return false;
|
|
209
|
+
if (target.includes('$') || target.includes('`')) return false;
|
|
210
|
+
return path.resolve(cwd, target) === path.resolve(cwd);
|
|
211
|
+
}
|
|
212
|
+
|
|
180
213
|
// `env -C <dir> <cmd> …`: the directory change and the read live in the SAME
|
|
181
214
|
// statement (env execs its argument as a single command with that cwd), so
|
|
182
215
|
// this is checked independently of the cross-statement `cwdChanged` flag.
|
|
@@ -215,7 +248,7 @@ function envDashCRelativeRead(stmt) {
|
|
|
215
248
|
// with no path argument at all implicitly searches the (now-relative) cwd,
|
|
216
249
|
// but adding that case risks false-positiving on legitimate stdin-reading
|
|
217
250
|
// commands, so it is left to the prose directive.
|
|
218
|
-
function detectSearchHygieneViolation(cmd) {
|
|
251
|
+
function detectSearchHygieneViolation(cmd, { cwd } = {}) {
|
|
219
252
|
const statements = splitStatements(cmd);
|
|
220
253
|
let cwdChanged = false;
|
|
221
254
|
for (let idx = 0; idx < statements.length; idx++) {
|
|
@@ -233,7 +266,7 @@ function detectSearchHygieneViolation(cmd) {
|
|
|
233
266
|
// the sole statement) - a lone standalone `cd` is the known limitation
|
|
234
267
|
// above and must never be blocked here.
|
|
235
268
|
if (isCdOrPushdStatement(stmt) && idx < statements.length - 1) {
|
|
236
|
-
cwdChanged = true;
|
|
269
|
+
if (!cdTargetsSessionCwd(stmt, cwd)) cwdChanged = true;
|
|
237
270
|
}
|
|
238
271
|
}
|
|
239
272
|
return null;
|
|
@@ -251,9 +284,9 @@ function blockVerdict(hit) {
|
|
|
251
284
|
|
|
252
285
|
// Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
|
|
253
286
|
// exits or writes; any internal error falls through to allow.
|
|
254
|
-
function evaluate(cmd) {
|
|
287
|
+
function evaluate(cmd, { cwd } = {}) {
|
|
255
288
|
try {
|
|
256
|
-
const hit = detectSearchHygieneViolation(cmd);
|
|
289
|
+
const hit = detectSearchHygieneViolation(cmd, { cwd });
|
|
257
290
|
if (hit) return blockVerdict(hit);
|
|
258
291
|
} catch {
|
|
259
292
|
// fall through to allow: a guard must never wedge the workflow
|
|
@@ -263,12 +296,15 @@ function evaluate(cmd) {
|
|
|
263
296
|
|
|
264
297
|
function run(data) {
|
|
265
298
|
let cmd = '';
|
|
299
|
+
let cwd;
|
|
266
300
|
try {
|
|
267
|
-
|
|
301
|
+
const input = JSON.parse(data);
|
|
302
|
+
cmd = (input.tool_input || {}).command || '';
|
|
303
|
+
cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
|
|
268
304
|
} catch {
|
|
269
305
|
process.exit(0); // unparseable input: nothing to gate
|
|
270
306
|
}
|
|
271
|
-
const v = evaluate(cmd);
|
|
307
|
+
const v = evaluate(cmd, { cwd });
|
|
272
308
|
if (v.kind === 'block') {
|
|
273
309
|
process.stdout.write(JSON.stringify(v.payload));
|
|
274
310
|
process.exit(2);
|