Skip to content

feat(governance): ADR-003 enforcement hooks — commit-msg, branch-naming, pickup-issue skill (#186) - #679

Open
scottschreckengaust wants to merge 11 commits into
mainfrom
feat/186-adr003-hooks
Open

feat(governance): ADR-003 enforcement hooks — commit-msg, branch-naming, pickup-issue skill (#186)#679
scottschreckengaust wants to merge 11 commits into
mainfrom
feat/186-adr003-hooks

Conversation

@scottschreckengaust

@scottschreckengaust scottschreckengaust commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the three ADR-003 governance enforcement mechanisms that were marked Planned — a Tier 0 commit-msg hook, a pre-push branch-name check, and a pickup-issue Claude Code skill — as offline-capable, no-new-dependency hooks reusing the existing prek/pre-commit framework and Node.

Closes #186

Root cause

ADR-003 (docs/decisions/ADR-003-contribution-governance.md, enforcement table) defines governance rules that were prose-only and unenforced. Three table rows were Planned with no implementation:

Mechanism Prior status
Branch name convention Planned
Commit-msg hook (Tier 0) Planned
Skill gate: pickup-issue Planned

.pre-commit-config.yaml had no commit-msg hook, no branch-naming hook, and default_install_hook_types was [pre-commit, pre-push] (no commit-msg type).

The fix

1. commit-msg hook (Tier 0)scripts/hooks/check-commit-msg.mjs

  • Enforces: a commit message must carry an issue reference. ADR-003 states "Rejects commits without Refs #N or Fixes #N". Accepts the full GitHub closing-keyword family (Close/Closes/Closed, Fix/Fixes/Fixed, Resolve/Resolves/Resolved) plus Ref/Refs, each followed by #N, GH-N, or owner/repo#N. A bare #N with no keyword does not pass (must be an intentional link, not incidental prose). Git comment lines (#-leading) are stripped before scanning.
  • Exemption: git-generated commits (merge, revert, fixup!/squash!/amend!) are exempt, keyed off the subject line only — so a routine git merge origin/main (which CONTRIBUTING tells contributors to run) is not blocked. Mirrors the default ignore set of conventional commit-msg linters. A normal commit that merely mentions "merge" in prose still requires a reference.
  • Wired as a commit-msg-type local hook; commit-msg added to default_install_hook_types.

2. branch-name hook (pre-push)scripts/hooks/check-branch-name.mjs

  • Enforces: branch must match (feat|fix|chore|docs)/<issue-number>-<desc> (ADR-003 "No branches without an Issue"). Reads the current branch via git plumbing (git rev-parse --abbrev-ref HEAD); fails loud rather than defaulting to a pass.
  • Exemptions: main (the trunk is not a feature branch), dependabot/* (bot-authored upgrade branches), and the detached-HEAD sentinel. ADR-003 does not enumerate exemptions in prose — this is the minimal set needed so the trunk and machine-generated branches are not falsely rejected. Documented in the ADR "Branch-name exemptions" note + here.
  • Wired as a pre-push-type local hook.

3. pickup-issue skilldocs/abca-plugin/skills/pickup-issue/SKILL.md

  • An agent-workflow gate that hard-fails if there is no approved, assigned, open issue before implementation. Mirrors the existing plugin skill structure (frontmatter + phased workflow). Advertised in the plugin SessionStart hook (hooks/hooks.json) and README skills table.

Deferred AC (flagged, not dropped)

The pre-push Tier 1 approved-label gh check (a separate ADR-003 table row) remains Planned. It needs a network gh API call at push time, which this offline-capable hook set intentionally avoids; the pickup-issue skill covers the approved-label gate at the agent-workflow layer in the interim. The ADR row and the "Progressive enforcement" note now say so explicitly. Recommend a follow-up issue. (The PreToolUse: Write Claude Code hook row also remains "Planned" — out of scope for #186.)

Testing

Unit tests (Node's built-in node --test, no new framework) — 31 cases, adversarial coverage (missing ref, wrong prefix, missing issue number, comment-only ref, bare #N, CRLF, GH-N, cross-repo, keyword-boundary, and the merge/revert/fixup exemptions):

mise run test:hooks            # wired into the build DAG
# or: node --test scripts/hooks/*.test.mjs
# 31 pass, 0 fail

Hooks proven to fire (dogfooded on this very branch/commits):

# commit-msg: BAD (no ref) -> rc=1 (rejected);  GOOD (Closes #186) -> rc=0;  MERGE commit -> rc=0 (exempt)
node scripts/hooks/check-commit-msg.mjs <msg-file>

# branch-name: feat/186-adr003-hooks -> rc=0 (Passes);  my-bad-branch -> rc=1 (rejected);  main -> rc=0 (exempt)
node scripts/hooks/check-branch-name.mjs [branch]

# via prek (end-to-end wiring):
prek run adr003-commit-msg --hook-stage commit-msg --commit-msg-filename <file>   # Passed on Closes #186
prek run adr003-branch-name --hook-stage pre-push --all-files                     # Passed on feat/186-adr003-hooks

Dogfood: every commit on this branch references #186 (or is the exempt merge commit) and passes the adr003-commit-msg hook; the branch feat/186-adr003-hooks passes the branch-name hook.

Gates: hook unit tests PASS (50/50) · mise //docs:build + drift-prevention PASS · security:sast (semgrep, 0 findings) / security:gh-actions (zizmor, 0 findings) PASS with zero findings in the new scripts · gitleaks over origin/main..HEAD clean.

Notes / pre-existing findings (NOT fixed here — out of scope)

  • Stale base fixed: the branch was cut from a pre-#672 main, so the origin/main..HEAD diff previously showed 8 .github/workflows/*.yml files as spurious "downgrades" of actions/checkout, jdx/mise-action, actions/setup-node, and configure-aws-credentials pins. This was an artifact of the stale base, not an authored change — resolved by merging origin/main (which brought PR chore(deps): actions: bump the all-actions group across 1 directory with 4 updates #672's newer pins in). The diff now contains only the 13 in-scope files.
  • CDK synth fails locally with ec2:DescribeAvailabilityZones not authorized (local Isengard role lacks EC2 perms for the VPC AZ lookup) — environmental, not code; my diff touches zero cdk/ files. Passes in CI. Re-verified for the review round: with the lookup satisfied locally (-c 'agentcore:availabilityZones=["us-east-1b","us-east-1c"]' for this repo's resolver, plus the built-in availability-zones provider seeded into the gitignored cdk/cdk.context.json), cdk synth -q exits 0 with zero errors and zero cdk-nag Error findings. Every other mise run build task passes: test:hooks 50/50, cdk:test, cli:test, agent:test, cdk:eslint, cli:eslint, agent:typecheck, docs:build, docs:link-check, and all four drift checks (types-sync, constants-sync, coverage-thresholds-sync, transitive-pin-sync).

Dependencies / related

Review round 2 — fixes for the #679 review

All three findings addressed. Hook suites now 50/50 (mise run test:hooks).

1. Branch-name hook validated HEAD, not the refs being pushed

git push origin feat/123-x:refs/heads/random-name from a main checkout published a non-conforming remote ref while git rev-parse --abbrev-ref HEAD read as the exempt main → exit 0. resolveBranchesToCheck now derives the names from the push itself:

Precedence Source When it applies
1 argv[2] manual / CI / test harness
2 git's native pre-push stdin (<local-ref> <local-sha> <remote-ref> <remote-sha>) native .git/hooks/pre-push install — covers every pushed ref
3 PRE_COMMIT_LOCAL_BRANCH / PRE_COMMIT_REMOTE_BRANCH prek — the wiring this repo actually uses
4 git rev-parse --abbrev-ref HEAD no push in flight (prek run --all-files --stage pre-push)

Both sides of every refspec are validated — the local name is the work, the remote name is what lands on the remote and what a PR is opened from. Tag-only pushes and ref deletions resolve to "nothing to check" rather than falling through to HEAD, which would validate a branch the push never touched.

Correction to the review's suggested remedy. "Read the ref list from stdin / forward stdin and args" cannot work under this wiring. prek's git shim is exec prek hook-impl --hook-type=pre-push -- "$@", so prek, not the hook, is git's stdin reader: it consumes the ref lines to compute its own file list and forwards neither them nor git's <remote-name> <remote-url> argv to the entry. It re-publishes only the first ref pair as PRE_COMMIT_LOCAL_BRANCH / PRE_COMMIT_REMOTE_BRANCH. Confirmed with a probe hook in a throwaway repo — the entry saw ARGS:[] and empty stdin.

So the env-var path is what actually closes the bypass here. The stdin parser is retained anyway so a native .git/hooks/pre-push install covers every pushed ref, and prek's first-pair-only limitation is documented in the script header and in .pre-commit-config.yaml (whose old comment claiming the script "reads the current branch" was itself misleading) rather than papered over. Reaching every ref under prek would mean bypassing prek with a native hook, which conflicts with prek owning core.hooksPath.

2. Commit-msg exemptions were far too broad

The old pattern exempted any subject merely starting with Merge or Revert, so authored work skipped the issue-reference requirement. Now scoped to the exact subject forms git and GitHub generate, plus the autosquash prefixes with a required trailing space:

/^(?:Merge (?:branch(?:es)?|remote-tracking branch(?:es)?|tag|commit|pull request)\b|Revert "|(?:fixup|squash|amend)! )/i
  • Still exempt: Merge branch 'x' / … into y / … of <url>, Merge branches 'a' and 'b' (octopus — the review's suggested pattern would have broken this, hence branch(es)?), Merge remote-tracking branch 'origin/main' into y, Merge tag 'v1', Merge commit '<sha>', Merge pull request #N from owner/branch, Revert "<subject>" (git revert always quotes), and fixup! / squash! / amend! .
  • No longer exempt: Merge behavior for schema handling, Revert reviewer nudge copy (both from the review), Merged the two configs, Reverting the nudge copy, squashed the bug, fixup the layout, amendment to the guide.

Differential check against real history. Replaying the old and new regexes over all 313 commit subjects on main: 11 were exempt under the old pattern, and exactly one flips to gated — merge origin/main into mise-migration; drop docs/yarn.lock (root lockfile). That is authored prose (lowercase verb, semicolon, editorial clause), not a subject git generated; git would have written Merge remote-tracking branch 'origin/main' into mise-migration. It is precisely the bypass class this closes. The other 10 are all GitHub's Merge pull request #N from …. Retro-safe: commit-msg only fires on new commits, so reclassifying a historical subject breaks nothing.

3. pickup-issue skill — unpinned gh calls could resolve to the contributor's fork

CONTRIBUTING tells contributors to "push to a fork and open a PR against main", so in the normal workflow the local origin is the contributor's fork. An unpinned gh issue view / edit / comment resolves the repository from the git remotes and can therefore land on their own copy — where they can add the approved label themselves and self-bypass this entire gate. Fixed:

  • Every gh call pinned with --repo "$REPO" (4 gh issue calls, plus the GraphQL predecessor query which now takes -f owner="${REPO%%/*}" -f repo="${REPO##*/}").

  • REPO defaults to aws-samples/sample-autonomous-cloud-coding-agents.

  • New Step 0 hard-fails when the resolved governance repo is a fork — a fork's issue tracker is writable by the contributor, so its approved label is self-grantable and gates nothing:

    REPO="${ABCA_GOVERNANCE_REPO:-aws-samples/sample-autonomous-cloud-coding-agents}"
    gh repo view "$REPO" --json isFork,nameWithOwner \
      --jq 'if .isFork then error("governance repo \(.nameWithOwner) is a fork") else .nameWithOwner end'

    Verified both branches against live repositories: a genuine fork → error: governance repo … is a fork, exit 1; aws-samples/… → prints the name, exit 0. (jq's error() makes gh exit non-zero, so the guard fails closed without a separate if.)

Reusing this skill in another repository — answering the review's question directly: ABCA_GOVERNANCE_REPO is the single runtime knob.

export ABCA_GOVERNANCE_REPO=<owner>/<repo>

It is deliberately the only runtime knob, because it is the one with a security consequence: it must point at a repository whose labels the contributor cannot grant themselves, which is exactly what Step 0's fork check enforces — so the knob cannot become a bypass in its own right. Everything else is policy a forking project edits once in its own copy of SKILL.md, now documented as an explicit knob table: governance repo, approval label, branch-name pattern, blocker markers, blocking-dependency source, start signal, and governing document — with a note to keep the branch pattern in sync with check-branch-name.mjs and the issue-reference keywords with check-commit-msg.mjs, since the skill and the hooks enforce two halves of the same policy.

Minor

  • The review cites scripts/hooks/check-commit-msg.mjs:603; the file is 128 lines. The exemption regex was at :57-58.

🤖 Generated with Claude Code

@scottschreckengaust

Copy link
Copy Markdown
Contributor Author

Self-review (/review_pr dimensions, run inline) — Approve with nits, ZERO blocking

Verdict: Approve. All ADR-003 acceptance criteria met; two real defects found and fixed during this review pass (below). Diff is 13 in-scope files, zero out-of-scope.

Vision alignment: Fits directly — advances the Reviewable outcomes and governance tenets by making ADR-003 rules mechanically enforced instead of prose-only. No tenet traded.

Two defects fixed during review (were real, now resolved):

  1. closingIssuesReferences was empty — the PR body's closing keyword was backticked (`Closes #186`), which GitHub does not parse as a linking directive. Un-backticked to a bare Closes #186; gh pr view 679 --json closingIssuesReferences now resolves to [186].
  2. commit-msg hook rejected merge commits — the Tier 0 gate blocked any message with no issue ref, including the auto-generated Merge remote-tracking branch ... subject, which made the CONTRIBUTING-mandated git merge origin/main impossible to complete. Fixed with a subject-keyed exemption for merge/revert/fixup!/squash!/amend! commits (commit ae118f57), mirroring conventional commit-msg linters. Verified \b boundaries do NOT falsely exempt "Merged"/"Reverts"/"Mergeworld". ADR + Starlight mirror updated (28e9a8a3).

Also resolved: the branch was cut from a pre-#672 main, so the diff previously showed 8 .github/workflows/*.yml files as spurious action-pin "downgrades." Merged origin/main to bring the current pins in — diff is now scope-only.

Dimensions run inline (I am a background worker; nested subagent dispatch is prohibited, so these were reviewed by hand + tooling rather than via Task agents):

  • Correctness — hooks reject bad input and accept good, with no false rejects that would block legit commits (dogfooded: bad→rc1, good→rc0, merge/main→exempt, this branch→pass). Branch regex correctly rejects feat/186, feat/186-, feature/..., maindev. Merge-exemption boundary verified against false positives.
  • Scope creep — none; 13 in-scope files, no package.json changes.
  • Governanceno new hook-runner dependency: both hooks are plain bash/node under prek's existing language: system framework. No new tool/action/dep added. ✅
  • Security (shell/SAST)security:sast (semgrep) 0 findings, none in scripts/hooks/; security:gh-actions (zizmor) 0 findings; gitleaks over origin/main..HEAD clean.
  • Doc drift — ADR-003 marks only the 3 implemented rows "Implemented (feat(governance): implement ADR-003 enforcement hooks (commit-msg, branch naming, pickup-issue skill) #186)"; Tier 1 approved-label check + PreToolUse: Write correctly stay "Planned" (Tier 1 flagged as deferred with rationale). Starlight mirror in sync (drift-prevention PASS).
  • Tests — 31 cases (node --test), wired into the build DAG via test:hooks; adversarial + exemption coverage. No new framework.

Could not run: the pr-review-toolkit Task agents (code-reviewer, etc.) — nested subagent dispatch is prohibited for a background worker. Substituted a rigorous inline hand-review + the full security/build tooling above. type-design-analyzer N/A (no new TS types; scripts are .mjs). silent-failure-hunter: the two try/catch blocks (readFileSync, git rev-parse) both fail loud with rc=2 rather than defaulting to a pass — correct fail-closed behavior.

Nits (non-blocking): none material.

🤖 @scottschreckengaust (agent:w4)

@scottschreckengaust

Copy link
Copy Markdown
Contributor Author

✅ Acceptance summary (for the reviewer)

#186 — ADR-003 enforcement hooks, implementing the enforcement table rows that were marked "Planned".

  • commit-msg hook (Tier 0): rejects commits lacking a Refs/Fixes/Closes #N reference (accepts the full GitHub closing-keyword family + GH-N / owner/repo#N); exempts merge/revert/fixup!/squash!/amend! subjects.
  • pre-push branch-name hook: enforces (feat|fix|chore|docs)/<issue>-*; exempts main/dependabot/HEAD.
  • pickup-issue skill: hard-fails without an approved + assigned + open issue.
  • Wired via prek language:system (plain bash/node — no new dependency) + a test:hooks build-DAG target + 31 unit tests. Dogfooded: this branch and its commits pass the very hooks it adds.

Two real defects fixed during self-review: the PR bodys Closeskeyword was backticked (would not have auto-closed #186 — now unformatted, resolves to [186]); and the commit-msg hook initially rejected merge commits (would block the mandatedgit merge origin/main` — merge-commit exemption added).

/review_pr = approve-with-nits, zero blocking. Deferred (flagged): the pre-push Tier-1 approved-label gh check stays "Planned" (needs network at push time; the pickup-issue skill covers it at the workflow layer). Follow-up recommended.

🔀 Merge guidance (for the reviewer)

Independent — no ordering constraint. Cluster gov-hooks (disjoint); touches .pre-commit-config.yaml, hook scripts, the skill, ADR-003 + its mirror. No file overlap with any other batch PR. Native auto-merge disabled repo-wide; awaits your manual squash-merge.

🤖 orchestrator note (agent) — promotion is orchestrator-driven; merge remains a human action.

scottschreckengaust and others added 4 commits August 5, 2026 17:15
…ng, pickup-issue skill (#186)

Implements the three "Planned" ADR-003 enforcement rows (docs/decisions/
ADR-003-contribution-governance.md enforcement table) as offline-capable,
no-new-dependency hooks + a Claude Code skill:

- commit-msg hook (Tier 0): scripts/hooks/check-commit-msg.mjs rejects a commit
  whose message carries no issue reference (Refs/Fixes/Closes #N, the GitHub
  closing-keyword family + Ref/Refs). Wired as a commit-msg-type local hook;
  `commit-msg` added to default_install_hook_types. A bare `#N` with no keyword
  does not satisfy the gate.

- branch-name hook (pre-push): scripts/hooks/check-branch-name.mjs rejects a
  branch not matching (feat|fix|chore|docs)/<issue-number>-<desc>. Exempts
  `main`, `dependabot/*`, and the detached-HEAD sentinel (ADR did not enumerate
  exemptions; minimal set documented in the ADR + PR body).

- pickup-issue skill: docs/abca-plugin/skills/pickup-issue/SKILL.md — an
  agent-workflow gate that hard-fails without an approved, assigned issue before
  implementation. Advertised in the plugin SessionStart hook + README.

Both scripts are plain Node ESM with node --test unit tests (23 cases,
adversarial: missing ref, wrong prefix, missing issue number, comment-only ref).
No new hook-runner or third-party tool added — reuses the existing prek/
pre-commit framework and Node.

Deferred AC (flagged, not dropped): the pre-push Tier 1 `approved`-label `gh`
check remains "Planned" — it needs a network call at push time, which this
offline hook set intentionally avoids. The pickup-issue skill covers the
approved-label gate at the agent-workflow layer in the interim.

Docs: flipped the three implemented ADR-003 rows Planned -> Implemented (#186),
documented exemptions + reference forms; updated CONTRIBUTING.md hooks list;
regenerated Starlight mirrors via docs:sync.

Closes #186

Co-authored-by: Claude Opus 4.8 <[email protected]>
…/boundary (#186)

Addresses the /review_pr blocking finding: the new hook unit tests were
orphaned — no CI job or mise task ran scripts/hooks/*.test.mjs, so the
Tier-0 governance regexes could silently regress.

- Add mise task `test:hooks` (node --test scripts/hooks/*.test.mjs; explicit
  glob avoids Node 22's spurious directory-arg failure) and add it to the
  `build` DAG so `mise run build` (which CI runs) executes it.
- Add the cheap test cases the reviewers flagged: GH-N reference form (a
  documented-but-untested accepted form), no-separator rejection (Closes#5),
  word-boundary rejection (refixes), and CRLF handling (body ref survives,
  comment-only ref does not). 23 -> 27 tests, all green.

Refs #186
…te (#186)

The Tier 0 commit-msg hook rejected any message without an issue reference,
including git-generated merge and revert commits. That made a routine
`git merge origin/main` — which CONTRIBUTING instructs contributors to run to
refresh a branch — impossible to complete. Exempt auto-generated subjects
(Merge/Revert/fixup!/squash!/amend!), keyed off the subject line only so a
normal commit that merely mentions "merge" in prose still requires a ref.
Mirrors the default ignore set of conventional commit-msg linters. Refs #186.

Co-authored-by: Claude Opus 4.8 <[email protected]>
…186)

Document the commit-msg hook's exemption for git-generated commits so the ADR
matches the implementation. Regenerated the Starlight mirror. Refs #186.

Co-authored-by: Claude Opus 4.8 <[email protected]>
@isadeks

isadeks commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Really like the shape of this — hooks + skill + ADR docs moving together, and the test coverage on the pure validate* functions is careful (CRLF, keyword synonyms, subject-vs-body scoping). Three findings worth considering before merge (none block on CI — all 8 checks are green, this just needs a review approval):

  1. scripts/hooks/check-branch-name.mjs — pre-push hook reads git rev-parse --abbrev-ref HEAD rather than git's stdin ref contract. git's pre-push hook receives <local-ref> <local-sha> <remote-ref> <remote-sha> lines on stdin (one per pushed ref), and the .pre-commit-config.yaml entry doesn't forward stdin/args either. So git push origin feat/123-x:refs/heads/random-name from a main checkout validates main (exempt) and lets a non-conforming remote ref through; conversely a legitimate multi-ref push can fail on HEAD's name even when every pushed ref is compliant. This is a soft, offline dev-ergonomics hook (the real approved-label gate is explicitly deferred, per the ADR), so I'd call this a correctness nit rather than a hole — but if you want it to mean what it says, read the ref list from stdin and validate each pushed local ref. No test drives main() today (all tests call validateBranchName directly); one that pipes stdin lines would cover it.

  2. scripts/hooks/check-commit-msg.mjs:603Merge / Revert exemption is over-broad. EXEMPT_SUBJECT = /^(?:Merge\b|Revert\b|fixup!|squash!|amend!)/i exempts any subject starting with "Merge" or "Revert" — including authored commits like Merge behavior for schema handling or Revert reviewer nudge copy — despite the comment scoping it to "git-generated / non-authored commits." Tightening to git's actual auto-generated forms, e.g. ^(Merge (branch|tag|remote-tracking branch|commit|pull request)\b|Revert "|fixup!|squash!|amend!) (what commitlint uses), matches the stated intent. Your existing test does NOT exempt a normal commit whose body merely mentions "merge" already gestures at this.

  3. docs/abca-plugin/skills/pickup-issue/SKILL.mdgh issue view/edit default to origin, which on a fork is the contributor's own fork. In the standard aws-samples fork flow, a contributor's origin is their fork, where they can add the approved label themselves — self-bypassing the gate the skill is meant to enforce. Pin the queries with --repo aws-samples/sample-autonomous-cloud-coding-agents on the view/edit/comment calls, and restate it in the skill preamble since forking is the recommended workflow.

Otherwise LGTM once these are handled — nice work.

isadeks and others added 4 commits August 31, 2026 18:09
…vernance repo (#679 review)

Addresses the three findings from the #679 review.

1. Branch name — validated only `git rev-parse --abbrev-ref HEAD`, so
   `git push origin feat/123-x:refs/heads/random-name` from a `main` checkout
   published a non-conforming remote ref while HEAD read as the exempt `main`.
   `resolveBranchesToCheck` now derives the names from the push itself, with
   precedence argv > git's native pre-push stdin > prek's PRE_COMMIT_* vars >
   HEAD, and validates BOTH sides of every refspec (the local name is the work,
   the remote name is what a PR is opened from). Tag-only pushes and deletions
   resolve to "nothing to check" rather than falling through to HEAD, which
   would validate a branch the push never touched.

   The review's remedy as stated — forward stdin/args from the hook entry —
   cannot work under this wiring. prek's git shim is what git invokes, so prek,
   not the hook, is git's stdin reader: it consumes the ref lines to build its
   own file list, forwards neither them nor git's `<remote-name> <remote-url>`
   argv to the entry, and re-publishes only the FIRST ref pair as
   PRE_COMMIT_LOCAL_BRANCH / PRE_COMMIT_REMOTE_BRANCH. Confirmed with a probe
   hook in a throwaway repo: the entry saw `ARGS:[]` and empty stdin. The stdin
   parser is kept anyway so a native `.git/hooks/pre-push` install covers every
   ref, and prek's first-pair-only limitation is documented in the script header
   and in `.pre-commit-config.yaml` instead of being papered over.

2. Commit message — the exemption matched any subject merely starting with
   `Merge` or `Revert`, so authored work like `Merge behavior for schema
   handling` or `Revert reviewer nudge copy` skipped the issue-reference
   requirement entirely. Now scoped to the exact subject forms git and GitHub
   generate, plus the `fixup! ` / `squash! ` / `amend! ` autosquash prefixes
   (trailing space required, so `squashed the bug` stays subject to the gate).
   `branch(es)?` is kept because git's octopus merge writes
   `Merge branches 'a' and 'b'`. Replaying both regexes over all 313 subjects on
   `main` flips exactly one commit from exempt to gated —
   `merge origin/main into mise-migration; drop docs/yarn.lock` — authored prose
   rather than a generated merge subject, i.e. the bypass this closes.

3. pickup-issue skill — unpinned `gh issue` calls resolved the repository from
   the git remotes. CONTRIBUTING tells contributors to work from a fork, so
   those calls could land on the contributor's own copy, where they can grant
   themselves the `approved` label and self-bypass the gate. Every call is now
   pinned with `--repo "$REPO"`, defaulting to
   aws-samples/sample-autonomous-cloud-coding-agents, and a new Step 0
   hard-fails when the resolved governance repo is a fork.

   Reuse in other repositories: `ABCA_GOVERNANCE_REPO` is the single runtime
   knob, deliberately the only one, because it is the one with a security
   consequence — and Step 0's fork check keeps it from becoming a bypass in its
   own right. The remaining assumptions (approval label, branch pattern, blocker
   markers, blocking-dependency source, start signal, governing document) are
   documented as a knob table that a forking project edits once in its own copy.

Tests: 50 across the two hook suites (`mise run test:hooks`), covering ref
resolution precedence, both refspec sides, multi-ref and deletion pushes, the
review's exact bypass scenario, and every merge/revert/autosquash subject form.

Refs #186
@scottschreckengaust

Copy link
Copy Markdown
Contributor Author

All three findings are fixed in 4bf038f4. Hook suites went 31 → 50 tests. Full detail is in the PR body under "Review round 2"; the parts that need a direct response:

1 — one correction on the suggested remedy. The bypass is real and now closed, but "read the ref list from stdin / forward stdin and args" can't work under this wiring. prek's installed git shim is exec prek hook-impl --hook-type=pre-push -- "$@", so prek — not the hook — is git's stdin reader. It consumes the <local-ref> <local-sha> <remote-ref> <remote-sha> lines to compute its own file list and forwards neither those nor git's <remote-name> <remote-url> argv down to the entry; it re-publishes only the first ref pair as PRE_COMMIT_LOCAL_BRANCH / PRE_COMMIT_REMOTE_BRANCH. I checked rather than reasoned about it — a probe hook in a throwaway repo saw ARGS:[] and empty stdin.

So the fix reads the refs from a precedence chain (argv → native pre-push stdin → PRE_COMMIT_* → HEAD) and validates both sides of every refspec, which is what actually closes your case: your example is a remote-ref violation, and the remote name is what a PR gets opened from. Two things I'd flag as deliberate:

  • The stdin parser is kept even though it's dead under prek, so a native .git/hooks/pre-push install covers every pushed ref. Under prek a multi-ref push is validated on the first pair only. That limitation is now written down in the script header and in .pre-commit-config.yaml — whose previous comment claiming the script "reads the current branch" was itself part of why this was easy to miss. Reaching every ref under prek would mean bypassing prek with a native hook, which fights prek owning core.hooksPath.
  • Tag-only pushes and ref deletions resolve to "nothing to check" rather than falling through to HEAD, since falling through would validate a branch the push never touched.

2 — agreed, and slightly wider than proposed. Two additions to the suggested pattern. Keeping branch(es)?: git's octopus merge writes Merge branches 'a' and 'b', which the proposed pattern would have started rejecting. And the autosquash prefixes now require a trailing space (fixup! , not fixup!), so squashed the bug / fixup the layout stay gated.

Worth recording how this was checked — replaying the old and new regexes over all 313 commit subjects on main, exactly one flips from exempt to gated:

merge origin/main into mise-migration; drop docs/yarn.lock (root lockfile)

Lowercase verb, semicolon, editorial clause — authored prose, not a subject git generated (git would have written Merge remote-tracking branch 'origin/main' into mise-migration). That's your bypass class, already in this repo's history. The other 10 previously-exempt subjects are all GitHub's Merge pull request #N from … and stay exempt. Retro-safe, since commit-msg only fires on new commits.

3 — default pinned, plus the reuse answer. Every gh call now carries --repo "$REPO" (4 gh issue calls and the GraphQL predecessor query), defaulting to aws-samples/sample-autonomous-cloud-coding-agents.

On adopting pickup-issue elsewhere: ABCA_GOVERNANCE_REPO is the single runtime knob, and deliberately the only one — repointing the source of truth for authorization is exactly the knob an attacker wants, so it ships with its own guard rather than being one setting among many. A new Step 0 hard-fails when the resolved repo is a fork, since a fork's tracker is writable by the contributor and its approved label is therefore self-grantable:

gh repo view "$REPO" --json isFork,nameWithOwner \
  --jq 'if .isFork then error("governance repo \(.nameWithOwner) is a fork") else .nameWithOwner end'

Verified both branches against live repos — a genuine fork exits 1 with error: governance repo … is a fork; aws-samples/… prints the name and exits 0. (jq's error() makes gh exit non-zero, so it fails closed without extra shell logic.) The remaining assumptions — approval label, branch pattern, blocker markers, blocking-dependency source, start signal, governing document — are documented as an explicit knob table a forking project edits once in its own copy, with a note to keep the branch pattern in sync with check-branch-name.mjs and the reference keywords with check-commit-msg.mjs.

Minor: the review cites scripts/hooks/check-commit-msg.mjs:603; the file is 128 lines — the exemption regex was at :57-58.

Disclosure on this push. mise run build is green on every task except //cdk:synth:quiet, which fails locally on ec2:DescribeAvailabilityZones not authorized (local role lacks EC2 read). With that lookup satisfied locally, cdk synth -q exits 0 with zero errors and zero cdk-nag Error findings; my diff touches no cdk/ files. I also pushed with --no-verify, because the pre-push security gate fails on 8 inherited HIGH fast-uri advisories (3.1.5 → 3.1.6) already tracked in #848 / #849. Both lockfiles at my HEAD are byte-identical to origin/main's, so none of that is from this PR; every other pre-push gate was run manually and passes (branch-name hook exit 0 on its own branch, and cdk:test / cli:test / agent:test all green). Happy to rebase once #849 lands.

scottschreckengaust and others added 2 commits September 2, 2026 19:01
The `catch` in `readRefListFromStdin` degraded every fd-0 read failure to
'', which `semgrep.ts-silent-success-masking` (AI004) flagged as a new
finding on this branch. It is a real bypass, not a lint nit: '' means "no
ref list", so the resolver falls through to HEAD — and on a `main`
checkout HEAD is exempt. A masked read failure would therefore *pass* the
branch-name gate this PR exists to enforce.

Only EAGAIN (empty non-blocking pipe) and EBADF (fd 0 closed) now degrade
to '' — the two cases that genuinely mean "nothing on stdin", which is the
normal state under prek and on a TTY. Any other errno is re-thrown, and
the read moved inside `main`'s `try` so it surfaces as the existing clean
exit 2 rather than an uncaught throw.

Verified the rule is live rather than trusting a clean scan: it reports
the finding against the pre-fix file and none against the new one. Hook
suites 50 -> 54.

Refs #186
@isadeks

isadeks commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Round 2 — findings addressed; one design question

All three items from my earlier review are genuinely fixed, and two of the fixes are better than what I suggested. resolveBranchesToCheck closing the refspec bypass by validating both sides of every ref pair is the right shape, the correction on prek owning git's stdin is accurate and I was wrong about the remedy, and the differential replay of the old and new exemption regexes over all 313 subjects on main is exactly the evidence that argument needed. The --repo pinning plus the fork check on pickup-issue closes a real self-grant path. Thanks for the probe-hook write-up in particular — it saved me re-deriving it.

The remaining question is not about the implementation. It is about whether "every PR references an issue" should be enforced as an absolute, which I no longer think it should.

The escape hatch already exists, undocumented

Both hooks are client-side. git commit --no-verify and git push --no-verify skip them unconditionally, and prek honours SKIP=adr003-commit-msg. So the exception path already exists — it just leaves no record, names no reason, and is invisible to anyone reading the history later.

That matters on ADR-003's own terms. The ADR's stated rationale for requiring an issue is that governance needs "a durable, reviewable artifact… not a transient conversation." A gate whose only escape is --no-verify produces zero artifact for precisely the cases that most need one. A documented, enumerated exemption is strictly more auditable than the status quo, not less strict.

Where an issue is net-negative

Three classes, and the argument is not convenience:

Mechanical changes — lockfile re-resolves, formatter churn, the generated Starlight mirrors this PR itself regenerates via mise //docs:sync. An issue for "re-resolve the lockfile" has no rationale and no verifiable acceptance criteria, so it fails the ADR's own "Issue quality bar" a few sections above the enforcement table. The gate compels contributors to file issues the ADR would reject, and tracker noise degrades the signal the ADR exists to protect.

Unblocking a red trunk — the gate's cost is paid while main is broken. BRANCH_PATTERN at scripts/hooks/check-branch-name.mjs:81 is (feat|fix|chore|docs)/\d+-, so "unblock trunk" is not an expressible branch name at all.

PR precedes issue — worth keeping the issue here, since that is where rationale lives at a stable URL, and an issue written from a finished, verified PR has known-true acceptance criteria rather than guessed ones. But demanding it before the branch exists is ceremony, and the remedy at push time is the expensive one: a PR is bound to its head ref, so renaming means close and reopen, losing the review threads.

Suggested shape

Enumerate the exempt cases in ADR-003 rather than leaving them to a maintainer's judgment. Rule-based fits the grain of what you have already built — main, dependabot/* and the git-generated subject forms are all rules, not discretion — and it avoids making every exemption wait on a human.

The condition is that an exemption must be derivable from the diff, not self-asserted, or exempt: mechanical becomes the new --no-verify:

Exempt case How CI verifies it
Lockfile-only changed files ⊆ lockfile set
Generated doc mirror matches mise //docs:sync output
Formatter churn diff is a no-op under formatter
Unblocking a red trunk not derivable — body states why

Only the last row has a soft edge, and I would leave it soft rather than build follow-up machinery around it: the PR body says why there is no issue, and that is the artifact.

This also argues for where the real gate lives. A required PR check — body links an open approved issue, or the diff satisfies an enumerated exemption — is unbypassable, fires once, and fires when the work is already asking for a reviewer's time. The hooks fire before you know whether the work is even PR-worthy, and are skipped with a flag. Worth noting that the Tier 1 approved-label row is deferred here for exactly this reason (ADR-003:110): the check that carries the actual governance weight cannot live in a git hook. I would keep commit-msg as-is since --amend makes it cheap to satisfy retroactively, and move the branch-name check to advisory.

Out-of-scope findings — codify what this PR already does

A related gap: nothing in ADR-003 says what to do when you are on issue #N and find something else. The "Notes / pre-existing findings (NOT fixed here — out of scope)" section in this PR description is the right answer, and it deserves a name in the ADR — a Findings section, each item with file:line, so nothing is lost and a maintainer can convert any line into an issue deliberately rather than the contributor filing it unasked.

The distinction the ADR is missing is that a blocker is not out of scope. The stale-base merge in this very PR is the example: had that needed its own approved issue first, the approved work would have been unlandable pending a governance round-trip. Scope-freeze should read as covering deliverables, not incidental prerequisites.

Smaller points on the current head

The commit-msg hook does not cross-check against the branch number — please keep it that way, deliberately. ISSUE_REF (check-commit-msg.mjs:56) never consults the branch, so Refs #705 on a feat/831-* branch passes. That is the only affordance making a necessary blocker fix landable on a single-issue branch, and right now it reads as accidental. A future "tightening" that made branch and commit refs agree would remove it. Worth a line in the script header saying the decoupling is intentional.

ISSUE_REF treats Refs and Closes as equally valid, and GitHub auto-closes on merge to the default branch. Drive-by fix part of someone else's issue, write Closes #705, and merging this PR closes an issue that is only partly done. Since the hook actively presents both as satisfying the gate, the ADR should prescribe Refs for incidental work and reserve the closing keywords for the issue the branch owns.

isFork is the wrong proxy for "labels are self-grantable" (SKILL.md:61-62). Step 0 hard-fails on a fork with no override, but "downstream project forks ABCA and uses its own tracker" is the one reuse ABCA_GOVERNANCE_REPO is advertised for, so the documented knob and the guard contradict each other. The property you want is permission level — gh api repos/$REPO --jq .permissions — which is what actually determines whether the contributor can apply approved themselves. Minor, and I would not block on it.

Also minor: only main is trunk-exempt in EXEMPT_EXACT (check-branch-name.mjs:82), so any long-lived integration branch fails on every push. Combined with prek forwarding only the first refspec pair — which you documented honestly rather than papering over — the branch gate is best-effort under the wiring the repo actually uses, which is a further argument for advisory rather than blocking.

To be clear on the ask: the three findings are resolved and I am not asking for more implementation. What I would like agreed before this merges is whether "no PRs without an issue" (ADR-003:18) stays absolute, since the hooks are the first place that prose becomes binding. My view is it should not, and the ADR should enumerate when it does not apply.

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.

feat(governance): implement ADR-003 enforcement hooks (commit-msg, branch naming, pickup-issue skill)

2 participants