From 002db846f53204373bb861f64b8f4760d75e5f6f Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Mon, 26 Oct 2026 21:04:22 -0400 Subject: [PATCH] chore(claude): release guard lets branch pushes through and asks in every mode A plain branch push (git push -u origin ) is the first half of a pull request, not a deploy, and now passes silently; a push that names main, a tag, HEAD or no branch, or uses --force, --delete, --tags, --mirror or --all, still asks. The bypassPermissions and dontAsk downgrade to deny is gone: Claude Code auto-approves a hook's ask there (verified with a dry-run push), as in Paul's other repos, so the guard is a reminder in bypass sessions and the subagent denial stays the hard stop. Docs, session hook and tests updated. Co-Authored-By: Claude Fable 5.1 --- .claude/agents/release-manager.md | 2 +- .claude/hooks/release-guard.sh | 74 ++++++++++++++++++++++++------- .claude/hooks/session-context.sh | 2 +- .claude/rules/release.md | 2 +- CLAUDE.md | 2 +- scripts/__tests__/hooks.test.js | 35 ++++++++++----- 6 files changed, 86 insertions(+), 31 deletions(-) diff --git a/.claude/agents/release-manager.md b/.claude/agents/release-manager.md index 6498d28..462209e 100644 --- a/.claude/agents/release-manager.md +++ b/.claude/agents/release-manager.md @@ -33,4 +33,4 @@ Report the version, the changelog text, the CI state, and the exact command for ## Rules -No `git push`, `gh pr merge`, `gh release` writes, `gh workflow run`, no tags, no version edits, no changelog edits. The release guard denies them here anyway. Report only what you verified; a check you could not run is reported as not run, never assumed. +No push to `main`, no `gh pr merge`, `gh release` writes or `gh workflow run`, no tags, no version edits, no changelog edits. The release guard denies those here anyway. Report only what you verified; a check you could not run is reported as not run, never assumed. diff --git a/.claude/hooks/release-guard.sh b/.claude/hooks/release-guard.sh index 2c9b5bf..60dd8c4 100755 --- a/.claude/hooks/release-guard.sh +++ b/.claude/hooks/release-guard.sh @@ -6,17 +6,25 @@ # release-please; merging the Release PR tags vX.Y.Z and publishes a GitHub Release # with the Chrome Web Store zip attached; gh release and gh workflow run can create, # edit or rebuild that package. Each of those is a human decision (docs/RELEASING.md): -# • interactive session → permissionDecision "ask" (the prompt is the gate) -# • inside a subagent → "deny" (agent_id or agent_type is present in the event) -# • bypassPermissions / dontAsk → "ask" cannot prompt there, so it becomes "deny" -# • --deny → forces "deny" regardless of context +# • interactive session → permissionDecision "ask" (the prompt is the gate) +# • inside a subagent → "deny" (agent_id or agent_type is present in the event) +# • --deny → forces "deny" regardless of context +# The permission mode is not special-cased, as in Paul's other repos: in bypassPermissions +# Claude Code auto-approves a hook's "ask" (verified 2026-10-26 with a dry-run push that +# named main), so there the guard is a reminder and the subagent "deny" is the only hard +# stop; the gate is CLAUDE.md's rule that nothing is pushed to main, merged, released or +# dispatched unless Paul asked for it. (Until 2026-10-26 this hook turned "ask" into "deny" +# in bypassPermissions, which blocked every push from the sessions Paul actually runs.) # -# Caught: git push (any ref or flag order, including -C, HEAD:main, --force); gh pr merge; -# gh api .../pulls/*/merge; gh release create|edit|upload|delete|delete-asset; gh api -# .../releases with a write method or fields; gh workflow run; gh api -# .../actions/workflows/*/dispatches; gh run rerun. Everything else passes silently, -# including pnpm -C app test|build, git commit, gh pr create, gh release view|download -# and gh run list. +# A plain branch push (git push -u origin , --force-with-lease included) is the +# first half of a pull request, not a deploy, and passes silently. Caught: a push that +# names main, a tag, HEAD, or no branch at all (so the current branch could be main), or +# that uses --force, --delete, --mirror, --all, --tags or a +refspec, in any flag order +# including -C and HEAD:main; gh pr merge; gh api .../pulls/*/merge; gh release +# create|edit|upload|delete|delete-asset; gh api .../releases with a write method or +# fields; gh workflow run; gh api .../actions/workflows/*/dispatches; gh run rerun. +# Everything else passes silently, including pnpm -C app test|build, git commit, +# gh pr create, gh release view|download and gh run list. # # Input: the hook event as JSON on stdin. Output: JSON on stdout, or nothing. Always exits 0. set -u @@ -37,7 +45,6 @@ hook_read_input AGENT_ID="$(hook_json_get agent_id)" AGENT_TYPE="$(hook_json_get agent_type)" -MODE="$(hook_json_get permission_mode)" IN_SUBAGENT=0 if [ -n "$AGENT_ID" ] || [ -n "$AGENT_TYPE" ]; then IN_SUBAGENT=1; fi @@ -48,8 +55,47 @@ B='(^|[[:space:];&|(`])' WRITE_METHOD='(-X[[:space:]]*|--method[[:space:]=]+)(POST|PUT|PATCH|DELETE)|(^|[[:space:]])(-f|-F|--field|--raw-field|--input)([[:space:]]|=)' WHAT="" +# A push is classified by its arguments (everything after "push" up to the next shell +# operator): the first bare word is the remote, the rest are refspecs. +PUSH_GUARDED=0 +PUSH_WHY="" if matches "${B}git([[:space:]]+(-C|-c)[[:space:]]+[^[:space:]]+|[[:space:]]+--?[^[:space:]]+)*[[:space:]]+push([[:space:]]|$)"; then - WHAT="git push: a push to main deploys site/ to GitHub Pages and feeds release-please, and a branch push is the first half of every merge" + PUSH_ARGS="$(printf '%s' "$TOOL_COMMAND" | tr '\n' ' ' | sed -E 's/^.*[[:space:]]push([[:space:]]+|$)//; s/(&&|\|\||;|\|).*$//')" + REMOTE_SEEN=0 + REFSPECS=0 + PUSH_WHY="no branch is named, so the current branch (which may be main) would be pushed" + for tok in $PUSH_ARGS; do + case "$tok" in + --force|-f|--mirror|--all|--tags|--delete|-d|--prune) + PUSH_GUARDED=1 + PUSH_WHY="$tok forces, deletes or mass-updates remote refs" + ;; + -*) ;; + *) + if [ "$REMOTE_SEEN" = 0 ]; then + REMOTE_SEEN=1 + else + REFSPECS=1 + DST="${tok#*:}" + case "$tok" in + +*) PUSH_GUARDED=1; PUSH_WHY="a + refspec force-updates the remote ref" ;; + :*) PUSH_GUARDED=1; PUSH_WHY="an empty source deletes the remote ref" ;; + esac + case "$DST" in + main|refs/heads/main|HEAD|refs/tags/*|v[0-9]*) + PUSH_GUARDED=1 + PUSH_WHY="the push targets $DST, which reaches main, a tag or an unnamed branch" + ;; + esac + fi + ;; + esac + done + [ "$REFSPECS" = 1 ] || PUSH_GUARDED=1 +fi + +if [ "$PUSH_GUARDED" = 1 ]; then + WHAT="git push: $PUSH_WHY; a push to main deploys site/ to GitHub Pages and feeds release-please (a plain branch push, git push -u origin , passes without asking)" elif matches "${B}gh[[:space:]]+pr[[:space:]]+merge([[:space:]]|$)" || matches "${B}gh[[:space:]]+api[[:space:]].*pulls/[^[:space:]/]+/merge"; then WHAT="merging a pull request lands it on main; merging the Release PR tags vX.Y.Z and publishes a GitHub Release with the Chrome Web Store zip" elif matches "${B}gh[[:space:]]+release[[:space:]]+(create|edit|upload|delete|delete-asset)([[:space:]]|$)" || { matches "${B}gh[[:space:]]+api[[:space:]].*/releases" && matches "$WRITE_METHOD"; }; then @@ -63,9 +109,7 @@ fi [ -n "$WHAT" ] || exit 0 if [ "$FORCE_DENY" = 1 ] || [ "$IN_SUBAGENT" = 1 ]; then - hook_pretooluse_decision deny "Release guard (subagent): $WHAT. Subagents never push, merge, release, or dispatch workflows. Put the exact command and the verification checklist in your report; the human runs it from the main session." -elif [ "$MODE" = bypassPermissions ] || [ "$MODE" = dontAsk ]; then - hook_pretooluse_decision deny "Release guard: $WHAT. This session runs in $MODE, where a hook cannot prompt; run the command from a session in default or auto mode, or Paul runs it by hand." + hook_pretooluse_decision deny "Release guard (subagent): $WHAT. Subagents never push to main, merge, release, or dispatch workflows. Put the exact command and the verification checklist in your report; the human runs it from the main session." else hook_pretooluse_decision ask "Release guard: $WHAT. main is the deploy and release branch (docs/RELEASING.md), so this prompt is the gate. Approve only if this is the intended step." fi diff --git a/.claude/hooks/session-context.sh b/.claude/hooks/session-context.sh index 15f9ddf..75e2dd5 100755 --- a/.claude/hooks/session-context.sh +++ b/.claude/hooks/session-context.sh @@ -102,5 +102,5 @@ echo "- Latest GitHub Release: $LATEST_REL$REL_NOTE" echo "- Open Release PR: $RELEASE_PR" echo "- Last CI on main: $CI_RUN. Last Deploy Site run: $PAGES_RUN" echo "- Open issues: $ISSUES" -echo "- Merging the Release PR is the release gate (tags, GitHub Release, store zip); the Chrome Web Store upload is manual under paul@pmds.info. The release guard asks before git push, gh pr merge, gh release writes, gh workflow run and gh run rerun, and denies them in subagents." +echo "- Merging the Release PR is the release gate (tags, GitHub Release, store zip); the Chrome Web Store upload is manual under paul@pmds.info. The release guard asks before a push to main (a plain branch push passes), gh pr merge, gh release writes, gh workflow run and gh run rerun, and denies them in subagents." exit 0 diff --git a/.claude/rules/release.md b/.claude/rules/release.md index 82dcf83..f53c128 100644 --- a/.claude/rules/release.md +++ b/.claude/rules/release.md @@ -17,4 +17,4 @@ paths: - The Chrome Web Store upload is manual: Paul downloads the zip and uploads it in the Developer Dashboard under paul@pmds.info (item `gnlidlpidaoalbbmekofjednjkhhmehn`). The store requires `manifest.json` at the archive root and a version greater than the published one. `/store-upload-check [tag]` verifies the package first. - `pages.yml` deploys `site/` on a push to `main` touching `site/**`. `ci.yml` runs on every PR and push to `main`: Lint & Format, Type Check, Test, Build, Extension E2E (MV3) under xvfb, and Claude Code conformance & hook tests (`node scripts/claude-code-conformance.js --root . --strict`, `node --test scripts/__tests__/*.test.js`). Branch protection on `main` requires these checks on an up-to-date branch; administrators can bypass in an emergency. - Workflows pin `actions/checkout@v7`, `pnpm/action-setup@v6` (pnpm 10), `actions/setup-node@v6` (Node 22). Dependabot (`.github/dependabot.yml`) proposes action updates weekly with a `ci` prefix. -- Never hand-edit a version or `CHANGELOG.md`, never `gh release create`, never push a tag. The release guard asks before `git push`, `gh pr merge`, `gh release` writes, `gh workflow run` and `gh run rerun`, and denies them in subagents. +- Never hand-edit a version or `CHANGELOG.md`, never `gh release create`, never push a tag. The release guard asks before a push that reaches `main` (a plain branch push passes), `gh pr merge`, `gh release` writes, `gh workflow run` and `gh run rerun`, and denies them in subagents. diff --git a/CLAUDE.md b/CLAUDE.md index a400c5d..af941f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ The root `package.json` exists for release-please's version stamp and has no scr - A push to `main` that touches `site/**` deploys the site to GitHub Pages (`pages.yml`). Every push to `main` runs release-please, which maintains one Release PR. **Merging the Release PR is the human release gate**: it stamps the version into `package.json`, `app/package.json` and `app/manifest.json`, updates `CHANGELOG.md`, tags `vX.Y.Z`, publishes a GitHub Release and attaches `optia-X.Y.Z.zip` with the manifest `key` stripped. Paul uploads that zip in the Chrome Web Store Developer Dashboard under paul@pmds.info; the store needs a version greater than the published one. `docs/RELEASING.md` has the detail. Never hand-edit a version. - CI (`ci.yml`) on every PR and push to `main`: lint, typecheck, test, build, the MV3 e2e under xvfb, and Claude Code conformance with the hook tests. Branch protection on `main` requires those checks. -- The `release-guard` hook asks before `git push`, `gh pr merge`, `gh release create|edit|upload|delete`, `gh workflow run` and `gh run rerun`, and denies them inside subagents. Do not look for a way around it; hand Paul the command. +- The `release-guard` hook asks before a push that reaches `main` (a plain branch push, `git push -u origin `, passes), `gh pr merge`, `gh release create|edit|upload|delete`, `gh workflow run` and `gh run rerun`, and denies them inside subagents. Do not look for a way around it; hand Paul the command. ## Working conventions diff --git a/scripts/__tests__/hooks.test.js b/scripts/__tests__/hooks.test.js index 440b952..a244b93 100644 --- a/scripts/__tests__/hooks.test.js +++ b/scripts/__tests__/hooks.test.js @@ -53,13 +53,20 @@ describe("release-guard (PreToolUse, Bash)", () => { "git push origin main": /git push/, "gh pr merge 123": /merging a pull request/, "gh release create v1.2.0": /gh release writes/, - "git push": /git push/, - "git push -u origin chore/claude-code-conformance": /git push/, - "git push origin HEAD:main": /git push/, - "git push --force-with-lease origin fix/x": /git push/, - "git -C /tmp/x push": /git push/, - "git --no-pager push origin main": /git push/, - "cd app && git push": /git push/, + "git push": /no branch is named/, + "git push origin HEAD:main": /targets main/, + "git push origin HEAD": /targets HEAD/, + "git -C /tmp/x push": /no branch is named/, + "git --no-pager push origin main": /targets main/, + "cd app && git push": /no branch is named/, + "git push --force origin fix/x": /--force forces/, + "git push origin --delete feat/old": /--delete forces/, + "git push origin :feat/old": /empty source deletes/, + "git push --tags": /--tags forces/, + "git push origin v1.2.0": /targets v1\.2\.0/, + "git push origin +fix/x": /force-updates/, + "git push origin refs/heads/main": /targets refs\/heads\/main/, + "git push -u origin chore/x && git push origin main": /targets main/, "gh pr merge 82 --squash --subject 'chore(main): release 1.2.0'": /merging a pull request/, "gh api -X PUT repos/PMDevSolutions/Optia/pulls/82/merge": /merging a pull request/, "gh release edit v1.1.3 --notes 'x'": /gh release writes/, @@ -88,7 +95,7 @@ describe("release-guard (PreToolUse, Bash)", () => { for (const cmd of ["git push origin main", "gh pr merge 123", "gh release create v1.2.0"]) { const res = guard(cmd, { agent_id: "a1b2", agent_type: "release-manager" }); assert.equal(res.permissionDecision, "deny", cmd); - assert.match(res.permissionDecisionReason, /Subagents never push, merge, release/); + assert.match(res.permissionDecisionReason, /Subagents never push to main, merge, release/); } }); @@ -102,11 +109,10 @@ describe("release-guard (PreToolUse, Bash)", () => { assert.equal(res.permissionDecision, "deny"); }); - test("downgrades ask to deny where a hook cannot prompt", () => { + test("asks in bypassPermissions and dontAsk too (Claude Code decides what ask means there)", () => { for (const permission_mode of ["bypassPermissions", "dontAsk"]) { const res = guard("git push origin main", { permission_mode }); - assert.equal(res.permissionDecision, "deny", permission_mode); - assert.match(res.permissionDecisionReason, new RegExp(permission_mode)); + assert.equal(res.permissionDecision, "ask", permission_mode); } }); @@ -121,6 +127,11 @@ describe("release-guard (PreToolUse, Bash)", () => { "git diff main", 'git commit -m "fix: push fix for the badge"', "git -c core.pager=cat log --grep push", + "git push -u origin chore/claude-code-conformance", + "git push --force-with-lease origin fix/x", + "git push -u origin chore/x && gh pr create --fill", + "git push origin feature/one feature/two", + "git push --dry-run -u origin chore/x", "git fetch origin && git pull --rebase", "git checkout -b chore/x origin/main", "gh pr view 12", @@ -164,7 +175,7 @@ describe("session-context (SessionStart)", () => { assert.match(out, /- Latest GitHub Release: unavailable/); assert.match(out, /- Open Release PR: unavailable/); assert.match(out, /- Open issues: unavailable/); - assert.match(out, /release guard asks before git push/); + assert.match(out, /release guard asks before a push to main/); }); test("reads the Release PR, latest release, CI, Pages and issues from gh", () => {