@marcomigozzi/trailhead 0.13.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 +142 -29
- package/README.md +141 -28
- 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 +17 -10
- package/bin/lib/codex-projection.test.js +80 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -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 +40 -28
- package/bin/trailhead.test.js +503 -6
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- 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/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -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/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -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/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -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/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
- package/plugins/trailhead/hooks/mod/README.md +219 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
- package/plugins/trailhead/hooks/mod/register.tsx +293 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
- package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -3
- 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 +3 -3
- 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 +17 -6
- 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/prototype.md +1 -1
- 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 +3 -3
- 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 +16 -13
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -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 +217 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
- package/plugins/trailhead/types/index.d.ts +103 -0
|
@@ -8,13 +8,27 @@
|
|
|
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
|
|
17
|
-
// hook when executed directly.
|
|
26
|
+
// Self-contained (its only dependency is the sibling lib/ module). Pure functions (incl. evaluate) exported for tests
|
|
27
|
+
// and the merged trailhead-bash-guards.js; runs as a hook when executed directly.
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const { basename, splitStatements, tokenize, stripQuotes, stripRedirections } = require('./lib/shell-scan.js');
|
|
18
32
|
|
|
19
33
|
const SENTINEL = /(?:^|\s)TRAILHEAD_VERIFIED_INSTALL=1(?:\s|$)/;
|
|
20
34
|
|
|
@@ -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;
|
|
@@ -133,7 +265,7 @@ function isVerified(cmd) {
|
|
|
133
265
|
return SENTINEL.test(String(cmd));
|
|
134
266
|
}
|
|
135
267
|
|
|
136
|
-
function
|
|
268
|
+
function blockVerdict(hit) {
|
|
137
269
|
const list = hit.packages.join(', ');
|
|
138
270
|
const reason =
|
|
139
271
|
`trailhead: this ${hit.ecosystem} install names package(s) not yet vetted (${list}). ` +
|
|
@@ -142,23 +274,36 @@ function block(hit) {
|
|
|
142
274
|
'and repository metadata (present and matching). ' +
|
|
143
275
|
'STOP and ask the human on anything suspicious (block-and-ask): do not install on your own judgement. ' +
|
|
144
276
|
'Once vetted, re-run the exact command prefixed with `TRAILHEAD_VERIFIED_INSTALL=1` to proceed.';
|
|
145
|
-
|
|
146
|
-
|
|
277
|
+
return { kind: 'block', payload: { decision: 'block', code: 'UNVETTED_PACKAGE_INSTALL', reason } };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
|
|
281
|
+
// exits or writes; any internal error falls through to allow.
|
|
282
|
+
function evaluate(cmd, opts) {
|
|
283
|
+
try {
|
|
284
|
+
if (isVerified(cmd)) return { kind: 'allow' }; // agent has vetted these
|
|
285
|
+
const hit = detectInstall(cmd, { cwd: opts && opts.cwd });
|
|
286
|
+
if (hit) return blockVerdict(hit);
|
|
287
|
+
} catch {
|
|
288
|
+
// fall through to allow: a guard must never wedge the workflow
|
|
289
|
+
}
|
|
290
|
+
return { kind: 'allow' };
|
|
147
291
|
}
|
|
148
292
|
|
|
149
293
|
function run(data) {
|
|
150
294
|
let cmd = '';
|
|
295
|
+
let cwd;
|
|
151
296
|
try {
|
|
152
|
-
|
|
297
|
+
const input = JSON.parse(data);
|
|
298
|
+
cmd = (input.tool_input || {}).command || '';
|
|
299
|
+
cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
|
|
153
300
|
} catch {
|
|
154
301
|
process.exit(0); // unparseable input: nothing to gate
|
|
155
302
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
} catch {
|
|
161
|
-
// fall through to allow: a guard must never wedge the workflow
|
|
303
|
+
const v = evaluate(cmd, { cwd });
|
|
304
|
+
if (v.kind === 'block') {
|
|
305
|
+
process.stdout.write(JSON.stringify(v.payload));
|
|
306
|
+
process.exit(2);
|
|
162
307
|
}
|
|
163
308
|
process.exit(0);
|
|
164
309
|
}
|
|
@@ -173,4 +318,4 @@ if (require.main === module) {
|
|
|
173
318
|
});
|
|
174
319
|
}
|
|
175
320
|
|
|
176
|
-
module.exports = { detectInstall, isVerified };
|
|
321
|
+
module.exports = { detectInstall, isVerified, evaluate };
|
|
@@ -4,9 +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
|
-
|
|
9
|
-
const
|
|
10
|
+
// TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
|
|
11
|
+
const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-install-guard.js');
|
|
12
|
+
const { detectInstall, isVerified, evaluate } = require('./trailhead-install-guard.js');
|
|
10
13
|
|
|
11
14
|
let passed = 0;
|
|
12
15
|
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
@@ -85,6 +88,176 @@ ok('detects install after ; chain', (() => { const d = detectInstall('echo hi; p
|
|
|
85
88
|
ok('detects install in a piped tail', (() => { const d = detectInstall('true | cargo add serde'); return d && d.packages.includes('serde'); })());
|
|
86
89
|
ok('compound with no install stays null', detectInstall('cd app && npm run build && ls') === null);
|
|
87
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
|
+
|
|
88
261
|
// --- unit: isVerified ---
|
|
89
262
|
ok('sentinel recognised', isVerified('TRAILHEAD_VERIFIED_INSTALL=1 npm install lodash'));
|
|
90
263
|
ok('no sentinel', !isVerified('npm install lodash'));
|
|
@@ -106,6 +279,13 @@ ok('non-install allowed (exit 0)', a3.code === 0 && a3.out.trim() === '');
|
|
|
106
279
|
const a4 = runHook('pip install -r requirements.txt');
|
|
107
280
|
ok('requirements-file install allowed (exit 0)', a4.code === 0 && a4.out.trim() === '');
|
|
108
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
|
+
|
|
109
289
|
// --- end-to-end: unparseable stdin allowed ---
|
|
110
290
|
const u = (() => {
|
|
111
291
|
try {
|
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
// practice (an approval asked with only the project's name, no URL), so this
|
|
8
8
|
// hook blocks an AskUserQuestion that is a mockup-approval ask when the
|
|
9
9
|
// assistant prose immediately before it (no tool call in between) holds no
|
|
10
|
-
// URL or file path.
|
|
10
|
+
// URL or file path. A link carried by the approval question itself (question,
|
|
11
|
+
// header, option label or description) is accepted too.
|
|
12
|
+
// Transcript timing: Claude Code writes the CURRENT assistant message (its
|
|
13
|
+
// text and tool_use blocks) to the transcript only after the tool call
|
|
14
|
+
// completes, so at PreToolUse the prose carrying the link is usually not
|
|
15
|
+
// there yet. The prose check therefore runs only when the pending ask is
|
|
16
|
+
// found in the transcript (by tool_use_id, or as the identical tail ask);
|
|
17
|
+
// otherwise the guard cannot judge and allows.
|
|
11
18
|
// Blocks with exit 2 + {"decision":"block",...}; allows everything else.
|
|
12
19
|
// Fail-OPEN on any error (exit 0): a missing/unreadable transcript or an
|
|
13
20
|
// unexpected shape must never wedge the workflow.
|
|
@@ -16,7 +23,7 @@
|
|
|
16
23
|
// Pure functions are exported for tests; runs as a hook when executed directly.
|
|
17
24
|
|
|
18
25
|
const fs = require('fs');
|
|
19
|
-
const { hasLink, isMockupApprovalAsk, BLOCK_REASON } = require('./lib/mockup-link.js');
|
|
26
|
+
const { hasLink, isMockupApprovalAsk, askHasLink, BLOCK_REASON } = require('./lib/mockup-link.js');
|
|
20
27
|
|
|
21
28
|
// Only the transcript tail matters: the prose sits right before the ask.
|
|
22
29
|
const TAIL_BYTES = 512 * 1024;
|
|
@@ -25,14 +32,41 @@ const blocksOf = (e) => (Array.isArray(e.message && e.message.content) ? e.messa
|
|
|
25
32
|
const isPendingAsk = (e) => e.type === 'assistant'
|
|
26
33
|
&& blocksOf(e).some((b) => b.type === 'tool_use' && b.name === 'AskUserQuestion');
|
|
27
34
|
|
|
35
|
+
const mainChain = (entries) => entries.filter((e) => e && (e.type === 'assistant' || e.type === 'user') && !e.isSidechain);
|
|
36
|
+
|
|
37
|
+
// Index (in the main chain) of the entry holding the pending ask being
|
|
38
|
+
// judged, or -1 when it is not in the transcript yet. With a tool_use id:
|
|
39
|
+
// the assistant entry carrying that id. Without: the last entry, only if it
|
|
40
|
+
// is a pending AskUserQuestion whose input equals toolInput.
|
|
41
|
+
function pendingAskIndex(entries, toolUseId, toolInput) {
|
|
42
|
+
const main = mainChain(entries);
|
|
43
|
+
if (toolUseId) {
|
|
44
|
+
return main.findIndex((e) => e.type === 'assistant'
|
|
45
|
+
&& blocksOf(e).some((b) => b.type === 'tool_use' && b.id === toolUseId));
|
|
46
|
+
}
|
|
47
|
+
const i = main.length - 1;
|
|
48
|
+
if (i < 0 || !isPendingAsk(main[i])) return -1;
|
|
49
|
+
const same = blocksOf(main[i]).some((b) => b.type === 'tool_use' && b.name === 'AskUserQuestion'
|
|
50
|
+
&& JSON.stringify(b.input) === JSON.stringify(toolInput));
|
|
51
|
+
return same ? i : -1;
|
|
52
|
+
}
|
|
53
|
+
|
|
28
54
|
// The assistant prose right before the ask: walk the main-chain entries back
|
|
29
|
-
// from the end, skip the pending AskUserQuestion
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
// from the anchor entry (default: the end), skip the pending AskUserQuestion
|
|
56
|
+
// entries (keeping the text that precedes the tool_use inside the anchor
|
|
57
|
+
// entry itself), and collect text blocks until anything else (a user turn, a tool
|
|
58
|
+
// result, another tool call).
|
|
59
|
+
function proseBeforeAsk(entries, anchor) {
|
|
60
|
+
const main = mainChain(entries);
|
|
61
|
+
let i = anchor === undefined ? main.length - 1 : anchor;
|
|
35
62
|
const texts = [];
|
|
63
|
+
// Text blocks in the same entry, before its tool_use, are prose too.
|
|
64
|
+
if (i >= 0 && main[i] && isPendingAsk(main[i])) {
|
|
65
|
+
const blocks = blocksOf(main[i]);
|
|
66
|
+
const firstTool = blocks.findIndex((b) => b.type === 'tool_use');
|
|
67
|
+
blocks.slice(0, firstTool).filter((b) => b.type === 'text').forEach((b) => texts.push(b.text || ''));
|
|
68
|
+
}
|
|
69
|
+
while (i >= 0 && isPendingAsk(main[i])) i--;
|
|
36
70
|
for (; i >= 0; i--) {
|
|
37
71
|
const e = main[i];
|
|
38
72
|
if (e.type !== 'assistant') break;
|
|
@@ -73,9 +107,12 @@ function run(data) {
|
|
|
73
107
|
const payload = JSON.parse(data);
|
|
74
108
|
if (payload.tool_name && payload.tool_name !== 'AskUserQuestion') process.exit(0);
|
|
75
109
|
if (!isMockupApprovalAsk(payload.tool_input)) process.exit(0);
|
|
110
|
+
if (askHasLink(payload.tool_input)) process.exit(0);
|
|
76
111
|
if (!payload.transcript_path) process.exit(0);
|
|
77
|
-
const
|
|
78
|
-
|
|
112
|
+
const entries = parseEntries(readTail(payload.transcript_path));
|
|
113
|
+
const idx = pendingAskIndex(entries, payload.tool_use_id, payload.tool_input);
|
|
114
|
+
if (idx < 0) process.exit(0); // current message not flushed yet: cannot judge
|
|
115
|
+
if (!hasLink(proseBeforeAsk(entries, idx))) block();
|
|
79
116
|
} catch {
|
|
80
117
|
// fall through to allow: a guard must never wedge the workflow
|
|
81
118
|
}
|
|
@@ -92,4 +129,4 @@ if (require.main === module) {
|
|
|
92
129
|
});
|
|
93
130
|
}
|
|
94
131
|
|
|
95
|
-
module.exports = { isMockupApprovalAsk, hasLink, proseBeforeAsk };
|
|
132
|
+
module.exports = { isMockupApprovalAsk, hasLink, askHasLink, proseBeforeAsk, pendingAskIndex };
|