Enforcement layers — reference
Extracted from
CLAUDE.mdper 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.yamland.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 thehimmel-retired-org-namerule (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.shbelow). gh --jsonfield-name guard (pre-commit): gh-json-fields (every literalgh <sub> … --json a,b,cin staged markdown/shell/ts/js is checked against the field listgh <sub> --json __bogus__prints — offline, no API call). A typo’d field makesghexit 1, which callers report as a generic “cannot evaluate”, so the guarded path silently never runs: that is how the (retired, HIMMEL-2705)/cr-publiccommand’s HIMMEL-1202 bounded wait shipped polling the nonexistentheadRefSha(HIMMEL-1288) — kept here as the worked example the guard exists to catch. Non-literal field lists (--json "$FIELDS") and subcommands with no--jsonsupport are skipped; a missingghskips the gate loudly rather than blocking the commit.- Headless-gemini gate (pre-commit): no-headless-gemini (blocks new
gemini -p/--prompt/--bgintroductions unless the call has a# headless-gemini-ok: <reason>marker; HIMMEL-157). Headlessclaudecalls have no commit gate (retired in HIMMEL-5424) — see “Claude invocation billing” below. The gate exempts the rootCHANGELOG.md(is_exempt()): it’s fully generated from immutable commit subjects byscripts/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 nestedsome/dir/CHANGELOG.mdis still checked. - Fork/template drift guards (pre-commit): telegram-fork-drift (flags
the vendored
telegram-himmelfork drifting from the claude-plugins-officialtelegramversion it’s pinned to — fail-open if upstream isn’t installed locally, fail-closed on detected drift; bypassSKIP=telegram-fork-drift), template-himmel-plugins (two invariants ondocs/setup/settings-template.json, the single listadopt.sh/install-plugins.shinstalls from: every locally-vendored@himmelmarketplace plugin must be enabled in it, and every marketplace named by anenabledPluginskey must have anextraKnownMarketplacesentry —install-plugins.shregisters 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 vendoredclaude-hudtree must match its recorded pin inmarketplace/plugins/claude-hud/VENDORED.md; bypassHUD_DRIFT_OK=1; HIMMEL-718). - Repo-integrity guards (pre-commit): hookspath-misconfig (
core.hooksPathmust be unset or point inside the repo/git-common-dir— catches the HIMMEL-45 class of bug where hooks are silently skipped; bypassHOOKSPATH_OK=1), artifact-leakage (blocks newly-staged generated artifacts —node_modules/, OS junk files, a wrong-package-manager lockfile sitting next to abun.lock; HIMMEL-371), graph-artifact-branch (scoped tographify-out/— blocks staged graph artifacts unless the branch ischore/graph-publish-*orGRAPH_COMMIT_OK=1; HIMMEL-2167). - Settings portability (pre-commit): settings-portability (a tracked
.claude/settings{,.local}.jsonor.codex/hooks.jsonmay carry no absolute user path —C:/Users/,/Users/,/home/<name>/— and no.exein a hook"command"; a"Bash(curl.exe:*)"permissions entry is deliberately out of scope, which is why the.exerule matches only"command"lines). The shape it exists to catch is written by a tool, not by hand:graphify installrewrites its own hook block in both files with a machine-absolutegraphify.EXEpath 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 inenvironment-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.jsonunderscripts/needs a tracked, in-sync sibling lockfile; for bun projects bun is resolved byPATH→$BUN_INSTALL/bin→$HOME/.bun/bin, because a hook inherits the invoking git’s environment and never sees the~/.bashrclayer 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-lockfileon 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/libnode suites; HIMMEL-1578), trust-node-suites (scripts/trustnode 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.mdDescription 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
bunxfails open; HIMMEL-2163/2802). - Doctor check-ID + unchecked-mktemp gates (pre-commit):
doctor-check-ids (
check-doctor-check-ids.sh, scoped toscripts/himmel-doctor.sh— everycheck_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 unguardedT=$(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 fromgit diff --cached -U0, so pre-existing unguarded mktemps never block an unrelated commit; predicate shared with its suite viascripts/lib/unchecked-mktemp.sh; this was the single most-repeated CR-ledger defect class (10agreedfindings in the 14 days to 2026-09-07, always the same unchecked-capture shape) and is alsoknown-findings.json’stest-setup-unchecked-vacuous-greenshape (a); bypassUNCHECKED_MKTEMP_OK=1; HIMMEL-2709; HIMMEL-4301: on a staged merge only lines added relative to EVERY parent count (the conflict resolution,git diff --ccsemantics), so a merge-forward needs no bypass; and CI’sunchecked-mktemp-rangejob runs--range --merge-refover the PR’s net diff so a commit made with the hook skipped is still caught). - Runtime pin (not a gate —
/himmel-doctorC20): the doctor FAILS (exit 1) when the running node major ≠ the.nvmrcpin, so the drift stops being rediscovered per session (HIMMEL-1986 → HIMMEL-2010). Downgraded to a non-fatal WARN under$CI/$GITHUB_ACTIONSand by the bypassNODE_MAJOR_DRIFT_OK=1. The doctor never edits.nvmrcand 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 derivedJIRA_PROJECT_KEY-Nform, else#[0-9]+— himmel’s own#Nenumeration, 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.envat all;TICKET_ID_REQUIRED=0is the explicit opt-out. Merge/revert commits and the configurable comma-listTICKET_ID_EXEMPT_AUTHORS(defaultdependabot[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 isscripts/ci/ticket-id-internal-authors.txt(one GitHub login per line, case-insensitive, defaultyotamleo) — add a login there to bind a new internal committer.check-commit-range.shandcheck-pr-title.shcompare the PR author (TICKET_ID_TRUSTED_AUTHOR) against it; an unlisted author gets aNOTEand 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.yamlentry must keeppass_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 aSecurity 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 satisfycheck-security-reviewed.sh’s pre-push TOKEN_RE, or a non-emptyPlatforms 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-devmarker so adopters are never affected — seecheck-doc-guard.shbelow). - AGENTS.md-fresh (pre-commit, himmel-dev only): check-agents-md-fresh
(blocks committing a stale
AGENTS.mdwhenCLAUDE.md/AGENTS.md/scripts/agents-md/*is staged;AGENTS.mdis generated fromCLAUDE.mdbyscripts/agents-md/generate.mjsso the two never drift, HIMMEL-471; gated behind.himmel-dev; bypassAGENTS_MD_OK=1). - Template versioning (pre-commit + pre-push, himmel-dev only):
template-version / template-version-push (
check-template-version.sh,--pre-pushfor the push-time twin) — template content change requires ametadata.versionbump; HIMMEL-524. - Pre-push: no-push-to-main (also refuses a NEW branch outside
type/slug|handover/<TICKET>-<slug>|claude/*, HIMMEL-5308; bypassBRANCH_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 namesnpm install -g npm@11rather 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; bypassSKIP_PR_MERGEABLE=1; HIMMEL-136), no-force-push (hard-refuses a force-push tomainwith no bypass; warns but proceeds on a force-push to any other ref, bypassSKIP_FORCE_PUSH_GATE=1to 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, seedocs/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.
-
The
.himmel-devmarker. 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. -
The staging step. These are pre-commit hooks — each triggers off
git diff --cachedand exits 0 when its inputs are not staged (check-agents-md-fresh.shgreps the staged names forCLAUDE.md/AGENTS.md/scripts/agents-md/;check-debrand-coverage.shcomputes arelevantcount the same way;check-lanes-inventory.shis a one-linegrep -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 runsgit checkout --orphan ci-doc-invariants && git add -Afirst: against an unborn HEAD,add -Astages the whole committed tree, so every trigger fires against committed bytes.The orphan is load-bearing, not stylistic.
git read-tree --empty && git add -Alooks equivalent and is not — re-adding against the real HEAD rebuilds an index identical to it, leavinggit diff --cachedempty 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. -
Never
grep -qon the staged list. Underset -o pipefail,-qexits 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. Plaingrep(output to/dev/null) drains the stream and is safe. This bitcheck-agents-md-fresh.shandcheck-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.
| Gate | CI copy |
|---|---|
no-history-comments | lint 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-hashes | lint 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-suites | already mirrored: the ungated lanes-and-trust-suites job runs node --test scripts/trust/tests/*.test.mjs on every PR |
worktree-isolation | local by nature: it refuses a commit made on main from the primary checkout (an editing-station rule); a CI runner commits nothing |
merged-branch-check | local 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-push | local by nature: --pre-push reads the push range handed over by git on stdin, which does not exist on a runner |
telegram-fork-drift | local 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.hooksPathset makes git stop looking at the common dir at ALL; a RELATIVEcore.hooksPathresolves 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 hooksis git’s OWN resolution of “where does a hook file belong,” already correct forcore.hooksPathunset, relative, or absolute, from any cwd, per worktree. Whencore.hooksPathis 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-runinstall-main-ref- transaction.sh/setup.sh/setup-hooks.shaftergit worktree addwhenevercore.hooksPathis 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-edtarget=...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 hostiletarget=$(...)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/mainunresolved (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-transactionfires on EVERY ref update in the repo —fetch’srefs/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 nogit diffever 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 onmainthat touch files matched by the CONSUMING repo’sfiles:regex. Built for two-lane repos like luna (PR lane = structural paths via PR; plugin lane = vault content straight to main). Without afiles:filter it matches everything (degrades to full isolation, with offending paths listed).worktree-isolation(check-worktree-isolation.sh) — himmel-grade: blocks EVERY commit onmain.
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):
| Tier | Timeout | Membership |
|---|---|---|
| fast guard / injector | 15 | nothing 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 call | 60 | the 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, sorun-hook-with-bash.test.mjsasserts 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’sEXPECTED_SCRIPT_ORDERis 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.shon its own*entry, and the PostToolUsetrigger-cr-on-pr-create.sh/trigger-cr-on-push.shpair, 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 reviewon an upstream PR opened from this machine): the PR’sowner/repomust be this clone’s armed origin (git config --local himmel.coderabbit true,coderabbit-available.sh) or be named inCR_TRIGGER_REPOS(comma-separatedowner/repoallowlist). 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 incr_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/systemMessagewith newlines,continue:false/stopReasonfrom 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. -
timeoutbounds 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 at60for that reason (28× the measured p95, and no added worst-case wall clock: the*-matcherauto-arm-on-cap.shentry 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_MSoverride 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_MEMBERSsecurity 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 fullmemberTimeoutMs()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 owntimeout, 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), whereentryDeadlineis the entry’s owntimeout(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):
| Tool | processes / call | graphify hook-guard’s share | after pricing |
|---|---|---|---|
Bash | 31 | 1 (3%) | 30 |
Edit | 16 | 0 | 16 |
Read | 7 | 1 (14%) | 6 |
Grep | 7 | 1 (14%) | 7 |
Glob | 4 | 1 (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):
node scripts/hooks/bench-hook-stack.mjs --runs 7node scripts/hooks/bench-hook-stack.mjs --runs 7 --settings marketplace/plugins/himmel-ops/hooks/hooks.jsonIt 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' -> $# = 0dash -c 'sh probe.sh a b' -> $# = 2bash -c '. probe.sh a b' -> $# = 2so 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.0bash -c 'sh "$RUN_NODE" --version' -> rc=127 sh: command not foundbash -c 'command -p sh "$RUN_NODE" --version' -> rc=0 v24.20.0The 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 defeatblock-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_DIRis the only env prefix accepted, soQUEUE_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 — whenHANDOVER_DIRis given — contained under it. AHANDOVER_DIRthat 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 listsiblings); a lookalike…/scripts/handover/queue-lock.shfalls through. The relative form is judged against an absolute payloadcwdonly — never the hook’s own$PWD; no or a relative payloadcwdfalls through (HIMMEL-3494). - The script word is relative or
/-rooted — never a drive letter,\or'(HIMMEL-3494). On POSIX,C:\x\…orC:/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 payloadcwd. Windows Git Bash therefore always gets a prompt for this shape, like theimpacted-suites.shexception below. - Windows Git Bash drive-letter paths (HIMMEL-3192). The three
non-executed path positions (the
HANDOVER_DIRvalue, the--sweepdir, the doc) go through ONE helper (ql_abs_path) accepting/x,/c/xandC:/x(C:\xwhen 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>/…, soC:/x,c:/xand/c/xare equal and a drive spelling cannot dodge containment (a mixed spelling of two different directories still falls through). Drive-relativeC: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
--checkand the quoted delimiter. Every body line starts withSUITE. The command ends at the first closingIMPACTED_EOF, and nothing may follow it. The no-heredoc form is a single line. - The path is
scripts/cr/impacted-suites.shrelative to an absolute payloadcwd(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 theHIMMEL_REPOcheckout. A backslash, a single quote, a drive letter or any CR refuses it. On POSIX,C:\x\…orC:/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 onrefs/heads/main. The script sources nothing, so that one file is every byte it runs. The check happens at match time only (TOCTOU, as inguard-pr-check-literal.sh). The docs-audit lane’sorigin/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:
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-pushchains to the repo hook (tokensave’schain-repo-hookshim 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.shnever 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-
cdshape —cd /foreign && gh pr createin one Bash call may carry the pre-cdcwd in the payload, leaving that PR un-gated. The marker still exists andcr-pending-audit.shstill reports it. Parsingcdout of the command would be a tokenizer, which is explicitly out of this hook’s scope./pr-checkitself no longer carries this exposure at all — HIMMEL-2226 deleted its<repo-path>argument and thecdthat argument fed, so there is no compoundcdfor anything to parse out of it: it takes step 0’s cwd as-is (git rev-parse --show-toplevelof the process’s actual working directory, not a text-parsed command line), which is unambiguous by construction. Only a bare manualgh pr createremains exposed to this limitation. - A throwaway clone’s evidence dies with the clone — the ledger and
clear-cr-marker.loglive 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,fixedordeferred; - 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 amenditself. 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_FILEset as a prefix, throughenv, or by an earlierexport -
the cwd set by an earlier
cd/pushd/env -C -
a git behind a wrapper (
timeout,nice,stdbuf,ionice,sudo,chrt,taskset, orxargs) — HIMMEL-4365. The cwd fails closed when the wrapper carries aGIT_*word, or when anxargs -Istring lands in a global option, the subcommand, or--outputand its operand (attached or the next word). A barexargs gitreads its subcommand from stdin, so it fails closed too. When stdin words land among the subcommand’s arguments (no-Istring, or the string sits there), an injected option such as--output=<primary>/xcould 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 readsmerge-base,ls-tree,ls-files,rev-parse,cat-file,status. Words past a bare--or--end-of-optionsare 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; writegit 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:
pullis 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.fetchis allowed unless it uses-u,--upload-packor--refmap, names a./path/URL repository, or passes a<src>:<dst>refspec.- A config override (
-c,--config-env, anyGIT_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-envkey matchingremote.*.url|pushurl|receivepack|uploadpack|vcs,url.*.insteadOf|pushInsteadOf,core.sshCommandorprotocol.*(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-urlclause 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/--fileform already captures is now captured from the glued form too. - (fail-open) an unresolvable
--filetarget (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_SYSTEMset as an env prefix or export repoint what file--global/--systemactually 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/-Cimplicitly 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, sobranch -u origin/xwith no third operand is ordinary and matches the already-allowedpush -u. The branch arm now requires an EXPLICITmain/masteroperand, mirroring update-ref/symbolic-ref’s own scoping, before any of these flags trigger the check. - (cheap)
switch’s long form of-Cis--force-create, not modelled before;--forc(an unambiguous abbreviation of--forcethat real git accepts) is now matched via the same_bwimc_is_long_abbrevhelper 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-thandling 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.
| # | Shape | Ticket | Proving row |
|---|---|---|---|
| 1 | Quoted/commented heredoc opener — echo '<<EOF', echo "<<EOF", # <<EOF — used to start heredoc body-blanking and ERASE a following real write | HIMMEL-2591 | adjudicate5 rows 1a, 1b, 1c |
| 2 | mv <primary>/tracked.txt $DEST — a dynamic destination used to suppress the SOURCE check | HIMMEL-2592 | adjudicate5 row 2a |
| 3 | tee /dev/null >/primary/f — an attached redirect operator consumed as a second tee file operand | HIMMEL-2595 | adjudicate6 rows 1a, 1c |
| 4 | echo x >| <primary>/f — the >| clobber operator split at the pipe by the clause splitter | HIMMEL-2592 | adjudicate7 rows 1a, 1b |
| 5 | rm/mv-source of a symlink INSIDE the primary pointing out | HIMMEL-2592 | adjudicate7 rows 2a, 2b; adjudicate8 rows 5a/5b/5c MIRROR |
| 6 | tee /dev/null > /dev/null from the primary — a false POSITIVE | HIMMEL-2595 | adjudicate7 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, anmvsource) 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.jsonnor a.claudedirectory but writes one anyway: a directory copy whose source holds the file. (tar -C … -x,unzip -dandgit checkout <ref> -- .claude— when they DO name.claudein the text, as in the HIMMEL-3499 probes — are now caught:tar/gtar/bsdtar/unzip/checkout/restoreare recognized verbs, gated by tar’s own create/list-mode and bycheckout/restoreneeding agitword 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 --directorynow IS caught, since the verb match no longer depends on which directory flag it uses — onlyfind … -execremains open here); - a
.claudemention 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
settingsorclaudeletters 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
lnwould have created it); - a writer outside the verb list whose command does not name
settings.jsonand has no redirect (sed -i,perl -pi,awk -i inplace,sponge,ed,vim,python3 -c,node -eandgit config -fon 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 onn, 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 pinsLC_ALL=Cand 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-awareawk. (This env has noLANG/LC_ALLby default, which madeawk 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 listsC.utf8, macOS/BSD have noC.UTF-8— and onawkhonouring it, which mawk does not. HIMMEL-2011.)Edit/MultiEdittoMEMORY.mdare decidable:simulate-memory-edit.jsapplies the payload’s replacement(s) (MultiEdit’sedits[]sequentially; first occurrence unlessreplace_all; anold_stringnot found → allow, the Edit tool itself errors on that) against the on-disk file, and the RESULT runs through the same three checks asWrite— one code path, so the rules cannot drift betweenWriteandEdit.NotebookEditis still denied — its payload doesn’t reveal the resulting content, so the guard forces a whole-fileWriteit can inspect.- topic files — the tier-2 landing spot; unrestricted (no body cap,
Editallowed). 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.jsonand inwire-hook-bash.mjs’sEXPECTED_SCRIPT_ORDER;node scripts/hooks/wire-hook-bash.mjs --checkproves 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|clearpasses only when the script word resolves (physically) to<repo>/scripts/lib/bank-lift.shof the checkout the hook runs from, or of one of its.claude/worktrees/*:scripts/lib/bank-lift.shor./scripts/lib/bank-lift.shfrom that root with nocdin the command, or an absolute /~/$HOMEpath. A planted/tmp/x/bank-lift.sh show, a relative path from another cwd, or aworktrees/w1/../../..escape denies. The script itself must be a regular file, not a symlink, whose directory resolves physically to that checkout’sscripts/lib: a symlinkedscripts/liborbank-lift.shpointing 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.jsoncopied with--parentsinto~denies.- Split names in inline code. Interpreter /
sed ecode 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 beforesh). - Archive extraction.
tar/gtar/bsdtarextracting (-x, a bundled or old-stylex,--extract,--get; not-O/--to-stdout),unzip(not-l/-t/-v/-Z/-p/-c) andcpio -i/--extractdeny when the destination (-C,-C<dir>,--directory[=],-d,-d<dir>,-D) is HOME,~/.himmelor 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-Oand still denies (tar-f -C -T -X -b -Iand 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 (plaintar -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, anychdir,find -execdir, a(or backtick (subshell,$( ),<( ),$(( ))), a{ … }group,env/sudowith-C/-i, or a shell running a-cstring. 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 ofcp/mv/install/rsync/ln(destination,--parentspath, link source, missing-ancestor check), denies. An absolute,~or$HOMEdestination is judged exactly as with a known cwd, socd /x; tar -xf a.tar -C /var/outis 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 — mostlyS=/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,-Nand abbreviated long names all deny. Allowed: tar-x -f -C -z -j -J -v -o -m -k -p(dashed, bundled or old-stylexzf),--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,-fVand-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, sotar --extract --file a.tar --directory "$HOME" O .himmel(memberO) andtar --ge -f a.tar -C "$HOME"(an abbreviation) deny. An explicit-x/--extract/--getkeeps 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,ZIPINFOorZIPINFOOPTanywhere, 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 asecho $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 theunzipcommand never matchesUNZIP. 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/--directoryrelative 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-Cagainst the cwd). A relative-Cafter a computed one (-C "$d" -C ..) fails closed. unzip-dis 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-ppass mode) denies whatever its destination: GNU cpio keeps absolute and../names by default and--no-absolute-filenamesis not modelled (fail closed; extract with tar or unzip instead). Listing (-t, cpio--list), stdout extraction and creation stay allowed. Residuals:pax,7z xandbsdcpioare 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-Pis what disables. - Computed destinations. An extraction destination that cannot be
resolved —
-C "$D",--directory="$D",unzip -d "$D",$(…)or backticks — denies;$HOMEspellings still resolve. A variable assigned a literal earlier in the same command is not tracked, soS=/tmp/s; tar -xf a -C $S/xdenies 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/hois not one; compared lexically and physically) reaches the lift with a member such ashome/<user>/.himmel/state/bank-lift.jsonand denies:-C,--directory,unzip -d, and the extraction cwd (the hook’s cwd). A destination inside HOME other than HOME,~/.himmelor 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 (aponytail: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.himmelunderdotglob. 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.shhonours a lift only when it is account-bound, owner- and mode-checked, bounded by the usage cache’s seven_dayresets_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 ofscripts/lib/bank-lift.shinside 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 namingbank-lift.jsonorbank-lift.sh(not a longer name such astest-bank-lift.sh) passes only when it isbash scripts/lib/bank-lift.sh show|clearor the directscripts/lib/bank-lift.sh show|clear(the repo’s own copy, as above), or a reader:cat,lesswithout-o/-O/--log-file/+cmd,head,stat,ls,file,wc,test/[,jqwithout-i/--in-place,tailwithout-f/-F/--follow,grep/rgwithout--pre. The command word is resolved past assignments and theenv/sudo/doas/nice/xargs/timeout/stdbuf/ionice/chrt/taskset/setsid/nohupwrappers, but a wrapper carrying any option leaves it unknown, so the clause denies. Everything else naming the lift denies, includingrm/mv/cpof the lift,source/.ofbank-lift.sh,bash -cbodies,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 namesbank-lift.sh, is refused too (usecat/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 inrun-hook-with-bash.js. Fails CLOSED on missingjq, 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_REPOanchor’s own working tree. Forpr-check-context.shandpr-check-env.shthat is all ofscripts/cr/, plusscripts/guardrails/lib.shandscripts/lib/load-dotenv.sh. For every other target it is only the entry script andscripts/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 readsHIMMEL_REPOfrom 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
teeword, 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/1and>>/dev/nullall 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 (
:roor:rw):/,/etc,/root, the docker socket,$HOMEitself,$HOMEdotdirs (.ssh .aws .gnupg .kube .docker .config), and the Windows home treeC:\Users\<user>./homeis NOT a blanket prefix —$HOME/Documents/projis 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-addof a root-equivalent cap (SYS_ADMIN SYS_PTRACE DAC_OVERRIDE DAC_READ_SEARCH ALL),--deviceof 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).
- cwd mismatch, prefixes, redirects: all covered by the rule above.
- Bounded gate lookups: the hook shadows
ghwith 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. - Lib-load: a gate lib that is missing, unreadable, unparseable, or lacks its
function denies (was
|| exit 0). - 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>/statuseson 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:
git config --local himmel.coderabbit trueUntil 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.yamlis 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:
| Shape | Precedent | Why 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=0instead — the same thing an operator does by hand withCR_APP=0. That also silencedcr_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=0remains 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_botand a dependabot login. A human account cannot setis_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 onlygraph.jsonactually 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.shsays 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.
- Zero unresolved review THREADS. A green check-run is NOT clean. The
thread query is the truth.
gh pr checksreadsstatusCheckRollup, which merges check-runs and statuses and says nothing about whether anyone resolved what CodeRabbit wrote: PR #2209 carried six unresolved CodeRabbit threads under apassrollup on 2026-09-06.check-ci.shexits 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. - 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(asuccesswhose description readsReview 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 withgit config --local himmel.coderabbit true; unarmed, it is silent. Afailure/errorstatus is a NOTE too — what the App found reaches the gate as threads and body findings, not as a status colour. - One trigger per head, from CI, never from a session.
auto_reviewis off in.coderabbit.yaml, so the review runs on an explicit@coderabbitai reviewcomment thatscripts/hooks/trigger-cr-on-pr-create.shandtrigger-cr-on-push.shpost structurally — a head move DISCARDS the in-flight review, so the post follows every push.cr_trigger_repo_armed(HIMMEL-2034, inscripts/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, andcheck-ci.shrequests none of its own (the--escalatefull-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.ymlgates on eventschedule/workflow_dispatch; the nightly — recognised by a non-skipped guard-corpus-full (the script also still matches awindows-latestjob, which no longer runs) — is skipped entirely, because its tier=allshell-unithas 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 toshell-extended-nightly-issue.sh). The issue records the writing run’s id and an older run never closes or refreshes it.cut-tag.shrefuses withgh workflow run ci.yml --ref mainnamed 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
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, andwire-hook-bash.mjs, and read that as “not wired anywhere” — true only of those three files. Delivery via the himmel-ops pluginhooks.jsonabove predates #1992 and was always correct. The real gap was per-machine enablement:himmel-ops@himmel: falsein a machine’s live~/.claude/settings.json(a manual toggle, an older template) leaves this hook installed but inert, silently — existence in the plugin’shooks.jsonis not the same as being enabled. Closed structurally, not by re-wiring:scripts/install/manifest.json’shimmel-ops-pluginitem probes the LIVE enabled value (not mere key presence);docs/setup/settings-template.json(the HIMMEL-1032 reconciler’s own whitelist) already flags ittrue; andinstall-plugins.sh’s force-enable step — the mechanismhimmelctl ensure/installruns for that item — flips a driftedfalseback totrue, additive-only. Re-executing #1992’s settings-chain steps was explicitly rejected: it would double-invoke this hook, drop itsmcp__.*arm (settings-chain entries areBash/PowerShell-only), and a--chainmember 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> pushedwhose sha equalsgit rev-parse HEADand@{u}in the call’scwd, orCHECKPOINT <full sha> cleanwhose sha is HEAD while the tree is clean and HEAD equals@{u}or is inorigin/main. The session then compacts at its ceiling and carries on.handoff: unlocked by a*-RESUME.mdbeside 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:
BACKENDS_REGISTRYenv var (test seam — file path).scripts/backends.jsonat the repo root.- 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:
| class | meaning |
|---|---|
read / write / destructive | the tool’s class in its server’s tools map |
unknown-server | the server is missing from servers |
unlisted-tool | the server is listed, the tool is not |
malformed | unparseable 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%):
- writes a mechanical status snapshot into the handover root
(
scripts/lib/handover-path.shresolution; 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), - 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), - 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
nosessionkey (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 pastAUTO_ARM_STALE_ESCALATE_AGE(default 1800s), observed acrossAUTO_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’sfive_hourresets_atwhen 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 10around every python call (the wedged stub IGNORES SIGTERM — plaintimeoutwaited 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 boundedread -t 5for 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):
- 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). - User-level (all repos) —
scripts/machine-setup/ubuntu.shregisters the hook in~/.claude/settings.jsonwith 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.
| Subcommand | What 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. |
work | takes the singleton lock (mkdir), drains entries oldest-first ONE at a time, exits. A second worker refuses to start — that IS the concurrency bound. |
status | report-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, cleanuprunning/<gen>.json a job CLAIMED by rename — running, or left by a worker that diedattempts/<gen>.n the crash counter, kept outside the entry so entries stay write-oncetmp/ the pre-rename staging file (atomic publish)worker.lock/pid the singleton lock; stale when the pid is gone or the heartbeat ages outqueue.jsonl append-only census log: attempt | enqueued | enqueue-failed | dedup | oversize | ran | timeout | retained | expired | abandoned | dropped | corrupt | detached-fallbackattempt (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:
node scripts/hooks/stop-queue.mjs status # what is queued and what has runnode scripts/hooks/stop-queue.mjs work # drain now, in the foregroundDedup 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:
- Allowlist base — named system/interpreter variables and nothing else: the
Windows/Git-Bash set (
SystemRoot,ComSpec,PATHEXT,MSYSTEM,APPDATA/LOCALAPPDATA, theProgramFilesroots …), the POSIX set (SHELL,TERM,LANG, theLC_*locale set,TZ, theXDG_*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 narrowedenv -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 admitXDG_MQTT_PASS). The list isENV_SYSTEMinscripts/hooks/stop-queue.mjs. - 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’sHIMMEL_/CLAUDE_/… prefixes: a prefix would admitHIMMEL_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. - 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.shwake’sdenials=change raises a worker toSHIP-STEPorPAUSE-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: firstallowFromofaccess.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 byCHIEF_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 indenial-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 frontmatterresume_cwdto 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 noresume_cwd.- Refusal.
headed-arm-worker.shexits 14, namingack-denial.sh, when the session name or the doc’s worktree slug has aSHIP-STEP/PAUSE-RISKpage newer than its latest ack.--ignore-denialsoverrides. No record, an acked one, aREPEAT, 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 runningchief-wait.shpaged. 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 correspondingdocs/commands-catalog.mdupdate. 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-pushflag): reads the push rangebase...HEADand 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`):
blockrows — consumed bycheck-doc-guard.sh(unchanged behaviour):block / add / ^\.claude/commands/ / docs/commands-catalog.mdblock / add / ^marketplace/plugins/[^/]+/(commands|skills)/ / docs/commands-catalog.mdblock / add / ^marketplace/plugins/[^/]+/\.claude-plugin/plugin\.json / llms.txt— new in HIMMEL-587: adding a plugin manifest requires allms.txtupdate.
adviserows — consumed exclusively byscripts/lib/doc-freshness.sh:advise / modify / ^scripts/hooks/ / docs/internals/enforcement.mdadvise / 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:
- Changelog scoping: only files touched by a
feat/fixcommit in the range count as in-scope (chore / docs / refactor commits are excluded). - 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:
advisesurface —/pr-checkprints findings at review time (pre-push).sessionsurface —scripts/hooks/inject-doc-freshness.sh(SessionStart hook) injects a<system-reminder>block overorigin/main...HEADat the start of each feature-branch session.morningsurface —generate-morning-briefing.shincludes 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;morningstays off. 0/false/off/no/ unset — all surfaces off.- No
gatesurface — the hard blocking gate ischeck-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.
bash scripts/lib/red-control-lint.sh # defaults to scripts/bash scripts/lib/red-control-lint.sh scripts/cr # or any file / directoryOne <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 ismain.is_main_ref REF— ref isrefs/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/mainhas 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:
| Verb | State | Action | Override |
|---|---|---|---|
pr-create | HEAD == main | refuse (rc=2) | none |
pr-create | dirty worktree | warn (rc=1) | --allow-dirty flag |
pr-create | branch merged into main | refuse (rc=2) | --allow-merged-base flag |
pr-merge --admin | any | refuse (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 byscripts/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 globalallowFrom). This authorizes/armfrom the operator in a DM or an allowlisted group (groups carry distinct per-group context). A non-operator member of a shared group has afromnot inallowFrom, so their/armfalls through to ordinary (powerless) chat. The chat is also already allowlisted upstream bymakeAllowat ingest, so this is operator-identity on top of chat-allowlisting. The reply routes back to the originating chat (group → its owngroup_<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 typedm.textcommand 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 smartarm 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:
- Repo pin — the PR’s own URL must resolve to
CR_PUBLIC_REPO(defaultyotamleo/Himmel) — never cwd-derived (the script runs from the bridge’s checkout cwd). - PR OPEN + base = default branch, re-verified FRESH immediately before merging (the HIMMEL-1080 retarget-race lesson, ported verbatim).
- Head identity — the PR’s
headRefOidmust prefix-match the operator-supplied SHA, checked BOTH up front and at the fresh pre-merge re-verify. Mismatch = refuse (“head moved”), never merge. check-ci.shexit 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.shwith the PR’s full URL as the selector, never a bare number, so gh resolves the right repo.
- no changes-requested + the HIMMEL-1126 body/outside-diff gate). Since this
script runs from the private-repo cwd, it invokes
- Audit preflight + durable MERGING record before the merge; refuse if the
sink is unwritable (same fail-closed contract as
merge-on-green.sh). gh pr merge <pr> --squash --admin --repo <repo> --match-head-commit <fresh-headRefOid>, then poll to confirmedMERGEDbefore reporting success — a zeroghrc means “accepted”, never “merged” outright.CLAUDECODEself-refusal — exits immediately (before any gh call) if theCLAUDECODEenv 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 frommerge-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, perHIMMEL_PROTECTION_SOURCEinmerge-on-green.sh:classic,ruleset,either(default) orboth(both must pass). LikeHIMMEL_PUBLIC_ORIGIN_NWOit is a fixed literal in the script, never an env seam (ruleset/eitherare 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_adminson 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>) isenforcement: activewith an EMPTYbypass_actors— any bypass actor (adminRepositoryRole,pull_requestoralwaysmode) refuses, since it can merge past the required checks; an API error, null/non-array field or zero checks refuses too.eitherreads 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 writesPROTECTION-OK[-PREMERGE] source=classic|ruleset|classic+ruleset detail=…to the audit log. With the live yotamleo-org/Himmel ruleset (protect-main, admin bypasspull_request) the ruleset path refuses and classic protection carries the merge; relaxing that bypass is an operator call.merge-on-green.shexits 18 (GitHub-blocked, HIMMEL-3381) on a fresh pre-mergeBLOCKED+REVIEW_REQUIREDpolicy read after green checks, an explicit GitHub base-branch policy / required-check / ruleset rejection at merge time, orcheck-ci.shexit 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 whenhimmel.mergeModeis set); if no automation identity can satisfy the required review, the merge is a human admin action; on this repo the operator relaxedprotect-mainon 2026-09-09 (HIMMEL-2887). The Jira auto-transition is opt-in per merge (HIMMEL-3143): without--jira-transitionmerge-on-green.shmerges, then only recordsjira-transition=skip=partial key=<KEY> status-unchangedon theMERGEDaudit line and posts the PR body’sRemaining:line as a Jira comment — the ticket status is never changed (HIMMEL-6094) — so the caller decides (a worker reads its brief’scompletes-ticket: yes|noline,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 askip=…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) whenHIMMEL_CONSOLE_LEGis set — exported byconsole-kit/headed-arm-leg.shinto every console-spawned leg — andgo_gate(scripts/lib/go-gate.sh, shared withblock-unresolved-cr-merge.sh’s Console-GO gate and withgo.sh; see there) finds<handover_root>/.locks/go/<pr>.<certified head sha>missing or not carryinghead=<that sha>(a broken/truncatedgo-gate.shrefuses 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-committo that same certified sha; a console leg’s merge must also pass--go <that GO file>(same directory and basename as the filego_gateverified, so another PR’s or head’s file or a missing path exits 17 withphase=console-go-flag) and prints aGO evidence:line, so the merge command shows the review it relies on (HIMMEL-7165); only the console writes it, viaconsole-kit/go.sh(which refuses under the marker), andscripts/chokepoints.jsonregisters the marker so a per-callHIMMEL_CONSOLE_LEG=prefix is denied (HIMMEL-2919). A--judgeleg (HIMMEL-3133) is the sameHIMMEL_CONSOLE_LEG=1process, so it is blocked from writing its ownGOby 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 separateHIMMEL_CONSOLE_JUDGE=1marker 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 -pand 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 -pand the Agent SDK when you run them with an API key from the linked org;claude -psigned 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 -pdrew 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 thefeedback_jira_running_numbersauto-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-memoryfeedback_jira_plugin_strict(after second drift) → PreToolUse hookblock-backend-tier.sh(originallyblock-mcp-when-plugin-exists.sh, generalised to registry-driven routing in HIMMEL-400) + pre-commitmcp-plugin-refsgate (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.
| Denied | Matched 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 anywhere | closes 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 segment | same 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 rebind | a 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 root | the 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:
- the on-disk bytes equal the blob at the tip of the record’s stored
anchor_ref(refs/remotes/origin/<default branch>, resolved once viascripts/guardrails/lib.sh’sdefault_branch()and recorded byrecord-hook-integrity.shalongside the pins, schema v2); - the record’s previously-stored
anchorcommit is an ancestor of that tip (git merge-base --is-ancestor— monotonic: a rewind oforigin/<default>denies, it never re-pins backward); - 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:
| Trigger | Scope | Verdict | Bypass |
|---|---|---|---|
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 unaffected | Deny, rc=2 | HIMMEL_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/configedit 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 plaingit fetch origin, is invisible — the fetch command itself then names nothing suspicious. (git config remote.*.urlIS 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.urland-c url.*.insteadOfare all denied there (verified:git remote add evil /tmp/decoyandgit remote set-url origin /tmp/decoyboth 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/configedit 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:
| command | pre-fix | now |
|---|---|---|
git remote set-url origin /tmp/decoy | rc=2 | rc=2 |
git "remote" "set-url" origin /tmp/decoy | rc=0 | rc=2 |
git remote 'set-url' origin /tmp/decoy | rc=0 | rc=2 |
git remote se\t-url origin /tmp/decoy | rc=0 | rc=2 |
git "remote" "add" evil /tmp/d | rc=0 | rc=2 |
git remote "remove" / 'rm' / re\name | rc=0 | rc=2 |
git sym"bolic-ref" … / git up"date-ref" … | rc=0 | rc=2 |
git config --global url.….instead"Of" … | rc=0 | rc=2 |
git config remote."origin".url /tmp/decoy | rc=0 | rc=2 |
echo x > .git/"refs/remotes"/origin/main | rc=0 | rc=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-fixgit fetch origin # rc=0 BY DESIGN, and must stay soThe 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.
| command | pre-fix | now |
|---|---|---|
git remote -v set-url origin /tmp/evil.git | rc=0 | rc=2 |
git remote --verbose add evil /tmp/d | rc=0 | rc=2 |
git remote --verbose 'set-url' origin /tmp/evil.git | rc=0 | rc=2 |
"git" remote $'\x61'dd origin /tmp/evil.git | rc=0 | rc=2 |
git remote $'\x61'dd origin /tmp/evil.git | rc=2 | rc=2 |
git remote set-url origin /tmp/evil.git | rc=2 | rc=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.
| command | pre-fix | now |
|---|---|---|
git remote \⏎set-url origin /tmp/evil.git | rc=0 | rc=2 |
git remote \␍⏎set-url origin /tmp/evil.git | rc=0 | rc=2 |
git remote se\⏎t-url origin /tmp/evil.git | rc=0 | rc=2 |
git remote \⏎-v \⏎set-url origin /tmp/evil.git | rc=0 | rc=2 |
git remote set-url origin /tmp/evil.git | rc=2 | rc=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:
- bare local-path remote spellings (
.,./…,../…,/…); - quoted/escaped remotes (codex-6);
- bare relative names (
..) and then the configured remote itself, which ended the remote-argument enumeration entirely — class (ii) inverted to gate the refspec destination; - ordinary shell quoting across the whole class-(i) keyword set (round 3);
- intervening options, and a quoted command token (round 4);
- 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.