Skip to content

fix(hooks): read the PreToolUse payload, add three forge gates - #145

Merged
CybotTM merged 5 commits into
mainfrom
fix/hook-payload-and-forge-gates
Aug 6, 2026
Merged

fix(hooks): read the PreToolUse payload, add three forge gates#145
CybotTM merged 5 commits into
mainfrom
fix/hook-payload-and-forge-gates

Conversation

@CybotTM

@CybotTM CybotTM commented Aug 6, 2026

Copy link
Copy Markdown
Member

scripts/validate_git_command.py has never fired for anyone who installs this skill. It reads a top-level "command" key, but the PreToolUse payload is {"tool_name": ..., "tool_input": {"command": ...}}. That JSON parses cleanly, so the JSONDecodeError fallback does not rescue it either — command comes out empty and the function returns before any check runs.

$ printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git commit -m \"stuff\""}}' | python3 scripts/validate_git_command.py
                                     # (nothing)
$ printf '%s' '{"command":"git commit -m \"stuff\""}' | python3 scripts/validate_git_command.py
<system-reminder> … Commit message doesn't follow Conventional Commits format …

With read_command() accepting both shapes the existing advisory checks work as intended. That fix is the reason for the rest of the PR: three gates that belong to this skill had been living in a machine-local hook, invisible to anyone installing git-workflow. One of them recommends this repo's own scripts/pr-status.sh, so the skill was shipping the tool without the gate that points at it.

What the three gates catch

Gate Failure it prevents
Hard-wrapped forge body (3+ prose lines in the 55–85 column band) Breaks read ragged in the web UI, break on mobile, and survive verbatim in release notes where markdown does not reflow
repos/O/R/pulls/comments/{id}/replies without the PR number GitHub answers 404 and posts nothing — the reply looks sent
Sleep-loop over gh pr view/checks/status Waits for the one outcome it was told about and sleeps through every other actionable event, including reviews

They deny rather than warn: each describes an action that silently does the wrong thing, as opposed to one that merely reads badly. They run before the advisory checks, since a denied command never executes and warning about its commit-message style would be noise.

Deliberate limits. The reply gate only matches the /replies subresource and only in a segment that actually invokes gh/curlpulls/comments/{id} is a legitimate read endpoint, and matching the path anywhere would block writing about it in an echo. The poll gate exempts --watch. The hard-wrap counter skips tables, lists, quotes, headings and fenced code, and ignores a lone short line, so a real one-line paragraph does not trip it.

A fourth gate stays machine-local on purpose: it resolves commit SHAs against the forge to catch hashes written from memory, which needs a network call and would not fit the 2 s hook timeout in hooks/hooks.json.

Verification

payload now read (advisory)     → <system-reminder> … Conventional Commits …
reply path without PR number    → deny
hand-rolled PR poll             → deny
pr-status.sh --watch            → passes
hard-wrapped --body (4 lines)   → deny
single-line --body              → passes
ls -la                          → passes

Ruff format and ruff check pass via pre-commit.

validate_git_command.py never fired. It read a top-level "command" key,
but Claude Code sends {"tool_name": ..., "tool_input": {"command": ...}}.
That JSON parses fine, so the JSONDecodeError fallback did not trigger
either -- the function got an empty string and returned silently on
every invocation. Everyone who installs this skill has been running a
hook that does nothing.

With the payload actually read, three gates move in from a machine-local
hook where they did not belong. All three are git-workflow concerns and
one of them recommends this skill's own pr-status.sh, which the skill
was not shipping the gate for:

  - A forge body carrying 3+ hard-wrapped prose lines. The breaks read
    ragged in the web UI and survive verbatim in release notes.
  - A reply to repos/O/R/pulls/comments/{id}/replies without the PR
    number. GitHub answers 404 and posts nothing, silently.
  - A sleep-loop over gh pr view/checks/status. It waits for the one
    outcome it was told about and sleeps through every other actionable
    event.

These deny rather than warn, because each describes an action that
silently does the wrong thing rather than one that merely reads badly.
They run before the advisory checks -- a denied command never executes,
so warning about its commit-message style would be noise.

A fourth gate stays machine-local: it resolves commit SHAs against the
forge to catch hashes written from memory, which needs a network call
and would not fit the 2s hook timeout.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
Copilot AI lite review requested due to automatic review settings August 6, 2026 08:48
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

CybotTM added 3 commits August 6, 2026 11:39
Three ruff findings, all carried over verbatim from the machine-local
hook the gates came from: re.I and re.S (FURB167) and a doubled
startswith call (PIE810).

Local pre-commit passed because CI pins ruff 0.16.0 while the hook run
resolves its own. Verified here against the pinned version -- ruff check
and ruff format --check both clean -- and the six gate probes still
behave: reply-path deny, PR-poll deny, --watch through, hard-wrapped
body deny, commit-message reminder, plain command through.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
Found while reviewing this PR by hand (Copilot could not review it -- the
requesting account is over its quota).

The hard-wrap gate opened whatever path `--body-file` named. That path is
routinely a pipe: `gh pr create --body-file <(generate-body)` hands over
/dev/fd/N, and opening it here waits for a writer this process cannot
see. Measured before the fix: the hook did not return within 4 seconds
against a fifo. A hook that hangs is worse than one that misses a
finding, so a non-regular path is now skipped and a regular one is read
up to 256 KiB.

The script also had no tests at all, which is a poor pairing with gates
that deny. tests/test_validate_git_command.py covers twelve cases: the
nested-payload bug that made every check unreachable, each gate firing,
and -- more useful -- each near-miss that must NOT fire: reading a single
comment (same path prefix, no /replies), the reply path quoted inside an
echo rather than invoked, a lone `gh pr view` that is not a poll,
pr-status.sh --watch, and a single-line body. The fifo case asserts the
hook returns at all.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
`env FOO=1 gh api …/replies` and `sudo gh api …/replies` both passed the
gate. INVOKES_FORGE_API anchored on gh/curl at the start of the segment,
so anything in front of them broke the match.

The anchor itself has to stay -- matching the reply path anywhere would
block writing ABOUT it in an echo or a commit message, which is how the
pattern ended up anchored in the first place. So the anchor now skips
over leading VAR=value assignments and the usual wrapper words
(sudo, env, time, command, nohup, xargs) before requiring gh api / curl.
Quoted prose is unaffected.

Four cases added, three of them prefixes that were measured slipping
through, plus one for a call in the second segment of a && chain.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
@CybotTM

CybotTM commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Review by hand — Copilot could not run

copilot-pull-request-reviewer[bot] answered: "unable to review this pull request because the user who requested the review has reached their quota limit." The standing rule is that a missing bot review gets done by hand, so this is a review of my own change, written to find fault with it. Two findings were serious enough to fix in this PR rather than file; the rest are stated as limitations.

Fixed while reviewing

--body-file on a pipe hung the hook. The hard-wrap gate opened whatever path the flag named. gh pr create --body-file <(generate-body) is ordinary usage and hands over /dev/fd/N; opening it here waits for a writer this process cannot see. Measured before the fix: no return within 4 s against a fifo. A hook that hangs is worse than one that misses a finding — hooks.json allows it 2 s and every Bash call goes through it. Now non-regular paths are skipped and regular ones read to a 256 KiB cap. a0bff80

A command prefix bypassed the reply gate. env FOO=1 gh api …/replies and sudo gh api …/replies both passed, because the pattern anchored on gh/curl at the very start of the segment. Measured, both. The anchor cannot simply go — matching the reply path anywhere would block writing about it in an echo, which is why it was anchored originally — so it now skips leading VAR=value assignments and the usual wrappers first. f02808c

No tests existed for this script at all, which pairs badly with gates that deny. Sixteen cases now, and the useful half is the near-misses that must not fire: reading a single comment (same path prefix, no /replies), the reply path quoted inside an echo, a lone gh pr view that is not a poll, pr-status.sh --watch, a one-line body.

Limitations I am not fixing here

read_command keeps the old top-level "command" fallback. For Claude Code payloads it is dead code. I kept it so the script stays callable directly, and because removing it would silently change behaviour for any other caller — but it is unexercised in practice and worth deleting once nothing else feeds this script.

The 55–85 column band is a heuristic. A paragraph whose lines genuinely fall in that range, three times consecutively, is denied. The >= 3 threshold and the skipping of tables/lists/quotes/code keep it quiet in practice, but this is pattern-matching on prose and it will be wrong sometimes. The remedy for a false positive is obvious to the operator (unwrap it), which is why I consider the trade acceptable.

isValid-style precision is missing on the poll gate. POLLS_PR matches pr-status.sh itself, so a loop around pr-status.sh without --watch is denied. That is intended, but it means the gate assumes the script's own --watch is the only sanctioned wait — a fair assumption inside this skill, less so if someone wraps it deliberately.

These three gates now exist in two places. They still run in the machine-local hook they came from. Until that copy is stripped, a matching command gets two denies; the first wins, so the effect is cosmetic. I am deliberately not removing them before this merges — a window with no gate is the worse failure, and this repo has just demonstrated how long a silently-inactive hook can go unnoticed.

What I checked and found sound

  • The nested-payload fix is the reason the rest is worth anything: every advisory check in this file has been unreachable since it was written. The first test case pins exactly that.
  • Deny runs before the advisory warnings. A denied command never executes, so commenting on its commit-message style would be noise.
  • reply_path_without_pr splits on &&, ||, ;, |, & and newline, so a call in a later segment is still seen — covered by a case.
  • The ported hard_wrapped counter is unchanged from the version that has been in service since 2026-08-02; I did not take the opportunity to "improve" it.
  • ruff@0.16.0 check and format --check clean against the pinned CI version, after the first push failed on FURB167/PIE810 that the local pre-commit did not raise.

Evidence status

Every deny and pass above is a test case in tests/test_validate_git_command.py, 16 of 16 green. The two fixed defects were measured failing first — the fifo probe timed out at 4 s, and both prefix forms returned no deny — so the tests are known to sit in the position where the defect shows.

The new test file carries a shebang but went in as 100644, which ruff
0.16.0 flags as EXE001 in its default rule set.

It cannot be reproduced on this machine: DrvFs mounts report every file
as executable to stat(), so `ruff check` passes locally regardless of the
git mode. Only CI on native ext4 is authoritative here, and `chmod +x`
plus `git add` does not reliably carry the bit either -- hence
`git update-index --cacheinfo 100755`.

Verified in the tree rather than the working directory:
`git ls-files -s` reports 100755.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
@sonarqubecloud

sonarqubecloud Bot commented Aug 6, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@CybotTM
CybotTM merged commit 0323803 into main Aug 6, 2026
21 of 22 checks passed
@CybotTM
CybotTM deleted the fix/hook-payload-and-forge-gates branch August 6, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants