@marcomigozzi/trailhead 0.8.1 → 0.9.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.
Files changed (38) hide show
  1. package/README.it.md +12 -9
  2. package/README.md +12 -9
  3. package/bin/lib/codex-projection.js +5 -4
  4. package/bin/lib/codex-projection.test.js +3 -1
  5. package/bin/trailhead.js +9 -5
  6. package/bin/trailhead.test.js +130 -0
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/commands/prune.md +6 -0
  10. package/plugins/trailhead/hooks/hooks.json +4 -0
  11. package/plugins/trailhead/hooks/lib/gh-subcommand.js +102 -0
  12. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +99 -0
  13. package/plugins/trailhead/hooks/trailhead-body-guard.js +221 -0
  14. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +222 -0
  15. package/plugins/trailhead/hooks/trailhead-issue-injection-scanner.js +61 -35
  16. package/plugins/trailhead/hooks/trailhead-issue-injection-scanner.test.js +76 -0
  17. package/plugins/trailhead/hooks/trailhead-secret-guard.js +4 -15
  18. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +31 -0
  19. package/plugins/trailhead/skills/_shared/choices.md +2 -1
  20. package/plugins/trailhead/skills/_shared/configuration-reference.md +1 -1
  21. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  22. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  23. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  24. package/plugins/trailhead/skills/_shared/session-handoff.md +3 -3
  25. package/plugins/trailhead/skills/_shared/substrate-commands.md +41 -5
  26. package/plugins/trailhead/skills/_shared/substrate.md +10 -6
  27. package/plugins/trailhead/skills/_shared/teamwork.md +19 -12
  28. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  29. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  30. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -0
  31. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +12 -2
  32. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  33. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
  34. package/plugins/trailhead/skills/trailhead-manage/references/pruning.md +48 -0
  35. package/plugins/trailhead/skills/trailhead-view/SKILL.md +4 -2
  36. package/plugins/trailhead/skills/trailhead-work/SKILL.md +13 -11
  37. package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
  38. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -3
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+ // gh-subcommand.js: shared gh-subcommand detector, extracted from
3
+ // trailhead-body-guard.js's ghBodyWrite and trailhead-secret-guard.js's
4
+ // ghIssueWrite (#153 follow-up), so both guards share ONE token walk instead
5
+ // of drifting copies.
6
+ //
7
+ // Bug fixed here (#153): the original inline walk only treated `-R`/`--repo`
8
+ // as a two-token (value-taking) global gh flag. Any OTHER two-token global
9
+ // flag (e.g. `gh --hostname h issue edit 5 --body ""`) desynced the walk:
10
+ // after skipping `--hostname`, the index landed on its value `h`, which is
11
+ // not a `-`-prefixed flag so the loop stopped there, and `h` was mistaken for
12
+ // the subcommand. The write became invisible to both guards (a missed
13
+ // empty-body block in body-guard, an unscanned secret in secret-guard).
14
+ //
15
+ // Follow-up fix (#153): a first pass tolerated a stray value token after any
16
+ // unknown flag UNLESS that token was itself a flag or a subcommand keyword
17
+ // (issue/pr/api). That guard reintroduced the exact same desync whenever the
18
+ // flag's VALUE literally equalled a subcommand keyword, e.g.
19
+ // `gh --hostname api issue edit 5` mis-resolved `sub` to `api` (the flag
20
+ // value), hiding the real `issue edit` write again.
21
+ //
22
+ // Fix: drop the keyword-based guard entirely and model flag arity instead.
23
+ // gh's only value-taking global flag before a subcommand is -R/--repo; a
24
+ // small, explicit set of global flags are boolean (take no value). Every
25
+ // OTHER flag token is treated as value-taking, so its value can never be
26
+ // mistaken for the subcommand - including when that value happens to equal
27
+ // a subcommand keyword like `issue`/`pr`/`api`.
28
+ //
29
+ // Second follow-up fix (#153): treating every unknown flag as value-taking
30
+ // reopened the MIRROR case - an unknown BOOLEAN flag directly before the
31
+ // subcommand (e.g. `gh --foo issue edit 5`) now gets treated as value-taking
32
+ // and swallows the subcommand itself as if it were the flag's value, landing
33
+ // `sub` on the verb (`edit`) instead of `issue`. The two failure directions
34
+ // (a flag's VALUE colliding with a subcommand keyword, vs. an unknown
35
+ // BOOLEAN flag consuming the subcommand) cannot both be resolved by a
36
+ // positional walk alone: `gh --X <tok> issue edit` and `gh --X issue edit`
37
+ // are structurally ambiguous without knowing --X's arity in advance, and gh
38
+ // itself rejects unknown flags so neither shape occurs with the real `gh`
39
+ // binary. But the guard defends against any `gh`-named binary, so it must
40
+ // not depend on that.
41
+ //
42
+ // Design principle: keep the value-taking primary walk (it is correct for
43
+ // every KNOWN two-token flag, e.g. -R/--repo, and for the common case of an
44
+ // unknown two-token flag), then ADD a fail-safe fallback that scans forward
45
+ // for a recognized write-subcommand keyword whenever the walk did not land
46
+ // on one. Erring toward DETECTION (scanning/considering a write) is the safe
47
+ // direction for a security guard; under-detection (missing a real write) is
48
+ // the dangerous one. The downstream verb gate in each guard still prevents a
49
+ // stray keyword token in a genuinely non-write command from being
50
+ // misclassified as a write.
51
+ //
52
+ // Self-contained. No requires.
53
+
54
+ // gh's boolean global flags (take NO value). gh's only value-taking global
55
+ // flag before a subcommand is -R/--repo; every other flag token here is
56
+ // treated as value-taking, so an unknown two-token global flag's value can
57
+ // never be mistaken for the subcommand (#153) - including when that value
58
+ // happens to equal a subcommand keyword like `issue`/`pr`/`api`.
59
+ const BOOLEAN_GLOBAL_FLAGS = new Set(['-h', '--help', '--version']);
60
+
61
+ // The subcommands the guards care about; used by the fail-safe fallback
62
+ // below to recover from an unknown boolean flag swallowing the subcommand.
63
+ const WRITE_SUBCOMMANDS = new Set(['issue', 'pr', 'api']);
64
+
65
+ // Parse a raw shell command string and locate the gh subcommand/verb.
66
+ // Returns { sub, verb } (verb may be undefined) or null when this is not a
67
+ // gh invocation at all.
68
+ function parseGhSubcommand(cmd) {
69
+ const toks = String(cmd).trim().split(/\s+/);
70
+ let i = 0;
71
+ while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
72
+ if (i >= toks.length || !/(^|\/)gh$/.test(toks[i])) return null; // the gh binary
73
+ const start = i + 1; // first token after `gh`
74
+ i = start;
75
+ // Primary positional walk. -R/--repo (and any non-boolean, non-glued flag)
76
+ // is treated as value-taking: consume one following non-flag token so an
77
+ // unknown two-token global flag's value cannot shift the subcommand (#153).
78
+ while (i < toks.length && toks[i].startsWith('-')) {
79
+ const flag = toks[i];
80
+ i++; // the flag token itself
81
+ if (flag.includes('=') || BOOLEAN_GLOBAL_FLAGS.has(flag)) continue; // no separate value
82
+ if (i < toks.length && !toks[i].startsWith('-')) i++; // consume its value
83
+ }
84
+ let sub = toks[i];
85
+ let verb = toks[i + 1];
86
+ // Fail-safe fallback: if the walk did NOT land on a recognized write
87
+ // subcommand, an unknown *boolean* flag before the subcommand may have
88
+ // consumed the subcommand itself as if it were the flag's value (the mirror
89
+ // of the value-collision case). Scan forward for the first recognized
90
+ // subcommand keyword and use it. Over-detection is the safe direction for a
91
+ // guard; under-detection (missing a real write) is the dangerous one (#153).
92
+ // The downstream guards still gate on the verb, so a stray keyword token in a
93
+ // non-write command does not become a spurious write.
94
+ if (!WRITE_SUBCOMMANDS.has(sub)) {
95
+ for (let j = start; j < toks.length; j++) {
96
+ if (WRITE_SUBCOMMANDS.has(toks[j])) { sub = toks[j]; verb = toks[j + 1]; break; }
97
+ }
98
+ }
99
+ return { sub, verb };
100
+ }
101
+
102
+ module.exports = { parseGhSubcommand, BOOLEAN_GLOBAL_FLAGS, WRITE_SUBCOMMANDS };
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // Tests for gh-subcommand.js. Run: node gh-subcommand.test.js
3
+ const assert = require('assert');
4
+ const { parseGhSubcommand } = require('./gh-subcommand.js');
5
+
6
+ let passed = 0;
7
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
8
+
9
+ // --- env prefix + gh binary detection ---
10
+ ok('env prefix before gh is skipped',
11
+ (() => { const r = parseGhSubcommand('FOO=1 gh issue edit'); return r && r.sub === 'issue' && r.verb === 'edit'; })());
12
+ ok('an absolute gh path is recognised',
13
+ (() => { const r = parseGhSubcommand('/usr/bin/gh api repos/o/r/issues/5'); return r && r.sub === 'api'; })());
14
+ ok('a non-gh command returns null', parseGhSubcommand('echo issue edit') === null);
15
+ ok('a bare gh with no subcommand does not throw', (() => {
16
+ const r = parseGhSubcommand('gh');
17
+ return r && r.sub === undefined && r.verb === undefined;
18
+ })());
19
+
20
+ // --- plain subcommand/verb ---
21
+ ok('plain gh issue edit', (() => { const r = parseGhSubcommand('gh issue edit 5'); return r.sub === 'issue' && r.verb === 'edit'; })());
22
+ ok('plain gh pr edit', (() => { const r = parseGhSubcommand('gh pr edit'); return r.sub === 'pr' && r.verb === 'edit'; })());
23
+ ok('plain gh api', (() => { const r = parseGhSubcommand('gh api repos/o/r/issues/5'); return r.sub === 'api'; })());
24
+
25
+ // --- -R / --repo two-token form still resolves ---
26
+ ok('gh -R owner/repo issue edit 5 resolves issue/edit',
27
+ (() => { const r = parseGhSubcommand('gh -R owner/repo issue edit 5'); return r.sub === 'issue' && r.verb === 'edit'; })());
28
+ ok('gh --repo owner/repo pr edit resolves pr/edit',
29
+ (() => { const r = parseGhSubcommand('gh --repo owner/repo pr edit'); return r.sub === 'pr' && r.verb === 'edit'; })());
30
+
31
+ // --- --repo=owner/repo single-token form ---
32
+ ok('gh --repo=owner/repo issue edit resolves issue/edit',
33
+ (() => { const r = parseGhSubcommand('gh --repo=owner/repo issue edit'); return r.sub === 'issue' && r.verb === 'edit'; })());
34
+
35
+ // --- REGRESSION (#153): an unknown two-token global flag no longer desyncs ---
36
+ ok('gh --hostname h issue edit 5 --body "" resolves issue/edit',
37
+ (() => { const r = parseGhSubcommand('gh --hostname h issue edit 5 --body ""'); return r.sub === 'issue' && r.verb === 'edit'; })());
38
+ ok('gh --hostname h pr edit 5 resolves pr/edit',
39
+ (() => { const r = parseGhSubcommand('gh --hostname h pr edit 5'); return r.sub === 'pr' && r.verb === 'edit'; })());
40
+ ok('gh --hostname h api --method PATCH ... resolves api',
41
+ (() => {
42
+ const r = parseGhSubcommand('gh --hostname h api --method PATCH repos/o/r/issues/5 -f body=x');
43
+ return r.sub === 'api';
44
+ })());
45
+
46
+ // --- multiple unknown two-token flags ---
47
+ ok('gh --hostname h --foo bar issue edit 5 resolves issue/edit',
48
+ (() => { const r = parseGhSubcommand('gh --hostname h --foo bar issue edit 5'); return r.sub === 'issue' && r.verb === 'edit'; })());
49
+
50
+ // --- REGRESSION (#153 follow-up): a two-token global flag whose VALUE
51
+ // literally equals a subcommand keyword must still resolve the real
52
+ // subcommand, not the flag's value ---
53
+ ok('gh --hostname api issue edit 5 resolves issue/edit (flag value equals a keyword)',
54
+ (() => { const r = parseGhSubcommand('gh --hostname api issue edit 5'); return r.sub === 'issue' && r.verb === 'edit'; })());
55
+ ok('gh --hostname issue issue comment 5 resolves issue/comment (flag value equals a keyword)',
56
+ (() => { const r = parseGhSubcommand('gh --hostname issue issue comment 5'); return r.sub === 'issue' && r.verb === 'comment'; })());
57
+ ok('gh --hostname pr pr edit 5 resolves pr/edit (flag value equals a keyword)',
58
+ (() => { const r = parseGhSubcommand('gh --hostname pr pr edit 5'); return r.sub === 'pr' && r.verb === 'edit'; })());
59
+
60
+ // --- a real boolean global flag directly before the subcommand is not over-consumed ---
61
+ // (Trade-off of the new model: an UNKNOWN flag like --foo is now treated as
62
+ // value-taking, so it consumes the next token. Only flags in
63
+ // BOOLEAN_GLOBAL_FLAGS are known to take no value.)
64
+ ok('gh --help issue edit resolves issue/edit (real boolean flag not swallowing the subcommand)',
65
+ (() => { const r = parseGhSubcommand('gh --help issue edit'); return r.sub === 'issue' && r.verb === 'edit'; })());
66
+
67
+ // --- single-token --hostname=h form ---
68
+ ok('gh --hostname=h issue edit resolves issue/edit',
69
+ (() => { const r = parseGhSubcommand('gh --hostname=h issue edit'); return r.sub === 'issue' && r.verb === 'edit'; })());
70
+
71
+ // --- gh api with explicit flags before the write flag (baseline) ---
72
+ ok('gh api --method PATCH ... resolves api',
73
+ (() => {
74
+ const r = parseGhSubcommand('gh api --method PATCH repos/o/r/issues/5 -f body=x');
75
+ return r.sub === 'api';
76
+ })());
77
+
78
+ // --- REGRESSION (#153 second follow-up): an unknown BOOLEAN flag directly
79
+ // before the subcommand must not swallow the subcommand as its own value
80
+ // (the mirror of the keyword-value collision case above) ---
81
+ ok('gh --foo issue edit 5 resolves issue/edit (unknown boolean flag)',
82
+ (() => { const r = parseGhSubcommand('gh --foo issue edit 5'); return r.sub === 'issue' && r.verb === 'edit'; })());
83
+ ok('gh -x pr edit 5 resolves pr/edit (unknown short boolean flag)',
84
+ (() => { const r = parseGhSubcommand('gh -x pr edit 5'); return r.sub === 'pr' && r.verb === 'edit'; })());
85
+ ok('gh --foo api repos/o/r/issues/5 -f body=x resolves api (unknown boolean flag)',
86
+ (() => {
87
+ const r = parseGhSubcommand('gh --foo api repos/o/r/issues/5 -f body=x');
88
+ return r.sub === 'api';
89
+ })());
90
+ ok('gh --foo --bar issue edit 5 resolves issue/edit (two unknown boolean flags)',
91
+ (() => { const r = parseGhSubcommand('gh --foo --bar issue edit 5'); return r.sub === 'issue' && r.verb === 'edit'; })());
92
+
93
+ // --- a genuine non-write subcommand must NOT be forced to look like a write ---
94
+ // (the fallback only fires when the walk missed a WRITE_SUBCOMMANDS keyword;
95
+ // `repo` is not one, so it must be reported as-is, not fabricated into a keyword)
96
+ ok('gh repo view owner/repo resolves sub "repo" (not forced into a write keyword)',
97
+ (() => { const r = parseGhSubcommand('gh repo view owner/repo'); return r.sub === 'repo'; })());
98
+
99
+ console.log(`✓ gh-subcommand: ${passed} assertions passed`);
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ // trailhead-body-guard.js: PreToolUse(Bash) hook.
3
+ // trailhead rewrites issue/PR bodies with a read-modify-write: `gh issue view
4
+ // <n> --json body` -> transform in memory -> `gh issue edit <n> --body...`.
5
+ // If the pre-write read hits a transient failure (e.g. a GitHub HTTP 5xx) and
6
+ // comes back with an empty body without `gh` exiting non-zero, that empty
7
+ // string flows straight into the write and clobbers the existing body. A
8
+ // trailhead body is never legitimately empty, so this is the write-site
9
+ // backstop: it hard-blocks a `gh issue edit` / `gh pr edit` / `gh api` write
10
+ // that would set a body to empty or whitespace-only, protecting the existing
11
+ // body from a degraded read.
12
+ // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
13
+ // Fail-OPEN on any error (exit 0): this is a cheap backstop, not a hard
14
+ // dependency, and a crash here must never block a legitimate write.
15
+ // Self-contained. Pure functions are exported for tests; runs as a hook when
16
+ // executed directly.
17
+
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const { parseGhSubcommand } = require('./lib/gh-subcommand.js');
21
+
22
+ // Is the resolved body text clearly empty or whitespace-only?
23
+ function isEmptyBody(text) {
24
+ return text === '' || /^\s*$/.test(text);
25
+ }
26
+
27
+ // A value that is a shell expansion (command substitution, backticks, or a
28
+ // bare env-var reference) can't be resolved statically: treat it as unknown
29
+ // rather than guessing, so the guard never blocks on a body it can't prove
30
+ // empty.
31
+ function isShellExpansion(text) {
32
+ return /\$\(/.test(text) || /`/.test(text) || /^\$/.test(text);
33
+ }
34
+
35
+ // `fs.readFileSync` does not expand `~`; do it ourselves so a `~/`-prefixed
36
+ // (or bare `~`) --body-file / api @-path is actually read, rather than
37
+ // throwing ENOENT and silently falling through to "unknowable".
38
+ function expandHome(p) {
39
+ if (p === '~') return os.homedir();
40
+ if (p.startsWith('~/')) return os.homedir() + p.slice(1);
41
+ return p;
42
+ }
43
+
44
+ // Read a --body-file (or api `@file`) target, mirroring secret-guard's
45
+ // bodyFileText: sliced to ~1MB. Throws (ENOENT etc.) are the caller's
46
+ // problem to catch.
47
+ function readBodyFile(filePath) {
48
+ return fs.readFileSync(expandHome(filePath), 'utf8').slice(0, 1_000_000);
49
+ }
50
+
51
+ // Strip one matching pair of surrounding quotes (an artifact of parsing a
52
+ // bare `body=` value that itself contains a literal `""`/`''`, e.g. `-f
53
+ // body=""`, where the RAW command text carries the quote characters rather
54
+ // than the shell having already stripped them). A no-op when the value
55
+ // isn't quote-wrapped.
56
+ function stripQuotedPair(val) {
57
+ if (val.length >= 2) {
58
+ const first = val[0];
59
+ const last = val[val.length - 1];
60
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
61
+ return val.slice(1, -1);
62
+ }
63
+ }
64
+ return val;
65
+ }
66
+
67
+ // Resolve the body for a `gh issue edit` / `gh pr edit` command. Returns:
68
+ // undefined -> no body param present at all (not a body-carrying write)
69
+ // { body: string } -> known body text
70
+ // { body: null } -> a body param IS present but its text is unknowable
71
+ // (shell expansion, stdin/process-substitution path,
72
+ // or an unreadable --body-file): never block on this.
73
+ function resolveEditBody(cmd) {
74
+ // --body-file <path> / its short alias -F (quoted or bare; bare stops at
75
+ // whitespace/shell metacharacters, same tokenizer approach as
76
+ // secret-guard's bodyFileText). The `(?<![-\w])` guard on -F mirrors
77
+ // secret-guard's bodyFileText: it stops -F matching when embedded inside
78
+ // a longer token, and keeps --body-file from being double-matched.
79
+ const fileMatch = cmd.match(
80
+ /(?:--body-file|(?<![-\w])-F)(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s"';|&()<>`]+))/
81
+ );
82
+ if (fileMatch) {
83
+ const p = fileMatch[1] || fileMatch[2] || fileMatch[3];
84
+ if (p === '-' || p.startsWith('<')) return { body: null }; // stdin / process substitution
85
+ try {
86
+ return { body: readBodyFile(p) };
87
+ } catch {
88
+ return { body: null }; // unreadable: unknowable, not provably empty
89
+ }
90
+ }
91
+
92
+ // Inline --body <val> / --body=<val> / its short alias -b, quoted or
93
+ // bare. The `(?<![-\w])` guard on -b keeps it from matching the "-b"
94
+ // that appears inside "--body" itself.
95
+ const inlineMatch = cmd.match(
96
+ /(?:--body|(?<![-\w])-b)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|(\S*))/
97
+ );
98
+ if (inlineMatch) {
99
+ const val =
100
+ inlineMatch[1] !== undefined
101
+ ? inlineMatch[1]
102
+ : inlineMatch[2] !== undefined
103
+ ? inlineMatch[2]
104
+ : inlineMatch[3];
105
+ if (val === undefined) return { body: null };
106
+ if (isShellExpansion(val)) return { body: null };
107
+ return { body: val };
108
+ }
109
+
110
+ return undefined; // no body param at all
111
+ }
112
+
113
+ // Resolve the body for a `gh api ... -f/-F/--field/--raw-field body=<val>`
114
+ // write. Called only once the caller has already confirmed a `body=` field
115
+ // is present, so a null return here always means "unknowable", never
116
+ // "absent". Handles gh's `@<path>` file-reference form (`@-` is stdin, so
117
+ // unknowable) and a bare value carrying literal surrounding quotes (e.g.
118
+ // `-f body=""`, where the shell hasn't run yet so the quote characters are
119
+ // still in the raw command text).
120
+ function resolveApiBody(cmd) {
121
+ let raw;
122
+ let m = cmd.match(/(?:-f|-F|--field|--raw-field)\s+"body=([^"]*)"/);
123
+ if (m) raw = m[1];
124
+ if (raw === undefined) {
125
+ m = cmd.match(/(?:-f|-F|--field|--raw-field)\s+'body=([^']*)'/);
126
+ if (m) raw = m[1];
127
+ }
128
+ if (raw === undefined) {
129
+ m = cmd.match(/(?:-f|-F|--field|--raw-field)\s+body=(\S*)/);
130
+ if (m) raw = m[1];
131
+ }
132
+ if (raw === undefined) return null;
133
+
134
+ const val = stripQuotedPair(raw);
135
+
136
+ if (val.startsWith('@')) {
137
+ const ref = val.slice(1);
138
+ if (ref === '-') return null; // @- : stdin, unknowable
139
+ try {
140
+ return readBodyFile(ref);
141
+ } catch {
142
+ return null; // unreadable: unknowable, not provably empty
143
+ }
144
+ }
145
+
146
+ if (isShellExpansion(val)) return null;
147
+ return val;
148
+ }
149
+
150
+ // Is this a `gh` write that would CLOBBER an issue/PR body (the
151
+ // read-modify-write overwrite shape), and if so what would it write?
152
+ // Returns { kind, body } or null. `kind` is 'issue' | 'pr' | 'api'; `body` is
153
+ // the resolved text (string), or null when present-but-unknowable. Only
154
+ // `gh issue edit` / `gh pr edit` / `gh api ... body=` are recognised: `create`
155
+ // and `comment` add new content rather than overwrite an existing body, so
156
+ // they are out of scope for this guard.
157
+ function ghBodyWrite(cmd) {
158
+ const parsed = parseGhSubcommand(cmd);
159
+ if (!parsed) return null;
160
+ const { sub, verb } = parsed;
161
+
162
+ if ((sub === 'issue' || sub === 'pr') && verb === 'edit') {
163
+ const resolved = resolveEditBody(cmd);
164
+ if (resolved === undefined) return null; // no body param: not this guard's concern
165
+ return { kind: sub, body: resolved.body };
166
+ }
167
+ if (sub === 'api' && /(?:^|\s)(?:-f|-F|--field|--raw-field)\s+['"]?body=/.test(cmd)) {
168
+ return { kind: 'api', body: resolveApiBody(cmd) };
169
+ }
170
+ return null;
171
+ }
172
+
173
+ function block(kind) {
174
+ const label = kind === 'api' ? 'GitHub API write' : `gh ${kind} edit`;
175
+ process.stdout.write(
176
+ JSON.stringify({
177
+ decision: 'block',
178
+ code: 'EMPTY_ISSUE_BODY_WRITE',
179
+ reason:
180
+ `trailhead: this ${label} would set an EMPTY issue body, overwriting the existing one. ` +
181
+ 'A trailhead body is never legitimately empty, so this usually means a read-modify-write whose ' +
182
+ 'pre-write read returned an empty or degraded body (e.g. a transient HTTP 5xx on `gh issue view --json body`). ' +
183
+ 'Aborting to protect the existing body: re-read the current body (treat a non-200 or empty read as a hard ' +
184
+ 'error, retry once with backoff), rebuild it, then write again.',
185
+ })
186
+ );
187
+ process.exit(2);
188
+ }
189
+
190
+ function run(data) {
191
+ try {
192
+ let cmd = '';
193
+ try {
194
+ cmd = (JSON.parse(data).tool_input || {}).command || '';
195
+ } catch {
196
+ process.exit(0); // unparseable input: nothing meaningful to say
197
+ }
198
+ const hit = ghBodyWrite(cmd);
199
+ if (!hit || hit.body === null || hit.body === undefined) {
200
+ process.exit(0); // not a body write, or a body we cannot prove empty
201
+ }
202
+ if (isEmptyBody(hit.body)) {
203
+ block(hit.kind);
204
+ }
205
+ process.exit(0);
206
+ } catch {
207
+ process.exit(0); // never let a crash here block a legitimate write
208
+ }
209
+ }
210
+
211
+ if (require.main === module) {
212
+ let data = '';
213
+ const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends → give up (no write identified)
214
+ process.stdin.on('data', (c) => (data += c));
215
+ process.stdin.on('end', () => {
216
+ clearTimeout(timer);
217
+ run(data);
218
+ });
219
+ }
220
+
221
+ module.exports = { ghBodyWrite, isEmptyBody, resolveEditBody, resolveApiBody };
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ // Tests for trailhead-body-guard.js. Run: node trailhead-body-guard.test.js
3
+ // No framework: plain asserts + child_process for the end-to-end hook behaviour.
4
+ const assert = require('assert');
5
+ const { execFileSync } = require('child_process');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+
10
+ const HOOK = path.join(__dirname, 'trailhead-body-guard.js');
11
+ const { ghBodyWrite, isEmptyBody } = require('./trailhead-body-guard.js');
12
+
13
+ let passed = 0;
14
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
15
+
16
+ // Run the hook end-to-end: pipe tool_input JSON on stdin, capture {code, out}.
17
+ function runHook(command, env = {}) {
18
+ const input = JSON.stringify({ tool_input: { command } });
19
+ try {
20
+ const out = execFileSync('node', [HOOK], { input, env: { ...process.env, ...env } });
21
+ return { code: 0, out: out.toString() };
22
+ } catch (e) {
23
+ return { code: e.status, out: (e.stdout || '').toString(), err: (e.stderr || '').toString() };
24
+ }
25
+ }
26
+
27
+ // --- unit: ghBodyWrite() recognition ---
28
+ {
29
+ const tmp = path.join(os.tmpdir(), `bg-file-${process.pid}.md`);
30
+ fs.writeFileSync(tmp, 'hello there\n');
31
+ const r1 = ghBodyWrite(`gh issue edit 5 --body-file ${tmp}`);
32
+ ok('recognizes gh issue edit --body-file', r1 && r1.kind === 'issue' && r1.body === 'hello there\n');
33
+ fs.unlinkSync(tmp);
34
+ }
35
+ ok('recognizes gh issue edit --body (inline)',
36
+ (() => { const r = ghBodyWrite('gh issue edit 5 --body "hi"'); return r && r.kind === 'issue' && r.body === 'hi'; })());
37
+ ok('recognizes gh pr edit --body "" (empty inline)',
38
+ (() => { const r = ghBodyWrite('gh pr edit 5 --body ""'); return r && r.kind === 'pr' && r.body === ''; })());
39
+ ok('recognizes gh api ... -f body=foo',
40
+ (() => { const r = ghBodyWrite('gh api --method PATCH repos/o/r/issues/5 -f body=foo'); return r && r.kind === 'api' && r.body === 'foo'; })());
41
+ ok('recognizes the -R o/r global-flag form',
42
+ (() => { const r = ghBodyWrite('gh -R owner/repo issue edit 5 --body "hi"'); return r && r.kind === 'issue' && r.body === 'hi'; })());
43
+ // Regression (#153): an unknown two-token global flag (e.g. --hostname h)
44
+ // before the subcommand must not desync the walk and hide the write.
45
+ ok('recognizes gh issue edit behind an unknown two-token global flag (--hostname h)',
46
+ (() => { const r = ghBodyWrite('gh --hostname h issue edit 5 --body ""'); return r && r.kind === 'issue' && r.body === ''; })());
47
+ ok('recognizes gh pr edit behind an unknown two-token global flag (--hostname h)',
48
+ (() => { const r = ghBodyWrite('gh --hostname h pr edit 5 --body ""'); return r && r.kind === 'pr' && r.body === ''; })());
49
+ // Regression (#153 follow-up): a two-token global flag whose VALUE literally
50
+ // equals a subcommand keyword (`api`) must still resolve the real `issue
51
+ // edit` write.
52
+ ok('recognizes gh issue edit behind a keyword-valued two-token global flag (--hostname api)',
53
+ (() => { const r = ghBodyWrite('gh --hostname api issue edit 5 --body ""'); return r && r.kind === 'issue' && r.body === ''; })());
54
+ // Regression (#153 second follow-up): an unknown BOOLEAN flag directly
55
+ // before the subcommand must not swallow the subcommand as its own value.
56
+ ok('recognizes gh issue edit behind an unknown boolean flag (--foo)',
57
+ (() => { const r = ghBodyWrite('gh --foo issue edit 5 --body ""'); return r && r.kind === 'issue' && r.body === ''; })());
58
+
59
+ // --- unit: ghBodyWrite() non-matches ---
60
+ ok('ignores gh issue edit with no body param',
61
+ ghBodyWrite('gh issue edit 5 --add-label x') === null);
62
+ ok('ignores gh issue comment (excluded verb)',
63
+ ghBodyWrite('gh issue comment 5 --body "hi"') === null);
64
+ ok('ignores gh issue create (excluded verb)',
65
+ ghBodyWrite('gh issue create --title t --body-file /tmp/x') === null);
66
+ ok('ignores non-gh commands',
67
+ ghBodyWrite('echo --body ""') === null);
68
+
69
+ // --- unit: unknowable bodies are not blockable ---
70
+ {
71
+ const r = ghBodyWrite('gh issue edit 5 --body "$(cat f)"');
72
+ ok('a shell-expansion inline body is unknowable (not blockable)', r && r.kind === 'issue' && r.body === null);
73
+ }
74
+ {
75
+ const r = ghBodyWrite('gh issue edit 5 --body-file -');
76
+ ok('a --body-file - (stdin) body is unknowable (not blockable)', r && r.kind === 'issue' && r.body === null);
77
+ }
78
+ {
79
+ const r = ghBodyWrite('gh issue edit 5 --body-file /no/such/file/does-not-exist.md');
80
+ ok('a --body-file whose read throws is unknowable (not blockable)', r && r.kind === 'issue' && r.body === null);
81
+ }
82
+
83
+ // --- unit: isEmptyBody() ---
84
+ ok("isEmptyBody('') is true", isEmptyBody('') === true);
85
+ ok("isEmptyBody(' \\n\\t ') is true", isEmptyBody(' \n\t ') === true);
86
+ ok("isEmptyBody('x') is false", isEmptyBody('x') === false);
87
+ ok("isEmptyBody(' a ') is false", isEmptyBody(' a ') === false);
88
+
89
+ // --- end-to-end: block ---
90
+ const b1 = runHook('gh issue edit 5 --body ""');
91
+ ok('blocks an empty inline --body (exit 2)', b1.code === 2 && /"decision":"block"/.test(b1.out) && /EMPTY_ISSUE_BODY_WRITE/.test(b1.out));
92
+
93
+ // Regression (#153): the same block must fire when an unknown two-token
94
+ // global flag (e.g. --hostname h) sits before the subcommand.
95
+ const bHost1 = runHook('gh --hostname h issue edit 5 --body ""');
96
+ ok('blocks an empty --body behind an unknown two-token global flag (issue edit, exit 2)',
97
+ bHost1.code === 2 && /"decision":"block"/.test(bHost1.out) && /EMPTY_ISSUE_BODY_WRITE/.test(bHost1.out));
98
+ const bHost2 = runHook('gh --hostname h pr edit 5 --body ""');
99
+ ok('blocks an empty --body behind an unknown two-token global flag (pr edit, exit 2)',
100
+ bHost2.code === 2 && /"decision":"block"/.test(bHost2.out) && /EMPTY_ISSUE_BODY_WRITE/.test(bHost2.out));
101
+
102
+ // Regression (#153 follow-up): a two-token global flag whose VALUE literally
103
+ // equals a subcommand keyword (`api`) must still block the real `issue edit`
104
+ // empty-body write end to end.
105
+ const bHost3 = runHook('gh --hostname api issue edit 5 --body ""');
106
+ ok('blocks an empty --body behind a keyword-valued two-token global flag (--hostname api, exit 2)',
107
+ bHost3.code === 2 && /"decision":"block"/.test(bHost3.out) && /EMPTY_ISSUE_BODY_WRITE/.test(bHost3.out));
108
+
109
+ // Regression (#153 second follow-up): an unknown BOOLEAN flag before the
110
+ // subcommand must not hide the write by swallowing the subcommand itself.
111
+ const bFoo = runHook('gh --foo issue edit 5 --body ""');
112
+ ok('blocks an empty --body behind an unknown boolean flag (--foo, exit 2)',
113
+ bFoo.code === 2 && /"decision":"block"/.test(bFoo.out) && /EMPTY_ISSUE_BODY_WRITE/.test(bFoo.out));
114
+
115
+ {
116
+ const empty = path.join(os.tmpdir(), `bg-empty-${process.pid}.md`);
117
+ fs.writeFileSync(empty, '');
118
+ const b2 = runHook(`gh issue edit 5 --body-file ${empty}`);
119
+ ok('blocks an empty --body-file (exit 2)', b2.code === 2 && /"decision":"block"/.test(b2.out));
120
+ fs.unlinkSync(empty);
121
+ }
122
+
123
+ {
124
+ const whitespace = path.join(os.tmpdir(), `bg-ws-${process.pid}.md`);
125
+ fs.writeFileSync(whitespace, ' \n\t \n');
126
+ const b3 = runHook(`gh pr edit 5 --body-file ${whitespace}`);
127
+ ok('blocks a whitespace-only --body-file (exit 2)', b3.code === 2 && /"decision":"block"/.test(b3.out));
128
+ fs.unlinkSync(whitespace);
129
+ }
130
+
131
+ const b4 = runHook('gh api --method PATCH repos/o/r/issues/5 -f body=');
132
+ ok('blocks an empty gh api body= (exit 2)', b4.code === 2 && /"decision":"block"/.test(b4.out));
133
+
134
+ // --- end-to-end: block via short flags (-b / -F, gh's documented aliases) ---
135
+ const b5 = runHook('gh issue edit 5 -b ""');
136
+ ok('blocks an empty short -b (issue edit, exit 2)', b5.code === 2 && /"decision":"block"/.test(b5.out));
137
+
138
+ const b6 = runHook('gh pr edit 5 -b ""');
139
+ ok('blocks an empty short -b (pr edit, exit 2)', b6.code === 2 && /"decision":"block"/.test(b6.out));
140
+
141
+ {
142
+ const shortEmpty = path.join(os.tmpdir(), `bg-short-empty-${process.pid}.md`);
143
+ fs.writeFileSync(shortEmpty, '');
144
+ const b7 = runHook(`gh issue edit 5 -F ${shortEmpty}`);
145
+ ok('blocks an empty short -F body-file (exit 2)', b7.code === 2 && /"decision":"block"/.test(b7.out));
146
+ fs.unlinkSync(shortEmpty);
147
+ }
148
+
149
+ const a5 = runHook('gh issue edit 5 -b "real content"');
150
+ ok('allows a non-empty short -b (exit 0)', a5.code === 0 && a5.out.trim() === '');
151
+
152
+ // --- unit: ghBodyWrite() recognizes short flags directly ---
153
+ ok('ghBodyWrite recognizes -b (issue edit)',
154
+ (() => { const r = ghBodyWrite('gh issue edit 5 -b "hi"'); return r && r.kind === 'issue' && r.body === 'hi'; })());
155
+ {
156
+ const tmp = path.join(os.tmpdir(), `bg-shortfile-${process.pid}.md`);
157
+ fs.writeFileSync(tmp, 'content\n');
158
+ const r = ghBodyWrite(`gh pr edit 5 -F ${tmp}`);
159
+ ok('ghBodyWrite recognizes -F (pr edit)', r && r.kind === 'pr' && r.body === 'content\n');
160
+ fs.unlinkSync(tmp);
161
+ }
162
+ // -b must not accidentally match the "-b" inside "--body" itself.
163
+ ok('ghBodyWrite still resolves --body correctly alongside the -b alias',
164
+ (() => { const r = ghBodyWrite('gh issue edit 5 --body "hi"'); return r && r.kind === 'issue' && r.body === 'hi'; })());
165
+
166
+ // --- end-to-end: block via gh api empty-body forms ---
167
+ const b8 = runHook('gh api --method PATCH repos/o/r/issues/5 -f body=""');
168
+ ok('blocks gh api -f body="" (literal empty double-quotes, exit 2)', b8.code === 2 && /"decision":"block"/.test(b8.out));
169
+
170
+ const b9 = runHook("gh api --method PATCH repos/o/r/issues/5 -f body=''");
171
+ ok("blocks gh api -f body='' (literal empty single-quotes, exit 2)", b9.code === 2 && /"decision":"block"/.test(b9.out));
172
+
173
+ {
174
+ const apiEmpty = path.join(os.tmpdir(), `bg-api-empty-${process.pid}.md`);
175
+ fs.writeFileSync(apiEmpty, '');
176
+ const b10 = runHook(`gh api --method PATCH repos/o/r/issues/5 -F body=@${apiEmpty}`);
177
+ ok('blocks gh api -F body=@<empty file> (exit 2)', b10.code === 2 && /"decision":"block"/.test(b10.out));
178
+ fs.unlinkSync(apiEmpty);
179
+ }
180
+
181
+ const a6 = runHook('gh api --method PATCH repos/o/r/issues/5 -F body=@-');
182
+ ok('allows gh api -F body=@- (stdin, unknowable, exit 0)', a6.code === 0 && a6.out.trim() === '');
183
+
184
+ {
185
+ const apiFull = path.join(os.tmpdir(), `bg-api-full-${process.pid}.md`);
186
+ fs.writeFileSync(apiFull, 'real content\n');
187
+ const a7 = runHook(`gh api --method PATCH repos/o/r/issues/5 -f body=@${apiFull}`);
188
+ ok('allows a non-empty gh api -f body=@<file> (exit 0)', a7.code === 0 && a7.out.trim() === '');
189
+ fs.unlinkSync(apiFull);
190
+ }
191
+
192
+ // --- end-to-end: block via ~-expanded --body-file (fs.readFileSync does not expand ~) ---
193
+ {
194
+ const homeRel = `.bg-tilde-test-${process.pid}.md`;
195
+ const homeAbs = path.join(os.homedir(), homeRel);
196
+ fs.writeFileSync(homeAbs, '');
197
+ const b11 = runHook(`gh issue edit 5 --body-file ~/${homeRel}`);
198
+ ok('blocks an empty ~/-prefixed --body-file (exit 2)', b11.code === 2 && /"decision":"block"/.test(b11.out));
199
+ fs.unlinkSync(homeAbs);
200
+ }
201
+
202
+ // --- end-to-end: allow ---
203
+ const a1 = runHook('gh issue edit 5 --body "not empty"');
204
+ ok('allows a non-empty body write (exit 0, no output)', a1.code === 0 && a1.out.trim() === '');
205
+ const a2 = runHook('gh issue view 5 --json body'); // read, not a write
206
+ ok('allows a read (exit 0)', a2.code === 0 && a2.out.trim() === '');
207
+ const a3 = runHook('gh issue comment 5 --body ""'); // excluded verb, even though empty
208
+ ok('allows an empty gh issue comment (excluded verb, exit 0)', a3.code === 0 && a3.out.trim() === '');
209
+ const a4 = runHook('gh issue edit 5 --body "$(cat f)"'); // unknowable, can't prove empty
210
+ ok('allows an unknowable (shell-expansion) body (exit 0)', a4.code === 0 && a4.out.trim() === '');
211
+
212
+ // --- crash-safety ---
213
+ try {
214
+ execFileSync('node', [HOOK], { input: 'not json at all {{{' });
215
+ ok('unparseable stdin does not throw (exit 0)', true);
216
+ } catch (e) {
217
+ ok('unparseable stdin exits 0', e.status === 0);
218
+ }
219
+ const c2 = runHook('gh issue edit 5 --add-label x'); // non-body gh command
220
+ ok('a non-body gh command exits 0', c2.code === 0 && c2.out.trim() === '');
221
+
222
+ console.log(`✓ body-guard: ${passed} assertions passed`);