Skip to content

Enforcement layers — reference

Extracted from CLAUDE.md per HIMMEL-164 (state-not-prompt slimming). CLAUDE.md keeps a session-time summary; the full reference lives here. Source of truth for what actually runs: .pre-commit-config.yaml and .claude/settings.json.

Pre-commit Enforcement

Source of truth: .pre-commit-config.yaml. Smoke tests for each Claude hook live alongside it as scripts/hooks/test-<hook>.sh — run after any hook edit before pushing.

Stages currently wired:

  • Format/lint (pre-commit): trailing-whitespace, end-of-file-fixer, check-yaml, check-json, shellcheck.
  • Secrets (pre-commit): gitleaks (.gitleaks.toml), including the himmel-retired-org-name rule (HIMMEL-3508): a retired third-party org’s name must never reappear in this repo’s tracked content (gitleaks scans staged/tracked files, not commit-message or PR-metadata text — those stay a contributor discipline point, not a structurally enforced surface).
  • Branch hygiene (pre-commit): worktree-isolation (blocks commit when branch == main), merged-branch check (warns on commits to already-merged branches).
  • MCP plugin enforcement (pre-commit): mcp-plugin-refs (blocks commits referencing Atlassian MCP Jira tools that have a himmel-jira plugin equivalent — see block-backend-tier.sh below).
  • gh --json field-name guard (pre-commit): gh-json-fields (every literal gh <sub> … --json a,b,c in staged markdown/shell/ts/js is checked against the field list gh <sub> --json __bogus__ prints — offline, no API call). A typo’d field makes gh exit 1, which callers report as a generic “cannot evaluate”, so the guarded path silently never runs: that is how the (retired, HIMMEL-2705) /cr-public command’s HIMMEL-1202 bounded wait shipped polling the nonexistent headRefSha (HIMMEL-1288) — kept here as the worked example the guard exists to catch. Non-literal field lists (--json "$FIELDS") and subcommands with no --json support are skipped; a missing gh skips the gate loudly rather than blocking the commit.
  • Headless-gemini gate (pre-commit): no-headless-gemini (blocks new gemini -p / --prompt / --bg introductions unless the call has a # headless-gemini-ok: <reason> marker; HIMMEL-157). Headless claude calls have no commit gate (retired in HIMMEL-5424) — see “Claude invocation billing” below. The gate exempts the root CHANGELOG.md (is_exempt()): it’s fully generated from immutable commit subjects by scripts/gen-changelog.sh, so it can only RECORD a headless call, never introduce one, and its own opt-in marker can’t survive regeneration (HIMMEL-2250). A nested some/dir/CHANGELOG.md is still checked.
  • Fork/template drift guards (pre-commit): telegram-fork-drift (flags the vendored telegram-himmel fork drifting from the claude-plugins-official telegram version it’s pinned to — fail-open if upstream isn’t installed locally, fail-closed on detected drift; bypass SKIP=telegram-fork-drift), template-himmel-plugins (two invariants on docs/setup/settings-template.json, the single list adopt.sh/ install-plugins.sh installs from: every locally-vendored @himmel marketplace plugin must be enabled in it, and every marketplace named by an enabledPlugins key must have an extraKnownMarketplaces entry — install-plugins.sh registers ONLY what that map declares, so an unregistered marketplace’s plugins can never install and the post-install presence-verify fails the whole run (HIMMEL-2691). Fail-closed on drift; a MISSING input is a legitimate skip, but an input that EXISTS and is unreadable fails closed rather than reading as two empty sets), hud-drift (himmel-dev only — the vendored claude-hud tree must match its recorded pin in marketplace/plugins/claude-hud/VENDORED.md; bypass HUD_DRIFT_OK=1; HIMMEL-718).
  • Repo-integrity guards (pre-commit): hookspath-misconfig (core.hooksPath must be unset or point inside the repo/git-common-dir — catches the HIMMEL-45 class of bug where hooks are silently skipped; bypass HOOKSPATH_OK=1), artifact-leakage (blocks newly-staged generated artifacts — node_modules/, OS junk files, a wrong-package-manager lockfile sitting next to a bun.lock; HIMMEL-371), graph-artifact-branch (scoped to graphify-out/ — blocks staged graph artifacts unless the branch is chore/graph-publish-* or GRAPH_COMMIT_OK=1; HIMMEL-2167).
  • Settings portability (pre-commit): settings-portability (a tracked .claude/settings{,.local}.json or .codex/hooks.json may carry no absolute user path — C:/Users/, /Users/, /home/<name>/ — and no .exe in a hook "command"; a "Bash(curl.exe:*)" permissions entry is deliberately out of scope, which is why the .exe rule matches only "command" lines). The shape it exists to catch is written by a tool, not by hand: graphify install rewrites its own hook block in both files with a machine-absolute graphify.EXE path every time it runs, so a one-off cleanup does not hold and this gate is what catches the re-add on the next commit that stages either file. Both patterns self-test at startup — a regex that silently stopped matching would render as a clean pass on every commit (HIMMEL-2156; re-apply recipe in environment-gotchas.md).
  • Lane-inventory guard (pre-commit, himmel-dev only): lanes-inventory-guard (blocks the lane inventory being re-added to CLAUDE.md prose — the inventory lives in scripts/lanes/lanes.json, queried via /lanes; HIMMEL-689).
  • Dependency integrity (pre-commit): lockfile-integrity (npm + bun — every package.json under scripts/ needs a tracked, in-sync sibling lockfile; for bun projects bun is resolved by PATH → $BUN_INSTALL/bin → $HOME/.bun/bin, because a hook inherits the invoking git’s environment and never sees the ~/.bashrc layer bun’s installer writes to (HIMMEL-2439) — with no bun found the gate refuses only commits that stage something under the bun package and skips the rest, and the bun it does find must be a RELEASE build: a canary/prerelease bun fails --frozen-lockfile on a clean lockfile and the gate used to report that as bogus lock drift, HIMMEL-2010), uv-lock-integrity, pip-hashes (requirements*.txt must use —generate-hashes).
  • CLAUDE.md budget (pre-commit, himmel-dev only): claude-md-budget (CLAUDE.md stays under its always-on byte budget; HIMMEL-2038).
  • Guardrail + fail-open-shape lints (pre-commit): guardrail-matrices (egress + voice-policy invariants; HIMMEL-1522), fail-open-lint (fails-open-on-unknown shape lint; HIMMEL-1776).
  • Node-suite drift gates (pre-commit): hook-lib-node-suites (scripts/hooks + scripts/lib node suites; HIMMEL-1578), trust-node-suites (scripts/trust node suites; HIMMEL-1589), profile-manifest-diff (plugin-profile manifest stays pinned; HIMMEL-2189).
  • Skill/command lints (pre-commit): skill-description-cap (skill/command description stays within 120 chars; HIMMEL-2037), command-positional-args (no bare $<digit> in a command’s fenced code; HIMMEL-2051), commands-catalog-drift (docs/commands-catalog.md Description column matches command frontmatter; HIMMEL-2064).
  • Oxlint ratchets (pre-commit): oxlint-complexity-ratchet (no NEW function above the audited max; HIMMEL-2154), oxlint-hardening-zero-violations (bug-class hardening — zero violations; pinned oxlint 1.89.0, with root/cwd diagnostics; findings and tool/JSON failures block, while only a missing bunx fails open; HIMMEL-2163/2802).
  • Doctor check-ID + unchecked-mktemp gates (pre-commit): doctor-check-ids (check-doctor-check-ids.sh, scoped to scripts/himmel-doctor.sh — every check_cNN() must be defined exactly once per numeric ID and invoked exactly once in the tail call list; catches a silently-shadowed ID from two branches picking the same next-free number, which a textually-clean merge does not surface; HIMMEL-2664), unchecked-mktemp (check-unchecked-mktemp.sh, always-run — refuses a commit whose staged diff ADDS an unguarded T=$(mktemp -d) capture: reads the STAGED blob of every staged *.sh (never the working tree), intersects the predicate’s offending lines with the file’s own ADDED lines from git diff --cached -U0, so pre-existing unguarded mktemps never block an unrelated commit; predicate shared with its suite via scripts/lib/unchecked-mktemp.sh; this was the single most-repeated CR-ledger defect class (10 agreed findings in the 14 days to 2026-09-07, always the same unchecked-capture shape) and is also known-findings.json’s test-setup-unchecked-vacuous-green shape (a); bypass UNCHECKED_MKTEMP_OK=1; HIMMEL-2709; HIMMEL-4301: on a staged merge only lines added relative to EVERY parent count (the conflict resolution, git diff --cc semantics), so a merge-forward needs no bypass; and CI’s unchecked-mktemp-range job runs --range --merge-ref over the PR’s net diff so a commit made with the hook skipped is still caught).
  • Runtime pin (not a gate — /himmel-doctor C20): the doctor FAILS (exit 1) when the running node major ≠ the .nvmrc pin, so the drift stops being rediscovered per session (HIMMEL-1986 → HIMMEL-2010). Downgraded to a non-fatal WARN under $CI/$GITHUB_ACTIONS and by the bypass NODE_MAJOR_DRIFT_OK=1. The doctor never edits .nvmrc and never switches a runtime.
  • Commit-msg / PR traceability (HIMMEL-1483, default-on HIMMEL-2442): conventional-commit-msg validates conventional format and requires a ticket matching TICKET_ID_PATTERN, else the derived JIRA_PROJECT_KEY-N form, else #[0-9]+ — himmel’s own #N enumeration, which is the ticket system for a repo without Jira rather than a relaxed fallback. The ticket half is ON by default, including for an adopter with no .env at all; TICKET_ID_REQUIRED=0 is the explicit opt-out. Merge/revert commits and the configurable comma-list TICKET_ID_EXEMPT_AUTHORS (default dependabot[bot],dependabot) are exempt. CI applies the ticket-ID half to internal committers only (HIMMEL-3806): HIMMEL is a private Jira, so an external contributor cannot cite an ID. The allowlist is scripts/ci/ticket-id-internal-authors.txt (one GitHub login per line, case-insensitive, default yotamleo) — add a login there to bind a new internal committer. check-commit-range.sh and check-pr-title.sh compare the PR author (TICKET_ID_TRUSTED_AUTHOR) against it; an unlisted author gets a NOTE and skips only the ticket-ID check (conventional shape and every other gate still apply). An empty login or an unreadable/empty allowlist keeps the ID required (fail-safe), and the local commit-msg hook is unchanged. The .pre-commit-config.yaml entry must keep pass_filenames: true — the commit-msg stage’s filename argument IS the message file. Handed none, the hook falls back to .git/COMMIT_EDITMSG; only when that fallback does not resolve to a readable file does it fail CLOSED (reject) rather than pass (HIMMEL-2461). It also WARNS (never blocks, HIMMEL-3022) at commit time when a Security reviewed:/Platforms tested: trailer is present but does not conform — staying silent when the trailer is absent altogether — honoring a [skip security-review]/[skip platforms-check] marker, or a token that will satisfy check-security-reviewed.sh’s pre-push TOKEN_RE, or a non-empty Platforms tested: value, exactly as those gates would — so the author usually sees the problem before the pre-push gate refuses the push. It cannot see a PR-body attestation (the gate’s third path, since no PR exists yet at commit time), so a message the warning still flags may in that one case still pass the gate.
  • Doc-guard (pre-commit + pre-push, himmel-dev only): check-doc-guard (blocks ADDING a command/skill file without a matching update to docs/commands-catalog.md; gated behind .himmel-dev marker so adopters are never affected — see check-doc-guard.sh below).
  • AGENTS.md-fresh (pre-commit, himmel-dev only): check-agents-md-fresh (blocks committing a stale AGENTS.md when CLAUDE.md / AGENTS.md / scripts/agents-md/* is staged; AGENTS.md is generated from CLAUDE.md by scripts/agents-md/generate.mjs so the two never drift, HIMMEL-471; gated behind .himmel-dev; bypass AGENTS_MD_OK=1).
  • Template versioning (pre-commit + pre-push, himmel-dev only): template-version / template-version-push (check-template-version.sh, --pre-push for the push-time twin) — template content change requires a metadata.version bump; HIMMEL-524.
  • Pre-push: no-push-to-main (also refuses a NEW branch outside type/slug | handover/<TICKET>-<slug> | claude/*, HIMMEL-5308; bypass BRANCH_NAME_OK=1), npm-audit (high+), npm-licenses (allowlist), npm-audit-signatures (needs npm >= 11 — older npm bundles a registry public key that expired 2025-01-29 and fails every package with EEXPIREDSIGNATUREKEY; the gate checks the version first and names npm install -g npm@11 rather than surfacing the registry error, HIMMEL-2440), code-review-before-push (multi-agent CR via pr-review-toolkit), pr-mergeable-gate (refuses to push while the branch’s open PR is CONFLICTING; bypass SKIP_PR_MERGEABLE=1; HIMMEL-136), no-force-push (hard-refuses a force-push to main with no bypass; warns but proceeds on a force-push to any other ref, bypass SKIP_FORCE_PUSH_GATE=1 to silence the warning; HIMMEL-136), platforms-tested (cross-platform attestation for shell/script changes — see below), security-reviewed (security-review attestation for non-docs code changes; HIMMEL-176, see docs/security-review.md).

Gates execute working-tree tooling — and the CI backstop (HIMMEL-1305)

The convention: local gates judge the INDEX but run from the WORKING TREE. check-debrand-coverage.sh materializes the staged (or HEAD) copies of CLAUDE.md and debrand.json into a tempdir and validates those — correct about what is being committed. But it executes the generator (scripts/agents-md/generate.mjs) from the working tree, as does every other gate here with the helper it sources (scripts/guardrails/lib.sh) and with the gate script itself (pre-commit reads it off disk).

So an unstaged edit to that tooling can approve a commit the edit is not part of: stage a CLAUDE.md change that orphans a live debrand rule, separately (unstaged) weaken the generator’s coverage branch, and the gate passes on code that is not in the resulting commit.

Decision: keep working-tree execution; add a CI backstop. Materializing tooling from the index was considered and rejected — it does not actually close the hole (the hook script and lib.sh are still read from disk, so a real fix means materializing the entire hook chain from the index) and it would make one gate the odd one out without changing the class. These gates are drift guards, not a security boundary against local edits: anyone able to edit generate.mjs unstaged can equally edit the gate.

The backstop is the layer that has the property for free. The doc-invariants job in .github/workflows/ci.yml runs the SAME gate scripts against a clean checkout, where committed source and committed tooling are the same tree by construction. Mirrored there: check-agents-md-fresh.sh, check-debrand-coverage.sh, check-lanes-inventory.sh — the gates whose subject is a whole-tree invariant rather than a staged diff. check-doc-guard.sh is deliberately NOT mirrored: it keys on files being added in a commit, so it has nothing to assert about a clean checkout.

Where that job actually runs — read this before relying on it. GitHub Actions is disabled on the private repo by design (gh api repos/{owner}/{repo}/actions/permissions → {"enabled": false}); the workflow is listed active, but nothing dispatches it and gh run list shows only Dependabot. The deliberate split is pre-commit locally, Actions on the public mirror, where Actions is enabled.

So doc-invariants gates the public tree after propagation, not the private PR. On private, the pre-commit gates are the only enforcement — which is exactly why the working-tree caveat above is a caveat and not a solved problem. The public run still earns its keep: propagation is a re-projection and therefore an independent diff surface, so it is where a CLAUDE.md/AGENTS.md/debrand inconsistency would otherwise land unchecked.

Do not read a green private PR as evidence these gates ran — on private they run only at commit time, on your machine.

Three gotchas when adding to that job — all fail SILENTLY GREEN. A gate that no-ops is indistinguishable from a gate that passed, so a mistake here does not break CI; it quietly removes the check.

  1. The .himmel-dev marker. These gates are himmel-dev-only and no-op without the gitignored marker (which keeps them off for adopters). CI has no marker, so the job creates one first.

  2. The staging step. These are pre-commit hooks — each triggers off git diff --cached and exits 0 when its inputs are not staged (check-agents-md-fresh.sh greps the staged names for CLAUDE.md / AGENTS.md / scripts/agents-md/; check-debrand-coverage.sh computes a relevant count the same way; check-lanes-inventory.sh is a one-line grep -x 'CLAUDE.md' || exit 0). A CI checkout’s index is identical to HEAD, so nothing is staged and all three no-op. The job runs git checkout --orphan ci-doc-invariants && git add -A first: against an unborn HEAD, add -A stages the whole committed tree, so every trigger fires against committed bytes.

    The orphan is load-bearing, not stylistic. git read-tree --empty && git add -A looks equivalent and is not — re-adding against the real HEAD rebuilds an index identical to it, leaving git diff --cached empty again. It appears to work on Windows only because CRLF normalization leaves incidental differences; on the ubuntu runner it stages exactly nothing.

    Because the orphan leaves HEAD unborn, nothing after that step may clone the checkout — cloning an unborn HEAD yields an empty worktree. That is why test-ci-doc-invariants.sh (which clones) runs before it.

  3. Never grep -q on the staged list. Under set -o pipefail, -q exits at the first match and closes the pipe; the writer upstream dies of SIGPIPE and the pipeline reports 141 even though grep matched, so the trigger reads as “not staged” and the gate exits 0. It only bites once the staged list outruns the ~64KB pipe buffer — invisible on ordinary commits, and a fail-open on exactly the large ones you most want checked. Plain grep (output to /dev/null) drains the stream and is safe. This bit check-agents-md-fresh.sh and check-lanes-inventory.sh; it is also why the whole-tree staging above made them look broken.

Both are pinned by scripts/hooks/test-ci-doc-invariants.sh, which runs before the staging step. It clones the repo (a fresh clone, not a linked worktree — a worktree resolves the marker from the primary checkout and would not reproduce CI), breaks the tree three ways, and asserts each gate blocks with the staging step and is a false green without it. That second assertion is the point: it fails the moment someone simplifies the staging step away.

The first version of this job shipped without the staging step and was caught by the codex adversarial CR lane, not by CI — the job was green on a tree it had never looked at. Verifying it by hand had the same blind spot: running the gates in a clean worktree returns rc=0 whether they checked anything or not.

Pre-commit gates and their CI copy (HIMMEL-5758)

A contributor without hooks installed, or committing with --no-verify, skips every pre-commit gate. Ten gates had no CI copy; each is now either mirrored in CI or inherently local.

GateCI copy
no-history-commentslint job, step “history-comment gate (PR range)”: base copy of check-history-comments.sh --range <base>
bundle-registry-lint, no-mcp-sdk-v1, fail-open-lint, pip-hasheslint job, ONE step “tree-only pre-commit gates (PR range)”: each runs from its BASE copy (a PR cannot weaken the gate that judges it; bundle-registry-lint’s scripts/himmelctl lib and hook launcher are staged from base too; fail-open-lint runs the BASE suite, lint and fixtures laid over a copy of the PR tree, since the gate cds to the tree it judges); a missing base sha or base copy is a red job. Pinned by scripts/ci/test-ci-lint-tree-gates.sh
trust-node-suitesalready mirrored: the ungated lanes-and-trust-suites job runs node --test scripts/trust/tests/*.test.mjs on every PR
worktree-isolationlocal by nature: it refuses a commit made on main from the primary checkout (an editing-station rule); a CI runner commits nothing
merged-branch-checklocal by nature: it warns that the committer’s local branch is already merged into main (stale-branch edits); CI checks out a detached merge ref with no local branch to be stale
template-version-pushlocal by nature: --pre-push reads the push range handed over by git on stdin, which does not exist on a runner
telegram-fork-driftlocal by nature: it compares the pinned fork with the upstream plugin cached under ~/.claude/plugins and skips when that cache is absent (a runner); drift is not a property of a PR

Pre-push gate delivery — push from the branch’s OWN worktree (HIMMEL-1574 / HIMMEL-1809)

The working-tree caveat above is sharper at pre-push time, because the pusher’s working tree and the pushed content are routinely different trees. pre-commit’s pre-push hooks (shellcheck, gitleaks, the attestation gates) lint the tree at the pusher’s cwd, never the commits on the wire — so pushing a worktree branch from the primary checkout, which sits on main by design for the whole lane workflow, lints main’s copy of every file the branch touched. Findings then cite line numbers the branch does not have (unreproducible by running the linter directly, which is what made HIMMEL-1805 cost a full extra lane round), and, in the common case, it fails OPEN: a branch whose touched files are clean on main passes a gate that never inspected it. check-cr-before-push.sh — the first himmel-owned pre-push stage, reached through the pre-push.legacy shim while git’s raw ref stream is still intact — now refuses that shape before any other work: a push into refs/heads/<b> whose commit is not this worktree’s HEAD exits 2 naming both branches. Comparing the SHA rather than the branch NAME is deliberate — it also catches git push origin <sha>:refs/heads/b, HEAD~3:refs/heads/b, and a tag pushed onto a branch, each of which lints a tree the push does not carry. Detached HEAD refuses too. Deletes and non-branch destinations are exempt (no working-tree content is claimed), a DIRTY working tree is a named, still-open ceiling, and PUSH_FOREIGN_REF_OK=1 git push … (a per-command prefix reaches this git hook) is the explicit bypass — it accepts a gate result computed from the wrong tree. So: git -C <worktree> push …, not git push <branch> from the primary. Note pre-commit OR-folds the legacy hook’s exit code (retv | run(...)) rather than short-circuiting, so the configured hooks still run and their misattributed output still prints below the refusal; the push is refused all the same. The shim itself is the delivery seam and had its own hole: it resolved the gate via git rev-parse --show-toplevel, which from a worktree is the WORKTREE root, so every worktree ran its own base commit’s copy and any pre-push hardening silently skipped worktrees created before it. It now resolves the primary checkout (first entry of git worktree list --porcelain, falling back to the --git-common-dir parent) and fails closed if no gate is found there. The shim is a file under .git/hooks/, so it is not delivered by git pull — re-run bash scripts/hooks/install-cr-pre-push-legacy.sh (or scripts/setup.sh / scripts/setup-hooks.sh, which call it) once per clone after this change. Spec: scripts/hooks/test-install-cr-pre-push-legacy.sh, scripts/hooks/test-check-cr-before-push.sh.

check-main-ref-transaction.sh — main-branch reference-transaction guard (HIMMEL-2095)

This is a git reference-transaction hook, not a Claude PreToolUse hook — it is not in .claude/settings.json or the wire-hook-bash.mjs inventory, and it could not be: pre-commit install --hook-type does not support reference-transaction at all (its own --help lists commit-msg, post-checkout, post-commit, post-merge, post-rewrite, pre-commit, pre-merge-commit, pre-push, pre-rebase, prepare-commit-msg — this type isn’t among them), which is exactly why it needs its own installer, below.

The rule: refuses an update to refs/heads/main whose new oid is not already an ancestor of refs/remotes/origin/main. Creating a local commit on main — git commit, merge, rebase, or reset --hard to an unpublished oid — is refused; a fast-forward to already-published history (git pull --ff-only, fetch + merge --ff-only, reset --hard origin/main) is not.

Why this hook type: d05ef8ca (and three earlier instances) landed directly on private main via git commit --no-verify then git push --no-verify, which skips pre-commit, commit-msg AND pre-push wholesale — so check-worktree-isolation.sh, check-graph-artifact-branch.sh, check-commit-msg.sh and check-push-target.sh all fire correctly and were verified working, but never RAN. reference-transaction is not one of the hook types --no-verify disables, so it complements those four gates rather than replacing them. Server-side branch protection is not an option here: both the branch-protection and rulesets APIs return HTTP 403 “Upgrade to GitHub Pro” on this private repo.

Installation: .git/hooks/ is untracked, so the tracked script alone is NOT the protection. scripts/hooks/install-main-ref-transaction.sh (called from BOTH scripts/setup.sh and the lighter scripts/setup-hooks.sh, the same two call sites install-cr-pre-push-legacy.sh uses in the pre-push section above) installs a shim that execs the tracked check-main-ref-transaction.sh via bash explicitly (never relying on the target’s own exec bit surviving every checkout/platform). Five review rounds found the installer (and /himmel-doctor check C32, which must stay in sync) re-deriving something by hand that was already solved elsewhere, wrong in a new way each time:

  • Where does git look for a hook? core.hooksPath set makes git stop looking at the common dir at ALL; a RELATIVE core.hooksPath resolves PER WORKTREE (not shared, breaking the common-dir guarantee); git rev-parse --git-common-dir’s own relative output is relative to the invoking directory, not the worktree top. All three close the same way: ask git directly instead of re-deriving its answer — git rev-parse --path-format=absolute --git-path hooks is git’s OWN resolution of “where does a hook file belong,” already correct for core.hooksPath unset, relative, or absolute, from any cwd, per worktree. When core.hooksPath is relative, the installer additionally enumerates every linked worktree (git worktree list --porcelain) and installs into each one’s own resolved location, since a single install would otherwise leave every worktree but one unprotected — with one stated, accepted limit: only worktrees that exist at install time are covered; a worktree created afterwards has no shim until the installer is re-run (a Suggestion the installer documents rather than silently under-covers — see its own header — and the honest fix is operational: re-run install-main-ref- transaction.sh / setup.sh / setup-hooks.sh after git worktree add whenever core.hooksPath is relative). C32 reports on its OWN checkout only; it does not enumerate siblings.
  • How do you embed one string (the target path) in another (the hook file) and read it back safely? Two attempts each broke differently: an eval-ed target=... assignment extracted from the hook file was exploitable outright — the marker gating entry into that code path authenticates NOTHING (a comment, copyable by anyone), so a hook carrying the marker plus a hostile target=$(...) executed arbitrary commands during a supposedly read-only doctor run. Replacing it with a literal # himmel-main-ref-target: <path> comment (read verbatim, never parsed) closed the code-execution class but broke a third way: a checkout path containing a literal embedded newline byte (legal on POSIX filesystems) terminates the comment line early and injects a new line of shell into the file. The fix removes the question rather than patching it a third time: the target now lives in EXACTLY ONE place, git config --local himmel-main-ref.target <path>, set once by the installer — git’s own config subsystem already solves “store an arbitrary byte string, retrieve it exactly” (verified: a value containing $, backticks, ", and a literal embedded newline all round-trip byte-exact), and it is naturally shared across every linked worktree via --local. The shim is now 100% STATIC (identical bytes at every install site, nothing per-checkout interpolated into the file at all) and reads the value back the same way at hook-run time; C32 reads it the exact same way too. Nothing is ever parsed out of the hook FILE’s content any more beyond the plain marker-presence check.

C32 verifies the resolved hook is present-with-marker, executable (git silently ignores a non-executable hook on Unix), and that its configured target genuinely resolves before reporting OK, and otherwise reports missing / foreign (a reference-transaction hook exists but carries no Himmel marker — refused to overwrite) / non-executable / target-vanished / no-target- configured. Every one of these is the same lesson: writing the hook file is not the same fact as git actually running it — a C32 OK proves the marker, the executable bit, and the resolved-target readability, all at the moment it ran; it cannot prove none of those has changed since, and it says nothing about sibling worktrees.

Two deliberate fail-open decisions, so neither gets “fixed” back into a fail-closed that bricks the repo:

  • refs/remotes/origin/main unresolved (a fresh clone mid-setup, a differently-named remote) → allow, with a warning. There is no published main to bypass, so this cannot be this guard’s bug class.
  • The shim’s configured target (git config --local himmel-main-ref.target) stops resolving (the checkout that ran the installer got moved, renamed, or pruned) → allow, with a warning. reference-transaction fires on EVERY ref update in the repo — fetch’s refs/remotes/*, stash, tags, every branch — so refusing on a broken shim would fail every such operation repo-wide, recoverable only by knowing to delete an untracked file under .git/hooks/ that no git diff ever shows.

Escape: MAIN_REF_TRANSACTION_OK=1 as an ordinary PER-COMMAND prefix (MAIN_REF_TRANSACTION_OK=1 git commit --no-verify …) — unlike EDIT_ON_MAIN_OK and every other Claude-side hook bypass, this one does NOT need the launching-shell rule: reference-transaction runs as a direct child of git commit/git push itself, so ordinary environment inheritance already reaches it. Using it appends one line to <git-common-dir>/main-ref-overrides.log (shared by every linked worktree). .single-writer at the repo root opts a repo out entirely (vaults, state repos that commit straight to main by design) — same convention as block-edit-on-main.sh below.

Known residual, stated plainly rather than left for someone to discover: git -c core.hooksPath=/dev/null commit bypasses this hook (and any client-side git hook — an inherent limit of the mechanism, not something closable at this layer).

Spec: scripts/hooks/test-check-main-ref-transaction.sh.

Portable export — .pre-commit-hooks.yaml (HIMMEL-214)

Worktree isolation was structurally enforced only in himmel; other repos the operator works in (notably luna) relied on prose. .pre-commit-hooks.yaml at the repo root exports two gates other repos opt into via pre-commit’s remote-repo mechanism (repo: https://github.com/yotamleo-org/Himmel + pinned rev:):

  • pr-lane-isolation (check-pr-lane-isolation.sh) — path-scoped: blocks commits on main that touch files matched by the CONSUMING repo’s files: regex. Built for two-lane repos like luna (PR lane = structural paths via PR; plugin lane = vault content straight to main). Without a files: filter it matches everything (degrades to full isolation, with offending paths listed).
  • worktree-isolation (check-worktree-isolation.sh) — himmel-grade: blocks EVERY commit on main.

Both are language: script so they run from pre-commit’s clone of himmel and can source scripts/guardrails/lib.sh. Bypass for a deliberate exception: SKIP=<hook-id> git commit … (pre-commit native), i.e. SKIP=pr-lane-isolation or SKIP=worktree-isolation. Consumer snippet + luna’s concrete PR-lane regex: docs/luna/pr-lane-guard.md.

Prompt-stage Hooks (wired in NEITHER lane)

Covers both prompt-stage events: UserPromptSubmit (fires on submit, sees the raw typed text) and UserPromptExpansion (fires when a slash command expands, can block the expansion, and matches on command_name). Neither event is wired in either lane today — the one script written against either ships unwired, deliberately, because .claude/settings.json is operator-only.

Neither .claude/settings.json nor .codex/hooks.json has a UserPromptSubmit key — no hook fires on prompt submit in either lane. improve-on-submit.sh was deleted (HIMMEL-2006) after HIMMEL-708 dropped it from the Claude settings as a no-op (#899) and HIMMEL-1981 dropped the leftover Codex copy, which was costing a pwsh→cmd→bash spawn on every Codex prompt to reach a guaranteed exit 0. (inject-initiative.sh is a real Claude-lane hook, but it fires on SessionStart, not UserPromptSubmit — see below.)

block-pr-check-args.sh — /pr-check no-argument fence (HIMMEL-2306), SHIPPED UNWIRED

The one script in the repo written against a prompt-stage event. It refuses /pr-check carrying any argument, so a stale /pr-check <repo-path> cannot review the CURRENT repo — and clear ITS marker — instead of the one meant. It fires on UserPromptExpansion, not UserPromptSubmit: that event carries command_args as a first-class field, so the hook reads the argument the harness already split out rather than parsing raw prompt text.

CLAUDE LANE ONLY. UserPromptExpansion is a Claude Code harness event. .codex/hooks.json supports only PreToolUse, PostToolUse, SessionStart, SessionEnd and Stop — there is no prompt-stage event in the Codex lane at all, so this hook has nothing to attach to there and /pr-check <path> stays silently ignored under Codex even after the Claude side is wired. The Codex twin says so in its own words rather than inheriting the Claude runbook’s “refused” wording. Closing that half needs a Codex-side event that does not exist today, or a different mechanism; it is tracked as unfinished, not done.

It is INERT until an operator wires it — .claude/settings.json is not agent-writable, and as of this writing neither lane has a UserPromptExpansion key either, so an unwired checkout still silently ignores arguments:

"UserPromptExpansion": [
{ "matcher": "pr-check",
"hooks": [ { "type": "command",
"command": "bash \"$HIMMEL_REPO/scripts/hooks/block-pr-check-args.sh\"" } ] }
]

Anchored on HIMMEL_REPO, deliberately not CLAUDE_PROJECT_DIR. The rest of the hook stack is wired through $CLAUDE_PROJECT_DIR because those hooks guard work happening in himmel. This one does not: /pr-check’s supported foreign-repo lane (HIMMEL-2035) is a session whose cwd IS the repo under review, so there CLAUDE_PROJECT_DIR resolves to the REVIEWED repo — and wiring a security hook through it would either execute a crafted repo’s own scripts/hooks/block-pr-check-args.sh as a hook, or point at a path that does not exist so the guard silently never fires. HIMMEL_REPO arrives from outside any repo (adopt/setup writes it into settings.json env) and a reviewed repo cannot forge it. Same reasoning as HIMMEL-2226 Finding 1’s <himmel_dir> resolution: a guard must not be located by the thing it guards against.

Fails closed (security fence, not a workflow nudge): missing jq, invalid JSON, or any unexpected rc exits 2. Wired with its matcher it only ever runs for /pr-check, so that bounds the blast radius to that one command. Bypass: PR_CHECK_ARGS_OK=1 in the launching shell.

Why a hook and not runbook prose: HIMMEL-2226 tried twice in-prompt and both were rejected on review. Anything that lets the runbook OBSERVE the argument has already brought the untrusted value inside the boundary it is defending — so the refusal has to happen before the prompt is assembled.

Event-schema note for the next hook author (verified against claude.exe 2.1.251, which disagrees with the published docs on two points that each produce a hook that silently never fires): the payload fields are command_args and prompt — there is no command_input and no expanded_prompt; blocking is top-level {"decision":"block"} or exit 2, because hookSpecificOutput.permissionDecision: "deny" is PreToolUse-only; and the matcher compares against the bare command_name (pr-check, no leading slash).

Claude PreToolUse Hooks

16 distinct guardrail scripts are wired across the PreToolUse matchers in .claude/settings.json (counted once each, regardless of how many --chain matchers a shared guardrail appears under) and fire BEFORE Claude executes tool calls: auto-approve-safe-bash.sh, check-cr-marker-on-pr-create.sh, block-jira-compound-write.sh, block-tail-pipe-on-gates.sh, block-read-secrets.sh, block-destructive-commands.sh, block-git-stash.sh, block-rogue-claude-schedule.sh, block-chokepoint-env-prefix.sh, require-quiet-run.sh, block-edit-on-main.sh, guard-memory-capture.sh, orchestrator-inline-guard.sh, block-backend-tier.sh, mcp-policy.sh, and auto-arm-on-cap.sh. Thirteen BLOCK risky operations; one (auto-approve-safe-bash) GRANTS permission for safe ones so they don’t hang; one (auto-arm-on-cap) is a fail-open watchdog that decides nothing; one (mcp-policy) only tracks MCP calls and always allows — see MCP policy tracking. Nine FURTHER guards — block-docker-privesc.sh (HIMMEL-441), block-merged-pr-commit.sh (HIMMEL-512), block-unresolved-cr-merge.sh (HIMMEL-936), block-rogue-codex-exec.sh (HIMMEL-2023), guard-implementor-dispatch.sh (HIMMEL-920), guard-chief-dispatch.sh (HIMMEL-2323), block-glm-external-writes.sh (HIMMEL-654), block-graphify-egress.sh (HIMMEL-621/622), and block-lesson-enforcement-writes.sh (HIMMEL-767) — are shipped via the himmel-ops plugin hooks.json rather than .claude/settings.json (so they can be agent-installed without a settings self-mod veto — same delivery path as inject-minerva-critic.sh, the plugin’s Skill-matcher critic INJECTOR rather than a guard, see its own section below); all nine are live only after /himmel-update (marketplace re-sync) + a fresh session. block-docker-privesc.sh and block-merged-pr-commit.sh fail CLOSED (security boundaries); block-rogue-codex-exec.sh (raw codex exec outside scripts/codex/dispatch-codex-exec.sh — no ACL preflight, no model/sandbox pins, no job registry, and no watchdog, so a wedged raw run is exactly the HIMMEL-1788 invisible hang; bypass CODEX_EXEC_RAW_OK=1) fail CLOSED only for suspicious-token commands when jq is missing or parsing fails; block-unresolved-cr-merge.sh blocks when it can evaluate but fails OPEN on every CR/CI-gate API/dependency error (HIMMEL-936 design) — except the bounded initial PR lookup on a real merge, which fails CLOSED (HIMMEL-3915); guard-implementor-dispatch.sh is a COST guard and fails OPEN (see its own section below); guard-chief-dispatch.sh is a WORKFLOW fence and fails OPEN (see its own section below); block-glm-external-writes.sh fails CLOSED on a detected GLM lane (a missing jq blocks rather than allows — see its own section below); block-lesson-enforcement-writes.sh fails CLOSED only once its own loop seam (HIMMEL_LESSON_LOOP=1) is active (see its own section below); block-graphify-egress.sh delegates its verdict to the shared corpus × provider × purpose policy rather than duplicating it here — see egress-matrix.md.

Bypass convention (applies to every guardrail hook that documents a bypass env var — not all of them do): session-sticky env vars must be set in the shell that LAUNCHED Claude Code (EDIT_ON_MAIN_OK=1 claude). Claude cannot inject env vars into hook processes, so per-call prefix syntax does not work. Bypass lasts until the Claude process exits — restart without the var to re-enable. Alternative: comment the hook stanza in .claude/settings.json. (auto-approve-safe-bash has no bypass — it only ever grants; to disable it, comment its stanza.)

Hook timeout policy + latency SLO (HIMMEL-1985)

Every command hook in .claude/settings.json and in the himmel-ops plugin hooks.json carries an explicit timeout. An unset command-hook timeout inherits Claude Code’s 600 s default, so a wedged hook stalls the tool call for ten minutes — and a PreToolUse command hook that times out fails OPEN, which means the guardrail is silently skipped rather than loudly broken. Same hang class HIMMEL-1982 fixed on the Codex side, pointed at our own stack.

Two tiers, sized from measurement (never guessed — a bound that can fire under normal suite load is worse than 600 s):

TierTimeoutMembership
fast guard / injector15nothing slow runs inside the hook’s own runtime: string analysis, a registry read, at most one py_armor call — or an outbound call that is detach_run-ed and therefore outside the bound
synchronous external call60the hook itself blocks on gh, schtasks, or ≥2 py_armor calls (each capped at PY_ARMOR_TIMEOUT, default 10 s)

Both tiers size a single hook. A --chain entry (HIMMEL-2002) is bounded as a unit, so it takes 60 once it has several members regardless of their tier — the bound has to cover them serially, or the tail of the chain is skipped fail-open. See the dispatcher section below.

The tier follows what the hook waits on, not what it ultimately causes. telegram-notification.sh, telegram-session-end.sh and jira-nudge-on-end.sh all reach the network, but every one of them detach_runs the curl and returns — measured 176–234 ms — so they belong in the fast tier; bounding them at 60 s would protect nothing.

60 s members: block-edit-on-main.sh (2 py_armor calls), auto-arm-on-cap.sh / auto-arm-on-subagent-cap.sh (py_armor + arm-resume.sh → schtasks), trigger-cr-on-pr-create.sh / trigger-cr-on-push.sh (gh api --paginate + gh pr comment), block-merged-pr-commit.sh / block-unresolved-cr-merge.sh (gh-backed branch-shipped.sh / review-merge-gate.sh / ci-green-gate.sh). Hooks that already carried an operator-chosen bound (the 10 s ledger hooks, the 10–30 s SessionStart injectors, the 10 s Stop hook) keep it — all measured with ≥8× headroom. The same convention already lives in wire-hook-bash.mjs, which splices new SessionStart siblings at timeout: 30.

Claude PreToolUse dispatcher (HIMMEL-2002)

Twin of the Codex dispatcher (HIMMEL-1989, harness-compat.md). Claude Code launched one node run-hook-with-bash.js <script> per settings entry, and a Bash tool call matched 13 of them. run-hook-with-bash.js --chain a.sh b.sh … runs the same guardrails, in the same order, from one node process (Bash tool call: 13 matching entries / 11 launcher processes → 4 entries / 2 launchers). What drops is the node launches, not the Git Bash ones — the members stay one shell each by design (no extra wrapper shell), so the per-Git-Bash pool cost is unchanged; only the launcher’s own process count falls.

  • Matchers are disjoint — Bash, PowerShell, Read|Grep, the Edit/Write family, the atlassian MCP matcher. Two matching blocks would re-multiply the launches, so run-hook-with-bash.test.mjs asserts pairwise disjointness over the literal tools they name (mirroring the parity gate’s OVERLAP probe). The cost of disjointness is that a guardrail guarding several tools appears in several chains — wire-hook-bash.mjs’s EXPECTED_SCRIPT_ORDER is therefore the flattened document order, with those repeats, still frozen exactly.

  • Whole-chain validation first. A missing or duplicated member denies outright (exit 2), before any member runs — never a prefix of the chain.

  • First deny wins and short-circuits: a member’s exit 2, or a deny expressed as JSON on exit 0, is emitted verbatim and ends the chain. Safe because every chained hook is a pure check. Side-effecting hooks stay out of every chain, so an earlier deny cannot suppress a side effect that used to happen unconditionally: auto-arm-on-cap.sh on its own * entry, and the PostToolUse trigger-cr-on-pr-create.sh / trigger-cr-on-push.sh pair, deliberately left as two sibling entries. Chaining that pair reads like one more launch saved, but it makes the push-triggered review reachable only if the PR-triggered one does not deny first — don’t. Both post only on a himmel-armed repo (HIMMEL-2034, after the pair commented @coderabbitai review on an upstream PR opened from this machine): the PR’s owner/repo must be this clone’s armed origin (git config --local himmel.coderabbit true, coderabbit-available.sh) or be named in CR_TRIGGER_REPOS (comma-separated owner/repo allowlist). Anything else gets a stderr advisory and no comment — the sanctioned review path for a foreign diff is the hermes/codex critic (scripts/cr/critic-first-pass.sh). The guard sits in cr_trigger_post_review (scripts/lib/coderabbit-trigger-ledger.sh), the one seam both hooks and the forge seam post through.

  • Our stdout stays one JSON object or empty. One JSON emitter passes through byte-for-byte; two or more merge (ask > allow, reasons joined with " | ", additionalContext/systemMessage with newlines, continue:false/stopReason from any member, any other key dropped with a stderr warning). A member’s plain stdout goes to our stderr, and a non-blocking non-zero rc is carried only when no member decided.

  • timeout bounds the WHOLE chain, not each member — a chain is serial, so the entry’s budget is shared by every member. It is therefore sized for the chain, never taken as the max of its members: the members’ 15 s fast-guard bound would become a 15 s budget for all ten of them, and a single slow member could burn it before the later security guards ever ran — a PreToolUse timeout fails OPEN, so those guards would be silently skipped. The multi-member chains sit at 60 for that reason (28× the measured p95, and no added worst-case wall clock: the *-matcher auto-arm-on-cap.sh entry already bounds every tool call at 60 s). See the SLO note below.

  • Each member is bounded too, and the bound is budget-aware — warn-only members share the clock, must-run members get their own window capped by an entry-safe deadline (HIMMEL-3080). The entry timeout alone still lets one hung member spend the whole thing, so the launcher gives every warn-only member the smaller of the 15 s fast-guard bound and what is left of the 50 s whole-chain budget — floored at 500 ms, so a spent budget clamps the tail without starving it to a 0 ms slice (RUN_HOOK_CHAIN_MEMBER_TIMEOUT_MS / RUN_HOOK_CHAIN_BUDGET_MS override the first two). A member that outruns its bound is killed, named on stderr, and skipped — the chain continues, which is what happened before the collapse when Claude Code killed a hung entry and its siblings ran on. Deliberately not a deny: turning a slow warn-only guard into a hard block on the tool call is a worse failure than the one being bounded.

    A MUST_RUN_CHAIN_MEMBERS security guard is different: it must never be silently skipped just because an upstream warn-only neighbour spent the shared budget, so it gets its own full memberTimeoutMs() window regardless of what the shared clock has left. Left uncapped, that exemption is its own hazard — several must-run members (or one that hangs) can collectively outrun the entry’s own timeout, and a killed PreToolUse entry fails OPEN, silently skipping every member that had not yet run, where the launcher itself would have denied. So a must-run member’s window is also capped, by an entry-safe deadline: min(memberTimeoutMs(), entryDeadline − now), where entryDeadline is the entry’s own timeout (RUN_HOOK_CHAIN_ENTRY_TIMEOUT_MS, default 60 s) minus a safety margin (RUN_HOOK_CHAIN_ENTRY_SAFETY_MARGIN_MS, default 5 s) for the launcher’s own start-up and exit overhead. When even the 500 ms floor cannot fit before that deadline, the launcher denies without spawning the member, fail-closed, naming whichever prior member actually spent the shared budget — the same guarantee a real timeout/crash inside the window already gives (HIMMEL-2060 / HIMMEL-3601), just reached one step earlier. That consumer note is scored by how much of each prior member’s run happened before the shared budget’s own deadline, not by raw elapsed time, so a must-run member that ran long inside its own exempt window is never blamed for spend that happened after the shared budget was already gone. This is what keeps the chain inside the entry timeout even under HIMMEL-3080’s must-run exemption: every path — warn-only clamp, must-run’s own window, or the pre-spawn deadline-exhausted deny — ends with the launcher reporting what it dropped or denied rather than being killed mid-chain with the tail skipped silently.

SLO: p95 < 1 s per hook. Claude Code runs matching entries concurrently, so the stack’s cost is roughly the slowest matching entry, not their sum — but a --chain entry runs its own members serially, so that entry’s p95 is their sum (measured 2026-08-22: the 10-member Bash chain 2.1 s p95, the 5-member PowerShell chain 1.3 s, both warn:slo, both far inside their 60 s bound). Measured on OVERLORD8 (2026-08-21, 5–7 runs each, serial): the ledger hooks sit at ~60 ms and the graphify hook-guard binaries at ~150–220 ms, but everything that spawns a shell is dominated by Windows process-start and therefore by whatever else the box is doing — the heaviest guards (block-backend-tier.sh, orchestrator-inline-guard.sh, auto-arm-on-*, guard-memory-capture.sh) and the tree-scanning SessionStart injectors (inject-initiative.sh, inject-where-are-we.sh, graphify-freshness-advisory.sh) measured 0.3–0.9 s p95 on an idle box, 1.1–1.7 s p95 with a critic panel running alongside, and up to 2.2 s p95 with several benchmark and panel processes competing at once. Which hook is slowest changes run to run; the ceiling does not. Under real concurrent load (19 hooks at once) the ticket’s live bench put the whole chain at 595 ms median / 1.25 s worst. That ~7× load spread is the whole argument for 15 s rather than a tight 3–5× p95 bound — it still leaves ≈7× headroom over the worst thing ever observed here, and it is 40× better than the 600 s default.

Per-tool spawn inventory (HIMMEL-2480). Counted from .claude/settings.json alone (plugin chains from marketplace/plugins/himmel-ops and claude-hud add more on top, and this count excludes them):

Toolprocesses / callgraphify hook-guard’s shareafter pricing
Bash311 (3%)30
Edit16016
Read71 (14%)6
Grep71 (14%)7
Glob41 (25%)4

Counting rule: one process for the entry’s top-level interpreter, plus one more when the command routes through run-hook-with-bash.js (the bash -l wrapper then the node runner), plus one per scripts/hooks/*.sh chain member the runner spawns serially. The Bash figure is dominated by the 11-member --chain entry (13 processes) — the graphify guard is ~3% there and irrelevant; it is the only extra spawn on Read and Glob, the highest-frequency tools, which is why pricing targets those.

The gotcha: graphify install re-adds the stock entries (matchers Bash|Grep + Read|Glob, no timeout, absolute exe path) and upstream exposes no matcher/timeout knob. himmel never runs that installer itself, but the operator is told to run it by hand in several places, so re-apply the pricing afterwards with bash scripts/lib/graphify-bin.sh price-hooks (idempotent; also drops the Codex hook-check no-op). Reproduce the counts with node scripts/hooks/bench-hook-stack.mjs for latency and by reading .claude/settings.json for the counts.

The bench spawns hooks through run-hook-with-bash.js’s resolveBash(), the same resolver the wired hooks use: on Windows a bare bash can be the WSL launcher or a 0-byte WindowsApps alias, which would time a different shell than the session actually runs — or hang.

Re-benchmark (report-only, exits 0 — warn-only, not a gate):

Terminal window
node scripts/hooks/bench-hook-stack.mjs --runs 7
node scripts/hooks/bench-hook-stack.mjs --runs 7 --settings marketplace/plugins/himmel-ops/hooks/hooks.json

It replays each configured hook with a representative benign stdin payload (an echo, an empty agent result) — once per matcher alternative, with p50/p95 taken per branch and the worst branch reported, so a hook that branches by tool is sized off its slow path rather than having it averaged away — redirects the write-through hooks at a scratch dir via their own env knobs (HIMMEL_TRUST_LEDGER_DIR, HIMMEL_SESSION_RUNS_LEDGER, AUTO_ARM_BIN=/bin/true, AUTO_ARM_STATE_DIR), and hard-skips the hooks that emit something outbound and irreversible: trigger-cr-on-pr-create.sh / trigger-cr-on-push.sh (their dedup ledger is keyed per head SHA, so a replay burns a real trigger slot) and telegram-notification.sh / telegram-session-end.sh / jira-nudge-on-end.sh (a replay sends real messages). The hook still inherits the caller’s real environment, credentials included — that is deliberate, since a hook stripped of PATH/HOME/git config takes an error path in milliseconds and the measurement is a lie. Keeping SKIP correct is not left to whoever adds the next hook remembering this benchmark exists: the suite scans every wired hook’s source — comments stripped, following one hop into the scripts/lib helpers it names — for outbound-emission markers (api.telegram.org, the session-status.ts relay, cr-trigger-ledger, gh pr|issue comment, a gh api that names a write method or passes REST fields, a gh api graphql carrying a mutation, a curl with -X POST/--data) and fails if one is missing from SKIP. Reads are deliberately not markers — replaying a gh api GET is harmless, and gh api graphql -f query=… is a read despite the -f (that is simply how GraphQL variables are passed; review-merge-gate.sh’s reviewThreads query false-positived on it) — and neither is bare gh pr create, because this repo’s hooks guard that command rather than run it, so the string lives in guard prose and heredoc advisories (both check-cr-marker-on-pr-create.sh and inject-initiative.sh false-positived on it). It is a tripwire for the shapes that exist, not a proof of no egress; extend the list when a new client shows up.

Read the STATUS column: warn:failed means a run spawn-failed, took a signal or exited anything other than 0 (continue) / 2 (block) — exit 1 counts as a failure here, because that is where a missing jq or a script bug lands, in a few ms, and it would otherwise read as the fastest hook in the stack; warn:no-timeout means a hook is back on the 600 s default; warn:tight means its bound is under 3× its own p95; warn:slo means it is over the 1 s per-hook SLO. slowest_hook_p95_ms in the SUMMARY is the max across per-hook p95s, not a percentile across them — the runner fires matching hooks concurrently, so the slowest matching hook is what the session waits on. Re-run after adding a hook, or when the harness feels sluggish, and update the tiers above if the shape moved.

The invariant is structurally pinned, not prose-enforced: scripts/hooks/bench-hook-stack.test.mjs asserts that no command hook in either config is left without a timeout and that every bound sits in the policy band — and the pre-commit gate at .pre-commit-config.yaml (scripts/hooks + scripts/lib node suites, HIMMEL-1578) already fires on edits to both files.

run-node.sh / resolve-node.sh — dash silent-no-op class (HIMMEL-2692 / HIMMEL-2741 / HIMMEL-2758)

Every chained hook above is launched through scripts/lib/run-node.sh, which Claude Code runs under /bin/sh — dash on Debian/Ubuntu — so a bash-only expansion anywhere in that call chain aborts the file with dash’s “Bad substitution” and takes the FAIL-OPEN path: exit 0, nothing on stdout/stderr, no guardrail fires. HIMMEL-2692 fixed the bashism in run-node.sh itself; HIMMEL-2741 found the same class one file downstream (resolve-node.sh, which run-node.sh sources) — invisible to both the literal-bashism grep (scoped to the wrong file) and the busybox-based control (busybox ash implements the bashism dash doesn’t). scripts/lib/test-run-node.sh now checks every file the two run-node.sh copies source, not just the launchers themselves, and prefers real dash over busybox for the non-bash-POSIX-shell control.

HIMMEL-2758 is the third member of the class and the first where the invocation convention, not a bashism inside the chain, was the defect. Every hook command was emitted as . "$…/run-node.sh" "$…/run-hook-with-bash.js" <args>. POSIX leaves . with operands beyond the filename unspecified, and dash drops them:

dash -c '. probe.sh a b' -> $# = 0
dash -c 'sh probe.sh a b' -> $# = 2
bash -c '. probe.sh a b' -> $# = 2

so on Debian/Ubuntu run-node.sh reached its exec "$_node" "$@" with an empty "$@" and ran a bare node. Handed the hook’s own JSON payload on stdin that node parses it as JavaScript and dies with a SyntaxError at rc=1; handed nothing it exits 0. Claude Code treats a PreToolUse rc=1 as a non-blocking hook error, so under either reading the tool call PROCEEDS — the write fence, the destructive-command block, the secret-read block and the review-owed marker gate were not merely quiet, they were OPEN. Neither reading is ever rc=2, so it was never a DENY.

The fix has three parts. Both wirers (scripts/hooks/wire-hook-bash.mjs for the 14 .claude/settings.json entries, scripts/hooks/wire-plugin-hook-bash.mjs for the 21 marketplace/plugins/himmel-ops/hooks/hooks.json entries) emit command -p sh "…/run-node.sh" … and recognise both superseded shapes as legacy forms, so existing entries MIGRATE on the next run rather than being refused as foreign. run-node.sh (both byte-identical copies) refuses an empty "$@" with one stderr line and rc=2 — a visible DENY — instead of exec’ing a payload-less node. And scripts/lib/test-run-node.sh gained three cases: the zero-argument refusal under both bash and dash, an assertion that no live hook command carries either superseded launcher, and the hostile-PATH case below. .codex/hooks.json does not reference run-node.sh, so the Codex lane needed no twin change.

sh rather than bash because HIMMEL-1516 banned the bare bash token from this inventory over Windows PATH roulette (the WSL stub, the 0-byte Store alias, the “Select an app to open ‘bash’” modal). sh is not that class: Claude Code already runs the whole hook command through sh -c, so sh is exactly as resolvable as the command line carrying it, and there is no System32 sh.exe decoy.

command -p sh, not a bare sh. The first cut of this fix wired a bare sh, and that traded one silent failure for another. Replacing the . BUILTIN with an external launcher introduces a PATH lookup the dot form never needed, and a hook shell’s PATH is not ours to assume — the pinned-minimal-PATH GUI/agent launch is the exact environment resolve-node.sh exists for. Measured on a bash-/bin/sh station with PATH=/nonexistent, outer shell started by absolute path:

bash -c '. "$RUN_NODE" --version' -> rc=0 v24.20.0
bash -c 'sh "$RUN_NODE" --version' -> rc=127 sh: command not found
bash -c 'command -p sh "$RUN_NODE" --version' -> rc=0 v24.20.0

The dot form survived because resolve_node() step 3 probes absolute well-known locations (/usr/bin, homebrew, …) and never consults PATH for node at all — so a bare-sh wiring would have swapped a dash-only silent no-op for a restricted-PATH dead chain, the same fail-the-wrong-way class this ticket exists to close. The else node … branch cannot catch it: that branch is reached only when run-node.sh is MISSING, and a bare node is equally unresolvable there. command -p is the POSIX “search the standard utilities PATH” form, so the launcher resolves from the system default whatever PATH was inherited. test-run-node.sh pins both PATH shapes (unset — where exec’s confstr fallback to /bin:/usr/bin means even a bare sh would have worked, which is why that case alone did not catch this — and set-but-useless), with a negative control asserting the superseded bare sh still dies rc=127 there.

The same commit dropped run-node.sh’s $(dirname "$0") fork for the builtin ${0%/*}: under that same hostile PATH the fork failed with dirname: command not found on stderr and silently demoted the direct-execution lane. A hook launcher must not depend on any external utility resolving.

Busybox ash is not a control for this class either — it implements dot-with-operands, exactly as it implements the ${var//pat/repl} expansion of HIMMEL-2741. Only real dash reproduces both. (It IS a useful control for the launcher’s portability, which is a different question: test-run-node.sh runs the wired shape under dash, busybox ash and zsh --emulate sh, and command -p resolves under all three.)

SessionStart chaining — --chain --lifecycle (HIMMEL-2003)

The dispatcher’s SessionStart half. Block 1’s four warn-only injectors (check-update-available, inject-initiative, qmd-staleness-notice, graphify-freshness-advisory) each ran as its own node launch; --lifecycle (which requires --chain) collapses them to one entry, one launcher. SessionStart hooks are warn-only — Claude injects plain stdout as context and there is no permission gate — so the PreToolUse combining rules above are all wrong here. Instead: member stdout is concatenated (newline-joined, document order), member stderr is forwarded, and the chain always exits 0, so one broken warn-only hook can never silence its siblings or colour a session’s startup. Whole-chain validation still runs first and still fails closed, so a stale checkout is loud rather than silently degraded. timeout becomes a single 60 s budget for the whole chain instead of four per-hook bounds — 60 s because every --chain entry must outlive the launcher’s own worst case (the 50 s RUN_HOOK_CHAIN_BUDGET_MS plus members x the 500 ms per-member floor), or Claude Code SIGKILLs the launcher while it still believes it has time. Asserted in run-hook-with-bash.test.mjs.

The tradeoff is real. As four entries the injectors ran concurrently (p95 456 / 1301 / 938 / 1691 ms ⇒ ≈1.7 s wall); serial in one launcher makes it their sum, ≈4.9 s. Bought: three fewer node launches at every session start. Live smoke (OVERLORD8, 2026-08-22) measured 4.2 s wall, rc 0, empty stderr.

auto-approve-safe-bash.sh — pre-Bash auto-approve gateway (HIMMEL-203)

Fires on Bash. Returns a permissionDecision:"allow" for read-only / inspection commands so they run without a prompt — INCLUDING ones wrapped in loops/pipes with $var expansion, which Claude Code’s native matcher refuses to match against the allow-list (“Contains simple_expansion”) and which are therefore DENIED at rc=0 in headless/auto — the prompt cannot render, so it resolves as a silent no-op rather than a hang (HIMMEL-1969 probes, 2026-08-22). The matcher bails BEFORE reading the allow-list, so widening allow rules cannot fix this; a hook that reads the literal command and decides itself is the structural fix (HIMMEL-195 escalation: instructional command-shape rule → structural hook).

Inverted contract vs the block- hooks:*

  • NEVER blocks, NEVER denies. Worst case it stays silent → command falls through to the normal permission flow. It FAILS OPEN (missing jq, unparseable input, anything-not-provably-safe → silent exit 0).
  • Only ever EMITS “allow”. The destructive deny-list + the block-* deny hooks remain the hard backstop: per CC docs a deny rule and an exit-2 hook WIN over a hook “allow”, so approving cat */grep * here cannot defeat block-read-secrets (it exits 2 on a secret read; that wins).

Auto-approved iff ALL hold: no command/process substitution ($( ` <( >(); no interpreter shell-out tell (system( popen( exec(); no output redirect to a real file (only >/dev/null and fd-dups tolerated); and every sub-command (split on | && || ;) resolves — after skipping shell keywords, redirects, and the innocuous leading assignments LANG/LANGUAGE/LC_*/TZ (plus a bare literal JIRA_PROJECT_KEY=<KEY>, honoured only when the binary is the Jira CLI’s node, HIMMEL-4780; any other VAR= prefix falls through) — to a binary in the read-only safe set, or git/gh read-subcommand, or the dogfooded Jira CLI (node …/scripts/jira/dist/index.js …). Variable expansion in ARGUMENTS is fine (binary is still a literal); a variable AS the binary ($cmd …) falls through. Writes, interpreters (awk/sed shell-out), and git/gh write subcommands deliberately fall through — with ONE exception: a git push --force-with-lease on a NON-main branch is granted (HIMMEL-212), since the blanket git push --force* deny used to block even the safe lease form. Bare git push --force / -f, and any lease push that names main as the target or runs from the main branch, are NOT granted (they stay deny-listed / fall through; check-no-force-push.sh hard-refuses force-to-main as the ref-level backstop). block-destructive-commands.sh now carries the matching branch-aware carve-out on the deny side (HIMMEL-2054) — before that fix the deny hook refused every --force-with-lease shape unconditionally, and deny/exit-2 wins over this allow, so the grant described above was unreachable in practice.

Second exception — one queue-lock.sh lock verb (HIMMEL-3131, HIMMEL-3192): [HANDOVER_DIR=<root>] bash [<repo>/]scripts/handover/queue-lock.sh acquire <doc> | release|heartbeat <doc> [<token>] | status <doc> | status --sweep [<dir>] is granted, because bash is not a safe binary and a leading HANDOVER_DIR= is not an innocuous assignment, so the shape used to fall through to the auto-mode classifier — which, having read a just-landed merge in the narrative, denied release as a merge without review. Guards:

  • The WHOLE command only. Never one segment of a compound: the exception is not wired into the per-segment safe check, and ANY unquoted separator in the command (; | & newline — including a trailing &, ;, &&, || or | that leaves an empty tail) disqualifies it. HANDOVER_DIR is the only env prefix accepted, so QUEUE_LOCK_FORCE_RELEASE=1 (a chief action) can never ride along.
  • Every value literal: no expansion, glob or ..; the doc is an absolute .md, and — when HANDOVER_DIR is given — contained under it. A HANDOVER_DIR that is / or a bare drive root (/c, C:/; trailing slashes and . components included) falls through (HIMMEL-3198): it would turn containment off or span a whole drive.
  • The script resolves into a real checkout of this repo (the checkout the hook lives in or one of its git worktree list siblings); a lookalike …/scripts/handover/queue-lock.sh falls through. The relative form is judged against an absolute payload cwd only — never the hook’s own $PWD; no or a relative payload cwd falls through (HIMMEL-3494).
  • The script word is relative or /-rooted — never a drive letter, \ or ' (HIMMEL-3494). On POSIX, C:\x\… or C:/x/… would be checked as one path but run as a cwd-relative file of that literal name, so any such spelling falls through, as does a drive-letter payload cwd. Windows Git Bash therefore always gets a prompt for this shape, like the impacted-suites.sh exception below.
  • Windows Git Bash drive-letter paths (HIMMEL-3192). The three non-executed path positions (the HANDOVER_DIR value, the --sweep dir, the doc) go through ONE helper (ql_abs_path) accepting /x, /c/x and C:/x (C:\x when quoted — an unquoted backslash is eaten by bash before the path is seen, so it never qualifies). Every containment comparison first normalises BOTH sides to /<lower-case drive>/…, so C:/x, c:/x and /c/x are equal and a drive spelling cannot dodge containment (a mixed spelling of two different directories still falls through). Drive-relative C:x, slash-form UNC (//host/share) and a /-prefixed path carrying a backslash are not accepted. Linux-only tested — the cases simulate the spelling; Git Bash itself was not run.

Third exception — /pr-check step 3.6’s impacted-suites.sh literals (HIMMEL-3486): bash <path> <40hex>..<40hex> and bash <path> --check <40hex>..<40hex> <<'IMPACTED_EOF' followed by zero or more SUITE … lines and a closing IMPACTED_EOF are granted. This required gate step used to fall through to the classifier, which sometimes denied it as Out-of-Place Publication and so parked the worker. Guards:

  • The WHOLE command only, parsed off the raw command. Line 1 has no control character, operator, expansion or glob. The heredoc form needs --check and the quoted delimiter. Every body line starts with SUITE . The command ends at the first closing IMPACTED_EOF, and nothing may follow it. The no-heredoc form is a single line.
  • The path is scripts/cr/impacted-suites.sh relative to an absolute payload cwd (never the hook’s own $PWD), or a /-rooted …/scripts/cr/impacted-suites.sh, bare or double-quoted. Either way it must resolve at a worktree root of the HIMMEL_REPO checkout. A backslash, a single quote, a drive letter or any CR refuses it. On POSIX, C:\x\… or C:/x/… would be checked as one path but run as a cwd-relative file of that literal name. So Windows Git Bash always gets a prompt.
  • The bytes are the anchor’s (the HIMMEL-3383 precedent). The file is regular, not a symlink. It hashes unfiltered to the same blob as both the anchor’s working-tree copy and refs/heads/main’s, and the anchor is on refs/heads/main. The script sources nothing, so that one file is every byte it runs. The check happens at match time only (TOCTOU, as in guard-pr-check-literal.sh). The docs-audit lane’s origin/main..<head> range is not accepted.

In practice, spell the --check submission with the /-rooted anchor path (bash "<himmel_dir>/scripts/cr/impacted-suites.sh" --check … <<'IMPACTED_EOF'), never the bare relative one (HIMMEL-3587). This grant sits in auto-approve-safe-bash.sh and covers both spellings, but guard-pr-check-literal.sh runs in the same PreToolUse chain and denies the relative spelling of the heredoc form at its own “one simple command” check — a heredoc carries a < and a newline, so bash scripts/cr/impacted-suites.sh --check <sha>..<sha> <<'IMPACTED_EOF' … is refused there before this grant is ever reached, parking the worker. The no-heredoc listing command (bash <path> <40hex>..<40hex>) has no heredoc and is not affected either way.

It fails toward a prompt, never toward approval. Spec: scripts/hooks/test-auto-approve-safe-bash.sh.

check-cr-marker-on-pr-create.sh — review-owed marker-pending pre-PR-create guard

Fires on Bash. Blocks gh pr create — including one hidden inside $(…) or backticks, matched only at command position (start of string, or right after a ;, &, |, backtick, or $( / () — while a review-owed marker file exists for the branch. This is a plain regex scan over the extracted command string, not a real shell parse: it correctly skips a plain string literal or comment (echo "gh pr create docs", # TODO: gh pr create), but a quoted argument that itself contains one of those separator characters immediately before the literal text — e.g. grep -E 'x|gh pr create' file — CAN still be false-blocked, since the regex has no notion of quoting. The marker (<git-common-dir>/cr-pending/<branch>) is written by check-cr-before-push.sh on push and cleared through scripts/cr/clear-cr-marker.sh when /pr-check runs clean. For an existing PR, that sanctioned script accepts only check-ci.sh exit 0. CodeRabbit’s own signal is not part of that verdict (HIMMEL-3360): a stale, absent, skipped or rate-limited App review prints a NOTE, and the gate is CI green + zero unresolved threads. The stale-anchor exit 4 and the HIMMEL-2162 “panel carry” that cleared it from an exact-head critic-panel row (carry=freshness-panel in <git-common-dir>/clear-cr-marker.log) are retired with it, as the interim CHECK_CI_FRESHNESS_CARRY_HIGH_RISK knob was before them. Repo resolution is cwd-first (HIMMEL-2035). The hook resolves the repo whose marker it checks in this order: the payload’s .tool_input.cwd, else .cwd, accepted only when the path exists and is inside a git work tree; else $CLAUDE_PROJECT_DIR under the same two checks; else warn + exit 0. Before 2035 it read $CLAUDE_PROJECT_DIR unconditionally, which was wrong in two directions at once — a gh pr create in a foreign clone was un-gated, and a himmel branch of the same name false-blocked it. Branch resolution is unchanged: --head/-H wins (a gh pr create invoked from a worktree targets a branch other than the resolved repo’s own current branch — HIMMEL-213), else the resolved repo’s current branch. Behaviour change for himmel’s own branches: a PR created from a worktree without --head now resolves that worktree’s branch, so a real pending marker there now blocks where it previously sailed through as an accepted missed block. That is a correct tightening — the marker exists, so the block is true.

If the command carries --repo <owner>/<name> naming a different repo than the resolved repo’s origin, the hook emits one stderr advisory naming both nwos and still checks the resolved repo’s marker. The source branch and its review evidence live in that local repo even when the PR targets an upstream repo. An absent or unparseable origin likewise only affects the advisory, never the marker decision. A marker whose recorded SHA no longer matches HEAD blocks as “stale marker — re-review needed” rather than silently passing. Fails OPEN on any parse/lookup failure (missing jq/git, no branch resolved, unreadable marker) — a missed block is cheaper than bricking gh pr create on this hook’s own bug. One deliberate exception (HIMMEL-2720): once a marker is present for the resolved branch, an unreadable HEAD fails CLOSED — a hook that cannot read HEAD must not let a pending-CR PR through. Spec: scripts/hooks/test-check-cr-marker-on-pr-create.sh.

CR gate outside the himmel checkout (adopters + upstream PRs) — HIMMEL-2035

A branch in any git repo an operator works in from a himmel session gets the same structural CR evidence as a himmel branch. The whole design is one line:

cwd selects the repo; the resolved himmel checkout selects the himmel scripts.

$CLAUDE_PROJECT_DIR is not that mechanism: it is unset in a Bash-tool shell (Claude Code injects it into hook processes only, not into commands a session or a runbook fence runs), and the worktree-isolation guard refuses outright any command that references it (HIMMEL-2035, HIMMEL-2226).

The ledger (ledger-append.sh), the marker write (check-cr-before-push.sh), the clear (clear-cr-marker.sh) and the pending audit (cr-pending-audit.sh) all already resolve their paths from git rev-parse --git-common-dir of the process cwd, so they land in the reviewed repo with no flag. No --repo flag was added to any of them — it would be a second resolution source for a datum git already answers. Regression fences: the foreign-repo cases in scripts/cr/test-ledger-append.sh, test-clear-cr-marker.sh and test-cr-pending-audit.sh.

The one carve-out: .env is himmel’s, not the target’s. The principle governs artifacts, never policy config. load_dotenv’s default root also follows cwd, so making cwd mobile would have made the gate read the adopter’s .env — silently dropping a CR_REQUIRE_CROSS_MODEL=true the operator set to harden exactly these reviews, a fail-open introduced by the fix. Every cwd-mobile load_dotenv therefore pins --root "$(_load_dotenv_primary_for <himmel-root>)". The wrapper is mandatory and a bare --root "$SCRIPT_DIR/../.." (the script’s own BASH_SOURCE-derived root) is a trap: --root is a silent no-op when <dir>/.env is missing, and the gitignored .env exists only in the primary checkout — never in a linked worktree, where all feature work happens, and where the script itself can just as easily be running from.

Arming a repo (lean-invoke; nothing auto-installs into a repo you did not ask us to touch):

Run from the himmel primary checkout — the gate path below is baked in at install time and must name the primary checkout, never a worktree:

Terminal window
bash scripts/cr/install-cr-gate.sh --target <path> [--remove] [--status] [--force]

It writes the target’s real pre-push slot (not pre-push.legacy, which only a pre-commit install chains) in the target’s effective hooks dir (core.hooksPath if set, else <git-common-dir>/hooks), carrying the owner marker # himmel-cr-gate-v1 and himmel’s gate path baked in at install time. The baked path names himmel’s primary checkout, never a worktree — a worktree path would brick every adopter’s push gate the day /clean_garden prunes it, fail-closed but invisibly, in their repo. Idempotent (installed / already-current); refuses (exit 2, nothing written) on a non-work-tree, on a foreign pre-push without --force, and on the himmel checkout itself. --status reports installed/absent/foreign/stale (0/0/1/1); stale means the marker is there but the baked path no longer resolves — the remedy is re-running the installer, and the hook itself prints it. The installer never runs git config on the target.

Reviewing: /pr-check takes no argument, ever (HIMMEL-2226, operator ruling 2026-08-31) — cwd selects the repo under review, so reviewing another repo means starting a session whose cwd IS that repo and running a bare /pr-check there. When the cwd is not a himmel checkout, the runbook’s 0a-adopter step resolves the himmel scripts from the HIMMEL_REPO anchor — wired into settings.json env from a source OUTSIDE the repo under review — never from the reviewed repo’s own files. The baked # himmel-cr-gate-path: line the installer above writes into the target’s pre-push hook is not a resolution source: a crafted repo could bake any path it likes into its own hook, so trusting it back would be arbitrary code execution the moment 0b runs a script from the resolved root. pr-check-context.sh then prints one repo=… branch=… base=… head=… line so the transcript records which repo the review certifies, and resolves the base from origin/HEAD in that repo. scripts/cr/test-pr-check-context.sh T70 exercises a foreign tmp clone whose remote default is trunk, despite local main and master decoys, and verifies that the base-skew counts also follow origin/trunk. CodeRabbit runs only on an armed repo — the same predicate HIMMEL-2034 scoped the trigger hooks with (cr_trigger_repo_armed: git config --local himmel.coderabbit true on a clone whose origin is that nwo, or the nwo named in CR_TRIGGER_REPOS). The /pr-check runbook posts no CodeRabbit request (it only reads the App’s commit status, as a note); the one thing that posts @coderabbitai is the trigger hook, and on an unarmed repo it prints CR_TRIGGER_FOREIGN_ADVISORY (foreign repo — not triggering CodeRabbit; …) and exits 0 — an advisory, never a failure. No coderabbit=unarmed token is emitted anywhere. The critic panel carries the gate. scripts/cr/test-pr-check-pair.sh check (iii) pins the predicate and its call site.

Host scope: GitHub only. clear-cr-marker.sh’s post-PR gate requires gh and treats an unreadable PR state as fail-closed, so on a Bitbucket/GitLab/ self-hosted origin gates 1–4 pass and the marker can never clear — worse than no gate. The installer warns (never refuses) on a non-github.com origin so an adopter learns at install time, not at their first blocked PR. Widening this means a separate ticket against gate 5, not weakening it.

When core.hooksPath points outside the repo. The installer writes to git’s own effective hooks dir (rev-parse --git-path hooks, which honours every config scope and expands ~) — but only when that dir lives inside the target repo. When it does not, at any scope (a global/system core.hooksPath, or a repo-local one naming a shared or home directory), the gate is installed into the repo’s own .git/hooks regardless: writing into a directory other repos share would arm repos the operator never named and collide with whatever already owns that slot. The installer then reports which of two situations it is in, because they differ completely:

  • the shared pre-push chains to the repo hook (tokensave’s chain-repo-hook shim was exactly this, before tokensave was dropped — HIMMEL-2581) → the gate fires; informational note.
  • it does not chain → the gate is installed but inert, and the warning names both remedies the machine’s owner runs themselves: git -C <target> config --local core.hooksPath .git/hooks, or adding a chain stanza to the shared hook. install-cr-gate.sh never edits git config or a machine-wide hook on anyone’s behalf.

Fork-to-upstream PRs remain gated. When gh pr create --repo other/thing targets a repo other than the resolved repo’s origin, the hook advises and continues checking the local source branch’s marker. A pending marker blocks; no marker allows. scripts/hooks/test-check-cr-marker-on-pr-create.sh Test 14 covers matching and divergent origins with and without a marker, plus a missing-origin case with a marker.

Known limitations:

  • The compound-cd shape — cd /foreign && gh pr create in one Bash call may carry the pre-cd cwd in the payload, leaving that PR un-gated. The marker still exists and cr-pending-audit.sh still reports it. Parsing cd out of the command would be a tokenizer, which is explicitly out of this hook’s scope. /pr-check itself no longer carries this exposure at all — HIMMEL-2226 deleted its <repo-path> argument and the cd that argument fed, so there is no compound cd for anything to parse out of it: it takes step 0’s cwd as-is (git rev-parse --show-toplevel of the process’s actual working directory, not a text-parsed command line), which is unambiguous by construction. Only a bare manual gh pr create remains exposed to this limitation.
  • A throwaway clone’s evidence dies with the clone — the ledger and clear-cr-marker.log live in the reviewed repo. If durable evidence is needed for an upstream PR, put the audit line in the handover.

Spec: scripts/cr/test-install-cr-gate.sh, scripts/cr/test-pr-check-pair.sh.

CR ledger amends — only a signed adjudication lowers a finding (HIMMEL-6511)

The ledger is an append-only JSONL file, so anything that can append a line could once append {"kind":"amend","set":{"verdict":"disproved"}} and clear a Critical or Important finding. Now a lowering amend counts only when it is signed. An amend is lowering when its set does any of these:

  • names a severity other than crit;
  • sets the verdict to disproved, fixed or deferred;
  • carries a deferred_to;
  • moves the finding to another head.

Who signs. ledger-append.sh is the only amend writer, and it signs every amend it writes. It stamps src: "ledger-append" and amend_mac, an HMAC-SHA256 under the GO key (~/.config/himmel/go-hmac.key, domain himmel-amend-v1). The mac covers the branch, the target head, the finding id, the artifact, the perspective, the sorted set and src. So a mac copied onto another finding, head or branch does not verify.

Who checks. Each gate reader runs its amend rows through scripts/cr/ledger-amend-auth.js before merging them:

  • clear-cr-marker.sh;
  • review-round.sh (ledger query, promote, and the defer/cap analysis);
  • the chief’s cr-disposition.sh;
  • the merge-gate lib scripts/lib/review-ledger-evidence.sh.

The helper drops a lowering amend that fails to verify, so the finding stays open. Amends that do not lower a finding (agreed, crit, a reason) pass unsigned. If the helper is missing, the reader fails closed.

Cutover. The cutover is the file position of the first amend in that ledger that carries an amend_mac. Rows above it were written before signing existed and are honoured unsigned, so in-flight work is not re-blocked. Below it, every lowering amend must verify. With no GO key on the host (CI, a fresh station), nothing is signed and every amend is honoured, as before.

  • Ceiling. The signer shares the operator’s uid. Any process running as that uid can read the key, or call ledger-append.sh amend itself. The check therefore stops a hand-appended or tool-forged line, not a same-uid writer that goes through the sanctioned path. The upgrade is a separate-uid signer that only a judge or the chief can drive (HIMMEL-6772).

Spec: scripts/cr/test-ledger-amend-auth.sh, plus the HIMMEL-6511 rows in test-clear-cr-marker.sh, test-pr-check-rounds.sh and chief-kit/test-cr-disposition.sh.

block-jira-compound-write.sh — jira write-shape bounce (HIMMEL-1077)

Fires on Bash. Jira CLI writes are sanctioned (HIMMEL-205), but approval is decided on command SHAPE: wrap a sanctioned create in $(…), a heredoc, or chain it with a segment the gateway above cannot vet, and the gateway stays silent — the write then falls through to the auto-mode classifier, which judges it cold and denies it as “[External System Writes] unrequested publishing”. Incident (2026-07-16): two sanctioned creates denied inside a compound chain; the identical creates as literal single commands auto-approved seconds later. Root cause = shape, not permission (HIMMEL-203) — so per HIMMEL-195 the fix is structural, not prose.

This hook turns that opaque denial into a deterministic bounce naming the ONE sanctioned retry shape: reissue the SAME verb with the SAME arguments as one literal command, moving any inline BODY to a file written with the Write tool (verbs that carry no body — transition, assign, watch, … — just get reissued literally). Naming exactly one shape is deliberate — the same incident had paced retries across shapes read as “[Auto Mode Bypass]” tool-shopping.

The example names the verb the agent ACTUALLY used, never a substitute: printing a create example for a blocked assign invites the retry to file a ticket instead of assigning. Arguments stay “same as before” apart from what the shape requires (--desc-file for create, --comment-file for comment, a positional status for transition, no <TICKET> for project-create) — even spelling out --type Task would tell a --type Bug retry to file the wrong type.

The named CLI path is the one INVOKED when that is absolute (another checkout may front a different Jira, so its write must not be redirected here); only a RELATIVE path resolves to this primary checkout via git-common-dir, because dist/ is untracked and the relative form dies MODULE_NOT_FOUND from a linked worktree — a bounce whose “do exactly this” command cannot run would provoke the very retry-across-shapes it exists to stop.

Blocks iff BOTH hold: the command invokes the Jira CLI with a WRITE verb (create|comment|transition|edit|link|assign|move|watch|unwatch|attach| project-create|sprint|worklog add — the first non-flag token after the CLI, which must be the script node actually runs, in a simple command’s command position, matched against a quote-masked copy so a verb inside quoted DATA never counts; the list is static rather than introspected because this hook fires on EVERY Bash call — a missing verb only forfeits the guidance, it can never wrongly block), AND auto-approve-safe-bash.sh — consulted as a subprocess, so there is ONE safety model and no forked copy to drift — declines to approve it. Whatever the gateway approves runs untouched: literal single writes (bare or cd-prefixed), and chains whose every segment the gateway vets, are never bounced. Read verbs are never bounced. Fails OPEN (missing jq, unparseable input, a gateway that errors) — a guard that only improves a denial message must never itself brick a write.

Only PROVABLY UNCONDITIONAL writes are in scope — the rule the whole design turns on. The retry guidance is unconditional, so it must never be handed to a write that might not run: anything after &&/||, inside an if/loop/case body, or in a function definition is left alone, as are shapes the flat scanner cannot justify a verdict on (multiple heredocs on a line, unparseable delimiters, whitespace CLI paths, quoted verbs). Every such gap forfeits GUIDANCE only — the write keeps its pre-HIMMEL-1077 classifier denial. What it must never do is bounce a command that writes nothing, because the “do exactly this” message would then order a mutation the command never made. Data that merely LOOKS like a write is masked accordingly: quoted spans, comments, quoted-delimiter heredoc bodies, array elements, arithmetic, and ${…} expansions — while a top-level $(…)/backtick substitution, which really does run, stays visible. The exception is a substitution nested inside a ${…} expansion (${x:-$(cmd)}): the flat scanner masks the whole expansion, so that $(…) is masked too — a documented MISS (guidance forfeited on that shape), never a wrong block. Bypass: JIRA_COMPOUND_WRITE_OK=1 claude (launching shell). Spec: scripts/hooks/test-block-jira-compound-write.sh (100 checks).

block-tail-pipe-on-gates.sh — exit-code-swallowing pipeline deny (HIMMEL-1696)

Fires on Bash. Denies a pipeline whose FIRST stage INVOKES an exit-code-critical himmel gate (clear-cr-marker.sh, run-shell-tests.sh, check-ci.sh, merge-on-green.sh, pr-merge.sh, scripts/*/test-*.sh) and whose LAST stage is tail/head — where $? is tail’s status, not the gate’s. A gate orchestrator recorded CLEAR_RC=0 when clear-cr-marker.sh had exited 16, so it believed the CR gate had cleared when it had not. Invocation is decided at COMMAND POSITION (leading VAR= assignments and a bounded launcher set are stepped over), so grep -n x scripts/check-ci.sh | tail — a READ of the gate script — passes. Intermediate stages are irrelevant; line continuations are folded first; the correct shape it prescribes is <cmd> > <file> 2>&1; echo "RC=$?"; tail -80 <file> (it also names ${PIPESTATUS[0]}). Fails OPEN on anything unevaluable. Bypass: a same-line # tail-pipe-ok: <reason> marker — not an env prefix, which block-chokepoint-env-prefix.sh would itself deny. HIMMEL-2082 extends it to the trailing-segment shape: a gate followed by a later ;/&&/||, newline or & segment anywhere in the command (check-ci.sh 12; echo done) is denied too — a gate must be the final segment so the task exit status is the gate’s. Allowed: gate last, gate then only a redirect, the next segment being an echo/printf/exit that reads $? or ${PIPESTATUS[, later gates joined by &&, gate || exit 1, and a gate used as an if/while condition. A gate inside a bash -c/eval payload is not covered yet (HIMMEL-5105). Spec: scripts/hooks/test-block-tail-pipe-on-gates.sh.

block-edit-on-main.sh — pre-edit guard

Fires on Edit/Write/MultiEdit/NotebookEdit. Refuses any edit targeting the primary checkout of a repo — forcing all feature work into a worktree. Two block cases: (1) the repo is on main/master (the original case), and (2) HIMMEL-507 — the repo is on a feature branch but the edit lands in the primary checkout rather than a linked worktree. The structural signal for “primary checkout vs worktree” is the .git entry: a normal checkout has a .git directory, a linked worktree (and a submodule) has a .git file. So an edit inside a .claude/worktrees/… linked worktree on a feature branch is always allowed — that is where feature work belongs — while the same feature branch checked out in the primary tree is blocked. This closes the gap where an autonomous session did feature work on a PR branch in the primary checkout instead of isolating it in a worktree. Paths are canonicalised via realpath -m first, so .. traversal and symlink tricks cannot bypass. handovers/** is exempt. Bypass: EDIT_ON_MAIN_OK=1 (covers both block cases). Per-repo opt-out: place a local .single-writer file at the repo root (gitignored via global excludes — never committed, so clones stay protected by default); the hook then allows edits in that repo (both cases) and skips the block entirely. Anchored to the edited file’s repo (repo_real), so a marker in a parent repo cannot leak the opt-out onto a nested repo.

Dependencies: jq plus either GNU realpath -m (Linux native; Git Bash on Windows includes it) or python3 (macOS default — uses pathlib.Path.resolve(strict=False) for cross-platform canonicalisation). macOS operators who want unprefixed realpath: brew install coreutils && export PATH="$(brew --prefix coreutils)/libexec/gnubin:$PATH" (brew installs as grealpath; gnubin PATH exposes it as plain realpath).

block-write-into-main-checkout.sh — Bash-mediated primary-checkout write fence (HIMMEL-2526)

PreToolUse on Bash. The PREVENTION half of the HIMMEL-2526 pair — detect-dirty-primary.sh (documented in the PostToolUse section below) is the DETECTION half. block-edit-on-main.sh above only fires on Edit|Write|MultiEdit|NotebookEdit, so a Bash-mediated write (cat >, sed -i, a python heredoc) reaches the primary checkout unfenced; this hook closes that gap by denying a Bash command whose TARGET path resolves into a protected checkout (a repo’s primary checkout, per the same git-dir == git-common-dir discriminator block-edit-on-main.sh and block-edit-live-settings.sh use) rather than the worktree the session is scoped to.

Spec: scripts/hooks/test-block-write-into-main-checkout.sh.

Git subcommands aimed at a protected checkout (HIMMEL-3401). A git command rewrites a checkout’s tree, index, HEAD, refs or config without naming a file (git -C <primary> checkout <worker-branch> -- f, restore --source=, merge, pull . <worker>, read-tree -u -m), so arm (g) classifies the SUBCOMMAND on an allowlist. A read-only subcommand is never examined. Every other subcommand, including unknown aliases, has its target’s REPO ROOT put through main_checkout_verdict. The target covers:

  • cumulative -C

  • --git-dir / --work-tree

  • GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE set as a prefix, through env, or by an earlier export

  • the cwd set by an earlier cd / pushd / env -C

  • a git behind a wrapper (timeout, nice, stdbuf, ionice, sudo, chrt, taskset, or xargs) — HIMMEL-4365. The cwd fails closed when the wrapper carries a GIT_* word, or when an xargs -I string lands in a global option, the subcommand, or --output and its operand (attached or the next word). A bare xargs git reads its subcommand from stdin, so it fails closed too. When stdin words land among the subcommand’s arguments (no -I string, or the string sits there), an injected option such as --output=<primary>/x could turn a read into a write anywhere, so the clause is denied. The exceptions are subcommands with no file-writing option: add/stage/rm/checkout/restore (their resolved repo is still checked) and the reads merge-base, ls-tree, ls-files, rev-parse, cat-file, status. Words past a bare -- or --end-of-options are never read as options, so they are exempt, unless the word before is an option that may take it as its operand. The cost: a read such as … | xargs -I{} git log -1 {} is denied from any cwd; write git log -1 --end-of-options {} (revisions) or -- {} (paths) instead.

HIMMEL-4365 also checks the file that --output <file> names on a read subcommand (diff, log, show) as a write operand. So git -C <primary> diff --output=.claude/settings.json is denied. HIMMEL-4518 treats archive -o <file>, format-patch -o <dir> / --output-directory[=]<dir> (also -o at the end of a bundled cluster such as -ko <dir>) and the file operand of bundle create the same way. Their repo check alone passed from a worker cwd, so git archive -o <primary>/x.tar HEAD is now denied, while -o /tmp/x.tar and worker-local paths are allowed. restore --staged writes only the primary’s index, but it is still denied like add: the chief’s next commit would carry what the worker staged.

A cd may not have run (false && cd <worker>; git merge x), so a write is also checked from every cwd an earlier cd left. The cost is that cd <worker> && git merge x typed from the primary is denied as well.

Checking the repo root closes the -C <primary>/handovers and -C <primary>/<ignored-dir> exemption holes. An unresolvable cwd or target on a write fails closed.

The chief’s wrap flow is carved out by shape:

  • pull is allowed only with --ff-only, flags from a closed list, and operands limited to none, <remote-name>, or <remote-name> main|master. pull --ff-only . <worker> is denied.
  • fetch is allowed unless it uses -u, --upload-pack or --refmap, names a ./path/URL repository, or passes a <src>:<dst> refspec.
  • A config override (-c, --config-env, any GIT_CONFIG* variable) voids both carve-outs, since it can repoint the remote or refspec.

The configured upstream is protected too: config, remote and branch -u|-f writes on the primary are denied, and so are branch and tag creation or deletion and reflog expire|delete. The bypass is the same as for every arm: EDIT_ON_MAIN_OK=1 in the launching shell, or .single-writer.

push is judged by its destination. A local path, ., a file:// URL or a --repo= path that resolves into the primary is denied, and so is any --receive-pack / --exec (it can run receive.denyCurrentBranch=updateInstead on the receiving side and rewrite the primary’s tree). A remote name or a network URL passes, unless the same command repoints a remote. In that case push, fetch, pull, ls-remote and remote are denied. A repoint is:

  • a -c / --config-env key matching remote.*.url|pushurl|receivepack|uploadpack|vcs, url.*.insteadOf|pushInsteadOf, core.sshCommand or protocol.* (case-insensitive, judged by key name, never by value);
  • a GIT_CONFIG_COUNT / GIT_CONFIG_PARAMETERS / GIT_CONFIG_KEY_* env assignment;
  • or a git remote add|set-url clause anywhere in the command line.

A repoint made by an earlier, separate command (git config remote.x.url …) is still not seen — cross-command state tracking is out of scope for a command-text scanner, and HIMMEL-3407’s common-dir-owner check (below) only reasons about the SAME clause.

Before the verb match, quotes are dropped, backslash escapes are undone (\git, gi\t, \-C), backslash-newline continuations are joined, ANSI-C $'…' is decoded ($'\x67it', $'\147it', $'git') and locale $"…" is read as "…". An escaped or quoted spelling therefore matches the word the shell runs. Direct-exec mode fails closed on input it cannot read: empty or non-JSON stdin, a non-object, a missing tool_name, or a Bash/PowerShell payload whose command is missing or not a string.

Common-dir owner (HIMMEL-3407). The TARGETS above resolve to the repo a git command is AIMED at — for a write run from a LINKED worktree’s own cwd (no -C/--git-dir pointed elsewhere), that is the worktree’s OWN root, a legitimate feature checkout, so the ordinary verdict allows. But config / remote writes, branch -u|--set-upstream-to|--unset-upstream|-M|-C|-f|--force, checkout -B/switch -C onto main|master, and update-ref / symbolic-ref on refs/heads/main|master land in the worktree’s SHARED $GIT_COMMON_DIR, which the PRIMARY checkout also reads — so a worker could repoint branch.main.remote/merge or remote.origin.url (poisoning the chief’s own pull --ff-only) or move refs/heads/main itself, all without ever writing -C <primary>. For exactly those subcommands/flags, the target’s OWNING primary checkout (primary_checkout_root — a no-op for an ordinary, non-worktree repo) now gets the SAME main_checkout_verdict (_bwimc_git_check_common_owner). Accepted cost: an ordinary git config <k> <v> or git remote add <name> <url> from a worker’s own cwd now denies too — it writes the shared config regardless of the key/remote name, and the -C <primary> form of the same write already denied unconditionally. Ordinary worktree git (checkout, commit, merge, branch create/delete/plain rename without -M/-C/-u/-f/--unset-upstream, update-ref/ symbolic-ref on any OTHER ref) is unaffected.

Two shapes narrow the config/remote check rather than widen it (adversarial review, round 1): config --global/--system/--worktree write a DIFFERENT file entirely (not the shared repo config), and are exempt outright; config -f/--file <path> redirects to an ARBITRARY path, resolved and run through the same file-target check --git-dir/--work-tree already use, so --file <primary>/.git/config still denies while a path outside any repo does not. remote update/prune/show are fetch-shaped (refresh, not repoint) and are exempt; get-url never reaches this arm at all (already read-shaped).

Also fixed the same round: update-ref/symbolic-ref -m <reason> refs/heads/main used to read the -m VALUE as the positional ref-name and stop there, missing the real ref name entirely — the loop now consumes the value like every other value-taking flag in this file does. --stdin reads its ref updates from stdin, invisible to a command-text scanner, and is denied outright rather than silently passed. branch -M/-C (branch’s own FORCE rename/copy — bare -m/-c cannot overwrite an existing branch, so they stay ordinary) and checkout -B/switch -C (force-create-or-reset a branch to a start-point, the same “move an existing ref” class spelled through a different verb) now trigger the check too.

A second adversarial round found five more issues in that round-1 shape, against a differential run of this branch’s hook vs main’s on identical payloads:

  • (bypass) a GLUED -f<path> (config -f<primary>/.git/config …, no space) is a real git invocation and was not recognised as the file flag at all — the same value the separate-token -f/--file form already captures is now captured from the glued form too.
  • (fail-open) an unresolvable --file target (a "$VAR" the scanner cannot read) used to be silently allowed; it now denies as an unresolved git target, like every other arm.
  • (bypass) GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM set as an env prefix or export repoint what file --global/--system actually write, voiding the scope exemption above — a raw substring scan of the clause for either name now withholds the exemption when present (coarse, but only ever adds a check, never removes one).
  • (false deny) -u/--set-upstream-to/--unset-upstream/-f/-M/-C implicitly target the CURRENT branch when no branch-name operand is given — in a linked worktree that is always the worker’s own feature branch (git refuses to check main out in two worktrees at once), never main, so branch -u origin/x with no third operand is ordinary and matches the already-allowed push -u. The branch arm now requires an EXPLICIT main/master operand, mirroring update-ref/symbolic-ref’s own scoping, before any of these flags trigger the check.
  • (cheap) switch’s long form of -C is --force-create, not modelled before; --forc (an unambiguous abbreviation of --force that real git accepts) is now matched via the same _bwimc_is_long_abbrev helper the option-value arms already use; and a BUNDLED short cluster (checkout -fB main) is now recognised by scanning for the flag letter anywhere after a leading dash, the same way _bwimc_opt_scan’s own -t handling works.

Still unmodelled from a linked worktree’s own cwd: tag and reflog expire|delete writes, and any other branch create/delete/rename — same residual class, narrower now. Spec: scripts/hooks/test-block-primary-git-writes.sh.

KNOWN FAIL-OPEN SHAPES — CLOSED by HIMMEL-2592. This section previously listed TWO open shapes; HIMMEL-2526’s sixth and final review round found four more, so the real residual was six instances of one class — a gap between this doc and the ticket record that is itself worth noting, since the two shapes listed here were the ones a reader would have believed complete. All six are closed by this PR. The record stays rather than being deleted: a fence believed to cover a shape it does not is worse than no fence, and a record of what was actually checked is the mitigation.

#ShapeTicketProving row
1Quoted/commented heredoc opener — echo '<<EOF', echo "<<EOF", # <<EOF — used to start heredoc body-blanking and ERASE a following real writeHIMMEL-2591adjudicate5 rows 1a, 1b, 1c
2mv <primary>/tracked.txt $DEST — a dynamic destination used to suppress the SOURCE checkHIMMEL-2592adjudicate5 row 2a
3tee /dev/null >/primary/f — an attached redirect operator consumed as a second tee file operandHIMMEL-2595adjudicate6 rows 1a, 1c
4echo x >| <primary>/f — the >| clobber operator split at the pipe by the clause splitterHIMMEL-2592adjudicate7 rows 1a, 1b
5rm/mv-source of a symlink INSIDE the primary pointing outHIMMEL-2592adjudicate7 rows 2a, 2b; adjudicate8 rows 5a/5b/5c MIRROR
6tee /dev/null > /dev/null from the primary — a false POSITIVEHIMMEL-2595adjudicate7 rows 3a, 3b

Also closed here: HIMMEL-2597 — the guardrails/lib.sh symlink resolver’s hop cap returned the still-unresolved path WITH SUCCESS on exhaustion, and each hop’s own containing directory was never canonicalised — proven by adjudicate8 rows 1a and 2a plus the test-lib.sh rows. Each row reproduces against a real linked worktree with passing negative controls:

PROBE_WT=<worktree> bash "$HOME/.himmel-2592-adjudicateN.sh" <scratch-dir-under-$HOME>

Instance 2 was filed as a DESIGN ticket, not a bug: the third occurrence of one pattern (an operand check nested inside a sibling’s resolution guard, already fixed twice in the positional and -t destination branches), and the fix states the invariant none of those earlier fixes did — below.

The design invariants that close the class (what stops a seventh instance, not the six patches themselves):

  • An operand/opener/splitter loop that meets a token it cannot classify FAILS CLOSED or hands it to the redirect arm — it never silently consumes it.
  • Every operand the extractor can resolve STATICALLY is checked INDEPENDENTLY of whether any sibling operand resolved.
  • Path resolution is chosen by the OPERATION, not by the path: a verb acting on the directory ENTRY (rm, an mv source) does not dereference the final symlink; a verb writing THROUGH it does.
  • ENTRY semantics apply ONLY to a BARE final-component symlink. Anything after the link in the raw token — a trailing /, /., /.., /name, or a glob — traverses it, so the operation is FOLLOW.
  • A dynamic ($VAR) operand fails open on ITSELF ONLY — never consumed as another class, never suppressing a sibling. This is a deliberate divergence from HIMMEL-2592’s literal “fails closed” wording, ratified because the HIMMEL-2526 §2 spec says dynamic targets fail open and denying every variable operand would make the fence unusable.

Deliberately removed denies. Three were removed as ONE ratified class: rm <wt>/filelink, mv <wt>/filelink <wt>/x, rm -r <wt>/dirlink — an ENTRY verb acting on a bare final-component symlink whose ENTRY lives in an unprotected checkout cannot reach the referent, so the old deny protected nothing. Verified at filesystem level with counter-controls (echo x > <filelink> clobbers the referent’s contents; rm -r <dirlink>/ removes them) — the counter-controls are what make the “referent untouched” rows evidence rather than a blind fixture. Each removal is pinned in the suite beside its MIRROR row: the same verb on a link whose entry lives INSIDE the primary still denies.

Accepted cost. HIMMEL-2597’s fail-closed behaviour means guard_canon_path returns rc=1 on a symlink chain longer than 40 hops — parity with the kernel’s ELOOP limit, which is effectively what realpath -m honours in the Write-tool fence (block-edit-on-main.sh) — so a chain that long, even one that never leaves the worktree, is refused with “the target path could not be canonicalised”. A chain up to 40 hops resolves normally and is classified on its real target: a 10-hop worktree-local chain ALLOWS (adjudicate8 row 1c), a 41-hop one still DENIES (row 1d), as does a 41-hop chain into the primary (row 1e). A chain that long is pathological, and the ticket ratified fail-closed over silently emitting an unresolved path — but the cost is a false positive on a genuinely deep worktree-local chain, named here rather than left for a reader to discover by surprise.

Related open scope limits on the DETECTION half: HIMMEL-2571 (status -uno does not see newly CREATED files) and HIMMEL-2590 (the baseline cursor advances only on a reporting run). One level down, the SAME silent give-up shape recurs and is NOT closed here: HIMMEL-2600 — block-edit-on-main.sh’s own canon() (GNU realpath -m) returns an unresolved path at rc=0 on a symlink CYCLE, deferred as out of this PR’s diff. It does not share the depth-cap bypass above — realpath -m resolves a 12-hop chain correctly (its own limit is ELOOP, around 40) — so the exposure is a cycle only, not a merely-deep chain.

Still-true limitations of this fence, unchanged by this PR (see the hook’s own header): no cd modelling, interpreter bodies are not parsed for writes, and the verb scan is prefix-blind (sudo cp is missed) — except tee, which carries a CLOSED prefix-skip list and so does handle sudo tee/env tee/FOO=1 tee. -T/--no-target-directory IS handled (this bullet used to say the opposite): for mv it forces the destination to plain ENTRY resolution instead of directory-follow (bundled forms like -fT included), and for ln the same flag — alongside -n/--no-dereference — does the same. For cp specifically it is deliberately NOT applied: real cp -T against a directory-shaped destination refuses (rc=1) and writes nothing, so treating it as a mode-flip would be a pure false positive.

block-edit-live-settings.sh — live-settings write guard (HIMMEL-2360)

Fires on Edit/Write/MultiEdit/NotebookEdit, plus a textual Bash/PowerShell arm (below). Denies a write to a LIVE settings file — basename settings.json or settings.local.json with an immediate .claude parent — when EITHER it sits under $HOME/.claude/ (the operator’s user-scope live config) OR its repo is the PRIMARY checkout: git rev-parse --git-dir and --git-common-dir, both resolved to absolute paths, are EQUAL. A linked worktree’s git-dir lives under the primary’s .git/worktrees/<name> and so differs from git-common-dir — always allowed there.

Replaces the two Edit(**/.claude/settings.json) / Edit(**/.claude/settings.local.json) permissions.deny rules. A plain glob-deny rule cannot distinguish the primary checkout from a linked worktree — gitignore-style glob semantics match at any depth, and the rule is not scoped to which working directory the session is actually in — so it also blocked a worker from editing the WORKTREE COPY of settings.json, a harmless edit that only takes effect after that worker’s PR is reviewed, merged, and the operator relaunches. The discriminator is git-dir == git-common-dir: protection follows repo layout, not machine paths. A second full clone of the repo elsewhere on disk has the same layout and is therefore ALSO denied; a linked worktree is not.

Workflow implication: because a worker can now edit its own worktree’s settings.json, a hook-wiring change (the “three places” rule in scripts/hooks/CLAUDE.md: script + wire-hook-bash.mjs’s EXPECTED_SCRIPT_ORDER + .claude/settings.json) rides the OWNING WORKER’s own PR — the operator no longer hand-edits .claude/settings.json inside each worker’s worktree for every hook-wiring change.

env -S text (HIMMEL-4521). The Bash arm decodes GNU env -S escapes (\c \f \n \r \t \v \# \$ \_ \\ \' \", ${NAME}) and judges the decoded words. Anything it does not fully model stays denied: an unknown escape, \c, an unterminated quote, a ${...} beyond a plain name, a nested env. Because env splices the decoded words back into its own argv and getopt re-parses them, a first word (decoded or plain) that is an env option (-S, -C, -i, -u, --split-string) or a NAME=VALUE assignment is also denied by shape, with no attempt to model env’s options inside -S text. The -S text is reached after env’s own -a/--argv0/--unset/--chdir argument as well, including any unique prefix of those long options (--argv, --un, --ch). -S text followed by further operands in the same command is denied too: env appends them to the split words, so the text alone is not the command. This over-denies the PYTHONPATH=... example in GNU’s manual on purpose. A text decoder cannot close this class (HIMMEL-5233); the structural upgrade is HIMMEL-5236, denying live-settings writes by effect, not by command text.

The Bash/PowerShell arm is textual and blunt (HIMMEL-1525, HIMMEL-3468, HIMMEL-3499, HIMMEL-3555). It does not parse commands. It fires when the command text names settings.json/settings.local.json (rule 1, any verb) or names a .claude directory together with a copy/extract/checkout-shaped verb (cp/mv/install/rsync/ln/dd/tee/truncate/tar/gtar/bsdtar/ unzip/checkout/restore) or -t/--target-directory (rule 2). A .claude/worktrees/<name> mention (every linked worktree’s own container path) is stripped before this match, so an ordinary cross-worktree -C <other-worktree> reference does not count — unless .. appears anywhere in the text, since the hook never resolves .. and a worktrees/../ (or deeper) climb can reach back into the primary’s own .claude, so the strip is skipped and the mention stays visible. tar/unzip additionally exclude their own CREATE/LIST/TEST/verbose-list modes, and checkout/restore additionally require a git word — both checked against only the shell SEGMENT containing the verb (split on ;, &, |, #, mirroring how a shell itself separates commands), not the whole command text, so a chained or commented trailing token cannot spoof the mode check and an unrelated later git cannot turn a plain checkout.md read into a match. That single-segment check is the PowerShell arm’s; the Bash arm judges EVERY segment holding a tar/unzip/checkout/restore word, each by that word’s own first argument (tar case-insensitively), from the shared tokenizer below — so a harmless first segment can no longer vouch for a later extraction (HIMMEL-3564). It then denies when the mention is live: the cwd is a primary checkout or is in no repo at all; the text names the primary root or $HOME’s .claude (<home>/.claude, $HOME/.claude, ${HOME}/.claude, ~/.claude, ~user/.claude, with or without a trailing /); the command contains .. anywhere; or it contains a cd/pushd/popd word or a case-sensitive -C/--chdir word (git -C, make -C, env -C). The worktree-relative exemption is judged against the PreToolUse cwd, and a .. climb or a directory change in the same command moves the real target: from <primary>/.claude/worktrees/<wt>, ../../settings.json is the primary’s file, whatever the verb. Rule 1 lets through a bare read (cat/head/tail/grep/rg/diff/wc/less/jq/git diff|show|log|status|blame) that has no chaining, pipe or redirect.

Three narrower checks, judged before the text rules above and each independent of mentions_settings/dir_dest (HIMMEL-3686). (1) When the PreToolUse cwd is itself inside a linked worktree’s own container path (<primary>/.claude/worktrees/<name>/…), every write-destination word containing .. is resolved LEXICALLY against that cwd (collapsing ./.. as plain text, no filesystem access) — landing on the primary’s .claude itself, or anywhere under it outside worktrees/, denies, even though the text names neither .claude nor settings (a bare cp -r x/. ../.. from the worktree). (2) An unquoted {…,…} brace group in the text denies outright — braces are never expanded — when the text names settings.json or settings.local.json (any command, so sed -i and perl -pi count), or, under a write verb, when dir_dest already matched or the cwd is a nested worktree; this catches a brace group hiding a .. climb from every check above, none of which expand braces (tee .{,.}/.{,.}/settings.json). From a nested worktree, a writer that is not a recognised verb is also denied on ONE word that is a path (has a /) holding a brace group that could spell a .. (a literal .., a . inside an alternative, or a . beside an empty alternative), whatever its leaf spells (.{,.}/.{,.}/sett{ings,x}.json) — judged per word, never on the whole text. (3) Every write-destination word — every word of a write-verb command, and only the redirect targets (>, >>, &>, >|) of any other command — that names a path EXISTING on disk, OR that is itself a symlink even if DANGLING (review round 3, codex-1: -e follows a symlink and reports false when its target does not exist yet, e.g. a live settings.json that has not been created — -L is a second stat-family builtin, no extra subprocess, and catches the symlink itself so canon() still resolves where it points), is resolved with canon() (the same realpath -m-or-Python resolve() this hook already uses, which follows symlinks); a destination that already exists (or exists as a symlink) and resolves to a live settings file, or to the primary checkout’s or $HOME’s .claude/ DIRECTORY ITSELF outside worktrees/ whatever the operand is called (HIMMEL-3700 I1: a symlinked directory such as cp -r x/. dlink), denies even though the text is otherwise silent — the resolved directory is judged as if settings.json were written inside it. A path merely DEEPER under .claude/ (.claude/logs/x.log) is not judged by that rule: it cannot become the live settings file. Glued forms are judged too (I4): of=<path>, -t<dir>, -rt<dir> and --target-directory=<dir> — and any abbreviation GNU getopt accepts (--target=<dir>, --targ=<dir>) — are each checked as spelled and with the value stripped out. This is the one place the hook reads the filesystem, and only for destination operands. When the word’s leaf case-folds to settings.json/settings.local.json but the full path does not exist yet, the check also fires on the PARENT directory existing (review round 2, codex-2): canon() already resolves a missing final component safely, so a not-yet-existing settings file reached through a pre-existing symlinked parent that escapes into the primary’s .claude/ denies too. The leaf-name gate keeps this scoped rather than firing on every scanned word’s parent (almost always true, since the parent is often just the cwd) — check_target’s own canon() call is a subprocess, and a padded or heredoc command can carry thousands of words (the J1242 timing tests). Checks (1) and (3) walk the tokenizer’s own ST_W[] words (quotes/escapes removed, internal spaces preserved) when it vouched for the command, so a quoted destination with a space stays one operand; only a command the tokenizer could not model falls back to a plain whitespace split, same as every other TOK=0 fallback in this file.

The Bash arm tokenizes (HIMMEL-3546, HIMMEL-3564). A Bash command is split by a quote-aware, segment-wise tokenizer — canonical in scripts/hooks/lib/shell-tokenize.sh, inlined byte-for-byte into this hook and guard-pr-check-literal.sh (hook-integrity pins launched hooks, not sourced libs, so neither sources it; scripts/hooks/test-shell-tokenize.sh fails on drift and its --sync mode rewrites both copies). A quoted |, ; or > is data, and 2>&1 is a redirect. Rule 1 then allows a chain (;, &&, ||, |, newline) in which every segment is an assignment or one allowlisted read, and denies on a substitution, subshell, background &, tee, <>, an output redirect other than /dev/null or an fd dup, an exported or sensitive assignment name, a $/glob command word (any $/glob word for less/git/rg/sed), sed -i/-f/w/e or a second sed command, rg --pre, or less +…. A command the tokenizer does not model (a heredoc, $'…', case, an unterminated quote) falls back to the text rules above, and the tar/unzip/checkout mode checks fail closed on it.

Word boundaries around the verbs and around cd are the complement of a word character, not a list of shell metacharacters. An enumerated class missed (, \, " and ' in successive review rounds. The complement covers any metacharacter without listing it, and a verb inside a word (add, scp) does not match. Quotes and backslashes are deleted from the command text first, because the shell drops them inside a word (c\p, c""p and settings.js\on spell what they name). A line continuation (backslash-newline) is removed as a pair. The PowerShell arm deletes quotes and backticks instead (backtick-newline as a pair), and folds \ to /. Then // and /./ are collapsed to /. A linked worktree’s own absolute root, with its trailing /, is blanked out before the primary-root match, so a nested worktree can write its own settings.

ANSI-C quoting ($'\x2e\x2e', settings$'\x2e'json) can spell any byte, so it is not decoded. Any $' in a command that also contains the substring settings or claude counts as a live settings mention, whatever the cwd. Line continuations are joined before this check, because bash joins $\<newline>' into $' before it reads words.

Accepted false denies (each has a test row): a harmless cd plus a worktree-settings mention; any .. beside a worktree-settings mention (cp ../notes.txt .claude/settings.json); any $' beside a settings or claude substring, even for the worktree’s own copy; a cat >> other.md whose heredoc prose names settings.json (the hook does not parse where the bytes land); a PowerShell read of the file piped or redirected onward (the Bash arm allows the read-only chains above); and a worktree’s own tar -x -C .claude extraction (HIMMEL-3499) — tar’s -C flag shares its spelling with the cd/git -C directory-move signal, which cannot tell tar’s self-targeting -C .claude apart from an unrelated cwd shift.

Known residuals — the hook matches text, so it misses a write whose text does not name the file or its directory in a form above:

  • a command that names neither settings.json nor a .claude directory but writes one anyway: a directory copy whose source holds the file. (tar -C … -x, unzip -d and git checkout <ref> -- .claude — when they DO name .claude in the text, as in the HIMMEL-3499 probes — are now caught: tar/gtar/bsdtar/unzip/checkout/restore are recognized verbs, gated by tar’s own create/list-mode and by checkout/restore needing a git word somewhere in the text — HIMMEL-3499, HIMMEL-3555.);
  • an extraction tool other than tar/unzip: python -m zipfile, python3 -m tarfile, cpio, 7z (HIMMEL-3555 — the fix stayed scoped to the named probes’ tools rather than enumerating every archive utility);
  • a directory change by another spelling: find … -exec, or a directory flag with another name (tar --directory now IS caught, since the verb match no longer depends on which directory flag it uses — only find … -exec remains open here);
  • a .claude mention spelled with a shell glob the hook’s own trailing boundary does not accept (.claude*, .clau?e) — the shell has not expanded it (this is static command TEXT, not a runtime-built name, unlike the next bullet), but the boundary character class stops at the glob metacharacter (HIMMEL-3555);
  • a name built at run time: variables (including ${var@E} escape expansion), $(…) and backtick substitution, printf %b, and an EXPANDED glob (the shell resolves it before the hook ever sees the literal text);
  • an ANSI-C word that escapes the settings or claude letters themselves ($'\x73ettings.json');
  • a symlink the command itself creates and then writes through in the same invocation (a pre-existing symlink destination — file or directory — is resolved and caught, HIMMEL-3686 / HIMMEL-3700, but the destination-existence check runs at PreToolUse, before the command’s own ln would have created it);
  • a writer outside the verb list whose command does not name settings.json and has no redirect (sed -i, perl -pi, awk -i inplace, sponge, ed, vim, python3 -c, node -e and git config -f on a symlink operand): only write-verb operands and redirect targets are scanned for symlink resolution (HIMMEL-3700);
  • an absolute path into a second clone of the repo, other than this session’s own primary checkout;
  • a POSIX-mount spelling (/c/Users/…) of a Windows drive root.

Bypass: EDIT_LIVE_SETTINGS_OK=1 (launching shell only, same convention as EDIT_ON_MAIN_OK).

block-read-secrets.sh — pre-read guard

Fires on Bash/PowerShell/Read/Grep. Refuses any tool call that would print or grep the contents of a secret file (.env, .env.*, .envrc, id_rsa, id_ed25519, *.pem, *.key, *.p12, *.pfx, credentials.json, secrets.y[a]ml). Bypass: READ_SECRETS_OK=1.

Environment dumps and transcript reads (HIMMEL-5412). Two GitHub tokens reached 33 and 29 transcripts through shapes this guard did not know. It now also denies a python/node/bun/deno command that prints the whole environment (print(os.environ), dict(os.environ), os.environb, console.log(process.env), Bun.env, Deno.env.toObject()) or reads /proc/<pid>/environ, and a Read or Grep of a session transcript (~/.claude/projects/**/*.jsonl, subagent transcripts included; a Grep rooted at ~/.claude/projects or a project directory below it too, except the memory/ subtree): a transcript holds every earlier tool result verbatim. A single named key stays allowed (os.environ.get("PATH"), os.environ["HOME"], process.env.HOME, Bun.env.HOME, Deno.env.get("HOME")), as do printenv HOME and echo $PATH. Not yet matched (HIMMEL-5418): a Bash reader of a transcript (cat, grep -r, jq), a Grep rooted above ~/.claude/projects, an interpreter shell-out to env, process['env'] and a destructured {env} = process. The filtered shell dumps (env | grep, printenv | grep, export -p | grep, set | grep) and shell /proc/*/environ reads were denied before (HIMMEL-5277/5292) and keep regression rows. The detection half is detect-token-in-output.sh below.

Intentionally NOT blocked:

  • Interactive editors (vim, nano, vi, nvim, emacs, view) — they never surface file content to Claude as a tool result.
  • Write-only ops (echo >, tee, mv, cp).
  • In-place rewrites: sed -i …, sed -i.bak …, sed --in-place …, awk -i inplace …, awk --in-place …. These rewrite the file without piping content to stdout, so .env rotation/edit keeps working.

sed and awk WITHOUT -i/--in-place ARE blocked, because they print file contents to stdout (awk '{print}' .env, sed s/X/Y/ .env). Prefer asking the operator to echo specific values via the ! prefix instead of bypassing.

A quoted PATTERN is data, not a read target (HIMMEL-2213). For the grep-family (grep, egrep, fgrep, rg, ripgrep, ag) and the sed/awk-family (sed, awk, gawk, mawk, nawk), a quoted argument is exempt from the secret-path check in exactly two positions: immediately after the bare command (zero flags in between), or as the value of -e/--regexp. So grep '.env' file.txt — which searches file.txt for the text .env — is allowed, while everything that genuinely reads still blocks: -f/--file’s value — separate-token or glued — is always a real file, and a <-redirect (grep 'x' < .env) is caught independently of command position by the redirect arm.

The exemption is deliberately positional rather than “the first non-flag argument”, because deciding whether a token is a flag’s operand or a genuine pattern needs a per-flag arity table for every tool in both families — an unbounded enumeration whose gaps are false negatives. The cost is an accepted false positive: a pattern behind any other flag (grep -rn '.env' scripts/, grep -m 1 '.env' f) still blocks. That is the right direction — a false positive costs a cycle, a false negative leaks a secret — and the denial names the two shapes that pass (grep '.env' -rn scripts/, grep -e '.env' -rn scripts/), so the recovery costs a message rather than a cycle. The tokenizer is quote-naive by design, so a multi-word quoted pattern (grep 'foo .env' f) splits before the quote check and can still false-block; the same recoveries apply.

A glued option value is scanned too (HIMMEL-2228). The tokenizer above only ever matched whole shell words, so grep --file=.env and grep -f.env (bundled -rf.env too) — which make grep genuinely open and read that file as its pattern source — matched no secret glob and were allowed: a false negative. glued_opt_secret() now splits a glued token at its first = or : (or past a bundled short f) and scans the value half, in both the outer clause and the HIMMEL-440 recursed bash -c body; PowerShell’s -Param:.env glue is covered by the : split.

The glued scan sits outside the positional exemption above, so a glued value is never exempt — including --regexp=.env (whose separate-token form, -e '.env', IS exempt) and a glob value like --exclude=*.pem. sed --in-place=.env file.txt denies the same way — a real in-place rewrite that leaks nothing, but a glued value can’t be told apart from a genuine read target without per-tool knowledge. As with the 2213 false positives above, that’s the accepted direction, and the denial names both the tradeoff and the recovery. The one exception is the existing armed state doing its job: a token already consumed as a preceding -f/-e’s own value is skipped (grep -f --file=.env opens a file literally named --file=.env, not the secret).

Nested shell bodies are re-scanned as command lines (HIMMEL-4492). An interpreter -c body (bash, sh, zsh, dash, ksh, ash) is scanned again as its own command line, recursively, so a glob read inside it (bash -c 'cat .e*') expands against cwd exactly like a top-level one. Wrappers (timeout, nice, sudo and their flag values) and quoted command words ('cat' .env) no longer hide the command. Nesting deeper than 4 levels denies: a body the guard cannot read fully counts as unreadable, which is fail-closed. An eval body is not re-scanned: eval 'cat .env' is allowed, as it was before. Remaining ceilings are named as ponytail: notes in the hook header (HIMMEL-4438).

guard-memory-capture.sh — auto-memory capture guard (HIMMEL-570 / HIMMEL-1088)

Ceiling, and the gap the guard cannot close alone (HIMMEL-3313 / HIMMEL-3314). Besides the ≤200-char line rule the guard holds an absolute ceiling of MEMORY_LINE_CEIL (default 60) pointer lines, so a compound pass that only shortens lines cannot land on an index that is over the ceiling — it must consolidate (HIMMEL-3313 went 115 → 56 lines, every dropped line folded verbatim into its theme file). The capture log showed line-ceiling / line-too-long denies on 2026-09-18/19 while the index still grew from 74 to 134 lines. The mechanism is not one rogue writer: the guard is registered only on Edit|Write|MultiEdit|NotebookEdit, and the default way of working in auto-mode is a heredoc, sed -i or a short script — a Bash command line is opaque to a payload-inspecting guard, and gating Bash would be both leaky and noisy. The denies were the guard working on the path it can see.

The state check — memory-index-state-notice.sh. A SessionStart warn-only (last-but-one member of the --chain --lifecycle group) that reads MEMORY.md itself: it reports line-ceiling (pointer lines > MEMORY_LINE_CEIL) and line-too-long (any pointer line > MEMORY_LINE_MAX chars), whichever tool wrote the file, at the moment the harm lands — an over-long index is cut at session load, dropping its newest routing lines. Same rules, knobs and defaults as the guard (a suite case pins that they do not drift); whole-file rather than diff-scoped, so a legacy violation keeps ringing until the file is fixed. It detects and reports; it does not prevent the write. Residual gap: a non-Write/Edit writer can still break the ceiling within a session and is caught at the next session start, not at the write. Silent when healthy, and it fails open — every error path exits 0, because it runs on every session start including chiefs and workers. Cost: one git rev-parse and one awk pass over at most the first 256 KiB of the index. Tests live in test-guard-memory-capture.sh (S/F/D/W cases).

Fires on Edit/Write/MultiEdit/NotebookEdit, scoped to the Claude Code auto-memory store (*/.claude/projects/*/memory/*). The always-loaded MEMORY.md index is O(themes), not O(facts): it carries one ≤200-char routing line per theme, and the facts live in the theme topic files it names (natively lazy-loaded). This guard enforces only the mechanically- decidable form rules on that index — everything semantic (is this status-only? does a theme already cover it?) is left to the model + the memory-compound skill.

What it gates, by target:

  • MEMORY.md — (1) any - pointer line >200 characters → deny; (2) more than ~60 pointer lines → deny (the structural ceiling on n, the O(facts) backstop); (3) net growth >400B in one write → deny (raises evasion friction, does not close it); and it logs a line-count-delta tripwire on every allowed write. The char rule pins LC_ALL=C and then counts characters explicitly — it drops UTF-8 continuation bytes (\200-\277) before measuring length, so the count is exact with no locale and no UTF-8-aware awk. (This env has no LANG/LC_ALL by default, which made awk length() count bytes, not chars: a multibyte routing-line character like an em-dash could false-deny a compliant line. Asking for a UTF-8 locale instead would bet on one existing — glibc lists C.utf8, macOS/BSD have no C.UTF-8 — and on awk honouring it, which mawk does not. HIMMEL-2011.) Edit/ MultiEdit to MEMORY.md are decidable: simulate-memory-edit.js applies the payload’s replacement(s) (MultiEdit’s edits[] sequentially; first occurrence unless replace_all; an old_string not found → allow, the Edit tool itself errors on that) against the on-disk file, and the RESULT runs through the same three checks as Write — one code path, so the rules cannot drift between Write and Edit. NotebookEdit is still denied — its payload doesn’t reveal the resulting content, so the guard forces a whole-file Write it can inspect.
  • topic files — the tier-2 landing spot; unrestricted (no body cap, Edit allowed). This is what makes capture always work.
  • *.bak — exempt (compound writes a ~25KB backup by design).

Adopter story is unconditional: no vault/qmd predicate. A machine with no substrate still gets index form-gating (the rules are pure form) and still captures freely into topic files.

Semantics: deny = exit 2 with a retry contract on stderr (shown to the model: “append the durable body to the theme topic file the index routes to, then retry with a ≤200-char routing line”). Never ask — ask hangs unattended sessions. Every deny is logged to .capture-log.jsonl (append-only JSONL: hash + ≤120-char excerpt + target + lines_delta + lane), the input to the decoupled capture audit. The deny-log excerpt reads .new_string as well as .content, because the index append fires as an Edit (measured in the Phase-0 spike, HIMMEL-1086) — without the fallback every routine deny would log empty.

Bypass: MEMORY_CAPTURE_OK=1 — checked first, before any deny branch, and restart-only (set in the launching shell; a per-call prefix does not reach a running session). Compound is NOT bypass-exempt — binding compound’s own output to the 200-char rule is the point (the 2026-07-16 pass emitted a 1,472-char “pointer” line; *.bak covers its backup). Optional advisory on allow via MEMORY_GUARD_ADVISORY=1 (default off; the spike verified additionalContext reaches the model but arrives after the write, so it is a next-action nudge, not a pre-action steer).

block-destructive-commands.sh — deterministic destructive-command floor (HIMMEL-754)

Fires on Bash/PowerShell. Shared floor with the Codex lane — ports the TERMINAL_DESTRUCTIVE command classes from scripts/hermes/assets/parity_guard.py — blocking catastrophic/ shared-machine/irreversible shapes at COMMAND POSITION regardless of tool-permission config: recursive rm / Windows del/erase/rd/rmdir, disk/boot mutation (format/diskpart/bcdedit/mkfs), disk wipe (cipher /w), scheduled-task mutation recognized by CAPABILITY rather than by spelling (HIMMEL-1821: schtasks mutating verbs, the PowerShell ScheduledTasks module’s write cmdlets — Register-/Unregister-/Set-/ Start-/Stop-/Disable-/Enable-ScheduledTask — and the Schedule.Service COM object, while the read-only diagnostics schtasks /query + help/no-verb forms, Get-ScheduledTask, Get-ScheduledTaskInfo, Export-ScheduledTask and the in-memory builder cmdlets New-ScheduledTask/-Trigger/-Action stay allowed, HIMMEL-1141), process termination (taskkill/Stop-Process/ pskill/kill -9), system shutdown/reboot/logoff, Windows registry mutation, permission mutation (icacls/takeown), git push --force/-f, git reset --hard, git clean -f, git filter-branch, and remote-exec pipes (curl/wget … | sh). git push --force-with-lease is branch-aware (HIMMEL-2054): refused when it targets main/master/the resolved default branch (git symbolic-ref refs/remotes/origin/HEAD, main/master fallback), names no explicit branch at all (ambiguous → refused), the literal HEAD or its @/@{...} shorthand (a symbolic ref this hook cannot statically resolve → ambiguous → refused), a branch argument containing a shell variable/command-substitution sigil ($branch, `cmd` — this hook only inspects the command STRING and never executes it, so a runtime value is unresolvable → ambiguous → refused), a wildcard refspec dst (refs/heads/*:refs/heads/*, which can match main/master), the bare :/+: “matching” refspec (empty branch after parsing — can force-update any locally-matching remote branch, including main), a remote other than origin (the only remote this hook can resolve a default branch for), or a cd chained anywhere in the command (default-branch resolution is scoped to the hook’s own cwd, not wherever a prior cd selected); granted to any other explicit branch or <branch>:<branch> refspec on origin — this is the deny-side half of the auto-approve-safe-bash.sh HIMMEL-212 carve-out below, which the blanket deny used to make unreachable. Branch tokens are quote-stripped (one matching outer pair of '…'/"…") before comparison, so a literally-quoted branch name — which a real shell strips before git ever sees it — is judged the same as its unquoted form. Default-branch resolution strips only the fixed refs/remotes/origin/ prefix, so a default containing its own slash (e.g. release/stable) still matches. A bare force flag is refused even clustered inside a short-option bundle of git push’s other clusterable boolean flags (-vf, -fv, -4f — not an attached -o push-option value like -ofoo), not just standalone -f, and the lease flag is matched from its shortest unambiguous git-accepted abbreviation (--force-w) through the full spelling, not just the exact --force-with-lease string. A +-prefixed refspec (git’s own unconditional force marker for that one ref, e.g. origin +main) is denied like a bare force flag — regardless of which branch it targets, and regardless of whether --force/--force-with-lease appears anywhere in the command at all, since a + refspec carries no server-side safety check of its own; this also covers a lease scoped to one ref (--force-with-lease=main) leaving a different +-prefixed refspec in the same push unprotected. Every git rule above judges argv, not flattened text (HIMMEL-5358): segments come from the shared tokenizer scripts/hooks/lib/shell-tokenize.sh (so a # comment or quoted prose is never read as a command), git’s global options are stripped by git’s own grammar (-C, -c, --git-dir, --work-tree, --namespace, --config-env, -p/-P/--no-pager, …), and the deny rules apply to the subcommand’s own arguments. A word the tokenizer cannot resolve (an expansion or command substitution) in a global-option or subcommand position is refused for a destructive subcommand, and a word or assignment whose value carries a git command (X="git reset --hard"; $X) is re-parsed, as is the text after a word’s first = with a leading ! dropped (an alias body, a credential.helper=!…, core.sshCommand). A --config-env alias is refused outright, since its body lives in an env var the hook cannot read. A heredoc body gets no argv relief: each body line is held to the plain text markers (git reset --hard, git clean -f, git filter-branch, a forced git push), since a later command can run what it writes (an alias appended to .git/config). The lease carve-out survives an unrelated -c key=value (core.pager, user.name); -C, a -c touching remote.*/url.*/push.*/branch.*/ include*/core.worktree/core.bare, or a malformed -c turn it off. Recognizes a bounded set of launcher wrappers (sudo, env, cmd /c, powershell/pwsh -c) and an executable-path prefix, so a wrapped or path-qualified form (/usr/bin/env rm -rf /, C:\…\format.exe) is refused like the bare name. HIMMEL-3984/3983 widened command position to the exec-style wrappers exec, timeout, nohup, nice, time and xargs (with their flags), to the compound keywords (do, then, else, elif, if, while, until, !, {, (, a function body), and to a find/fd exec flag (-exec, -execdir, -ok, -x, --exec-batch); find … -delete is refused as a recursive delete. Routine git/gh/mv/cp, non-recursive rm, curl without a remote-exec pipe, and normal git status/commit/push are all allowed.

Deliberately NOT a general shell parser: quoted-payload wrappers (bash -c "rm -rf /", su -c, env -S) and the wrappers listed in the ponytail: note on guard_cmdpos_grammar (setsid, stdbuf, flock, …) are an accepted residual gap — this hook plus the auto-mode classifier are the outer defense layers, not an arms race against every wrapper permutation. Fails CLOSED on missing jq or malformed/truncated JSON (a security floor, not a convenience hook — the EXIT trap also converts any unexpected top-level failure to a block rather than a fail-open rc=1). Deny text is the tell that separates this deterministic hook from an auto-mode classifier denial (HIMMEL-2798/ HIMMEL-3020 class): this hook’s message always names its own short reason (e.g. recursive rm), never quoted script content or Stage 2 classifier error. Bypass: DESTRUCTIVE_OK=1 (launching shell, session-sticky). Spec: scripts/hooks/test-block-destructive-commands-{a,b,c,d}.sh.

check-hook-file-parse.sh — write-time hook-file parse guard (HIMMEL-2230)

WIRED (HIMMEL-2248). Registered in .claude/settings.json and in wire-hook-bash.mjs’s EXPECTED_SCRIPT_ORDER; node scripts/hooks/wire-hook-bash.mjs --check proves the two agree. It shipped INERT under HIMMEL-2230 and was live only once that wiring landed — an unregistered hook script does nothing at all.

PostToolUse on Bash|Edit|Write|MultiEdit|NotebookEdit — it joins the existing block with that matcher rather than adding a fourth PostToolUse launch; a Bash tool call writes no file, so the extra arm is a no-op for it. Runs scripts/lint/hook-parse-check.sh — the [parse] (bash -n) and [quote-break] checks — against a written file whose path is under scripts/hooks/ and ends in .sh; exits 2 with the finding on stderr, otherwise 0.

WHY IT IS A WRITE-TIME HOOK and not another commit gate: a file under scripts/hooks/ is LIVE the instant it is saved — the next tool call executes it. The motivating incident put a prose apostrophe inside a single-quoted awk program, which ended the shell string; the hook stopped parsing, exited non-zero on every invocation, and denied EVERY command fleet-wide, benign ones included. It never reached a commit, so the pre-commit shellcheck gate and scripts/lint/shell-lint.sh were both out of the path by construction — scanning every reachable revision of that file confirms it (zero committed revisions ever failed bash -n). Commit-time linting cannot catch this class; only write-time can.

Scope is deliberately narrow — at or below scripts/hooks/, ending in .sh (the case glob matches a nested subdirectory too, not just a direct child) — so it stays cheap enough to run on every write. The path test folds \ to / first — the Windows lane delivers the same file as scripts\hooks\x.sh — and stats the raw path first, falling back to the folded one, so a filename that legitimately contains a backslash is never rewritten.

FAILS OPEN on everything unevaluable — missing jq, unparseable stdin, missing checker, a path outside scope, a file that is not on disk. That is the opposite of the block-* fences and it is deliberate: this is a hygiene guard, and one that blocked edits whenever a dependency was absent would be worse than the problem it solves. Being PostToolUse, exit 2 does not prevent the write that already happened — it puts the breakage in front of the model while the context to fix it is still loaded, which is the whole value.

The same two checks also run at commit time through shell-lint.sh, which calls the identical scripts/lint/hook-parse-check.sh — one implementation, two consumers. shellcheck reports the class precisely (SC1011) and is in .pre-commit-config.yaml, but it is OPTIONAL and absent on plenty of hosts, and shell-lint.sh degrades to “BOM + errexit checks only” when it is missing; [parse] needs nothing but bash. [quote-break] additionally covers the shape bash -n cannot see at all: when the stray apostrophe happens to re-balance the file, it parses cleanly while awk receives a TRUNCATED program.

Spec: scripts/hooks/test-check-hook-file-parse.sh (positive controls, negative controls including a healthy-corpus sweep of every scripts/hooks/*.sh).

block-git-stash.sh — shared-refs/stash guard (HIMMEL-1755)

Fires on Bash/PowerShell. Every worktree of a checkout shares ONE refs/stash, so a stash mutation in one session acts on entries a PARALLEL session owns — measured twice in a single worker (2026-08-12): a git stash drop destroyed another chain’s entry, recovered only because it had been tagged. Refuses the MUTATIONS at COMMAND POSITION (same grammar as block-destructive-commands.sh, so git -C <path> stash pop and git --git-dir=<d> stash drop are caught, while grep -rn "git stash" src/ is a read and passes): a bare git stash including its option/pathspec forms (git stash -u, git stash -- <path> — git treats these as push), plus push/save/pop/drop/clear/branch/store, plus the low-level plumbing equivalents git update-ref/symbolic-ref against refs/stash directly (HIMMEL-2077). The reads and the non-destructive verbs stay allowed — list, show, apply (apply leaves the entry in place for its owner), create (prints a dangling commit, writes no ref) and --help. The deny names the sanctioned alternatives: a refs/checkpoints/<slug> snapshot (checkpointWorktree, wired into both spawners), a WIP commit on your own branch, or — for a stash-shaped snapshot — the race-free git tag wip-<slug> "$(git stash create)", restored with git stash apply wip-<slug>. Both of those are allowed forms, so the escape hatch is not needed for them; push then tag stash@{0} is NOT equivalent, because another session can drop the entry between the two commands. Deliberately NOT part of the parity_guard.py floor: that ports catastrophic/shared-machine terminal shapes, and this is fleet policy about one checkout’s shared ref namespace. The brief-level twin is STASH_BAN_LINE in the lane worker prompts. Fails CLOSED on missing jq or malformed JSON. Bypass: GIT_STASH_OK=1 (launching shell, session-sticky). Spec: scripts/hooks/test-block-git-stash.sh.

block-bare-qmd-query.sh — unbounded qmd search verbs (HIMMEL-3960, HIMMEL-3956)

Fires on Bash. The qmd launcher is a node trampoline that forwards no signals, so a timeout N qmd query … kills node and orphans the bun child; five such orphans ran at ~99 % CPU for ~15 h. Refuses qmd query|search|vsearch at COMMAND POSITION (the block-git-stash.sh grammar, widened to wrappers such as timeout, env, nice, setsid, xargs, sh -c and a bun/node/npx runtime prefix, and stepping over global options before the verb). The match also runs over the command’s words after bash quote removal (HIMMEL-4121), so qmd "qu"ery, qmd $'query', qmd \query and q"md" query are refused while qmd "query"" notes" (one non-verb word) passes. An unknown $'\X' escape is read as zsh reads it (X, since the Bash tool may run zsh); a verb held in a variable is still a miss (ponytail in the hook). Other verbs (status, update, embed, get) and non-command-position text (grep 'qmd query' log) pass. The deny names the bounded replacement, bash scripts/lib/qmd-bounded.sh <verb> … (deadline $QMD_TIMEOUT_SECS, else 300 s, killing the whole process group), or the mcp__qmd__query tool. A must-run chain member in run-hook-with-bash.js: a starved run denies. Fails CLOSED on missing jq or malformed JSON. PowerShell is unguarded (ponytail in the hook). Bypass: QMD_UNBOUNDED_OK=1 (launching shell). Spec: scripts/hooks/test-block-bare-qmd-query.sh.

Before a worker or preflight depends on qmd retrieval, run bash scripts/lib/qmd-key-probe.sh (HIMMEL-5895): one real vec query against the qmd server (QMD_MCP_URL, default http://localhost:8181/mcp), bounded by QMD_KEY_PROBE_TIMEOUT seconds (default 90). Exit 0 = the server embedded the probe query. Exit 1 = the embedding provider answered 401 (No cookie auth credentials found): the server was started without QMD_OPENAI_API_KEY, so every vec query fails; restart it with the key. Exit 2 = anything else (nothing listening, deadline, bearer-token refusal, unreadable reply). The output is fixed text and never carries the key or the Authorization header. Spec: scripts/lib/test-qmd-key-probe.sh.

block-bank-lift-writes.sh — operator-only weekly budget override (HIMMEL-4445)

Fires on Bash and on Edit|Write|MultiEdit|NotebookEdit (Codex: Bash and the apply_patch envelope). The weekly budget override ~/.himmel/state/bank-lift.json (HIMMEL-4423) relaxes the 7-day subscription usage budget check while it is valid, so raising it is the operator’s call. The hook refuses every agent write to that path — redirects (>, >>, >|, &>, <>), tee, cp/mv/install/ln/rsync as a destination, dd of=, sed -i, touch/truncate/patch, -o/--output options, editors, downloaders and interpreters handed the path, find -exec, xargs, and inline interpreter code (python3 -c, perl -e, node -e, a heredoc script) that names bank-lift.json, $BANK_LIFT_FILE or bank-lift.sh — under every spelling of the path: ~, $HOME, ${HOME}, /home/<user>, ~user, relative from ~, ~/.himmel or state/, ./.. segments, quote splits, backslashes, $'…', globs, braces, case, symlinks (dangling too) and a cd before a relative name. A whole-directory write into state/, .himmel/ or ~ is refused when its source could put a lift there (a lift-named, glob or $var source, a dir/ or dir/. contents copy, -T, ln -n); copying an unrelated file in passes. It also refuses bank-lift.sh set (and the sourced _bank_lift_cmd set) under any launcher: bash/sh/direct/source/., env/timeout/nohup/command/exec/nice/sudo/xargs, -c bodies, heredocs into a shell, a computed ($(…), $var) subcommand or script word. Whole-command layer (judge J1874, runs FIRST): a reader can hand the lift path to a writer that never names it — cp x $(jq -rn '"…/bank-lift.json"'), echo > "$(ls <lift>)", ls <lift> | xargs cp x. So when the command text names bank-lift.json or bank-lift.sh ANYWHERE — inside $( ), backticks, heredoc bodies and pipelines, after quote removal and $'…' decoding — the command passes only if EVERY clause at every depth (pipeline stages, substitution bodies, -c bodies, &&/||/; lists) is a reader under the limits below or bash scripts/lib/bank-lift.sh show|clear (also direct, the script word spelled literally, and only the repo’s own copy — see HIMMEL-4458 below), AND no redirect writes anywhere but /dev/null (>, >>, <>, >|, &>, &>>; fd dups such as 2>&1 are fine). xargs is not a wrapper here, so any xargs clause denies; so does an assignment to a variable a reader or the loader obeys (LESS*, RIPGREP_*, GREP_*, LD_*, DYLD_*, PATH, BASH_ENV, ENV), and less with -o/-O (attached too), --log-file or a +cmd. A reader, wrapper (env, sudo, timeout, …) or bash word counts only when it is bare or exactly /usr/bin/<name> / /bin/<name>: /tmp/cat, ./cat, ~/bin/less or /tmp/env cat is whatever was planted there, so it denies (both layers). A lift-naming command the tokenizer cannot parse reliably (an unclosed quote or substitution, a heredoc delimiter whose quote does not close on its line) denies; a quoted delimiter with a space (<<'END MARK', <<"E F", <<"E"' F') is read as one word, as bash does. Over-deny is accepted: test -f <lift> && echo yes, cat <lift> > /tmp/copy, set -e; bash …/bank-lift.sh show, a heredoc commit message naming the lift, and a write to any lift-NAMED file (/tmp/x/bank-lift.json) all deny; split the command, or put text in a file. Residual (ponytail): a lift name obfuscated by a glob or brace inside a substitution does not trigger this layer. HIMMEL-4458 closed these gaps:

  • Repo-anchored script. bank-lift.sh show|clear passes only when the script word resolves (physically) to <repo>/scripts/lib/bank-lift.sh of the checkout the hook runs from, or of one of its .claude/worktrees/*: scripts/lib/bank-lift.sh or ./scripts/lib/bank-lift.sh from that root with no cd in the command, or an absolute / ~ / $HOME path. A planted /tmp/x/bank-lift.sh show, a relative path from another cwd, or a worktrees/w1/../../.. escape denies. The script itself must be a regular file, not a symlink, whose directory resolves physically to that checkout’s scripts/lib: a symlinked scripts/lib or bank-lift.sh pointing out of the checkout denies, as does a path that does not resolve. A symlinked path to the checkout itself still passes, since it resolves to the same root.
  • cp --parents / rsync -R (--relative). These recreate the source’s path under the destination, so the created path (destination + source, rsync’s /./ marking the start) is judged: a glob-spelled .himmel/state/bank-l?ft.json copied with --parents into ~ denies.
  • Split names in inline code. Interpreter / sed e code also matches once quotes, +, commas, backticks and blanks are dropped, so "bank"+"-"+"lift"+".json" and "ba"+"nk-lift.sh" deny; prose such as "bank-lift show" does not (the joined form needs a separator before sh).
  • Archive extraction. tar/gtar/bsdtar extracting (-x, a bundled or old-style x, --extract, --get; not -O/--to-stdout), unzip (not -l/-t/-v/-Z/-p/-c) and cpio -i/--extract deny when the destination (-C, -C<dir>, --directory[=], -d, -d<dir>, -D) is HOME, ~/.himmel or its state dir — a member can be .himmel/state/bank-lift.json. With no destination option the effective cwd counts: the hook’s cwd, or — with a directory-changing word in the command — unknown, which denies (see Unproven cwd). An option’s operand is never read as a flag: tar -xf -O -C ~ names the archive -O and still denies (tar -f -C -T -X -b -I and GNU-only -H -K -N -V -g -F -L, the long forms that take one, unzip -d -P -O -I, cpio -F -E -H -I -O -D -R -M -C). A letter whose arity differs between GNU tar and bsdtar (plain tar -s, -L, …), or an unknown long option, makes the next word untrusted as -O; a destination option spelled as another option’s operand judges the cwd and every later word.
  • Unproven cwd: relative destinations deny. The fence does not model the cwd through shell control flow (a cd may fail, be skipped, run in a subshell or go through CDPATH). The cwd is the hook’s own cwd unless a directory-changing word appears anywhere in the command — raw text or dequoted tokens: cd, pushd, popd, eval, su, runuser, chroot, CDPATH, any chdir, find -execdir, a ( or backtick (subshell, $( ), <( ), $(( ))), a { … } group, env/sudo with -C/-i, or a shell running a -c string. Then the cwd is unknown for the whole command, and an extraction with no destination option, or a RELATIVE (or computed $VAR) destination of an extraction or of cp/mv/install/rsync/ln (destination, --parents path, link source, missing-ancestor check), denies. An absolute, ~ or $HOME destination is judged exactly as with a known cwd, so cd /x; tar -xf a.tar -C /var/out is allowed. Remedy (named in the deny): use an absolute destination (tar -C /abs, cp … /abs), or run the command on its own without the directory-changing word. Over-deny vs the previous per-operator cd modelling, per cwd: p22-samp 66 of 20 601, p22-hot 28 of 5 602, p22-hist 27 of 4 789, write-fence templates 4 of 771, suite-1874 2 of 317, p22-gen 0, corpus 0 — mostly S=/abs; …; cp x "$S/" (a computed destination) in a command that also has a $( ), ( or cd.
  • Extraction options are allowlisted. While extracting, any option the destination check does not model denies, long or short, bundled letters included, so --one-top-level=DIR, --transform, -P/--absolute-names, -h, -X, -N and abbreviated long names all deny. Allowed: tar -x -f -C -z -j -J -v -o -m -k -p (dashed, bundled or old-style xzf), --extract, --get, --file, --directory, --strip-components, --gzip, --gunzip, --bzip2, --xz, --zstd, --verbose, --no-same-owner, --no-same-permissions, --same-permissions, --preserve-permissions, --touch, --keep-old-files, --skip-old-files; unzip -o -q -n -j -d. Every cpio extraction denies (below). Value options take --opt=V, --opt V, -fV and -f V. List mode (tar -t) is not checked. Over-deny of this and the relative-cd rule vs the previous fence: 0 in every recorded corpus (p22-samp 20 601, p22-hot 5 602, p22-hist 4 789, write-fence templates 771, p22-gen 605, corpus 514, suite-1874 317; both cwds).
  • tar/unzip mode is allowlisted. tar/gtar/bsdtar and unzip count as extracting unless a non-extract mode is PROVEN by a real option word: tar -t -c -r -u -d (dashed, bundled, or an old-style bundle as argv[1] only), the full long names --list --create --append --update --diff --compare, or -O/--to-stdout; unzip -l -t -v -Z -p -c. A member or operand word, a word after a non-option or --, and the word after an unknown long option never prove a mode, so tar --extract --file a.tar --directory "$HOME" O .himmel (member O) and tar --ge -f a.tar -C "$HOME" (an abbreviation) deny. An explicit -x/--extract/--get keeps the extract reading. Over-deny vs the previous fence: 0 in every recorded corpus, both cwds.
  • tar/unzip option variables. When a command names TAR_OPTIONS, UNZIP, UNZIPOPT, ZIPINFO or ZIPINFOOPT anywhere, every tar/unzip extraction in that command denies. That covers a prefix, export, env, declare/typeset/local -x, readonly, printf -v, read, a nameref, += and a nested body. A mere mention also denies, such as echo $TAR_OPTIONS; tar -xf …; that over-deny is accepted. A proven non-extract mode still allows. The scan matches the bare, case-sensitive name, so the unzip command never matches UNZIP. It reads the raw text and the dequoted tokens, like the directory-word scan, so a quote-split name still matches. The value is never parsed. A value inherited from the launching environment is out of scope: each Bash call is a fresh shell the hook cannot see.
  • Chained tar -C. tar applies each -C/--directory relative to the one before it, so a relative one is judged as the composed path: tar -xf x -C "$HOME/projects" -C .. is HOME and denies (also bundled, attached and --directory= spellings, and a relative first -C against the cwd). A relative -C after a computed one (-C "$d" -C ..) fails closed. unzip -d is last-one-wins and needs no chain.
  • Absolute member names. An extraction that keeps absolute (or ../) member names writes wherever a member names, whatever the destination, so it denies: tar/gtar/bsdtar -P (bundled or old-style too), --absolute-names, --absolute-paths; unzip -: and -^. Every cpio extraction (-i/ --extract, and -p pass mode) denies whatever its destination: GNU cpio keeps absolute and ../ names by default and --no-absolute-filenames is not modelled (fail closed; extract with tar or unzip instead). Listing (-t, cpio --list), stdout extraction and creation stay allowed. Residuals: pax, 7z x and bsdcpio are not in the extractor list; symlink-member traversal (a member symlink then a member written through it) relies on GNU tar’s and bsdtar’s default protections, which -P is what disables.
  • Computed destinations. An extraction destination that cannot be resolved — -C "$D", --directory="$D", unzip -d "$D", $(…) or backticks — denies; $HOME spellings still resolve. A variable assigned a literal earlier in the same command is not tracked, so S=/tmp/s; tar -xf a -C $S/x denies too: spell the literal path.
  • Ancestors of HOME. Members are relative, so a destination that is a strict ancestor of HOME (/, /home, ~/..; whole path components, so /ho is not one; compared lexically and physically) reaches the lift with a member such as home/<user>/.himmel/state/bank-lift.json and denies: -C, --directory, unzip -d, and the extraction cwd (the hook’s cwd). A destination inside HOME other than HOME, ~/.himmel or its state dir (-C "$HOME/projects") stays allowed.
  • Directory symlinks. tar --keep-directory-symlink (and its --keep-d… abbreviations) follows a directory symlink inside the destination and denies on extraction. Ceiling (a ponytail: in the hook): a symlink already inside an allowed destination is followed by default for a member’s intermediate path (GNU tar with no directory member, unzip, cpio); unzip and cpio have no opt-in flag to deny.
  • Linear scan. The per-word lowercase fork in the name rule and the per-clause basename forks are gone, so a 20k-word command takes well under a second instead of a minute (the hook timed out on ~10k words). Accepted over-deny (HIMMEL-4458 item 6): a contents copy into bare HOME (cp -r dotfiles/ ~/, rsync -a dir/ ~/, cp * ~/) still denies — the same copy can create .himmel/state/… from the source tree, and * matches .himmel under dotglob. Out of the hook’s reach by design, bounded at runtime instead: Write-tool CONTENT that is itself a script writing the lift, a script FILE that writes it without being handed its path, variable indirection that never spells the name, a bind mount, and interpreter tricks that never spell it. Judge J1855b bounds them where the lift is READ (HIMMEL-4423): bank-preflight.sh honours a lift only when it is account-bound, owner- and mode-checked, bounded by the usage cache’s seven_day resets_at, and well-formed; anything else is no lift. A valid lift skips only the seven_day ceiling — five_hour still binds and a full seven_day still refuses — so a forged lift buys at most the rest of the current 7-day window. Remaining hook gaps (ponytail): an edited or planted copy of scripts/lib/bank-lift.sh inside a worktree is trusted as the repo’s script; and a pure-glob source under a computed destination (cp --parents * "$d"). Name rule (chief ruling, applied before the rules above on every clause, nested ones included): a clause with a word naming bank-lift.json or bank-lift.sh (not a longer name such as test-bank-lift.sh) passes only when it is bash scripts/lib/bank-lift.sh show|clear or the direct scripts/lib/bank-lift.sh show|clear (the repo’s own copy, as above), or a reader: cat, less without -o/-O/--log-file/+cmd, head, stat, ls, file, wc, test/[, jq without -i/--in-place, tail without -f/-F/--follow, grep/rg without --pre. The command word is resolved past assignments and the env/sudo/doas/nice/xargs/timeout/ stdbuf/ionice/chrt/taskset/setsid/nohup wrappers, but a wrapper carrying any option leaves it unknown, so the clause denies. Everything else naming the lift denies, including rm/mv/cp of the lift, source/. of bank-lift.sh, bash -c bodies, find -exec, echo, git grep, and commit messages or ticket titles given inline (git commit -m, a jira --title). This overrides the ticket’s original allowance for such mentions. The remedy for text is a file: git commit -F <file>, a jira --desc-file. Over-deny is accepted: inline interpreter code that only READS the lift, or edits a file whose text names bank-lift.sh, is refused too (use cat/jq, or the Edit tool). The deny names the remedy — the operator runs ! bash scripts/lib/bank-lift.sh set ... — and there is deliberately no env bypass. A must-run chain member in run-hook-with-bash.js. Fails CLOSED on missing jq, malformed JSON or a tokenizer error. Residuals are ponytail-marked in the hook. Spec: scripts/hooks/test-block-bank-lift-writes.sh.

guard-pr-check-literal.sh — relative scripts/cr/ gate spellings (HIMMEL-3383, HIMMEL-3495)

Every worker profile allow-lists bash scripts/cr/pr-check-context.sh and bash scripts/cr/pr-check-env.sh CR_CLAUDE_AGENTS (HIMMEL-3359, HIMMEL-3375), plus relative literals for thirteen more scripts/cr/ gate scripts (review-round, cr-scores, clear-cr-marker, write-verdicts, … — the hook’s TARGETS list, which its suite checks against every Bash(bash scripts/cr/… allow row). From a worker’s worktree such a literal runs the BRANCH’s copy. The permission matcher is a prefix match, so the rule also covers other spellings that run the same relative file: extra arguments, ./, //, .., quotes, sh/source/., wrappers such as env or timeout, a compound command, a glob or a brace. The hook permits running the checkout’s own copy only when three conditions hold:

  • the cwd’s git-common-dir is $HIMMEL_REPO/.git (the himmel lane);
  • the cwd is the worktree root;
  • the working-tree bytes and file modes of the guarded files equal the HIMMEL_REPO anchor’s own working tree. For pr-check-context.sh and pr-check-env.sh that is all of scripts/cr/, plus scripts/guardrails/lib.sh and scripts/lib/load-dotenv.sh. For every other target it is only the entry script and scripts/cr/anchor-handoff.sh (HIMMEL-3495): each of those entry scripts sources the hand-off as its first statement, so an entry byte-equal to the anchor’s re-execs the anchor’s copy, and every library it loads comes from the anchor. A branch that deletes the hand-off line changes the entry’s bytes and is denied. A worker that edits one gate script loses only that script’s relative spelling, not all of them. The hand-off reads HIMMEL_REPO from the environment, as the hook does.

review-round.sh (every verb), panel-first-pass.sh and clear-cr-marker.sh also refuse a branch other than the one checked out in cwd, and a detached HEAD: they write shared per-branch state, and a relative run is auto-allowed from any worker’s worktree.

What fires it. The hook classifies by the script a command runs, not by its text. It strips quotes and backslashes and splits the command into simple commands. A simple command counts when its command word runs a file (a shell, source, ., eval, or a path) and one of its words ends, after dropping . segments and repeated /, in a relative target script, or is a glob, brace or $var that could. Whether anything runs is a presence test over every word, not a walk of positions: a shell, source, ., eval, a wrapper, -exec or -ok anywhere in the command counts (HIMMEL-3495), so a redirect in front (2>&1 bash …) or find … -exec bash … {} + is seen. A glob whose directory is a literal path other than scripts/cr (scripts/hooks/*.sh, docs/*, ./node_modules/*) is not a candidate while no cd is present, and a bare * or {} is not one while nothing that could run it is present (HIMMEL-3433). Mentions (grep, cat, git diff, the test suites) are a no-op. Before the quote-strip, one simple command (no substitution, heredoc, $'…' or comment) has its provably inert quoted words dropped from the text these checks read (HIMMEL-3546): a quoted word with no live $, glob or redirect, on a command with no VAR= prefix, that is an argument of echo/printf/grep/egrep/fgrep/jq/cat/head/tail/wc, a sed script the shared tokenizer verified, or an argument after an unquoted .sh target whose bytes this hook already checks. The tokenizer is the one block-edit-live-settings.sh uses (see that section); it reads 2>&1 as a redirect, never a separator (HIMMEL-3458). Absolute paths, including the anchor’s, are a no-op too: no allow rule matches them, and the runbook’s adopter lane depends on them. (A consequence: a settings allow rule spelled bash "$HIMMEL_REPO/scripts/cr/<script>" is dead. This hook denies the $-bearing word before the allow list is consulted, so an allow rule has to name a spelling this hook lets through, never a variable one.) A candidate that does not resolve to exactly scripts/cr/<script> from the cwd (a glob, a variable, any .., which the kernel resolves after following symlinks), or a command that changes directory (cd, pushd, env -C), denies outright: the conditions are checked in the cwd, so they prove nothing about another copy. So does a candidate inside a compound command, behind a wrapper (env, timeout, xargs, …) or after a VAR= prefix: another command in the same call can rewrite the checked bytes before the script runs (cp x scripts/cr/pr-check-context.sh; bash …), a wrapper’s operands can hide the word it runs, and BASH_ENV= runs a file first. A glob or brace list is matched against the guarded names, in any case, so scripts/c[r]/pr-chec[k]-context.sh counts too. Any word naming a path through cr/ (any case) that the shell rewrites before it runs (a $ expansion, a $( ) or backtick substitution, a glob, a brace list, a ~) denies. So does a relative one with an uppercase letter, which a case-insensitive filesystem folds. busybox and toybox count as shells. The canonical anchored fence is exempt only in its exact shape; only its echo text may vary. Classification uses bash builtins only, so a missing tool cannot turn it into a no-op. Text classification has limits, and the hook’s ponytail: names them.

Nested env -S (HIMMEL-4491). The env -S split-string test also reads inside a nested shell body. A bash -c '…' (or sh, zsh, dash, ksh, mksh) body or the words after eval are one quoted word to the outer scan, so an env -S '… \c' inside them was no word of it. Each such body is joined and run through the same test, recursively, so bash -c "env -S 'bash scripts/cr/… \c'" denies like the unnested form. su -c, script -c, watch and a body held in a variable or read from stdin are not re-read (the hook’s ponytail:).

Write-channel backstop (HIMMEL-3913, HIMMEL-3917). Before any of the above, the hook reads the raw text. The inert drop and the here-string reader exemption (HIMMEL-1813) both let text that names a target pass as data. That text can leave its reader only through a write: a redirect into a file, or tee’s file operands. Whatever later runs the file cannot be enumerated. It may be a shell, command -p source, $(cat F), xargs, awk '{system($0)}', or chmod +x F; F, or a later tool call that never names a target, such as a write into ~/.bashrc. So the backstop denies the channel, not the executor. A command whose dequoted text names a target denies if any of these holds:

  • it contains <<<;
  • it contains a tee word, in the raw text or after dequoting;
  • it shadows a reader with a function or alias;
  • a > is left after removing the only provably inert redirects. Those are fd dups (2>&1, >&2, N>&-) and a redirect to exactly /dev/null (>/dev/null, 2>/dev/null, &>/dev/null, with or without a space). >/dev/nullx, >/dev/stdout, >/dev/fd/3, >/proc/self/fd/1 and >>/dev/null all deny.

The rule has no allowed-literal carve-out and no executor list. Every allowed spelling carries no channel by construction, and the canonical fence’s only redirect is >&2. A > inside quoted text counts too. So a commit message, PR body or append-results.sh bullet that names a guarded script and has an ASCII arrow -> denies. The remedy is to write the text with the Write tool and pass the file (-F, --body-file, --comment-file), or to use → / to. append-results.sh takes its text as an argument only, so a Results bullet never contains >. Heredocs are not a channel here, because the main path judges their body as raw text. The residuals are a file written by the Write/Edit tools (this is a Bash matcher) and text that never spells a target; the backstop’s ponytail: names them. Running either matches no allow rule, so the classifier decides. One git exception: a single git command with no quotes, substitution or heredoc, with no word starting with -, whose words are pathspec-only and that redirects into a file not named like a target (git show HEAD:<target> > /tmp/x), is allowed: it writes only git’s own output. Options are refused because --format/--pretty (%xNN), --src-prefix, --dst-prefix and --line-prefix put command bytes into that output.

Command position and the dispatcher (HIMMEL-6015). Before the text scan, a command whose every command word provably runs no gate directly is allowed: an inert reader, an inert sed, a pathspec-only git, echo, printf without -v, cd, or bash running scripts/handover/chief-kit/himmel-gate.sh by its exact anchored spelling (bash "$HIMMEL_REPO/…/himmel-gate.sh" <name> <args>, with HIMMEL_REPO absolute and every other HIMMEL_REPO reference refused). Such a command has no VAR= word, no substitution, $'…' or comment, no write redirect other than an fd dup or /dev/null, and a live $ only as a plain $NAME reference. From a dispatcher run on, segments join only by && or |, never ;, ||, & or a newline, and a pipe straight after the dispatcher needs a lone --help (an fd dup such as 1>&2 hides the pipe from the dispatcher’s own check). So a target named in a commit message, git grep, sed -n or a cat heredoc no longer denies. A python command is not on that list: it goes to the text scan unchanged, and its body parse is HIMMEL-6014. The dispatcher runs a registered gate or ledger writer by name from the anchor. Before the exec it refuses an unknown name, a relative HIMMEL_REPO, an anchor off refs/heads/main or whose copy of the script, the dispatcher or anchor-handoff.sh differs from main’s committed bytes, and so for what the script runs or sources (all of scripts/cr/, scripts/guardrails/lib.sh and scripts/lib/load-dotenv.sh for pr-check-context / pr-check-env; go-gate.sh and handover-path.sh for go, the set the guard holds them to), and a piped stdout (a lone --help is exempt).

Why the anchor, not a ref. The compare base is the anchor’s working-tree bytes, never refs/remotes/origin/main. Refs live in the common git dir, which a worker can write (git update-ref), so a ref-based base could be moved onto the edit. The anchor is the primary checkout, which block-edit-on-main keeps workers from editing. A git write can still reach its working tree (git -C <primary> checkout <branch> -- <path>, a detached HEAD), so the anchor must be on refs/heads/main and its guarded paths must equal main’s committed tree (git ls-tree, which runs no filter). The check runs at match time only, so a swap between the check and the exec is not covered. Trade-off: a primary lagging behind main, or ahead of the branch’s base, differs from a clean branch, so the hook denies and the anchored fence is the remedy. That is the safe direction.

The compare runs file by file on <mode> <blob-id> <path> lines from git hash-object --no-filters, so no clean filter, index flag or replace ref can hide an edit. Untracked files, symlinks, other non-regular files and names with control characters all refuse.

When the conditions all hold, the hook exits 0 silently and the permission rules decide as before. It never emits an allow of its own, so it can only narrow. When any condition fails, or cannot be evaluated (no cwd, unset HIMMEL_REPO, a missing tool on PATH, an unreadable anchor, or malformed stdin), it refuses and prints the canonical anchored fence as the remedy. It is a member of MUST_RUN_CHAIN_MEMBERS in run-hook-with-bash.js, so a starved chain denies instead of skipping it.

It sources and execs nothing from the checkout under review. Its git calls ignore inherited GIT_* variables and switch off fsmonitor and replace refs (--no-replace-objects). Fails CLOSED. There is no bypass variable, because the canonical fence is always available and is the remedy. Spec: scripts/hooks/test-guard-pr-check-literal.sh.

block-rogue-claude-schedule.sh — raw scheduler-arm guard (HIMMEL-647)

Fires on Bash/PowerShell. Refuses a tool call that registers an OS scheduler job which launches claude without routing through the sanctioned arming tools (arm-resume.sh, pipeline-cadence.sh, schedule-resume.sh). Blocks when the command BOTH (a) registers a job — schtasks /create, Register-ScheduledTask/Register-ScheduledJob, a crontab write, or at <timespec> — AND (b) launches the claude executable (claude.exe/.cmd, a /claude or \claude binary path, or a bare claude "<prompt>"). Bypass: ROGUE_SCHEDULE_OK=1.

Why: a hand-rolled schtasks /create … /tr <bat> whose .bat is just "claude.exe" "load <handover> …" (no cd /d, no Start In) fires with the scheduler’s default cwd C:\Windows\System32, so the relaunch runs OUTSIDE the repo — a stray ~/.claude/projects/C--Windows-System32 project gets registered, block-edit-on-main can’t find .git, relative handover paths break, and the autonomous run is wasted. arm-resume.sh already emits the cd /d "$RESUME_CWD" || exit /b 1 guard, pre-trusts the cwd (HIMMEL-386), dedups and self-cleans; this guard forces scheduled claude relaunches back through it.

Known limitation (accepted): only catches a rogue arm that writes the launcher AND registers it in ONE tool call (the HIMMEL-647 incident shape). A split across two calls — write the .bat in call 1, schtasks /create /tr that.bat in call 2 — carries no claude token on the register call and is not caught. Targets the accidental shape, not a determined bypass.

block-chokepoint-env-prefix.sh — sanctioned-script env-prefix guard (HIMMEL-1746)

Fires on Bash/PowerShell. Denies a per-call env-prefixed invocation of a REGISTERED sanctioned script (scripts/chokepoints.json — one registry, one reader) when the assignment names one of THAT script’s own registered seam variables (test seams / opt-ins, e.g. STOP_WORKER_BRIDGE_ROOT_OVERRIDE on stop-worker.sh, ARMAUTOMERGE on merge-on-green.sh). The native permission matcher bails on VAR= shapes (HIMMEL-203), so VAR=x bash scripts/handover/merge-on-green.sh cannot match the sanctioned script’s own standing allow-rule and falls through to classifier judgment instead — this guard gives that class a structural home. The scanner positionally SIMULATES the argv each interpreter in the chain (shell, env, env -S) really produces, deriving env’s option handling from its actual option grammar rather than an enumerated spelling list (HIMMEL-1803 rounds 1–6 closed the option-grammar and empty-word gaps that kept reopening this predicate as a class), so a sanctioned script is recognized ONLY when its registered path is the invoked-program token of a command segment, and a seam assignment counts ONLY as a leading assignment of that SAME segment. Fails OPEN on anything unresolvable (missing jq, missing/malformed registry, an internal error) — a belt around sanctioned scripts whose bare invocations are already governed by allow-rules + the classifier, never a general env-var ban. Also denies clearing a registered seam with no assignment word: env -u NAME/--unset NAME/--unset=NAME (same segment, via env’s option grammar) and, cross-segment by design, an in-shell unset NAME or export -n NAME anywhere earlier in the payload — carried forward to every LATER segment only, never denying the invoked-program token itself (HIMMEL-2927). The same cross-segment carry covers a plain or exported ASSIGNMENT with no unset/export -n/env -u in sight: an earlier segment consumed entirely as leading assignment words (SEAM=0;, SEAM=;, two such words in a row) folds those names forward exactly like unset, and export NAME[=val] folds unconditionally regardless of -n — no value inspection, so export SEAM=1 (re-arming) denies too, a documented over-deny in the arming direction (HIMMEL-2933). That cross-segment carry is SCOPED to the ( … ) subshell it was learned in: a clear at paren-depth ≥1 drops back out when its matching ) closes, so (unset SEAM); bash scripts/handover/merge-on-green.sh still allows, while any unresolved paren (unmatched ), an unclosed subshell, a { … } group, or a bash -c/eval string) stays fail-closed at depth 0 exactly like today (HIMMEL-2929). A ( opens a real (scoping) subshell ONLY when it stands at COMMAND POSITION — the first token of a command; every other paren shape is opaque and never changes depth: arithmetic (( … )), command/process substitution $( … )/<( … ), an array-assignment NAME=( … ), and [[ ( ) ]] / case … in (x) grouping parens (each opaque for the mundane reason that a word already consumed command position before the paren), plus any paren nested inside an already-opaque one. Legacy $[ … ] arithmetic is consumed as one opaque, bracket-balanced span, so a ;/&/|/newline inside it neither splits a segment nor lets the following ( read as a real subshell — the $[1 +<newline>(SEAM=0)] shape main denies was briefly allowed by an early paren-depth build (HIMMEL-2929, worker N45). Opaque for SCOPING is not opaque for SCANNING (HIMMEL-3185): segment_cmd lifts every (( … )) / $(( … )) body out whole (arith_body, paren-balanced to the closing )), also inside a double-quoted string) and emits it as its own tagged segment, which scan_segment folds (arith_fold) into the same UNSET_NAMES accumulator let/declare use — so the spaced (( HIMMEL_CONSOLE_LEG = 0 )), +=/-=/<<= and the other compound operators, prefix/postfix ++/--, an array element SEAM[0]=0, a comma-joined (( a = 1, SEAM = 0 )), a ternary arm and a $(( … )) used as a statement, argument or assignment-word value all deny, exactly as real bash assigns the seam in the current shell. Two ordering/nesting rules came out of CodeRabbit’s round on PR #853: the tagged body is emitted BEFORE the segment that holds the call, because bash expands $(( … )) in a word before it execs the command (bash <chokepoint> $(( SEAM = 0 )) leaked while echo $(( SEAM = 0 )); bash <chokepoint> did not — the earlier rows all put the call in a later segment), and a subscript may nest, so arith_fold scans SEAM[a[b]] = 0 with a bracket-depth counter and folds the inside of every subscript as arithmetic in its own right (x[ SEAM = 0 ] = 1). The legacy $[ … ] fold likewise reaches the call in its own segment. ${SEAM:=0} / ${SEAM=0} do NOT leak — they assign only an unset (or, for :=, empty) variable and an armed seam is set — which the oracle pins as noleak rows. The fold names ASSIGNMENT forms only: comparisons and reads — (( SEAM == 0 )), >, >=, <=, !=, $(( SEAM + 1 )) — and assignments to a non-seam name (or one that merely shares a prefix, HIMMEL_CONSOLE_LEG_X) still allow, and a clear inside a real closed subshell ( (( SEAM = 0 )) ); … still allows through #643’s positive rule. Known residual (ponytail: in the source): quotes inside an arithmetic body are not modelled, so a ) inside a quoted string there mis-balances. Each spelling is pinned by a real-bash leak oracle in the suite (a stub sanctioned script prints the four merge-on-green seams; a leak row must change one, a noleak row must leave all four intact). Telling those shapes apart via a blacklist (“any ((/$(/backtick ANYWHERE disables scoping for the whole call”) was tried and abandoned across rounds 1-2 — each found the next false ALLOW at the next nesting shape. The positive command-position rule replaced it: anything the tracker does not positively recognise as a real subshell folds forward to main’s un-scoped deny, so a subshell-scoped clear that shares a payload with an unrelated $(...) elsewhere now allows precisely instead of over-denying. The eval/bash -c recursion target (the re-parsed operand string) forces this same no-scope override unconditionally, regardless of what that string itself contains — a paren-scoped clear and its sanctioned-script call both living inside one -c/eval string (no ((/$(/ backtick of its own) is exactly a “bash -c/eval string” per the rule above and must never be modeled, even though the recursed substring alone would otherwise look like plain balanced parens (CodeRabbit, PR #643 @ 5ce5bbed). Known residual (accepted, unchanged posture, HIMMEL-912): deliberately case-varied paths/vars, a path assembled from shell variables, PowerShell-native $env: syntax, sudo/xargs/find -exec wrappers, and string reconstruction deeper than the bounded eval/bash -c recursion. Bypass: ENV_PREFIX_GUARD_OK=1 (launching shell, session-sticky). Spec: scripts/hooks/test-block-chokepoint-env-prefix.sh.

Text layer for detached and non-Linux launches (HIMMEL-3921). The in-session seam guard (scripts/lib/chokepoint-seam-guard.sh, HIMMEL-3914) only sees calls under a claude ancestor on Linux; setsid -f, at, a pure-bash double-fork, systemd-run --user, cron and non-Linux hosts bypass it. The hook therefore adds a parse-free, deny-leaning scan of the raw command (raw_obfuscated, plus two arms in raw_mention): (1) a seam write (NAME=, export/env/read/printf -v/declare/…) beside a scripts/ path word carrying ANY glob/brace/$var character after scripts/ (no prefix compare or registry resolution: every quote- or escape-split spelling mis-resolved it, so an unrelated scripts/hooks/*.sh beside a write verb also denies, an accepted over-deny; ls scripts/*.sh with no write token is allowed), any word containing scripts/ (relative, ./ or absolute) spelled with a /. or // segment (no normaliser: scripts/lanes/../lanes/stop-w*.sh denies), or an ANSI-C $' word naming a path or carrying any backslash escape other than the plain whitespace/quote ones (\n \t \r \\ \' \" \a \b \f \v; an allowlist, so \x \u \U \c \e and octal all count; a bare $'\t' is allowed), is denied (any clearing token of arm (3) below anywhere in the command also counts as the write here, with no verb anchoring; a seam NAME= passed as the value of a --long-option with no env-like word before it, e.g. ledger-append.sh amend --set k=v, is an argument and not a write here); (2) BASH_ENV, BASH_FUNC_*, SHELLOPTS, BASHOPTS or extdebug beside a sanctioned-script word is denied; (3) CLEARING a seam or HIMMEL_CONSOLE_LEG (ANY unset whatever its argument, any export -n, declare|typeset|local +x, exec -<opt>, ${!, and any standalone -u*/-i*/--u*/--i*/bare - token in env position (HIMMEL-3955: the token follows an env-like word, env, a $var/brace word or a glob that matches env, with only option and NAME=val words between, in the same simple command; the relief is an ALLOWLIST (relief_off): it runs only when the whole command is plain words ([A-Za-z0-9_./=:@%+,-]+) joined by spaces or tabs, its first non-assignment word is a bare read-only program (grep diff ls cat head tail wc stat file cut uniq cmp basename dirname realpath readlink echo printf) and no word is env in any case; any quote, backslash, $, backtick, redirection, separator, paren, brace, glob metachar or newline takes the old plain whole-text match, so the relief never loosens the old deny (a glob-path grep -i … scripts/hooks/*.sh therefore still denies); grep -i, sed -i, diff -u, ls -i, sort -u and a --id N flag are not env-clearing), so env -u NAME/-uNAME/-iu/$x-uNAME/a globbed en? with a long option all deny) beside a sanctioned-script word is denied — this reverses the old “clear-and-prove” allow and over-denies subshell-scoped clears (the raw scan cannot model scope). The lib also fails closed (exit 96) on Linux when /proc is unreadable or is not a real procfs mount (stat -f -c %T /proc must be proc; a fake view via unshare -rm + tmpfs is refused); genuine non-Linux stays hook-only. Deliberate over-deny: quiet-run.sh ... -- env -u X bash test and a worker-launch argv whose brief text mentions env -u or extdebug deny beside a sanctioned-script word; put the control in a script file and run that instead. Residuals (ponytail): an obfuscated seam NAME beside a LITERAL sanctioned-script path (export "$n=1"), a program word that hides both its scripts/ anchor and its glob, and S2/S4 (HIMMEL-3930).

Suite concurrency budget (HIMMEL-1818). Test suites share a machine-wide semaphore (scripts/lib/suite-semaphore.sh): HIMMEL_SUITE_SLOTS slots (default 6) under ${TMPDIR:-/tmp}/himmel-suite-semaphore.d. Only the two sanctioned scripts take a slot — bash scripts/quiet-run.sh suite -- <cmd> (any command) and scripts/ci/run-shell-tests.sh (once per sweep). A busy budget exits 75, naming each holder’s pid, label and age, plus the retry shape SUITE_LOCK_WAIT=60 bash scripts/quiet-run.sh suite -- <cmd> (SUITE_LOCK_WAIT=<seconds> waits for a slot). A slot is reclaimed when its owner is gone, its pid names another process (start-time identity), or it passed HIMMEL_SUITE_SLOT_TTL (default 14400 s). Nested suites re-enter via HIMMEL_SUITE_SLOT_HELD, honoured only when the recorded owner is a live ancestor. The semaphore knobs are registered seams on this hook (a per-call HIMMEL_SUITE_SLOTS=/HIMMEL_SUITE_SLOT_HELD= prefix is denied; SUITE_LOCK_WAIT= is not a seam). There is no invoked-program-level deny of a bare bun test/node --test/test-*.sh run outside the sanctioned script — an earlier version of this hook added one and it false-denied ordinary heredoc body text (commit messages, PR bodies) that merely mentioned a suite name; that mechanism was removed (see HIMMEL-3763). Bypassing the budget entirely (timeout/xargs/find -exec wrappers, $var paths, bun run test/npm test, or simply not routing through quiet-run) is untouched by this hook and tracked under HIMMEL-3763.

require-quiet-run.sh — bare test-suite bounce (HIMMEL-1952)

Fires on Bash. Denies a bare bash/sh/direct invocation of a repo shell test suite (scripts/**/test-*.sh, scripts/ci/run-shell-tests.sh) at command position and prints the exact scripts/quiet-run.sh-wrapped replacement command. scripts/quiet-run.sh already existed, was cataloged (docs/commands-catalog.md, /quiet-run), and CLAUDE.md’s Tests section already explained why quiet reporters are deliberate — none of that prose produced compliance, so per HIMMEL-195 the fix is deny + name the exact replacement, the same shape as block-jira-compound-write.sh and block-graphify-egress.sh; an advisory additionalContext was measured not to change behaviour. Decided per SEGMENT (split on ;/&/|/subshell delimiters), not per whole command, so a compound that wraps one suite call in quiet-run.sh and runs another bare in a separate segment still bounces on the unwrapped one. Deliberately narrow scope: only a suite path actually being EXECUTED counts, never one merely mentioned as an argument to cat/grep/etc., and node --test/bun test are untouched (they already have their own quiet reporters by convention). Two normalization stages run BEFORE the segment split so PROSE QUOTING a suite command is not mistaken for running one (HIMMEL-2322 — it refused a handover-doc write, and the same shape blocks stuck-playbook recovery notes and --body-file PR bodies): heredoc BODIES are dropped unless the heredoc’s own intro line feeds a shell, so cat > doc.md <<'EOF' prose is data while bash <<'EOF' and cat <<'EOF' | bash keep full coverage; and separator characters inside quoted spans are neutralized (single-quoted always, double-quoted only when free of command substitution), so echo "…; <suite>" and a git commit -m body cannot forge a command position while a quoted suite PATH still bounces. Fails OPEN on anything this hook cannot evaluate (missing jq, unparseable JSON, non-Bash tool) — a workflow nudge, not a security fence. Bypass: QUIET_RUN_BYPASS=1 (launching shell, session-sticky). Spec: scripts/hooks/test-require-quiet-run.sh. Two wait shapes that never end are also refused (HIMMEL-4970): until ! pgrep -f <pat> / while pgrep -f <pat> whose <pat> occurs in the same command (the harness runs every Bash call as <shell> -c "... eval '<command>'", so the wrapper shell self-matches; a bracketed [c]lear-cr-marker.sh does not), and a while/until ...; do :; done (or do true) loop with no sleep. The deny names tail --pid=<pid> -f /dev/null, the task’s own output file, and the bracket trick. scripts/quiet-run.sh itself (HIMMEL-2967) fails closed on any argv element with a .. path component and, for the label suite when argv is bash <path> …, requires <path> to be a git-tracked test-*.sh — closing the gap where a permissions.allow tail-glob rule (HIMMEL-2959) admits the literal label + directory but quiet-run itself performed no path validation before exec. The tracked-file check itself only applies inside a git repository — outside one (no .git discoverable) it prints a skip note and lets the command through unchecked, since there is no tracked-vs-untracked distinction to enforce; any OTHER git rev-parse failure (git absent, a transient error) still fails closed.

block-docker-privesc.sh — root-equivalent container guard (HIMMEL-441)

Fires on Bash/PowerShell. Membership in the docker group is root-equivalent: an agent in it can start a container as root and bind-mount any host path writable, bypassing file permissions, block-read-secrets, AND block-edit-on-main (the motivating case wrote /etc as root via docker run -v /etc:/host-etc:rw … install …). Blocks docker/podman run|exec|create (and docker cp) when it detects:

  • Secret-bearing bind-mount — any mode (:ro or :rw): /, /etc, /root, the docker socket, $HOME itself, $HOME dotdirs (.ssh .aws .gnupg .kube .docker .config), and the Windows home tree C:\Users\<user>. /home is NOT a blanket prefix — $HOME/Documents/proj is allowed.
  • System-integrity bind-mount — writable only: /usr /bin /sbin /lib /lib64 /boot /var /sys /proc /dev (read-only mounts leak no secret; a small read-only allowlist under /etc — localtime, timezone, resolv.conf, hosts, ssl/certs, ca-certificates — is carved out).
  • Privilege flags: --privileged, --pid=host/--pid host, --user 0|root / -u 0|0:* / -u0 / --user=0|root, --cap-add of a root-equivalent cap (SYS_ADMIN SYS_PTRACE DAC_OVERRIDE DAC_READ_SEARCH ALL), --device of a host block device, --volumes-from.

Host paths are normalised first (expand ~/$HOME; $PWD/relative → project-local/allowed; Windows \→/ with drive-colon-aware -v splitting; collapse /./, /../, //, trailing /). Bypass: DOCKER_PRIVESC_OK=1. Accepted limitations (header): docker exec into an already-privileged container, --volumes-from re-mounts, env-substituted paths it cannot resolve, /proc/self/root/symlinks, rootless podman (treated the same), and a container COMMAND arg that literally equals a privesc flag (rare FP → use the bypass). Spec: scripts/hooks/test-block-docker-privesc.sh.

block-merged-pr-commit.sh — merged-PR branch commit guard (HIMMEL-512)

Fires on Bash/PowerShell. Blocks a git commit whose target branch has an already-MERGED pull request on the forge. The signal is forge_pr_has_merged <branch> from scripts/lib/branch-shipped.sh (via forge.sh), which calls gh pr list --state merged --head <branch> and returns a merge count.

Fail-OPEN posture (hygiene guard, not a security boundary): every uncertain path exits 0 and lets the commit proceed — missing jq, unreadable stdin, non-literal cd / -C arguments, detached HEAD, forge unreachable, timeout (default 10 s), non-numeric payload. Only a positively confirmed merged-branch commit triggers a block. The guard warns to stderr on forge-unreachable paths so the uncertainty is visible without blocking.

Bypass: MERGED_PR_COMMIT_OK=1 set in the shell that LAUNCHED Claude (MERGED_PR_COMMIT_OK=1 claude) — follows the same session-sticky convention as the other block-* hooks.

Delivery: shipped via the himmel-ops plugin hooks.json (same exec-if-exists $CLAUDE_PROJECT_DIR pattern as block-docker-privesc); live only after /himmel-update (marketplace re-sync) + a fresh session.

Paired artifacts: scripts/lib/branch-shipped.sh (predicate), scripts/hooks/test-block-merged-pr-commit.sh (smoke suite).

block-unresolved-cr-merge.sh — CodeRabbit merge-remark gate (HIMMEL-936)

Fires on Bash/PowerShell. Blocks a gh pr merge while the target PR has unresolved CodeRabbit review threads or an undisposed outside-diff body finding; CodeRabbit’s verdict on the head SHA is a NOTE, never a block (HIMMEL-3360, best effort) — structural enforcement of the operator’s “never merge over unresolved CodeRabbit remarks” rule (HIMMEL-195: structural > instructional). The signal is cr_merge_gate <pr-selector> [<owner/repo>] from scripts/lib/review-merge-gate.sh, which runs a GraphQL reviewThreads query (counts unresolved threads whose first-comment author matches coderabbit, covering both coderabbitai and coderabbitai[bot] login forms) and reads CodeRabbit’s commit status on the head SHA via scripts/lib/coderabbit-signal.sh. The same predicate is called from scripts/handover/pr-merge.sh (GitHub forge only) before its mergeability poll, so machines without the plugin hook still get the gate on that path (a block there exits pr-merge.sh with code 5, distinct from a real gh pr merge failure’s code 4).

HIMMEL-1072 — CodeRabbit posts a STATUS, not a check-run. This gate used to query check-runs for .name=="CodeRabbit", which matched nothing on every PR since it shipped (verified on 5 consecutive live PRs: check-runs = 0, statuses = 3–5). The in-flight block never fired and the HIMMEL-980 zombie override beneath it was unreachable; the fixtures mocked a check-run, so the suite confirmed the wrong shape instead of catching it. statusCheckRollup merges check-runs AND statuses, which is why gh pr checks showed CodeRabbit while the check-runs API did not. The gate now reads the /statuses LIST endpoint (the combined /status drops creator, and the rollup carries no identity at all) and matches on creator.id — HIMMEL-1058’s “identity, not display name”. The zombie override is gone: reviving it on the status would let a >90m pending review through on age alone, which is the same uncertainty-reads-as-green bug. CR_ZOMBIE_CHECKRUN_MINS is no longer read.

Posture: deny on unresolved threads and on undisposed outside-diff body findings — positive evidence only. A CodeRabbit status of pending/failure/error/absent/skipped prints a NOTE and falls through (HIMMEL-3360): from 2026-07-16 to 2026-09-21 absent BLOCKED, because an unreviewed head reading as green is what merged #1243 with 6 unresolved threads (reverted in #1244); the thread gate is what actually catches that shape, and the status arm only held finished PRs on an App that was late. INFRASTRUCTURE failures still fail OPEN — gh/jq missing, incomplete PR metadata, GraphQL/status API error, jq parse failure, and no gh pr merge at command position all exit 0; an unresolvable selector (gh pr view failure) exits 3 — allow, but re-anchorable, so the PreToolUse hook retries once with the cwd branch rather than letting a quoted/mis-tokenized selector dodge the gate (top-level consumers treat any non-2 rc as allow). Each emits a cr-merge-gate: degraded (<why>) - failing open note to stderr so the uncertainty is visible without blocking. A broken query is not evidence.

HIMMEL-3915 — the PR lookup fails CLOSED. The hook resolves the PR number and head with its own gh pr view (selector, then, when the selector is not already the cwd branch, one re-anchor to the cwd branch) BEFORE any gate runs, bounded to 10s per attempt. On a real merge command, an unresolved lookup (every attempt that ran failed, or timed out) DENIES for every session, naming scripts/handover/merge-on-green.sh as the sanctioned path; a refusal costs a retry. It used to exit 0 after the CR/CI gates, so a transient gh/auth error let a trust-path PR through with no GO. The fail-open contract above covers the CR/CI gates’ own API reads (a non-timeout failure); the residuals that were open here are closed by HIMMEL-3918, below. There is NO --help/-h carve-out: a help-flagged merge is gated (and fails closed) like any other merge, and the deny text points at gh help pr merge, which is not a merge and does not match the command regex. Every spelling of the carve-out was a new bypass (an escaped space in a --body value, $'..' quoting, delimiters), so it was removed rather than patched again. Known remaining gap: a merge with no selector AND no cwd branch still exits 0 (nothing to resolve).

HIMMEL-3929 — the detector only ever adds to main’s. The detector used to be the text regexp gh pr merge, so every spelling it missed merged with no gate. That regexp still runs byte for byte (on its own normalized copy) and never stops firing: every rule below only ADDS a deny, so nothing the old detector denied can pass (C="gh pr merge 42"; $C, eval "$C", ./gh pr merge stay denied). The added rules, each per segment (split on ; & | (): (A) a gh word, then only flags (-R/--repo take a value), then a pr word, then a merge word, or gh api / gh alias followed by any merge-ish word (.../pulls/N/merge, mutation{mergePullRequest, alias set mm pr merge); gh pr view, gh pr list --search "merge conflict" and gh pr diff | grep merge never reach a merge word there. (B) a segment with a gh word, a $ or backtick and a merge-ish word fires, where merge-ish is a literal merge word in any copy or a word whose literal residue is letters of merge (m${X:-er}ge, $'m\x65rge', gh${IFS}pr${IFS}merge); a non-merge gh command with $ or a substitution (gh pr view "$PR") is untouched. (C) a segment with a gh word, a pr word and a glob word that globs to merge (m?rge, [m]erge, a bare *, [!x]erge, [^x]erge) fires, after ONE shared dequote (quotes and backslashes stripped: "gh" "pr" m?rge, \gh pr $M, g\h pr $M); ls * has no such precondition. Separately, the GraphQL mutation name mergepullrequest anywhere in the dequoted, lowercased command denies, whatever ( or ; splits around it. (E) one shell-computed word, by position: a literal gh word then an expansion or substitution in the subcommand position or, after pr, in the verb position (gh pr "$M" 1, gh $A 42, gh "$@" 42), or a segment with any non-plain word (rule D’s allowlist, so command/env/nice "$G" and X=1 "$G" count) before an api word, followed by a merge-ish word or a PUT to a pulls/ path (G=gh; "$G" api -X PUT repos/o/r/pulls/1/merge), fires; gh pr view "$PR" never has an expansion in those positions. Note the new deny of the GET form gh api .../pulls/N/merge, which the merge rule cannot tell from the PUT. (D) the mirror case: in a segment with a pr word then a merge word, every word up to the merge word must match ^[A-Za-z0-9_./+=-]+$ (quote chars and backslashes ignored), else it fires ($'\x67\x68' pr merge, G=gh; ${G} pr merge, "$G" pr me\rge). Once fired, the raw-char allowlist above refuses every expansion spelling, and the command must parse as exactly gh [-R v | --repo v | --repo=v]* pr [same]* merge <closed tokens>; gh api .../pulls/N/merge (and mergePullRequest GraphQL), gh alias set|import naming a merge, bash -c/sh -c/eval/env wrappers, a prefix or chain, and any other flag placement deny. The -R/--repo value found before the verb is the gated repo exactly like one after it. The one exact exception is gh help pr merge (the deny text’s own pointer). Non-merge gh (gh pr view, gh api .../pulls/N, gh run, gh alias list) and git merge main && gh ... (merge before gh) are untouched. A gh alias that expands to a merge and is invoked by its own name (gh mm 8) is not visible to a text detector; defining one is what the alias rule denies. Known residual, exactly two shapes: BOTH the program word AND the verb shell-computed (or a bash -c fed from a variable holding both), or a computed merge path such as gh api -X PUT "$U"; each is undecidable by text and is not detected; the structural backstop (a gh- or credential-level merge gate) is HIMMEL-3945. Fail closed also for the nested sources: scripts/lib/review-merge-gate.sh returns 1 (so the hook’s load check denies) when one of its five helpers is missing, and the hook denies when timeout-bin.sh will not load. Fixtures that copy review-merge-gate.sh into a scratch tree must copy its helpers too.

HIMMEL-3918 — six residuals closed, by ONE deny rule rather than parsing (five panel rounds each found a new redirect/prefix shape; every parse rule in this hook became a bypass in #1494 and again here). A command whose unquoted text names gh pr merge must be a single plain command with no quoting: the raw text may contain only [A-Za-z0-9], space and _ . / : = - ,, and its first word must be literally gh; anything else denies. There is no quote-stripping (a strip that disagrees with bash on ', " and $'..' was a bypass, so every quote, #, \, $, redirect and separator is denied outright). The gh pr merge detector runs on a normalized copy (backslash-newline pairs removed, then every backslash, then every ' " and $, newlines to spaces), so gh pr mer\<newline>ge, m""erge, "merge" and $'merge' are seen (word forms that need a real expansion, ${X:-merge} and {merge,}, are closed by HIMMEL-3929, below), and only a hit on that copy triggers the allowlist. Every word after merge must then be in a closed set (-s -m -r -d --squash --merge --rebase --delete-branch --disable-auto; --auto (the pin binds at enable time, a later push changes the head) and --admin (merges past required checks) deny, -R/--repo <v>, --match-head-commit <v>, at most one selector); grouped short flags (-dt), attached -Rrepo, -b/-t/-F/-A, unknown flags and a second positional deny, because gh’s pflag reads those differently from the hook (-dt 5 is -d -t 5). A custom subject/body goes through scripts/handover/merge-on-green.sh. Quoted mentions of gh pr merge (e.g. echo "gh pr merge") are over-denied by design. That one rule replaces the earlier cd/pushd, prefix (env/command/builtin/exec/NAME=value) and redirect handling, all of which now deny (“single plain command”), each naming the merge-on-green sanctioned script (scripts/handover/) as the sanctioned path. A merge-on-green invocation carries no gh pr merge text, so it is untouched. Over-denies, accepted (HIMMEL-3917 precedent): any mention such as echo gh pr merge, a path-qualified /usr/bin/gh, any quoted argument, and any chained merge (HIMMEL-3925 tracks narrowing the quoted case).

  1. cwd mismatch, prefixes, redirects: all covered by the rule above.
  2. Bounded gate lookups: the hook shadows gh with a 10 s bounded function for everything the sourced gate libraries call (libs untouched) under ONE 45 s hook budget: each call is capped to what remains (the repo-name lookup and the trust-path check included, the latter to min(30 s, remaining)) and none starts once it is spent, so the hook denies before Claude Code’s 60 s timeout (which reads as non-blocking). A timeout on a real merge denies (“timed out (10s bound)”), never a hook-budget expiry.
  3. Lib-load: a gate lib that is missing, unreadable, unparseable, or lacks its function denies (was || exit 0).
  4. Head TOCTOU: every direct merge must pin --match-head-commit (space or = form) equal to the head the gates read, else deny. Checked last so each earlier gate keeps its own reason. The merge sanctioned script pins its own head in a subprocess the hook never sees, so it is unchanged. CRs are stripped from the command at the capture boundary. Not covered: bash -c '...' wrappers (the executor grammar, owned by the prlit backstop work). A degraded VERDICT query is the one exception that does not return early: the thread query still runs and its evidence still blocks, because an unresolved thread is evidence even when the status endpoint is down (observed live — GitHub 503’d /commits/<sha>/statuses on 2026-07-17).

Review freshness (HIMMEL-1181, B2) — retired (HIMMEL-3360). The gate once denied on a latest bot review anchored to an older commit than the head (the cr-review-freshness library, GraphQL, now deleted; live instance PR #1273: status success + 0 unresolved threads over a head nobody reviewed, because a clean incremental posts no review object and GitHub outdates the old threads). CodeRabbit is best effort, so a stale anchor is now the same NOTE as an absent review, and the reader plus its suite are deleted. What that head still owes is what every head owes: zero unresolved threads and the exact-head critic panel via /pr-check.

Chief GO gate (HIMMEL-2919 / HIMMEL-3142) — runs THIRD, after the CR and CI gates. A worker spawned by a chief (chief-kit/headed-arm-worker.sh exports HIMMEL_CONSOLE_LEG=1) may merge only on its chief’s GO. The hook first checks the marker is non-empty at all — an unset/empty marker (the ordinary session) never sources scripts/lib/go-gate.sh, so a missing or broken library cannot block a merge the gate was never meant to bind. A non-empty marker sources it (dropping any inherited go_gate/chief_worker first) and chief_worker applies the one shared reading: empty/0/false/off/no, case-insensitive and whitespace-stripped, is not a worker; anything else is. A worker’s gh pr merge then resolves the PR number and head SHA itself and calls go_gate <pr> <head> <handover_root>, which is pure (no gh, no write): rc 0 only when <root>/.locks/go/<pr>.<head> exists and carries the line head=<head> exactly; otherwise rc 2 with a one-line reason naming the exact GO path (no root / no file / a GO for an older head are all the same refusal — a stale GO is never reusable). The GO file (merge approval) is written only by the chief (chief-kit/go.sh, which itself refuses under the marker), so the evidence is a file the worker cannot set on its own command line. Once a GO is confirmed the hook additionally REQUIRES the merge command to pin --match-head-commit (space- or =-form, quotes stripped) equal to that GO-bound head: an unpinned or mismatched pin exits 2, because the GO was bound to this hook’s own gh pr view read and a separate gh pr merge could otherwise land a different commit. Only the GO/pin half is fail-CLOSED (a missing go-gate.sh, or one that sources but lacks go_gate, exits 2); an unresolvable PR selector on a real merge is now fail-CLOSED too (HIMMEL-3915, above), no longer open like the CR/CI siblings. merge-on-green.sh runs the same chief_worker/go_gate pair as its own gate (below), using the $sha check-ci certified — the same head its --match-head-commit pins — and go.sh uses chief_worker for its refusal, so none of the three can drift on “is this a worker” or “what a GO binds” (HIMMEL-3149).

go.sh itself refuses to write a GO for a head nobody reviewed (HIMMEL-4565): before it signs anything it runs the anchor’s own chief-kit/ready-check.sh <pr> <head> from the caller’s cwd, and any non-PASS exits 4 with ready-check’s output and writes nothing. That covers the CR ledger (check 4: an ok row for that exact head in <git-common-dir>/cr-critic-scores.jsonl), the head and merge state, the check rollup, unresolved threads, trailers, ticket ids and coverage. It re-runs rather than trusting a recorded PASS, so the chief’s flow is unchanged (ready-check, then go) and no file a worker could write stands in for the check. A trust-reviewed GO (--trust-reviewed <qid>, HIMMEL-3832) also needs the judge’s verdict on disk: go_trust_verdict reads every *.md in <root>/<user>/<bucket>/verdicts/<qid>/ only (the scope go_verdict_scope resolves for the anchor, HIMMEL-4589; an unresolved scope exits 5, fail closed), takes the first non-blank line under ## Verdict, and accepts only **GO** for head \<40-hex>`orNO-GO for head `<40-hex>`, with one optional trailing full stop. It passes only when no file is unparsed, none is NO-GO for this head, and at least one is GO for it. A verdict for another head is ignored as an earlier round. Anything else exits 5 and writes nothing: no files, a free-form line, or an id that is not a path segment ([A-Za-z0-9][A-Za-z0-9._-]*). The id is still signed into the trust mac, so the HMAC domains and merge-on-green's exits 17/21 are unchanged. HIMMEL-4984: write-verdict.shends each record withmac: <64 hex>, an HMAC under the GO key (domain himmel-verdict-v1, message scope|qid|name|sha256(every byte above the mac line)); go_verdict_mac_okrefuses an unsigned or edited record, so a hand-written exact-format file no longer passes, here or inreview-round.sh(NO-GO delta, layer-decision and scope paths). Alayer-decision: lifts the HIMMEL-4885 repeated-class stop only from a record whose mac verified (an unsigned NO-GO still feeds the classes and still vetoes, but cannot unlock). Every consumer reads a record once and verifies and parses that one copy (go_verdict_snapshot), so a rewrite between the check and the read cannot reuse a verified mac. A record that cannot be read whole (unreadable, empty, NUL byte) fails closed: go_trust_verdictrefuses (rc 2) and the scope round is refused rather than skip it, andjudge_nogo_recordkeeps its qid in the repeated-class history;write-verdict.shrefuses NUL and other non-text control bytes in evidence.go_trust_verdict also refuses a scope-round record (delta-scope:/delta-from:), and review-round.shbinds scope records to the branch's PR/branch and lets any NO-GO for the new head block the scope round. Records written before this change are unsigned and are refused; re-runwrite-verdict.sh` for an open PR’s judge record. Residual: the key is readable by the worker’s uid, so a same-uid forger can still sign; it proves a record came through the writer’s format and key, not who ran it. Like the key residual below, that is HIMMEL-3897’s.

CodeRabbit availability — arm it per repo (HIMMEL-1125)

⚠️ Setup step. On a repo that HAS the CodeRabbit App, run this once:

Terminal window
git config --local himmel.coderabbit true

Until you do, the CodeRabbit merge gates are a no-op there. That is the deliberate trade-off below — read it before deciding it is a bug. Since HIMMEL-3360 the arming decides only whether CodeRabbit’s signal is READ at all (its NOTE lines and any outside-diff body findings it posts); an unarmed repo and an armed repo whose App never answered both pass on CI green + zero unresolved threads + the panel, so arming can no longer block a merge on its own. The history below explains why it is a config value and not a file.

Why. A repo without the CodeRabbit App has NO status on any head, so an armed gate blocks every merge there forever. HIMMEL-1072 shipped exactly that: the gate was armed by default on repos it could never pass on, and adopters had to discover CR_PROFILE=none to work at all. So the gate is now availability-gated — scripts/lib/coderabbit-available.sh answers “is CodeRabbit’s App configured for THIS repo?”, and the gate arms only when it says yes.

The signal is a repo-scoped, NON-VERSIONED git config value — deliberately not a committed .coderabbit.yaml. A committed file is evidence of a config, never of an installation, and it is wrong in both directions:

  • Config without the App — .coderabbit.yaml is tracked, so every clone/fork inherits it while inheriting no App installation. This harness ships by being cloned, so that is the main adopter path: arming on the file would block their merges forever. (Found by the codex adversarial pass on the original design.)
  • App without config — an App on defaults publishes no file, so the gate would silently disarm on a repo that really does have CodeRabbit.

A tracked file is also PR-controlled at exactly the moment the gate matters: a diff that deleted it would disarm the requirement reviewing it. git config --local lives in .git/config — outside any tree, so it cannot be inherited by a fork, added or deleted by a PR, or copied around. --local and not global: availability is per-repo, and a ~/.gitconfig value would arm every repo on the machine (the same over-reach, one level up).

The trade-off, stated plainly: this reverses the HIMMEL-1072 fail-closed default. An operator who never arms a repo silently has no CodeRabbit merge gate there. That is the accepted cost of never blocking an adopter who has no CodeRabbit at all (operator call, 2026-07-17), and it is why arming is a documented setup step rather than inferred.

Overrides: CR_APP=1 arms without the config (shallow CI, or an App on defaults); CR_APP=0 disarms without touching the critic profile; CR_PROFILE=none still disarms and outranks both.

Scope of the disarm — the part worth being precise about: it turns off only the CodeRabbit-specific requirement (an absent CR verdict stops being a blocker). The generic unresolved-review-thread gate in check-ci.sh is NOT keyed on the probe: it blocks on any reviewer’s unresolved thread, human included, for everyone, and “cannot evaluate” still fails closed for everyone. Disarming CodeRabbit never turns a real finding green.

The MACHINE-GENERATED PR class — HIMMEL-2278. Some PRs never receive an App review at all, so “absent” is their steady state and the armed gate blocks them forever. Two shapes, both with a precedent PR that ended in a manual operator merge after a chief parked on a review that was never coming:

ShapePrecedentWhy the App never reviews it
Bot-authored dependency bump#2013 (dependabot)CodeRabbit does not review bot-authored PRs
Pure regenerated-artifact publish#2035 (/graph-publish)zero code; a critic panel over a 17 MB regenerated graph.json is review theater

check-ci.sh once carried a machine_pr_gate classifier that exempted these two shapes from the fail-closed status gate. Since HIMMEL-3360 the App’s SILENCE is tolerated on every PR, not only this class — absent, pending, paged, an unreadable status query and the skip-classified family (Review rate limited, automatic reviews are disabled, …) are all NOTEs — so the classifier had nothing left to decide and was deleted; the table above is kept as the precedent record. What gates every PR, machine or not: the checks-green watch, the CHANGES_REQUESTED blocker, the paginated unresolved-thread gate, and cr_body_gate’s outside-diff-range body findings whenever the App did post.

Zero-review-object shapes at a head. A clean walkthrough with nothing actionable mints no review object, and neither does a rate-limited or skipped run, or a success :: Review completed status over a PR that never carried a review object; a review object with bodylen=0 and zero threads is an empty carrier. check-ci.sh tells the shapes apart in its NOTE but gates none of them: “0 unresolved threads” over an unreviewed head is exactly the best-effort case, and what that head owes is the exact-head critic panel (/pr-check), which does not depend on the App. bodylen=0 objects with threads are inline carriers and their threads count like any other.

The first cut set CR_ARMED=0 instead — the same thing an operator does by hand with CR_APP=0. That also silenced cr_body_gate, so on the day the App did review a machine-class PR, its outside-diff-range findings — which carry no thread and are therefore invisible to every other gate — would have been dropped. CR_APP=0 remains the right manual escape hatch for a human who has looked at the PR; an automatic classifier must be narrower than one.

Neither arm is a label, a title marker or a body token — a marker an arbitrary author can set would be a one-line CR bypass for any code PR, which is worse than the drift it fixes. Each is derived from something a code PR cannot cheaply fake:

  • dependabot — GitHub’s own author.is_bot and a dependabot login. A human account cannot set is_bot, and cannot open a PR as an App.
  • graph-publish — the diff shape: every changed path must be one of the two tracked graphify-out/ artifacts. Not both present — when only graph.json actually changed, the publish PR is a single file and is still in the class. A PR carrying any code touches at least one path outside that two-element set and is therefore never in the class, whatever it labels or titles itself. (graph-publish.sh says so in its PR body and posts no review-trigger comment on open, but that text is documentation for human readers — the gate reads the diff, not the prose.)

Any unreadable, erroring, sentinel-less or truncated classifier probe returns “not the class” and leaves the gates armed: the emitted path count must equal the count the probe advertised, so a partial file list can never read as a complete one. The controls live in scripts/test-check-ci.sh (cases 2278-c … 2278-r): the artifact pair plus one code path still fails closed, a dependabot login without is_bot is not the class, a file literally named * cannot glob its way in, a truncated list fails closed — and on a PR that IS in the class, unresolved threads, a red check, CHANGES_REQUESTED, a failed or pending App status, and an outside-diff body finding all still block.

Cross-repo: the probe describes the local clone, so cr_merge_gate refuses to apply the local answer to a foreign gh pr merge --repo other/thing target — it holds no signal for that repo, so it does not gate it. A malformed --repo value is not treated as foreign; it falls through to the rc=3 re-anchor path, or a quoted --repo would silently disable the gate.

Caveat — CR_PROFILE=none is overloaded. It is a critic-panel profile (“claude-only review”, .env.example) AND, since HIMMEL-1072, the CodeRabbit merge-gate opt-out read here. So choosing claude-only review also silently drops this gate. HIMMEL-1125 preserves that coupling rather than widening it; CR_APP is the unambiguous availability switch.

Bypass: CR_MERGE_GATE_OK=1 set in the shell that LAUNCHED Claude (same session-sticky convention as the other block-* hooks). CR_PROFILE=none or CR_APP=0 skip the gate entirely, and a repo that was never armed (git config --local himmel.coderabbit true) does not gate at all. Precedence, highest first: CR_PROFILE=none → CR_APP (1 arms, 0 disarms) → the repo-scoped himmel.coderabbit config → default: disarmed.

Delivery: shipped via the himmel-ops plugin hooks.json (same exec-if-exists $CLAUDE_PROJECT_DIR pattern as block-docker-privesc / block-merged-pr-commit); live only after /himmel-update (marketplace re-sync) + a fresh session.

Paired artifacts: scripts/lib/review-merge-gate.sh (predicate), scripts/lib/coderabbit-signal.sh (the ONE reader for CodeRabbit’s verdict — shared with ci-green-gate.sh and check-ci.sh, so all three agree on the bot’s identity by construction), scripts/lib/test-cr-merge-gate.sh and scripts/hooks/test-block-unresolved-cr-merge.sh (smoke suites).

CodeRabbit is the App, and only the App (HIMMEL-2704)

Operator ruling 2026-09-07: the GitHub App is the CodeRabbit surface. There is no CLI. The coderabbit-review wrapper (the CLI wrapper) and coderabbit-gate (the /pr-check conserve-or-run gate) are DELETED, together with CODERABBIT_BIN, CODERABBIT_WSL, CODERABBIT_TIMEOUT_SECS, CODERABBIT_CLI_DISABLE and CODERABBIT_ALLOW_WSL. HIMMEL-2683 (“arm the CodeRabbit CLI”) is closed won’t-do.

Why. The CLI was a second charge against the SAME rate-limited CodeRabbit account for a signal the App already posts on the PR — and worse, each CodeRabbit-driven fix round it forced also cost an extra paid codex panel row, because the cross-model marker gate needs a non-Claude availability row at the NEW head. Findings are commented on and tracked where they belong: on the PR, in GitHub.

What replaces it: nothing, pre-PR. The critic panel and the codex adversarial pass carry /pr-check; CodeRabbit enters after gh pr create. That is a real change in coverage for a repo with no App — such a repo now gets no CodeRabbit signal at all, which is the accepted cost of the ruling. It does not weaken the merge gates above, because those were always keyed on the App.

What the App owes, and who checks it — CodeRabbit is best effort (HIMMEL-3360): one CI-only reviewer beside the local critic panel, never run locally, never waited on. The push/PR hooks PRODUCE its trigger (3); scripts/check-ci.sh is the post-PR BACKSTOP that reads the head’s thread state and CI, and prints the App’s own status as a NOTE. The structural backstop for the rule itself is scripts/hooks/check-cr-best-effort.sh (pre-commit): a tracked line that names CodeRabbit and schedules, queues or holds work on it fails the commit, with one same-line escape (cr-best-effort-ok: <reason>) for quoted history. Seven finished PRs sat ~7 h on 2026-09-21 because docs and memory taught “one review per hour, sequence the opens”; that was the second drift, so the rule became a gate.

  1. Zero unresolved review THREADS. A green check-run is NOT clean. The thread query is the truth. gh pr checks reads statusCheckRollup, which merges check-runs and statuses and says nothing about whether anyone resolved what CodeRabbit wrote: PR #2209 carried six unresolved CodeRabbit threads under a pass rollup on 2026-09-06. check-ci.sh exits 3 on unresolved threads, and that gate is NOT availability-keyed — it is generic and blocks on any reviewer’s unresolved thread, human included. A CodeRabbit finding that EXISTS — a thread, or an undisposed outside-diff body finding (cr_body_gate), including one posted at a PRIOR head when the current head carries no review at all — blocks exactly like a human’s. Best effort covers absence only (HIMMEL-3360): a review that is rate-limited, silent or not yet posted never holds the gate; a finding that was posted always does, until it is dispositioned in the CR ledger at the head it was posted at.
  2. Its commit status is informational. CodeRabbit posts a commit status, never a check-run (verified live on five PRs — see scripts/lib/coderabbit-signal.sh), so it is matched by creator id, not display name. absent, pending, paged, skipped (a success whose description reads Review skipped: automatic reviews are disabled, HIMMEL-1317) and a stale review anchor are all the same thing to the gate: a NOTE naming the state, then the thread gate above. The reader arms only on a repo armed per HIMMEL-1125 with git config --local himmel.coderabbit true; unarmed, it is silent. A failure / error status is a NOTE too — what the App found reaches the gate as threads and body findings, not as a status colour.
  3. One trigger per head, from CI, never from a session. auto_review is off in .coderabbit.yaml, so the review runs on an explicit @coderabbitai review comment that scripts/hooks/trigger-cr-on-pr-create.sh and trigger-cr-on-push.sh post structurally — a head move DISCARDS the in-flight review, so the post follows every push. cr_trigger_repo_armed (HIMMEL-2034, in scripts/lib/coderabbit-trigger-ledger.sh) gates that comment so the harness never summons the bot onto a repo it does not own the CR gate for. That post is the whole of the harness’s relationship with the App’s rate limit: nothing counts, rations or sequences reviews, and check-ci.sh requests none of its own (the --escalate full-review path is retired).

One consequence worth stating plainly. scripts/cr/pr-check-external.sh (the Claude-free ship lane) required, per HIMMEL-1224, a quorum of TWO responding cross-model reviewers before it would clear a diff that changes the CR/merge gate infrastructure itself. The CodeRabbit CLI was that second seat, and scripts/cr/critics.json’s panel is codex-only (the glm row was retired 2026-08-18, HIMMEL-1904). With no second seat the quorum cannot be met, so that lane now REFUSES gate-infra diffs outright — the same disposition it already produced whenever the CLI was absent, which was the common case. Gate-infra changes go through the interactive /pr-check (Claude backstop). Adding a second non-Claude row to critics.json re-opens the lane.

Codex adversarial pass — re-armed on high-risk diffs only, once per branch (HIMMEL-2707)

The one-suite-run invariant. The after-report’s == Summary == PR comment is the ONE suite-run artifact any PR gate consumes — nothing in the merge or CR path re-runs scripts/ci/run-shell-tests.sh against a head that already carries one. scripts/chief/base-status.sh:257-316 is the implementation: it reads the after-report Summary keyed by head SHA + fence scope and refuses to certify a fence PASS unless a Summary exists at that exact head, is not TRUNCATED (a budget-expired run), was not filtered by --changed-since, carries a parseable FAIL: N count, and its scope: line actually covers the fence being certified — any of those failing reads PENDING, never a silent pass. This is a description of an invariant the code already enforces, not a new gate: no reviewer, critic, or CR lane in this repo — including the pass below — has ever re-executed the suite; each one reads (or fails to read) the same Summary artifact.

Removed (HIMMEL-3818). The codex adversarial /pr-check pass (codex-adv-kickoff.sh, codex-adv-harvest.sh, codex-adv-completion-check.sh, run-codex-adversarial.sh) is deleted. It had sat dormant since HIMMEL-1957 (nothing in the repo set CODEX_ADV_OK), drew on the same OpenAI subscription usage budget as the panel’s codex critic, and agreed on 6% of its findings against 23% for the panel codex. The HIMMEL-1957 “it re-runs the suite” hypothesis was disproved by the HIMMEL-2707 audit (the companion runs sandbox: "read-only" with approvalPolicy: "never"), and HIMMEL-2737’s flip-it-on question was decided REMOVE by the operator on 2026-09-29. Gate 3b no longer accepts a codex-adv availability row: a legacy row of that name is not cross-model evidence, so the panel’s own non-Claude rows are the only source.

Main CI cadence — cron, not per merge (HIMMEL-5113)

ci.yml has no push: branches: [main] trigger: a merge starts no run (it was ~52 runs/day, half cancelled as superseded). Main is swept by schedule cron 43 1,8,15,22 * * * (UTC; nominal gaps 7/7/7/3 h — cron cannot express an exact 7 h period, so the worst cron-string gap is 7 h; GitHub schedule delays of 4–9 h are observed on this repo, so the real gap can reach ~11–12 h) on the ubuntu-only fast tier, by the nightly cron 17 7 * * * (SUITE_TIER_MODE=all, guard-corpus-full; nightly-only gates key on github.event.schedule, not event_name), and by gh workflow run ci.yml --ref main on demand. PR triggers are unchanged.

  • A break surfaces up to a nominal 7 h later (more when GitHub delays the schedule) and spans several merges. The cron run’s “Merge range since the previous main run” step summary lists git log --oneline <previous completed sweep>..<sha> (best effort).
  • A merge with no run at its sha is unknown, neither red nor green. The red signal is the newest completed cron or dispatch run on main (main-sweep-red.yml gates on event schedule/workflow_dispatch; the nightly — recognised by a non-skipped guard-corpus-full (the script also still matches a windows-latest job, which no longer runs) — is skipped entirely, because its tier=all shell-unit has the same job name as the fast sweep’s and an extended-only red would otherwise be closed by the next fast sweep; extended reds go to shell-extended-nightly-issue.sh). The issue records the writing run’s id and an older run never closes or refreshes it. cut-tag.sh refuses with gh workflow run ci.yml --ref main named when no run or no check-run exists at the sha.
  • Tag cuts therefore need a dispatched or cron run at the exact tip, green, before cut-tag.sh.

CI-green merge gate (HIMMEL-1043)

Runs on the SAME gh pr merge path as the CodeRabbit gate above, one step after it: blocks the merge while the target PR’s head SHA is not green — any non-CodeRabbit check-run with a red conclusion (failure, timed_out, cancelled, action_required, startup_failure, stale) or still pending (status != completed), or a non-CodeRabbit commit status (newest per context) of failure/error/pending. It also fail-CLOSED-blocks when the head has >100 check-runs (more than the single API page can certify — a failing/pending run may sit on an unread page 2; same page-limit stance as cr-merge-gate, HIMMEL-980). This repo has no branch protection, so GitHub will not otherwise block a merge over red/pending CI — this gate is the structural “ready to merge ⇒ green” enforcement (operator rule 2026-07-15; HIMMEL-195 structural > instructional; the green-gate HIMMEL-1042’s true auto-merge needs). CodeRabbit is excluded here on purpose — the CR gate above owns it — so this gate never double-blocks nor false-blocks on a hung CodeRabbit review. HIMMEL-1072: that exclusion only became real then. It was written against CodeRabbit check-runs, which CodeRabbit never posts (it posts a commit status), while its real status rode the combined /status aggregate straight into this gate’s pending block — so the gate false-blocked on exactly the signal it documented that it ignored. It now reads the /statuses LIST endpoint and excludes CodeRabbit by creator identity (creator.id, shared with the CR gate via cr_signal_bot_id).

The signal is ci_green_gate <pr-selector> [<owner/repo>] from scripts/lib/ci-green-gate.sh. It is wired into the same block-unresolved-cr-merge.sh PreToolUse hook (reuses that hook’s adversarially-hardened selector extraction; a block prints block-red-ci-merge: … to stderr and exits 2) AND into scripts/handover/pr-merge.sh before its mergeability poll (a block there exits pr-merge.sh with code 6, distinct from the CR gate’s 5 and a real merge failure’s 4).

Fail-OPEN posture: deny ONLY on positive red/pending evidence. gh/jq missing, API/parse error, and a genuinely checkless PR (zero check-runs AND zero commit statuses) all exit 0, and an unresolvable selector (gh pr view failure) exits 3 — allow, but re-anchorable, so the PreToolUse hook retries once with the cwd branch rather than letting a mis-tokenized selector dodge the gate (coderabbit-4; top-level consumers treat any non-2 rc as allow). Every fail-open path emits a ci-green-gate: degraded (<why>) - failing open stderr note — never a false block on a repo/branch with no CI. Exception (HIMMEL-1072): the page limits are NOT fail-open — >100 check-runs or ≥100 commit statuses on the head block (rc 2), because a red one may sit on an unread page and a merge gate must not certify green from a partial view.

Bypass: CI_MERGE_GATE_OK=1 in the LAUNCHING shell (independent of the CR gate’s CR_MERGE_GATE_OK; NOT coupled to CR_PROFILE).

Paired artifacts: scripts/lib/ci-green-gate.sh (predicate), scripts/lib/test-ci-green-gate.sh and the shared scripts/hooks/test-block-unresolved-cr-merge.sh / scripts/handover/test-pr-merge.sh (smoke suites).

block-lesson-enforcement-writes.sh — lesson-loop write-fence (HIMMEL-767)

Fires on Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell, but only when HIMMEL_LESSON_LOOP=1 is set — the self-evolving lessons→tickets/draft-PR loop is PROPOSE-ONLY, and this hook is the delivery surface that structurally denies it enforcement-path writes: the agent file-tool surface (Edit/Write/MultiEdit/NotebookEdit file_path/notebook_path, exhaustive — the exact path is always in hand) plus the Bash/PowerShell command surface, which as of round 4 (HIMMEL-767) inverted from a deny-list of write-shaped verbs to an allow-list of proven readers — rounds 1–3b kept enumerating one more write shape per adversarial review round (glued redirects, attached -t, PowerShell aliases) with no sign of convergence, so round 4 flips the default: a closed set of command-position verbs PROVEN read-only (cat/grep/ls/diff/wc/sed without -i/ find without -delete/-exec/git read-verbs/script-executing interpreters/PowerShell readers like Get-Content/Select-String/…) is exempt from operand checking; every OTHER verb — ln, truncate, mkdir, any PowerShell writer or its built-in alias, or a tool this fence has never heard of — has every operand scanned as a write-target candidate, so new write shapes no longer need individual enumeration. Two mechanisms sit outside the verb check: redirect targets (>/>>/glued forms/dd’s of=) always deny regardless of verb, and the git hook-routing shape-deny (core.hooksPath/include.path/includeif.*, any of git config/git config --unset/git -c key=..., each token stripped of one layer of quoting before matching) runs unconditionally per clause. One deliberate behavior change: the old cp-source read carve-out is gone — cp is not proven read-only, so its source operand is a candidate too now (use cat/grep to inspect an enforcement file instead of cp-ing it out). Round 5 (HIMMEL-767) closed four finite gaps in the round-4 model: (1) the interpreters are no longer unconditionally exempt — an inline-eval flag (node/bun/deno -e/--eval, python -c, bash/sh -c, pwsh -Command/-c/-EncodedCommand) makes the interpreter NOT exempt, and the clause’s raw text is scanned for an enforcement-path signal instead (python -c "open('scripts/hooks/x.sh','w')" now denies; python -c "print(1)" still allows; executing a script FILE — bash scripts/hooks/test-x.sh — is still exempt); (2) the git hook-routing shape-deny now resolves its git-clause head through the same wrapper-skipping walk the general classifier uses, closing command git -c core.hooksPath=X commit/env git config --add include.path X/sudo git .../timeout N git ...; (3) the standalone fd-prefixed redirect form (N>/N>>) now accepts any number of digits, not just one; (4) the leading VAR=val assignment skip accepts any letter-case, not just lowercase. Full model + per-entry rationale: docs/internals/lesson-provenance.md. Bare relative operands remain candidates regardless of is_path_like (round 2, unchanged), anchored to the payload cwd. Every other session exits before any parse — zero always-on cost (HIMMEL-177).

Fail-CLOSED once active (deliberately, not the NARROW fallback some sibling fences use): with HIMMEL_LESSON_LOOP=1 set, a missing jq, malformed stdin JSON, or a missing fence sibling (scripts/guardrails/lesson-write-fence.sh) all DENY — a fully-automated loop worker has no human to mis-serve by refusing. Delegates to scripts/guardrails/lesson-write-fence.sh, which classifies against the deny-list policy scripts/guardrails/enforcement-paths.json; both the posture and the deny-list classes are documented in docs/internals/lesson-provenance.md.

Delivery: shipped via the himmel-ops plugin hooks.json (same exec-if-exists $CLAUDE_PROJECT_DIR pattern as block-docker-privesc); live only after /himmel-update (marketplace re-sync) + a fresh session. The plugin command itself fails CLOSED (exit 2) if the project hook script is missing while HIMMEL_LESSON_LOOP=1 (a stale checkout mid-loop), and stays a no-op otherwise — round-3 CR fix, HIMMEL-767. The codex-lane delivery (.codex/hooks.json → run-hook.{sh,cmd} → codex-hook-adapter.sh) already failed closed on a missing guardrail script before this fix (its own precondition check), so it needed no change.

Paired artifacts: scripts/guardrails/test-lesson-write-fence.sh (111 checks), scripts/hooks/test-block-lesson-enforcement-writes.sh (9 checks).

guard-implementor-dispatch.sh — lane-routing dispatcher guard (HIMMEL-1513; composed with the HIMMEL-920 usage budget guard)

Fires on Agent. Structural enforcement of the second-drift lane-routing rule: worker 31 routed four implementation workers through ordinary Agent-tool Claude subagents while the claudex/GLM entry points were available, consuming the scarce Claude subscription usage budget and bypassing the flow-run ledger.

Policy decision: HARD BLOCK, not warn-only. This matches block-backend-tier.sh: after two recorded prose drifts, another warning is not enforcement. The guard refuses only when it can positively establish BOTH that the dispatch is implementation-shaped and that a suitable external lane is currently available. The refusal is one line and names the concrete command (bun scripts/telegram/spawn-claudex.ts ..., falling back to spawn-glm.ts) because the original failure was not knowing the dispatcher entry point; a bare “use a lane” refusal would reproduce the stall.

Classification: the hook reads both Agent description and prompt. Direct implementation words (implement|fix|land), worktree paths, ticket IDs, and commit/attestation instructions are signals. Explicit read-only research/exploration/investigation/review/planning remains allowed, including a ticket or “how to fix” mention, unless the brief transitions to action (for example “investigate and then implement”) or includes worktree/commit/trailer instructions. Read-only agent types and the claudex/GLM/Codex lane-wrapper agents are exempt. This conservative posture is intentional: over-blocking an orchestrator is worse than missing a cost-routing nudge.

Availability: the hook invokes the existing scripts/lanes/resolve.mjs --json resolver, so lanes.json, machine probes, local profile suppression, and LANES_REGISTRY test overrides remain the single source of truth for registry availability. A registry-available lane must also be RUNNABLE before the guard refuses toward it: bun must be on PATH and the lane’s scripts/telegram/spawn-<lane>.ts dispatcher must exist on disk. An unrunnable lane is skipped with a warning, so claudex is still preferred and glm is still the fallback. No suitable lane, an unrunnable lane, missing bun/jq/node/resolver, a missing dispatcher script, malformed hook input, resolver failure, or invalid resolver output all fail OPEN and allow the Agent dispatch — the orchestrator is never stranded on a command that cannot execute.

Retained usage budget policy (HIMMEL-920): when no lane is available (registry- absent or unrunnable), the guard falls through to the pre-existing usage budget-aware cost guard instead of allowing unconditionally. IMPL_GUARD_HARD (default 80) and IMPL_GUARD_WEEKLY_HARD (default 85) refuse at their respective usage budget ceilings. A weekly budget override validated by scripts/lib/bank-lift.sh raises weekly HARD to 100; a standing override also raises five-hour HARD to CADENCE_USAGE_MAX_PCT (default 100). Explicit IMPL_GUARD_* overrides win; invalid overrides retain their original defaults. Invalid/unreadable lifts warn and retain defaults; absent lifts retain the existing policy and refusal message. WARN is unchanged. The guard refuses an eligible implementor-shaped dispatch outright; IMPL_GUARD_WARN (default 65) attaches a visible warn-only notice instead of blocking. Both require a fresh IMPL_GUARD_CACHE_PATH usage cache (default max age 300s) and a provably live five_hour.resets_at window — a missing/unparseable resets_at downgrades a HARD hit to the WARN notice, and an expired window allows outright. See the “bank-aware policy when no lane is available” section of test-guard-implementor-dispatch.sh for the full matrix.

Audited bypass: IMPL_GUARD_OK=1 (deliberate carve-out) or IMPL_GUARD_DISABLE=1 (kill switch), set in the LAUNCHING shell and session-sticky. Every use warns and appends a JSONL audit row to ~/.claude/lane-routing-guard/overrides.jsonl (IMPL_GUARD_LOG is the test seam). A per-call env prefix does not reach the hook process.

Delivery: shipped via the himmel-ops plugin hooks.json (matcher Agent, same exec-if-exists $CLAUDE_PROJECT_DIR pattern as block-docker-privesc); live only after /himmel-update (marketplace re-sync) + a fresh session.

Reviewed-round guard, extended here (HIMMEL-1568): this hook is also the Agent-dispatch chokepoint for HIMMEL-1553’s reviewed-round guard (countReviewedRounds/checkRoundGuard in scripts/telegram/round-guard.ts), via a CLI entry point on that same script (`bun round-guard.ts check —cwd

[--branch ] --task-file `) — never a shell reimplementation, so exactly one predicate backs this hook and `spawn-glm.ts`/`spawn-claudex.ts`. Thresholds are unchanged: `ROUND_WARN_THRESHOLD` (2) allows with a substantive `INVARIANT:` section; `ROUND_ESCALATE_THRESHOLD` (3) refuses the cheap lane unconditionally (no `--rounds-override` is wired here, so there is no unblock at 3+ rounds at this chokepoint). Runs after every exemption above it (read-only/research, orchestrator-harness). A dispatch with no `cwd` is 0 rounds and allowed (no identity to check); once a `cwd` is given, a runtime failure of the predicate itself (bun/script missing, unclean exit) fails **CLOSED** — `IMPL_GUARD_DISABLE=1` is the only bypass, there is no INVARIANT-shaped escape for this failure mode.

Spec: scripts/hooks/test-guard-implementor-dispatch.sh.

guard-chief-dispatch.sh — chief/worker dispatch fence (HIMMEL-2323)

Fires on Agent. Structural enforcement of standing operator ruling 20: PR/ticket-shaped work dispatched FROM A CHIEF must go to an armed DETACHED session with a mission doc (bash scripts/handover/arm-resume.sh), never an in-process Agent-tool subagent — those die with the parent, are not relaunchable, and dump their reports into parent context. This is the CLAUDE.md “second drift” escalation: the ruling stood verbatim in its own handover chain and still drifted twice.

Policy: deny only when the session is CHIEF-shaped AND the dispatch’s description/prompt match markers from at least TWO DISTINCT ship-flow marker families — worktree (.claude/worktrees, clean-garden.sh, git worktree, /worktree, “create a worktree”), pr-open (gh pr create, “open a/the PR”, gh pr edit), cr-gate (/pr-check, “CR-clean”, clear-cr-marker, “CodeRabbit”). A single-family match never denies — that conjunction is the false-positive budget keeping bounded read-only research frictionless. subagent_type: Explore always allows, and judge-ro allows while .claude/agents/<id>.md pins exactly tools: Read, Grep, Glob, Bash, the same structural read-only lane (HIMMEL-5180) — the ONLY carve-outs. guard-implementor-dispatch.sh exempts both ids the same way, on the same tools check. There is deliberately no prompt-text read-only declaration carve-out: three review rounds each added one and each leaked it a different way (a partial-scope aside, an output-shaped phrase, a mode declaration governing a subordinate clause rather than the whole dispatch), so it was removed rather than patched a fourth time. A genuinely read-only dispatch uses Explore or judge-ro, or the audited CHIEF_DISPATCH_OK=1 override below.

Chief detection (fail-open — unknown means NOT a chief): HIMMEL_SESSION_ROLE=chief (case-insensitive; any other non-empty value decides NOT a chief immediately) in the launching shell — the PRIMARY, exact signal. Failing that, a bounded head-read of transcript_path’s first user turn, scoped to the FIRST handover-doc path named in it (the launch prompt loads exactly one doc, and it is the first path named — a later mention, e.g. a mission doc naming the chief’s own doc further down the same turn, does not count): chief-shaped only if that first path’s basename matches *-chief.md.

Audited bypass: CHIEF_DISPATCH_OK=1 (deliberate carve-out) or CHIEF_DISPATCH_DISABLE=1 (kill switch), set in the LAUNCHING shell and session-sticky; the old names CONSOLE_DISPATCH_OK / CONSOLE_DISPATCH_DISABLE are still read. Every use warns and appends a JSONL audit row to ~/.claude/chief-dispatch-guard/overrides.jsonl (CHIEF_DISPATCH_LOG, old name CHIEF_DISPATCH_LOG, is the test seam).

Delivery: shipped via the himmel-ops plugin hooks.json (matcher Agent, second entry alongside guard-implementor-dispatch.sh); live only after /himmel-update (marketplace re-sync) + a fresh session.

Spec: scripts/hooks/test-guard-chief-dispatch.sh.

guard-chief-foreground.sh — the chief runs long kit scripts in the background (HIMMEL-5821)

A foreground run of a slow chief-kit script blocks the chief’s turn for minutes and every worker’s SendMessage queues behind it. Fires on Bash, only when HIMMEL_CONSOLE_DOC is set (headed-arm.sh exports it into a --role chief session, but a worker launched from a chief can inherit it) and the resolved session name ends in -chief or -console (an unresolvable name is allowed), and denies a command that runs a script named in scripts/handover/chief-kit/long-running.txt unless tool_input.run_in_background is true. The deny message gives the re-issue shape. A textual match (interpreter + name, or a path in command position), so a grep that names a script is allowed. A workflow nudge: fails open on missing jq or an unparseable payload.

Bypass: CHIEF_FG_OK=1 (old name CONSOLE_FG_OK) in the LAUNCHING shell, session-sticky.

Delivery: himmel-ops plugin hooks.json (matcher Bash), the same lane as guard-chief-dispatch.sh. Spec: scripts/hooks/test-guard-chief-foreground.sh.

block-glm-external-writes.sh — GLM-lane external-write deny (HIMMEL-654)

Fires on Bash|PowerShell|mcp__.*. The deterministic classifier substitute for third-party offload lanes, which have no auto-mode classifier and usually run --permission-mode bypassPermissions (GLM workers via spawn-glm.ts, claude-glm sessions). Detects the lane by ANTHROPIC_BASE_URL containing api.z.ai (set by glm-env.ts buildGlmEnv / the scripts/claude-glm launchers, inherited by hook processes); off-lane sessions exit 0 on the first env check — near-zero overhead, before the jq check.

On-lane it hard-blocks: git push, remote-URL rewrites (git remote set-url, git config …url — a worker repointing a remote is outside its brief), the gh CLI EXCEPT the issue-ops + pr/run-reads carve-out below (gh pr create/merge/edit/review/comment/ready, gh api, gh repo, … stay blocked — parent-session actions), network CLIs (curl/wget/Invoke-WebRequest/Invoke-RestMethod/iwr/irm), and all mcp__* tools except the qmd carve-out below (v1 chores are repo-local; a blanket deny beats a write-verb list). git commit/add/status/diff and bun/npm install stay allowed.

Allowed on-lane (operator policy 2026-07-03 — audited-action carve-out): the Jira CLI (scripts/jira/ path or a bare jira) — writes are audited in Jira history and recoverable, so GLM workers may update status/comments and file followup tickets; qmd KB reads (mcp__plugin_qmd_qmd__*, allowed before the blanket mcp__* deny); and (HIMMEL-675) gh issue <anything> (full issue surface, reads AND writes — cr-deferred followups are gh issues, audited + recoverable) plus read-only PR/CI context (gh pr view|diff|checks|status| list, gh run view|list|watch). The gh carve-out counts command-position gh occurrences vs allowed ones, so a compound smuggling a denied gh past an allowed one (gh pr view 1 && gh pr merge 1) still denies (total > allowed) — it shares the command-position wrapper gap with the other arms. Atlassian MCP stays blocked — Jira routing is CLI-first (block-backend-tier enforces that in every session).

Fail-CLOSED: missing jq on the GLM lane blocks (parity with block-rogue-claude-schedule). Command-position matching (start, or after ; & | ( — not space/quote) keeps commit-message prose mentioning “git push” from false-blocking. Bypass: GLM_EXTERNAL_WRITES_OK=1 set in the shell that spawns the worker (session-sticky).

Known limitations (accidental-shape guard, backstopped by the parent CR gate — the load-bearing control since HIMMEL-1961 retired the pushurl tripwire that used to sit beside it): a wrapper that displaces the command from command position is missed (env-prefixed FOO=1 git push, sudo/xargs/timeout, the dashed git-push), and in-process network is invisible (bun/node fetch, including the bun-invoked telegram bridge send path).

Delivery: shipped via the himmel-ops plugin hooks.json (same exec-if-exists $CLAUDE_PROJECT_DIR pattern as block-docker-privesc); live only after /himmel-update (marketplace re-sync) + a fresh session, AND the checkout workers branch from having pulled the merge. Spec: scripts/hooks/test-block-glm-external-writes.sh.

Delivery vs. enablement (HIMMEL-2292). PR #1992 / HIMMEL-2085 found this hook absent from .claude/settings.json, .codex/hooks.json, and wire-hook-bash.mjs, and read that as “not wired anywhere” — true only of those three files. Delivery via the himmel-ops plugin hooks.json above predates #1992 and was always correct. The real gap was per-machine enablement: himmel-ops@himmel: false in a machine’s live ~/.claude/settings.json (a manual toggle, an older template) leaves this hook installed but inert, silently — existence in the plugin’s hooks.json is not the same as being enabled. Closed structurally, not by re-wiring: scripts/install/manifest.json’s himmel-ops-plugin item probes the LIVE enabled value (not mere key presence); docs/setup/settings-template.json (the HIMMEL-1032 reconciler’s own whitelist) already flags it true; and install-plugins.sh’s force-enable step — the mechanism himmelctl ensure/install runs for that item — flips a drifted false back to true, additive-only. Re-executing #1992’s settings-chain steps was explicitly rejected: it would double-invoke this hook, drop its mcp__.* arm (settings-chain entries are Bash/PowerShell-only), and a --chain member cannot carry --fail-closed-when.

block-worker-askuserquestion.sh — chief-spawned worker AskUserQuestion deny (HIMMEL-2923)

The old path block-leg-askuserquestion.sh sources this file, so settings that still name it run the same hook.

Fires on AskUserQuestion, keyed on HIMMEL_CONSOLE_LEG=1 (set by the chief’s launcher, scripts/handover/chief-kit/headed-arm-worker.sh, HIMMEL-2919). A worker launched headed by a chief runs in a window nobody answers — two workers parked on this call in 2026-09-10, one for 77 minutes. Second drift on prose → structural. Denies with a message naming SendMessage to the chief as the replacement. Workflow nudge, not a security fence: fails open on missing jq, malformed, or empty stdin so it never locks an operator session out of the tool. Live: the launcher exports the marker into every chief-spawned worker (HIMMEL-2919). A --judge worker (HIMMEL-3133, design §3.2 “the judge is a worker”) is denied identically — same HIMMEL_CONSOLE_LEG=1 (its separate HIMMEL_CONSOLE_JUDGE=1 marker feeds only guard-judge-writes.sh, below) — and correctly so: a judge is read-only and evidence-gathering, so it has no one to ask either.

guard-worker-wakeup.sh — chief-spawned worker ScheduleWakeup deny (HIMMEL-3034)

The old path guard-leg-wakeup.sh sources this file, so settings that still name it run the same hook.

Fires on ScheduleWakeup, keyed on HIMMEL_CONSOLE_LEG=1 (same gate and launcher export as block-worker-askuserquestion.sh). A worker waits with ONE foreground blocking command (docs/handover/worker-preface.md); a self-scheduled polling wake re-reads the worker’s full context just to find “not yet” — cost program HIMMEL-2763. Denies with a worker wakeup-deny: … reason naming check-ci.sh --max-wait / a foreground until-loop / a SendMessage to the chief as the replacement. Not a worker (marker unset or not 1) or any other tool_name → exit 0 with no output at all, so it costs an operator or chief zero context. Workflow nudge, not a security fence: fails open on missing jq, malformed, or empty stdin. Claude lane only — ScheduleWakeup is a Claude Code tool, so .codex/hooks.json carries no twin. Suite: scripts/hooks/test-guard-worker-wakeup.sh.

redirect-webfetch-walled-hosts.sh — WebFetch redirect for walled hosts (HIMMEL-4908)

PreToolUse, matcher WebFetch. A URL whose host (or a subdomain of it) is on the one line of scripts/web/walled-hosts.conf (x.com, twitter.com, mobile.twitter.com, instagram.com, reddit.com, redd.it) is denied with the paste-ready, shell-quoted bash scripts/web/fetch-url.sh '<url>'; the Scrapling stealth fetcher returns the page where WebFetch gets HTTP 402. Any other host is allowed silently. A workflow convenience, not a fence: it fails open on missing jq/conf, malformed input or a control character in the URL, and makes no network call. Bypass: HIMMEL_WEBFETCH_WALLED_OK=1 in the launching shell. Codex has no WebFetch tool, so there is no .codex/hooks.json twin. Suite: scripts/hooks/test-redirect-webfetch-walled-hosts.sh.

guard-worker-context-handoff.sh — chief-spawned worker context hand-off (HIMMEL-4569)

Fires on every tool (*), keyed on HIMMEL_CONSOLE_LEG=1 plus a non-empty HIMMEL_CONSOLE_NAME (both from headed-arm-worker.sh); anything else exits 0 with no output, and so does a call from the worker’s in-process subagents (a non-empty string agent_id), which inherit the worker’s env and transcript. Reads fill with scripts/context-fill.sh --percent on the hook’s transcript_path, and the window from the same claude-hud snapshot.

Off by default (HIMMEL-4710). With no HIMMEL_WORKER_SESSION_CONTEXT_MODE or old HIMMEL_WORKER_SESSION_CONTEXT_MODE (or none) the hook exits 0 with no output for PreToolUse and PreCompact at any fill; headed-arm-worker.sh --context-guard compact|handoff turns it on. One reference: worker-context-guard.md.

Threshold — 75 % of the autocompact ceiling, not of the window. The ceiling is CLAUDE_CODE_AUTO_COMPACT_WINDOW when numeric (it outranks the flag), else HIMMEL_WORKER_SESSION_AUTOCOMPACT (the launcher’s resolved --autocompact; unset = the 200000 pin, auto = the window, junk = the window with a warning), clamped to the window; threshold % = ceil(share × ceiling / window), share = HIMMEL_WORKER_SESSION_CONTEXT_SHARE (1-100, else 75). An opus worker pinned at 200000 reports a 1000000-token window, so the ceiling is 20 % fill and a flat 75 % of the window never fired; it now fires at 15 % (150k). Headroom: compactions were observed from 157k of 200k (HIMMEL-4089), so 75 % leaves 7k tokens for the checkpoint; 85 % would let a compaction fire first, and HIMMEL-4569’s 65 % stopped every opus worker at 13 %. No window in the snapshot → 75 % of the fill.

Two modes, HIMMEL_WORKER_SESSION_CONTEXT_MODE (old name HIMMEL_LEG_CONTEXT_MODE), set by the chief at launch (headed-arm-worker.sh --context-guard, which validates it and propagates it only when set) and read only from the hook process’s env, so a worker cannot flip it in-session.

  • compact: unlocked by the newest CHECKPOINT Results bullet: CHECKPOINT <full sha> pushed whose sha equals git rev-parse HEAD and @{u} in the call’s cwd, or CHECKPOINT <full sha> clean whose sha is HEAD while the tree is clean and HEAD equals @{u} or is in origin/main. The session then compacts at its ceiling and carries on.
  • handoff: unlocked by a *-RESUME.md beside the worker doc that carries the doc’s worker id (N1364, N1364b …) and was modified after the session’s first turn.

Past the threshold it denies every call except the hand-off ones — a Write/Edit/MultiEdit of a *-RESUME.md, SendMessage, ListAgents, ToolSearch, TaskStop, a bare bash …/append-results.sh, queue-lock.sh release, wrap-subtree-check.sh or context-fill.sh, a bare git [-C <dir>] add|commit|push|status|rev-parse and a bare cd [<dir>] (no &&, ||, $( or newline) — until the mode’s unlock holds or the worker doc (the .md path in the transcript’s first load <brief> and continue turn) ends on a WRAPPED marker. A last BLOCKED frees only those hand-off calls plus Read, Grep and Glob (HIMMEL-4710). The deny names the mode, the fill, the threshold and its basis, the exact unlock steps (compact: commit, push, rev-parse, the CHECKPOINT bullet; handoff: the RESUME path with the next id letter, the chief and the marker command), the one-line commit form (-m paragraphs plus --trailer) and the BLOCKED route.

PreCompact. Also wired as a PreCompact hook: past the threshold an auto compaction is refused ({"decision":"block"}, exit 2) until a CHECKPOINT or RESUME unlock holds or the last marker is WRAPPED, so WIP is pushed before context is summarised; a manual compaction is always allowed. What the harness does after a refused auto-compaction is undocumented (ponytail: in the header). Codex has no PreCompact event. Fails open with one stderr line when fill is UNKNOWN/STALE, the transcript or worker doc cannot be found, or the input does not parse — a false block strands a worker nobody watches (ponytail: in the header). Bypass: WORKER_SESSION_CONTEXT_HANDOFF_OK=1 (old name WORKER_SESSION_CONTEXT_HANDOFF_OK=1) in the launching shell. Wired in .claude/settings.json (PreToolUse and PreCompact) and .codex/hooks.json (PreToolUse only; Codex transcripts carry no claude-hud snapshot, so there it fails open). Suite: scripts/hooks/test-guard-worker-context-handoff.sh.

guard-agent-model.sh — Fable model-override deny on Agent (HIMMEL-3847)

Fires on Agent. Denies a tool_input.model matching a pattern in scripts/guardrails/agent-model-policy.json (blocked_model_patterns; today fable / claude-fable-*, matched case-insensitively), and a judge dispatch (subagent_type judge, judge-ro or another judge-* id, plugin-namespaced or not) whose model is not Opus — its frontmatter tier — unless the prompt carries a line of its own ESCALATION: <reason> (the CLAUDE.md “Fable is the escalation target for ONE hard call” lane; a mid-sentence mention does not count). The reason must be real: empty, shorter than min_reason_chars (10) or a listed placeholder_reasons entry (test, n/a, tbd, …, compared after lowercasing and collapsing punctuation) is treated as no reason and still denies. A marker that rescues a would-be deny is echoed on stderr as agent-model-escalation: … allowed by ESCALATION reason: <reason> (exit 0) so overrides are auditable. The block list is data, not code: unblocking a future Fable is a one-line reviewed edit to the policy file (remove or narrow the pattern), not a hook rewrite; the suite proves a listed pattern denies and the same pattern removed allows. A missing or malformed policy file, or an invalid regex in it, fails open (the suite asserts the shipped file parses). HIMMEL-3630 retired Fable as the judge default (Opus 5.5 high measured ~2.7x cheaper per verdict, $8.23 vs $21.94, HIMMEL-3595); headed-arm-worker.sh --judge pins that for judge sessions, but an in-process Agent call passing model: fable silently overrides the agent’s frontmatter, so this is the structural twin. The deny names HIMMEL-3630, the marker and the measured reason. No model (that is guard-subagent-model.sh’s deny, below, not this hook’s), any other model, another tool, or an unparseable payload → exit 0 with no output (workflow nudge, not a security fence: fails open, like guard-worker-wakeup.sh). The suite asserts judge.md and judge-ro.md still say model: opus, so a tier change there fails it. Not covered: a Workflow script’s agent() dispatches — no per-dispatch hook seam is known (they run inside the one Workflow tool call; ponytail: in the hook header, follow-up ticket in the PR body). Claude lane only — .codex/hooks.json has no PreToolUse Agent matcher, so there is no twin. Suite: scripts/hooks/test-guard-agent-model.sh.

guard-subagent-model.sh — an Agent dispatch must name a model (HIMMEL-2653, HIMMEL-5421)

Fires on Agent (plugin-delivered, marketplace/plugins/himmel-ops/hooks/hooks.json). Denies (exit 2, structured permissionDecision: deny) any dispatch whose tool_input.model is absent, null, empty or whitespace-only, for every subagent_type except fork (a fork inherits the parent model and ignores model). A typed agent (Explore, Plan, judge-ro, a plugin agent) is no longer exempt: an unnamed model silently bills the parent tier. This hook owns “a model must be named”; guard-agent-model.sh owns “the named model must not be blocked” — neither repeats the other’s check. The old HIMMEL_REQUIRE_SUBAGENT_MODEL=1 opt-in is gone (deny is the default). Bypass: SUBAGENT_MODEL_OK=1 in the launching shell. Fails open on a missing jq, unparseable or empty stdin, a non-Agent tool, a non-object tool_input or a wrong-typed model / subagent_type. Not covered: a Workflow script’s agent() dispatches (same seam gap as above, HIMMEL-3849). Suite: scripts/hooks/test-guard-subagent-model.sh.

read-clamp.sh — read-clamp PreToolUse hook (HIMMEL-2993)

Fires on Read/Grep and Bash, keyed on HIMMEL_CONSOLE_LEG=1 (same gate as block-worker-askuserquestion.sh — never a chief or an interactive session). Measured over 12 merged-PR workers, repeated Reads of the same file (≈4.5%) and whole-file reads (≈2.2%) were the #2/#3 cost levers on a PR’s token bill after HIMMEL-2990; the instructional layer already existed (the worker system prompt’s read-range sentence, #692) — this is the structural escalation on second drift.

Denies (a) a Read without offset/limit of a file over HIMMEL_READ_CLAMP_LINES (default 2000, the Read tool’s page size) lines, naming the file’s line count and the exact offset=<n> limit=<m> pages that cover it (the first 8, then the continuation step) so any lane retries in one step; (b) a Read whose exact (path, offset, limit) triple was already read this session, naming the recorded timestamp — a different range of the same file is allowed and recorded; (c) the equivalent Bash shapes (cat <file>, sed -n '1,$p' <file>, head -n <huge> <file>) against an over-limit file. An unrecognised Bash shape is always allowed — this hook never denies on a guess.

Per-session state: one line per allowed read under ${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/himmel-read-clamp/<session_id>/reads.tsv. Workflow nudge, not a security fence: fails open on a missing jq, unparsed stdin, or an uncreatable runtime dir (state stays unwritten, the call is allowed) — this hook saves tokens, it does not protect anything. Escape hatch: HIMMEL_READ_CLAMP_OK=1 in the launching shell allows one session past the clamp (e.g. re-reading a file after an external edit invalidated a recorded range), logged to stderr each time it fires. headed-arm-worker.sh --judge (HIMMEL-3133) takes the other lever instead — it raises the whole-file limit itself (HIMMEL_READ_CLAMP_LINES=4000) rather than bypassing the clamp, since independent reading is a judge’s actual job; the repeat-read half of the clamp is untouched. Joins the existing block-read-secrets.sh chain on both the Read|Grep and Bash PreToolUse matchers — right after it, so a denied secret read never gets clamp-recorded.

block-backend-tier.sh — service-agnostic backend-routing guard (HIMMEL-400)

Fires on mcp__plugin_atlassian_atlassian__* tool calls (and any other MCP prefix registered in the registry). Routing is driven by scripts/backends.json — a JSON registry with one entry per service, each carrying enabled, mcp_prefix, cli, and chain fields.

Default chain: [cli, api, mcp] — the three registered services (jira, bitbucket, github) all use this default. When a service’s chain ranks cli above mcp, the hook hard-blocks MCP calls that have a known CLI equivalent (autogenerated blocked-set: the hook introspects the CLI’s --list-commands output and blocks iff the mapped verb is present, so the blocked-set tracks the CLI without requiring hook edits). The api tier is ADVISORY ONLY — it is a curl/WebFetch, not an interceptable named tool, so it never triggers a hard block; it only adds a one-line “prefer raw REST before MCP” note to the refusal message.

Per-service bypass vars (set in the shell that LAUNCHED Claude Code):

  • MCP_ALL_OK=1 — global, bypasses every service.
  • MCP_JIRA_OK=1 — Jira only (backward-compat alias).
  • MCP_<SERVICE_UPPER>_OK=1 — generic per-service (e.g. MCP_GITHUB_OK=1).

Registry load order:

  1. BACKENDS_REGISTRY env var (test seam — file path).
  2. scripts/backends.json at the repo root.
  3. Code-level defaults baked into the hook (backward compatible — works without the JSON file).

Absent or malformed registry → warn to stderr + fall back to code defaults (fail open on config read, fail closed on jq parse of the hook INPUT itself).

CLI paths resolve against the PRIMARY checkout, not the invoking one (HIMMEL-2237). Every registered cli is a path under an untracked dist/ build artifact, which git never carries into a linked worktree. Resolving the existence probe against the invoking checkout therefore made it always miss in a worktree, fall open, and leave this guard INERT in exactly the place CLAUDE.md mandates all feature work happens — armed only on main, where nobody is meant to be working. The primary is derived from the hook’s own location: git worktree list --porcelain names it first, with git rev-parse --path-format=absolute --git-common-dir as the fallback. It must be --git-common-dir, not --git-dir, which in a linked worktree resolves to .git/worktrees/<name> (the same subtlety scripts/cr/install-cr-gate.sh solves under HIMMEL-2035). The REGISTRY is deliberately still read from the invoking checkout — backends.json is tracked, so a branch’s own copy is the routing config under test there.

When the primary’s CLI is genuinely absent (a fresh clone that never built), the hook still falls open — that is the real “no plugin equivalent available” case — but now prints a one-line advisory naming the service, the path it looked for, and the tool call it let through. An inert guard that reports nothing is indistinguishable from a working one, which is how this class went unnoticed in HIMMEL-1773 and HIMMEL-2085 as well.

The regression property in test-block-backend-tier.sh §4 is verdict equality across checkouts, not any single verdict: a throwaway repo plus a linked worktree, a .gitignored relative CLI path standing in for dist/, and the hook copied in so both checkouts run the real script. Those cases deliberately bypass the JIRA_CLI/CONFLUENCE_CLI seams — the seams skip path resolution entirely, which is why no earlier case could have caught this.

block-mcp-when-plugin-exists.sh is now a thin shim that execs block-backend-tier.sh for backward compatibility with machines whose settings.json still references the old filename. The hook in this repo’s settings.json was updated to point directly at the new file.

Pairs with the pre-commit mcp-plugin-refs gate, which catches the same Atlassian MCP names in committed source files as defense-in-depth if this PreToolUse hook is bypassed or disabled.

MCP policy tracking (HIMMEL-4676)

scripts/hooks/mcp-policy.sh is a tracking + gating seam, not a gate. It fires on every MCP call (matcher mcp__.*): in .claude/settings.json as its own entry after block-backend-tier.sh; in .codex/hooks.json, whose PreToolUse matchers must stay disjoint, as one mcp__.* chain mcp-policy.sh+block-backend-tier.sh (so in Codex, backend-tier’s fail-closed jq branches also cover non-Atlassian MCP calls — a recorded ponytail: in test-mcp-policy.sh §8). As shipped it always allows. It classifies each call against the registry scripts/guardrails/mcp-policy.json and records what it would do:

classmeaning
read / write / destructivethe tool’s class in its server’s tools map
unknown-serverthe server is missing from servers
unlisted-toolthe server is listed, the tool is not
malformedunparseable input, or a tool_name that is not mcp__<server>__<tool> in [A-Za-z0-9_.-] (ANSI and control bytes land here)

The would-verdict is deny for unknown-server, unlisted-tool, malformed, a server whose verdict is deny, a destructive tool not named in that server’s allow_destructive, and an off-box server whose provider carries a corpus:* purpose:* deny (or pending-operator) row in egress-matrix.json (read only; the matrix stays authoritative). Everything else would-allow.

Audit. One JSONL line per call to $HOME/.himmel/state/mcp-audit.jsonl: ts, session, server, tool, class, egress, mode, verdict, reason. No tool argument is ever written. Past audit.max_bytes (1 MiB) the log rotates to .1. To find what the registry is missing: jq -r 'select(.class=="unknown-server" or .class=="unlisted-tool") | "\(.server) \(.tool)"' ~/.himmel/state/mcp-audit.jsonl | sort | uniq -c.

Mode — the gating seam. The registry’s top-level mode covers unknown servers and malformed calls; a server’s own mode overrides it for that server. observe (the only mode shipped) records and allows. enforce applies the would-verdict: a deny exits 2 and names the registry entry to add. Enforce is tested (test-mcp-policy.sh §5) but off everywhere; switching a server on is an operator decision for a later PR, once the audit log shows its tools are fully listed.

Fails open on every own error — no jq, an unreadable or malformed registry, a jq error, the internal timeout 2, an unwritable audit dir. It is a tracker; it must never stall or refuse a call on its own bug. Measured p95 is recorded in the PR that added it; test-mcp-policy.sh §9 holds a latency budget (MCP_POLICY_P95_BUDGET_MS).

It does not replace block-backend-tier.sh: the Atlassian CLI redirect keeps its own matcher and still denies by design. Test seams: MCP_POLICY_REGISTRY, MCP_POLICY_EGRESS_MATRIX, MCP_POLICY_AUDIT_LOG.

auto-arm-on-cap.sh — usage-cap watchdog (HIMMEL-220)

Fires on every tool call (matcher *), throttled to one real check per AUTO_ARM_CHECK_INTERVAL (default 60s; the fast path costs one stat()). Reads the claude-statusline usage cache (/tmp/claude/statusline-usage-cache.json, the same source resume-slot.sh / cap-reset-time.sh consume). When any window’s utilization crosses AUTO_ARM_THRESHOLD (default 90%):

  1. writes a mechanical status snapshot into the handover root (scripts/lib/handover-path.sh resolution; falls back to the state dir when the root is unresolvable OR unwritable — the arm matters more than the snapshot’s address). The snapshot name is stable per cap-window+session, so retries overwrite in place (no file spam),
  2. arms a resume via arm-resume.sh --time smart --handover <snapshot> (rc=3 “already armed” counts as success — dedup with any operator-armed or chief-armed job),
  3. blocks the in-flight tool call ONCE (exit 2) so the model is told to write a full handover and wind down. The fired marker is keyed per cap window AND per session (from the PreToolUse session_id), so every concurrent session gets its own one-shot notice while the scheduler still holds exactly one job.

This hook is a WATCHDOG, not a guard — it inverts the directory’s fail-closed convention, with a two-grade failure policy:

  • Absence of signal (missing/unparseable cache, stale cache below the escalation bound, schema drift where no window carries a numeric utilization, below threshold, throttled, already fired, already escalated for this wedge+session) → quiet exit 0 — EXCEPT when the escalated marker is the shared nosession key (session_id was unavailable): that dedup skip exits 1 with a visible shared-notice warn instead of 0, so a harness regression that loses session_id cannot silently suppress sibling wind-downs. A cache with NO parseable utilization is treated as unusable, never coerced to “0%, all fine”. Staleness is NOT quiet indefinitely (HIMMEL-275): a cache frozen past AUTO_ARM_STALE_ESCALATE_AGE (default 1800s), observed across AUTO_ARM_STALE_MIN_CHECKS (default 3) consecutive real checks of a live session, escalates — a one-shot exit-2 block keyed per wedge-mtime AND per session (each concurrent/subsequent session gets its own wind-down notice) plus a safety arm at the stale cache’s five_hour resets_at when 2min–24h out, else now+5h. The arm uses an explicit HH:MM, never --time smart (smart re-reads the same wedged cache and fail-closes — detect and recover would die together). The arm stays globally deduped via arm-resume rc=3; if the one-shot marker itself cannot be persisted, the block downgrades to a repeating visible exit-1 warn rather than block-looping.
  • Watchdog malfunction (python3 missing/crashed/hung, unwritable state dir, snapshot unwritable everywhere, arm-resume missing or failing) → exit 1: non-blocking, but stderr SURFACES to the user. A broken watchdog must be seen, not whisper into a discarded stream. The hang armor (the Windows Store python stub was observed wedging live, and a hung PreToolUse hook hangs the whole session) is three-layered: GNU timeout -k 5 10 around every python call (the wedged stub IGNORES SIGTERM — plain timeout waited forever), python stdout redirected to a FILE instead of $(...) (the stub’s orphan child inherits the pipe handle and keeps $() waiting on EOF even after the kill), and a bounded read -t 5 for the stdin payload.

Arm failures leave no fired marker (next interval retries) and after AUTO_ARM_MAX_ARM_FAILURES (default 3) consecutive failures the hook escalates to the one-shot exit-2 block anyway — “the safety net is TORN, arm manually” — because a watchdog that sees the cliff must bark even when its own parts are broken.

This is the detect half of HIMMEL-122 (the arm half is arm-resume.sh). Boundary vs the HIMMEL-207 supervisor: the supervisor parks the OWNER relaunch; this hook arms a WORK-session resume — both funnel through the HIMMEL-Resume-* scheduler dedup. Kill switch: AUTO_ARM_DISABLE=1 in the launching shell. Operational dependency: the usage cache is only fresh while the statusline is rendering. Under claude-hud (HIMMEL-718) the hud custom-line composer drives the usage-cache producer (scripts/statusline/usage-cache-producer.sh) render-timed but freshness-gated — it re-forks the producer only when the consumer cache (/tmp/claude/statusline-usage-cache.json, the filename retained from the claude-statusline lineage; live production is now claude-hud’s) is older than USAGE_CACHE_TTL (default 300s), so a live session refreshes it at most once per TTL instead of freezing at session start. The session-start freeze that drove HIMMEL-275 — observed 2026-06-10/11 against the legacy bash bar, whose stdin-rates path never rewrote the cache mid-session — no longer occurs on this setup (verified HIMMEL-1233: cache mtime tracks live renders, utilization signal current, no spurious “STATUSLINE WEDGED” escalations). The HIMMEL-275 escalation path stays wired as a SAFETY NET: if the producer ever stops feeding the cache during a live session (hud unwired, composer failing), past the escalation bound the hook safety-arms and blocks once per session rather than quietly standing down on a stale cache. Spec: scripts/hooks/test-auto-arm-on-cap.sh (paired smoke suite).

Wiring (two layers, both shipped):

  1. Project-level — the matcher: "*" PreToolUse stanza in this repo’s .claude/settings.json (operator-authorized 2026-06-10; the agent cannot self-add such a stanza — auto-mode classifies it as self-modification absent explicit operator specification).
  2. User-level (all repos) — scripts/machine-setup/ubuntu.sh registers the hook in ~/.claude/settings.json with an absolute himmel path (idempotent, prompt-gated, mirrors the end-session-wiki registration). The hook resolves its lib + arm-resume relative to its own location, so it works from any project’s session — luna and <state-repo> sessions get cap protection too. NOTE: with both layers active, himmel sessions run the hook twice per tool call — harmless (the throttle marker is shared, so the second invocation is a single stat() no-op).

inject-minerva-critic.sh — plugin PreToolUse(Skill) critic injector (HIMMEL-429)

The one PreToolUse hook NOT wired in .claude/settings.json (so it is not counted in the “nine guards” above): it ships in the himmel-ops plugin (marketplace/plugins/himmel-ops/hooks/hooks.json, matcher: "Skill"), so any himmel-ops install gets it — himmel sessions and external installs alike — with no repo wiring.

Closes the no-/minerva bypass. When superpowers:brainstorming or superpowers:writing-plans is invoked by ANY path (auto-trigger, direct /skill, sub-skill handoff) without going through /minerva, it injects a scoped additionalContext directive so the model still runs the matching minerva adversarial critic loop (spec-critic / plan-critic; himmel-ops:minerva Stage 2 / Stage 4 charters). The invoked skill name is read at tool_input.skill (field path confirmed by the HIMMEL-429 spike); per the CC hooks reference a PreToolUse additionalContext is wrapped in a system reminder and inserted next to the tool result, where the model reads it on the next request.

ADVISORY context, not a permission change — it cannot widen what any hook allows. FAIL-OPEN (unlike the block-* hooks): it only ever exits 0 with either the injection envelope or empty stdout, so it never blocks a Skill call on its own error (a PreToolUse exit 2 would block the tool). Kill switch: MINERVA_HOOK_DISABLE=1 in the launching shell (bypass convention as above). Paired smoke test: marketplace/plugins/himmel-ops/hooks/test-inject-minerva-critic.sh.

Claude SessionStart Hooks

Wired in the SessionStart array of .claude/settings.json; fires once when a session starts. Stdout on exit 0 is injected as additional context. check-update-available.sh (the himmel-update nudge) also runs here; its behind-count is read from the LOCAL remote-tracking refs and the git fetch that refreshes them is DETACHED, so an offline or slow remote can no longer stall — or time out — session start (HIMMEL-1844; a fresh clone is therefore silent on its first check, and nudges on the next one). On an install with no .git (a tarball or native package) it compares VERSION with the latest release tag through scripts/lib/release-check.sh instead (HIMMEL-3247), on the same detached-refresh shape; a check that could not complete is reported as “could not check for updates” once no check has succeeded for UPDATE_CHECK_STALE (default 7 days), never as “up to date” (docs/setup/updating.md). Both paths keep their throttle stamps, the first-attempt stamp and the release cache in the himmelctl cache dir ($HIMMELCTL_CACHE_DIR, default ~/.claude/himmel; --purge-state removes it) rather than /tmp, so a machine that reboots inside the stale window still reaches it (HIMMEL-3260). A third SessionStart hook, inject-where-are-we.sh (HIMMEL-516, plugin-delivered via the himmel-ops hooks.json), injects the relevant slice of the where-are-we ledger; opt-in behind HIMMEL_WHERE_ARE_WE, fail-open and warn-only.

qmd-staleness-notice.sh — qmd index trust advisory (HIMMEL-1286)

Default: ON, but silent unless something is wrong. Runs scripts/luna/qmd-staleness.sh --quiet and prints a <system-reminder> only when the local qmd index is STALE (older than QMD_STALENESS_MAX_AGE_HOURS, default 36h), INCOMPLETE (chunks pending embedding), MISSING a required collection (opt-in, see below), or UNREADABLE (qmd status’s report could not be parsed — fail-loud, because an unparseable report is not evidence of health). A fresh index prints nothing at all: every line a SessionStart hook emits is paid for in every session, and a daily “index is fine” banner is the always-on noise that trains a reader to skip the block — taking the real warning with it.

This is the one always-on exception to the HIMMEL-177 lean-invoke default, and the trigger is the safety-critical row: the failure mode is a session answering confidently off a stale index, and the session that would remember to run a manual check is not the session that gets burned. qmd status is a local sqlite read, so the per-session cost is one cheap subprocess.

Never blocks, always exits 0. A station with no qmd installed (guard rc 2) is silent — adopters who do not use qmd are never nagged about a tool they never installed. The local guard advises refreshing the index with scripts/luna/qmd-reindex.sh. The hook’s older receiver advisory is pending separate hook-integrity-authorized cleanup (HIMMEL-4896).

rc 2 is the ONLY silent non-verdict — and only while no qmd policy is declared. Everything else that is not a freshness verdict is reported as UNVERIFIED: qmd status failing (rc 7 — a corrupt or locked index, a crashed or wedged qmd), the probe timing out (124/137), and any exit code the hook does not recognise. Those were all silent once, which recreated the exact hole this ticket closes one level up — the check quietly stopped checking. “I could not verify” is a trust claim, not a staleness claim, and the advisory says so explicitly so a reader does not conclude the index is stale when it may be fine. Setting QMD_STALENESS_REQUIRE_COLLECTIONS revokes the rc 2 exemption for that station: it is an explicit declaration that qmd matters here, so a vanished qmd is a substrate that disappeared, not an adopter who never installed one.

The probe is bounded on both paths. Where GNU coreutils timeout exists it bounds the guard; otherwise the hook runs a foreground poll loop rather than an unbounded call. The GNU part is load-bearing on Windows: command -v timeout also matches C:\Windows\System32\timeout.exe, which is a sleep, not a command runner — invoking it GNU-style fails instantly, and the hook would read that as the guard’s own rc 1 and print a MISCONFIGURED advisory blaming the operator’s env vars while never checking the index. qmd-cadence.sh’s liveness probe sidesteps the question entirely: it runs under qmd_bounded (HIMMEL-3956), which needs no timeout binary. The distinction is not academic: the SessionStart entry’s own timeout bounds the hang by killing the hook process, and the hook prints nothing until the guard returns — so an unbounded fallback meant a hung qmd took the warning down with it and the session heard silence on exactly the wedged-index condition. An outer timeout bounds the hang; only an inner one can report it.

Required collections (opt-in). Freshness and completeness are properties of the documents that are indexed, so a station missing an entire collection scores perfectly on both — the shape measured on an auxiliary workstation (index fresh, salus absent outright). Set QMD_STALENESS_REQUIRE_COLLECTIONS in the launching shell to a comma-separated list (e.g. himmel,luna) and the guard exits 8 with a banner naming every absent one. Opt-in because the required set is per-station policy this script cannot infer. A required set that cannot be checked (no readable Collections block) fails closed on rc 6, never 0.

What the freshness figure actually measures — do not build on it blindly. qmd status’s Updated: is not a refresh timestamp. qmd computes it as SELECT MAX(modified_at) FROM documents WHERE active = 1, and modified_at is written from the source file’s mtime — so it means “the newest document this index knows about was edited n ago”. It is a proxy, and an asymmetric one: a corpus that has simply been quiet reads as old even when the index was rebuilt minutes ago, and one busy collection can keep the global figure recent while another collection’s new files sit un-indexed. The banner therefore states the observation and both of its readings rather than asserting staleness — an always-on warning that is wrong daily is one the reader learns to skip, which would take the real warning with it. The durable refresh/receipt timestamp that would settle it is HIMMEL-1307 (it spans qmd-reindex.sh and the receiver side, so it is not the guard’s to invent). The proxy is still worth having for the case this was built for: a receiving station whose index stopped arriving while the source corpus kept moving is exactly what it detects correctly.

Budget granularity. qmd status reports ages under a day to the hour and everything above it as whole days (its formatter floors), so 1d ago denotes the whole interval [24h, 48h). The guard resolves a floored label to its worst case, so an index at or above 24h is not certifiable under the 36h default — the practical effect is “warn once the index crosses a day”. Reading the floor instead made the 36h budget behave like a ~48h one and reported a two-day-old index fresh. Set QMD_STALENESS_MAX_AGE_HOURS=47 for the literal one-missed-fire-plus-headroom behaviour.

The probe runs OUT OF BAND (HIMMEL-1844). qmd status is a cold sqlite read — 11.5s measured cold against ~1.4s warm — and cold is the normal case at session start, which made this hook a large slice of the 16% SessionStart timeout rate. A killed hook prints nothing, so the runs that most needed the advisory were the ones that lost it. The hook now SERVES its last verdict from $QMD_STALENESS_CACHE_DIR/qmd-staleness/qmd-staleness-notice.out (state dir default /tmp/claude; the hook owns the private subdir under it) — a mkdir -p, a few stats for the trust checks below, and one cat, with no qmd anywhere — and when that file is older than QMD_STALENESS_CACHE_TTL (default 3600s) it kicks a DETACHED refresh whose output becomes the cache the NEXT session start reads. The banner text is unchanged; it is simply up to one TTL old, which is nothing against a 36h staleness budget. The deferred verdict surfaces at the next SessionStart — startup, compact or resume, whichever comes first — so a station with no cache yet is silent for exactly one session. A changed QMD_STALENESS_MAX_AGE_HOURS / QMD_STALENESS_REQUIRE_COLLECTIONS is honoured by the next refresh, not the next session. QMD_STALENESS_CACHE_TTL=0 disables the cache and probes inline (the pre-1844 behaviour, block and all). The cache is only ever served when both it and its directory are regular, non-symlinked, owned by this user and writable by nobody else — its contents go verbatim into the session context, so anything else reads as no cache at all and the hook probes inline instead.

Guard exit codes (scripts/luna/qmd-staleness.sh): 0 healthy · 1 usage/config · 2 qmd not installed · 3 STALE · 4 INCOMPLETE · 5 both · 6 unreadable report · 7 qmd status failed · 8 required collection absent (outranks 3/4/5 in the exit code; the banner still names every condition). A bad QMD_STALENESS_MAX_AGE_HOURS or QMD_STALENESS_REQUIRE_COLLECTIONS surfaces as a loud MISCONFIGURED banner (rc 1) rather than a check that silently stops running. Suites: scripts/luna/test-qmd-staleness.sh (guard) and scripts/hooks/test-qmd-staleness-notice.sh (the hook’s routing table).

inject-initiative.sh — opt-in initiative mode (HIMMEL-425)

Default: OFF. When HIMMEL_INITIATIVE=1 is set in the launching shell, the session is given a scoped “drive to ship” directive so a normal session proactively runs the /pr-check → open PR → transition ticket → handover sequence at natural completion points, without the operator saying “ship it” each time. Drains stdin; never blocks session start (always exits 0).

Since HIMMEL-2036 the injected text is a ~370 B pointer, not the former ~3.1 KB runbook: it names the active auto-ship steps, points at scripts/hooks/initiative-runbook.md for the step bodies, and keeps the no-merge guard + the no-rail-relaxation sentence INLINE (a rail is not a lookup). A ≤400 B budget assertion in scripts/hooks/test-inject-initiative.sh holds the line. Measured deltas: docs/token-economy.md.

Master switch: 1, true, on, yes, all (case-insensitive) → all four parts. All falsy / unset values (0, false, off, no, empty) leave it off — a byte-identical no-op, so default behaviour is unchanged.

Per-part control (mirrors CRITIC_PANEL_TIERS): set HIMMEL_INITIATIVE to a comma-separated subset of the auto-ship step vocabulary plan,execute,prcheck,pr,ticket,merge,public,handover (the master switch all enables prcheck,pr,ticket,handover) to inject only those steps (e.g. HIMMEL_INITIATIVE=prcheck,pr = run automated review + open the PR, but don’t auto-transition the ticket or write the handover). Parsing is case-insensitive and whitespace-tolerant (PR, ticket works); unknown tokens are ignored, and a value that resolves to no recognized part is treated as off; steps always render in canonical chain order regardless of input order. The directive echoes the recognized tokens (Active steps: …) so a typo is visible in-session. No dependency enforcement — a degenerate subset (e.g. handover alone) just narrows the advice; the structural rails still apply, so it degrades gracefully rather than doing anything unsafe.

The injected text is warn-only context, not a permission change — it cannot widen what the hooks allow. The rails still HARD-block: check-cr-marker-on-pr- create gates gh pr create until a clean /pr-check; attestation trailers must be in the FIRST commit; reactive git commit --amend and self-editing .claude/settings.json to widen rules are still vetoed; merge stays an operator action (the directive explicitly excludes merge).

Companion: scripts/hooks/test-inject-initiative.sh (paired smoke suite). Sits alongside the bypass/opt-in flags (EDIT_ON_MAIN_OK, READ_SECRETS_OK, IMPROVE_ON_SUBMIT) — all set in the shell that LAUNCHED Claude.

record-primary-baseline.sh — primary-checkout dirty-baseline recorder (HIMMEL-2526)

Plugin-delivered via the himmel-ops hooks.json, placed right after record-hook-integrity.sh. Records the PRIMARY checkout’s tracked git --no-optional-locks status --porcelain -uno output at session start, keyed by session_id, to ${HIMMEL_PRIMARY_BASELINE_DIR:-$HOME/.claude/himmel/primary-baseline}/<session_id>.primary-baseline. This is the DETECTION half’s baseline: detect-dirty-primary.sh (the PostToolUse companion below) diffs each Bash call’s current status against it, so a file already dirty when the session started is never reported.

Modelled closely on record-hook-integrity.sh’s conventions (best-effort, every early exit is exit 0 writing nothing, same CLAUDE_PROJECT_DIR/ jq/git checks, same session_id shape guard) but writes to a SEPARATE directory and is intentionally NOT chmod 0400 — unlike the integrity pin, this baseline is a live cursor that detect-dirty-primary.sh rewrites during the session every time it reports a new dirty path, so the same path is reported once rather than on every subsequent Bash call. It never writes into HIMMEL_HOOK_INTEGRITY_DIR — that directory belongs exclusively to record-hook-integrity.sh’s own ticket (HIMMEL-2528).

Resolves the PRIMARY via scripts/guardrails/lib.sh’s primary_checkout_root (compares git rev-parse --absolute-git-dir against --git-common-dir), so a SessionStart fired from inside a linked worktree still records the primary’s own state, not the worktree’s. Write via temp file + mv so a reader never observes a partial file.

Spec: scripts/hooks/test-record-primary-baseline.sh.

detect-dirty-primary.sh — primary-checkout dirty-write detector (HIMMEL-2526)

PostToolUse on the Bash matcher. The DETECTION half of the HIMMEL-2526 pair — block-write-into-main-checkout.sh (documented in the PreToolUse section above) is the PREVENTION half. Exists because block-edit-on-main.sh is only wired on Edit|Write|MultiEdit|NotebookEdit: a Bash-mediated write (cat >, sed -i, a python heredoc) bypasses it entirely. The motivating incident: a dispatched subagent wrote four files into the primary checkout via Bash while it was on main, and nothing noticed for 18 minutes.

DELIBERATELY UNGATED by any worker marker. An earlier draft gated this on HIMMEL_WORKER=1; that was withdrawn and must not come back — HIMMEL_WORKER is exported only by the telegram lanes, scripts/lib/claude-headless.sh never sets it, and an Agent-tool subagent inherits the parent’s environment unmarked. The incident worker carried no marker, so a marker-gated detector would never have fired on the very shape it exists to catch.

On every Bash call: if no baseline file exists for this session, exits 0 silently (fail-open — the recorder is warn-only and may legitimately have written nothing). Otherwise resolves the PRIMARY the same way the recorder does and re-runs git --no-optional-locks -C <primary> status --porcelain -uno (~single-digit ms on a normal checkout — affordable on every Bash call). A failed invocation is never interpreted as clean — it logs once per session (a marker file beside the baseline, not stderr noise on every call) and exits 0.

Diffs the current porcelain output against the baseline by exact LINE, not by path: only lines present now and absent from the baseline are reported, so a status-code change on an already-dirty file ( M → MM) still counts as new. Nothing new → exit 0 silently. Something new → rewrites the baseline to the current output, THEN reports via exit 2 with the message on stderr — this repo’s PostToolUse warn-only convention (the tool already ran; see check-hook-file-parse.sh). The rewrite is what makes a newly dirty path reported ONCE rather than on every subsequent Bash call for the rest of the session, while a later, different change is still caught against the rewritten baseline. The message names the PRIMARY path, the specific new dirty path(s), and what to do (the write belonged in a worktree; check whether a dispatched worker was handed an absolute primary path; HIMMEL-2526).

Spec: scripts/hooks/test-detect-dirty-primary.sh.

detect-token-in-output.sh — GitHub token in a tool call or result (HIMMEL-5412)

PostToolUse and PostToolUseFailure on every tool (* here; PostToolUse .* in .codex/hooks.json, which has no failure event), so a failed call’s error text is scanned too. The DETECTION half of the HIMMEL-5412 pair; block-read-secrets.sh is the PREVENTION half. It scans the whole hook input as text for the gitleaks GitHub shapes (ghp_/gho_/ghu_/ghs_/ghr_ + 36 alphanumerics, github_pat_ + 82), so a token in tool_input (a Write body) counts as much as one in the result. On a hit it emits systemMessage plus additionalContext and sends one Telegram DM through merge-block-alert.sh’s sender, naming the tool, the token kind and the variable it was assigned to when the text shows one, never the value. One DM per (session, token); the sentinel under ~/.himmel/state/token-in-output is a sha256 prefix, never the token, and a failed send removes it so a later call retries; a sent DM writes sent into it, and an empty one older than 2 minutes (a hook killed mid-send) is reclaimed. Fail-open, always exits 0; the clean path is a bash regex on stdin and forks nothing.

Spec: scripts/hooks/test-detect-token-in-output.sh (fixture tokens are built at run time, so the file holds no token-shaped literal).

Detached end-of-session work — the bounded Stop queue (HIMMEL-2004)

Every end-side hook (Stop, SessionEnd) returns in milliseconds because none of them does its work inline: each spawns the real body DETACHED via scripts/lib/detach.sh so it cannot trip the harness teardown budget. That fixed latency and created the opposite problem — the detached work was unbounded. A fleet of N children ending at once fanned out N independent trees (bun, git, curl, node), and HIMMEL-1993 measured this box’s kernel pool leaking in proportion to exactly that file-handle churn. Nothing throttled them, nothing deduped a repeated session, and a session that died mid-flight lost its work silently. That pool growth is now watched rather than rediscovered by hand: HIMMEL-1604 added four Himmel{KernelPool*,CommitPressure} alert rules over the already-scraped windows_memory_pool_* / commit metrics (scripts/observability/README.md#kernel-pool--commit-pressure-himmel-1604).

scripts/hooks/stop-queue.mjs replaces the fan-out with a durable directory plus ONE worker. Stdlib-only node; no new dependency runs on every session end.

SubcommandWhat it does
enqueue --key NAME [--ttl SECONDS] -- COMMAND…writes one entry atomically (tmp file + rename), starts the worker if none is live, exits. Sub-second; nothing runs inline.
worktakes the singleton lock (mkdir), drains entries oldest-first ONE at a time, exits. A second worker refuses to start — that IS the concurrency bound.
statusreport-only census: what is pending, and what the log recorded as run, deduped, expired or abandoned. Never mutates.

Layout, under $HIMMEL_STOP_QUEUE_DIR (default ~/.himmel/stop-queue, the same convention as the ci-queue / flow-run ledgers — deliberately NOT in the repo, so the queue outlives a worktree pruned mid-drain):

entries/<key>.json one queued job: argv, saved stdin payload, gate env, ttl, cleanup
running/<gen>.json a job CLAIMED by rename — running, or left by a worker that died
attempts/<gen>.n the crash counter, kept outside the entry so entries stay write-once
tmp/ the pre-rename staging file (atomic publish)
worker.lock/pid the singleton lock; stale when the pid is gone or the heartbeat ages out
queue.jsonl append-only census log: attempt | enqueued | enqueue-failed | dedup |
oversize | ran | timeout | retained | expired | abandoned | dropped |
corrupt | detached-fallback

attempt (HIMMEL-713) is logged before anything else in the CLI’s enqueue path — before stdin is even read — so a hook the harness cancels mid-enqueue (the “Hook cancelled” class on a short session) still leaves a trace. An attempt row with no matching enqueued/enqueue-failed is the observable signature of a cancelled hook; without it that case was indistinguishable from a session that never fired the hook at all, the same masking class that cost the session-runs ledger five silent days (HIMMEL-2022, session-runs.errors.log below). Since the row’s key is only known after sanitizeKey may have altered it, the attempt row instead carries a per-invocation id that is threaded into the matching enqueued/dedup/enqueue-failed/oversize row — that id, not key, is the correlation field an audit joins on.

Inspect or drain by hand:

Terminal window
node scripts/hooks/stop-queue.mjs status # what is queued and what has run
node scripts/hooks/stop-queue.mjs work # drain now, in the foreground

Dedup is per (hook, session): the key carries the payload’s session_id, so a fleet of N children keeps N entries while one session re-firing REPLACES its own (newest payload wins — an older one for the same session is stale, not lost work). With no identifiable session the key gets a unique suffix instead, so two callers never collapse into one entry.

Nothing is dropped silently. An entry past its TTL (default 1 h — it has to outlast a full backlog, or the queue expires its own tail before the worker reaches it), one that has burned its attempt budget, and a malformed one each write a log row before they are removed. An entry left behind by a session that died is run by the next worker — it is a file, not a process.

A job that misses its deadline is stopped as a TREE, not just at its direct child (HIMMEL-2028). The jobs here are trees — node run-hook-with-bash.js → bash <hook>.sh → curl / bun / python — so signalling only the child left descendants running outside the concurrency bound and overlapping the job’s own retry, which is precisely the fan-out the queue exists to remove. The worker holds each job’s pid while it runs and hands it to scripts/lib/kill-tree.mjs on expiry (taskkill /T on Windows, a process-GROUP signal on POSIX); a reap that fails says so in the timeout log row’s reap_failed field.

What goes through it. The two .claude/settings.json end-side hooks are wired as stop-queue.mjs enqueue -- <the command they used to run>, so their scripts are untouched. The three himmel-ops SessionEnd hooks already full-body-detach themselves, so their seam is that call site: detach_queued <key> … in scripts/lib/detach.sh instead of detach_run. The worker then runs each job with HIMMEL_DETACH_INLINE=1, which makes every nested detach_run synchronous — without it the queued work would escape the worker the moment it started and the bound would buy nothing.

Secrets never reach an entry. An entry records argv, so detach_queued is used ONLY for the outer self-re-exec (argv = a script path plus a payload file). The inner relay calls — a curl whose URL carries a bot token — stay on plain detach_run and are run inline by the worker. The environment an entry carries is a prefix allowlist (HIMMEL_, CLAUDE_, VOICE_, WHERE_ARE_WE_, JIRA_NUDGE_, DETACH_): enough for a job to behave as its OWN session would rather than as the session that happened to start the worker, never the session’s credentials, which each hook loads from a .env inside the job at run time.

A job’s environment is an allowlist, not “everything minus secrets” (HIMMEL-2030). The worker OUTLIVES the session that started it and drains other sessions’ entries, so a name-pattern denylist over its own environment was the wrong shape: it could only strip credential names someone had written down, and this box carries names it does not match (GGS_MQTT_PASS is neither PASSWORD nor PASSWD). A job’s environment is now built in three layers:

  1. Allowlist base — named system/interpreter variables and nothing else: the Windows/Git-Bash set (SystemRoot, ComSpec, PATHEXT, MSYSTEM, APPDATA/LOCALAPPDATA, the ProgramFiles roots …), the POSIX set (SHELL, TERM, LANG, the LC_* locale set, TZ, the XDG_* base dirs, USER) and the version-manager namespaces (NVM_*, FNM_*, VOLTA_HOME, ASDF_*). Enumerated empirically on Windows Git-Bash — every interpreter and every queued end-side hook re-run under a narrowed env -i — with the cross-platform names justified in the list itself. Every name is spelled out: there are no prefix wildcards, because a prefix inside an allowlist is a denylist by another name (^XDG_ would also admit XDG_MQTT_PASS). The list is ENV_SYSTEM in scripts/hooks/stop-queue.mjs.
  2. The entry’s own snapshot, authoritative over the names it owns — which is now that whole system set as well as the gate/path names, because TZ, LANG, LC_*, XDG_* (where a tool keeps its config, and on Linux its credentials) and the version-manager roots are per-SESSION, not machine constants. So for an entry that has a snapshot the base ends up empty: nothing at all crosses from the worker, and every value a job sees came from the session that queued it. The allowlist base is what a legacy snapshot-less entry (queued before an upgrade, still inside its TTL) falls back to rather than starving — and it is exact names only, never the snapshot’s HIMMEL_/CLAUDE_/… prefixes: a prefix would admit HIMMEL_MQTT_PASS, and the worker’s own gates are another session’s answer. That branch owes a pre-snapshot entry enough environment to RUN, nothing more.
  3. The secret-name denylist as a second fence over the result, which is what catches an entry file written by an older version or edited on disk.

An unnamed variable does not cross, whatever it is called — so a credential under an unguessable name cannot reach another session’s job.

Escape hatches. HIMMEL_STOP_QUEUE_OFF=1 makes detach_queued fall back to plain detach_run — losing the bound is acceptable, losing the work is not; the same fallback fires wherever node or the script is absent (an adopter running these hooks outside this checkout). HIMMEL_STOP_QUEUE_ENV_EXTRA is a comma-separated list of extra variable NAMES a job needs, for a platform this enumeration never saw — an adopter is never bricked by a missing name. Set it in the shell that launches Claude; matching is case-insensitive. It widens what the entry’s snapshot carries, not what the base inherits, so both the name and the value come from the session that queued the job — widening the base instead would hand a job another session’s value for a name its own session chose. That also means a hatched variable’s value is written into the entry file: name platform constants, never a credential (the denylist refuses the obvious shapes, but the never-enqueue-a-secret rule above still governs). Spec: scripts/hooks/stop-queue.test.mjs plus cases 4–6 of scripts/lib/test-detach.sh.

Codex parity is unchanged: the queue is a wrapper, not a hook, and every wired command still names the same .sh/.ts script, so scripts/codex/test-codex-hook-parity.sh sees the same end-side inventory and the CLAUDE_END_ONLY allowlist is untouched.

stop-chief-idle-guard.sh — a chief may not go structurally dead (HIMMEL-3144 / HIMMEL-3148)

A second Stop entry in .claude/settings.json, deliberately a SIBLING of the stop-queue.mjs entry and NOT enqueued through it: the queue exists to run end-side work asynchronously and always allows, while this hook must hand Claude Code a synchronous {"decision":"block","reason":"…"} on stdout before the turn ends. Origin: a chief ended its turn with an empty fleet and a 9-item held queue and sat dead for 58 minutes — no Stop guard and no monitor event starts a new turn in an idle interactive session.

It blocks a stop only when ALL of these hold, and allows (exit 0, no stdout) on anything it cannot positively confirm: the payload is not the hook’s own re-entry (stop_hook_active is not true, so it blocks at most once per turn); the stopping session holds a *-chief queue-lock (best-effort — it recomputes sha256("<session_id>|<handover path>") and looks for queue-lock.sh’s persisted per-session token file under ${XDG_RUNTIME_DIR:-/tmp}/himmel-queue-lock/; a false negative only allows); bank-preflight.sh and queue-lock.sh status --sweep both answer (sweep rc 0 or 20 are valid, anything else is a fault); and zero worker locks are held (a straight count of held slug= lines other than the chief’s own — deliberately not gated on freshness, since IDLE-HELD? is heartbeat age, not death). One or more held worker locks IS the wake path (a worker’s SendMessage starts a new turn), so the hook does not block then (HIMMEL-3148). The block reason names the lock document, the bank-preflight FLEET line, the usage budget token and the held worker locks, and says to dispatch the next queued item, verify a READY PR, or run /chief next --arm. Every subprocess shares ONE 8 s budget (HIMMEL_STOP_GUARD_TIMEOUT, under the wired 10 s Stop timeout), and the JSON is printed only as the last statement. It does not inspect the held Jira/dispatch queue. Test seams: HIMMEL_STOP_GUARD_USAGE_PREFLIGHT (old HIMMEL_STOP_GUARD_BANK_PREFLIGHT still read), HIMMEL_STOP_GUARD_QUEUE_LOCK. Spec: scripts/hooks/test-stop-chief-idle-guard.sh.

chief-precompact-snapshot.sh — snapshot a chief’s Live state at compaction (HIMMEL-2973)

Wired as the repo’s one PreCompact entry in .claude/settings.json (10 s timeout, no matcher, so it fires for both manual and auto compaction). It is the out-of-band sibling of the SessionStart:compact re-inject hook: that one restores a chief’s state INTO the transcript after compaction; this one records what the state WAS at compaction time, so the restoration can be audited rather than trusted.

It reads the chief doc named by HIMMEL_CONSOLE_DOC (exported by headed-arm.sh / arm-resume.sh) and writes an immutable <HIMMEL_CONSOLE_WORKDIR>/precompact-<n>.snap, n = highest existing + 1 (written to a temp name and hard-linked into place, so a snap is never overwritten and a torn write is never visible). The snap is sha256=<hex of the body> then lock= (the queue-lock owner token, read from .locks/queue/, never written), workers= / queue= / last-go= / acked= (the doc’s ## Live state lines, backticks stripped), go-file=<pr>.<sha7> (newest file in .locks/go/), trigger= (from stdin when present), and a verbatim --- tick tail when <workdir>/last-tick.txt exists. The work dir is created 0700 and refused when it is a symlink or not owned by the caller, since the snap carries RETASK nonces and lock tokens.

It is a SILENT NO-OP for every non-chief session (HIMMEL_CONSOLE_DOC unset, or the doc or work dir unusable): no output, no file. A chief launched by a hand-pasted line has neither variable and is simply not snapshotted. It ALWAYS exits 0 and has no bypass variable: a workflow nudge, not a security fence, so a failure must degrade to “no snapshot”, never block a compaction.

The consumer is scripts/handover/chief-kit/compacted-check.sh (G11): it compares the doc’s last COMPACTED bullet (legs, queue, last GO, and acked when present) against the newest snap. rc 0 G11 ok; rc 1 G11 LOSS <field> per differing field; rc 2 no snapshot / snapshot corrupt, which the gate treats as a loss — preservation unverified is never a pass. Known simplifications (ponytail: in the sources): ## Live state is read with a plain fence toggle and single-line key: value lines, so a wrapped value is truncated to its first line (G11 then reports LOSS, it does not hide it); lock and go-file are recorded for forensics but not compared, because the shipped bullet carries neither; and nothing writes last-tick.txt yet, so the --- tick tail is normally absent. Specs: scripts/hooks/test-chief-precompact-snapshot.sh, scripts/handover/chief-kit/test-compacted-check.sh.

Claude SessionEnd Hooks

Wired in the SessionEnd array of the himmel-ops plugin hooks.json (exec-if-exists); fire once when a session ends. Stdout lands in the session transcript (not operator-visible live). Coexist with any user-level SessionEnd hook (e.g. end-session-wiki); Claude Code runs all registered SessionEnd hooks. Alongside refresh-where-are-we-on-end.sh (HIMMEL-572):

jira-nudge-on-end.sh — warn-only Jira-update nudge (HIMMEL-618)

Default: OFF. When HIMMEL_JIRA_NUDGE is truthy (1/true/on/yes, resolved from the launching shell OR the session repo’s .env), this hook emits one warn-only line if a session committed work clearly tied to a Jira ticket but made no Jira mutation that session — so the operator keeps the tracker in sync. It is warn-only: it never performs a Jira write and never blocks teardown (always exits 0).

Detection (ALL must hold, else silent no-nudge): a parseable transcript first-timestamp (session-start epoch via session-transcript.sh); at least one in-window commit (git log --since=@<start> — read-only sessions never nudge); JIRA_PROJECT_KEY resolvable from the repo .env; the branch name OR an in-window commit subject references <KEY>-<N>; and no jira-mutation breadcrumb dated at/after the session start.

The breadcrumb is the “did this session touch Jira” signal: every mutating jira CLI verb (transition, comment, create, move, edit, assign, worklog add, link, sprint) calls a shared writeJiraBreadcrumb() (scripts/jira/src/breadcrumb.ts) immediately after its mutating request resolves — NOT gated on the command’s exit code, so a mutation that landed before a later non-fatal failure (e.g. an attachment upload) still counts. The breadcrumb is a machine-global append-only log at ~/.claude/jira-breadcrumbs/<repo-key>__<branch>.log (line <epoch>\t<TICKET>), keyed by the basename of git remote get-url origin (stable across worktrees, so the CLI writer and the hook reader agree). Session-id keying is impossible — the standalone CLI process never receives the Claude session_id — so the hook matches on epoch >= session-start; residual cross-session suppression (two parallel sessions on one ticket) is an accepted limitation. The reader path lives in scripts/lib/jira-breadcrumb.sh.

Suppressed when the ticket initiative auto-ship step is active (HIMMEL_INITIATIVE includes ticket) — that step already injects the same reminder at SessionStart, so this is the second warn-only surface for when the step is OFF, not a structural backstop. The whole detection body runs full-body detached (HIMMEL-661, same __himmel_detached re-exec pattern as refresh-where-are-we-on-end.sh): the parent parks the SessionEnd payload in a temp file and returns in ~0.1s, so in practice it no longer loses the teardown race (“Hook cancelled”) — even the gate-off path previously cost ~1.7s of process spawns on Windows Git Bash. Nudge surface: the Telegram send path when configured (TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID, or the JIRA_NUDGE_RELAY_CMD override) — the only operator-reaching channel for an unattended session; the stdout print survives only for direct child-mode invocation (bash jira-nudge-on-end.sh __himmel_detached <payload-file> — tests or a manual debug run reproducing that contract; a plain manual invocation goes through the detaching parent and produces no stdout). Paired hermetic suite: scripts/hooks/test-jira-nudge-on-end.sh.

Claude PostToolUse Hooks

Five distinct hooks across three matchers wired in .claude/settings.json fire AFTER a tool call completes: auto-arm-on-subagent-cap.sh and session-run-hook.ts (matcher Agent), trigger-cr-on-pr-create.sh and trigger-cr-on-push.sh (matcher Bash), and shadow-ledger.mjs (matcher Bash|Edit|Write|MultiEdit|NotebookEdit). session-run-hook.ts and shadow-ledger.mjs are the passive observability taps described below, not enforcement hooks.

Passive observability taps — session-run-hook.ts + shadow-ledger.mjs

Both scripts are wired across many hook events — PreToolUse, PostToolUse, SessionStart, SessionEnd, and (shadow-ledger.mjs only) PostToolUseFailure, PermissionDenied, PermissionRequest, and Notification — but neither is an enforcement hook: they RECORD and never block. session-run-hook.ts appends one row per session/subagent lifecycle event to the session-runs ledger, the substrate the observability exporter reads. shadow-ledger.mjs is the blind, hash-chained decision-record tap (HIMMEL-1529): it computes and flushes its own shadow verdict before the request reaches the permission layer, then defers, emitting no permissionDecision. Both are FAIL-OPEN, ALWAYS — every failure path exits 0 with empty stdout, and neither may block, slow, delay, or ask on a tool call, because a telemetry gap is always cheaper than a bricked session. Detail lives in scripts/observability/README.md and ../architecture.md, not duplicated here.

log-classifier-denial.sh — classifier-denial tap (HIMMEL-3724)

Wired on PermissionDenied. Same fail-open, always-exit-0 contract as the taps above: it appends one redacted JSON line per denial to ~/.himmel/state/classifier-denials.jsonl (session/tool/reason_tag, a capped and redacted input_head, and a normalised input_sha — never unredacted command text), and never blocks, delays, or asks. tick.sh reads that log into a denials=<worker>:<n>[:SHIP-STEP|REPEAT|PAUSE-RISK] field so a chief sees a repeated or escalating denial within one tick instead of only when a worker happens to report it. Paired smoke test: scripts/hooks/test-log-classifier-denial.sh.

Hardening (phase 2a): every text field (cwd, session_id, tool, session_title, not just input_head) is redacted then capped; the redactor catches short secrets in KEY=value and flag forms (DB_PASS=x, --password x, mysql -pX), deliberately over-matching; the hook runs under umask 077 (dir 0700, log 0600); and the log rotates to classifier-denials.jsonl.1 past HIMMEL_CLASSIFIER_DENIALS_MAX_BYTES (default 1 MiB, one generation kept). tick.sh labels a non-worktree row <dir>#<session_id[0:8]> so a re-dispatched slug never inherits an old session’s count, and reads denials=none when the log does not exist yet. chief-wait.sh wakes on denials= only when it gets worse (a new worker, a higher count or class), so one denial wakes the chief once and not again when it ages out of the 30-minute window.

Hard rule: input_head and reason_tag never leave the host. They are redacted best-effort, not scrubbed; tick.sh classifies on them locally and prints only leg:count:class. The page (phase 2b, below) sends the worker label, count and class and nothing else. test-log-classifier-denial.sh pins the set of files allowed to name either field, so a new reader fails the suite until that list is edited on purpose. None of the phase 2b files reads the log: they see only the tick’s denials= field.

Phase 2b: page, ack, refuse re-dispatch (scripts/handover/chief-kit/).

  • Page. When a chief-wait.sh wake’s denials= change raises a worker to SHIP-STEP or PAUSE-RISK (a new worker, or a higher class; a count rising inside one class wakes but does not page), the waiter sends ONE Telegram message per rise through the kit’s existing bridge path (scripts/lib/merge-block-alert.sh: first allowFrom of access.json, chief-route.ts reply; no new bot, token or client): DENIAL-PAGE <leg> <class> x<count> - a classifier denial parked this leg. .... It fails open and is bounded by CHIEF_WAIT_PAGE_TIMEOUT (default 45 s, also the most a hung sender can delay the wake). It pages only what a running waiter saw rise.
  • Records. Before the send, the waiter writes <key>.page (worker, count, class, timestamp) to ${HIMMEL_DENIAL_ACK_DIR:-~/.himmel/state/denial-acks} (dir 0700, files 0600; format in denial-ack-lib.sh). The key is the worker label reduced to [A-Za-z0-9._#+-]: the worktree slug, or <dir>#<sid8> for a non-worktree cwd.
  • ack-denial.sh <worker-label-or-handover-doc> writes <key>.ack (worker, the paged count and class, timestamp). A doc resolves through its frontmatter resume_cwd to the worktree slug. Idempotent; a worker never paged is a no-op. Exit 0 acked / already acked / nothing to ack; 1 record dir not writable; 2 usage, unusable label, or a doc with no resume_cwd.
  • Refusal. headed-arm-worker.sh exits 14, naming ack-denial.sh, when the session name or the doc’s worktree slug has a SHIP-STEP/PAUSE-RISK page newer than its latest ack. --ignore-denials overrides. No record, an acked one, a REPEAT, or an unreadable record launches (fail open); the check runs before --dry-run’s exit, so a dry run predicts the refusal.
  • Known limits. Timestamps are 1 s granularity (a page in the same second as an ack reads as acked). A <dir>#<sid8> worker can be paged and acked but a launch cannot be matched to it. The refusal covers only denials a running chief-wait.sh paged. Tests: test-ack-denial.sh, test-chief-wait.sh (p* cases), test-headed-arm-worker.sh (case 38).

auto-arm-on-subagent-cap.sh — subagent-result cap watchdog (HIMMEL-276)

Closes the detection gap left by auto-arm-on-cap.sh: when the cap hits while subagents are running, the main-loop’s own tool calls keep succeeding (so the PreToolUse hook never fires) while subagents return "You have hit your session limit" as their tool RESULT text. The usage cache (frozen at session start on this setup) also reads low, so both existing detection paths miss the cap entirely — the session wakes up with no armed resume and no handover.

Detection sentinels (case-insensitive substring match on the raw Agent tool result text):

  • "you have hit your session limit" — primary subagent cap string
  • "usage limit reached" — alternate Claude cap phrasing

On detection: same arm + one-shot-block contract as auto-arm-on-cap.sh: writes a mechanical status snapshot (via the handover-path resolver), calls arm-resume.sh --time smart --handover <snapshot>, exits 2 (one-shot block per session — the model is told: resume armed, write a handover now). The session dedup uses a sub-<sid> marker key distinct from the PreToolUse hook’s markers, so both hooks can independently fire in the same session without marker collision.

Failure contract: same WATCHDOG semantics as auto-arm-on-cap.sh (fail-open — no bug in this hook may block tool calls):

  • exit 0 → quiet pass (disabled, non-Agent tool, no sentinel, already fired).
  • exit 1 → surfaced MALFUNCTION (missing arm bin, snapshot unwritable, py-armor missing, arm failed). Non-blocking.
  • exit 2 → one-shot block; stderr to model.

Kill switches: AUTO_ARM_DISABLE=1 (shared) or AUTO_ARM_SUBAGENT_DISABLE=1 (hook-only). Both must be set in the LAUNCHING shell.

Spec: scripts/hooks/test-auto-arm-on-subagent-cap.sh (paired smoke suite).

Wiring: matcher: "Agent" PostToolUse stanza in .claude/settings.json.

rtk-hook-guard.sh — rtk rewrite wrapper (HIMMEL-241)

Not an enforcement hook — a fail-OPEN wrapper around the user-level rtk hook claude PreToolUse rewriter (registered by rtk init -g in ~/.claude/settings.json). rtk rewrites bare find … commands to rtk find …, but rtk find rejects compound predicates/actions at runtime (-not, -exec, -o, -a, -delete, !, \(…\)) and silently ignores -prune — which broke every LUNA runbook clip scan (find … -not -path '*/_synthesis/*' …). The guard delegates to rtk, extracts the rewritten command VALUE from rtk’s JSON output (jq, with a grep+sed fallback anchored on the rtk find value when jq is missing or fails — HIMMEL-264), and suppresses ONLY a rtk find rewrite whose command carries one of those tokens (empty hook output → the original find runs unmodified through the normal permission flow). Simple finds and all other commands keep rtk’s rewrite verbatim — zero token regression. Failure contract: rtk missing, crashing, or silent → exit 0 with empty output (rtk is an optimizer, never worth blocking a tool call over); extraction failure on output that still contains an rtk find rewrite → suppressed (an unscannable rewrite is never forwarded — forwarding it would resurrect the original bug); extraction failure on non-find output → forwarded verbatim (nothing the guard screens).

Git in a worktree (HIMMEL-3283; claudex lane HIMMEL-2953). Claude Code’s own worktree-isolation screen refuses a command that runs rtk with git among its operands, and the rewrite hands it exactly that. Nothing hook-visible marks a session as EnterWorktree-pinned, so the guard forwards no rewrite that names git as a word when the payload cwd is inside .claude/worktrees/ — the harness sees plain git. Cost: rtk’s git compression is lost in unpinned worktree sessions too; a worktree outside .claude/worktrees/ keeps the rewrite. Evidence and the worker-side recovery: stuck-playbook.md.

Wiring: docs/setup/settings-template.json registers the guard (<himmel-path> placeholder, resolved by the setup scripts), and scripts/machine-setup/ubuntu.sh swaps every bare rtk hook claude command in ~/.claude/settings.json for bash "<himmel>/scripts/hooks/rtk-hook-guard.sh" on each run — a re-run of rtk init -g after a swap re-adds a raw entry, so the swap is not gated on the guard already being present (HIMMEL-264). Spec: scripts/hooks/test-rtk-hook-guard.sh (25 asserts; token set verified against rtk 0.40.0). The ubuntu.sh swap/patch jq filters have their own fixture spec, scripts/machine-setup/test-ubuntu-settings-jq.sh (22 asserts, drift-guarded mirrors).

Standalone reconcile (HIMMEL-399). The inline swap above only runs inside full machine-setup, which invokes rtk init -g exactly once. When an operator runs rtk init -g on its own it can stack duplicate bare entries, and the swap by itself never collapses the result (guard + a freshly re-added bare entry, swapped again, = two guard entries). scripts/lib/reconcile-rtk-hook.sh <settings-json> <himmel-path> is the on-demand reconcile: swap every bare rtk hook claude entry to the guard AND collapse to exactly ONE guard entry, idempotently (spec: scripts/hooks/test-reconcile-rtk-hook.sh, 16 asserts). Reconcile user scope only — rtk init -g is global and the guard is an absolute path, so a project-scope copy would only double-fire the hook. Expected side effect: rtk identifies its own hook by the rtk hook claude signature, which the guard wrapper replaces, so rtk init --show reports Hook: not found and rewritten commands print a [rtk] /!\ No hook installed banner to stderr — benign noise (the guard is installed and rewriting works), with no rtk flag to quiet it. Do not “resolve” it by re-running rtk init -g; that just re-adds the bare entry the reconcile removes.

check-platforms-tested.sh — pre-push gate (.pre-commit-config.yaml)

Not a Claude PreToolUse hook — runs from the pre-commit framework at git push time. Blocks pushes when the diff vs main touches any cross-platform-sensitive path (*.sh, *.bash, *.zsh, PowerShell files, *.psm1, *.psd1, *.cmd, *.bat, anything under scripts/, or **/bin/*) and no Platforms tested: line is present in a commit body OR the open PR description.

The line must name at least one recognised token (case-insensitive): linux, windows, macos, mac, darwin, ubuntu, debian, fedora, arch, wsl, posix, gitbash, git-bash, powershell, pwsh. Empty (Platforms tested:) and unrecognised (Platforms tested: yes) values do NOT satisfy the gate.

Self-attestation only — the hook does not verify the claim, but the gate forces the operator to think about Linux/macOS-vs-Windows before push, which is where bugs like missing shell:true, $Args collision, BOM mojibake, and python vs python3 keep slipping through CR. Bypass: PLATFORMS_TESTED_OK=1 git push ... or include [skip platforms-check] in any commit message in the push range.

check-doc-guard.sh — doc-guard gate (.pre-commit-config.yaml, himmel-dev only)

Not a Claude PreToolUse hook — runs from the pre-commit framework at git commit time (pre-commit stage) and at git push time (pre-push stage via --pre-push). HIMMEL-454.

Trigger: a command or skill file is ADDED (not merely modified) in any of the watched source paths:

  • .claude/commands/**
  • marketplace/plugins/*/commands/**
  • marketplace/plugins/*/skills/**

…without also touching docs/commands-catalog.md in the same change set. The path → required-doc mapping lives in scripts/hooks/doc-guard-map.tsv so the set of guarded paths can be extended without editing the hook.

Added-only rationale: the gate uses git diff --diff-filter=A and checks only newly-added files. Modifications to existing commands/skills are not gated — catalog decay on edits is acceptable friction; adding a wholly new command/skill without a catalog entry is the primary gap this closes.

.himmel-dev opt-in scoping: the gate is himmel-CONTRIBUTOR-only. At the top of each run the hook calls the is_himmel_dev_repo predicate in scripts/guardrails/lib.sh, which returns true iff a .himmel-dev marker file exists at the repo root. When the marker is absent the hook exits 0 immediately — downstream adopters who only run himmel as a harness are never gated. .himmel-dev is gitignored (never committed to the repo), so a fresh clone has no marker and the gate is inert by default. The scripts/himmel-update.sh --plugins-check run emits a non-fatal warn_doc_guard_off nudge when a himmel-source checkout (detected by the presence of scripts/hooks/check-doc-guard.sh) lacks the marker, prompting the contributor to create it.

rc contract:

  • 0 — pass (marker absent, no new source files, or all additions paired with a catalog touch).
  • 1 — violation: one or more new command/skill files have no corresponding docs/commands-catalog.md update. The hook prints the offending paths and blocks the commit/push.
  • 2 — cannot-evaluate (fail-closed): git, awk, or another required tool is missing; the change set cannot be parsed. Blocks rather than silently passes.

Bypass: DOC_GUARD_OK=1 set in the shell that LAUNCHED the git command (DOC_GUARD_OK=1 git commit …). Per-call prefix works here because this is a pre-commit script (not a Claude hook), so the env var is visible to the child process. Test seam: DOC_GUARD_FORCE_ERR=1 forces an exit-2 to verify fail-closed behaviour; DOC_GUARD_NO_FETCH=1 keeps the pre-push path fully offline (skips any remote introspection).

Pre-commit vs pre-push modes:

  • Pre-commit (default): inspects the staged set (git diff --cached --diff-filter=A). Paired doc touch must also be staged in the same commit.
  • Pre-push (--pre-push flag): reads the push range base...HEAD and checks ALL commits in the range. A command added in commit 1 and the catalog updated in commit 3 of the same push PASSES — the gate only requires the pair to appear somewhere in the pushed range, not necessarily in the same commit.

Smoke test: scripts/hooks/test-doc-guard.sh.

Warn-only doc-freshness detector — doc-freshness.sh (HIMMEL-587)

Companion to check-doc-guard.sh — warn-only, never blocks. Ships alongside the blocking gate as the “drift nudge” surface for doc staleness.

4-column map (scripts/hooks/doc-guard-map.tsv):

The map is now a 4-column TSV (strength / trigger / path-regex / required-doc); previously it held only two columns (path-regex/required-doc`):

  • block rows — consumed by check-doc-guard.sh (unchanged behaviour):
    • block / add / ^\.claude/commands/ / docs/commands-catalog.md
    • block / add / ^marketplace/plugins/[^/]+/(commands|skills)/ / docs/commands-catalog.md
    • block / add / ^marketplace/plugins/[^/]+/\.claude-plugin/plugin\.json / llms.txt — new in HIMMEL-587: adding a plugin manifest requires a llms.txt update.
  • advise rows — consumed exclusively by scripts/lib/doc-freshness.sh:
    • advise / modify / ^scripts/hooks/ / docs/internals/enforcement.md
    • advise / modify / ^scripts/(jira|bitbucket)/ / docs/internals/jira-plugin.md

scripts/lib/doc-freshness.sh — the warn-only detector. Reads only advise rows; double-filtered for near-zero false positives:

  1. Changelog scoping: only files touched by a feat/fix commit in the range count as in-scope (chore / docs / refactor commits are excluded).
  2. Doc-presence suppression: if the required doc itself changed in the range it is already updated — the row is silently skipped.

When both filters pass and a matched in-scope file is found, the detector emits a tab-separated finding line (source-file TAB required-doc TAB reason). It always exits 0 — a broken warn-only hook must never stall a session.

Three surfaces, all gated by HIMMEL_DOC_FRESHNESS:

  • advise surface — /pr-check prints findings at review time (pre-push).
  • session surface — scripts/hooks/inject-doc-freshness.sh (SessionStart hook) injects a <system-reminder> block over origin/main...HEAD at the start of each feature-branch session.
  • morning surface — generate-morning-briefing.sh includes a freshness section in the daily morning report.

HIMMEL_DOC_FRESHNESS grammar (default OFF; grammar mirrors HIMMEL_INITIATIVE):

  • 1 / all / true / on / yes — all three surfaces active.
  • Comma-subset e.g. advise,session — only those surfaces; morning stays off.
  • 0 / false / off / no / unset — all surfaces off.
  • No gate surface — the hard blocking gate is check-doc-guard.sh, controlled by .himmel-dev + DOC_GUARD_OK.

Read from the himmel clone’s .env by the session and morning surfaces (via scripts/lib/load-dotenv.sh); the advise surface also reads .env at /pr-check call time. A value exported in the launching shell or set in ~/.claude/settings.json "env" {} overrides .env.

Bash-only. There is no PowerShell execution path for these three surfaces: /pr-check is a Claude slash command running in Bash, the SessionStart hook is wired via Bash, and generate-morning-briefing.sh is a Bash script.

Smoke tests: scripts/lib/test-doc-freshness.sh, scripts/hooks/test-inject-doc-freshness.sh.

Warn-only RED-control lint — red-control-lint.sh (HIMMEL-2544)

LEAN-INVOKE — run on demand, wired into no hook, gate or CI path. It reports mutation controls carrying the silent failure mode the contract in scripts/lib/red-control.sh names: a RED confirmed line whose enclosing conditional tests ONLY an inequality (!=) or a bare file-existence check (-e/-f), with the mutant’s exit status never compared anywhere in that block. Such a control is satisfied by a mutant that crashed or emitted nothing — the empty string differs from everything — so it prints “RED confirmed” while proving nothing.

Terminal window
bash scripts/lib/red-control-lint.sh # defaults to scripts/
bash scripts/lib/red-control-lint.sh scripts/cr # or any file / directory

One <file>:<line> <reason> line per hit on stdout, a summary on stderr. It exits 0 always — with hits, without hits, and on internal error. That is deliberate: a nonzero exit is all anyone needs to wire a heuristic text-matcher into pre-commit or CI by accident, and this is a heuristic — it anchors on the literal RED confirmed convention string, so a control that does not print that phrase is invisible to it. Report-only, never a gate, by construction.

Its hit list is empty on main by construction — every file carrying the convention string is compliant — so any line it prints is new. That is an invariant maintained at review time, deliberately not gated in CI: T9 of the smoke test scans the real scripts/ tree and reports any hits into the suite log, but passes regardless, because failing there would make a warn-only tool a gate through run-shell-tests.sh and let one heuristic false positive turn the whole shell suite red on unrelated work. The deterministic assertion that does fail on a regression is T8’s, over the suite’s own fixture tree. Smoke test: scripts/lib/test-red-control-lint.sh.

Guardrails (scripts/guardrails/)

Shared shell library of git-state predicates consumed by THREE layers: pre-commit hooks, Claude PreToolUse hooks, and himmel-gh slash commands. Adding a new predicate to scripts/guardrails/lib.sh lights up across all three call sites at once.

Predicates (lib.sh)

  • is_on_main [DIR] — current branch is main.
  • is_main_ref REF — ref is refs/heads/main (for pre-push stdin contract).
  • is_dirty [DIR] — worktree has any staged/unstaged/untracked changes.
  • is_merged_into_main [DIR] — branch is merged via direct OR squash (cherry-pick equivalence via patch-id).
  • is_behind_origin_main [DIR] — origin/main has commits not in HEAD.

Each returns 0 (true) / 1 (false). Internal errors return 2 — callers MUST treat 2 as fail-closed. The if predicate form silently collapses rc=1 and rc=2 into one branch and fails OPEN on errors; use the guard_call helper exported from lib.sh (or pred_check inside guard-gh.sh) for if-style consumption, or branch explicitly on $? when finer control is needed.

himmel-gh dispatcher (guard-gh.sh)

Consumed by /gh-pr-create and /gh-pr-merge. Verb/state matrix:

VerbStateActionOverride
pr-createHEAD == mainrefuse (rc=2)none
pr-createdirty worktreewarn (rc=1)--allow-dirty flag
pr-createbranch merged into mainrefuse (rc=2)--allow-merged-base flag
pr-merge --adminanyrefuse (rc=2)session env GH_ADMIN_MERGE_OK=1

rc=0 proceed, rc=1 proceed-with-warning, rc=2 refuse. Per the bypass convention above, GH_ADMIN_MERGE_OK=1 must be set in the shell that LAUNCHED Claude — per-call prefix does not work because Claude cannot inject env vars into hook processes.

The --allow-* flags are CONSUMED by the guard (stripped from argv before forwarding to gh). --admin is INSPECTED but forwarded.

Fails-open-on-unknown lint (lint-fail-open.sh, HIMMEL-1776)

Convention: every guard classifies its input allow / deny / unknown — and unknown never silently takes the allow path. HIMMEL-1776 was filed against 5 confirmed instances of this one rule implemented wrong in one worker. The lint pins 4 SHAPES (unreadable-config, sentinel-not-denied, pct-unguarded, quota-zero) against 6 pre-fix reconstructions in fixtures/fail-open/ — sentinel-not-denied (an unresolvable endpoint taking a sentinel a wildcard matrix cell matched) has 3 code-shape variants (plain, multiline fallback arm, single-quoted literal), the other three shapes one apiece (an unreadable denylist reading as empty; a null usage budget reading passing a >= refuse gate; a malformed transcript resolving at zero cost). lint-fail-open.sh lints the SHAPE over the guard surfaces (scripts/guardrails/, scripts/hooks/, lanes preflights + bench, spawn-glm.ts, the launcher family, refresh-graph-map.sh): a -f-gated read with no -r and no failure handling, a fallback classification literal with no hard-deny comparison, a *pct* threshold compare with no unknown branch, a money reading coalesced to 0 instead of ?? null. A deliberate exception is marked inline — # fail-open-ok: <reason> (shell) / // fail-open-ok: <reason> (JS) — the reason is the point, not the marker. Each detector is proven red against a committed reconstruction of the pre-fix code it targets (fixtures/fail-open/); the suite’s final case re-asserts the whole tree stays clean, so the NEXT occurrence of this shape — the standing guard, not a specific numbered instance — cannot merge quietly.

Smoke tests

  • bash scripts/guardrails/test-lib.sh — predicate behavior.
  • bash scripts/guardrails/test-guard-gh.sh — dispatcher matrix.
  • bash scripts/guardrails/test-lint-fail-open.sh — fails-open lint (also discovered by scripts/ci/run-shell-tests.sh).

Run after any edit to lib.sh or guard-gh.sh before pushing.

scripts/uninstall.sh — wet-run REAL_HOME fence (HIMMEL-2505)

POSIX plugin teardown verifies outcomes (HIMMEL-2796/2804/2800): removal tries plugin scopes plus the install-profile scope and re-lists marketplaces; a listed marketplace is repaired only after a scoped uninstall fails and is never transiently cleaned up; marketplaces-only previews subtract only exact plugin/scope/project handoff rows (legacy marketplace-only rows remain usable for scope replay, not subtraction). PowerShell parity is tracked separately by HIMMEL-2783.

Consent regression coverage (HIMMEL-2755, HIMMEL-2807): non-interactive uninstall without --yes refuses with rc=2; run scripts/himmelctl/test/test-uninstall-confirm.sh, scripts/himmelctl/test/test-wizard-uninstall.sh, scripts/himmelctl/test/test-wizard-uninstall-converge.sh, and scripts/test-e2e-symmetry.sh (real --yes settings roundtrip) when changing that contract.

Ledger-driven removal (HIMMEL-3332 S6): after the fence, uninstall.sh loads the provenance ledger (${HIMMEL_PROVENANCE_DIR:-$HOME/.himmel}/provenance.jsonl) through scripts/lib/provenance-read.sh and folds it into one verdict per unit: remove what himmel created, restore what it replaced from the recorded backup, keep what pre-existed, is already gone, was modified since install (prompt on a TTY) or has no backup. The read fails closed: a missing, unparsable or unfoldable ledger (or no jq) keeps the six overwrite-prone rows — plugins, marketplaces, statusLine, env.HANDOVER_DIR, the claude-hud config, adopter script copies — and prints a hand command for each. --dry-run writes nothing, the ledger included; outcome rows are appended only when the ledger loaded. --purge-state deletes the ledger and its backups last, and only after a clean (not halted) run; --keep-backups skips the backup prune. Coverage: scripts/test-uninstall-provenance.sh, scripts/lib/test-provenance-read.sh, and the no-ledger regression in scripts/test-uninstall.sh.

Not a hook — a guard the script carries on itself, listed here for the same reason: it stops a destructive run the hook pipeline never sees (uninstall.sh is invoked directly, not through the Bash-tool matchers). After a 2026-09-03 incident where a mutation-test run executed the script (no --dry-run) against the operator’s real $HOME and swept ~/.claude, ~/.ssh, ~/.gitconfig, ~/.codex and ~/.local, a WET run now refuses — before the banner, the confirmation prompt, or any step — whenever $HOME carries a live-operator marker (~/.claude/.credentials.json, ~/.claude.json, an ~/.ssh/id_* key, or ~/.gitconfig), unless HIMMEL_UNINSTALL_REAL_HOME=1 is set (the operator’s own shell, the himmelctl wizard’s confirmed teardown spawn, or the VM harness — never a test suite, which must point at its own fixture $HOME). --dry-run is never fenced; nothing is removed under it. The refusal:

ERROR: refusing a wet uninstall — found <marker>
This $HOME (...) looks like a live operator profile, not a test
fixture. A wet uninstall here is refused by default (HIMMEL-2505,
after the 2026-09-03 incident where a test run swept a real HOME).
If this really IS the machine to offboard, re-run from your OWN
shell with: HIMMEL_UNINSTALL_REAL_HOME=1 bash scripts/uninstall.sh ...
Otherwise, pass --dry-run to preview without touching anything.

Lifting the fence is policed at the caller, not at runtime (HIMMEL-3336). The manifest rows whose env column is - (user-claude-md, user-agents-md, hud-config) resolve {HOME} from $HOME with no per-row override, so a suite that lifted the fence while redirecting only some of [6/8] (the HIMMEL_USER_SETTINGS/cache/bridge vars) reached the operator’s live files. A runtime refusal on “fence lifted plus a per-row override” was rejected: the himmelctl wizard’s confirmed teardown does exactly that for an operator with a non-default install, and at runtime it is indistinguishable from a suite. The rule instead: every file under scripts/ that sets HIMMEL_UNINSTALL_REAL_HOME=1 must also assign HOME from a mktemp-derived value in the same file (a literal mktemp, or a variable traced back to one), or be on the short operator-path allowlist in scripts/test-uninstall-real-home-callers.sh (today scripts/himmelctl/bin.js and the remedy text in scripts/uninstall.sh, a reason each). “Sets the var to 1” covers VAR=1, quoted or numeric JS keys/values ('VAR': 1, VAR: '1') and PowerShell $env:VAR = 1, and only exactly 1 (not 10 / 1x / 1.5). “Traced back to one” follows HOME through transitive simple NAME=value assignments (td=$(mktemp -d), ctl="$td/home", HOME="$ctl"), and a value carrying a ${x:-...}-style expansion outside a $(...) is never scratch. That suite is a text heuristic and says so — it does not follow a HOME set through a helper function or sourced file, nor an env block passed as a variable, and it cannot tell an assignment from assignment-shaped text in a comment or quoted string (a false pass, tracked in HIMMEL-3345); adding an operator-path caller means adding it to the allowlist on purpose.

Same operator ruling: wet/destructive test suites now run only inside a VM, never against a live station — see docs/setup/vms.md.

Remote auto-actions — Telegram /arm (HIMMEL-424)

A sanctioned surface for the operator to trigger a bounded privileged action from Telegram. Auth model B2: the trusted bridge (scripts/telegram/) parses a structured command and invokes the action DIRECTLY — the spawned claude agent is never in the trust path. So a conversational “resume HIMMEL-x” to the agent does NOT arm anything; only the bridge-parsed /arm does. Auth = operator-identity: the allowlisted operator (sender ∈ global allowFrom) sent a non-forwarded, typed /arm in a DM or an allowlisted group — never body content an attacker could inject.

Command: /arm <ticket|path> [at HH:MM | auto | smart] (default smart). A ticket (^[A-Z][A-Z0-9]+-[0-9]+$) resolves to a resume handover under handover_root (case-insensitive, specs/ excluded, type: handover preferred, ambiguity refused — never silently picked); a path must exist and resolve under handover_root. The bridge shells auto-action.sh → arm-resume.sh (per-handover dedup; no --force/--dedup-any remotely). An explicit at HH:MM rides --long-gap — a HUMAN typing a far time on Telegram IS the explicit choice the HIMMEL-1475 long-gap guard exists to force (the guard targets the orchestrator silently defaulting to a far park, not a typed one); smart/auto keep the bare call (the guard exempts those sentinels by design).

Activation flag — TELEGRAM_AUTO_ACTIONS (default OFF, operator-only). A per-op enable-list whose grammar mirrors HIMMEL_INITIATIVE (so users learn one convention): unset / 0/off/no → no ops (inert; /arm is ordinary chat); 1/all/on/yes → every NON-privileged op; else a comma-list of op names (case-insensitive, unknown tokens dropped, pure-typo → off). v1 shipped one op (arm-resume); v2 (HIMMEL-1213) adds a second, merge-public; v3 (HIMMEL-1272) adds a third, restart (the bridge bouncing itself — see below) — each op is its own independent opt-in token (enabling one does NOT enable the other), and merge-public ships DISABLED by default like every op here — adding its name to the dispatch table only makes it recognizable, never enabled. merge-public is EXPLICIT_ONLY (a privileged public squash-merge): the blanket =1/=all/on/yes aliases do NOT enable it — it requires being NAMED explicitly (=merge-public or =arm-resume,merge-public), so an operator already running =1 for arm-resume cannot silently inherit the merge capability (HIMMEL-1213 CR). The enable-all aliases turn on every non-EXPLICIT_ONLY known op. The dispatch-table keys (OPS/KNOWN_OPS in auto-action.ts) are the closed op allow-list, re-asserted in auto-action.sh; EXPLICIT_ONLY_OPS is the privileged subset the alias skips.

Where to set TELEGRAM_AUTO_ACTIONS (HIMMEL-1270) — the flag; EXPLICIT_ONLY_OPS above is a code-defined constant, not an operator setting. Either the bridge’s own ~/.claude/channels/telegram/.env (the file that already holds TELEGRAM_BOT_TOKEN) or the poller’s launching env — a real process env var wins over the file. Restart the bridge to apply either way. Until HIMMEL-1270 the poller read this key from process.env ONLY while the docs pointed at the file, so a correctly-edited .env enabled nothing and /mergepub silently routed as chat. A configured value that parses to zero ops (e.g. arm instead of arm-resume) now logs a startup WARNING naming the unrecognised tokens, so “I enabled it and nothing happened” is no longer indistinguishable from “it’s off”.

Guards (fail-closed):

  • Operator-identity — an auto-command runs only when the SENDER is the allowlisted operator (isAllowed(access, from) — the global allowFrom). This authorizes /arm from the operator in a DM or an allowlisted group (groups carry distinct per-group context). A non-operator member of a shared group has a from not in allowFrom, so their /arm falls through to ordinary (powerless) chat. The chat is also already allowlisted upstream by makeAllow at ingest, so this is operator-identity on top of chat-allowlisting. The reply routes back to the originating chat (group → its own group_<id> session). (Earlier DM-only restriction relaxed for hardcoded operator-only groups; identity check keeps it safe if a third party ever joins.)
  • Typed-only — a media-caption or voice-transcript /arm (caption: true) is not eligible; only a genuinely typed m.text command is.
  • Forward-refuse — a forwarded /arm (any Telegram forward marker) is refused and audited (refused-forwarded); this kills the prompt-injection vector.
  • The arm runs fire-and-forget off the ingest loop (a slow --time smart arm can’t stall polling); the operator reply goes via the chat outbox + flush.

Audit: one append-only, sanitized line per attempt (executed OR refused) to bridgeRoot()/auto-action-audit.log: <iso-ts> chat=<id> user=<id> fwd=<0|1> op=<op> arg=<arg> resolved=<basename> time=<t> rc=<n> result=<armed|already-armed|ambiguous|refused-forwarded|no-match|error>. For merge-public the SAME log/line shape is reused (arg=PR number, time=SHA, resolved empty) with additional result values: merged / not-green / head-moved / no-open-pr, else error. The result= label is computed by poller.ts’s op-aware auditResult(op, rc) (HIMMEL-1213 codex-adv): because merge-public relays merge-public-on-green.sh’s own rc space, rc=0 logs as merged (never arm-resume’s armed) and 12/15/16 as no-open-pr/head-moved/ not-green, so result-keyed forensic queries stay correct for the irreversible merge outcome.

restart (HIMMEL-1272) — the bridge bouncing ITSELF. Commands: /restart (rung 1, poller only) and /restart full (rung 2, whole bridge). Anchored on the whole message like the other two, so a stray /restart mid-sentence is chat. It carries the SAME unconditional guards (operator-only sender, typed-not-forwarded, not a caption) but sits in the ORDINARY tier, not EXPLICIT_ONLY_OPS: it does no git write, touches no public repo, persists nothing in the scheduler, and its worst case is a bridge that comes back up. The obvious abuse is a restart loop, and the sender is already pinned to the global operator; the supervisor’s POLLER_MAX_FAILS breaker is the backstop.

Unlike the other two ops it has NO script — it is executed in-process by poller.ts (makeRestart), because rung 1 must exit the caller and rung 2’s relaunch must outlive the processes it kills. dispatchAutoAction refuses it (SELF_EXECUTED_OPS) so it can never be laundered into an auto-action.sh call. Rung 1 exits 0 and lets supervisor.ts respawn; rung 2 shells schtasks /run /tn HimmelTelegramBridge — the already-registered logon task (the logon task), fired by the scheduler SERVICE so it is detached from the poller it is about to kill, and so it reads the CURRENT User environment (the only mechanism that picks up a SetEnvironmentVariable(…, 'User') without a reboot). Non-Windows refuses rung 2 with rc 20 rather than pretending.

The ack is APPENDED to outbox.jsonl before firing and is drained by the respawned poller on its next tick — this process never flushes it, because firing is what kills the transport. Audit labels are restarting (accepted + fired), restart-unsupported (rc 20, or a build with no restart dep wired) and error (a non-zero fire rc, which is reported rather than swallowed). An accepted restart whose fire FAILS therefore writes two lines: restarting then error — acceptance and outcome are separate events, since the process expects not to survive the first. Bootstrap caveat: enabling restart requires one manual restart, because the flag is read at poller startup.

merge-public (HIMMEL-1213) — the Telegram-authorized PUBLIC-repo merge. Command: /mergepub <pr> <sha12> (sha12 = the PR’s head SHA, ≥12 hex chars, from the agent’s PR-ready report). The 12-hex floor (48 bits, up from 7=28 bits) blunts a prefix-grinding attack: the agent may push public fix-commits, so a 7-hex prefix could be ground to a malicious commit sharing the operator-approved prefix and pass both the SHA gates and --match-head-commit (HIMMEL-1213 gate review). Anchored on the whole message, same as /arm — a mid-text or malformed /mergepub falls through to chat, never auto. On a match the bridge shells the new sanctioned script scripts/merge-public-on-green.sh <pr> <sha> (stripped child env, no TELEGRAM_BOT_TOKEN) via auto-action.sh’s merge-public case, which additionally validates the PR is numeric and the SHA is 12-40 lowercase hex (anchored, rejecting multi-line values) before shelling out. This is the ONLY way a public squash-merge gets authorized — no allow-rule for this script or a public gh pr merge exists anywhere (and since HIMMEL-3402 no Bash(bash scripts/*) prefix rule reaches it), so an agent’s run goes to the classifier, and the script self-refuses under that path anyway (see gate 7 below). The sanctioned script enforces 7 gates, mirroring merge-on-green.sh’s (HIMMEL-1042) structure but with the binding INVERTED (public-pinned, not private-only) — see scripts/merge-public-on-green.sh’s header for the full exit-code reference:

  1. Repo pin — the PR’s own URL must resolve to CR_PUBLIC_REPO (default yotamleo/Himmel) — never cwd-derived (the script runs from the bridge’s checkout cwd).
  2. PR OPEN + base = default branch, re-verified FRESH immediately before merging (the HIMMEL-1080 retarget-race lesson, ported verbatim).
  3. Head identity — the PR’s headRefOid must prefix-match the operator-supplied SHA, checked BOTH up front and at the fresh pre-merge re-verify. Mismatch = refuse (“head moved”), never merge.
  4. check-ci.sh exit 0 is the only pass (CI green + zero unresolved threads
    • no changes-requested + the HIMMEL-1126 body/outside-diff gate). Since this script runs from the private-repo cwd, it invokes check-ci.sh with the PR’s full URL as the selector, never a bare number, so gh resolves the right repo.
  5. Audit preflight + durable MERGING record before the merge; refuse if the sink is unwritable (same fail-closed contract as merge-on-green.sh).
  6. gh pr merge <pr> --squash --admin --repo <repo> --match-head-commit <fresh-headRefOid>, then poll to confirmed MERGED before reporting success — a zero gh rc means “accepted”, never “merged” outright.
  7. CLAUDECODE self-refusal — exits immediately (before any gh call) if the CLAUDECODE env marker is present; the bridge spawns this script’s env WITHOUT that marker, so this can only fire on an agent-initiated call. Deliberately a SEPARATE script from merge-on-green.sh, not a shared “which repo class” switch — the repo guard there is a safety boundary, and a flag that inverts a safety boundary is the failure mode this avoids. HIMMEL-2869 widened that guard to admit ONE named public origin under live-verified branch protection, which does not soften this reasoning: the two bindings still point opposite ways — this script’s pin NARROWS what a human-only, CLAUDECODE-self-refusing sanctioned script may touch, while merge-on-green’s WIDENS a boundary an agent runs under. Protection source (HIMMEL-3808). The widened public-origin gate is satisfied by classic branch protection, by an effective ruleset, or by both, per HIMMEL_PROTECTION_SOURCE in merge-on-green.sh: classic, ruleset, either (default) or both (both must pass). Like HIMMEL_PUBLIC_ORIGIN_NWO it is a fixed literal in the script, never an env seam (ruleset/either are widening choices; an ambient variable must not steer a merge gate) — an adopter chooses by editing the line, and an unknown value refuses (exit 12, unknown-protection-source=<value>). Classic = enforce_admins on AND a non-empty required-status-checks list (unchanged). Ruleset = rules/branches/<base> carries at least one required status check AND every contributing ruleset (rulesets/<id>) is enforcement: active with an EMPTY bypass_actors — any bypass actor (admin RepositoryRole, pull_request or always mode) refuses, since it can merge past the required checks; an API error, null/non-array field or zero checks refuses too. either reads classic first and never touches the ruleset endpoints when classic passes. Both call sites (guard 2b and the fresh pre-merge re-read) share one function, and each success writes PROTECTION-OK[-PREMERGE] source=classic|ruleset|classic+ruleset detail=… to the audit log. With the live yotamleo-org/Himmel ruleset (protect-main, admin bypass pull_request) the ruleset path refuses and classic protection carries the merge; relaxing that bypass is an operator call. merge-on-green.sh exits 18 (GitHub-blocked, HIMMEL-3381) on a fresh pre-merge BLOCKED + REVIEW_REQUIRED policy read after green checks, an explicit GitHub base-branch policy / required-check / ruleset rejection at merge time, or check-ci.sh exit 5 (a required check never reported, or the required set is unreadable) — once, with one operator DM per (repo, PR, head) and no poll or retry (exit 17 is the policy refusal: no GO, a GO path naming another PR or head, or a solo-mode refusal when himmel.mergeMode is set); if no automation identity can satisfy the required review, the merge is a human admin action; on this repo the operator relaxed protect-main on 2026-09-09 (HIMMEL-2887). The Jira auto-transition is opt-in per merge (HIMMEL-3143): without --jira-transition merge-on-green.sh merges, then only records jira-transition=skip=partial key=<KEY> status-unchanged on the MERGED audit line and posts the PR body’s Remaining: line as a Jira comment — the ticket status is never changed (HIMMEL-6094) — so the caller decides (a worker reads its brief’s completes-ticket: yes|no line, docs/handover/worker-preface.md § Shipping — HIMMEL-3271); with the flag it comments and transitions the ticket named by the PR title’s [PROJ-N] tag (never an Epic — a Story is transitioned like a Task, HIMMEL-5540; any unreadable step degrades to a skip=… result, never a failed merge). It used to fire on every merge and closed a ticket whose sibling work was still owed, which no “no other open PR” heuristic can detect, so it stays opt-in rather than heuristic. A PR carrying two ticket tags transitions only the first. It also exits 17 (policy-refused phase=console-go) when HIMMEL_CONSOLE_LEG is set — exported by console-kit/headed-arm-leg.sh into every console-spawned leg — and go_gate (scripts/lib/go-gate.sh, shared with block-unresolved-cr-merge.sh’s Console-GO gate and with go.sh; see there) finds <handover_root>/.locks/go/<pr>.<certified head sha> missing or not carrying head=<that sha> (a broken/truncated go-gate.sh refuses too: phase=console-go-lib-missing / console-go-symbol-missing); the gate runs before the marker clear and before --dry-run, so a refused leg mutates nothing and a dry run reports the refusal, and the merge itself pins --match-head-commit to that same certified sha; a console leg’s merge must also pass --go <that GO file> (same directory and basename as the file go_gate verified, so another PR’s or head’s file or a missing path exits 17 with phase=console-go-flag) and prints a GO evidence: line, so the merge command shows the review it relies on (HIMMEL-7165); only the console writes it, via console-kit/go.sh (which refuses under the marker), and scripts/chokepoints.json registers the marker so a per-call HIMMEL_CONSOLE_LEG= prefix is denied (HIMMEL-2919). A --judge leg (HIMMEL-3133) is the same HIMMEL_CONSOLE_LEG=1 process, so it is blocked from writing its own GO by this exact same gate — Guard E needs no judge marker for that; “the judge is a leg” (design §3.2). HIMMEL-4564 later added a separate HIMMEL_CONSOLE_JUDGE=1 marker for a different job, the write fence below.

Plugin-bump recheck (HIMMEL-5188, HIMMEL-5203, HIMMEL-5208, HIMMEL-7310). Plugin versions change only in a release cut, the PR that changes the root VERSION file; any other PR leaves every plugin.json version as it is at its merge-base, so two PRs editing one plugin from the same base never collide and never need a merge of main. merge-on-green.sh exits 23 when a PR touching marketplace/plugins/ fails that rule against the live base: a non-release PR changed a plugin version, or a release cut leaves a plugin it touches at or below the version the base already ships, which would put two contents under one version. It runs scripts/check-plugin-drift.sh --bump-required from a freshly fetched base-tip copy (with scripts/lib/vm-mode.sh); a checker blob that cannot be read OR is empty refuses (an empty script exits 0), and a checker that exits 0 without one of its fixed success lines (no plugin version changed outside a release cut / release cut: every changed plugin version is above the base / no plugin files changed, whole line) refuses too, so a newline-only or unrelated-output stub is no verdict. scripts/check-plugin-drift.sh is in ci-trust-paths.txt because the gate executes it (vm-mode.sh is covered by ^scripts/lib/). Residual window: a sibling PR merging between the recheck and gh pr merge is not seen; closing it needs a server-side merge queue or required up-to-date branch, like the HIMMEL-1105 retarget window.

Solo mode (HIMMEL-7001). An adopted project with one person and no chief selects it with git config himmel.mergeMode solo in the anchor’s local config; adopt.sh ships merge-on-green.sh and the files it needs (check-pr-title.sh, ticket-id-internal.sh, ticket-id-internal-authors.txt, chokepoints.json). Solo mode relaxes no gate an adopted project can run: green checks with every review thread resolved (check-ci), the trust-path, main-red and privacy gates, and ARMAUTOMERGE all still apply, and a PR touching a trust path still needs a trust-reviewed GO. Only a himmel chief signs that GO for an adopted project (go.sh --repo <owner/name> --trust-reviewed <qid>, HIMMEL-7392), and only for a repo listed with merge_allowed true in scripts/guardrails/himmel-adopted.json on the harness repo’s default branch (shipped empty), on a judge record written with write-verdict.sh --repo <owner/name>. Without that GO merge-on-green.sh refuses such a PR for every caller; the operator merges it in the forge web UI or with a raw gh pr merge, both of which bypass the script and so nothing enforces the list there. The optional forge branch rules (HIMMEL-3897) are the way to enforce it on those paths. The one skip is in the static re-check: an adopted project carries none of the harness’s registries, so a check whose script is absent from the merge result is skipped and audited per check (STATIC-RECHECK skipped check=<name> reason=solo-absent), while a script that is present still runs and still fails closed (exit 24); outside solo mode a missing script is still exit 24. No GO file is asked for because only a leg session or a caller that passes a GO file is ever bound to one. What it adds is a refusal (exit 17, phase=solo-console-leg / solo-himmel-clone) wherever a GO is the rule, a console leg or an anchor carrying console-kit/go.sh, and any value other than solo fails closed (phase=solo-invalid-mode). A solo merge’s MERGING and MERGED audit lines end in mode=solo; without the config nothing changes.

Judge write guard (scripts/hooks/guard-judge-writes.sh, plugin copy marketplace/plugins/himmel-ops/hooks/guard-judge-writes.sh, HIMMEL-4564/4608/4614). With HIMMEL_CONSOLE_JUDGE=1 (exported by headed-arm-worker.sh --judge) it denies a judge every push, PR write, merge, GO, inbox send, Jira write and non-qmd MCP call, and any Write/Edit outside: ~/.cache/himmel/verdicts/, /tmp/claude-<uid>/, <handover root>/**/verdicts/<qid>/ and the judge’s own doc. The doc and qid come from HIMMEL_CONSOLE_JUDGE_DOC / HIMMEL_CONSOLE_JUDGE_QID, both set by the launcher (the qid is the <qid> of the judge’s resume_cwd ~/.cache/himmel/verdicts/<qid>/scratch); a missing or malformed value allows nothing under the handover root, so a judge cannot forge a sibling judge’s verdict or doc. A symlink that does not resolve is denied. Bash file writes are not screened (residual, HIMMEL-4607). With the marker unset it is a silent no-op. It exits 21 (REFUSED reason=trust-path, HIMMEL-3895) for EVERY caller, worker or not, when the PR’s diff touches a CI trust path and the GO does not carry a valid trust-reviewed mac. The pattern list (scripts/ci/ci-trust-paths.txt, one ERE per line) is read from the repo’s default branch via the contents API, never the PR head or a worktree file, so a PR editing the list cannot shrink its own gate; the PR’s files come from REST pulls/<n>/files (paginated, renames match on both names), the listed count must equal changed_files, and the head sha is re-read around the listing so a push mid-check refuses. A missing list, an empty one, an invalid ERE or any unreadable step refuses; the one exception is a 404 when the anchor checkout’s origin URL positively names a different github.com repo, which is audited TRUST not-adopted and merges as before (an unreadable origin or an SSH host alias cannot prove that, so it refuses). In an adopted repo (its default branch’s .claude/settings.json sets env.HIMMEL_REPO) a missing list refuses instead of not-adopted, and the list may add to the anchor’s core list scripts/ci/ci-trust-paths-core.txt but never shrink it: a changed file matching a core line the list lacks refuses list core-missing <pattern> (HIMMEL-7391). A trust-path PR additionally refuses while the anchor checkout is behind origin (git ls-remote origin HEAD not an ancestor of the anchor’s HEAD), so the gate code that runs is current. The GO passes only when go_trust_gate (scripts/lib/go-gate.sh) verifies the ordinary GO and a trust-reviewed=<id> line inside its mac — go.sh --trust-reviewed <id> signs himmel-go-trust-v1|nwo|pr|sha|id, a separate domain from the ordinary himmel-go-v2 mac, so an ordinary GO cannot be relabelled and the id cannot be edited after signing; success is audited TRUST reviewed … trust=<id>. Residual risk: the key (~/.config/himmel/go-hmac.key) is readable by the same uid every worker runs as, so the mac proves the file came through go.sh, not that a human or judge ran it; and a raw gh pr merge bypasses merge-on-green entirely — binding the trust review to required CI on the base is HIMMEL-3897 (H1). That is why merge-on-green deliberately does NOT reuse this script’s CR_PUBLIC_REPO: an env-overridable constant costs nothing on a narrowing pin and would be a widening seam on the other. Supports --dry-run (every gate runs, no merge fires) for manual/terminal shakedown; the Telegram command grammar has no dry-run variant. The repo name itself carries no secret (himmel is a single public repo) — only the activation flag + allowFrom are operator-personal, and those stay out of the repo (.env / access.json).

Still HARD-blocked (out of scope): editing access.json/settings.json, --force/--dedup-any arms, merging PRs except via the head-SHA-bound merge-public op, ops other than arm-resume/merge-public/restart (the closed allow-list is OPS/KNOWN_OPS in auto-action.ts — this line tracks it).

Tests: scripts/telegram/{router,auto-action,poller}.test.ts (bun) + scripts/telegram/test-auto-action.sh + scripts/test-merge-public-on-green.sh (privileged-script smoke).

Claude invocation billing

Subscription-authenticated claude -p / --print / --bg draws from the same 5-hour/weekly subscription usage budget as interactive sessions. Verified 2026-10-13 (HIMMEL-5424):

  • Anthropic’s Help Center says the previously announced headless/Agent SDK billing change is paused and that the Agent SDK, claude -p and third-party apps still draw from subscription limits; its 2026-10-07 update repeats that they still run on subscription limits.
  • New on 2026-10-07: Max and Team plans get monthly API credits (Max 5x $100, Max 20x $200, Team up to $500 pooled; no rollover). They are spent only through an API key from a linked Claude Console org, the credit page lists Claude Code as not covered, and the credits do not change Claude or Claude Code usage limits. The Help Center adds that the credits do cover claude -p and the Agent SDK when you run them with an API key from the linked org; claude -p signed in with the plan, and runs started by the GitHub Action, an IDE extension or the desktop app, still draw the plan’s usage limits.
  • HIMMEL-1748 measured the CLI path on 2026-08-11/12: claude -p drew from the same subscription usage budget; a 48-chunk luna refresh exhausted it about 2.9 hours in and chunks 27-48 failed. Invocation shape is therefore not the cost question; pre-run usage budget state is.

Sources: https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan, https://platform.claude.com/docs/en/about-claude/api-credits-for-subscribers, https://support.claude.com/en/articles/17154008, https://code.claude.com/docs/en/headless.md.

Rule for scripts in this repo: scheduled or unattended sites route through scripts/lib/bank-preflight.sh before spending. Headless calls parse the result with --output-format json rather than sniffing prose and declare an explicit --permission-mode (never bypassPermissions). These are review-enforced conventions, not structural guarantees.

Which headless shape fits which job (claude -p vs claude --bg vs headed) — env inheritance, permission-mode, confirm/dedup, fleet-UI visibility per mode: headless-modes.md.

HIMMEL-5424 retired the no-headless-claude pre-commit gate and its # headless-claude-ok: marker: it verified marker presence only, never the preflight, JSON-output or permission-mode conventions, so it added friction at every call site without controlling spend. bank-preflight.sh is the drain control.

Re-measure recipe: snapshot the five_hour and seven_day utilization values, run one known subscription-authenticated claude -p job, then snapshot both values again and compare the deltas.

API-key claude -p and the monthly API credit (HIMMEL-5433, 2026-10-13): as the billing note above says, the credit covers claude -p run with an API key from the linked Claude Console org (https://support.claude.com/en/articles/17154008); the platform page’s “Doesn’t cover: Claude Code” row means interactive Claude Code. So the API credit lane (claude -p + ANTHROPIC_API_KEY) uses the covered surface; no switch to the SDK or the raw Messages API is needed. Measured 2026-10-13 (account B, Haiku, total_cost_usd 0.0032): Console > Billing promotional credit went from $199.91 to $199.90, so the spend landed on the credit.

Worker API-lane switch (HIMMEL-6476): scripts/api-lane/claude-api.sh refuses rc 5 unless HIMMEL_API_LANE=on is in the caller’s environment, and a per-call env prefix is refused by the guards, so workers never reached it and the PR critic (critic-first-pass.sh) was skipped. headed-arm-worker.sh now reads an opt-in seam from the LAUNCHING shell: WORKER_SESSION_API_LANE=on puts HIMMEL_API_LANE=on and HIMMEL_API_ACCOUNT=auto into the worker’s environment (--dry-run prints both). Default OFF: unset or any other value launches a worker with neither. The wrapper strips an ambient HIMMEL_API_LANE/HIMMEL_API_ACCOUNT (both are in chief_context_worker_env_unset_names), so the seam is the only way in. The console makes it the standing default by exporting WORKER_SESSION_API_LANE=on in its own launch environment. The switch does not bypass spend control: claude-api.sh still applies the API credit guard (~/.himmel/state/api-credit) and account routing per call, and bank-preflight.sh still gates the launch itself.

Subagent transcripts are NOT flat files under the project directory. A session’s own transcript is ~/.claude/projects/<slugified-cwd>/<sessionId>.jsonl, but every subagent it dispatches writes to ~/.claude/projects/<slugified-cwd>/<parentSessionUUID>/subagents/agent-<agentId>.jsonl. Any usage attribution that globs only */*.jsonl therefore sees zero sidechain turns and is structurally blind to subagent spend — which the HIMMEL-2653 measurement found to be 50.06% of a 4.40e9-token 24-hour window (general-purpose alone 49.85%). Recurse (**/*.jsonl), and deduplicate on requestId: one streaming request emits ~1.83 JSONL lines carrying identical usage counters, so counting lines instead of requests inflates the total by ~83%.

scripts/lib/bank-preflight.sh’s fleet-size cap only REFUSES a caller that declares CADENCE_USAGE_LAUNCH=1 (an actual arm/launch); a plain usage budget read (a /pr-check critic pass, a statusline probe, a hook-smoke test) gets the same verdict and FLEET line but is never refused by it — detail: lane-calibration.md § Launch vs. read (HIMMEL-2789).

Refs: HIMMEL-1748; scripts/graphify/refresh-graph-map.sh; scripts/lib/bank-preflight.sh; https://code.claude.com/docs/en/headless.md.

Operator conventions — worked examples

The two operator conventions (layer-selection HIMMEL-177 + structural>instructional HIMMEL-195) keep their directives in CLAUDE.md. The illustrative examples live here.

Layer-selection examples (HIMMEL-177)

The full rationale: default to lean-invoke because it keeps the cost on the operator’s side (one slash command when needed), which is the right side — the operator knows when a rule applies; Claude does not. Adding always-on rules without a trigger creates default-rule drift: CLAUDE.md grows, operator + Claude both stop reading it carefully, rules silently lose authority.

Examples per layer:

  • Safety-critical → default-hook (the cost of forgetting to invoke manually is bigger than the cost of always running): block-edit-on-main, block-read-secrets, gitleaks.
  • Frame-shaping → default-rule (changes how Claude reads the entire task): “PRs require approval”, conventional commits, “prefer plugin over MCP”.
  • High frequency × low marginal cost → default-rule + installed skill: /handover, /clean, /worktree.
  • Eval-shaped → defer: timeboxed ticket, close Won’t Do on expiry. See the feedback_jira_running_numbers auto-memory for the anti-zombie protocol.

Structural-escalation examples (HIMMEL-195)

Each was instructional first, escalated to structural after drift:

  • MCP-jira drift — CLAUDE.md “prefer plugin” → auto-memory feedback_jira_plugin_over_mcp → auto-memory feedback_jira_plugin_strict (after second drift) → PreToolUse hook block-backend-tier.sh (originally block-mcp-when-plugin-exists.sh, generalised to registry-driven routing in HIMMEL-400) + pre-commit mcp-plugin-refs gate (structural-pair — defense in depth).
  • Edit-on-main — operator preference → PreToolUse hook block-edit-on-main.sh (convention was clear, slip was cheap; the hook removes the slip surface entirely).
  • Secrets reads — operator preference → PreToolUse hook block-read-secrets.sh (same pattern; the cost of one slip is bigger than the cost of the gate).

The rule: track the drift count per instructional rule. First drift is signal; second drift is a structural escalation due — don’t wait for the third, by then the rule has lost authority and Claude is rationalising bypasses. default-rule is fine as the FIRST layer; its next layer after drift is structural, not “stronger CLAUDE.md prose.” Prose does not enforce.

himmel-bus delivery hook (HIMMEL-4828)

bus-deliver-hook.sh (PostToolUse) and bus-deliver-sessionstart.sh deliver verified bus records into a session as additionalContext. Dark unless HIMMEL_BUS_NAME is set in the session’s environment; fail-open on its own errors (a nudge, not a fence). Claude Code 2.1.295 caps hook additionalContext at 8000 chars and 200 lines, so a batch stops at 7500 chars / 190 lines and the cursor commits past the emitted records only. record-hook-integrity.sh also pins marketplace/plugins/himmel-bus/lib/*.mjs, and bus-deliver-run.js stays silent (fails closed) if one differs from its pin. server/index.mjs and scripts/telegram/bus.ts sit outside the pinned dirs (residual).

himmel-bus store guard — a soft block (speed bump), not a fence (HIMMEL-4829)

guard-bus-store.sh is a PreToolUse hook on the Bash chain and on the Edit|Write|MultiEdit|NotebookEdit chain (HIMMEL-4818 threat model T3, T5, T6). Phase 1 runs every session and the store as one uid, so the guard only makes the tool-driven routes to forged or leaked bus state cost a denial; direct writes are detected by the hash chain, and prevention needs the phase-2 second uid.

DeniedMatched as
any Bash command that names the bus root (read or write)himmel/bus followed by /, end of word or any char outside [A-Za-z0-9_.-] (so himmel/bus.json is not the root), and not preceded by [A-Za-z0-9_-], in any spelling of the state home
any Bash command whose .cwd is under the bus root, or is the himmel state dir ($XDG_STATE_HOME/himmel normalised after appending /himmel) while the command has a bounded bus token with or without a slash (grep -r . bus, tar cf - bus)the payload cwd (text and readlink -m); bus status run from the state dir is over-denied on purpose
a command that names the state dir (state/himmel, $XDG_STATE_HOME/himmel, ${XDG_STATE_HOME}/himmel) and a bounded bus token anywherecloses the in-command cd ~/.local/state/himmel && cat bus/log/x form
a bus call split over lines with backslash-newline (b\<nl>us, reg\<nl>ister)continuations are joined (the pair removed, as bash does) before the segment split
a verb that can spell register/bind/rebind without writing it ($'reg\x69ster', $'\162egister', reg$x, r?gister, reg[i]ster, re${x}ister, reg$(printf i)ster) after a bus-able word in the segmentsame normaliser as the bus side: ANSI-C escapes and expansions become *, the whole word is glob-matched against the three verbs, and a bare $x (no literal character) is not a verb, so bus send c $BODY stays allowed; a bare variable-held verb is a residual (HIMMEL-5206)
a word that can spell bus without writing it: b''us, b?s, b[u]s, b*s, b{u,x}s, b${x}us, $'\x62'us, b$(true)us, b`true`us, b""us; a glob or expansion right under himmel/per path component, quotes and parens dropped, each expansion or command substitution replaced by *, then glob-matched against bus; the early exit lets any payload with a quote (a JSON-escaped \" included), backtick, $, glob character, backslash or brace through to these rules
a substitution body ($(bus register x), echo "$(bus rebind z)", cat <(bus bind y), backticks)an opener left open by the word split ($(, <(, >(, backtick, ${) ends its word and the text after it starts a segment of its own; an opener alone is never a bus or verb candidate, so diff <(sort a) <(sort b), gh pr create --title "$(git log -1) ..." --body "$(cat f)" and echo $((a + b)) $((c + d)) stay allowed; an opener glued to a word (b$(printf u)s) keeps the segment; a body is nested: the outer state is saved when it opens and OR-ed back at its ) or closing backtick (a close never lowers it), so bus $(true) register x, bus <(:) rebind x, ( bus --summary ')' register x ) and echo `bus --summary \) bind x 1` still pair bus with the verb; a body inherits the outer state, a close never found keeps the rest of the segment outer (fail closed), and parens are counted without modelling quotes (HIMMEL-5238 is the structural answer)
a path spelled with // or /./ (himmel//bus, himmel/./bus)repeated slashes and /./ are collapsed before matching
bus register, bus bind, bus rebinda bounded bus word, then a bounded verb later in the same segment (split on ; & `
assigning HIMMEL_BUS_* (X=1 cmd, export, env)HIMMEL_BUS_<name>= (+= too) in the raw text and in the quote- and backslash-stripped text (HIMMEL_BUS_"NAME"=x, HIMMEL_BUS_NA\ME=x); a non-plain word holding = is run through the same normaliser, and its name part (before the first =) is denied when it glob-matches a HIMMEL_BUS_* variable the repo uses or the pattern HIMMEL_BUS_* (HIMMEL_BUS_${x}=1, HIM${y}EL_BUS_NAME=x, $'HIMMEL_BUS_NAME=x'); a name with no literal character (env "$K=$V" cmd) is a residual (HIMMEL-5206); reading one is not an assignment
Write/Edit/MultiEdit/NotebookEdit on a path under the bus rootthe path text, or its readlink -m resolution

bus status|peers|wait|send|adopt and everything else stay allowed, silently. Fails closed only for its subject: a payload it cannot parse (missing jq, bad JSON, a non-string command) is scanned as raw text and denied when it names the bus root; otherwise it is allowed, so a hook bug never locks a session out. Bypass: BUS_STORE_GUARD_OK=1 in the launching shell (session-sticky) — needed to bus register/bind by hand; the launcher’s own call runs inside a script and never appears in a tool call. Suite: scripts/hooks/test-guard-bus-store.sh.

Residual (text matching, not a shell parser): an interpreter that assembles the path, a renamed copy of the CLI, eval, a script file that does the write, a $(...)-built path, a Bash command that goes through a symlink alias (only file-tool paths are resolved; a symlinked payload cwd is HIMMEL-5206), a payload with no jq where only the raw root text is scanned, a separator inside a quoted option value, the verb fed on stdin to xargs, the verb or path held in a shell variable (v=register; bus $v x), and the Read/Grep tools (not wired to this hook) all get past it. Conversely the guard over-denies, in the safe direction: a command that merely quotes the root (a git grep, a PR body), a verb word after a bus-able word in the same segment (bus send ... --summary "rebind done"; bus send c b*, a glob or expansion word that could expand to a verb), a glob or expansion under himmel/, and any word that can expand to bus (*, b?s, $x) once the cwd or command names the state dir. A same-uid process that re-chains the log is undetected until phase 2.

Hook-integrity pin: monotonic re-pin + anchor-tamper fence (HIMMEL-2528)

What changed. HIMMEL-1666’s record-hook-integrity.sh used to pin every scripts/hooks/*.sh / scripts/guardrails/*.sh script to its git-committed blob hash at HEAD, once, at SessionStart — session-start-absolute. That meant a wired hook change landing on main between a worker’s start and a later call hard-blocked every already-running session, even a fully legitimate merge: the on-disk bytes no longer matched the pin, forever, for the life of that session.

The pin is no longer session-start-absolute. On a mismatch, the launcher (run-hook-with-bash.js’s verifyProjectHookIntegrity()) now re-pins instead of denying forever, iff all three hold:

  1. the on-disk bytes equal the blob at the tip of the record’s stored anchor_ref (refs/remotes/origin/<default branch>, resolved once via scripts/guardrails/lib.sh’s default_branch() and recorded by record-hook-integrity.sh alongside the pins, schema v2);
  2. the record’s previously-stored anchor commit is an ancestor of that tip (git merge-base --is-ancestor — monotonic: a rewind of origin/<default> denies, it never re-pins backward);
  3. the previously-pinned blob for that path is reachable in that ref’s history (git log --full-history --find-object=…) — the new content did not just appear at the tip unrelated to what this session already trusted.

On accept, the launcher advances both the pin (to the new on-disk blob) and the stored anchor (to the new tip) in one lock-serialized transaction (scripts/hooks/hook-integrity-lock.sh), so two racing sessions cannot each half-write the record — a dead lock owner’s directory is reclaimed by renaming it to <lock>.dead.<pid>.<random> before removal, never by an in-place rm -rf, so exactly one contender wins the steal; a stray *.lock.dead.* directory next to the lock is that graveyard path, safe to ignore or clean up. An unmerged, feature-ahead hook change — the on-disk bytes differ from HEAD, but that blob is not the one that would be found by walking anchor_ref’s history — still denies. That is the intended degrade: only a change that genuinely reached the trusted branch re-pins; worktree drift or a not-yet-merged hook edit does not.

New worker-lane fence — the anchor-tamper class in block-glm-external-writes.sh. Re-pinning off origin/<default> makes refs/remotes/origin/<default> security-relevant for the first time: the re-pin step now trusts whatever that ref currently resolves to as the definition of “legitimately committed hook content.” block-glm-external- writes.sh gained a second worker-lane class (immediately after the HIMMEL-2085 pin-dir write-fence class, same gate, same “worker-ness not lane” scope) denying the command shapes a dispatched worker could use to make that ref resolve to attacker-chosen history:

TriggerScopeVerdictBypass
A Bash/PowerShell command matching either (i) a keyword/substring hit — refs/remotes/, remotes/origin/, symbolic-ref, remote set-head, remote set-url, remote add, remote remove, remote rm, remote rename, update-ref, insteadOf, remote.origin.url — anywhere in the (lowercased) command text, or (ii) a command-position git [global-flags] (fetch|push|pull) shape carrying an explicit refspec whose destination writes remote-tracking space — refs/remotes/…, or an <a>/<b> shorthand whose <a> is a real remote/tracking namespace in this repo — regardless of which remote is named, and including via git -C <dir>Any worker_lane session — GLM (ANTHROPIC_BASE_URL = api.z.ai) or a native dispatched worker (HIMMEL_WORKER=1); a headed operator session never reaches worker_lane and is unaffectedDeny, rc=2HIMMEL_HOOK_INTEGRITY_BYPASS_OK=1 set in the launching shell (same bypass var as the HIMMEL-2085 pin-dir class — one escape hatch covers both hook-integrity fences; since HIMMEL-3396 it is honoured only inside a linked worktree of the session’s recorded repo and audited, so the primary checkout keeps both fences on)

Class (ii) gates on the destination, not on the remote. It originally matched an enumeration of local-path remote spellings, and that axis was defeated four times: quoted remotes (codex-6), a bare .., bare relative directory names (codex-3), and finally the configured origin itself — git fetch evil.git x:origin/main, git fetch sub/dir x:origin/main and git fetch origin x:origin/main all measured rc=0 ALLOW before this rewrite, and all three forge the tracking ref exactly as well as git fetch . … does. The fence now classifies the refspec’s destination — refs/remotes/… denies unconditionally, refs/… (heads, tags, …) allows, a bare <a> allows as a local branch name, and an unqualified <a>/<b> denies iff <a> names remote-tracking space in this repo — so the remote argument stopped being load-bearing and no unlisted spelling can slip past. The scan is per command segment (split on ;, &, |, parens), and within a segment the refspec may sit anywhere, which is what covers --refmap=<spec>, --refmap <spec> and -c remote.origin.fetch=<spec> without a per-flag rule.

The <a>/<b> arm has to consult the repo because origin/main and a slash-bearing branch name are the same shape, and himmel mandates type/slug branches — a blanket deny false-refused git push origin HEAD:feat/x, the most common push in the repo. <a> is resolved against the union of git remote and the first segment of every existing refs/remotes/<a>/… ref in $CLAUDE_PROJECT_DIR (the second source matters because git remote remove origin is not fenced by class (i), and a stale refs/remotes/origin/main survives it and still DWIMs). This is not a return to the enumeration the class abandoned: that guessed at an open-ended space of ways to write a remote, where a fifth spelling always exists; this reads a small closed set out of git itself, and a worker cannot extend it because git remote add, set-url, remove/rm and rename are all denied by class (i). Those last three were a spec gap: this ticket’s threat model named remote set-url|add|rename|remove, but the prescribed class (i) fence list omitted rename and remove, and the implementation followed the narrower list — so both measured rc=0 ALLOW until this round (rm is git’s own accepted alias for remove, per git-remote(1), and is fenced with it). Removing or renaming origin is not a footnote here: it collapses the very set this arm consults, while a stale refs/remotes/origin/main survives and still DWIMs — which is why the tracking-namespace half of the union stays in place as defence in depth even now that the verbs are fenced. Read-only git remote and git remote -v match none of the verbs and stay allowed.

Git is spawned only from this arm — after a command-position fetch/push/pull with an explicit two-segment destination — and at most once per hook invocation, so an ordinary Bash call never pays for it. If the set cannot be established at all (not a repo, git missing, non-zero exit) the arm fails CLOSED: the fence refuses what it cannot prove safe, and failing open would hand a bypass to any worker that could make the lookup fail.

Deliberately still allowed: git fetch origin, git fetch origin main, git fetch --all, git push origin feat/x, git pull. These update tracking refs too, via the remote’s configured default refspec — but they carry no explicit tracking destination in the command text, and that absence is the discriminator. git fetch origin main:main (bare local branch) and git push origin HEAD:refs/heads/x (fully-qualified non-tracking) stay allowed as well, and so do git push origin HEAD:feat/x and HEAD:fix/x — feat and fix are not remotes. The (now-retired, HIMMEL-2705) scripts/propagate-public.sh:2274/:2913 used to run git -C … fetch -q origin "refs/heads/$b:refs/remotes/origin/$b", which this class denies and was deliberately not exempted: the hook inspects Bash tool calls, so a script’s own child-process fetch is never seen, and the only reachable case is a worker-lane agent typing that command out by hand — a shape several still-live scripts (e.g. scripts/lib/forge-github.sh) also use as a child process, unaffected by the same token.

The destination and the remote are both matched against a quoting-normalized copy of the command text, not the raw bytes (codex-6, reproduced: pre-fix, git fetch "." x:origin/main measured rc=0 ALLOW — a command-TEXT fence sees the raw text before the shell’s own quote removal, and the bare-spelling-only pattern never matched a quoted .). Normalization resolves a single-character backslash escape (\. → .) and then drops any remaining "/' characters outright, which also collapses adjacent-quote concatenation (""."") onto the same bare spelling for free — every token this class inspects is a bare path/ref segment (., /, alnum, -, _) that contains no quote character itself, so this can only widen a later match, never narrow it. Declared residual: bash ANSI-C quoting ($'...') is not decoded — resolving its escape sequences risked a wrong decode turning into a false ALLOW — so a $' appearing anywhere in a fetch/push/pull segment (remote side or destination side; $'origin\057main' would otherwise survive normalization with no literal / for the destination classifier to see) is denied outright instead, regardless of what it would decode to.

Class (i) is a plain, unanchored substring scan, not command-position matching — same non-anchored philosophy as the HIMMEL-2085 pin-dir class’s own pin_text_hit, and a deliberately broader net than the rest of this file’s command-position arms. Accepted consequence, test-pinned: a commit message that merely mentions one of these phrases in prose now also denies (scripts/hooks/test-block-glm-external-writes.sh’s “glm prose remote set-url (HIMMEL-2528 accepted overmatch)” case) — the same fail-closed direction as every other “Accepted OVER-blocks” row this file’s header already documents for the pre-existing GLM-lane classifiers.

Declared residual — OPEN. This is cheap coverage, not closure, and it is the class that makes origin/<default> a trusted input, so the residual is stated explicitly rather than left implicit:

  • A .git/config edit performed through a path this fence never inspects (a text-editing tool call, or a script whose content this class does not scan), followed by a plain git fetch origin, is invisible — the fetch command itself then names nothing suspicious. (git config remote.*.url IS caught by class (i) when it appears as literal command text — this gap is specifically an edit that never appears as command text at all.)
  • packed-refs / .git/refs/remotes/ written directly through a path never spelled out in the command text this class inspects (redirection into a file already inside a worker’s Edit grant, or any tool-call surface this hook does not mediate) is invisible the same way the HIMMEL-2085 pin-dir class’s own residual is.
  • An implicit-refspec fetch (git fetch origin) from a remote whose URL is already hostile is out of class (ii)‘s reach by construction — the destination gate has nothing to match. What keeps that gap narrow is class (i): git remote add, git remote set-url, git config remote.origin.url and -c url.*.insteadOf are all denied there (verified: git remote add evil /tmp/decoy and git remote set-url origin /tmp/decoy both measure rc=2), so a worker cannot register or repoint a remote as command text first. It does not close the bullet above — a .git/config edit that never appears as command text still reaches the same end.
  • Anything an OS-user-level attacker does entirely outside the tool-call surface stays out of scope, per this file’s header.

Closure is a signed hook-generation manifest — hook content trusted by signature over the committed tree, not by whatever origin/<default> currently resolves to — tracked as HIMMEL-2572.

Round 3 — class (i) is quote-normalized too (codex-2)

Everything above about quoting normalization described class (ii) only. Round 1 added anchor_unquote() to the destination/remote match and left class (i)‘s keyword scan on raw command text — one door closed, its twin left open. Measured on a native worker lane (HIMMEL_WORKER=1), the bypass covered the whole class-(i) keyword set, not just set-url:

commandpre-fixnow
git remote set-url origin /tmp/decoyrc=2rc=2
git "remote" "set-url" origin /tmp/decoyrc=0rc=2
git remote 'set-url' origin /tmp/decoyrc=0rc=2
git remote se\t-url origin /tmp/decoyrc=0rc=2
git "remote" "add" evil /tmp/drc=0rc=2
git remote "remove" / 'rm' / re\namerc=0rc=2
git sym"bolic-ref" … / git up"date-ref" …rc=0rc=2
git config --global url.…​.instead"Of" …rc=0rc=2
git config remote."origin".url /tmp/decoyrc=0rc=2
echo x > .git/"refs/remotes"/origin/mainrc=0rc=2

Class (i) now greps the same normalized copy class (ii) already used — one anchor_unquote() shared by both, deliberately not a second normalizer that could drift back apart exactly the way these two did.

This corrects the safety argument stated for the round-2 destination gate above. Two paragraphs earlier in this section lean on “git remote add and git remote set-url are both denied by class (i)”: the <a>/<b> lookup reading the live remote set, and — the one that matters — the implicit-refspec residual bullet. Until this round that claim held only for the unquoted spellings. The full pre-fix attack was two commands the hook allowed outright:

git remote 'set-url' origin /tmp/evil.git # rc=0 pre-fix
git fetch origin # rc=0 BY DESIGN, and must stay so

The second is the implicit-refspec form class (ii) must never deny, so the first was the entire defence. Class (i) is therefore not a redundant “cheap coverage” layer on this path — class (ii) is uncloseable against it alone. The dependency is pinned mechanically, not just in prose: the class (i) precondition for the destination gate rows in test-block-glm-external-writes.sh assert both chains deny, and go red under a class-(i)-only revert while every class-(ii) case stays green.

ANSI-C ($'...') treatment for class (i). The residual above is unchanged — escape bodies are still not decoded, because a wrong decode risks a false ALLOW. The fail-closed deny that answers it widened from a fetch/push/pull segment to any command-position git segment, since git remote $'\x61dd' evil /tmp/d spells a class-(i) keyword just as well as $'.' spells a remote (rc=0 ALLOW pre-fix). It is deliberately not scoped to an enumeration of the fenced subcommands: that is the same axis class (ii) abandoned after losing on it four times, and it cannot work here anyway — the subcommand token is itself quotable (git $'\x72emote' add evil, also rc=0 pre-fix). Accepted cost: a worker-lane git commit -m $'a\nb' is refused. Residual, stated not chased: like class (ii)‘s shapes this arm is command-position anchored, so rtk git remote $'…' is not seen; class (i)‘s unanchored keyword scan still covers wrapper invocations for every form anchor_unquote() does normalize.

Accepted overmatch, widened. Two spellings changed direction this round and are pinned as such rather than argued away: git commit -m "the remote 'add' flow is fenced" and git commit -m $'a\nb' now deny. This is not a new category — the unquoted twin (git commit -m "docs: note the remote set-url tripwire") already denied before this round and has been test-pinned since. No substring class can separate git remote 'add' evil from a commit message containing the same bytes without being scoped to a git-command shape, and scoping it that way would drop the .git/refs rows in the table above, which are not git commands at all. HIMMEL_HOOK_INTEGRITY_BYPASS_OK=1 in the launching shell remains the escape hatch.

Round 4 — two one-command anchor forgeries, and the fifth axis (codex-2, codex-3)

Both were reproduced on a native worker lane (HIMMEL_WORKER=1) against the round-3 hook, and both were live one-command bypasses: the denied step is the only defence, because the follow-up that completes the forgery is the by-design-allowed git fetch origin.

commandpre-fixnow
git remote -v set-url origin /tmp/evil.gitrc=0rc=2
git remote --verbose add evil /tmp/drc=0rc=2
git remote --verbose 'set-url' origin /tmp/evil.gitrc=0rc=2
"git" remote $'\x61'dd origin /tmp/evil.gitrc=0rc=2
git remote $'\x61'dd origin /tmp/evil.gitrc=2rc=2
git remote set-url origin /tmp/evil.gitrc=2rc=2

codex-2 — options between remote and its verb. git remote accepts options before its subcommand, so class (i)‘s six remote[[:space:]]+<verb> patterns, which require adjacency, saw no verb at all. Every fenced verb was reachable this way, not just set-url. The fix tokenises: after a remote token, option tokens (anything shaped -*) are skipped and the first non-option token is taken as the verb. No list of options exists anywhere in the code — whatever option git adds next is --prefixed and is skipped without an edit. Token equality also retired the ([^a-z0-9_-]|$) boundary hack that the two-character rm verb needed.

The over-block risk is the whole point of the “first non-option token” wording: git remote -v with no verb after it is a listing, not a metadata write. With no non-option token left, nothing is ever compared against the verb list, so bare git remote, git remote -v and git remote --verbose all stay allowed, and read-only verbs (show, get-url) fall through as non-option tokens that are simply not in the list. Those, plus git status, git fetch origin and git push origin feat/x, are re-measured at rc=0 against the fixed hook and pinned individually.

codex-3 — the ANSI-C arm’s literal git anchor was quote-evadable. That arm must read unnormalized text, because anchor_unquote() strips the ' that the $' marker is made of. Round 3 served that requirement and the command-position anchor from the same raw read, so the anchor was a literal unquoted git: "git" remote $'\x61'dd … walked past the arm (the " breaks the anchor) and past the normalized keyword scan (the undecoded body hides the verb). The fix splits the two requirements — the segment’s command-position token alone is run through the shared anchor_unquote() to decide whether it is git, and the rest of the segment stays raw so the $' marker survives to be seen. Segment splitting on ;&|() reproduces what the old regex’s (^|[;&|(]) start class and [^;&|]* body expressed.

Round 5 — the line-continuation axis (codex-1)

Reproduced on a native worker lane (HIMMEL_WORKER=1) against the round-4 hook. Another live one-command bypass, and the cheapest one yet: the bypass and its denied twin are the same command, one keystroke apart.

commandpre-fixnow
git remote \⏎set-url origin /tmp/evil.gitrc=0rc=2
git remote \␍⏎set-url origin /tmp/evil.gitrc=0rc=2
git remote se\⏎t-url origin /tmp/evil.gitrc=0rc=2
git remote \⏎-v \⏎set-url origin /tmp/evil.gitrc=0rc=2
git remote set-url origin /tmp/evil.gitrc=2rc=2

Bash deletes a backslash-newline pair before it parses anything, so every row above executes the plain git remote set-url … the fence denies. The hook flattened that newline to ; before anything looked for a verb, so the remote-verb tokeniser met a segment break exactly where the verb belonged, found no verb after remote, and the write sailed through. The follow-up git fetch origin is allowed by design, so one allowed command installs the attacker’s history as the trusted anchor.

The fix is an ordering, and the ordering is the whole finding. Line continuations are normalised away in join_line_continuations() upstream of the newline→; flattening that builds anchor_cmd_lc (and therefore anchor_cmd_norm). Downstream is too late: by then \+newline has already become \;, and nothing can distinguish it from a separator the operator typed. It is an axis removal — reproducing bash’s own deletion rule closes every command this spelling can hide — not a seventh spelling appended to a list. A single terminal ␍ is stripped from the line being joined, because a CRLF checkout puts it after the backslash and the join would otherwise never fire (the same byte that defeated the pin-dir scanner’s join in PR #2005).

Parity, because the join can fail-open in the other direction. \\+newline is an escaped literal backslash: the line really ends and bash runs two commands. Joining there would erase a real boundary, so echo x\\⏎git fetch . x:origin/main would stop being a command-position fetch and class (ii) would go blind. Only an odd trailing run continues — the same rule the pin-dir scanner’s trailing_escapes() applies. That direction is a test row of its own, and it goes rc=0 (a manufactured fail-open) when the odd/even test is replaced by “any trailing backslash”.

A continuation is ordinary shell formatting, so what it reassembles into is the only thing that may matter: a multi-line git commit -m and an echo split across a continuation are re-measured at rc=0 and pinned as ALLOW controls, so the six DENY rows cannot be satisfied by refusing every command containing a backslash-newline.

Both fences in the file are fixed, by ONE shared joiner. The GLM-lane shape matchers (gp_shape / gu_shape / gh_shape / net_shape) build their own cmd_lc with the same tr '\n\r' ';;', and had the identical gap — git \⏎push origin main measured rc=0 while the plain spelling measured rc=2, because those patterns want git and the verb in one segment. join_line_continuations() is defined once at top level and called upstream of both flattenings; it is deliberately not copied, because a divergent second copy is this ticket’s recurring defect (round 3 normalised quoting for class (ii) and left class (i)‘s identical need unserved for a whole round). A fix applied to one member of a twin pair is half a fix, and this section says so elsewhere — shipping that sentence over a known live twin would refute it. The GLM lane is currently dropped; that is recorded as a fact, and it is explicitly not the reason the twin would have been safe to leave — a fence left rotten while its lane is dormant is a fence that fails the day the lane comes back.

On that lane the join also removes a false DENY: gh \⏎issue list used to deny, because the split hid the gh issue carve-out from gh_allow while gh_shape still counted the gh. Joined, it reads as the gh issue list bash really runs and is allowed by design. Judging what the shell assembles is the point of the join in both directions, and both directions are pinned. The parity row is mirrored on this lane too, so the odd/even sub-decision is test-held on both sides rather than only on the anchor class.

Read this before adding a seventh enumerated case. This fence’s shape matching has now been defeated on six separate axes, each found by the round that followed the one which fixed its predecessor:

  1. bare local-path remote spellings (., ./…, ../…, /…);
  2. quoted/escaped remotes (codex-6);
  3. bare relative names (..) and then the configured remote itself, which ended the remote-argument enumeration entirely — class (ii) inverted to gate the refspec destination;
  4. ordinary shell quoting across the whole class-(i) keyword set (round 3);
  5. intervening options, and a quoted command token (round 4);
  6. line continuations — \+newline and \␍`+newline — which broke the text into segments before the verb could be read, on both the anchor class and the GLM-lane shape matchers (round 5).

Every round that lost had fixed the previous axis by adding members to a list. The two round-4 fixes deliberately do not: tokenising removes the adjacency axis rather than naming the options that exploit it, and anchoring on the stripped command token removes the quoting axis rather than naming the quote forms. Round 5 does not either: joining continuations before segmentation removes the axis rather than naming the two spellings that exploit it. A future edit that answers a new bypass by appending one more spelling to a pattern is repeating the move that has failed six times — prefer removing the axis, and if that is genuinely impossible, say so here.