@marcomigozzi/trailhead 0.7.0 → 0.8.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 +10 -6
- package/README.md +10 -6
- package/bin/lib/codex-projection.js +9 -6
- package/bin/lib/codex-projection.test.js +5 -1
- package/bin/trailhead.js +50 -5
- package/bin/trailhead.test.js +55 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -0
- package/plugins/trailhead/agents/trailhead-codebase-map.md +2 -0
- package/plugins/trailhead/agents/trailhead-debug.md +2 -0
- package/plugins/trailhead/agents/trailhead-executor.md +2 -0
- package/plugins/trailhead/agents/trailhead-fix.md +2 -0
- package/plugins/trailhead/agents/trailhead-plan.md +2 -0
- package/plugins/trailhead/agents/trailhead-research.md +2 -0
- package/plugins/trailhead/agents/trailhead-verify.md +2 -0
- package/plugins/trailhead/commands/auto.md +6 -0
- package/plugins/trailhead/commands/quick.md +1 -1
- package/plugins/trailhead/hooks/hooks.json +13 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +247 -0
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +49 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +279 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +116 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +291 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +95 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +2 -0
- package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +3 -0
- package/plugins/trailhead/skills/_shared/techniques.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +4 -2
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -0
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -0
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +2 -0
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +3 -1
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +19 -7
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +54 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +2 -2
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// shell-scan.js: shared quote-aware shell scanning helpers, extracted from
|
|
3
|
+
// trailhead-search-guard.js (#135 follow-up) so trailhead's Bash-command
|
|
4
|
+
// guards share ONE tokenizer/statement-splitter instead of drifting copies.
|
|
5
|
+
// trailhead-secret-read-guard.js's original Bash leg used a naive
|
|
6
|
+
// `.split(/\s+/)` tokenizer over the WHOLE command string, which let glued
|
|
7
|
+
// shell metacharacters (`cat .env|grep KEY`, `cat .env;echo done`) slip past
|
|
8
|
+
// detection entirely (a false negative in a security guard) and let a bare
|
|
9
|
+
// `.env` inside multi-word quoted prose (`"document .env usage"`) trip a
|
|
10
|
+
// false positive. This module's `splitStatements` + `tokenize` fix both: the
|
|
11
|
+
// former is quote-aware and splits on operator characters regardless of
|
|
12
|
+
// surrounding whitespace, the latter keeps a multi-word quoted phrase as ONE
|
|
13
|
+
// opaque token instead of splitting it into separate words.
|
|
14
|
+
//
|
|
15
|
+
// Consumers: trailhead-search-guard.js and trailhead-secret-read-guard.js.
|
|
16
|
+
// Self-contained, no external dependencies. Best-effort, like the guards that
|
|
17
|
+
// use it: not a full shell parser, just enough to close their known gaps.
|
|
18
|
+
|
|
19
|
+
// --- path helpers ------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function basename(p) {
|
|
22
|
+
const s = String(p).split('/');
|
|
23
|
+
return s[s.length - 1];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- quoting -----------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
// Strip a single pair of enclosing quotes: the WHOLE token must start AND end
|
|
29
|
+
// with the same quote character. A multi-word quoted phrase kept as one token
|
|
30
|
+
// by tokenize() (below) round-trips through this to its bare content
|
|
31
|
+
// (spaces and all) - callers testing that bare content against a basename
|
|
32
|
+
// pattern then correctly see prose, not a path, unless the phrase IS exactly
|
|
33
|
+
// a path (e.g. `".env"` -> `.env`, still flagged by design).
|
|
34
|
+
function stripQuotes(t) {
|
|
35
|
+
const s = String(t);
|
|
36
|
+
if ((s.startsWith('"') && s.endsWith('"') && s.length >= 2) ||
|
|
37
|
+
(s.startsWith("'") && s.endsWith("'") && s.length >= 2)) {
|
|
38
|
+
return s.slice(1, -1);
|
|
39
|
+
}
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- heredoc bodies ------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
// Matches a heredoc introducer anywhere it can appear on a line: `<<TAG`,
|
|
46
|
+
// `<<'TAG'`, `<<"TAG"`, and `<<-TAG` (which also allows the terminator line to
|
|
47
|
+
// be indented with leading tabs). Captures the operator's `-` and the tag
|
|
48
|
+
// (quoted or bare) separately.
|
|
49
|
+
const HEREDOC_RE = /<<(-?)\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z0-9_]+))/;
|
|
50
|
+
|
|
51
|
+
// A heredoc BODY is literal data (e.g. a script being written to a file), not
|
|
52
|
+
// shell statements executed in the current shell. Scan the command line by
|
|
53
|
+
// line; whenever a line introduces a heredoc, skip every following line up to
|
|
54
|
+
// and including the matching terminator line before it ever reaches the
|
|
55
|
+
// statement splitter below. Handles multiple heredocs on the same command and
|
|
56
|
+
// both quoted and unquoted tags. Best-effort: not a full shell parser.
|
|
57
|
+
function stripHeredocBodies(cmd) {
|
|
58
|
+
const lines = String(cmd).split('\n');
|
|
59
|
+
const out = [];
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
let line = lines[i];
|
|
62
|
+
out.push(line);
|
|
63
|
+
let m;
|
|
64
|
+
// A single line can introduce more than one heredoc (`cmd <<A <<B`); walk
|
|
65
|
+
// left to right and skip each body in turn.
|
|
66
|
+
while ((m = HEREDOC_RE.exec(line))) {
|
|
67
|
+
const dashStrip = m[1] === '-';
|
|
68
|
+
const tag = m[2] || m[3] || m[4];
|
|
69
|
+
line = line.slice(m.index + m[0].length); // keep scanning the rest of this line for more `<<TAG`s
|
|
70
|
+
if (!tag) break;
|
|
71
|
+
// Skip lines until the terminator: an exact match, or (for `<<-`) the
|
|
72
|
+
// tag preceded only by tabs.
|
|
73
|
+
while (i + 1 < lines.length) {
|
|
74
|
+
i++;
|
|
75
|
+
const body = lines[i];
|
|
76
|
+
const term = dashStrip ? body.replace(/^\t+/, '') : body;
|
|
77
|
+
if (term === tag) break; // terminator line: consumed, not a candidate statement either
|
|
78
|
+
// else: heredoc data line, deliberately NOT pushed to `out` - it must
|
|
79
|
+
// never become a candidate statement.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- statement splitting -------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
// Split a shell string into top-level statements on `&&`, `||`, `;`, `|`, and
|
|
89
|
+
// newlines, respecting single/double quotes (so a separator INSIDE a quoted
|
|
90
|
+
// argument, e.g. a grep pattern, does not fracture the statement). Heredoc
|
|
91
|
+
// bodies are stripped first (see stripHeredocBodies) so literal data written
|
|
92
|
+
// via `<<TAG` is never mistaken for statements. Splitting is purely
|
|
93
|
+
// character-driven (not whitespace-driven), so a glued separator with no
|
|
94
|
+
// surrounding spaces (`cat .env|grep KEY`, `cat .env;echo done`) still splits
|
|
95
|
+
// correctly. Best-effort: not a full shell parser, just enough to avoid the
|
|
96
|
+
// common false splits.
|
|
97
|
+
function splitStatements(cmd) {
|
|
98
|
+
const s = stripHeredocBodies(String(cmd));
|
|
99
|
+
const stmts = [];
|
|
100
|
+
let cur = '';
|
|
101
|
+
let inSingle = false;
|
|
102
|
+
let inDouble = false;
|
|
103
|
+
for (let i = 0; i < s.length; i++) {
|
|
104
|
+
const c = s[i];
|
|
105
|
+
if (inSingle) {
|
|
106
|
+
cur += c;
|
|
107
|
+
if (c === "'") inSingle = false;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (inDouble) {
|
|
111
|
+
cur += c;
|
|
112
|
+
if (c === '\\' && i + 1 < s.length) { cur += s[++i]; continue; } // escaped char stays literal
|
|
113
|
+
if (c === '"') inDouble = false;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === "'") { inSingle = true; cur += c; continue; }
|
|
117
|
+
if (c === '"') { inDouble = true; cur += c; continue; }
|
|
118
|
+
if (c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
|
|
119
|
+
if (c === '&' && s[i + 1] === '&') { stmts.push(cur); cur = ''; i++; continue; }
|
|
120
|
+
if (c === '|' && s[i + 1] === '|') { stmts.push(cur); cur = ''; i++; continue; }
|
|
121
|
+
if (c === ';' || c === '|' || c === '\n') { stmts.push(cur); cur = ''; continue; }
|
|
122
|
+
cur += c;
|
|
123
|
+
}
|
|
124
|
+
stmts.push(cur);
|
|
125
|
+
return stmts.map((t) => t.trim()).filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- tokenizing ----------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
// Quote-aware whitespace tokenizer: splits a statement on UNQUOTED
|
|
131
|
+
// whitespace, keeping a `"..."`/`'...'` span - even a MULTI-WORD one - as a
|
|
132
|
+
// single opaque token (quotes are kept in the token; stripQuotes() unwraps
|
|
133
|
+
// them). This is what keeps quoted prose like `"document .env usage"` safe:
|
|
134
|
+
// the whole phrase is one token, so a bare `.env` glued mid-phrase is never a
|
|
135
|
+
// separate candidate token, while a lone quoted path like `".env"` still
|
|
136
|
+
// round-trips to the bare `.env` via stripQuotes for basename testing.
|
|
137
|
+
// Mirrors splitStatements' escape handling (`\"` inside a double-quoted span
|
|
138
|
+
// stays literal, never closes the quote early). Best-effort: not a full shell
|
|
139
|
+
// parser (a real shell also strips quotes/joins adjacent quoted+bare runs
|
|
140
|
+
// into one word; this keeps the surrounding quotes intact instead, which is
|
|
141
|
+
// enough for the callers' basename tests).
|
|
142
|
+
function tokenize(stmt) {
|
|
143
|
+
const s = String(stmt).trim();
|
|
144
|
+
const tokens = [];
|
|
145
|
+
let cur = '';
|
|
146
|
+
let inSingle = false;
|
|
147
|
+
let inDouble = false;
|
|
148
|
+
for (let i = 0; i < s.length; i++) {
|
|
149
|
+
const c = s[i];
|
|
150
|
+
if (inSingle) {
|
|
151
|
+
cur += c;
|
|
152
|
+
if (c === "'") inSingle = false;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (inDouble) {
|
|
156
|
+
cur += c;
|
|
157
|
+
if (c === '\\' && i + 1 < s.length) { cur += s[++i]; continue; }
|
|
158
|
+
if (c === '"') inDouble = false;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (c === "'") { inSingle = true; cur += c; continue; }
|
|
162
|
+
if (c === '"') { inDouble = true; cur += c; continue; }
|
|
163
|
+
if (c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
|
|
164
|
+
if (/\s/.test(c)) {
|
|
165
|
+
if (cur) { tokens.push(cur); cur = ''; }
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
cur += c;
|
|
169
|
+
}
|
|
170
|
+
if (cur) tokens.push(cur);
|
|
171
|
+
return tokens;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function skipEnvPrefix(tokens) {
|
|
175
|
+
let i = 0;
|
|
176
|
+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
|
|
177
|
+
return i;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- search-verb argument scanning ----------------------------------------
|
|
181
|
+
|
|
182
|
+
// grep/rg/ag/sed/awk take a PATTERN (or script/program) as their first
|
|
183
|
+
// positional. Shared between the search guard (which skips the pattern before
|
|
184
|
+
// flagging a relative FILE arg) and the secret-read guard (which skips the
|
|
185
|
+
// pattern before flagging a SECRET FILE arg), so e.g. `grep ".env" config.txt`
|
|
186
|
+
// is never mistaken for a `.env` read: the FILE operand is config.txt, the
|
|
187
|
+
// PATTERN just happens to look like a secret path.
|
|
188
|
+
const GREP_VALUE_FLAGS = new Set([
|
|
189
|
+
'-e', '-f', '-A', '-B', '-C', '-m', '-D',
|
|
190
|
+
'--include', '--exclude', '--exclude-dir', '--exclude-from',
|
|
191
|
+
'--after-context', '--before-context', '--context', '--max-count',
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
// ripgrep has several value-taking flags GNU grep doesn't (glob/type filters,
|
|
195
|
+
// column limits); without these, scanning treats their value as a plain
|
|
196
|
+
// positional and misattributes it as the pattern/file in the block reason.
|
|
197
|
+
const RG_VALUE_FLAGS = new Set([
|
|
198
|
+
...GREP_VALUE_FLAGS,
|
|
199
|
+
'-g', '--glob', '-t', '--type', '-T', '--type-not', '-M', '--max-columns',
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
const SEARCH_VERB_CONFIG = {
|
|
203
|
+
grep: { patternFirst: true, valueFlags: GREP_VALUE_FLAGS, patternFlags: new Set(['-e']) },
|
|
204
|
+
rg: { patternFirst: true, valueFlags: RG_VALUE_FLAGS, patternFlags: new Set(['-e']) },
|
|
205
|
+
ag: { patternFirst: true, valueFlags: GREP_VALUE_FLAGS, patternFlags: new Set(['-e']) },
|
|
206
|
+
sed: { patternFirst: true, valueFlags: new Set(['-e', '-f']), patternFlags: new Set(['-e']) },
|
|
207
|
+
awk: { patternFirst: true, valueFlags: new Set(['-f', '-v']), patternFlags: new Set() },
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Walk a verb's argument tokens (verb itself already stripped), skipping
|
|
211
|
+
// flags (and their separate-token values), an explicit `--` end-of-options
|
|
212
|
+
// marker (which must not itself swallow the next real positional), and the
|
|
213
|
+
// leading pattern/script positional when the verb has one (`config.patternFirst`).
|
|
214
|
+
// Returns the first token for which `isMatch(token)` is true, or null.
|
|
215
|
+
// Generic over the predicate so both guards can drive it with their own
|
|
216
|
+
// notion of "interesting" (a relative file arg vs. a secret path arg).
|
|
217
|
+
function scanArgsSkippingPattern(args, config, isMatch) {
|
|
218
|
+
let patternConsumed = !config.patternFirst;
|
|
219
|
+
let afterDashDash = false; // once seen, every remaining token is positional, never a flag
|
|
220
|
+
for (let j = 0; j < args.length; j++) {
|
|
221
|
+
const t = args[j];
|
|
222
|
+
if (!afterDashDash && t === '--') { afterDashDash = true; continue; }
|
|
223
|
+
if (!afterDashDash && t.startsWith('-') && t !== '-') {
|
|
224
|
+
if (config.valueFlags.has(t)) {
|
|
225
|
+
j++; // consume its separate-token value
|
|
226
|
+
if (config.patternFlags && config.patternFlags.has(t)) patternConsumed = true;
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (!patternConsumed) { patternConsumed = true; continue; } // leading pattern/script positional
|
|
231
|
+
if (isMatch(t)) return t;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
basename,
|
|
238
|
+
stripQuotes,
|
|
239
|
+
stripHeredocBodies,
|
|
240
|
+
splitStatements,
|
|
241
|
+
tokenize,
|
|
242
|
+
skipEnvPrefix,
|
|
243
|
+
GREP_VALUE_FLAGS,
|
|
244
|
+
RG_VALUE_FLAGS,
|
|
245
|
+
SEARCH_VERB_CONFIG,
|
|
246
|
+
scanArgsSkippingPattern,
|
|
247
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for shell-scan.js. Run: node shell-scan.test.js
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const {
|
|
5
|
+
basename,
|
|
6
|
+
stripQuotes,
|
|
7
|
+
splitStatements,
|
|
8
|
+
tokenize,
|
|
9
|
+
scanArgsSkippingPattern,
|
|
10
|
+
SEARCH_VERB_CONFIG,
|
|
11
|
+
} = require('./shell-scan.js');
|
|
12
|
+
|
|
13
|
+
let passed = 0;
|
|
14
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
15
|
+
|
|
16
|
+
// --- basename ---
|
|
17
|
+
ok('basename strips leading dirs', basename('a/b/.env') === '.env');
|
|
18
|
+
ok('basename passes through a bare name', basename('.env') === '.env');
|
|
19
|
+
|
|
20
|
+
// --- stripQuotes ---
|
|
21
|
+
ok('stripQuotes unwraps a double-quoted token', stripQuotes('".env"') === '.env');
|
|
22
|
+
ok('stripQuotes unwraps a single-quoted token', stripQuotes("'.env'") === '.env');
|
|
23
|
+
ok('stripQuotes leaves a bare token alone', stripQuotes('.env') === '.env');
|
|
24
|
+
|
|
25
|
+
// --- splitStatements: glued metacharacters (no surrounding whitespace) ---
|
|
26
|
+
ok('splitStatements splits a glued pipe', JSON.stringify(splitStatements('cat .env|grep KEY')) === JSON.stringify(['cat .env', 'grep KEY']));
|
|
27
|
+
ok('splitStatements splits a glued semicolon', JSON.stringify(splitStatements('cat .env;echo done')) === JSON.stringify(['cat .env', 'echo done']));
|
|
28
|
+
ok('splitStatements splits a glued &&', JSON.stringify(splitStatements('cat .env&&echo done')) === JSON.stringify(['cat .env', 'echo done']));
|
|
29
|
+
ok('splitStatements keeps a quoted separator intact', JSON.stringify(splitStatements('grep "a;b" f.txt')) === JSON.stringify(['grep "a;b" f.txt']));
|
|
30
|
+
|
|
31
|
+
// --- tokenize: quoted multi-word phrase stays ONE token ---
|
|
32
|
+
ok('tokenize keeps a multi-word double-quoted phrase as one token',
|
|
33
|
+
JSON.stringify(tokenize('git commit -m "document .env usage"')) ===
|
|
34
|
+
JSON.stringify(['git', 'commit', '-m', '"document .env usage"']));
|
|
35
|
+
ok('tokenize keeps a multi-word single-quoted phrase as one token',
|
|
36
|
+
JSON.stringify(tokenize("echo 'a b c'")) === JSON.stringify(['echo', "'a b c'"]));
|
|
37
|
+
ok('tokenize round-trips a lone quoted path via stripQuotes',
|
|
38
|
+
stripQuotes(tokenize('cat ".env"')[1]) === '.env');
|
|
39
|
+
ok('tokenize handles an escaped quote inside a double-quoted span without closing early',
|
|
40
|
+
JSON.stringify(tokenize('grep "a\\"b" f.txt')) === JSON.stringify(['grep', '"a\\"b"', 'f.txt']));
|
|
41
|
+
|
|
42
|
+
// --- scanArgsSkippingPattern: search-verb pattern-positional skipping ---
|
|
43
|
+
const isDotEnv = (t) => stripQuotes(t) === '.env';
|
|
44
|
+
ok('scanArgsSkippingPattern skips the pattern positional (grep ".env" config.txt allows)',
|
|
45
|
+
scanArgsSkippingPattern(['".env"', 'config.txt'], SEARCH_VERB_CONFIG.grep, isDotEnv) === null);
|
|
46
|
+
ok('scanArgsSkippingPattern still finds a secret FILE operand after the pattern',
|
|
47
|
+
scanArgsSkippingPattern(['-n', 'KEY', '.env'], SEARCH_VERB_CONFIG.grep, isDotEnv) === '.env');
|
|
48
|
+
|
|
49
|
+
console.log(`✓ shell-scan: ${passed} assertions passed`);
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trailhead-search-guard.js: PreToolUse(Bash) hook.
|
|
3
|
+
// Structural fix for the recurring #132 -> #133 -> #134 defect: the prose
|
|
4
|
+
// search-command-hygiene ban (never `cd` then read/search a RELATIVE path) is
|
|
5
|
+
// present in every engine agent and in `_shared/techniques.md`, but a subagent
|
|
6
|
+
// under load still emits the banned shape, and the resulting relative read is
|
|
7
|
+
// non-static, so a `Read()` deny rule under bypass permissions falls back to a
|
|
8
|
+
// manual approval prompt (defeating headless operation). Prose hardening keeps
|
|
9
|
+
// regressing, so this hook makes the shape structurally impossible instead of
|
|
10
|
+
// merely discouraged.
|
|
11
|
+
//
|
|
12
|
+
// Blocks a SINGLE Bash command string that both:
|
|
13
|
+
// (a) changes the working directory - a `cd <dir>`, `pushd <dir>`, or
|
|
14
|
+
// `env -C <dir>` statement - joined to other statements via `&&`, `;`,
|
|
15
|
+
// `|`, or a newline (i.e. NOT the sole statement); AND
|
|
16
|
+
// (b) a later statement in that same command reads/searches a RELATIVE path
|
|
17
|
+
// via a read/search verb (grep, rg, ag, cat, head, tail, wc, nl, sed,
|
|
18
|
+
// awk, less, more, or `git grep`/`git log`/`git show`/`git diff`
|
|
19
|
+
// without a `-C`).
|
|
20
|
+
//
|
|
21
|
+
// KNOWN LIMITATION (do not try to catch this here): a *standalone* `cd <abs>`
|
|
22
|
+
// in one Bash call followed by a relative read in a LATER, separate call
|
|
23
|
+
// relies on the shell's persisted cwd, which this stateless per-call hook
|
|
24
|
+
// cannot see. Blocking a lone `cd` would be far too aggressive (a legitimate,
|
|
25
|
+
// common shape) so it is deliberately left to the prose directive.
|
|
26
|
+
//
|
|
27
|
+
// Blocks with exit 2 + {"decision":"block","code":...,"reason":...}; allows
|
|
28
|
+
// everything else. Crash-safe: any error -> exit 0 (never wedge the user's
|
|
29
|
+
// workflow). Self-contained (its own dependency is the sibling lib/ module,
|
|
30
|
+
// no external packages). Pure functions exported for tests; runs as a hook
|
|
31
|
+
// when executed directly.
|
|
32
|
+
|
|
33
|
+
// --- tokenizing -------------------------------------------------------------
|
|
34
|
+
// The quote-aware tokenizer/statement-splitter (splitStatements, tokenize,
|
|
35
|
+
// stripQuotes, basename) and the search-verb pattern-positional-skipping
|
|
36
|
+
// machinery (SEARCH_VERB_CONFIG, scanArgsSkippingPattern) are shared with
|
|
37
|
+
// trailhead-secret-read-guard.js via lib/shell-scan.js (#135 follow-up), so
|
|
38
|
+
// the two guards can never drift into two divergent tokenizers.
|
|
39
|
+
const {
|
|
40
|
+
basename,
|
|
41
|
+
stripQuotes,
|
|
42
|
+
splitStatements,
|
|
43
|
+
tokenize,
|
|
44
|
+
skipEnvPrefix,
|
|
45
|
+
SEARCH_VERB_CONFIG,
|
|
46
|
+
scanArgsSkippingPattern,
|
|
47
|
+
} = require('./lib/shell-scan.js');
|
|
48
|
+
|
|
49
|
+
// A leading grouping/keyword token that can precede the "real" first word of a
|
|
50
|
+
// statement without itself being that word: `(cd app && ...)`, `{ cd app; ... }`,
|
|
51
|
+
// `do cd "$d" && ...` (loop body), `then cd x`, `else cd x`. Stripping these
|
|
52
|
+
// (and a `(`/`{` glued directly onto the next token, e.g. `(cd`) lets the
|
|
53
|
+
// `cd`/`pushd`/read-verb checks below see the actual command even when it's
|
|
54
|
+
// wrapped in a subshell, brace group, or loop/conditional body.
|
|
55
|
+
const LEADING_GROUP_TOKENS = new Set(['(', '{', 'do', 'then', 'else']);
|
|
56
|
+
// A trailing grouping/keyword token (or glued punctuation) that can follow the
|
|
57
|
+
// real last word of a statement: `grep ... foo.txt)`, `grep ... foo.txt;`,
|
|
58
|
+
// `grep ... foo.txt }`, `... ; fi`, `... ; done`.
|
|
59
|
+
const TRAILING_GROUP_TOKENS = new Set([')', '}', ';', 'fi', 'done']);
|
|
60
|
+
|
|
61
|
+
// Strip a single leading grouping/keyword token and a single trailing
|
|
62
|
+
// grouping/keyword token (or glued punctuation) from an already-tokenized
|
|
63
|
+
// statement, so subshells, brace groups, and loop/conditional bodies don't
|
|
64
|
+
// hide a `cd`/read-verb from the checks that follow. Best-effort: handles the
|
|
65
|
+
// common shapes, not a full shell parser.
|
|
66
|
+
function normalizeStatementTokens(tokens) {
|
|
67
|
+
let t = tokens;
|
|
68
|
+
if (t.length && LEADING_GROUP_TOKENS.has(t[0])) {
|
|
69
|
+
t = t.slice(1);
|
|
70
|
+
} else if (t.length && (t[0][0] === '(' || t[0][0] === '{') && t[0].length > 1) {
|
|
71
|
+
t = [t[0].slice(1), ...t.slice(1)];
|
|
72
|
+
}
|
|
73
|
+
if (t.length) {
|
|
74
|
+
const last = t[t.length - 1];
|
|
75
|
+
if (TRAILING_GROUP_TOKENS.has(last)) {
|
|
76
|
+
t = t.slice(0, -1);
|
|
77
|
+
} else {
|
|
78
|
+
const stripped = last.replace(/[)};]+$/, '');
|
|
79
|
+
if (stripped !== last) {
|
|
80
|
+
t = stripped ? [...t.slice(0, -1), stripped] : t.slice(0, -1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return t;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A relative-path FILE argument: not absolute, not stdin (`-`), not a dynamic
|
|
88
|
+
// value we cannot resolve statically (fail open on those, same posture as the
|
|
89
|
+
// commit-guard's UNEXPANDABLE handling).
|
|
90
|
+
function isRelativeFileArg(t) {
|
|
91
|
+
const bare = stripQuotes(t);
|
|
92
|
+
if (!bare || bare === '-') return false;
|
|
93
|
+
if (bare.startsWith('/')) return false;
|
|
94
|
+
if (bare.startsWith('$') || bare.includes('$(') || bare.includes('`')) return false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- per-verb argument scanning ---------------------------------------------
|
|
99
|
+
|
|
100
|
+
// grep/rg/ag/sed/awk (the pattern-first search verbs) come from the shared
|
|
101
|
+
// lib (see lib/shell-scan.js); cat/head/tail/wc/nl/less/more take only FILE
|
|
102
|
+
// positionals and are specific to this guard's read-hygiene check.
|
|
103
|
+
const VERB_CONFIG = {
|
|
104
|
+
...SEARCH_VERB_CONFIG,
|
|
105
|
+
cat: { patternFirst: false, valueFlags: new Set() },
|
|
106
|
+
head: { patternFirst: false, valueFlags: new Set(['-n', '-c']) },
|
|
107
|
+
tail: { patternFirst: false, valueFlags: new Set(['-n', '-c']) },
|
|
108
|
+
wc: { patternFirst: false, valueFlags: new Set() },
|
|
109
|
+
nl: { patternFirst: false, valueFlags: new Set() },
|
|
110
|
+
less: { patternFirst: false, valueFlags: new Set() },
|
|
111
|
+
more: { patternFirst: false, valueFlags: new Set() },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Walk a verb's argument tokens (verb itself already stripped) for the first
|
|
115
|
+
// relative FILE argument found, via the shared pattern/flag-skipping walker.
|
|
116
|
+
function scanArgsForRelativeFile(args, config) {
|
|
117
|
+
return scanArgsSkippingPattern(args, config, isRelativeFileArg);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// `git grep`/`git log`/`git show`/`git diff` WITHOUT a `-C` (or --work-tree):
|
|
121
|
+
// git grep has a pattern positional like plain grep; log/show/diff don't, so
|
|
122
|
+
// only their pathspec after an explicit `--` is checked (the common idiom),
|
|
123
|
+
// keeping this conservative rather than guessing at ref vs. path tokens.
|
|
124
|
+
function detectGitRelativeRead(tokens) {
|
|
125
|
+
let i = 1; // tokens[0] === 'git'
|
|
126
|
+
let hasC = false;
|
|
127
|
+
while (i < tokens.length && tokens[i].startsWith('-')) {
|
|
128
|
+
if ((tokens[i] === '-C' || tokens[i] === '--work-tree') && !tokens[i].includes('=')) {
|
|
129
|
+
hasC = true;
|
|
130
|
+
i += 2;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (tokens[i].startsWith('--work-tree=') || tokens[i].startsWith('--git-dir=')) {
|
|
134
|
+
if (tokens[i].startsWith('--work-tree=')) hasC = true;
|
|
135
|
+
i++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
i++;
|
|
139
|
+
}
|
|
140
|
+
if (hasC) return null; // already path-explicit
|
|
141
|
+
const sub = tokens[i];
|
|
142
|
+
if (!['grep', 'log', 'show', 'diff'].includes(sub)) return null;
|
|
143
|
+
i++;
|
|
144
|
+
if (sub === 'grep') {
|
|
145
|
+
return scanArgsForRelativeFile(tokens.slice(i), VERB_CONFIG.grep);
|
|
146
|
+
}
|
|
147
|
+
const dashIdx = tokens.indexOf('--', i);
|
|
148
|
+
if (dashIdx === -1) return null;
|
|
149
|
+
return scanArgsForRelativeFile(tokens.slice(dashIdx + 1), { patternFirst: false, valueFlags: new Set() });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Is this statement itself a read/search verb call with a relative file arg?
|
|
153
|
+
function readViolationInStatement(stmt) {
|
|
154
|
+
const tokens = normalizeStatementTokens(tokenize(stmt));
|
|
155
|
+
const i = skipEnvPrefix(tokens);
|
|
156
|
+
const rest = tokens.slice(i);
|
|
157
|
+
if (rest.length === 0) return null;
|
|
158
|
+
const bin = basename(rest[0]);
|
|
159
|
+
if (bin === 'git') {
|
|
160
|
+
const file = detectGitRelativeRead(rest);
|
|
161
|
+
return file ? { verb: `git ${rest[1] || ''}`.trim(), file } : null;
|
|
162
|
+
}
|
|
163
|
+
const config = VERB_CONFIG[bin];
|
|
164
|
+
if (!config) return null;
|
|
165
|
+
const file = scanArgsForRelativeFile(rest.slice(1), config);
|
|
166
|
+
return file ? { verb: bin, file } : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Does this statement change the shell's persistent cwd (`cd`/`pushd`)? `env
|
|
170
|
+
// -C` is deliberately excluded here: it changes the directory only for the ONE
|
|
171
|
+
// command it wraps, not the rest of the script, so it's handled separately
|
|
172
|
+
// as a same-statement check, not this cross-statement sticky flag.
|
|
173
|
+
function isCdOrPushdStatement(stmt) {
|
|
174
|
+
const tokens = normalizeStatementTokens(tokenize(stmt));
|
|
175
|
+
const i = skipEnvPrefix(tokens);
|
|
176
|
+
const bin = tokens[i];
|
|
177
|
+
return bin === 'cd' || bin === 'pushd';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// `env -C <dir> <cmd> …`: the directory change and the read live in the SAME
|
|
181
|
+
// statement (env execs its argument as a single command with that cwd), so
|
|
182
|
+
// this is checked independently of the cross-statement `cwdChanged` flag.
|
|
183
|
+
function envDashCRelativeRead(stmt) {
|
|
184
|
+
const tokens = tokenize(stmt);
|
|
185
|
+
let i = skipEnvPrefix(tokens);
|
|
186
|
+
if (basename(tokens[i]) !== 'env') return null;
|
|
187
|
+
i++;
|
|
188
|
+
let foundC = false;
|
|
189
|
+
while (i < tokens.length) {
|
|
190
|
+
const t = tokens[i];
|
|
191
|
+
if (t === '-C') { foundC = true; i += 2; break; }
|
|
192
|
+
if (t.startsWith('--chdir=')) { foundC = true; i += 1; break; }
|
|
193
|
+
if (t.startsWith('-')) { i++; continue; } // some other env flag, best-effort skip
|
|
194
|
+
break; // reached the wrapped command
|
|
195
|
+
}
|
|
196
|
+
if (!foundC) return null;
|
|
197
|
+
const rest = tokens.slice(i);
|
|
198
|
+
if (rest.length === 0) return null;
|
|
199
|
+
const bin = basename(rest[0]);
|
|
200
|
+
if (bin === 'git') {
|
|
201
|
+
const file = detectGitRelativeRead(rest);
|
|
202
|
+
return file ? { verb: 'git', file } : null;
|
|
203
|
+
}
|
|
204
|
+
const config = VERB_CONFIG[bin];
|
|
205
|
+
if (!config) return null;
|
|
206
|
+
const file = scanArgsForRelativeFile(rest.slice(1), config);
|
|
207
|
+
return file ? { verb: bin, file } : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- top-level detection -----------------------------------------------------
|
|
211
|
+
|
|
212
|
+
// Detect the banned shape across the whole command. Returns
|
|
213
|
+
// { verb, file } on a hit, or null. Pure, exported for tests.
|
|
214
|
+
// KNOWN NARROWER GAP (deliberately not implemented): `cd app && grep -rn TODO`
|
|
215
|
+
// with no path argument at all implicitly searches the (now-relative) cwd,
|
|
216
|
+
// but adding that case risks false-positiving on legitimate stdin-reading
|
|
217
|
+
// commands, so it is left to the prose directive.
|
|
218
|
+
function detectSearchHygieneViolation(cmd) {
|
|
219
|
+
const statements = splitStatements(cmd);
|
|
220
|
+
let cwdChanged = false;
|
|
221
|
+
for (let idx = 0; idx < statements.length; idx++) {
|
|
222
|
+
const stmt = statements[idx];
|
|
223
|
+
|
|
224
|
+
const envHit = envDashCRelativeRead(stmt);
|
|
225
|
+
if (envHit) return envHit;
|
|
226
|
+
|
|
227
|
+
if (cwdChanged) {
|
|
228
|
+
const hit = readViolationInStatement(stmt);
|
|
229
|
+
if (hit) return hit;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A `cd`/`pushd` only counts when it is joined to a LATER statement (not
|
|
233
|
+
// the sole statement) - a lone standalone `cd` is the known limitation
|
|
234
|
+
// above and must never be blocked here.
|
|
235
|
+
if (isCdOrPushdStatement(stmt) && idx < statements.length - 1) {
|
|
236
|
+
cwdChanged = true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function block(hit) {
|
|
243
|
+
const reason =
|
|
244
|
+
"trailhead search-hygiene: a 'cd <dir>' (or pushd/env -C) followed by a relative " +
|
|
245
|
+
`${hit.verb} read (\`${hit.file}\`) makes the read target non-static, which trips the ` +
|
|
246
|
+
'Read() deny rule under bypass permissions (prompting for manual approval instead of ' +
|
|
247
|
+
'running headless). Pass explicit paths instead and never cd first: ' +
|
|
248
|
+
'grep -niE "<patterns>" <abs>/<file>, git -C <abs> ..., or use the Grep/Read tools.';
|
|
249
|
+
process.stdout.write(JSON.stringify({ decision: 'block', code: 'SEARCH_HYGIENE_VIOLATION', reason }));
|
|
250
|
+
process.exit(2);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function run(data) {
|
|
254
|
+
let cmd = '';
|
|
255
|
+
try {
|
|
256
|
+
cmd = (JSON.parse(data).tool_input || {}).command || '';
|
|
257
|
+
} catch {
|
|
258
|
+
process.exit(0); // unparseable input: nothing to gate
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const hit = detectSearchHygieneViolation(cmd);
|
|
262
|
+
if (hit) block(hit);
|
|
263
|
+
} catch {
|
|
264
|
+
// fall through to allow: a guard must never wedge the workflow
|
|
265
|
+
}
|
|
266
|
+
process.exit(0);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (require.main === module) {
|
|
270
|
+
let data = '';
|
|
271
|
+
const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends -> give up
|
|
272
|
+
process.stdin.on('data', (c) => (data += c));
|
|
273
|
+
process.stdin.on('end', () => {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
run(data);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { detectSearchHygieneViolation };
|