Allow, ask, deny: Claude Code permissions and a SessionStart status hook

Sort a repo's commands by what they can break: routine work runs unprompted, merges wait for an Allow tap, pushes to main and deploys are refused; a start hook flags a broken toolchain.

Claude Code intermediate Tested Opus 5.5 · Nov 2026May be dated

The prompt

Run it in a Claude Code session at the root of the repository. It writes two files and leaves the commit to you.

Fill in the blanks4 placeholders · empty ones stay as {{…}}

Example

Input: run at the root of a small static-site repo: Markdown entries under content/ (one marked draft: true), validate, build and test scripts in scripts/, wrangler.jsonc, a CI workflow that runs npm ci, validate, test and build, and package.json scripts build, validate, test, dev (build, then wrangler dev) and deploy (build, then wrangler deploy). The session starts on main with one uncommitted change and no node_modules.

  • repo_description: A Node 22 static site that builds a public prompt library from Markdown files; npm run validate, test, build and dev.
  • deploy_paths: Cloudflare Workers Builds deploys every push to main. npm run deploy (wrangler deploy) also deploys straight from the working tree.
  • protected_branch: main
  • other_shared_branches: none

Output (trimmed to the hook's output from the test run, five of the twelve rule groups, four of the eleven gaps and two of the four settings outside the repo):

Status: prompt-library | node 22.23.4 (needs >=22) | npm 10.9.9 | git 2.43.0 | gh 2.89.0 | wrangler MISSING | deps MISSING | branch main | uncommitted 1 file(s) | unpushed 0 commit(s) (to origin/main)
In progress: content/claude-code/test-plan.md (draft, TODO)
Local changes: uncommitted: M content/claude-code/test-plan.md; unpushed: 0 commit(s) (to origin/main)
Elsewhere: open PRs, review comments and CI runs: check GitHub (gh pr list, gh pr checks); not checked here, no network.
DEPS MISSING: run npm ci (not npm install) before validate, test, build or dev; until then don't claim they passed.
ON main: create a work branch (git switch -c <name>) before the first commit; never push main, it deploys production.

Ask and deny rules

Group Rules A command it stops A routine command it lets through
ask: Merge or approve a PR Bash(gh pr merge *), Bash(gh pr review *), mcp__github__merge_pull_request, mcp__github__enable_pr_auto_merge, mcp__github__pull_request_review_write gh pr merge 12 --squash --auto; gh pr review 12 --approve gh pr checks 12 --watch; gh pr comment 12 --body "…"
…
deny: Push to main Bash(git push * main), Bash(git push * main *), Bash(git push *:main), Bash(git push *:main *), Bash(git push *refs/heads/main), Bash(git push *refs/heads/main *), Bash(git push *--all*), Bash(git push *--mirror*) git push origin main; git push origin main -u; git push origin HEAD:main; git push origin :main; git push origin x:refs/heads/main; git push origin --all git push -u origin fix/main-nav; git push origin main-fix; git push origin HEAD:main-fix; git push origin domain-main
deny: Force push Bash(git push *--force*), Bash(git push -f *), Bash(git push * -f *), Bash(git push * -f), the same three for -fu and for -uf, Bash(git push * +*) git push --force-with-lease origin x; git push origin x --force; git push -f origin x; git push origin x -f; git push origin x -uf; git push origin +x git push origin fix-f; git push origin feat/c++
deny: Hard reset Bash(git reset *--hard*) git reset --hard HEAD~1; git reset HEAD~1 --hard git reset --soft HEAD~1 (not refused, but it prompts because no allow rule matches)
deny: Production deploy and the wrangler CLI Bash(npm run deploy *), Bash(npm * deploy*), Bash(pnpm * deploy*), Bash(yarn deploy*), Bash(yarn * deploy*), Bash(bun * deploy*), Bash(wrangler *), Bash(npx *wrangler *), Bash(npx *wrangler@*), Bash(npm exec *wrangler*), Bash(npm x *wrangler*), Bash(pnpm *wrangler*), Bash(yarn *wrangler*), Bash(bunx *wrangler*), Bash(bun *wrangler*), Bash(*/.bin/wrangler *), Bash(*/bin/wrangler *), Bash(*/wrangler.js *) npm run deploy; npm -s run deploy; npx -y wrangler@4 deploy; ./node_modules/.bin/wrangler deploy; wrangler versions deploy; bunx wrangler deploy npm run dev (it runs wrangler dev inside the script); npm run build; git switch -c deps/wrangler origin/main
…

Gaps: what the rules can't stop

  • Editing an allowed script or CI, then running it (runs unprompted). For example, adding wrangler deploy to scripts/build.mjs, to the build or test script, or to a prebuild script, then running npm run build. The allow rule matches and the deploy happens inside the script. An edited .github/workflows/validate.yml runs when the allowed git push of a work branch triggers a PR build. npm ci also runs dependency install scripts. The edit itself only prompts in default mode; it goes through without a prompt in acceptEdits, auto and bypassPermissions.
  • Editing .claude/settings.json or the hook: this prompts in default and acceptEdits modes, but not in auto mode (a classifier decides) or bypassPermissions.
  • Bare git push, or git push origin HEAD, while on main (runs unprompted). This pushes main and deploys production. Only the hook's ON main warning covers it.
  • Other ways of writing the same command (prompts): git -C . push origin main, bash -c "npm run deploy", env npm run deploy, sh -c …, xargs …, /usr/bin/git push origin main. No allow rule matches them. They prompt in default mode, the classifier decides in auto mode, and they run in bypassPermissions.
  • …

Settings outside the repo that do stop these:

  • GitHub ruleset on main: require a pull request with the Validate check passing and an approval from someone other than the author, block force pushes and deletion, and allow no bypass for admins or the session's account. This rejects every way of pushing to main, and any merge through the API by the agent.
  • …
  • Cloudflare deploy credentials: keep them out of the session (no API token, no wrangler login), so wrangler deploy fails however it is called. Keep Workers Builds' production branch set to main only.
  • …

The hook turned the starting state (on main, no node_modules) into two warnings that say what to do first. Each rule group names a command it stops and a routine one it lets through, such as pushes to work branches whose names contain main, and Claude also ran 207 commands through a matcher that follows the rule syntax; none landed in the wrong group.

Why it works

  • Sorted by consequence, not by tool. Git lands in all three lists: git commit is allowed, git branch -D asks, git push --force is refused. Routine, reversible work runs unprompted, with scripts allowed one by one so a deploy script added later isn't; the agent doesn't stall, and people stop approving prompts without reading them. Outward-facing steps wait for a tap, commits through the GitHub API included; since opening, updating and commenting on a pull request run unprompted, the merge's Allow prompt becomes the release button. Destructive steps are refused, so a tired "yes" can't happen.
  • Precedence does the carving. Claude Code checks deny, then ask, then allow, whatever the specificity, so a broad allow with narrow deny rules is safe for every spelling those rules list, in every position, and a broad ask rule would swallow routine work. Testing each rule on one command it must stop and one it must let through catches both holes and false positives.
  • Guard rails, not the wall. Rules match the command text, so git -C . push origin main isn't refused; it falls back to a normal prompt. The prompt makes Claude list such gaps, say which would run unprompted, and name the real boundary (branch protection, deploy credentials). Its CLAUDE.md lines cover what rules can't: a denied prompt means stop, not find another route, and a rule, hook or allowed script changes only in its own PR.
  • A start hook that turns environment gaps into instructions. Plain stdout goes into Claude's context at every session start, including /clear and compaction. A missing runtime or missing dependencies print a warning not to claim the checks passed, so an agent that couldn't run a check doesn't report it as passing; being on the protected branch prints what to do first. The hook never fails, calls no network and changes nothing (in the test it took 0.16 s and left git status unchanged), so it is safe to run every session.

Variations

  • Cloud sessions only. Start the script with [ "$CLAUDE_CODE_REMOTE" = "true" ] || exit 0 when developers' own machines don't need the status.
  • Long checks. If a routine check (browser QA, a full test suite) runs past the 2-minute default Bash timeout, add "env": {"BASH_DEFAULT_TIMEOUT_MS": "300000", "BASH_MAX_TIMEOUT_MS": "900000"} to the same file.
  • Keep secrets away from Claude's file tools. Add Read(./.env), Read(./.env.*) and Read(./secrets/**) to the deny list; for a hard boundary, enable the sandbox.
  • Surface a status the repo records. If CLAUDE.md keeps a status line (say, Status: staging is down until the migration lands), have the hook print it with grep -m1 '^Status:' CLAUDE.md, so every session starts from the current state.
  • A publishing pipeline. Publish from your phone: checkpoints, a facts gate and a one-tap merge uses these rules to make the merge's Allow tap the publish button.
  • Generalised from a blog published through pull requests.