Skip to content

Harden the commit guard, test the hooks, and gate on CI - #1

Merged
roadhero merged 10 commits into
mainfrom
fix/guard-commit-hardening
Sep 6, 2026
Merged

roadhero merged 10 commits into
mainfrom
fix/guard-commit-hardening

Conversation

@roadhero

@roadhero roadhero commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Summary

The commit guard had a real hole and no tests. A chained git commit -m ok && git push -f origin x was allowed because the force-push check was skipped whenever a commit appeared anywhere in the same Bash call. This PR fixes that, adds the behavioral test suite that should have caught it, wires the quality gate into CI, and folds in five smaller improvements to the reviewer agents and the PR workflow docs. Everything stays bash 3.2 + jq + git; no new dependencies.

  • guard-commit.sh judges each command segment on its own. Quoted text is stripped (multi-line aware, so heredoc commit messages that mention a flag are still allowed), the command is split on ;, &, | and newlines, and each segment is classified as commit / push / add independently.
  • More force-push forms are caught: -f inside a flag cluster (-uf), --mirror, +refspec. Bare --force-if-includes is also caught; it is a no-op without --force-with-lease, so nothing legitimate is lost.
  • Hook-skipping is now a hook rule, not just prose: --no-verify, git commit -n (alone or in a cluster like -anm), core.hooksPath overrides via -c, git config, or GIT_CONFIG_PARAMETERS. Section 14 already forbade this; the guard now enforces it.
  • Shell wrappers are refused, not guessed at. sh -c "…", bash -c "…", and eval "…" around a git commit/push exit 2 with a reason; the unwrapped form is always available.
  • Fail closed on an unparseable payload that mentions git commit/push, matching the existing fail-closed behavior when jq is missing.
  • Secrets scan covers the common idiom git add … && git commit in one call (working tree plus untracked files, at hook time nothing is staged yet), reads only added lines so scrubbing a leaked key stays committable, and runs git diff --no-ext-diff with core.fsmonitor=false so repo-local config cannot make the guard execute a program.
  • tests/hooks/: 73 guard cases and 4 format-hook cases, one per rule and per past regression, plus a self-check that every defined case was counted. Fake credentials are built at runtime so the suite never contains a secret-shaped literal.
  • CI: .github/workflows/gate.yml runs the §19.3 gate on every PR and on push to main, read-only token, checkout action pinned to a commit.
  • Reviewer agents: code-reviewer takes the Phase 1 plan as an input and traces plan → diff as well as diff → task, reporting plan coverage. The blocking "test that doesn't test what it claims" gets a criterion (presence-only or was-called-only assertions; assertions loosened in the diff), and qa pushes back on the same. security-reviewer and code-reviewer state that the acting principal comes from the verified session or token, never from the request body, a tool-call argument, or model output, and that tool arguments and model output are taint sources.
  • PR workflow docs: a "what happens after the PR is open" section (wait for CI, reply to every review comment with a pushed SHA, re-check after every push, done means green and replied, merging is the user's call) plus a PR-template checkbox.
  • Reconciliation: §19.2/§19.3/§19.6 now describe the tests, CI, and the copies-in-sync diff instead of "nothing to unit-test" and "no CI yet". README and STRUCTURE list --no-verify among what the hook blocks and state the hook boundary rule. The two scaffolder CLAUDE.md.tmpl copies had drifted from templates/CLAUDE.project.md (the format hook matches *.md, not *.tmpl); they are byte-identical again and the gate diffs them. docs/reconciliation.md gains a cited-paths-resolve and duplicates-in-sync step.

Deferred / out of scope

  • Variable indirection (p=push; git $p -f), aliases defined in an earlier call, flags fed through xargs, and quoting tricks like --no-veri"fy" are documented as accepted limits in the hook header. The guard is a backstop behind the permission deny rules and plan mode, not a sandbox.
  • No change to the always-loaded spine beyond three pointer sentences (§4 item 3, §9, §19).
  • No release tag in this PR.

Test plan

  • Local quality gate green: shellcheck hooks/*.sh tests/hooks/*.sh, jq empty settings.json settings2.json, agent-name grep, both diff -q template checks, bash tests/hooks/test-guard-commit.sh (73 passed), bash tests/hooks/test-format.sh (4 passed)
  • Both suites run under /bin/bash 3.2.57 on macOS; no bash-4-only constructs
  • Adversarial probes: -uf, -fu, +refspec, continuation lines, heredoc messages, escaped quotes, sh -c, eval, non-JSON payload, missing jq, repo-local diff.external (verified not executed), 200 KB command (under 1 s)
  • prettier --check clean on every changed markdown file
  • CI run on this PR green (first run of the new workflow)

…fy in guard-commit

A chained 'git commit -m ok && git push -f' was allowed: the force-push check was skipped whenever a commit appeared anywhere in the same Bash call. Quoted text is now stripped and the command is split on ; & | so every segment is classified and checked independently. Also blocked: --no-verify on commit/push, the -n short form, core.hooksPath overrides, and +refspec force-pushes. Adds tests/hooks/ with one stdin-payload case per rule and per past regression (39 guard cases, 4 format cases), all bash 3.2 + jq.
shellcheck, settings JSON, agent name invariant, scaffolder-template diff, and both hook test suites. Same commands as the local gate.
…tion blocker, name untrusted principal sources

code-reviewer now takes the Phase 1 plan as an input and traces plan → diff as well as diff → task, reporting plan coverage. The 🔴 'test that doesn't test what it claims' gets a criterion: presence-only or was-called-only assertions, tests that only exercise the fake, and assertions loosened in the diff; qa pushes back on the same. security-reviewer and code-reviewer state that the acting principal comes from the verified session or token, never from the request body, a tool-call argument, or model output, and that tool arguments and model output are taint sources.
Wait for CI and classify red per §4.7; reply to every top-level review comment with a pushed SHA, a deferred issue, or a reasoned disagreement; re-check after every push; done means green and replied, never merged.
…th the tested gate

§19.2/§19.3/§19.6 describe the hook tests, CI, and the copies-in-sync diff instead of 'nothing to unit-test' and 'no CI yet'. §4 and §9 pointers cover the plan reverse-trace and the PR-open-to-merge loop. README and STRUCTURE list --no-verify among what the hook blocks, add tests/hooks and the CI job, and state the hook boundary rule. The two scaffolder CLAUDE.md.tmpl files had drifted from templates/CLAUDE.project.md (the format hook matches *.md, not *.tmpl); they are byte-identical again and the gate diffs them. Reconciliation gains a cited-paths-resolve and duplicates-in-sync step.
Force-push: -f inside a flag cluster (-uf, -fu) and --mirror are now caught. Quote stripping is multi-line aware and drops escaped quotes first, so heredoc and multi-line commit messages that mention a flag are allowed again, and an escaped quote cannot flip parity. Backslash-newline continuations are joined before classification. Shell wrappers (sh -c, bash -c, eval) around a git commit/push are refused rather than guessed at. core.hooksPath and GIT_CONFIG_PARAMETERS are checked across the whole call. An unparseable payload that mentions git commit/push now fails closed. The secrets scan covers git add && git commit in one call (working tree plus untracked files), reads only added lines so scrubbing a leak stays committable, and runs git diff with --no-ext-diff and core.fsmonitor=false so repo-local config cannot execute a program. Tests: committer cases no longer run in a subshell, the suite asserts every defined case was counted, the private-key header is built at runtime, and 34 cases were added (73 total).
@roadhero
roadhero merged commit 7d4c4d3 into main Sep 6, 2026
1 check passed
@roadhero
roadhero deleted the fix/guard-commit-hardening branch September 6, 2026 00:54
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.

1 participant