@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.
- package/README.it.md +12 -9
- package/README.md +12 -9
- package/bin/lib/codex-projection.js +5 -4
- package/bin/lib/codex-projection.test.js +3 -1
- package/bin/trailhead.js +9 -5
- package/bin/trailhead.test.js +130 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/commands/prune.md +6 -0
- package/plugins/trailhead/hooks/hooks.json +4 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +102 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +99 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +221 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +222 -0
- package/plugins/trailhead/hooks/trailhead-issue-injection-scanner.js +61 -35
- package/plugins/trailhead/hooks/trailhead-issue-injection-scanner.test.js +76 -0
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +4 -15
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +31 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -1
- package/plugins/trailhead/skills/_shared/configuration-reference.md +1 -1
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
- package/plugins/trailhead/skills/_shared/principles.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +3 -3
- package/plugins/trailhead/skills/_shared/substrate-commands.md +41 -5
- package/plugins/trailhead/skills/_shared/substrate.md +10 -6
- package/plugins/trailhead/skills/_shared/teamwork.md +19 -12
- package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -0
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +12 -2
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
- package/plugins/trailhead/skills/trailhead-manage/references/pruning.md +48 -0
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +4 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +13 -11
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
- 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`);
|