@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Pure view logic of the trailhead mod: parse the work state file, build the
|
|
2
2
|
// status line and the pipeline stages. No engine calls here, so it is easy to test.
|
|
3
|
-
import type { WorkState } from '../../types'
|
|
3
|
+
import type { AutoRun, RunCounts, WorkState } from '../../types'
|
|
4
|
+
import { RESUME_HINT_STATUS, runLabel, runParts } from './auto-run-view'
|
|
4
5
|
|
|
5
6
|
export const STEPS = ['discuss', 'plan', 'execute', 'verify', 'resolve', 'paused'] as const
|
|
6
7
|
export const STAGES = ['discuss', 'plan', 'execute', 'verify'] as const
|
|
@@ -11,6 +12,7 @@ export type Stage = { name: StageName; status: StageStatus }
|
|
|
11
12
|
export type Pipeline = { stages: readonly Stage[]; isPaused: boolean }
|
|
12
13
|
|
|
13
14
|
const ELLIPSIS = '…'
|
|
15
|
+
export const ELSEWHERE = 'in another session'
|
|
14
16
|
|
|
15
17
|
// Cuts by code point, so a surrogate pair (an emoji in an issue title) is never split.
|
|
16
18
|
export const fit = (text: string, room: number): string => {
|
|
@@ -66,17 +68,48 @@ const stableJson = (value: unknown): string => {
|
|
|
66
68
|
return `{${keys.map(k => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
// The state belongs to this session (a state with no session counts as its own, like the orphan rule).
|
|
72
|
+
const isOwn = (state: WorkState | null, session: string): boolean =>
|
|
73
|
+
state !== null && (state.session === null || state.session === session)
|
|
74
|
+
|
|
75
|
+
// The ticket this session owns across a work-state file shared with other sessions. A state it wrote
|
|
76
|
+
// sets it; one another session wrote leaves it; a missing file clears it only when the last state read
|
|
77
|
+
// was this session's (its own Resolve), so another session's cleanup never drops the mark.
|
|
78
|
+
export const nextOwnTicket = (
|
|
79
|
+
prev: number | null,
|
|
80
|
+
lastState: WorkState | null,
|
|
81
|
+
nextState: WorkState | null,
|
|
82
|
+
session: string,
|
|
83
|
+
): number | null => {
|
|
84
|
+
if (nextState !== null) return isOwn(nextState, session) ? nextState.ticket : prev
|
|
85
|
+
return isOwn(lastState, session) ? null : prev
|
|
86
|
+
}
|
|
87
|
+
|
|
69
88
|
export const sameValue = (a: unknown, b: unknown): boolean => stableJson(a) === stableJson(b)
|
|
70
89
|
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
// What the autonomous run adds to the status: the run found beside the main checkout, its scope counts, and
|
|
91
|
+
// whether the resume hint shows. `elsewhere`: the foreign state's session is not known gone, so the
|
|
92
|
+
// status words it neutrally instead of offering to resume it.
|
|
93
|
+
export type RunInfo = { run: AutoRun | null; counts: RunCounts | null; hint: boolean; elsewhere?: boolean }
|
|
94
|
+
|
|
95
|
+
export const statusLine = (state: WorkState | null, isOrphaned: boolean, info?: RunInfo): string | undefined => {
|
|
96
|
+
const hint = info?.hint === true ? RESUME_HINT_STATUS : null
|
|
97
|
+
if (state === null || state.ticket === null) {
|
|
98
|
+
// Nessuno stato proprio: il run (slot senza sessione) o il suggerimento di ripresa, se ce ne sono.
|
|
99
|
+
if (hint !== null) return hint
|
|
100
|
+
return info?.run == null ? undefined : runLabel(info.run, info.counts)
|
|
101
|
+
}
|
|
73
102
|
const where = state.map === null ? 'whiteboard' : `map #${state.map}`
|
|
74
103
|
const ticket = state.title === null ? `#${state.ticket}` : `#${state.ticket} ${state.title}`
|
|
75
104
|
// No own "trailhead" prefix: the engine already prefixes a plugin's status with its name.
|
|
76
105
|
const parts = [where, ticket]
|
|
77
106
|
if (state.step !== null) parts.push(state.step)
|
|
78
|
-
if (state.auto)
|
|
79
|
-
|
|
107
|
+
if (state.auto) {
|
|
108
|
+
const [first, ...rest] = runParts(info?.run ?? { done: state.autoDone }, info?.counts ?? null)
|
|
109
|
+
parts.push(`auto ${first}`, ...rest)
|
|
110
|
+
}
|
|
111
|
+
if (isOrphaned) parts.push(info?.elsewhere === true ? ELSEWHERE : 'orphaned, resume?')
|
|
112
|
+
if (hint !== null) parts.push(hint)
|
|
80
113
|
return parts.join(' · ')
|
|
81
114
|
}
|
|
82
115
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Foglia senza import, condivisa da work-state-locate e dal run autonomo: il parser di
|
|
2
|
+
// `git worktree list --porcelain` e il tetto dei fallimenti di fila.
|
|
3
|
+
|
|
4
|
+
// Quanti fallimenti di fila tengono l'ultimo risultato noto prima di azzerarlo.
|
|
5
|
+
export const MAX_FAILURES = 3
|
|
6
|
+
|
|
7
|
+
// Path and full branch ref of each checkout in `git worktree list --porcelain` (null: detached or bare).
|
|
8
|
+
export const parseWorktreeBranches = (porcelain: string): { path: string; branch: string | null }[] =>
|
|
9
|
+
porcelain
|
|
10
|
+
.split(/\n\s*\n/)
|
|
11
|
+
.map(block => block.split('\n'))
|
|
12
|
+
.filter(lines => lines.some(line => line.startsWith('worktree /')))
|
|
13
|
+
.map(lines => {
|
|
14
|
+
const path = (lines.find(line => line.startsWith('worktree ')) as string).slice('worktree '.length).replace(/(.)\/+$/, '$1')
|
|
15
|
+
const branch = lines.find(line => line.startsWith('branch '))
|
|
16
|
+
return { path, branch: branch === undefined ? null : branch.slice('branch '.length) }
|
|
17
|
+
})
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// trailhead-bash-guards.js: PreToolUse(Bash) hook.
|
|
3
|
-
// One process for the
|
|
4
|
-
// instead of
|
|
3
|
+
// One process for the six Bash guards (commit, secret, install, search, body, labels)
|
|
4
|
+
// instead of six: each Bash call pays a single node start-up. The guards keep
|
|
5
5
|
// their own modules and verdicts; this runner only calls each one's pure
|
|
6
|
-
// evaluate(cmd) in a fixed order (commit, secret, install, search, body).
|
|
6
|
+
// evaluate(cmd) in a fixed order (commit, secret, install, search, body, labels).
|
|
7
7
|
// - First block wins: its payload goes to stdout and the hook exits 2; later
|
|
8
8
|
// guards do not run. An earlier advisory keeps its stderr line.
|
|
9
9
|
// - No block: advisories (secret-guard's "could not scan") surface on stderr
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// - Failure posture is unchanged per guard: a throwing guard counts as allow
|
|
12
12
|
// and the others still run; a guard module that fails to load is skipped
|
|
13
13
|
// with a one-line stderr warning.
|
|
14
|
-
// - --guards=<name,...> picks the guards (default: all
|
|
15
|
-
// ships no search-guard and registers --guards=commit,secret,install,body.
|
|
16
|
-
// A missing, empty, malformed or unknown-name list falls back to all
|
|
14
|
+
// - --guards=<name,...> picks the guards (default: all six). The Codex install
|
|
15
|
+
// ships no search-guard and registers --guards=commit,secret,install,body,labels.
|
|
16
|
+
// A missing, empty, malformed or unknown-name list falls back to all six, so
|
|
17
17
|
// a config typo never disables a guard.
|
|
18
18
|
// - TRAILHEAD_MOD_GUARDS=<name,...>@<epoch-ms>: the mod (which has ported those
|
|
19
19
|
// guards) stamps the guards it already ran; a stamp no older than
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// Crash-safe: unparseable input or any internal error -> exit 0.
|
|
23
23
|
// Self-contained, no dependencies. Pure functions are exported for tests.
|
|
24
24
|
|
|
25
|
-
const GUARD_ORDER = ['commit', 'secret', 'install', 'search', 'body'];
|
|
25
|
+
const GUARD_ORDER = ['commit', 'secret', 'install', 'search', 'body', 'labels'];
|
|
26
26
|
const MOD_GUARDS_FRESH_MS = 5000;
|
|
27
27
|
const MOD_GUARDS_RE = /^([a-z][a-z0-9-]*(?:,[a-z][a-z0-9-]*)*)@(\d+)$/;
|
|
28
28
|
|
|
@@ -37,7 +37,7 @@ function parseModGuards(value, now = Date.now()) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// Guards selected by --guards=<name,...>, in canonical order. Anything but a
|
|
40
|
-
// well-formed list of known names means all
|
|
40
|
+
// well-formed list of known names means all six.
|
|
41
41
|
function parseGuardsArg(argv) {
|
|
42
42
|
const arg = argv.find((a) => a.startsWith('--guards='));
|
|
43
43
|
if (!arg) return GUARD_ORDER;
|
|
@@ -17,8 +17,8 @@ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
|
17
17
|
const eq = (name, actual, expected) => { assert.deepStrictEqual(actual, expected, name); passed++; };
|
|
18
18
|
|
|
19
19
|
// Run the merged hook end-to-end: tool_input JSON on stdin, capture {code, out, err}.
|
|
20
|
-
function runHook(command, { env = {}, args = [], hook = HOOK } = {}) {
|
|
21
|
-
const input = JSON.stringify({ tool_input: { command } });
|
|
20
|
+
function runHook(command, { env = {}, args = [], hook = HOOK, cwd } = {}) {
|
|
21
|
+
const input = JSON.stringify({ tool_input: { command }, ...(cwd ? { cwd } : {}) });
|
|
22
22
|
const r = spawnSync('node', [hook, ...args], { input, env: { ...process.env, ...env } });
|
|
23
23
|
return { code: r.status, out: r.stdout.toString(), err: r.stderr.toString() };
|
|
24
24
|
}
|
|
@@ -30,9 +30,10 @@ const BAD_SECRET = 'gh issue comment 5 --body "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012
|
|
|
30
30
|
const BAD_INSTALL = 'npm install left-pad';
|
|
31
31
|
const BAD_SEARCH = 'cd x && grep y z';
|
|
32
32
|
const BAD_BODY = 'gh issue edit 5 --body ""';
|
|
33
|
+
const BAD_LABELS = 'gh issue create --title x --body y --label trailhead:bug';
|
|
33
34
|
|
|
34
35
|
// --- unit: constants ---
|
|
35
|
-
eq('guard order is fixed', GUARD_ORDER, ['commit', 'secret', 'install', 'search', 'body']);
|
|
36
|
+
eq('guard order is fixed', GUARD_ORDER, ['commit', 'secret', 'install', 'search', 'body', 'labels']);
|
|
36
37
|
ok('freshness window is 5s', MOD_GUARDS_FRESH_MS === 5000);
|
|
37
38
|
|
|
38
39
|
// --- unit: parseModGuards ---
|
|
@@ -48,10 +49,10 @@ for (const bad of ['commit', '@123', 'commit@abc', ',@1', '', undefined, 'commit
|
|
|
48
49
|
eq('unknown names are ignored', [...parseModGuards(`commit,bogus@${NOW}`, NOW)], ['commit']);
|
|
49
50
|
|
|
50
51
|
// --- unit: parseGuardsArg ---
|
|
51
|
-
eq('no --guards -> all
|
|
52
|
+
eq('no --guards -> all six', parseGuardsArg([]), GUARD_ORDER);
|
|
52
53
|
eq('valid subset keeps canonical order', parseGuardsArg(['--guards=body,commit']), ['commit', 'body']);
|
|
53
|
-
eq('empty --guards -> all
|
|
54
|
-
eq('malformed --guards -> all
|
|
54
|
+
eq('empty --guards -> all six', parseGuardsArg(['--guards=']), GUARD_ORDER);
|
|
55
|
+
eq('malformed --guards -> all six', parseGuardsArg(['--guards=commit,,body']), GUARD_ORDER);
|
|
55
56
|
eq('any unknown name invalidates the whole list', parseGuardsArg(['--guards=commit,secert']), GUARD_ORDER);
|
|
56
57
|
|
|
57
58
|
// --- unit: runGuards with fake evaluators ---
|
|
@@ -65,7 +66,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
|
|
|
65
66
|
evaluators: {
|
|
66
67
|
commit: spy('commit', () => { throw new Error('boom'); }),
|
|
67
68
|
secret: spy('secret', allow), install: spy('install', blocker('I')),
|
|
68
|
-
search: spy('search', blocker('S')), body: spy('body', allow),
|
|
69
|
+
search: spy('search', blocker('S')), body: spy('body', allow), labels: spy('labels', allow),
|
|
69
70
|
},
|
|
70
71
|
});
|
|
71
72
|
eq('a throwing guard counts as allow and the next one still blocks', r.code, 2);
|
|
@@ -75,7 +76,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
|
|
|
75
76
|
{
|
|
76
77
|
const r = runGuards('x', {
|
|
77
78
|
now: NOW, env: { TRAILHEAD_MOD_GUARDS: `commit@${NOW}` },
|
|
78
|
-
evaluators: { commit: blocker('C'), secret: allow, install: allow, search: allow, body: allow },
|
|
79
|
+
evaluators: { commit: blocker('C'), secret: allow, install: allow, search: allow, body: allow, labels: allow },
|
|
79
80
|
});
|
|
80
81
|
eq('a fresh stamp skips the listed guard', r.code, 0);
|
|
81
82
|
}
|
|
@@ -83,7 +84,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
|
|
|
83
84
|
const adv = (t) => () => ({ kind: 'advise', stderr: `${t}\n`, payload: { hookSpecificOutput: { additionalContext: t } } });
|
|
84
85
|
const r = runGuards('x', {
|
|
85
86
|
now: NOW, env: {},
|
|
86
|
-
evaluators: { commit: adv('a'), secret: adv('b'), install: allow, search: allow, body: allow },
|
|
87
|
+
evaluators: { commit: adv('a'), secret: adv('b'), install: allow, search: allow, body: allow, labels: allow },
|
|
87
88
|
});
|
|
88
89
|
eq('advisories exit 0', r.code, 0);
|
|
89
90
|
eq('advisory stderr lines are all written', r.stderr, 'a\nb\n');
|
|
@@ -96,9 +97,10 @@ const { evaluate: evSecret } = require('./trailhead-secret-guard.js');
|
|
|
96
97
|
const { evaluate: evInstall } = require('./trailhead-install-guard.js');
|
|
97
98
|
const { evaluate: evSearch } = require('./trailhead-search-guard.js');
|
|
98
99
|
const { evaluate: evBody } = require('./trailhead-body-guard.js');
|
|
100
|
+
const { evaluate: evLabels } = require('./trailhead-labels-guard.js');
|
|
99
101
|
for (const [name, cmd, ev] of [
|
|
100
102
|
['commit', BAD_COMMIT, evCommit], ['secret', BAD_SECRET, evSecret], ['install', BAD_INSTALL, evInstall],
|
|
101
|
-
['search', BAD_SEARCH, evSearch], ['body', BAD_BODY, evBody],
|
|
103
|
+
['search', BAD_SEARCH, evSearch], ['body', BAD_BODY, evBody], ['labels', BAD_LABELS, evLabels],
|
|
102
104
|
]) {
|
|
103
105
|
const r = runHook(cmd);
|
|
104
106
|
const want = ev(cmd);
|
|
@@ -169,19 +171,19 @@ for (const [name, cmd, ev] of [
|
|
|
169
171
|
const hook = path.join(tmp, 'trailhead-bash-guards.js');
|
|
170
172
|
fs.rmSync(path.join(tmp, 'trailhead-search-guard.js'));
|
|
171
173
|
|
|
172
|
-
const codex = runHook(BAD_SEARCH, { hook, args: ['--guards=commit,secret,install,body'] });
|
|
174
|
+
const codex = runHook(BAD_SEARCH, { hook, args: ['--guards=commit,secret,install,body,labels'] });
|
|
173
175
|
eq('--guards without search never loads search-guard', codex.code, 0);
|
|
174
176
|
eq('--guards without search: no warning', codex.err, '');
|
|
175
|
-
const still = runHook(BAD_INSTALL, { hook, args: ['--guards=commit,secret,install,body'] });
|
|
177
|
+
const still = runHook(BAD_INSTALL, { hook, args: ['--guards=commit,secret,install,body,labels'] });
|
|
176
178
|
eq('--guards subset still blocks with its guards', still.code, 2);
|
|
177
179
|
const only = runHook(BAD_INSTALL, { hook, args: ['--guards=commit'] });
|
|
178
180
|
eq('unselected guards do not run', only.code, 0);
|
|
179
181
|
|
|
180
182
|
for (const bad of ['--guards=', '--guards=commit,secert', '--guards=commit,,body']) {
|
|
181
183
|
const r = runHook(BAD_INSTALL, { hook, args: [bad] });
|
|
182
|
-
eq(`${bad}: falls back to all
|
|
184
|
+
eq(`${bad}: falls back to all six (install blocks)`, r.code, 2);
|
|
183
185
|
const clean = runHook('git status', { hook, args: [bad] });
|
|
184
|
-
ok(`${bad}: all
|
|
186
|
+
ok(`${bad}: all six are selected (the missing search module is reported)`, /search/.test(clean.err));
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
const missing = runHook(`${BAD_BODY} && ${BAD_SEARCH}`, { hook });
|
|
@@ -212,12 +214,16 @@ for (const [name, cmd, ev] of [
|
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
// --- every existing guard suite, re-run through the merged hook ---
|
|
215
|
-
|
|
217
|
+
// Run from a scratch dir outside any trailhead repo: those suites open unlabelled issues, which
|
|
218
|
+
// the labels guard (rightly) blocks inside a trailhead repo.
|
|
219
|
+
const SUITE_CWD = fs.mkdtempSync(path.join(os.tmpdir(), 'tb-suites-'));
|
|
220
|
+
for (const name of ['commit', 'secret', 'install', 'search', 'body', 'labels']) {
|
|
216
221
|
const file = path.join(__dirname, `trailhead-${name}-guard.test.js`);
|
|
217
|
-
const r = spawnSync('node', [file], { env: { ...process.env, TRAILHEAD_GUARD_HOOK: HOOK } });
|
|
222
|
+
const r = spawnSync('node', [file], { cwd: SUITE_CWD, env: { ...process.env, TRAILHEAD_GUARD_HOOK: HOOK } });
|
|
218
223
|
if (r.status !== 0) process.stderr.write(r.stdout.toString() + r.stderr.toString());
|
|
219
224
|
eq(`${name}-guard suite passes through the merged hook`, r.status, 0);
|
|
220
225
|
}
|
|
226
|
+
fs.rmSync(SUITE_CWD, { recursive: true, force: true });
|
|
221
227
|
|
|
222
228
|
// --- registration: one Bash entry, one command (the merged hook) ---
|
|
223
229
|
{
|
|
@@ -8,14 +8,28 @@
|
|
|
8
8
|
// package age, download counts, repo metadata) and checkpoint the human on a
|
|
9
9
|
// suspicious result (block-and-ask). A vetted install proceeds by re-running the
|
|
10
10
|
// command prefixed with the sentinel `TRAILHEAD_VERIFIED_INSTALL=1`.
|
|
11
|
-
// Only a NAMED install is gated:
|
|
12
|
-
// `npm ci`, `
|
|
13
|
-
//
|
|
11
|
+
// Only a NAMED install is gated: installs with no typed name (bare `npm install`,
|
|
12
|
+
// `npm ci`, `install --frozen-lockfile` / `--immutable` with no package, `pip install -r`,
|
|
13
|
+
// `cargo build`, `go mod download`, `bundle install`) and local paths / URLs /
|
|
14
|
+
// VCS refs carry no typed name and pass through. A file-like name (`foo.txt`,
|
|
15
|
+
// `x.tgz`) passes only when it is a path (`./x`, `sub/x`, `/x`, `~/x`) or exists
|
|
16
|
+
// on disk (relative to the command's cwd); otherwise it is a typed name and is gated.
|
|
17
|
+
// The on-disk check resolves against the hook's cwd; it does not follow a
|
|
18
|
+
// preceding `cd` in the same command. Command wrappers (`sudo`, `time`, `env`,
|
|
19
|
+
// `nohup`, `xargs`, `timeout`, `nice`, `stdbuf`, `setsid`, `ionice`, `doas`,
|
|
20
|
+
// `command`, `exec`) are peeled, with their own flags, so `sudo npm install foo`
|
|
21
|
+
// is gated like the bare form. Parsing is quote-aware (shared
|
|
22
|
+
// lib/shell-scan.js): text inside quotes or a heredoc (`echo "x; npm i foo"`) is
|
|
23
|
+
// never a command, and redirections (`2>&1`, `> log`) are never package names.
|
|
14
24
|
// Blocks with exit 2 + {"decision":"block",...}; allows everything else.
|
|
15
25
|
// Crash-safe: any error → exit 0 (never wedge the user's workflow).
|
|
16
|
-
// Self-contained
|
|
26
|
+
// Self-contained (its only dependency is the sibling lib/ module). Pure functions (incl. evaluate) exported for tests
|
|
17
27
|
// and the merged trailhead-bash-guards.js; runs as a hook when executed directly.
|
|
18
28
|
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const { basename, splitStatements, tokenize, stripQuotes, stripRedirections } = require('./lib/shell-scan.js');
|
|
32
|
+
|
|
19
33
|
const SENTINEL = /(?:^|\s)TRAILHEAD_VERIFIED_INSTALL=1(?:\s|$)/;
|
|
20
34
|
|
|
21
35
|
// Per-ecosystem: the binaries that own it, and the install verbs that fetch a
|
|
@@ -37,22 +51,129 @@ const VALUE_FLAGS = new Set([
|
|
|
37
51
|
'-t', '--target', '--prefix', '--root', '--python', '-d', '--dest',
|
|
38
52
|
'-w', '--workspace', '--registry', '-C', '--directory', '--chdir',
|
|
39
53
|
'--no-binary', '--only-binary',
|
|
54
|
+
'--filter', '-F', // pnpm workspace selector: its value is a project, not a package
|
|
40
55
|
]);
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
// Command wrappers that run the rest of the line as a command, mapped to the
|
|
58
|
+
// flags that consume a SEPARATE-TOKEN value (so it is not read as the command).
|
|
59
|
+
// Attached forms (`-uroot`, `--user=root`, GNU xargs `-i`/`-e`/`-l`) need no entry.
|
|
60
|
+
const WRAPPERS = {
|
|
61
|
+
sudo: new Set([
|
|
62
|
+
'-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--close-from',
|
|
63
|
+
'-U', '--other-user', '-r', '--role', '-t', '--type', '-D', '--chdir', '-R', '--chroot',
|
|
64
|
+
'-T', '--command-timeout',
|
|
65
|
+
]),
|
|
66
|
+
env: new Set(['-u', '--unset', '-C', '--chdir', '-a', '--argv0']), // -S handled apart
|
|
67
|
+
time: new Set(['-f', '--format', '-o', '--output']),
|
|
68
|
+
nohup: new Set(),
|
|
69
|
+
xargs: new Set([
|
|
70
|
+
'-n', '--max-args', '-I', '-a', '--arg-file', '-d', '--delimiter', '-E', '-L', '--max-lines',
|
|
71
|
+
'-P', '--max-procs', '-s', '--max-chars',
|
|
72
|
+
]),
|
|
73
|
+
timeout: new Set(['-s', '--signal', '-k', '--kill-after']), // + one DURATION operand
|
|
74
|
+
nice: new Set(['-n', '--adjustment']),
|
|
75
|
+
stdbuf: new Set(['-i', '-o', '-e', '--input', '--output', '--error']),
|
|
76
|
+
setsid: new Set(),
|
|
77
|
+
ionice: new Set(['-c', '--class', '-n', '--classdata']),
|
|
78
|
+
doas: new Set(['-a', '-C', '-u']),
|
|
79
|
+
command: new Set(), // -v / -V only look a name up: see isLookupFlag
|
|
80
|
+
exec: new Set(['-a']),
|
|
81
|
+
};
|
|
82
|
+
const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
83
|
+
|
|
84
|
+
// How a wrapper flag consumes its value. Returns { next } (value is the NEXT
|
|
85
|
+
// token), { attached } (value is inside this token) or {} (flagless). A short
|
|
86
|
+
// cluster is read like getopt: the first value-taking letter ends it, so `-nu root`
|
|
87
|
+
// takes the next token and `-uroot` carries its value attached.
|
|
88
|
+
function flagValue(f, valueFlags) {
|
|
89
|
+
if (f.startsWith('--')) {
|
|
90
|
+
const eq = f.indexOf('=');
|
|
91
|
+
if (eq !== -1) return { attached: f.slice(eq + 1) };
|
|
92
|
+
return valueFlags.has(f) ? { next: true } : {};
|
|
93
|
+
}
|
|
94
|
+
for (let k = 1; k < f.length; k++) {
|
|
95
|
+
if (!valueFlags.has('-' + f[k])) continue;
|
|
96
|
+
return k === f.length - 1 ? { next: true } : { attached: f.slice(k + 1) };
|
|
97
|
+
}
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `env -S` / `--split-string`: its payload IS the command line, not a flag value.
|
|
102
|
+
function isSplitFlag(f) {
|
|
103
|
+
if (f.startsWith('--')) return f === '--split-string' || f.startsWith('--split-string=');
|
|
104
|
+
for (let k = 1; k < f.length; k++) {
|
|
105
|
+
if (f[k] === 'S') return true;
|
|
106
|
+
if ('uCa'.includes(f[k])) return false; // value letter first: S is part of its value
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// `command -v` / `-V` (also inside a cluster like `-pv`) only describe a name and
|
|
112
|
+
// execute nothing, so nothing follows that can be an install.
|
|
113
|
+
function isLookupFlag(f) {
|
|
114
|
+
return !f.startsWith('--') && /[vV]/.test(f);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The text of an `env -S` payload token. A double-quoted payload (outer quotes
|
|
118
|
+
// stripped) also loses its bash escapes (`\$`, `\``, `\"`, `\\`); single-quoted
|
|
119
|
+
// text is literal. Local on purpose: the shared stripQuotes is left as is.
|
|
120
|
+
function payloadText(payload) {
|
|
121
|
+
const p = payload || '';
|
|
122
|
+
const stripped = stripQuotes(p);
|
|
123
|
+
const dq = p.length >= 2 && p.startsWith('"') && p.endsWith('"');
|
|
124
|
+
return dq ? stripped.replace(/\\([$`"\\])/g, '$1') : stripped;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The real command, past leading `NAME=value` assignments and any (nested) wrapper
|
|
128
|
+
// with its flags. Returns { toks, i }: toks may be rewritten when an `env -S`
|
|
129
|
+
// payload is re-tokenized; i is toks.length when nothing is left.
|
|
130
|
+
function skipWrappers(input) {
|
|
131
|
+
let toks = input;
|
|
132
|
+
let i = 0;
|
|
133
|
+
for (;;) {
|
|
134
|
+
while (i < toks.length && ENV_ASSIGN.test(toks[i])) i++;
|
|
135
|
+
const wrapper = basename(toks[i] || '');
|
|
136
|
+
const valueFlags = WRAPPERS[wrapper];
|
|
137
|
+
if (!valueFlags) return { toks, i };
|
|
138
|
+
i++; // past the wrapper
|
|
139
|
+
while (i < toks.length && toks[i].startsWith('-')) {
|
|
140
|
+
const f = toks[i++];
|
|
141
|
+
if (f === '--') break; // end of the wrapper's options
|
|
142
|
+
if (wrapper === 'env' && isSplitFlag(f)) {
|
|
143
|
+
const eq = f.indexOf('=');
|
|
144
|
+
const sIdx = f.indexOf('S');
|
|
145
|
+
let payload;
|
|
146
|
+
if (f.startsWith('--')) payload = eq !== -1 ? f.slice(eq + 1) : toks[i++];
|
|
147
|
+
else payload = sIdx === f.length - 1 ? toks[i++] : f.slice(sIdx + 1);
|
|
148
|
+
toks = [...tokenize(payloadText(payload)), ...toks.slice(i)];
|
|
149
|
+
i = 0; // the payload and the rest now form the command line
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
if (wrapper === 'command' && isLookupFlag(f)) return { toks, i: toks.length };
|
|
153
|
+
if (flagValue(f, valueFlags).next) i++; // consume its separate-token value
|
|
154
|
+
}
|
|
155
|
+
if (wrapper === 'timeout' && i < toks.length) i++; // the DURATION operand
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A file-like name is local only when it is a path or exists on disk. A leading
|
|
160
|
+
// `@` is a scoped npm name, never a path.
|
|
161
|
+
function isLocalFile(t, opts) {
|
|
162
|
+
if (t.startsWith('@')) return false;
|
|
163
|
+
if (t.includes('/')) return true;
|
|
164
|
+
const exists = (opts && opts.exists) || fs.existsSync;
|
|
165
|
+
return exists(path.resolve((opts && opts.cwd) || process.cwd(), t));
|
|
45
166
|
}
|
|
46
167
|
|
|
47
168
|
// A positional that is NOT a public-registry name: local path, url, vcs ref, or a
|
|
48
169
|
// file artifact. Go module paths (example.com/foo) look url-ish but ARE the named
|
|
49
170
|
// module, so for go only a leading . / ~ disqualifies.
|
|
50
|
-
function isLocalOrUrl(t, ecosystem) {
|
|
171
|
+
function isLocalOrUrl(t, ecosystem, opts) {
|
|
51
172
|
if (t.startsWith('.') || t.startsWith('/') || t.startsWith('~')) return true;
|
|
52
173
|
if (ecosystem === 'go') return false; // a bare module path is the name; gate it
|
|
53
174
|
if (t.includes('://')) return true;
|
|
54
175
|
if (/^(?:git\+|file:|https?:|hg\+|svn\+|bzr\+)/.test(t)) return true;
|
|
55
|
-
if (/\.(?:tgz|tar\.gz|whl|gz|zip|txt|toml|lock|cfg)$/.test(t)) return
|
|
176
|
+
if (/\.(?:tgz|tar\.gz|whl|gz|zip|txt|toml|lock|cfg)$/.test(t)) return isLocalFile(t, opts);
|
|
56
177
|
return false;
|
|
57
178
|
}
|
|
58
179
|
|
|
@@ -70,12 +191,29 @@ function bareName(t) {
|
|
|
70
191
|
return n;
|
|
71
192
|
}
|
|
72
193
|
|
|
194
|
+
// Positional package names among the arguments after the verb (redirections
|
|
195
|
+
// already removed). Quotes are stripped so `"lodash"` is the name `lodash`.
|
|
196
|
+
function collectPackages(args, ecosystem, opts) {
|
|
197
|
+
const packages = [];
|
|
198
|
+
for (let j = 0; j < args.length; j++) {
|
|
199
|
+
const t = args[j];
|
|
200
|
+
if (t.startsWith('-')) {
|
|
201
|
+
if (VALUE_FLAGS.has(t)) j++; // consume its separate-token value
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const name = stripQuotes(t);
|
|
205
|
+
if (!name || name === '&' || isLocalOrUrl(name, ecosystem, opts)) continue;
|
|
206
|
+
const bare = bareName(name);
|
|
207
|
+
if (bare) packages.push(bare);
|
|
208
|
+
}
|
|
209
|
+
return packages;
|
|
210
|
+
}
|
|
211
|
+
|
|
73
212
|
// Detect a public-registry install that NAMES at least one package, in a single
|
|
74
|
-
// shell
|
|
75
|
-
function detectSegment(
|
|
76
|
-
const toks =
|
|
77
|
-
let i =
|
|
78
|
-
while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
|
|
213
|
+
// shell statement. Returns { ecosystem, packages: [names] } or null.
|
|
214
|
+
function detectSegment(stmt, opts) {
|
|
215
|
+
const { toks, i: start } = skipWrappers(tokenize(stmt));
|
|
216
|
+
let i = start; // env prefix + sudo/time/env/nohup/xargs
|
|
79
217
|
if (i >= toks.length) return null;
|
|
80
218
|
|
|
81
219
|
const bin = basename(toks[i]);
|
|
@@ -102,27 +240,21 @@ function detectSegment(cmd) {
|
|
|
102
240
|
if (!ECOSYSTEMS[ecosystem].verbs.includes(verb)) return null;
|
|
103
241
|
i++;
|
|
104
242
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (VALUE_FLAGS.has(t)) j++; // consume its separate-token value
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (isLocalOrUrl(t, ecosystem)) continue;
|
|
113
|
-
packages.push(bareName(t));
|
|
114
|
-
}
|
|
243
|
+
const args = stripRedirections(toks.slice(i));
|
|
244
|
+
// A lockfile install (`--frozen-lockfile`, `--immutable`) carries no positional,
|
|
245
|
+
// so it yields no package here; a flag never excuses a name typed beside it.
|
|
246
|
+
const packages = collectPackages(args, ecosystem, opts);
|
|
115
247
|
if (packages.length === 0) return null; // lockfile/manifest install: nothing typed
|
|
116
248
|
return { ecosystem, packages };
|
|
117
249
|
}
|
|
118
250
|
|
|
119
251
|
// Agents chain commands (`cd app && npm install foo`), so check every shell
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// Returns the first
|
|
123
|
-
function detectInstall(cmd) {
|
|
124
|
-
for (const
|
|
125
|
-
const hit = detectSegment(
|
|
252
|
+
// statement, not just the first. splitStatements is quote- and heredoc-aware, so
|
|
253
|
+
// a separator or install text inside quotes / a heredoc body is not a statement.
|
|
254
|
+
// Returns the first statement's hit, or null.
|
|
255
|
+
function detectInstall(cmd, opts) {
|
|
256
|
+
for (const stmt of splitStatements(cmd)) {
|
|
257
|
+
const hit = detectSegment(stmt, opts);
|
|
126
258
|
if (hit) return hit;
|
|
127
259
|
}
|
|
128
260
|
return null;
|
|
@@ -147,10 +279,10 @@ function blockVerdict(hit) {
|
|
|
147
279
|
|
|
148
280
|
// Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
|
|
149
281
|
// exits or writes; any internal error falls through to allow.
|
|
150
|
-
function evaluate(cmd) {
|
|
282
|
+
function evaluate(cmd, opts) {
|
|
151
283
|
try {
|
|
152
284
|
if (isVerified(cmd)) return { kind: 'allow' }; // agent has vetted these
|
|
153
|
-
const hit = detectInstall(cmd);
|
|
285
|
+
const hit = detectInstall(cmd, { cwd: opts && opts.cwd });
|
|
154
286
|
if (hit) return blockVerdict(hit);
|
|
155
287
|
} catch {
|
|
156
288
|
// fall through to allow: a guard must never wedge the workflow
|
|
@@ -160,12 +292,15 @@ function evaluate(cmd) {
|
|
|
160
292
|
|
|
161
293
|
function run(data) {
|
|
162
294
|
let cmd = '';
|
|
295
|
+
let cwd;
|
|
163
296
|
try {
|
|
164
|
-
|
|
297
|
+
const input = JSON.parse(data);
|
|
298
|
+
cmd = (input.tool_input || {}).command || '';
|
|
299
|
+
cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
|
|
165
300
|
} catch {
|
|
166
301
|
process.exit(0); // unparseable input: nothing to gate
|
|
167
302
|
}
|
|
168
|
-
const v = evaluate(cmd);
|
|
303
|
+
const v = evaluate(cmd, { cwd });
|
|
169
304
|
if (v.kind === 'block') {
|
|
170
305
|
process.stdout.write(JSON.stringify(v.payload));
|
|
171
306
|
process.exit(2);
|