feat(source-control): gate PR bodies against pr-issue-linkage at authoring time - #1751
Conversation
…oring time The `pr-issue-linkage` check is a required merge gate, but nothing enforced its contract when a PR body was written, so a body missing a closing keyword or a `## Related` section was only caught post-hoc — one CI round trip after the PR was already open. A `PreToolUse` hook on the Bash tool now validates a `gh pr create` / `gh pr edit` body against the same contract before the call runs, and blocks with the missing half named so the authoring agent self-corrects in the same turn. Enforcement is keyed to the consuming repository's own policy: it runs only where `.github/workflows/pr-issue-linkage.yml` exists. The validator is mirrored rather than approximated — both HTML-comment strips in order, a deeper `###` heading counted as the `## Related` section's content, and JavaScript's `\b` boundaries transcribed explicitly. Extraction fails open: only a `--body` literal, a readable `--body-file`, or the sole heredoc feeding stdin or a `$(cat <<EOF)` substitution is judged. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 5m 39s —— View job Security Review — PR #1751
FindingsIMPORTANT — Telemetry sink executes an arbitrary binary from a consumer-controlled env varConfidence: PLAUSIBLE printf '%s\n' "$envelope" | ("$sink" >/dev/null 2>&1) &
sink="${root%/}/$sink"A project's { "env": { "HOOK_TELEMETRY_SINK": ".claude/bin/collect.sh" } }…and that script exfiltrates data or runs arbitrary code, it executes in the background on every The JSON envelope piped to the sink is safely constructed through What matters for risk calibration: Claude Code's permission model gates whether project-level SUGGESTION —
|
|
Claude finished @kyle-sexton's task in 6m 25s —— View job Security Review — PR #1751
Overall assessmentNo critical security vulnerabilities found. The hook's failure modes are all fail-open (ambiguous parse → enforcement skipped), not code-executing or incorrectly-blocking. The five findings below are correctness gaps and minor edge cases; none creates an exploitable security vector given the hook's stated purpose (authoring-time quality enforcement, not a security sandbox). Finding 1 —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 66432f0855
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The hook is invoked as a command by the harness, so its entry script needs the executable bit; the repo's exec-bit hygiene check enforces the same for the paired test. `hook-utils.sh` stays non-executable — it is sourced, never run. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Security section claimed the plugin emits no telemetry, which stopped being true the moment the hook landed: `emit_tel` dispatches an envelope on every run whenever a consumer wires `HOOK_TELEMETRY_SINK`. Document it the way guardrails documents the same shared facility, and state what the payload carries — the outcome and the body form read, labels only, never the body, command, or a path. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
main bumped source-control to 0.35.0 while this branch was open, so the plugin version moves to 0.36.0 and this branch's changelog entry moves with it.
main released source-control 0.35.1 while this branch was open; this branch's 0.36.0 entry stays on top of it and the manifest keeps 0.36.0.
|
Claude finished @kyle-sexton's task in 4m 32s —— View job Security Review — PR #1751
Finding 1 —
|
|
Claude finished @kyle-sexton's task in 4m 13s —— View job PR Review — #1751
|
Port verified differentially against the real validatorBeyond the 53 committed black-box cases, I ran a one-off differential harness: the 46 compared, 46 agree, 0 disagree. The corpus deliberately targets where a hand port diverges:
The harness is scratch, not committed: making it permanent would mean vendoring a hand-copy of Worth recording: this PR's own first-draft body was blocked by its own hook. The draft spelled |
`gh pr create --body` with nothing after it is a command gh rejects on its own — there is no body to judge — but the parser read the absent word as an empty value and blocked, reporting a body problem for what is actually a malformed command. Both body flags now require a following word before extraction. An explicitly empty value stays judged: the word is present, and gh would open a PR with a blank body the gate really does reject. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
main released source-control 0.36.0 while this branch was open, so this branch's entry moves to 0.37.0 and sits above it.
|
Claude finished @kyle-sexton's task in 4m 12s —— View job PR Review — #1751
|
| Fix | Commit |
|---|---|
Exec bit on hook and test (100755) |
5db7e3e |
| Telemetry disclosure in README | a0ae5d5 |
Fail-open when --body/--body-file has no trailing word |
c57fae3 |
The implementation is sound. What follows is the residual open items as of 9e29350, ordered by impact.
Finding 1 — hook-utils.sh comment actively recommends the unsafe HOOK_TELEMETRY_SINK configuration pattern
Severity: IMPORTANT — Confidence: CONFIRMED
# Sink path resolution: HOOK_TELEMETRY_SINK may be absolute OR relative to the
# consuming repo root. …Relative is the portable, team-shared wiring form:
# CC injects settings.json env values literally (no ${VAR} expansion), so a
# relative path tracked in settings.json is the only clone-portable,
# worktree-safe option.
This comment guides users toward tracking HOOK_TELEMETRY_SINK in a project's committed settings.json and calls it "the portable, team-shared wiring form." That is exactly the configuration that creates the code-execution primitive: a malicious repository ships .github/workflows/pr-issue-linkage.yml (to satisfy its own scope guard), a tracked .claude/settings.json with HOOK_TELEMETRY_SINK pointing at a relative path inside the repo, and an executable at that path. Any user who opens that repository and runs a gh pr create has the sink executed in the background.
The README's Telemetry (opt-in) section documents the feature without mentioning the project-level risk. The README Security section mentions no telemetry unless opted in, but gives no guidance on where the opt-in should live.
Minimum change before merge — add a SECURITY note to the hook::emit_telemetry docblock alongside the sink-resolution paragraph:
# SECURITY: HOOK_TELEMETRY_SINK must be configured in user-level (~/.claude/settings.json)
# or machine-level settings only — never in a tracked project .claude/settings.json.
# A project controls its own scope-guard file and could otherwise use a relative
# sink to execute arbitrary code on every gated hook invocation in any repository
# where the plugin is installed. A relative value in tracked settings.json is
# "portable" only in the non-malicious case; the portability is real but the
# trust model is user-level, not project-level.And mirror the constraint in the README's Telemetry (opt-in) subsection.
Finding 2 — --repo=* glob matches the empty-value form
Severity: SUGGESTION — Confidence: CONFIRMED
-R | --repo | -R?* | --repo=*) return 0 ;;--repo=* matches zero or more characters, so --repo= (empty value, no repo named) returns 0 and skips enforcement. gh rejects --repo= at runtime, so no PR is ever created through this path — no practical impact. But the hook's contract says it skips because "the target repository may not be the one whose gate file was read," and an empty --repo doesn't satisfy that. --repo=?* (one or more characters after =) would match the stated intent.
Finding 3 — command -v gh pr create is parsed as a live gh pr create
Severity: Low (no false block today) — Confidence: CONFIRMED
pr-body-linkage-gate.sh:312–319
The wrapper-stripping loop strips command as a prefix then continues, landing on gh. It does not apply the command -v/-V bail-out that hook::git_resolve_index uses (hook-utils.sh:1033–1035). In practice there is no false block — command -v gh pr create carries no --body, so body_flag == "" → return 0. It is a latent correctness gap and a semantic divergence from the git resolver worth closing in a follow-up.
Finding 4 — printf '%(...)T' requires Bash 4.2+; the hook documents 3.2+ support
Severity: Very low — Confidence: CONFIRMED
timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1)printf '%(…)T' is a Bash 4.2 extension. macOS ships Bash 3.2; on that host the format string is emitted literally, producing a malformed (but syntactically valid JSON) timestamp in the telemetry envelope. No functional impact — the hook's block/allow decision is unaffected and telemetry is opt-in — but the degradation is silent. The version guard at hook-utils.sh:329 (hook::read_supports_nchars) shows the pattern; date -u +%Y-%m-%dT%H:%M:%SZ as a fallback would close it consistently with that approach.
Minor Correctness Observations
Dead --repo entry in takes_value (pr-body-linkage-gate.sh:283): -R | --repo is matched by the early-return case arm at line 335 before control ever reaches takes_value. The entry is dead but could mislead a future reader wondering whether --repo's argument is being consumed.
Stray second argument on the success emit_tel call (pr-body-linkage-gate.sh:411): emit_tel "ok" "" — emit_tel uses only $1; the "" is silently ignored. emit_tel "blocked" at line 255 passes one argument. Harmless but inconsistent.
What Looks Good
- Scope guard is the right authority signal: keyed to the consumer's own
.github/workflows/pr-issue-linkage.yml, so the hook cannot drift from CI enforcement regardless of plugin version. - Fail-open design is correctly implemented and tested: all undeterminable paths (
$VARbody, multiple heredocs, unreadable file, no body flag,--repo, trailing--bodywith no word) return 0. Thec57fae3trailing-flag fix is in place. - HTML-comment stripping is a state machine, not a one-shot regex: matches the CI validator's two-pass structure (terminated spans → unterminated opener swallows remainder).
- Word-boundary transcription is correct:
$'\n'-wrapped probe +[^a-z0-9_]anchors faithfully reproduce JavaScript\b;Closes #12abcandunclosed #5are non-matches exactly as in CI. ## Relatedsection boundary is heading-level-aware:###subsections count as content, not terminators — matches the validator semantics and the PR's own description.sole_heredoc_bodycorrectly refuses multiple heredocs: returns 1 rather than guessing which heredoc reachesgh.block()output is agent-actionable: names the missing half and the exact text to add.- 53-case test suite covers every documented path, with fixture repos from an unrelated CWD to correctly exercise the scope guard.
- Plugin manifest, README, CHANGELOG, and version bumped in a coherent
0.37.0increment.
Summary
One actionable item before merge: Finding 1 — the hook-utils.sh comment block should include a SECURITY: note warning that HOOK_TELEMETRY_SINK belongs in user- or machine-level settings only, never in a tracked project settings.json, with a matching note in the README. All other findings are low-to-cosmetic. The core implementation is correct and the fail-open contract is solid throughout.
|
Claude finished @kyle-sexton's task in 4m 56s —— View job Security Review — PR #1751
Addressed since the initial commitsTwo findings from the Codex review were resolved in-branch:
IMPORTANT —
|
…1753) No linked issue ## Summary An independent fresh-context review of #1751 — run after that PR had already merged — found six defects in the `pr-body-linkage-gate` hook. I reproduced every one before touching the code; all six are fixed here, with a regression case for each. Two of them were live in normal use, which is why this is going out immediately rather than as routine follow-up. ### The two that were biting **A `cd` on the same command line retargeted the whole gate.** The gate file and any relative `--body-file` resolve against the hook payload's `cwd`, but the segment tokenizer discards the `cd` segment — so `cd <worktree> && gh pr create …`, a routine shape in a multi-worktree setup, was judged against the session's directory instead of the one `gh` actually runs in. Two distinct live defects fell out of that: - a **false block** — a compliant body was rejected because a same-named file in the session's directory was read in its place; - a **scope leak** — enforcement fired inside repositories carrying no `pr-issue-linkage.yml` at all, directly contradicting the scope guard's own stated promise. A `cd`, `pushd`, or `popd` segment now puts every later segment out of scope, the same posture `--repo` already had. A directory change *after* the `gh` call still gates normally. **The hook exceeded its own timeout on large bodies and silently stopped gating.** Trimming each body line ran through a command substitution, so every line cost a fork. Measured before the fix: | body | before | after | |---|---|---| | 200 lines | 4.4 s | 0.6 s | | 500 lines | 10.4 s | 0.7 s | | 1000 lines | 18.3 s | 1.3 s | | 5000 lines | — | 1.3 s | `hooks.json` declares a 15-second timeout, so past roughly 800 lines the hook was cancelled — on exactly the large PRs it most wants to catch, and `## Related` being the last section means the scan always walks the whole body. Both per-line trims plus the one in the heredoc reader are parameter expansion now, which is why the curve goes flat. A regression case fails if a 1000-line body ever approaches the timeout again. ### The other four - **Locale-dependent verdicts.** `[[:space:]]` stood in for JavaScript's `\s`, but its membership is locale-defined while `\s` is a fixed set. Under `LC_ALL=C` a body with a non-breaking space between `Closes:` and `#5` — routine in text pasted from an issue title — was rejected where CI accepts it. Both halves are pinned now: every non-ASCII member of the `\s` set is rewritten to a plain space by UTF-8 byte sequence (spelled as bytes, not `\uXXXX`, because bash renders `\u` through the very charmap being removed as a dependency), then matching runs under `LC_ALL=C` where `[[:space:]]` is exactly the six ASCII characters. Tests assert both locales agree. - **pflag grouped shorthand bypassed the gate.** `gh pr create -db BODY`, `-dbBODY`, `-dF file`, and `-dFfile` are all valid gh and all passed, because only a bare `-b`/`-F` was recognized. Clusters are walked properly now; an unknown letter stops the walk rather than guessing which letter would have consumed the next word. - **`gh` was matched only as the exact literal**, so `gh.exe`, `/usr/bin/gh`, `./gh`, and `sudo gh` all bypassed it — inconsistent with the basename comparison the wrapper loop ten lines above already used. Matched by basename now, backslash paths and `.exe` included. - **A stalled payload blocked the command.** The gate inherited the sibling *security* guards' fail-closed posture on unreadable stdin, which for a scoped policy gate means refusing an arbitrary Bash command because the hook could not read its own input. It allows now, with the divergence and its reason recorded at the site. Two smaller things came along: the absent-versus-empty `## Related` distinction moved off a sentinel string a section's content could theoretically equal, onto the return-code channel; and the pre-filter now requires `gh` at a word boundary, so `npm run lighthouse-prod` no longer pays for a full parse. ### What I did not fix One comment-stripping residual stays, documented at the hook's own site: the validator strips a comment span across a line break and joins what surrounds it, so a heading split by a comment mid-word is one heading to CI and two lines here. Reproducing it needs whole-body rather than per-line stripping, and the shape does not occur in a real body. ## Test plan - `plugins/source-control/hooks/pr-body-linkage-gate.test.sh` — **92 cases, up from 57**, all passing. New coverage is exactly the reviewer's uncovered list: grouped shorthand in all four shapes, `cd`/`pushd` drift plus the after-the-call control, `gh.exe` / path-qualified / `./gh` / `sudo gh`, a 1000-line body timing guard, locale-pinned cases run under both `LC_ALL=C` and a UTF-8 locale, `--body-file=X` and `-FX` attached forms, an absolute body-file path, the `.yaml` gate spelling, `gh pr edit --body-file`, missing-`jq` fail-open, and CRLF bodies. - Every defect reproduced against the shipped 0.37.0 hook first, then re-run against the fix. The before/after numbers in the table above are from that harness. - Differential re-run against the real ci-workflows validator: 46 fixtures, 46 agree, 0 disagree — unchanged, confirming none of these fixes moved the validator parity. - Repo gates green locally: `shellcheck`, `shfmt`, `check-silent-skips`, `check-hook-userconfig-argv`, `check-shell-portability` vs `origin/main`, `sync-hook-utils --check`, `check-changelog-parity --check-bump`, `validate-plugin-contracts`, and `markdownlint-cli2`. ## Related - Follows #1751, which introduced the hook. These are review findings against that PR; it had already merged when the review returned, so they land as a fix rather than as changes on that branch. - The test suite drops its claim to "prove the hook mirrors the ci-workflows validator". Nothing in it executes that validator — all 92 expectations are hand-transcribed from a reading of the JavaScript, which is precisely how the locale divergence survived #1751's own review. A genuine oracle would mean vendoring upstream JavaScript into this repo, which needs a sync seam decision rather than an invented one; recorded here as a follow-up candidate, deliberately not filed. - `docs/conventions/pr-body-convention/README.md` — unchanged by this PR; the gate still keys on the workflow file rather than the `pr_body_required_sections` key, for the reason #1751 recorded. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1544) *This was generated by AI during work-loop execution.* ## Summary Enables the two shell-portability-lint classes #1510 staged for this PR — `date -d` and `stat -c`. (The issue's third class, `mktemp -p`, went active separately in #1543 while this branch was open, so the token file's STAGED section is now empty.) - **Precision fixes to the staged regexes.** The original patterns matched `date`/`stat` as bare substrings, so `[[ -d "$candidate" ]]` (via "can-**DATE**") and `git -c alias.x=status -c ...` (via "**STAT**us") false-positived. Both now require whitespace immediately after the command name. - **Extended `is_guarded()`** with a same-line `stat -c` / `stat -f` guard requiring an actual `||` fallback relationship, matching the rigor #1519/#1534 established for the `readlink`/`realpath` guard. - **Ran `scripts/check-shell-portability.sh --all`** per the issue's step 4 and resolved every real hit from the two newly-active classes: - `portability-ok:` annotations on already-correct dual-dialect date/stat call sites in `claude-ops`, `context-guard`, `kindle-dedrm`, `work-items` (most span a line break or an if/else block, so the same-line auto-guard cannot recognize them even after extension); - a genuine fix for one previously-unguarded gap: `skill-quality`'s vendor-sync-age check had no BSD `date` fallback at all and silently no-op'd on macOS; - Windows-only-script annotations for `kindle-dedrm`'s two `stat -c` sites. - **Pre-existing violations of already-active classes** surfaced by touching `skill-quality/scripts/check-skill.sh` (GNU-only `\S`/`\b` escapes in its own `grep -qE` patterns) were fixed so the PR's own diff stays clean. - Every touched plugin's version is bumped with a matching CHANGELOG entry. ## Scanner correctness work (review rounds) Codex review found defects in the scanner itself across several rounds. Every one is addressed here — all but one fixed, and that one recorded as designed behavior. The first five: | Reported shape | Direction | Resolution | | --- | --- | --- | | `stat ${x:-$((1 \| 2))} -c %s` read clean | fail-open | Fixed — arithmetic expansion is its own mask state with per-frame paren-depth tracking, so `$((` is no longer consumed as `$(` plus a stray `(` | | `x=$(stat -c …) y=$(true) \|\| stat -f …` read as a guarded ladder | fail-open | Fixed — `status_swallowed()` now establishes that the matched frame is the *status-determining* frame of its command, rather than excluding one neighbour shape at a time | | `d"a"te -d …` / `st"a"t -c …` read clean | fail-open | Fixed — command names are spelled letter-by-letter with optional quote runs between them, since quote removal splices the word before the utility sees argv | | A quoted word spanning physical lines hid its option | fail-open | Fixed — records join on an unterminated quote as they already did on a dangling backslash, with every escape attributed to the physical line the hit sits on | | A utility named in a string (`echo "run date -d tomorrow"`) is reported | false positive | **Not fixed — documented.** Recorded in the script header as the gate's largest accepted over-flag | On the last row: matching text the shell would treat as a string literal is the whole mechanism behind the regex-escape classes, where `grep -E "\bword"` lives inside quotes and must still be caught. Requiring command position for the option-based classes alone needs a per-class axis in the token data plus word-level tokenization, and every partial answer trades this false positive for a fail-**open** — the same trade already made and withdrawn for `--` (see the block above `collapse_subs()`). `portability-ok:` is the one-line escape. This is the same decision already taken once in this file, now written down rather than left implicit. Two further defects were found and fixed while closing the quote-join finding, both pre-existing: - **Heredoc bodies leaked quote state.** A stray backquote in a PowerShell settings body (``"CustomRule`Path"``) opened a frame that, once joining was active, swallowed the 57 lines after it. Heredoc bodies are now excluded from joining — they are data, so they can neither continue a command nor leave a quote open — while still being scanned, since this corpus writes real scripts through heredocs. - **A `#` opening a joined physical line did not start a comment**, so a commented-out `|| stat -f` could excuse a hit above it. A newline now joins `WORDSTART`. The security-review lane then found a third, in the gate's own plumbing: a relative `SHELL_PORTABILITY_TOKENS` path shaped like `identifier=value` is parsed by awk as a variable assignment rather than opened, so no class loaded, every file reported clean, and awk still exited 0 — invisible to the scanner-fault check. It now gets the same `./` disambiguation the scanned file already had, and an empty pattern set fails closed however it arose. A further review round then found six more, five of them pre-existing and one a regression from the quote-join above. Rather than answer them one at a time — the pattern that had been producing a fresh variant every round — they were taken as three families and generalized: - **Quote spellings the token classes did not admit.** A backslash quotes exactly as a quote pair does, so the quote-run class is now `['"\]` in every place the command word, the short-option cluster and the long option are spelled — closing `da\te -d`, `date -\d`, `date "--date"`, `date --"date"=` and `stat --"format"=` together. `&>` / `&>>` join the separator class after the command name, since bash runs `date&>/dev/null -d tomorrow` with the GNU-only option. - **Boundaries that predate records containing a newline.** A structural newline ends a command inside a `$( )` frame, so it now bounds the guard's segment gap and the lookback both guards share. That lookback became a backward scan rather than a greedy `.*[;|&)]` match, because whether `.` matches a newline is an awk-implementation difference this gate must not rest on. **This closes the one regression the quote-join introduced**: `x=$(stat -c …` newline `true) || stat -f …` had read as a guarded ladder. - **Frames still not tracked.** A raw subshell inside a command substitution was not pushed, so its closing paren popped the substitution — the same unbalanced-frame failure the arithmetic branch fixed, one spelling over. A `)` with no frame open remains a `case` pattern terminator. Also in that round: a spaced redirection operand (`|| 2> /dev/null stat -f …`) is no longer rejected as a non-ladder, and the whole-file `portability-scope:` declaration moved out of a grep pre-pass into the awk program. A grep sees no shell structure, so it honored the token inside a heredoc **body**, where the line is generated data rather than a declaration the file makes about itself — one such line silently exempted a whole file. A final round found the same quote family reached through Bash ANSI-C (`$'…'`) and locale (`$"…"`) quoting: `d$'a'te -d`, `date -$'d'`, `stat -$'c'`, `st$'a't -c` and `date $"--date"=` all reach the GNU utility while reading clean. A quote-run element is now `(\$?['"]|\\)` — an optional `$` before a quote, or a backslash — defined once and shared by the command word, the short-option cluster, the long option, and the fallback guard. A **bare** `$` is deliberately excluded, since `$config` is a variable expansion rather than quote removal: `validate -d $config` stays clean and `d$a$t$e` is not a spelling of `date`, both pinned as negatives. Moving the scope decision into awk then turned out to have fixed only the heredoc half of its own problem: the check still read the raw record without asking what earlier lines had left open, so a physical line spelling `# portability-scope:` inside a multiline quoted value or substitution granted whole-file scope and suppressed every hit in the file. The marker now counts only on a line that also *opens* its own record — the one context where a leading `#` starts a comment rather than being data. A genuine declaration is unaffected, and the regression cases pin both directions, since the cheap fix here is one that quietly breaks the declaration it exists to protect. ## Token-file premise correction (rode along) The `mktemp -p` rationale comment asserted BSD/macOS mktemp "has no `-p`". It does — FreeBSD 14.2 and Apple both document `-p tmpdir, --tmpdir[=tmpdir]`. The real hazard is **precedence, and it diverges silently**: GNU treats `-p` as authoritative and overrides `TMPDIR`, while BSD/macOS consults it only as a fallback when `TMPDIR` is unset, so the same command writes to different directories per platform with no error either way. The gate's *behavior* was already correct; only its stated reason was wrong. Carried here because this PR owns the token file. The plugin CHANGELOG entries that quoted the old sentence are historical and left alone. ## Test plan - [x] `bash scripts/check-shell-portability.test.sh` — **215/215 passing**, including new regression cases for every shape above (arithmetic-expansion frames, sibling-substitution status ownership, quote-spliced command words on both rungs of a ladder, quoted words spanning lines, per-physical-line attribution and annotation scoping, heredoc-body isolation, and the joined-line comment opener). - [x] `scripts/check-shell-portability.sh origin/main` (this PR's own diff, 15 shell files in scope) — clean. - [x] `scripts/check-shell-portability.sh --all` — **19 hits, the same hits `origin/main`'s own scanner reports over the same tree**, all from unrelated already-active regex-escape classes and none from the two newly-active ones. Every scanner change above was held to that comparison, so no fix introduced a false positive anywhere in the corpus. One hit is attributed to a different line than main reports it: this PR introduces logical-line joining, so a backslash-continued record is now reported at its first physical line, as the script header specifies. That joining is also what makes a `date` whose `-d` sits on the next continued line reportable at all — main reads that shape clean. - [x] Full test suites for every touched script pass: `morning-brief.test.sh`, `claude-observability.test.sh`, `context-zone.test.sh`, `statusline-tee.test.sh`, `lease.test.sh`, `check-skill.test.sh`. - [x] `shellcheck --rcfile=.shellcheckrc` on every changed `.sh` file — clean. - [x] `scripts/validate-plugins.sh` — all manifests + catalog validate. - [x] `scripts/check-changelog-parity.sh --check-bump origin/main` — every version-bumped plugin has a matching CHANGELOG entry. ## Related - Closes #1510. - #1491 — original shell-portability-lint gate. - #1543 — activated `mktemp -p`, the issue's third class, independently of this PR. - #1528 — the deferred `mktemp -p` migration; closed. - #1562 — `--` end-of-options handling, which shares the word-level tokenization the command-position over-flag documented above would also need. - Rebased onto #1519 / #1534 / #1530, which merged mid-session and changed the same `check-shell-portability.sh` / `shell-portability-tokens.txt` files. Merged with `origin/main` again after #1603 / #1751 / #1752 landed; `context-zone.test.sh` takes main's side whole, since main replaced the unsuffixed `sed -i` this branch had annotated with a genuinely portable form. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
No linked issue
Summary
The
pr-issue-linkage / pr-issue-linkagecheck is a required merge gate, but nothing enforcedits contract at the moment a PR body was written. A body missing a closing keyword or a
## Relatedsection was therefore only ever caught post-hoc — one CI round trip after the PR wasalready open — which is what happened on most PRs filed directly with
gh pr createduring the2026-07-29 queue drain.
This adds the missing authoring-time enforcement: a
PreToolUsehook on the Bash tool, owned by thesource-controlplugin, that validates agh pr create/gh pr editbody against the samecontract before the call runs and blocks with the missing half named, so the authoring agent
self-corrects in the same turn instead of on the next CI cycle.
/source-control:pull-request createhas always run the equivalent pre-create gate(
skills/pull-request/reference/create.md§2.4.2). This hook covers the calls that never go throughthe skill; the skill's own path is unaffected, since its gate runs first and the hook then sees a
body that already passes.
Enforcement is keyed to the consumer's own policy
The gate runs only when the repository root carries
.github/workflows/pr-issue-linkage.yml(or.yaml). A repository that does not run the check is never gated, so the hook cannot drift awayfrom what its consumer actually enforces.
This is deliberately not the
pr_body_required_sectionsseam(
docs/conventions/pr-body-convention/). That key is the repo's configurable section scaffold, andits portable default excludes
Relatedon purpose; the authority for this gate is the workflowfile that defines the check.
The validator is mirrored, not approximated
Ported from the reusable
melodic-software/ci-workflows/.github/workflows/pr-issue-linkage.ymlgithub-scriptstep, including the three places a hand port silently diverges:comment opener swallowing the rest of the body. Without this an unedited PR template, whose
instructional prose names the very markers the gate looks for, passes vacuously.
## Related, so anested
### ...subsection is that section's content. A naive "next line starting with#"reading calls such a section empty and false-blocks a compliant body.
non-word characters around a newline-wrapped probe — so
Closes #12abcandunclosed #5staynon-matches exactly as they are in CI.
Fail-open on extraction, fail-closed on a determinable bad body
Judged: a
--body/-bliteral, a readable--body-file/-Fpath, and the sole heredoc feeding--body-file -or a--body "$(cat <<EOF ... EOF)"substitution.Allowed: an unexpanded variable, several heredocs (which one reaches
ghis not staticallyknowable), an unterminated heredoc, an unreadable body file, an absent body flag (
--fill,--template,--editor, the interactive prompt), and any--repo-targeted invocation, whosetarget may not be the repository whose workflow file the scope guard read. Guessing at a body the
hook cannot see would block compliant calls, which costs more than a miss.
The PowerShell tool and direct
gh api .../pullscalls are documented as out of scope at the hook'sown site, alongside the
--repolimit.Test plan
plugins/source-control/hooks/pr-body-linkage-gate.test.sh— 53 black-box cases, all passing:the scope guard, both halves independently, all nine closing keywords plus the colon and
owner/repo#Nforms, both no-issue markers, the two word-boundary non-matches, threecomment-stripping cases, four section-boundary cases (including the deeper-subsection case),
every body source and every undeterminable-body path,
gh pr edit, env/env(1)/sh -cwrappers,
--repo, and the kill switch.shellcheck(with.shellcheckrc),shfmt,check-silent-skips,check-hook-userconfig-argv,check-shell-portability(vsorigin/main),check-cross-plugin-source-drift,sync-hook-utils --check,check-changelog-parity(
--checkand--check-bump),check-plugin-manifest-presence,validate-plugin-contracts,validate-plugins, andmarkdownlint-cli2on every changed markdown file.gh pr createfired — and thefirst draft was blocked, correctly. That draft spelled the comment delimiters out literally
while describing the comment-stripping rule, so the strip ate everything after them,
## Relatedincluded. CI would have rejected it identically. The hook caught it before the PR existed, which
is the whole point.
Related
pr-issue-linkagepost-hoc during the2026-07-29 queue drain, which is the recurring failure this hook removes at the source.
docs/conventions/pr-body-convention/README.mdreserves the enforcement seam for thepr_body_required_sectionskey; this hook deliberately does not consume that key, for the reasongiven under "Enforcement is keyed to the consumer's own policy" above.
plugins/guardrails/hooks/block-convention-violation.shgates thegh pr createtitleagainst the tracked team convention. Different field, different source of truth; the two hooks
compose rather than overlap.