@marcomigozzi/trailhead 0.14.0 → 0.16.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 +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -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 +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -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 +6 -6
- package/bin/trailhead.test.js +355 -16
- 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/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -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 +472 -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/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -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/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- 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-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- 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 +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- 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/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -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 +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- 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 +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -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 +360 -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-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -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-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +62 -3
|
@@ -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);
|
|
@@ -12,8 +12,10 @@ let passed = 0;
|
|
|
12
12
|
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
13
13
|
|
|
14
14
|
// Run the hook end-to-end: pipe tool_input JSON on stdin, capture {code, out}.
|
|
15
|
-
function runHook(command) {
|
|
16
|
-
const
|
|
15
|
+
function runHook(command, cwd) {
|
|
16
|
+
const payload = { tool_input: { command } };
|
|
17
|
+
if (cwd !== undefined) payload.cwd = cwd; // optional top-level session cwd
|
|
18
|
+
const input = JSON.stringify(payload);
|
|
17
19
|
try {
|
|
18
20
|
const out = execFileSync('node', [HOOK], { input });
|
|
19
21
|
return { code: 0, out: out.toString() };
|
|
@@ -114,4 +116,66 @@ ok('cd + grep -- -x <relative file> is blocked (exit 2), `--` must not eat the f
|
|
|
114
116
|
const f1 = runHook('cd app && rg -g "*.js" TODO src/foo.js');
|
|
115
117
|
ok('cd + rg -g <glob> <pattern> <relative file> reports the real file, not the pattern (exit 2)', f1.code === 2 && /search-hygiene/.test(f1.out) && /src\/foo\.js/.test(f1.out) && !/`TODO`/.test(f1.out));
|
|
116
118
|
|
|
119
|
+
|
|
120
|
+
// --- #238: redirections are not relative reads; a cd to the session cwd is static ---
|
|
121
|
+
const blocked = (r) => r.code === 2 && /search-hygiene/.test(r.out);
|
|
122
|
+
const allowed = (r) => r.code === 0 && r.out.trim() === '';
|
|
123
|
+
|
|
124
|
+
for (const [name, cmd] of [
|
|
125
|
+
['heredoc-fed cat > abs', 'cd /x && cat > /abs/file <<EOF\nhi\nEOF'],
|
|
126
|
+
['append redirect target', 'cd /x && cat /abs/a >> out.txt'],
|
|
127
|
+
['output redirect target', 'cd /x && cat /abs/a > out.txt'],
|
|
128
|
+
['glued 2>/dev/null', 'cd /x && tail -5 /abs/log 2>/dev/null'],
|
|
129
|
+
['separate 2> target', 'cd /x && tail -5 /abs/log 2> err.log'],
|
|
130
|
+
['&> target', 'cd /x && cat /abs/a &> out.txt'],
|
|
131
|
+
['2>&1 duplication', 'cd /x && cat /abs/a 2>&1'],
|
|
132
|
+
['absolute input redirect', 'cd /x && wc -l < /abs/a'],
|
|
133
|
+
['grep with 2>/dev/null', 'cd /x && grep -n foo /abs/a 2>/dev/null'],
|
|
134
|
+
['herestring', 'cd /x && cat <<<hello'],
|
|
135
|
+
]) ok(`#238 allow: ${name}`, allowed(runHook(cmd)));
|
|
136
|
+
|
|
137
|
+
for (const [name, cmd, file] of [
|
|
138
|
+
['input redirect from relative file', 'cd app && cat < rel.txt', 'rel.txt'],
|
|
139
|
+
['relative read before output redirect', 'cd app && cat rel.txt > /abs/out', 'rel.txt'],
|
|
140
|
+
['relative read before 2>/dev/null', 'cd app && tail -5 rel.log 2>/dev/null', 'rel.log'],
|
|
141
|
+
['relative read before 2>&1', 'cd app && grep -n x rel.txt 2>&1', 'rel.txt'],
|
|
142
|
+
]) {
|
|
143
|
+
const r = runHook(cmd);
|
|
144
|
+
ok(`#238 block: ${name}`, blocked(r) && r.out.includes('`' + file + '`'));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const CWD = '/home/u/p';
|
|
148
|
+
for (const [name, cmd] of [
|
|
149
|
+
['cd to session cwd', 'cd /home/u/p && cat rel.txt'],
|
|
150
|
+
['cd to session cwd with trailing slash', 'cd /home/u/p/ && grep -n x rel.txt'],
|
|
151
|
+
['cd .', 'cd . && cat rel.txt'],
|
|
152
|
+
]) ok(`#238 allow with cwd: ${name}`, allowed(runHook(cmd, CWD)));
|
|
153
|
+
|
|
154
|
+
for (const [name, cmd] of [
|
|
155
|
+
['cd elsewhere', 'cd /home/u/other && cat rel.txt'],
|
|
156
|
+
['cd relative subdir', 'cd sub && cat rel.txt'],
|
|
157
|
+
['cd $HOME', 'cd $HOME && cat rel.txt'],
|
|
158
|
+
['cd ~', 'cd ~ && cat rel.txt'],
|
|
159
|
+
['cd -', 'cd - && cat rel.txt'],
|
|
160
|
+
]) ok(`#238 block with cwd: ${name}`, blocked(runHook(cmd, CWD)));
|
|
161
|
+
ok('#238 block: cd to would-be cwd without payload cwd', blocked(runHook('cd /home/u/p && cat rel.txt')));
|
|
162
|
+
|
|
163
|
+
// pure unit checks on the exported detector
|
|
164
|
+
const { detectSearchHygieneViolation } = require('./trailhead-search-guard.js');
|
|
165
|
+
ok('#238 unit: cd <cwd> is static when cwd is known',
|
|
166
|
+
detectSearchHygieneViolation('cd "/home/u/p" && cat rel.txt', { cwd: CWD }) === null);
|
|
167
|
+
ok('#238 unit: cd <cwd> still violates when cwd unknown',
|
|
168
|
+
detectSearchHygieneViolation('cd /home/u/p && cat rel.txt').file === 'rel.txt');
|
|
169
|
+
ok('#238 unit: relative cd after a real cd stays armed',
|
|
170
|
+
detectSearchHygieneViolation('cd sub && cd . && cat rel.txt', { cwd: CWD }).file === 'rel.txt');
|
|
171
|
+
// once armed by a real cd, nothing disarms: a cd to the session cwd only avoids arming
|
|
172
|
+
for (const cmd of [
|
|
173
|
+
'cd sub || cd /home/u/p && cat rel.txt',
|
|
174
|
+
'cd sub && (cd /home/u/p) && cat rel.txt',
|
|
175
|
+
'cd sub && cd /home/u/p && cat rel.txt',
|
|
176
|
+
]) ok(`#238 unit: stays armed: ${cmd}`, detectSearchHygieneViolation(cmd, { cwd: CWD }).file === 'rel.txt');
|
|
177
|
+
ok('#238 unit: cd with an extra target is not a no-op',
|
|
178
|
+
detectSearchHygieneViolation('cd /home/u/p /other && cat rel.txt', { cwd: CWD }).file === 'rel.txt');
|
|
179
|
+
ok('#238 allow: separate-token >& target', allowed(runHook('cd app && cat /abs/a >& out.txt')));
|
|
180
|
+
|
|
117
181
|
console.log(`✓ search-guard: ${passed} assertions passed`);
|
|
@@ -86,6 +86,20 @@
|
|
|
86
86
|
// a link prepared under a non-secret name onto `.env`
|
|
87
87
|
// (`ln -s .env.example l && mv l .env`).
|
|
88
88
|
//
|
|
89
|
+
// #273: a command carried in a git config value (`git -c alias.x="!cat .env" x`,
|
|
90
|
+
// `-c core.pager=...`, `--config-env=core.pager=VAR` with VAR assigned in the same
|
|
91
|
+
// statement) is scanned as a nested command, like `bash -c`; `--config-env` on a
|
|
92
|
+
// command key whose variable is not assigned there denies as unmodelled.
|
|
93
|
+
//
|
|
94
|
+
// The same scan covers the statement's own leading env assignments that inject config or
|
|
95
|
+
// commands: GIT_CONFIG_KEY_<n> / GIT_CONFIG_VALUE_<n> pairs (read whatever GIT_CONFIG_COUNT
|
|
96
|
+
// says), GIT_CONFIG_PARAMETERS (an unparsable one on a command key denies as unmodelled), and
|
|
97
|
+
// the command-valued variables GIT_PAGER, PAGER, GIT_SSH_COMMAND, GIT_SSH, GIT_EXTERNAL_DIFF,
|
|
98
|
+
// GIT_EDITOR, GIT_SEQUENCE_EDITOR, EDITOR, VISUAL, GIT_ASKPASS, SSH_ASKPASS. Only for a git
|
|
99
|
+
// statement; variables exported in an earlier statement are not visible. Backslash escapes in a
|
|
100
|
+
// quoted value follow the shell (shellUnquote). Global options git does not list before -c are
|
|
101
|
+
// walked as flags (walkGitGlobals), never a reason to stop scanning.
|
|
102
|
+
//
|
|
89
103
|
// #227: a construct the guard cannot interpret with certainty (eval, source / `.`, a
|
|
90
104
|
// bash 5.3 `${ cmd; }`, printf -v, a function definition, an alias, a quoted gh api body
|
|
91
105
|
// key, a backslash-newline inside a word) is not emulated. As a FINAL gate, after the
|
|
@@ -142,10 +156,17 @@ function isSecretBasename(name) {
|
|
|
142
156
|
return !NON_SECRET_ENV_SUFFIXES.has(lower.slice(lower.lastIndexOf('.') + 1));
|
|
143
157
|
}
|
|
144
158
|
|
|
159
|
+
// #276: il token grezzo tiene `\`+char verbatim. Con un backslash la parola si legge come
|
|
160
|
+
// la shell (`\.env` è `.env`); senza, resta il solo strip della coppia di apici esterna
|
|
161
|
+
// (le forme con apici nel mezzo, `.e""nv`, restano al gate dei costrutti non modellati).
|
|
162
|
+
function unquoteWord(t) {
|
|
163
|
+
return String(t).includes('\\') ? shellUnquote(t) : stripQuotes(t);
|
|
164
|
+
}
|
|
165
|
+
|
|
145
166
|
// Anche la forma `<rev>:<path>` (`git show HEAD:.env`, `scp host:.env .`):
|
|
146
167
|
// il path dopo il primo `:` è letto quanto un operando nudo.
|
|
147
168
|
function isSecretPathArg(t) {
|
|
148
|
-
const bare =
|
|
169
|
+
const bare = unquoteWord(t);
|
|
149
170
|
if (!bare) return false;
|
|
150
171
|
if (isSecretBasename(basename(bare))) return true;
|
|
151
172
|
const colon = bare.indexOf(':');
|
|
@@ -201,14 +222,18 @@ const GIT_PATHSPEC_SUBCOMMANDS = new Map([
|
|
|
201
222
|
|
|
202
223
|
// Opzioni globali di git (usage line, git 2.49), per trovare il sottocomando.
|
|
203
224
|
// Un'opzione con valore consuma la parola successiva salvo forma
|
|
204
|
-
// `--opt=value
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
225
|
+
// `--opt=value`. Un'opzione in nessuno dei due set NON ferma la camminata
|
|
226
|
+
// (vedi walkGitGlobals): per il sottocomando esenta nulla (fail closed, un
|
|
227
|
+
// valore non è mai letto come sottocomando: `git -C ls-files show HEAD:.env`
|
|
228
|
+
// esegue `show`), per gli `-c` successivi li lascia comunque controllati.
|
|
229
|
+
const GIT_GLOBAL_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--attr-source', '--super-prefix']);
|
|
208
230
|
const GIT_GLOBAL_FLAGS = new Set([
|
|
209
231
|
'-p', '--paginate', '-P', '--no-pager', '--no-replace-objects', '--no-lazy-fetch',
|
|
210
|
-
'--no-optional-locks', '--no-advice', '--bare',
|
|
232
|
+
'--no-optional-locks', '--no-advice', '--bare', '--literal-pathspecs', '--glob-pathspecs',
|
|
233
|
+
'--noglob-pathspecs', '--icase-pathspecs', '--exec-path', '--html-path', '--man-path', '--info-path',
|
|
211
234
|
]);
|
|
235
|
+
// Flag che accetta anche la forma `--opt=value` ma mai un valore separato.
|
|
236
|
+
const GIT_GLOBAL_EQ_FLAGS = new Set(['--exec-path', '--list-cmds']);
|
|
212
237
|
|
|
213
238
|
// `cp`/`mv` scrivono la destinazione e non la stampano: un nome segreto che è
|
|
214
239
|
// SOLO la destinazione di una copia/spostamento a sorgente singola è un nome
|
|
@@ -318,19 +343,52 @@ function isListedFlag(text, flags) {
|
|
|
318
343
|
return /^-[A-Za-z]+$/.test(text) && [...text.slice(1)].every((ch) => flags.has(`-${ch}`));
|
|
319
344
|
}
|
|
320
345
|
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
346
|
+
// UNICA camminata sulle opzioni globali di git, condivisa da gitSubcommandIndex
|
|
347
|
+
// e da extractGitConfigCommands così le due non divergono. Restituisce
|
|
348
|
+
// { sub, known, configs }: `sub` = indice del primo operando non-opzione (-1 se
|
|
349
|
+
// assente), `known` = false se la camminata ha incontrato un'opzione fuori dai
|
|
350
|
+
// set (allora nessuna esenzione per il sottocomando), `configs` = i valori
|
|
351
|
+
// `-c` / `-ckey=value` / `--config-env` visti prima del sottocomando.
|
|
352
|
+
// Un'opzione sconosciuta non interrompe la camminata: si tratta come flag fino al
|
|
353
|
+
// primo operando non-opzione (fail toward scanning).
|
|
354
|
+
function walkGitGlobals(operands) {
|
|
355
|
+
const out = { sub: -1, known: true, configs: [] };
|
|
324
356
|
for (let k = 0; k < operands.length; k++) {
|
|
325
357
|
const t = operands[k];
|
|
326
|
-
if (!t.startsWith('-'))
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
358
|
+
if (!t.startsWith('-') || t === '-') { out.sub = k; return out; }
|
|
359
|
+
if (t === '-c' || t === '--config-env') {
|
|
360
|
+
if (k + 1 >= operands.length) { out.known = false; return out; }
|
|
361
|
+
out.configs.push({ kind: t === '-c' ? 'c' : 'env', value: operands[++k], sample: `${t} ${operands[k]}` });
|
|
362
|
+
} else if (t.startsWith('--config-env=')) {
|
|
363
|
+
out.configs.push({ kind: 'env', value: t.slice('--config-env='.length), sample: t });
|
|
364
|
+
} else if (isGluedGitConfig(t)) {
|
|
365
|
+
out.configs.push({ kind: 'c', value: t.slice(2), sample: t }); // #273: `-ckey=value`
|
|
366
|
+
} else if (GIT_GLOBAL_VALUE_OPTIONS.has(t)) {
|
|
367
|
+
if (k + 1 >= operands.length) { out.known = false; return out; }
|
|
368
|
+
k++; // `-C <path>` and friends: the value is not a config
|
|
369
|
+
} else if (GIT_GLOBAL_FLAGS.has(t)) {
|
|
370
|
+
// known flag
|
|
371
|
+
} else {
|
|
372
|
+
const eq = t.indexOf('=');
|
|
373
|
+
const name = eq === -1 ? t : t.slice(0, eq);
|
|
374
|
+
const longForm = t.startsWith('--') && eq !== -1 && (GIT_GLOBAL_VALUE_OPTIONS.has(name) || GIT_GLOBAL_EQ_FLAGS.has(name));
|
|
375
|
+
if (!longForm) out.known = false; // unknown option: treated as a flag, no exemption
|
|
376
|
+
}
|
|
332
377
|
}
|
|
333
|
-
return
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Indice del sottocomando git oltre le opzioni globali, o -1 se lo precede
|
|
382
|
+
// un'opzione sconosciuta o manca.
|
|
383
|
+
function gitSubcommandIndex(operands) {
|
|
384
|
+
const w = walkGitGlobals(operands);
|
|
385
|
+
return w.known ? w.sub : -1;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// #275: come gitSubcommandIndex ma anche dopo un'opzione globale sconosciuta
|
|
389
|
+
// (fail toward scanning: l'esenzione no, la scansione sì).
|
|
390
|
+
function gitSubcommandIndexLoose(operands) {
|
|
391
|
+
return walkGitGlobals(operands).sub;
|
|
334
392
|
}
|
|
335
393
|
|
|
336
394
|
// Indici dei pathspec di un sottocomando in GIT_PATHSPEC_SUBCOMMANDS. git
|
|
@@ -374,7 +432,7 @@ function copyDestinationIndices(operands) {
|
|
|
374
432
|
// controllo; ogni altro token resta controllato, quindi l'esenzione non può
|
|
375
433
|
// riciclare una lettura altrove nello statement.
|
|
376
434
|
function nameOnlyTokenIndices(bin, tokens) {
|
|
377
|
-
const operands = tokens.slice(1).map((t) =>
|
|
435
|
+
const operands = tokens.slice(1).map((t) => unquoteWord(stripTrailingPunct(t)));
|
|
378
436
|
let indices = NO_SKIP;
|
|
379
437
|
if (bin === 'git') indices = gitPathspecIndices(operands);
|
|
380
438
|
else if (bin === 'cp' || bin === 'mv') indices = copyDestinationIndices(operands);
|
|
@@ -386,22 +444,225 @@ function nameOnlyTokenIndices(bin, tokens) {
|
|
|
386
444
|
// yet re-tokenized) so the caller can recurse into it as a nested command.
|
|
387
445
|
// Best-effort: only the common single-quoted-argument idiom, not every way a
|
|
388
446
|
// shell can be told to execute a string.
|
|
447
|
+
// #276: ogni token si legge come la shell esterna (`\\` dentro apici doppi diventa `\`)
|
|
448
|
+
// prima del join, così la shell interna vede lo stesso testo.
|
|
389
449
|
function extractNestedCommand(stmtTokens) {
|
|
390
450
|
if (!stmtTokens.length) return null;
|
|
391
451
|
const bin = basename(stmtTokens[0]);
|
|
392
452
|
if (bin === 'eval') {
|
|
393
453
|
const rest = stmtTokens.slice(1);
|
|
394
454
|
if (!rest.length) return null;
|
|
395
|
-
return
|
|
455
|
+
return rest.map(unquoteWord).join(' ');
|
|
396
456
|
}
|
|
397
457
|
if (SHELL_DASH_C_VERBS.has(bin)) {
|
|
398
458
|
const cIdx = stmtTokens.indexOf('-c');
|
|
399
459
|
if (cIdx === -1 || cIdx + 1 >= stmtTokens.length) return null;
|
|
400
|
-
return
|
|
460
|
+
return stmtTokens.slice(cIdx + 1).map(unquoteWord).join(' ');
|
|
401
461
|
}
|
|
402
462
|
return null;
|
|
403
463
|
}
|
|
404
464
|
|
|
465
|
+
// #273: `git -c key=value` / `--config-env=key=ENV` can name a COMMAND git runs
|
|
466
|
+
// (an alias with `!`, a pager, an editor, an ssh command, ...), which the plain
|
|
467
|
+
// token walk only tests as a file name. The key families git runs as a command,
|
|
468
|
+
// matched on the lowercased key (section and variable are case-insensitive; a
|
|
469
|
+
// lowercased middle segment only widens a wildcard). `bang` = the value is a
|
|
470
|
+
// command only when it starts with `!` (the `!` is not part of the command).
|
|
471
|
+
const GIT_COMMAND_KEYS = [
|
|
472
|
+
{ re: /^alias\..+$/, bang: true },
|
|
473
|
+
{ re: /^core\.(pager|editor|sshcommand|fsmonitor)$/ },
|
|
474
|
+
{ re: /^pager\..+$/ },
|
|
475
|
+
{ re: /^sequence\.editor$/ },
|
|
476
|
+
{ re: /^diff\.external$/ },
|
|
477
|
+
{ re: /^diff\..+\.textconv$/ },
|
|
478
|
+
{ re: /^filter\..+\.(clean|smudge|process)$/ },
|
|
479
|
+
{ re: /^credential\.(.+\.)?helper$/, bang: true },
|
|
480
|
+
{ re: /^gpg\.(.+\.)?program$/ },
|
|
481
|
+
// #275: more keys git runs as a command.
|
|
482
|
+
{ re: /^core\.(askpass|gitproxy)$/ },
|
|
483
|
+
{ re: /^(mergetool|difftool|browser|man)\..+\.cmd$/ },
|
|
484
|
+
{ re: /^merge\..+\.driver$/ },
|
|
485
|
+
{ re: /^uploadpack\.packobjectshook$/ },
|
|
486
|
+
{ re: /^diff\..+\.command$/ },
|
|
487
|
+
];
|
|
488
|
+
|
|
489
|
+
// `-ckey=value` (value glued on, no space).
|
|
490
|
+
function isGluedGitConfig(t) {
|
|
491
|
+
return t.length > 2 && t.startsWith('-c') && !t.startsWith('--');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// The word as the shell hands it over: quote pairs removed, their content kept
|
|
495
|
+
// (`alias.x="!cat .env"` -> `alias.x=!cat .env`). Backslashes follow the shell:
|
|
496
|
+
// outside quotes one escapes the next char (backslash-newline vanishes), inside
|
|
497
|
+
// double quotes only before `"`, `\`, `$`, a backtick or a newline, and inside
|
|
498
|
+
// single quotes nothing is special. No expansions: best-effort like the rest.
|
|
499
|
+
function shellUnquote(word) {
|
|
500
|
+
const chars = [...String(word)];
|
|
501
|
+
let out = '';
|
|
502
|
+
let q = null;
|
|
503
|
+
for (let i = 0; i < chars.length; i++) {
|
|
504
|
+
const ch = chars[i];
|
|
505
|
+
if (q === "'") { if (ch === "'") q = null; else out += ch; continue; }
|
|
506
|
+
if (ch === '\\' && i + 1 < chars.length) {
|
|
507
|
+
const nx = chars[i + 1];
|
|
508
|
+
if (q === null || '"\\$`\n'.includes(nx)) {
|
|
509
|
+
i++;
|
|
510
|
+
if (nx !== '\n') out += nx;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (q === '"') { if (ch === '"') q = null; else out += ch; }
|
|
515
|
+
else if (ch === '"' || ch === "'") q = ch;
|
|
516
|
+
else out += ch;
|
|
517
|
+
}
|
|
518
|
+
return out;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// The command text a git config value carries for `key`, or null when the key
|
|
522
|
+
// is not command-valued (or `!`-less where the `!` is required).
|
|
523
|
+
function isGitCommandKey(key) {
|
|
524
|
+
return GIT_COMMAND_KEYS.some((k) => k.re.test(key.toLowerCase()));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function gitConfigCommand(key, value) {
|
|
528
|
+
const spec = GIT_COMMAND_KEYS.find((k) => k.re.test(key.toLowerCase()));
|
|
529
|
+
if (!spec) return null;
|
|
530
|
+
if (spec.bang) return value.startsWith('!') && value.length > 1 ? value.slice(1) : null;
|
|
531
|
+
return value ? value : null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// #273: environment variables whose value git (or ssh under it) runs as a command.
|
|
535
|
+
const GIT_COMMAND_ENV_VARS = [
|
|
536
|
+
'GIT_PAGER', 'PAGER', 'GIT_SSH_COMMAND', 'GIT_SSH', 'GIT_EXTERNAL_DIFF', 'GIT_EDITOR',
|
|
537
|
+
'GIT_SEQUENCE_EDITOR', 'EDITOR', 'VISUAL', 'GIT_ASKPASS', 'SSH_ASKPASS',
|
|
538
|
+
];
|
|
539
|
+
|
|
540
|
+
// Entries of a GIT_CONFIG_PARAMETERS value (space-separated `'key=value'` or
|
|
541
|
+
// `'key'='value'`) as [key, value] pairs, or null when the quoting is unbalanced.
|
|
542
|
+
function parseGitConfigParameters(text) {
|
|
543
|
+
const entries = [];
|
|
544
|
+
for (const word of tokenize(text)) {
|
|
545
|
+
const kv = splitAssignment(shellUnquote(word));
|
|
546
|
+
if (kv) entries.push(kv);
|
|
547
|
+
}
|
|
548
|
+
const quotes = (text.match(/'/g) || []).length;
|
|
549
|
+
return quotes % 2 === 0 ? entries : null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Splits `NAME=value` at the first `=` (null when there is none or the name is empty).
|
|
553
|
+
function splitAssignment(kv) {
|
|
554
|
+
const eq = kv.indexOf('=');
|
|
555
|
+
return eq <= 0 ? null : [kv.slice(0, eq), kv.slice(eq + 1)];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// #275: `git config` options that take a separate value, and the ones that only read.
|
|
559
|
+
const GIT_CONFIG_VALUE_OPTIONS = new Set(['-f', '--file', '--blob', '-t', '--type', '--default', '--comment', '--value']);
|
|
560
|
+
const GIT_CONFIG_FLAGS = new Set([
|
|
561
|
+
'--global', '--system', '--local', '--worktree', '--add', '--replace-all', '--append', '--all',
|
|
562
|
+
'--bool', '--int', '--bool-or-int', '--path', '--expiry-date', '--includes', '--no-includes',
|
|
563
|
+
'--null', '-z', '--fixed-value', '--show-origin', '--show-scope', '--name-only', '--no-type',
|
|
564
|
+
]);
|
|
565
|
+
const GIT_CONFIG_READ_OPTIONS = new Set([
|
|
566
|
+
'--get', '--get-all', '--get-regexp', '--get-urlmatch', '--get-color', '--get-colorbool', '-l',
|
|
567
|
+
'--list', '--unset', '--unset-all', '-e', '--edit', '--rename-section', '--remove-section',
|
|
568
|
+
]);
|
|
569
|
+
const GIT_CONFIG_READ_WORDS = new Set([
|
|
570
|
+
'get', 'list', 'unset', 'edit', 'rename-section', 'remove-section',
|
|
571
|
+
]);
|
|
572
|
+
|
|
573
|
+
// #275: the [key, value] pairs a `git config` invocation writes. `args` = the words after
|
|
574
|
+
// `config`. The first positional is the key and the second the value (a leading `set`
|
|
575
|
+
// is the mode word); read verbs write nothing. An option the walk does not know could
|
|
576
|
+
// hide which word is the value, so each positional after a command-valued key is also
|
|
577
|
+
// taken as its value (fail toward scanning). A single `key=value` token is not a write.
|
|
578
|
+
function gitConfigWritePairs(args) {
|
|
579
|
+
const positional = [];
|
|
580
|
+
let unknown = false;
|
|
581
|
+
for (let k = 0; k < args.length; k++) {
|
|
582
|
+
const t = args[k];
|
|
583
|
+
// #275: `--` ends the options, every later word is a positional (`-n; cat .env` too).
|
|
584
|
+
if (t === '--') { positional.push(...args.slice(k + 1)); break; }
|
|
585
|
+
if (GIT_CONFIG_READ_OPTIONS.has(t)) return [];
|
|
586
|
+
if (!t.startsWith('-') || t === '-') {
|
|
587
|
+
if (positional.length === 0 && GIT_CONFIG_READ_WORDS.has(t)) return [];
|
|
588
|
+
if (positional.length === 0 && t === 'set') continue;
|
|
589
|
+
positional.push(t);
|
|
590
|
+
} else if (GIT_CONFIG_VALUE_OPTIONS.has(t)) k++;
|
|
591
|
+
else if (!GIT_CONFIG_FLAGS.has(t) && !/^--(file|blob|type|default|comment|value)=/.test(t)) unknown = true;
|
|
592
|
+
}
|
|
593
|
+
const pairs = positional.length >= 2 ? [[positional[0], positional[1]]] : [];
|
|
594
|
+
if (unknown) {
|
|
595
|
+
positional.forEach((key, i) => {
|
|
596
|
+
if (i + 1 < positional.length && isGitCommandKey(key)) pairs.push([key, positional[i + 1]]);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return pairs;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Every command text carried in the git global options of a statement, from
|
|
603
|
+
// `-c key=value`, `-ckey=value` and `--config-env[=| ]key=ENV`, plus the
|
|
604
|
+
// `--config-env` forms on a command-valued key whose ENV the statement does not
|
|
605
|
+
// assign (`unresolved`: the guard cannot see the value, so it fails closed).
|
|
606
|
+
// ENV is resolved from the statement's own leading NAME=value assignments.
|
|
607
|
+
function extractGitConfigCommands(tokens) {
|
|
608
|
+
const found = { commands: [], unresolved: [], unparsed: [] };
|
|
609
|
+
const first = skipEnvPrefix(tokens);
|
|
610
|
+
if (first >= tokens.length || basename(shellUnquote(tokens[first])) !== 'git') return found;
|
|
611
|
+
const env = new Map();
|
|
612
|
+
for (const a of tokens.slice(0, first)) {
|
|
613
|
+
const kv = splitAssignment(shellUnquote(a));
|
|
614
|
+
if (kv) env.set(kv[0], kv[1]);
|
|
615
|
+
}
|
|
616
|
+
const add = (key, value) => {
|
|
617
|
+
const cmd = gitConfigCommand(key, value);
|
|
618
|
+
if (cmd) found.commands.push(cmd);
|
|
619
|
+
};
|
|
620
|
+
// #273: config injected through the statement's own leading assignments. Pairs
|
|
621
|
+
// are read regardless of GIT_CONFIG_COUNT (fail toward scanning).
|
|
622
|
+
for (const [name, value] of env) {
|
|
623
|
+
const m = /^GIT_CONFIG_KEY_(\d+)$/.exec(name);
|
|
624
|
+
if (m && env.has(`GIT_CONFIG_VALUE_${m[1]}`)) add(value, env.get(`GIT_CONFIG_VALUE_${m[1]}`));
|
|
625
|
+
if (GIT_COMMAND_ENV_VARS.includes(name) && value) found.commands.push(value);
|
|
626
|
+
}
|
|
627
|
+
if (env.has('GIT_CONFIG_PARAMETERS')) {
|
|
628
|
+
const text = env.get('GIT_CONFIG_PARAMETERS');
|
|
629
|
+
const entries = parseGitConfigParameters(text);
|
|
630
|
+
if (entries) entries.forEach(([k, v]) => add(k, v));
|
|
631
|
+
else if ((text.match(/[A-Za-z0-9._-]+(?==)/g) || []).some(isGitCommandKey)) found.unparsed.push(`GIT_CONFIG_PARAMETERS=${text}`);
|
|
632
|
+
}
|
|
633
|
+
const operands = tokens.slice(first + 1).map(shellUnquote);
|
|
634
|
+
for (const cfg of walkGitGlobals(operands).configs) {
|
|
635
|
+
const kv = splitAssignment(cfg.value);
|
|
636
|
+
if (!kv) continue;
|
|
637
|
+
if (cfg.kind === 'c') { add(kv[0], kv[1]); continue; }
|
|
638
|
+
if (!isGitCommandKey(kv[0])) continue;
|
|
639
|
+
if (env.has(kv[1])) add(kv[0], env.get(kv[1])); else found.unresolved.push(cfg.sample);
|
|
640
|
+
}
|
|
641
|
+
// #275: `git config <key> <value>` writes a command into the config.
|
|
642
|
+
const sub = gitSubcommandIndexLoose(operands);
|
|
643
|
+
if (sub !== -1 && operands[sub] === 'config') {
|
|
644
|
+
gitConfigWritePairs(operands.slice(sub + 1)).forEach(([k, v]) => add(k, v));
|
|
645
|
+
}
|
|
646
|
+
return found;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// The unmodelled-hit shape (lib/unmodelled.js) for a `--config-env` on a command key whose
|
|
650
|
+
// variable the statement does not assign.
|
|
651
|
+
const GIT_CONFIG_ENV_UNMODELLED = (sample) => ({
|
|
652
|
+
id: 'git-config-env',
|
|
653
|
+
label: 'a git --config-env on a command-valued key whose variable is not assigned in the same statement',
|
|
654
|
+
hint: 'assign it in the same statement (`VAR="cmd" git --config-env=key=VAR ...`) or pass the command with `git -c key=cmd`',
|
|
655
|
+
sample,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// The unmodelled-hit shape for a GIT_CONFIG_PARAMETERS value the guard cannot parse.
|
|
659
|
+
const GIT_CONFIG_PARAMETERS_UNMODELLED = (sample) => ({
|
|
660
|
+
id: 'git-config-parameters',
|
|
661
|
+
label: 'a GIT_CONFIG_PARAMETERS value with unbalanced quoting that names a command-valued key',
|
|
662
|
+
hint: 'pass the command with `git -c key=cmd` or a well-quoted GIT_CONFIG_PARAMETERS',
|
|
663
|
+
sample,
|
|
664
|
+
});
|
|
665
|
+
|
|
405
666
|
// Scan a single top-level statement for a secret path. Recurses into a
|
|
406
667
|
// bash -c/eval quoted sub-command first (finding #1), routes grep/rg/ag/sed/
|
|
407
668
|
// awk through the shared pattern-skipping walk (finding #4), and falls back
|
|
@@ -419,6 +680,19 @@ function scanStatementForSecretPath(stmt) {
|
|
|
419
680
|
// stray `-f .env` on the wrapper itself) for defence in depth.
|
|
420
681
|
}
|
|
421
682
|
|
|
683
|
+
// #273: a command carried in a git config value (`-c core.pager="cat .env"`).
|
|
684
|
+
const git = extractGitConfigCommands(tokens);
|
|
685
|
+
for (const text of git.commands) {
|
|
686
|
+
const hit = scanBashForSecretPath(text);
|
|
687
|
+
if (hit) return hit;
|
|
688
|
+
}
|
|
689
|
+
if (git.unresolved.length) {
|
|
690
|
+
return { path: git.unresolved[0], unmodelled: GIT_CONFIG_ENV_UNMODELLED(git.unresolved[0]) };
|
|
691
|
+
}
|
|
692
|
+
if (git.unparsed.length) {
|
|
693
|
+
return { path: git.unparsed[0], unmodelled: GIT_CONFIG_PARAMETERS_UNMODELLED(git.unparsed[0]) };
|
|
694
|
+
}
|
|
695
|
+
|
|
422
696
|
const bin = basename(tokens[0]);
|
|
423
697
|
if (SEARCH_VERBS.has(bin)) {
|
|
424
698
|
return scanArgsSkippingPattern(tokens.slice(1), SEARCH_VERB_CONFIG[bin], isSecretPathArg);
|
|
@@ -462,13 +736,13 @@ function invokesInterpreter(stmt) {
|
|
|
462
736
|
let i = 0;
|
|
463
737
|
while (i < tokens.length && LEADING_KEYWORDS.has(tokens[i])) i++;
|
|
464
738
|
i += skipEnvPrefix(tokens.slice(i));
|
|
465
|
-
if (i < tokens.length && basename(
|
|
739
|
+
if (i < tokens.length && basename(unquoteWord(tokens[i])) === 'env') {
|
|
466
740
|
i++;
|
|
467
741
|
while (i < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]) || tokens[i].startsWith('-'))) {
|
|
468
742
|
i += ENV_VALUE_OPTIONS.has(tokens[i]) ? 2 : 1;
|
|
469
743
|
}
|
|
470
744
|
}
|
|
471
|
-
return i < tokens.length && STDIN_SCRIPT_INTERPRETERS.has(basename(
|
|
745
|
+
return i < tokens.length && STDIN_SCRIPT_INTERPRETERS.has(basename(unquoteWord(tokens[i])));
|
|
472
746
|
}
|
|
473
747
|
|
|
474
748
|
// Walk the command's top-level statements (quote-aware; `|`, `;`, `&&`,
|
|
@@ -570,7 +844,7 @@ function detectSecretRead(toolName, toolInput) {
|
|
|
570
844
|
if (toolName === 'Bash') {
|
|
571
845
|
const cmd = input.command || '';
|
|
572
846
|
const hit = scanBashForSecretPath(cmd);
|
|
573
|
-
if (hit) return { path: hit };
|
|
847
|
+
if (hit) return typeof hit === 'object' ? hit : { path: hit }; // object: an unmodelled hit (#273)
|
|
574
848
|
const um = unmodelledSecretPiece(cmd); // a final gate: the precise scan keeps priority
|
|
575
849
|
return um ? { path: um.piece, unmodelled: um.hit } : null;
|
|
576
850
|
}
|