@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
|
@@ -10,6 +10,8 @@ const {
|
|
|
10
10
|
isMockupApprovalAsk,
|
|
11
11
|
hasLink,
|
|
12
12
|
proseBeforeAsk,
|
|
13
|
+
pendingAskIndex,
|
|
14
|
+
askHasLink,
|
|
13
15
|
} = require('./trailhead-mockup-link-guard.js');
|
|
14
16
|
|
|
15
17
|
const HOOK = path.join(__dirname, 'trailhead-mockup-link-guard.js');
|
|
@@ -20,7 +22,7 @@ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
|
20
22
|
// Costruttori di righe transcript (stesso shape JSONL di Claude Code)
|
|
21
23
|
const text = (t) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: t }] } });
|
|
22
24
|
const thinking = () => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'thinking', thinking: '' }] } });
|
|
23
|
-
const toolUse = (name, input = {}) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'tool_use', id
|
|
25
|
+
const toolUse = (name, input = {}, id = 'x') => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'tool_use', id, name, input }] } });
|
|
24
26
|
const toolResult = (c) => ({ type: 'user', isSidechain: false, message: { content: [{ type: 'tool_result', tool_use_id: 'x', content: c }] } });
|
|
25
27
|
const userPrompt = (c) => ({ type: 'user', isSidechain: false, message: { content: c } });
|
|
26
28
|
|
|
@@ -103,8 +105,45 @@ const sidechain = [
|
|
|
103
105
|
];
|
|
104
106
|
ok('sidechain text is ignored', !hasLink(proseBeforeAsk(sidechain)));
|
|
105
107
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
// Senza ancora: comportamento storico, si parte dalla coda del transcript
|
|
109
|
+
const noAnchor = [userPrompt('x'), text('Ecco https://a.b/c')];
|
|
110
|
+
ok('without an anchor the prose is read from the transcript tail', hasLink(proseBeforeAsk(noAnchor)));
|
|
111
|
+
|
|
112
|
+
// Con ancora: si parte dall'ask trovato, non dalla coda
|
|
113
|
+
const anchored = [
|
|
114
|
+
userPrompt('x'),
|
|
115
|
+
text('Mockup: https://a.b/c'),
|
|
116
|
+
toolUse('AskUserQuestion', approvalInput, 'ask1'),
|
|
117
|
+
toolResult('Approvo'),
|
|
118
|
+
text('Altro testo senza link'),
|
|
119
|
+
];
|
|
120
|
+
ok('with an anchor the prose is read back from the anchored ask', hasLink(proseBeforeAsk(anchored, 2)));
|
|
121
|
+
|
|
122
|
+
// --- askHasLink ---
|
|
123
|
+
const askWith = (over) => ({ questions: [{ ...approvalInput.questions[0], ...over }] });
|
|
124
|
+
ok('link in the approval question counts', askHasLink(askWith({ question: 'Approvi il mockup https://claude.ai/artifact/AbC123?' })));
|
|
125
|
+
ok('link in the approval header counts', askHasLink(askWith({ header: 'https://claude.ai/artifact/AbC123' })));
|
|
126
|
+
ok('link in an approval option label counts', askHasLink(askWith({ options: [{ label: 'Apri https://a.b/c' }, { label: 'No' }] })));
|
|
127
|
+
ok('link in an approval option description counts', askHasLink(askWith({ options: [{ label: 'Approvo', description: 'vedi mockups/faq.html' }] })));
|
|
128
|
+
ok('approval ask without any link has none', !askHasLink(approvalInput));
|
|
129
|
+
ok('link only in a different non-approval question does not count', !askHasLink({
|
|
130
|
+
questions: [
|
|
131
|
+
{ question: 'Dove sta il piano?', options: [{ label: 'https://a.b/piano' }] },
|
|
132
|
+
approvalInput.questions[0],
|
|
133
|
+
],
|
|
134
|
+
}));
|
|
135
|
+
ok('malformed input has no ask link', !askHasLink({}) && !askHasLink(null));
|
|
136
|
+
|
|
137
|
+
// --- pendingAskIndex ---
|
|
138
|
+
const withIds = [userPrompt('x'), toolUse('Bash', {}, 'b1'), toolResult('ok'), text('Prosa'), toolUse('AskUserQuestion', approvalInput, 'ask1')];
|
|
139
|
+
ok('pendingAskIndex finds the entry by tool_use id', pendingAskIndex(withIds, 'ask1', approvalInput) === 4);
|
|
140
|
+
ok('pendingAskIndex by id ignores a missing id', pendingAskIndex(withIds, 'nope', approvalInput) === -1);
|
|
141
|
+
ok('pendingAskIndex without id falls back to input equality on the tail', pendingAskIndex(withIds, undefined, approvalInput) === 4);
|
|
142
|
+
ok('pendingAskIndex without id is -1 when the tail ask has a different input',
|
|
143
|
+
pendingAskIndex(withIds, undefined, askWith({ question: 'Approvi il mockup B?' })) === -1);
|
|
144
|
+
ok('pendingAskIndex is -1 when the tail is the previous tool_result',
|
|
145
|
+
pendingAskIndex([userPrompt('x'), toolUse('Bash', {}, 'b1'), toolResult('ok')], undefined, approvalInput) === -1);
|
|
146
|
+
ok('pendingAskIndex ignores sidechain entries', pendingAskIndex([{ ...toolUse('AskUserQuestion', approvalInput, 'ask1'), isSidechain: true }], 'ask1', approvalInput) === -1);
|
|
108
147
|
|
|
109
148
|
// --- end-to-end ---
|
|
110
149
|
function writeTranscript(entries) {
|
|
@@ -138,6 +177,39 @@ ok('other tools are ignored', r4.code === 0);
|
|
|
138
177
|
const r5 = runHook({ tool_name: 'AskUserQuestion', tool_input: { questions: [{ question: 'Approvi il piano?', options: [] }] }, transcript_path: writeTranscript(failing) });
|
|
139
178
|
ok('non-mockup ask is allowed', r5.code === 0);
|
|
140
179
|
|
|
180
|
+
// Shape reale di Claude Code 2.1.295: il messaggio corrente non e' ancora nel transcript
|
|
181
|
+
const unflushed = [userPrompt('lavora #12'), toolUse('Bash', {}, 'b1'), toolResult('ok')];
|
|
182
|
+
const ra = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(unflushed) });
|
|
183
|
+
ok('(a) transcript ending at a previous tool_result fails open', ra.code === 0 && ra.out.trim() === '');
|
|
184
|
+
|
|
185
|
+
const flushedNoLink = [...failing.slice(0, 5), toolUse('AskUserQuestion', approvalInput, 'ask1')];
|
|
186
|
+
const rb = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(flushedNoLink) });
|
|
187
|
+
ok('(b) tool_use_id with the ask entry present and no link in the prose is blocked', rb.code === 2 && /MOCKUP_LINK_MISSING/.test(rb.out));
|
|
188
|
+
|
|
189
|
+
const linkedAsk = askWith({ question: 'Approvi il mockup https://claude.ai/artifact/AbC123?' });
|
|
190
|
+
const rc = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: linkedAsk, transcript_path: writeTranscript(failing) });
|
|
191
|
+
ok('(c) link carried by the ask question is allowed', rc.code === 0 && rc.out.trim() === '');
|
|
192
|
+
|
|
193
|
+
const otherPending = [userPrompt('x'), text('Niente link'), toolUse('AskUserQuestion', { questions: [{ question: 'Altro?', options: [] }] }, 'other')];
|
|
194
|
+
const rd = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(otherPending) });
|
|
195
|
+
ok('(d) transcript ending in a different pending ask fails open', rd.code === 0 && rd.out.trim() === '');
|
|
196
|
+
|
|
197
|
+
// Testo e tool_use nella STESSA riga di transcript
|
|
198
|
+
const sameEntry = (t) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: t }, { type: 'tool_use', id: 'ask1', name: 'AskUserQuestion', input: approvalInput }] } });
|
|
199
|
+
ok('text before the tool_use in the same entry counts as prose', hasLink(proseBeforeAsk([userPrompt('x'), sameEntry('Vedi https://a.b/c')], 1)));
|
|
200
|
+
ok('same-entry text is also read from the tail', hasLink(proseBeforeAsk([userPrompt('x'), sameEntry('Vedi https://a.b/c')])));
|
|
201
|
+
const rs1 = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript([userPrompt('x'), sameEntry('Vedi https://a.b/c')]) });
|
|
202
|
+
ok('same-entry text with a link is allowed', rs1.code === 0 && rs1.out.trim() === '');
|
|
203
|
+
const rs2 = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript([userPrompt('x'), sameEntry('Mockup pronto su MyApp')]) });
|
|
204
|
+
ok('same-entry text without a link is blocked', rs2.code === 2 && /MOCKUP_LINK_MISSING/.test(rs2.out));
|
|
205
|
+
|
|
206
|
+
// Senza tool_use_id: ask in coda identico a tool_input (fallback per uguaglianza)
|
|
207
|
+
const ri1 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(flushedNoLink) });
|
|
208
|
+
ok('no id, identical tail ask, no link in the prose is blocked', ri1.code === 2 && /MOCKUP_LINK_MISSING/.test(ri1.out));
|
|
209
|
+
const flushedLink = [...failing.slice(0, 4), text('Mockup: https://claude.ai/artifact/AbC123xyz'), toolUse('AskUserQuestion', approvalInput, 'ask1')];
|
|
210
|
+
const ri2 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(flushedLink) });
|
|
211
|
+
ok('no id, identical tail ask, link in the prose is allowed', ri2.code === 0 && ri2.out.trim() === '');
|
|
212
|
+
|
|
141
213
|
let r6;
|
|
142
214
|
try { execFileSync('node', [HOOK], { input: 'not json' }); r6 = 0; } catch (e) { r6 = e.status; }
|
|
143
215
|
ok('unparseable input fails open', r6 === 0);
|
|
@@ -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,35 +266,48 @@ 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;
|
|
240
273
|
}
|
|
241
274
|
|
|
242
|
-
function
|
|
275
|
+
function blockVerdict(hit) {
|
|
243
276
|
const reason =
|
|
244
277
|
"trailhead search-hygiene: a 'cd <dir>' (or pushd/env -C) followed by a relative " +
|
|
245
278
|
`${hit.verb} read (\`${hit.file}\`) makes the read target non-static, which trips the ` +
|
|
246
279
|
'Read() deny rule under bypass permissions (prompting for manual approval instead of ' +
|
|
247
280
|
'running headless). Pass explicit paths instead and never cd first: ' +
|
|
248
281
|
'grep -niE "<patterns>" <abs>/<file>, git -C <abs> ..., or use the Grep/Read tools.';
|
|
249
|
-
|
|
250
|
-
|
|
282
|
+
return { kind: 'block', payload: { decision: 'block', code: 'SEARCH_HYGIENE_VIOLATION', reason } };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
|
|
286
|
+
// exits or writes; any internal error falls through to allow.
|
|
287
|
+
function evaluate(cmd, { cwd } = {}) {
|
|
288
|
+
try {
|
|
289
|
+
const hit = detectSearchHygieneViolation(cmd, { cwd });
|
|
290
|
+
if (hit) return blockVerdict(hit);
|
|
291
|
+
} catch {
|
|
292
|
+
// fall through to allow: a guard must never wedge the workflow
|
|
293
|
+
}
|
|
294
|
+
return { kind: 'allow' };
|
|
251
295
|
}
|
|
252
296
|
|
|
253
297
|
function run(data) {
|
|
254
298
|
let cmd = '';
|
|
299
|
+
let cwd;
|
|
255
300
|
try {
|
|
256
|
-
|
|
301
|
+
const input = JSON.parse(data);
|
|
302
|
+
cmd = (input.tool_input || {}).command || '';
|
|
303
|
+
cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
|
|
257
304
|
} catch {
|
|
258
305
|
process.exit(0); // unparseable input: nothing to gate
|
|
259
306
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
// fall through to allow: a guard must never wedge the workflow
|
|
307
|
+
const v = evaluate(cmd, { cwd });
|
|
308
|
+
if (v.kind === 'block') {
|
|
309
|
+
process.stdout.write(JSON.stringify(v.payload));
|
|
310
|
+
process.exit(2);
|
|
265
311
|
}
|
|
266
312
|
process.exit(0);
|
|
267
313
|
}
|
|
@@ -276,4 +322,4 @@ if (require.main === module) {
|
|
|
276
322
|
});
|
|
277
323
|
}
|
|
278
324
|
|
|
279
|
-
module.exports = { detectSearchHygieneViolation };
|
|
325
|
+
module.exports = { detectSearchHygieneViolation, evaluate };
|
|
@@ -5,14 +5,17 @@ const assert = require('assert');
|
|
|
5
5
|
const { execFileSync } = require('child_process');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
|
|
9
|
+
const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-search-guard.js');
|
|
9
10
|
|
|
10
11
|
let passed = 0;
|
|
11
12
|
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
12
13
|
|
|
13
14
|
// Run the hook end-to-end: pipe tool_input JSON on stdin, capture {code, out}.
|
|
14
|
-
function runHook(command) {
|
|
15
|
-
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);
|
|
16
19
|
try {
|
|
17
20
|
const out = execFileSync('node', [HOOK], { input });
|
|
18
21
|
return { code: 0, out: out.toString() };
|
|
@@ -113,4 +116,66 @@ ok('cd + grep -- -x <relative file> is blocked (exit 2), `--` must not eat the f
|
|
|
113
116
|
const f1 = runHook('cd app && rg -g "*.js" TODO src/foo.js');
|
|
114
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));
|
|
115
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
|
+
|
|
116
181
|
console.log(`✓ search-guard: ${passed} assertions passed`);
|