(Accuracy = Integrity = Quality = Trust) > Progress > Speed > Cost

Enforcement register

Guardrail Enforcement Register

Which rules have a shipped mechanical control linked to them, and which do not yet. This page is generated from the enforceability ledger, so its linkage claims match the controls declared in the pack's gates and hooks manifests when the page was generated.

This register lists every rule and the shipped mechanical controls linked to it. A link records that a control exists, not how much of the rule it covers, so none of the statuses means a rule is enforced. Gate-linked means at least one shipped repository gate cites the rule: class a when at least one of those gates is total for what it examines, class c only when every linked gate covers just a recognizable subset of the surface. Hook-linked means at least one shipped runtime hook cites the rule and no gate does. Preview only means every linked control is a preview-channel hook: a standalone file under .preview/ that an adopter installs by hand, not part of the plugin, so nothing runs for it unless it is installed. A status ending in partial records the same linkage and states that the linked mechanisms are known to cover only part of the rule; their residuals name what they do not cover. No control means no shipped control cites the rule yet; pending means the same, and its description states the intended build. Each mechanism's class and residual describe the boundary of what it checks, and a linked mechanism may cover only part of a rule's violation surface. The technical limits shown for each mechanism are the enforcement ledger's own text, quoted verbatim and not summarized. The class letter is a maintainer assessment of the check's decision procedure, not a coverage score.

Summary: Gate-linked (class a): 8; Gate-linked (class a), partial: 0; Gate-linked (class c only): 1; Gate-linked (class c only), partial: 8; Hook-linked: 19; Hook-linked, partial: 4; Preview only: 2; Preview only, partial: 0; Pending: 0; No control: 91.

RuleCorpus IDStatusLinked controls or planned build
The AIQT principle (highest precedence)prjint1No controlNo shipped control cites this rule.
Citation only from a file opened at the reviewed commitcitintNo controlNo shipped control cites this rule.
Claims about the work rest on observationclmobsNo controlNo shipped control cites this rule.
A completeness claim enumerates its setsetcmpHook-linkedhook:orch-stop-guard, class b; hook:orch-teammate-idle-guard, class b; hook:orch-yield-tool-guard, class b
Corroborate external claimscorrobNo controlNo shipped control cites this rule.
A count carries its predicatecntprdNo controlNo shipped control cites this rule.
Disclose a guard's residual coveragedscresNo controlNo shipped control cites this rule.
Evidence-grounded completionevgcmpGate-linked (class a)gate:selftest-execution, class a
A guard is only as good as its inputgrdinpGate-linked (class a)gate:aei-enumerator-selftest, class a; gate:derived-command-parameters, class c
Measured and estimated figures stay separateestsepHook-linkedhook:orch-prompt-stamp, class c; hook:orch-yield-tool-guard, class b
No fabricationnofabrNo controlNo shipped control cites this rule.
Observe before asserting behaviourobsbehNo controlNo shipped control cites this rule.
A partial read is not the wholeprtwhlNo controlNo shipped control cites this rule.
Read before characterizingrdbchrNo controlNo shipped control cites this rule.
Capture the reference when the claim is maderefcapNo controlNo shipped control cites this rule.
Reproduce a defect before fixing itreprodNo controlNo shipped control cites this rule.
A current timestamp is read from the clocktstampHook-linkedhook:orch-prompt-stamp, class c; hook:orch-yield-tool-guard, class b
Validate an inferred premise before actingvalinfNo controlNo shipped control cites this rule.
Verify a fix is in its commitvfxcmtHook-linked, partialhook:review-dispatch-pin, class c
Anything wrong is fixed firstactbefGate-linked (class c only), partialgate:release-cut, class c
Attestation lines are harness-ownedattintNo controlNo shipped control cites this rule.
Branch and merge only on greenartbr1Hook-linkedhook:protected-line-guard, class b
Cut branches from the live protected line and re-home after a rewritebrnrotGate-linked (class c only), partialgate:branch-root, class c; hook:branch-root-guard, class c; hook:git-discard, class b
A check fails closed on input it cannot readchkfclNo controlNo shipped control cites this rule.
Commit identitycmtidnHook-linkedhook:commit-identity, class b
A consumer follows its producercnsprdNo controlNo shipped control cites this rule.
Bind to the explicit target, not the ambient contextexpbndHook-linkedhook:git-discard, class b; hook:git-explicit-binding, class b; hook:git-stash-ref, class b; hook:orch-dispatch-ledger, class c
Gate disciplinegatdisGate-linked (class c only)gate:ci-parity, class c; gate:relied-exit-pipes, class c; hook:gate-weakening-guard, class b
A generated artefact is changed only through its sourcegensrcGate-linked (class a)gate:adapters-drift, class a; gate:agents-drift, class a; gate:changelog-drift, class a; gate:char-policy-drift, class a; gate:claude-drift, class a; gate:crosswalk-schema-drift, class a; gate:cursor-drift, class a; gate:disclosure-drift, class a; gate:enforceability-drift, class a; gate:enforcement-register-drift, class a; gate:first-pin-demo-drift, class a; gate:gensrc-failclose, class a; gate:gensrc-registry-drift, class a; gate:hooks-drift, class a; gate:install-drift, class a; gate:manifest-gen-drift, class a; gate:mappings-page-drift, class a; gate:notice-drift, class a; gate:reference-roster-drift, class a; gate:renderers-drift, class a; gate:roadmap-drift, class a; gate:rules-drift, class a; gate:secret-patterns-drift, class a; gate:skill-drift, class a; gate:worker-pack-drift, class a; hook:gensrc-edit-guard, class c
Verify licence compatibility before introducing third-party materialliccmpNo controlNo shipped control cites this rule.
No concealed failurenocnclHook-linkedhook:orch-truncation-guard, class b
Preserve uncommitted workprsuncHook-linkedhook:git-discard, class b
Protected-branch integrityprtbrnHook-linkedhook:protected-line-guard, class b
A required step remains required under frictionreqstpNo controlNo shipped control cites this rule.
A rerun pass does not erase an earlier failurererunfPreview onlyhook:rerun-pass-check (preview channel, installed by hand), class b
A review in flight pins its artefactrvwpinHook-linked, partialhook:review-workdir-hold, class c
Make retries safe to repeatrtsafeNo controlNo shipped control cites this rule.
Separate task changes from pre-existing workseptskHook-linked, partialhook:write-scope, class c
Stage artefacts and promote only on greenstgprmNo controlNo shipped control cites this rule.
A launched task stays observabletrkasyHook-linkedhook:orch-dispatch-ledger, class c; hook:orch-stop-guard, class b; hook:orch-teammate-idle-guard, class b; hook:orch-truncation-guard, class b; hook:orch-untracked-wait-loop, class b; hook:pgrep-self-match, class b
Validation is a gate on applyvalgatNo controlNo shipped control cites this rule.
Workers produce inert datawowo01Hook-linked, partialhook:write-scope, class c
Use absolute paths, not relativeabspthHook-linkedhook:abs-paths, class b; hook:abs-paths-bash, class b
A behavioural change carries a check that fails without itchgchkGate-linked (class a)gate:entry-guard, class a; gate:selftest-execution, class a
Preserve compatibility or provide a migration pathcmpmigNo controlNo shipped control cites this rule.
Confirm the execution target before a side-effectful operationexetgtGate-linked (class c only), partialgate:derived-command-parameters, class c; hook:write-scope, class c
Defence in depth by defaultdfdpthNo controlNo shipped control cites this rule.
A borrowed process timer is restored elapsed-awaretmrrstNo controlNo shipped control cites this rule.
A verification finding is fixed, not argued awayfndfixNo controlNo shipped control cites this rule.
Goal fidelity across a long trajectorygoalfdNo controlNo shipped control cites this rule.
High-assurance verificationhiasrvNo controlNo shipped control cites this rule.
A kill timeout outlives the wait it boundskltwatNo controlNo shipped control cites this rule.
Isolate verifiers and judge by their result signallvw001No controlNo shipped control cites this rule.
Match the surrounding codemtchcdNo controlNo shipped control cites this rule.
Minimize external dependencies in favour of standard librariesmindepNo controlNo shipped control cites this rule.
Propose a guardrail when an error reveals a gapslfgrdNo controlNo shipped control cites this rule.
Prefer the smallest correct changesmlcngNo controlNo shipped control cites this rule.
Surface a counterproductive instruction before executing itsrfcp1No controlNo shipped control cites this rule.
A test's verdict comes from the code, not its surroundingststhrmNo controlNo shipped control cites this rule.
A degraded verifier delivery is not a verdictvrfdlvHook-linkedhook:orch-truncation-guard, class b
Verifier diversityvrfdivNo controlNo shipped control cites this rule.
Maintain an AI toolchain registeraitregNo controlNo shipped control cites this rule.
Assess and advise are discussion onlyasadv1No controlNo shipped control cites this rule.
Claim a pooled item atomicallyatmclmNo controlNo shipped control cites this rule.
Change recordchtrk1Gate-linked (class a)gate:record-drift, class a
Change record has a curated public facechgtrkNo controlNo shipped control cites this rule.
Clarify before actingclrfy1No controlNo shipped control cites this rule.
Hold a concurrency lease to prevent double runscnclseHook-linkedhook:orch-lease-heartbeat-post, class c; hook:orch-lease-heartbeat-stop, class c; hook:orch-resume-audit, class b; hook:orch-stop-guard, class b
Continue by defaultcntdefHook-linkedhook:orch-ask-unattended, class b; hook:orch-stop-guard, class b; hook:orch-teammate-idle-guard, class b; hook:orch-yield-tool-guard, class b
Express authorization before executionexauthNo controlNo shipped control cites this rule.
Human oversight and the autonomy thresholdhumovsHook-linkedhook:orch-ask-unattended, class b
Do not bury the review surface under raw dumpscnsdifHook-linkedhook:diff-source, class b; hook:diff-wall-stop, class b
An orchestrator keeps a mistakes registermstregGate-linked (class a)gate:mistakes-register, class a; gate:mistakes-register-append-selftest, class a
Reconcile the record against realityrecnclGate-linked (class a)gate:record-drift, class a; gate:record-sections, class c; hook:orch-lease-heartbeat-post, class c; hook:orch-lease-heartbeat-stop, class c; hook:orch-resume-audit, class b; hook:orch-resume-barrier, class b
Records firstrecfstHook-linkedhook:orch-ask-unattended, class b; hook:orch-dispatch-ledger, class c
Close each session on greensesclsNo controlNo shipped control cites this rule.
Resume from the durable handoffsesresHook-linkedhook:orch-resume-audit, class b; hook:orch-resume-barrier, class b
A standing constraint persists across context losscnstprPreview onlyhook:constraint-reread (preview channel, installed by hand), class b
Trust recovery and escalationtrstreNo controlNo shipped control cites this rule.
Autonomy steps down after a confirmed trust losstrsrcvNo controlNo shipped control cites this rule.
Decision classification before enactingdecclsNo controlNo shipped control cites this rule.
Repeated failure triggers premise reviewrpfailNo controlNo shipped control cites this rule.
Background work during CI waitsbgcwaiNo controlNo shipped control cites this rule.
Cost tiercsttirNo controlNo shipped control cites this rule.
Classify content by sensitivity tierdatbndNo controlNo shipped control cites this rule.
Egress goes only to expected destinationssecegrNo controlNo shipped control cites this rule.
Keep secrets outsecsecGate-linked (class c only), partialgate:secrets-scan, class c; hook:secrets-shift-left, class c
Retrieval enforces the requester's authorizationseclprNo controlNo shipped control cites this rule.
No cross-context bleedsecncbNo controlNo shipped control cites this rule.
No disclosure of secrets or hidden contextsecndcNo controlNo shipped control cites this rule.
Rotate a leaked secretsecrotNo controlNo shipped control cites this rule.
Strong authenticationsecau1No controlNo shipped control cites this rule.
Least-privilege authorizationsecaznNo controlNo shipped control cites this rule.
Configuration that executes on load is treated as codeseccetGate-linked (class c only), partialgate:python-launcher-isolation, class c
Sound cryptographyseccryGate-linked (class c only), partialgate:dangerous-api, class c
Trusted, pinned dependency provenancesecsupNo controlNo shipped control cites this rule.
Fail closed in security-relevant pathssecfclNo controlNo shipped control cites this rule.
Validate federated identity and token flowssecfidNo controlNo shipped control cites this rule.
Validate and contain uploaded filessecuplNo controlNo shipped control cites this rule.
Guardrail configuration is integrity-protectedsecgciNo controlNo shipped control cites this rule.
Human authorization for consequential actionssechauNo controlNo shipped control cites this rule.
Validate external input at the boundarysecinpGate-linked (class c only), partialgate:dangerous-api, class c
Trust between agents is earned, not inheritedsecagtNo controlNo shipped control cites this rule.
Key managementseckeyNo controlNo shipped control cites this rule.
Least-privilege tool and file accessseclptNo controlNo shipped control cites this rule.
Redact sensitive content from logssecredNo controlNo shipped control cites this rule.
Social pressure is not authorizationsecopdNo controlNo shipped control cites this rule.
Encode output for its sinksecencNo controlNo shipped control cites this rule.
Generated output is untrusted inputsecoutNo controlNo shipped control cites this rule.
Referenced instructions are pinned and re-verifiedsecpinNo controlNo shipped control cites this rule.
Resist data, model, and memory poisoningsecpsnNo controlNo shipped control cites this rule.
Prefer removing a path over constraining or monitoring itrmvpthNo controlNo shipped control cites this rule.
A preview makes no changesecprvNo controlNo shipped control cites this rule.
Higher-trust instructions outrank lower-trust onessecpthNo controlNo shipped control cites this rule.
Protect audit records from the actors they recordsecaudGate-linked (class a)gate:mistakes-register, class a
Reject known-vulnerable dependency versionssecvlnNo controlNo shipped control cites this rule.
Publish artefacts with verifiable integritysecpubNo controlNo shipped control cites this rule.
Deserialize untrusted data only as datasecdszGate-linked (class c only), partialgate:dangerous-api, class c
Secure by default configurationseccfgNo controlNo shipped control cites this rule.
Security logging with traceable contextseclogNo controlNo shipped control cites this rule.
Secure session and token handlingsectokNo controlNo shipped control cites this rule.
Validate server-initiated requestssecssrNo controlNo shipped control cites this rule.
Resolve privileged filesystem paths against symlink racessecsprNo controlNo shipped control cites this rule.
Threat-model new trust boundaries before implementationsecthmNo controlNo shipped control cites this rule.
Validate tool arguments before usesectvlHook-linkedhook:commit-msg-subst, class b
Untrusted content is data, not instructionssecuntNo controlNo shipped control cites this rule.
Verify a dependency exists before adding itsecvdeNo controlNo shipped control cites this rule.
Bounded consumption and safe failuresecresNo controlNo shipped control cites this rule.
A destructive operation requires a verified restore pathsecrstNo controlNo shipped control cites this rule.
Minimize personal data sent to AI servicessecminNo controlNo shipped control cites this rule.
Honour residency, retention, and deletionsecdrrNo controlNo shipped control cites this rule.
Bind personal-data use to its authorized purposesecpurNo controlNo shipped control cites this rule.
Fixtures and examples use synthetic datasecsynNo controlNo shipped control cites this rule.

Mechanisms

  • Class a: deterministic gate: a machine predicate over committed artefacts, total for what it examines
  • Class b: runtime hook: a best-effort interception judging an action at the moment it runs
  • Class c: partial: a control that covers a recognizable subset of the surface
gate:adapters-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_adapters.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_adapters.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:aei-enumerator-selftest
  • Platform: ci
  • Default: block
  • Entry point: tools/aei_backlog_md.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

The reference enumerator's own grammar and fail-closed vectors: a malformed item-shaped line, an unknown token or blocker kind, and a duplicate id are enumeration errors (exit 3), never silently dropped items, and a backlog that yields ZERO items is a cannot-evaluate enumeration error (exit 3, no --aei JSON emitted), NOT an empty enumeration, unless it affirms emptiness with the sentinel line '<!-- aei: empty backlog -->' (a non-empty file the grammar does not recognize, such as a markdown table, and an unaffirmed empty file both fail closed, so an unrecognized backlog can never read as a drained actionable set), while a sentinel-affirmed empty backlog is a valid empty enumeration distinct from a parse failure. It proves the reference provider's contract, not any adopter's provider; the doctor probes those live.
gate:agents-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_agents.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_agents.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:branch-root
  • Platform: ci
  • Default: block
  • Entry point: tools/check_branch_root.py
  • Class: c
  • Channel: repository gate

Technical limits (from the enforcement ledger)

Judges only the revision under gate against the derived protected ref: it does not scan the repository's other branches, cannot prove where a branch was originally cut (a branch cut from a sibling that itself descends from the protected line still resolves), passes a fork point that survives a partial rewrite unless the configured lag threshold catches it, does not fail on multiple merge bases, and where no threshold is configured the staleness layer reports and never blocks. With multiple merge bases the reported first-parent lag is the MINIMUM (most-lenient) across bases, so a criss-cross history is judged by its nearest fork point. Replace refs are neutralized in the scrubbed env (GIT_NO_REPLACE_OBJECTS=1) so a `git replace --graft` cannot mask an orphan as rooted through merge-base; the on-disk .git/info/grafts residual is out of scope (an accidental-case guardrail; full adversarial-grade parent-rewrite resistance is not this gate's remit). A shallow repository maps a merge-base miss to cannot-evaluate (exit 2) with a `git fetch --unshallow` remediation, never a false ORPHANED, because truncated history cannot be told from a genuinely absent root (the CI gate checks out at fetch-depth:0). When the protected ref is derived from the default origin/HEAD, a stale local origin/HEAD (not refreshed since the last fetch) can skew the derived protected tip, so both the orphaned and the first-parent-lag verdicts are only as current as the clone's remote-tracking state; pass --protected an authoritative ref, or refresh origin/HEAD, when that currency matters. A degenerate invocation naming the SAME ref for --protected and --head is a self-comparison that always resolves (a ref is rooted on itself); the caller supplies distinct refs. The gate scrubs the ambient GIT_* environment before every git call but resolves the `git` executable through the ambient PATH, so it trusts the toolchain's `git`; a hostile PATH entry shadowing `git` is a trusted-toolchain / execution-environment concern outside this gate's threat model, not a branch-root bypass it defends against. The revert check (exit 1, REVERTS, judged after ORPHANED and before STALE, on by default with no flag) is judged from every merge base and reports the union of findings, one per path, so a path found from several bases is reported and counted once (the reported lag stays the least across bases). From each base it compares the blob of each path the head changes, renames off and paths compared as exact bytes, with the pre-image blobs of the 200 first-parent commits ending at that base (the window matches --max-lag 200; file modes are not compared, so a mode change does not hide a restored blob, and where the newest matching pre-image differs from the head only in mode the walk goes on to an older pre-image equal to the head's whole image; an add counts as an absent pre-image, so deleting a file the line added is a finding, and with renames off a rename on the head's side of a file the line added inside the window counts as such a deletion). Where a window commit renamed a path exactly (git log -M100%, which pairs by blob id with no rename limit), the window commits older than that rename on the old name are compared as well at the newest name the window's later exact renames carry that content to (the old name keeps its own history, and a name the content only passed through, or left again, gains none of it), so a restore at a path the line itself renamed after the change it undoes is found, and the commit that made the rename is among the commits that path undoes. Exact rename following misses three shapes, so a restore at the destination of content the line replaced at the old name is not found in any of them: a rename the line combines with a content change in the same commit; a rename in a commit that also writes new content at the old name (git pairs no rename while the source path still exists); and a rotation in one commit (b.txt to a.txt and c.txt to b.txt, say), which git reports as content changes, so content the line replaced at c.txt put back at b.txt is not found. A path the head adds is also compared with the window pre-image blobs of each path the head removes, paired on exact blobs rather than by git's rename detection (so no rename limit or similarity threshold applies and the pairing needs no further git call); a match other than the removed path's blob at the base, and other than the empty blob, is a finding reported under the added path, so a rename on the head's side that puts back content the line replaced is found even where the line's add of the old path lies outside the window. A path the head changes in place is never paired, as a source or as a destination, because a new file beside an edited one often holds the edited file's old content for a legitimate reason (a licence header, a version string, an ignore file, a generated file) and exact blobs cannot tell that from a move. So a head that moves content the line replaced at one path to another without both removing the one and adding the other is not found: deleting a.txt while overwriting an existing path with content the line replaced at a.txt, writing that content to a new path while putting new content at a.txt, or writing it over an existing path while a.txt stays. A path the head leaves as the base has it is never paired either. A move that keeps the removed path's blob at the base is not paired, so a rename on the head's side that restores only a file mode the line changed is not found, and the empty blob is never paired, so a move that puts back an empty file the line has since filled is not found. An added path that holds the same nonempty blob as an old pre-image of a removed path for another reason (a branch that deletes svc1/VERSION and adds svc2/VERSION holding a version svc1 once had, say) is paired too; and the added path is judged by the merge result at that path, where the tip normally has nothing, not against the tip's image of the removed path, so a stale branch whose rename carries content the line has since put back at the old path itself still stands (these two each an over-report, never a pass, that a Reverts trailer clears). Those findings are candidates, judged by the merge result: `git merge-tree --write-tree <protected tip> <head>` runs in the same scrubbed git environment with replace objects off and with merge.renames=true and merge.directoryRenames=false set on the call, so global or repository config cannot change those two settings (a path the head adds stays where the head put it and is judged there). A finding stands where that merge conflicts at the path (the report names the conflict), leaves the path's blob different from the tip's, or changes only its mode to a restored pre-image's mode and blob (so a branch that puts back a mode the line changed is a finding); where it does none of these, the finding is judged the same way at each path that git diff-tree -M (no rename limit) pairs the path with from a merge base to the protected tip, so a revert the merge carries into a path the line renamed stands. A path the merge leaves as the tip has it, there and at each such rename destination, is not a finding: a stale branch carrying a revert the line already made, a path that differs from one merge base only by line changes the head picked up through another, or a mode change the line never made. A merge-tree that cannot run, exits other than 0 (clean) or 1 (conflicted), or prints output that does not parse is cannot-evaluate (exit 2). The rename destinations come from diff-tree's pairing from each real merge base, which can differ from the merge's own (with several merge bases the merge pairs from a virtual base, and its rename limit, merge.renameLimit, is not pinned), so where the two pair a path differently the gate can judge another destination than the one the merge used and miss the revert there, and a destination that differs from the tip for another reason makes the finding stand. That rename detection runs only for a finding the merge leaves as the tip has it at the path itself, and it is not bounded by a rename limit, because a bounded detection skips inexact pairs without failing; it runs under the 30-second git timeout instead, so a detection too large to finish in time is cannot-evaluate (exit 2), never a pass; merge configuration other than the two pinned settings is not pinned. The gate judges the merge into the current protected tip only, not into a later one; and merge-tree writes the merged tree's objects into the repository's object database as unreferenced objects, so a read-only object database may be cannot-evaluate (exit 2). An empty listing from either window walk (the raw walk or the exact-rename walk), one that does not start at the base, and any malformed raw record (a non-ASCII or non-hex object id, a bad mode) are cannot-evaluate (exit 2). The check passes a deliberate restoration only when the `Reverts: <full sha>` trailers of the commits in base..head together name every protected-line commit the path undoes. It does not see a partial revert inside a file the branch also edits (the head's blob is new, not a pre-image), a revert of a change older than the window, a pre-image that existed only inside a merged side branch (merges are diffed against their first parent), or a revert that a later commit in the range undid again (only the head's net change from the base is judged), and it protects only where this CI gate runs. A shallow repository whose history ends inside the window is cannot-evaluate (exit 2).
gate:changelog-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_changelog.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_changelog.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:char-policy-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_char_policy.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_char_policy.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:ci-parity
  • Platform: ci
  • Default: block
  • Entry point: tools/check_ci_parity.py
  • Class: c
  • Channel: repository gate

Technical limits (from the enforcement ledger)

Token-level set parity between the three named files only (tools/run_all_checks.sh against the union of the CI workflows .github/workflows/quality.yml and .github/workflows/msg-leaks.yml), read as data and never executed. Identity is the normalized command including all script arguments, with python/shell launcher words and the interpreter-only flags (-I, -B, -E, -s, -P, -u, glued forms included) removed, and the value of a runtime-derived flag (--base, --protected, --head) masked as <ref:expression> (preserving which expression supplied it) only when its de-quoted value is byte-for-byte one of the reviewed shell expansions in MASKED_REF_EXPRESSIONS and its raw workflow spelling, before shell de-quoting, is byte-for-byte that expression's one reviewed spelling, its double-quoted form as quality.yml writes it (any other spelling that de-quotes into a reviewed expression, a backslash-escaped, backslash-split, adjacent-quoted or single-quoted one, is cannot-evaluate (exit 2), never masked, because quote and backslash removal would otherwise collapse spellings bash treats differently into one reviewed identity), so a change among those reviewed runtime spellings including a self-comparison diverges. A runtime-derived flag value is exactly one that carries $ or a backquote: such a value outside that reviewed set is cannot-evaluate (exit 2), never masked, a literal <ref:...> placeholder spelling included (the placeholder is the OUTPUT of the masking, accepted back only from the reviewed in-gate allowlist and its canonical identities, so a quoted placeholder literal in a workflow never collapses into a masked identity); a flag value carrying neither character stays in identity as a kept literal and two different literals diverge; that literal reading applies only to a value that survives the extraction path's YAML screening and shell tokenization (an inline unquoted *name or &name spelling in the space-separated flag form is already refused by the YAML feature screen, exit 2, while the equals form bypasses that screen: --base=*main stays a kept literal that diverges, exit 1, and --base=&main splits on the shell control character into an empty flag value, refused, exit 2; the local runner's gate-word allowlist refuses tilde, glob and brace words outright, exit 2), so a tilde, glob or brace spelling in a workflow flag value is a visible divergence on the CI side (exit 1), not a mask, while the same spelling on the local side is a refusal; and a ${{ }} GitHub expression anywhere in a run: value, a whole-line or trailing shell comment included, is cannot-evaluate (exit 2), never masked, read on the raw block lines before shell-comment stripping, over the scalar extent a conservative subset of the YAML block-scalar rules reads (from the content indentation the first non-empty line sets to the first non-empty line below it: a leading blank line indented past the content indentation is refused, exit 2, as YAML rejects that layout, while a later blank line stays inside the scalar wherever it falls; a dedented YAML comment after the block is outside the scalar; a dedented line that re-enters the step is refused, exit 2; a tab on a scalar line is refused, exit 2, even where YAML accepts one in content; and a folded run: scalar is refused, exit 2, so the subset refuses, never passes, the layouts it does not read). It does NOT compare environment values, operating systems, tool versions, execution order, multiplicity, step placement across jobs, labels, or whether the shell harness propagates a child failure; interpreter-flag differences are owned by check_python_launcher_isolation.py. Parity scope is exactly the three files, so gates in other workflows (the scheduled currency.yml) are not enumerated, and a named workflow that is missing or unreadable is cannot-evaluate (exit 2), never an empty side. Argument order is identity-relevant, so a harmless reorder fails loud. Coordinated removal of both of this gate's own invocations is undetectable if nobody runs the remaining file manually (branch protection and review own that case). The extractors implement a disclosed shell and YAML subset; an unknown construct is cannot-evaluate (exit 2), never a clean pass. Two disclosed masking/parse edges remain: tokenization does not preserve quote type for kept literals, so a quoted and an unquoted spelling of the same literal collapse into one identity, and a $ inside single quotes is still read as runtime-derived like a double-quoted one, where the raw-spelling screen then refuses every spelling but the reviewed double-quoted one, so quote loss can refuse but never mask or pass a new spelling; and a duplicate run: key within one step is counted as two members although YAML keeps one. The shadow scan has one soft edge: exotic quoting outside the supported grammar could hide a tools/ path from comment stripping. It fails closed (cannot-evaluate) on an unknown top-level or job-level workflow key, a top-level unconditional exit that would strand later local gates, unbalanced if/fi nesting in the runner, job content without a job mapping, a step key before a job's first step item (YAML rejects that shape), and a run_gate() body outside its recognized shape; deeper nested non-gate YAML (under on:, env:, with:, strategy:) is structurally recognized but not exhaustively schema-validated, though the shadow scan still blocks a hidden tools/ gate and reachability is outside token-parity scope: a gate counts as declared regardless of an enclosing conditional (a job-level if:, a shell conditional inside a run: block, or an if/fi in the local runner) that could keep it from running, validating a single gate's own argument semantics beyond the runtime expression is that gate's responsibility. Intended local-vs-CI asymmetries are reconciled through a reviewed in-gate allowlist, not silently ignored. The CI member set is the union over every job, so a gate moved between jobs is still one member and a gate dropped from every job is still a finding. It reads the job set the way YAML does or refuses: a job id is read only from a plain unquoted key at indent 2 that is a valid GitHub job id and not a YAML boolean or null, with its properties at indent 4, and a quoted, spaced, flow or other key at that indent, a line at indent 1 or 3, a deeper job body, or a repeated top-level, job-level or job-id key is cannot-evaluate (exit 2), never skipped. On any job, continue-on-error, uses, defaults or container is a finding (exit 1) whatever its value, because each can let a failed gate leave the job green (derived from the documented job-level keys; the step-level equivalents are cannot-evaluate, except that every run: step must carry exactly one shell: bash line, a finding (exit 1) when absent, because GitHub documents a step shell as overriding the job's default shell and a job default as overriding a workflow default of the same name; that a step shell also overrides a workflow's defaults.run.shell is inferred from those two statements, not documented for that case. Without the line either default could run every run: step under a program that exits 0. The line does not close defaults.run.working-directory at workflow or job level: a step shell does not override it and every gate is invoked by a relative path, so a defaults block can still choose which file each gate runs, this gate's own included, and that route stays refused only where the local runner runs this gate). In a workflow with more than one job it also requires exactly one job named Quality gates (the one required status of that workflow) whose job-level if: is always(), whose needs: lists every other job exactly once, so a job outside that list is a finding (exit 1), and whose content matches a pinned copy line for line (blank and comment lines aside, needs: compared as a slot), so a deleted exit 1 or shell: bash, a changed success filter or an added key or step is a finding; the self-test runs the pinned step under bash and proves it exits 0 only when every needed result is success. A workflow that runs the message/metadata leak scan must list opened, synchronize, reopened and edited in its pull_request types, and a workflow that does not run it must not carry edited on pull_request or pull_request_target (a PR title/body edit would re-run every job there); each departure is a finding (exit 1), and a trigger shape outside the recognized subset under those events is cannot-evaluate (exit 2); the push trigger and branches: filters are not checked. quality.yml must list exactly labeled in its pull_request types (QUALITY_PULL_REQUEST_TYPES) and carry no pull_request_target, because a pull request run of it is scheduled only by the label the merge lane applies to the train pull request, so a member head gets no Quality run; a departure (the former opened, synchronize and reopened types, GitHub's default types for a pull_request event with no types:, or another type beside labeled) is a trigger finding (exit 1), and an on: block with no pull_request event, or no top-level on: line at all, is a trigger finding too (exit 1), because a train head then gets no Quality run either. Lane rule: quality.yml must carry one top-level concurrency: block exactly as pinned (CONCURRENCY_BLOCK, blank and comment lines aside), so a newer run of a pull request cancels its superseded run while a push-to-main run, grouped by its own run id, is never cancelled; every heavy job (HEAVY_JOBS) must be present in quality.yml and must carry the pinned job-level if: (HEAVY_JOB_IF_LINE), whose value is read whole, every continuation line included, so the job runs only on a push and on a merge-train-* pull request of this repository; and each heavy member (HEAVY_COMMANDS, the expensive suites pinned by full identity) may run only in a job whose if: is exactly that pinned line. A missing or departing block, a concurrency: block in msg-leaks.yml, a job-level concurrency: key, a missing heavy job, a heavy job without the if:, a departing or continued if: on a heavy job, a heavy member in a job without the pinned if: and an if: on any other job but the Quality gates job of quality.yml are each a finding (exit 1), so a heavy job removed, or renamed so that it would run without the if:, and a heavy command relocated into an unconditioned job each fail loud. The rule compares text, not GitHub's evaluation of those expressions; heaviness is the pinned HEAVY_COMMANDS enumeration, so a new expensive command outside it can land in an unconditioned job with no lane finding until it is added to the set (the check_byte_canon.py identities are outside it because the registry job also runs them unconditioned, so for byte-canon-matrix only presence, the pinned if: and member parity are held, not a relocation of its commands), and workflows outside the two named files are not read for lanes or parity, so a heavy command added there is caught only as a parity difference. It does not check the branch-protection setting (that Quality gates and the msg-leaks job, Message/metadata leaks, are both required checks), a second job that reports the Quality gates name through an expression, GitHub's own evaluation of needs results, always(), cancellation, matrix results or step-shell precedence, or the runner image, runs-on labels, services and environment values (a job or step env: can set a variable such as BASH_ENV). Its checkout-credentials rule reads every .github/workflows/*.yml and *.yaml file, parsed whole into its YAML structure by a conservative block-YAML subset reader, and refuses any mapping whose uses: names actions/checkout unless its with: sets persist-credentials to the boolean false (a missing with: or key, true, an empty value or any string, a quoted 'false' among them, is a finding, exit 1); a workflow entry that is not a regular file (a symlink, directory, FIFO or device, refused on its own lstat before any read of it), a file it cannot read or parse (a byte outside printable ASCII plus tab and newline, a shape outside the subset such as a flow mapping, anchor, alias, tag, folded or multi-line scalar or repeated key), a uses: key of a step (a sequence-item mapping) in another letter case (a key of an env: or other non-step mapping is data and is not held to that rule), a with: or persist-credentials key of a checkout step in another letter case, a step uses: starting with ./ or .\ (a dot then either path separator, however a runner reads the backslash) once its surrounding whitespace is stripped, whatever path it names (a step cannot call a reusable workflow, which is a job-level uses:, so it runs a local action, a composite one among them, whose steps are not in these files; a path under .github/workflows/ is no exception), a with: that is not a mapping, a symlink its lstat sees at any component from the repository root to the workflows directory (.github or workflows), and a workflows directory that cannot be listed or holds no workflow file are each cannot-evaluate (exit 2). It does not close a concurrent writer to the tree during the run: each lstat is a path lookup apart from the later listing and reads, which follow symlinks, so .github, workflows or a workflow entry swapped for a symlink after its lstat is followed, and one swapped for a FIFO can block the read. It does not read a checkout inside a remote action or a remote reusable workflow, the target of a job-level uses:, which is not resolved (a local reusable workflow it calls is read only as a workflow file directly under .github/workflows/, like every other), another action that writes credentials, an INPUT_PERSIST-CREDENTIALS value set through env:, or any other route by which a step obtains the job token (an action input that defaults to it among them).
gate:claude-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_claude.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_claude.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:crosswalk-schema-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_crosswalk.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the shipped adopter-facing schema reference .aiqt/core/migration/crosswalk-schema.toml, regenerated deterministically from the tool's own constants, plus a self-test of the adopter-mode candidate generation (archive-before-annotation ordering, the 8.2 immutability refusal, deterministic output, and the computed unmatched list). The self-test confirms the generator archives the UNANNOTATED legacy bytes verbatim and does NOT consume or derive any pointer from the archived source (8.6-conformant): the Expected-successor:/Coverage: pointer lines are stripped from the lexical-overlap ranking input, so changing only a predecessor's pointer content never changes which successor a candidate row names, and no pointer-hint is lifted from the archived bytes. Candidate mapping rows are produced by ranking against a supplied successor inventory (the --successor-inventory adopter input) and carry EMPTY verdict fields; with no inventory supplied the generator emits predecessor rows and the computed unmatched list and defers candidate-mapping ranking. It proves the shipped schema reference equals a fresh regeneration and that the archive-first candidate flow holds its invariants on synthetic input; it does not validate any adopter's live runtime crosswalk (that is check_crosswalk) and does not judge whether the candidate ranking heuristics chose the semantically right successor, which stays human judgment.
gate:cursor-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_cursor.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_cursor.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:dangerous-api
  • Platform: ci
  • Default: block
  • Entry point: tools/check_dangerous_api.py
  • Class: c
  • Channel: repository gate

Technical limits (from the enforcement ledger)

Class c, partial: it covers part of each linked rule, and the clauses of each rule it does not cover are named below. An AST lint (tools/check_dangerous_api.py) over every *.py file under tools/, opf/tools/ (the vendored opf/tools/_vendor tree excluded; every other directory, __pycache__ included, is walked), .aiqt/core/hooks/scripts/ and each plugin/*/hooks/scripts/ tree, parsed and never imported or run. It fails on: a reference to a named unsafe deserializer (pickle, _pickle, cPickle, dill, cloudpickle load/loads/Unpickler and their private _load/_loads/_Unpickler, marshal load/loads, shelve open, Shelf classes and its Unpickler re-export, jsonpickle decode and its loads alias, the multiprocessing ForkingPickler.loads, pandas.read_pickle, joblib.load, torch.load, the yaml unsafe and full load functions, constructors and Loader classes), a yaml.load or yaml.load_all whose Loader is not proven a safe or base loader, an allow_pickle= that is not literal False, and a numpy.load whose third positional argument (allow_pickle) is not literal False or a * spread hides (secdsz); a verify= or verify_ssl= that is not a true-valued literal (False, 0, None and an empty string are false-valued), an ssl= that is False or 0, a .verify attribute set to anything but a true-valued literal, any reference to _create_unverified_context, _create_stdlib_context, _create_default_https_context, CERT_NONE, CERT_OPTIONAL or CLIENT_AUTH, an ssl.SSLContext whose protocol is not proven ssl.PROTOCOL_TLS_CLIENT, and a check_hostname or verify_mode (cert_reqs=) set, by assignment, any other store (an unpacked, for, with or comprehension target), literal setattr or keyword, to anything but True or a proven ssl.CERT_REQUIRED, and a standard-library client whose default context skips certificate checks (smtplib.SMTP_SSL, poplib.POP3_SSL and ftplib.FTP_TLS whose context=, imaplib.IMAP4_SSL whose ssl_context=, and any method named starttls or stls, called as x.starttls(...) or getattr(x, 'starttls')(...), whose context (called on an imported or locally defined class, as in smtplib.SMTP.starttls(server), the first positional argument is self) is not proven a verifying context: a call of ssl.create_default_context() (no purpose, or a proven ssl.Purpose.SERVER_AUTH) or ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) with no * or unreadable ** spread, or a name every binding of which, in every scope Python may read it from, is such a call; so an absent or None context, a parameter (a None default included), an attribute (self.context), a name with any other binding (a walrus, a starred unpacking, a conditional rebinding, a copy of another name, an import) and any other expression are findings (a bare annotation such as ctx: object, or a del, is no binding); any ssl.get_server_certificate, which never checks the hostname whatever ca_certs it is given; a logging.handlers.SMTPHandler whose secure= is set or hidden by a spread), each also when referenced without a call, and a server_hostname= that is an empty literal (seccry); a subprocess run/call/check_call/check_output/Popen whose shell is on, or unseen through a non-literal ** spread, a * spread or a ninth positional argument, over a non-literal command, and os.system, os.popen, posix.system, nt.system, subprocess.getoutput/getstatusoutput, asyncio.create_subprocess_shell (asyncio.subprocess included) and loop.subprocess_shell over a non-literal command or one a spread hides (secinp); and any reference to eval or exec (builtins included), types.FunctionType and the runpy, timeit, cProfile, profile, pdb and code runners, and any call of or reference to a method named exec_module or load_module, whatever object it is called on (secinp); a method judged by its name alone (starttls, stls, subprocess_shell, exec_module, load_module) referenced without a call, as x.name or getattr(x, 'name'), is an alias and a finding. It also fails (secinp, format validation at the boundary) on a str regular expression whose pattern holds \d or \w read as Unicode, which without re.ASCII match every Unicode digit and word character so an id, version or date validator admits Arabic-Indic or fullwidth digits, and that is anchored (starts with ^ or \A and ends with $, \Z or \z, after the leading inline flag groups and comment groups (?#...), in any order and, when verbose, past whitespace and # comments between them, with any comment group skipped, inside any group that holds the whole pattern ((...), (?:...), (?P<name>...), an atomic (?>...) or a scoped flag group such as (?i:...)), inside such a group that opens or closes the pattern ((^\d+)$) where neither holds an alternation of its own (a group that opens it is entered even when a quantifier follows it, so (^\d+)*$ reads as anchored, which errs toward reporting), and, when verbose, past whitespace and # comments) or matched from its start (re.match, re.fullmatch, or the match or fullmatch method of a compiled pattern) or anchored at its start and searched (re.search, or the search method of a compiled pattern), where a \d or \w is read as ASCII, as Python reads it, under re.ASCII or re.A in its flags, a leading inline (?a), or a scoped (?a:...) group around it, unless a scoped (?u:...) group nearer it reads it as Unicode again; under MULTILINE (re.M or re.MULTILINE in the flags, an inline (?m), or a scoped (?m:...) group unless a scoped (?-m:...) turns it off) ^ and $ match at every line, so a pattern anchored at both ends only by them is a line scan, not a whole-input validator, judged at each use as an unanchored one is (reported when matched from its start), and a compiled one is reported where it is compiled unless every use of it in its own file scans lines (a search, findall, finditer, sub, subn or split method of it, or its .pattern given as the resolved pattern of an re function, where that text is judged; a store of it to a name or attribute is followed to every read resolved to it and every attribute read named like it, such as self.P for a class attribute P, and any other use, match or fullmatch, an argument, a return or a container among them, reports it there), while \A, \Z and \z still anchor the whole input; an anchored pattern is reported whatever function uses it, findall, finditer, sub, subn and split included (anchored at both ends, it matches the whole input or nothing); a compiled pattern is an re.compile call, a walrus of one, either branch of a conditional expression, any operand of a boolean expression (cache or re.compile(...)), or a name every binding of which is one of these or a copy of another such name, and its method is judged where it is called, where it is referenced uncalled (a bound method held for later, alone or in functools.partial) and as re.Pattern.match, fullmatch or search (typing.Pattern is the same class) given the pattern first; its pattern resolves as a literal does (a string literal, a + of resolved parts, a conditional expression whose branches both resolve, a name every binding of which is an assignment of one, or the .pattern of a compiled pattern, the text of every re.compile it may be without that compile's flags, so re.fullmatch(LINE.pattern, text) is judged without LINE's re.M), and the standards manifests' id-patterns, which are data, are compiled with re.ASCII in tools/_standards.py by hand. Names resolve per scope as Python binds them, so a binding in another function never hides the one in force, and every binding a module, class or comprehension scope may hold is a candidate; an unbound name falls back to the builtin of that name where one exists. A sink module reached as an attribute of another module names that module (shutil.os.system is os.system; from logging.handlers import pickle binds pickle), except an attribute named like the module holding it (timeit.timeit). getattr(x, 'name') is read as x.name, and so is a name or attribute directly followed by a string literal in a call's positional arguments or a tuple or list display (the monkeypatch idiom (os, 'system') reads os.system), except in setattr, delattr, hasattr and a mock patch.object, which store, delete or test the attribute. A sink module (every dotted prefix of every named sink, derived from the sink sets, so multiprocessing.reduction, logging.handlers and torch are included) used other than as an attribute base (m.x; the first argument of getattr, setattr, delattr or a mock patch.object with a literal name; the argument of hasattr; or the name so read with a string literal) is a finding: bound to another name in any form (assignment, unpacking, walrus, conditional or boolean expression, default parameter, match capture, for or with target), passed as an argument, returned, yielded, held in a container, or passed to getattr with a non-literal name, unless a string literal directly follows it (out of scope, below). A judged callable referenced without a call, a literal dynamic import of a sink module, and a star import from a named sink module are also findings. Keywords are read as Python binds them: a literal ** dict by its keys, and so a ** or dict(...) inside it at any depth, in overwrite order (a duplicate key keeping its last value; a non-literal spread inside it leaves the keys written before it unseen, though a literal verify, ssl, check_hostname, server_hostname, verify_mode, cert_reqs or allow_pickle written there is still judged), and dict(...) only where dict can only be the builtin. A safe value (a yaml Loader, an SSLContext protocol, a verify_mode or cert_reqs, a client's context) is proven only when every binding the name may hold names it (for a context, is a call of a verifying constructor); a name that may also hold an assignment, a parameter or any other value the lint cannot name, an unbound name, and an expression prove nothing, so they are findings. A current legitimate site is admitted only by a reviewed in-gate allowlist entry (path, enclosing qualname, kind, exact count, reason line), and a count that no longer matches is itself a finding; the shipped entries are self-test mutant, fixture, stub, fault-injection, deny-table, static-analysis and own-source loader sites over the repository's own source, and regex sites where Unicode \w or \d is the intent (a word-character test over prose or command text, a disguised-marker detector, a list-marker scan that must also catch non-ASCII digits, a launcher or executable-fence detector whose wider match is the fail-closed side). Each file is decoded as Python decodes it (a BOM and a PEP 263 coding cookie honoured); a cookie other than utf-8, a file it cannot stat, read, decode or parse, a directory it cannot list or search (each plugin directory and its hooks directory included), any symlink inside a scanned root or directly under plugin/, a missing root and a non-regular *.py entry are cannot-evaluate (exit 2), never a clean pass. Per rule, NOT covered: for secdsz, deserialization in other languages and file formats, deserializers outside the named set, and dispatch the lint cannot see; for seccry, the choice of algorithm, mode, key size and other parameters, weak, deprecated or home-grown schemes, and protection of data at rest (only certificate and hostname verification in transit is linted); for secinp, validation of external input for type, range and format at the boundary beyond the ASCII-digit regex check above, and the SQL, markup and template injection classes (only the operating-system command and code-execution sinks are linted, by literalness). Out of scope: this lint guards against ACCIDENTAL use of a dangerous API in the repository's own code written the ordinary way, so code deliberately built to hide a sink is not claimed: a string-assembled or string-literal module name (importlib or __import__ with a computed name, sys.modules[...]; a literal import of a sink module is a finding, but the module it returns is not followed), getattr with a computed name or a sink module followed by an adjacent string literal (f(os, 'x') and (pickle, 'x')[0] read the module as an attribute base, not an escape, and getattr(shutil, 'os') is not read as the os module), __builtins__ aliasing (m = __builtins__, __builtins__.exec, __builtins__[...]), a rewritten module attribute (ssl.PROTOCOL_TLS_CLIENT = 2 still proves the protocol) and dunder access (__getattribute__, __setattr__, object.__setattr__, __dict__). It also does NOT catch: dynamic dispatch (getattr with a non-literal name on an object other than a sink module, setattr with a non-literal name, globals() or vars() lookups); a numpy.load reached through an alias and given allow_pickle positionally; an attribute chain whose base is not an import binding (a parameter, self, a call result or a subscript, so self.os.system and sys.modules['os'].system are not resolved); a sink module re-exported under a name other than its own (mod._p.loads where mod binds pickle as _p); a sink function obtained from such an object or from a call; a method run on a profiler, debugger or trace object; a shell reached through an argv list that names a shell (sh -c) or a Python interpreter given code (sys.executable with -c or -m), an executable= override, or os.exec*, os.spawn* or pty.spawn of a shell; a verify, verify_ssl, ssl, check_hostname, server_hostname, verify_mode, cert_reqs or allow_pickle value hidden in, or overwritten by, a non-literal ** spread (a literal ** dict is read by its keys at any depth, in Python's overwrite order; only the subprocess shell switch and the command, Loader, protocol, context and secure= of the judged sinks are denied when unseen), and a verify given positionally (Session.merge_environment_settings(url, proxies, stream, verify, cert)); a verifying context changed after it is built other than by a check_hostname or verify_mode store (its trust store replaced through load_verify_locations, or a setattr with a computed name), and a context= given to a client whose default verifies (urllib, http.client, asyncio ssl=), judged only where the context is built; a starttls or stls called on a class reached other than through an import binding or a locally defined class (read as bound) and a client configured from logging.config data; TLS verification disabled inside a third-party library's defaults, through a library option it does not name, or through environment variables; a sink whose literal arguments are still attacker-shaped (the lint judges literalness, not data flow); and code outside the scanned roots (the vendored tree, .github, .preview, site, and Python embedded in a non-.py file). For regex it does NOT catch: a pattern it cannot resolve (a parameter, an import, a call, an f-string, % or format), which is not judged, and any pattern held in data other than the standards id-patterns; a .pattern read through anything but the re.compile calls its receiver may be; an unanchored compiled pattern reached through an attribute (self.PAT.match; an anchored one, or a line scan with such a use in its own file, is reported where it is compiled), an import, a parameter, a call or a container; an re function itself held for later (f = re.fullmatch, functools.partial(re.fullmatch, pattern), re.Pattern.fullmatch or typing.Pattern.fullmatch referenced uncalled) and a method named through getattr but not called; \d and \w detectors with no start anchor used with search, and any not anchored at both ends used with findall, finditer, sub, subn or split; a line scan (anchored only by ^ or $ under MULTILINE) used only to scan lines, so a line validator that admits non-ASCII digits in each line is not seen, and a line scan matched from its start only in another module; a lookaround, conditional or backreference group at either end, not entered (^\d+(?=$) is not read as anchored), nor a group that closes the pattern with a quantifier after it (^(\d+$)? is not read as anchored); a top-level alternation, read by its first and last tokens only, and neither it nor a group holding its own alternation entered at an end; and \D, \W, \s, \S and \b, which also change with re.ASCII, so the \b detector false negatives in tools/check_secrets.py and tools/check_leaks.py are not addressed here; flags held in anything but a | of flags or a name bound only to one are read as neither ASCII nor MULTILINE (a finding, never a pass). The allowlist reasons are maintainer-reviewed assertions; the gate checks their presence and count, not their truth, so a reviewed site replaced by an unsafe one in the same function at the same count passes. Its linkage to secenc (output encoded for its sink) and secout (generated output is untrusted input) is arguable, so none is claimed.
gate:derived-command-parameters
  • Platform: ci
  • Default: block
  • Entry point: tools/check_derived_command_parameters.py
  • Class: c
  • Channel: repository gate

Technical limits (from the enforcement ledger)

An adopter-parameterized scan that flags a hardcoded LITERAL value at an adopter-declared command sink (a command word plus an option name in a declared command-surface file), so a reusable command derives its target-specific parameter rather than baking one in (grdinp input-soundness, exetgt templated-target re-verification). It ships NO command, option, repository, or path of its own: with no .aiqt/derived-command-parameters.toml it is NOT APPLICABLE, and a present but unusable configuration fails closed. It covers a recognizable SUBSET (class c, partial): it proves only that the declared sink is NON-LITERAL (a reference to an allowed variable), never that the variable resolves at runtime to the correct target, which is exetgt's runtime confirmation outside any static scanner. Its lexical scanner does not model pipelines and grouping precisely, credits no command substitution as derived, matches a command by basename only (an alias, a function, a wrapper, or a dynamically constructed command word slips it), and reasons only over the option forms and reference-variable names the adopter declared, so an undeclared sink, an obfuscated or constructed literal, and a forge or tool the configuration does not name are outside it.
gate:disclosure-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_disclosure.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_disclosure.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:enforceability-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_enforceability.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_enforceability.py generates from its declared source: it fails when the generated ledger differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:enforcement-register-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_enforcement_register.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the Markdown register (ENFORCEMENT.md) and the whole generated site register page (site/enforcement.html), both regenerated as complete files from the validated enforceability ledger, the enforcement roadmap, and the rule corpus. It fails when a generated view differs from a fresh regeneration, and it fails closed on a contradictory input (a committed ledger that no longer matches the manifests, or a roadmap status or mechanism set that contradicts the ledger linkage). The generator also runs a generation-time VERBATIM check: after composing the page it requires the exact escaped ledger-residual block to appear byte-for-byte in the composed HTML, and the raw residue to be the content of that mechanism's own Markdown fence, failing closed if a residue is swallowed, altered, or misplaced. This is a byte check over the generated source, not a re-parse of rendered HTML, and the whole-page byte-identity drift above then binds the shipped file to that checked source. It does not establish that a linked mechanism completely enforces a rule, that a pending proposal is technically sufficient, that a residue is semantically true (the author-graded limit lives in the enforceability generator and is inherited here, not cured), or how a downstream renderer outside this repository displays the Markdown; it covers only the two views this generator declares.
gate:entry-guard
  • Platform: ci
  • Default: block
  • Entry point: tools/check_entry_guard.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

Total over its declared surface: every Python script the local check roster launches and every runner the self-test execution manifest registers. Each must parse from its raw bytes and carry exactly one top-level test of the bare name __name__ against the exact string "__main__", as its last top-level statement, with no else, whose body ends in a call statement (an expression statement whose value is a call, or raise SystemExit of a call) with no raise anywhere before it in that body (at any depth, and conservatively inside a definition too); a deleted, duplicated, displaced, nested, misnamed, misspelled, or empty entry, and one whose only call is dormant (inside a definition, a lambda, a nested branch, or a try) or follows a raise, always fails. Every control input (the roster, the manifest, and each script) is opened non-blocking without following a final symlink and must be a regular file: a missing, unreadable, or unparsable script, a symlink, FIFO, directory, or other non-regular path, an unreadable, malformed, or schema-invalid roster or manifest (the manifest is held to the execution gate's own schema validator), and a roster that launches no Python script cannot be evaluated (exit 2). No gate can hold its own entry (with its guard lost, both of its steps exit 0 silently), so its self-test is registered as the suite entry-guard-selftest and both rosters launch it only through the execution gate, which reads the missing execution report of a child that ran nothing as cannot-evaluate (exit 2); the self-test reproduces this on a scratch copy with its own guard deleted, and launches its own live step as a child and requires its PASS line. That holds only while its registry row and both suite roster lines stay; a coordinated removal of them is outside it. It proves only that the entry exists in that form. A static check cannot see a neutralization that keeps that form: the final call running something that does not reach the checks or exiting with a constant status (sys.exit(0), print(), a bare main() whose failure status is dropped); an earlier statement in the guard body that exits or raises through a call, rebinds the called name (main = lambda: 0), or replaces the exit function (sys.exit = print); a top-level statement before the guard that exits, raises, or never returns (raise SystemExit(0), sys.exit, os._exit, an endless loop), rebinds __name__, rebinds or shadows the called name, or registers a hook that overrides the exit status (atexit.register(os._exit, 0), a signal handler, sys.excepthook); an import whose side effect exits or replaces sys.exit or a builtin; a dispatch or check deleted inside a function the entry calls, which is caught for a registered suite only by the execution gate; and any other statement that changes control flow or the exit status before or during the final call. A symlinked parent directory is followed. It does not read the CI workflow (the parity gate holds the two rosters equal), does not see a script the roster never launches, and cannot tell a legitimate from an illegitimate coordinated removal of an entry and its roster line in one reviewed change. Its self-test proves each vector discriminating against its own table of single-check removal mutants, not against every possible regression, and the execution gate accounts for its checks by family (every vector, every mutant, each launch leg), not per vector.
gate:first-pin-demo-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_first_pin_demo.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_first_pin_demo.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:gensrc-failclose
  • Platform: ci
  • Default: block
  • Entry point: tools/check_gensrc_failclose.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A registry-driven fail-close gate: for every target the gensrc registry declares, it corrupts a disposable sandbox copy (the real tree is never written) and asserts the generator's --check rejects it - a text target fails closed to exit 2, the one binary target drifts to exit 1, and the floor is that a corrupted target never yields exit 0. It is the durable form of the F-142b sweep and catches an F-154-class generator that does not consult its target. It exercises --check not regeneration, models unreadable as invalid UTF-8, probes every regular-file member of a tree, recognizes only the 'python3 tools/gen_<stem>.py' regenerate grammar, and does not judge the semantic correctness of generated content.
gate:gensrc-registry-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_gensrc.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_gensrc.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:hooks-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_hooks.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_hooks.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:install-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_install.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_install.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:manifest-gen-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_manifest.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the five generated outputs (manifest, .gitattributes, ROOT, announce snippet, and the EN-8 frozen floor .aiqt/frozen.json) regenerated from the ownership map and the git-tracked surface. It proves the committed outputs equal a fresh regeneration and that the inversion assertion holds; it does not prove a class ASSIGNMENT is semantically right (a wrong but consistent class survives to review), and it cannot see a file hidden by an existing concrete exclusion pattern (the disclosed bounded residual of 4.2).
gate:mappings-page-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_mappings.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_mappings.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:mistakes-register
  • Platform: ci
  • Default: block
  • Entry point: tools/check_mistakes_register.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

Total over the declared register: a broken digest chain or sequence, a reused id, an illegal status transition, a rewrite of anchored or merge-base rows, and an unverifiable landed row always fail; a declared-but-unreadable register, a malformed or unreadable anchor, an anchor entry that exists but is not a usable regular file (a dangling symlink or a directory; presence is an explicit lstat whose only absence answer is ENOENT, so such an entry, or an anchor lstat failure such as EIO, never takes the missing-anchor allowance), a present registry that is not a JSON object, a git failure, launch error or timeout deciding whether the register is tracked or reading its merge-base authority, and a launch error or timeout reading whether a landed row's commit exists, are exit 2. The anchor write (--update-anchor, tracked and untracked registers alike) never opens an existing entry: a non-regular anchor entry (a symlink, dangling or live, included) is refused with its target neither created nor rewritten, and a green advance lands by atomic replace of an exclusively created temporary. A same-authority local file offers no protection against an actor who rewrites the WHOLE chain and the anchor together, which defeats the untracked mode (the tracked mode is bounded by merge-base history instead), so rewrites are DETECTABLE within those bounds, never prevented; adopters needing real separation bind the same interface to a server-side append store. The landed-row check proves the cited commit and check path exist, not that the check semantically guards the mistake. In a checkout with no registry the live leg is NOT APPLICABLE with the self-test as assurance.
gate:mistakes-register-append-selftest
  • Platform: ci
  • Default: block
  • Entry point: tools/orch_register.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A synthetic self-test of the appender's write-time validation, not a live gate. On hermetic fixture registers it shows that an append whose resulting register fails the register gate's own verify_lines or verify_append_only (an illegal status transition, an unknown status, a landed row without an existing commit or repo-relative check_ref, a row that would fuse onto an unterminated last line, an anchored row rewritten in place, a wrong-family id in either direction) is refused with exit 1; that the appender validates in the gate's own context (the validation root is the nearest enclosing git repository of the appender's own installed location, exercised in subprocess vectors, unpatched for an appender installed inside a repository and, for one installed outside any, through a test-only search ceiling at the fixture's temporary directory (a .git entry above that directory reads as absent, so a git checkout around TMPDIR cannot decide the verdict, and a control appends through the same ceiling from inside a repository below it), so a register inside a nested repository validates under the declaring outer root, never under the repository that merely contains it and never under a root derived from the invocation directory; the id family comes from that root's registry declaration naming the register path, a --prefix that disagrees with it is refused, and a path no declaration names, a root with no registry, an appender installed outside any git repository, a gate root that is not a git repository, and two declarations resolving to the same path, hard links to one inode included, are cannot-evaluate with exit 2); that an authority it cannot evaluate (a tracked register with no computable merge-base, a git failure deciding whether the register is tracked, a malformed or unreadable anchor, an anchor entry that exists but is unusable, a dangling symlink, a directory or an anchor lstat failure that is not ENOENT included, an unreadable register or register descriptor, a symlinked register path, a landed row's commit read that git cannot launch or that times out, a register directory that cannot be created) is exit 2; that an ordinary append to an untracked register with no anchor is exit 2 naming the anchor, as a first row and on unanchored rows; that every refusal leaves the rows other appenders wrote byte-identical (a refusal before the first-row creation leaves an absent register and its directory absent; a refusal under the acquired lock after the exclusive creation removes the created file and the directory components the call itself made, naming the removal, only while that file is still the call's own empty file; a lock that cannot be acquired on the created file leaves that empty file in place and names it; anything else is left in place and named); that the commit is all or nothing, a failure injected at each commit step (the exclusive creation of a temporary file in the register's own directory, its lock, a failed or short write, its permission bits, its fsync, the under-lock re-check, the rename) leaving the register's bytes unchanged and no temporary file, with a temporary that cannot be removed named by its path; that a file or symlink swapped in at the temporary's name before the name check, or a temporary removed from its name, is refused by that name with exit 2 and the register byte-identical, a swapped-in entry left in place and named; that --bootstrap lands a register's first row and establishes the anchor --update-anchor would write, after which the ordinary gate passes, before and after a following ordinary append; and that clean appends, an AT- attestation row included, still land, each re-read with its last row and a clean verify_lines asserted. It binds only appends made through tools/orch_register.py: cooperating appenders are serialized by an exclusive flock held across the read, the validation and the commit of an existing register (one that took its lock on a file another appender's commit has since replaced starts over on the file the path names), a first row is created exclusively and re-checked under the acquired lock (the path still names the created file and it is still empty, so an appender landing between the creation and the lock forces a refusal, never an interleaved row), and the commit is bound to the validated file (opened once with O_NOFOLLOW and never written; the temporary, locked and fsynced, is renamed over it only while the path still names its device and inode, it has no other hard link and it still holds exactly the validated bytes, and only while the temporary's name still names the temporary, its lstat a regular file with the open temporary's device and inode), but a writer that does not take the lock (a hand edit, any other tool) is outside that serialization: the re-check catches one landing between the validation and the re-check, one landing between the re-check and the rename is overwritten by the rename, and any other is caught later by the mistakes-register gate, not here. At the temporary's name, an entry such a writer swaps in before the name check is refused, but one swapped in between the name check and the rename (two system calls) is renamed over the register unvalidated and the append reports success; the name check does not re-read the temporary's bytes, so a writer that opens the temporary by name and rewrites it is not caught here either; and the removal of the temporary after a refusal checks its identity and unlinks it in two calls, so an entry put at its name in between is unlinked instead. Not covered by the all-or-nothing commit: a crash after the rename and before the directory sync completes (the rename may then not be durable), a directory sync or --bootstrap anchor write that fails after the rename (exit 2, saying the row is in the register), a temporary a crash before the rename leaves beside an unchanged register, and the replaced file's owner, group, ACLs and extended attributes (its permission bits are kept). Validation runs on the decoded UTF-8 text with the gate's own line splitting, not on raw bytes. An ordinary append requires the anchor exactly as the gate without --update-anchor does; only --bootstrap, which appends a register's first row, takes the gate's --update-anchor allowance for an untracked register with no anchor, and an untracked register that already holds rows but has no anchor is anchored by the gate's --update-anchor, not by the appender.
gate:notice-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_notice.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_notice.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:python-launcher-isolation
  • Platform: ci
  • Default: block
  • Entry point: tools/check_python_launcher_isolation.py
  • Class: c
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A deterministic scan over a RECOGNIZABLE SUBSET of the repo's Python launcher configuration: the generated plugin hooks.json, tools/run_all_checks.sh, every *.yml/*.yaml candidate under .github/workflows/ (only a regular file is scanned; a symlink of any kind, never followed, a directory, or any other non-regular or unreadable candidate is a named cannot-evaluate, exit 2), and the optional .claude/settings.json and .claude/settings.local.json files (exactly those two are read). A direct python3 launcher there is FAILed unless it runs isolated (-I, or the full -P -E -s), so a tool-written sibling cannot shadow a stdlib import and neuter the control; the gate itself refuses to run non-isolated (exit 2) so a sibling cannot neuter the gate first, and it fails closed (exit 2) on a missing, unreadable, non-regular, or malformed input rather than passing it as clean. Because -I drops the script's own directory from sys.path, a hook script that imports a module beside it fails under -I, and a PreToolUse hook that fails exits 1, a non-blocking error that lets the tool call through; so every Python hook script a launcher in the plugin hooks.json or a scanned .claude/settings.json or .claude/settings.local.json runs is parsed (AST), each script once, and FAILs when it imports a sibling (an absolute import whose first name is a module file beside it or ANY subdirectory beside it: even a data-only directory resolves the import, as an empty namespace portion, only once the bootstrap has put the hook's directory on sys.path, so it is a sibling too) with no module-level sys.path.append of its own directory (an expression statement of the module body itself, not nested in a function, class, if or try, whose argument is str() or os.fspath() of Path(__file__).parent with an optional .resolve() or .absolute() before or after .parent, os.path.dirname(__file__), os.path.dirname of os.path.abspath or os.path.realpath of __file__, os.path.abspath or os.path.realpath of any of those string forms, the same spellings through a module-level from-os.path import binding, or a module-level name last bound to one of those by a plain or annotated assignment, where ANY other module-level store or delete of the name, an augmented assignment, a for, with or walrus target, an except-as, an import binding, a del or a global rebinding in a nested scope, forgets its credit) or when the module-body statement holding the import comes before that append in source order (an import in a function defined before it included, a conservative over-refusal); an import of a sibling whose name the stdlib or a module importable from the isolated interpreter path also provides FAILs whatever the bootstrap (the append lands last on sys.path, so the sibling is never the module imported), with two refinements: a spec the isolated path serves with no loader origin is a namespace portion, which does not win over a regular module or package and so counts only against a sibling that is itself only a portion (the portions then combine, the earlier one searched first), and when a winner exists but the sibling is a data-only directory the import works through the winner and the directory holds no code to shadow, so that import is clean (a data-only directory whose name nothing provides keeps the bootstrap requirement); a relative import FAILs (a hook script runs as a top-level script, never a package member), any sys.path.insert call in a hook script FAILs, since a directory ahead of the stdlib lets a file beside the hook shadow a stdlib import, every other module-level sys.path mutation beside the bootstrap append (a remove, pop or clear call, a slice, item, plain, annotated or augmented assignment, a del) FAILs, since it can drop or reorder the appended directory after the bootstrap, and the whole source is held to ONE bounded SYS RULE instead of a per-construct binding enumeration: a load of the name sys is accepted ONLY as the receiver of an attribute read whose attribute is outside the import-machinery set the gate names in one constant (path, modules, meta_path, path_hooks, path_importer_cache, builtin_module_names, implementation, pycache_prefix, dont_write_bytecode, flags -- the attributes the import system reads to resolve an import), so sys.argv, sys.exit and the other attribute reads stay clean, a load of sys.path or another machinery attribute is accepted ONLY inside a module-body own-directory bootstrap append itself (an idempotent repeated append included), and EVERY other use of the name sys FAILs at its line -- an assignment, augmented assignment, return, call argument, subscript, iteration, container, comprehension, with, walrus or lambda carrying a bare sys or a machinery attribute, a rebinding of the name sys (a store, del, def, class, import or except-as binding), an import sys as X, and a from-sys import of a machinery name or * -- because a function returning sys.path, a sys.modules['sys'] subscript and an augmented-assignment value each carried the object out of the dotted grammar unseen; NAMED COST, accepted over-refusals of working shapes: a read-only use of a machinery attribute (iterating sys.path, a sys.modules lookup) and a sys.path.append of a directory other than the script's own (an append ahead of the bootstrap can put a shadowing provider before the appended directory, so appends are no longer presumed harmless) are refused, fail-closed over modelling aliasing and mutation; and the source of each regular sibling module the hook imports unshadowed (the module file, or a package's own __init__) is read, ONE level deep, and held to the same sys rule (its own own-directory bootstrap append sanctioned the same way), each source once, so a sanctioned sibling cannot silently re-open the shadow class the hook itself is held to. The script operand is the first non-option word after the interpreter options; the plugin-root variable resolves only in a plugin's hooks/hooks.json, and the project-directory variable and a relative path resolve against the repo root; a -c, -m or standard-input launch runs no script file and is out of scope, and an operand that cannot be placed or resolved (another variable, an absolute path, a glob, a tilde, a path leaving the tree), a relative operand whose python3 launcher is not the FIRST word of its hook command string, with only raw-literal VAR=val environment assignments allowed before it (any other earlier word -- a directory-change spelling in any quoting, a cd hidden in an eval operand or a sourced file, an unset-variable cd$X spelling that expands to cd, an env word, or any other command -- could run arbitrary shell before the launch, so no directory-change word list is kept and every non-assignment prefix word is refused alike, the refusal message naming the remedy, an absolute or Claude Code variable path; NAMED COST: a harmless prefix such as echo a && python3 -I hk/h.py is refused for a relative operand even though it moves no directory), a script missing from the tree, an unreadable script or directory, hook source that does not parse, source too deeply nested or chained to parse and analyse (a RecursionError or MemoryError, caught where the source is parsed and analysed), and an imported sibling whose winning file is not a single plain Python source, or that cannot be read, parsed or analysed, are cannot-evaluate (exit 2). It does NOT catch: wrapper or indirect launchers (a bash -c string, a .sh re-launcher), $PYTHON or shell aliases and functions, a dynamically assembled argv, the programmatic subprocess children in the tools and the regenerate strings in .aiqt/gensrc.json (a separate follow-up), a script that mutates its own sys.path at runtime (which -I cannot prevent), an unrecognized interpreter name, launcher configuration outside the enumerated surfaces, YAML or shell constructs beyond the supported line grammar (the run: value grammar is CLOSED: a plain unquoted scalar, a LITERAL (|) block scalar read under the full YAML block-header grammar (a FOLDED > header, although inside that header grammar, is cannot-evaluate until folding is implemented: YAML folds the content into one command, so a line-by-line reading can disagree with the decoded value), and a single-quoted scalar with no escape, decoded to its body, are the only accepted value forms, while a double-quoted scalar (its escapes undecoded), a single-quoted scalar with an escape or no closing quote, a quoted scalar followed by anything (a trailing comment included), a flow mapping or flow sequence step, value or line, a run key inside a flow collection, ANY quoted key (an escaped spelling YAML decodes to run included), any line opening with a node property, tag, anchor or alias, an explicit key, and a run key with whitespace before its colon are each cannot-evaluate, exit 2, naming the file and line, each form matched after any number of leading sequence dashes; inside a steps: block (a plain bare steps: key; one with a same-line value is cannot-evaluate) the first entry must be a '- ' item and every line at the item or step-key indentation must be a plain unquoted identifier key from the closed step-key set (name, run, uses, with, env, if, id, shell, working-directory, continue-on-error, timeout-minutes), a plain key outside that set or a line the step model cannot place being cannot-evaluate, a LITERAL block's content bounded by the KEY column so a sibling step key after a dashed '- run: |' block re-enters those rules instead of being consumed as block content, and every shell: value READ under a closed classification (a bare known non-python shell keyword, bash, sh, pwsh, powershell or cmd, is clean; a python template without isolation options is a non-isolated finding, on a step and under defaults.run at the workflow and job level, a bare run: whose deeper next line is a plain key: line being read as that mapping, shell and working-directory keys only; every other shell: value, an isolation-claiming python template included, is cannot-evaluate), while deeper with:/env: input VALUES keep the line-form handling (a shell:-spelled input KEY is read as a shell template, a disclosed over-read) and a plain key other than run: and shell: outside a steps: block remains unscanned (the step tracker is single-level); an empty run: value, a bare run: or one whose only same-line text is a YAML comment (YAML strips it to the same empty value), is read ONLY in the two proven shapes, a next-line plain scalar or a next-line block header whose content is deeper than both the header and the key, and is null only when the next non-blank, non-comment line is not deeper than the key; a comment line before a deeper value, a header with no content deeper than the key (a header at the end of the text included) or with content at or above its own column, a line opening with '#' directly after that header (YAML block content the reader refuses rather than reads), any other run: value starting with | or > on the key line or that deeper line, a node property or alias, or a plain scalar continued onto a further deeper line is cannot-evaluate, exit 2, never skipped, each naming the file and line), and the PATH provenance of python3 itself; in the hook-script rule, a dynamic import (importlib.import_module, __import__), rebound os or Path names (any use of the NAME sys outside the sys rule's accepted forms is a finding, a bare call argument and a function parameter default included, but a reach that never spells sys, a ctypes or gc excavation of the module object, is not read), what a scanned sibling source itself imports (the scan is one level deep: only the modules the hook imports by name, a deeper sibling-import chain being the disclosed residual), a sibling package's submodules (import name.sub executes name/sub.py, not the scanned __init__), the isolated-path capture's import order (the shadow rule reads a list captured when the gate module is imported, so a host that embeds the gate by import after mutating sys.path changes what the shadow rule reads; run as a script under -I, the shipped form, the capture precedes any scan), a hook script launched directly through its shebang (the script path is then the command word, not a python operand, so no launcher is recognized and its bootstrap is not checked), a symlinked hook script, a hook script reached through another script it launches, and hook configuration outside the scanned files (the adopter-side template opf/enforcement/claude/settings.opf.json among them); the bootstrap credit is also conservative by design, and these working shapes are REFUSED rather than credited (disclosed over-refusals): an append under an if __name__ guard, a try/except import fallback, a sys.path.extend of the script's own directory, a sibling import under if TYPE_CHECKING:, a function-local store of a tracked directory name, a read-only use of an import-machinery sys attribute, a sys.path.append of another directory, and a relative script operand whose isolated launcher is not the first word of its hook command string; a python3 token embedded in a quoted argument or a heredoc may be miscounted.
gate:record-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/check_record_drift.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

Total over the surface it examines: an OPEN merge_pending row carrying a TYPED ref (pr:N, decision:X) whose merge or record evidence exists in the declared truth sources always fails the gate. It reads only typed structured refs; a narrative number in prose, an untyped row, or a register kept outside the declared surface is out of scope by design (D9), and drift the truth sources themselves cannot see (an unpushed local merge, a PR merged under a subject with no (#N) marker) passes. In a checkout with no orchestration registry the live leg is NOT APPLICABLE (the record is machine-local by design) and the self-test carries the assurance; that scope boundary is printed, never silent.
gate:record-sections
  • Platform: ci
  • Default: block
  • Entry point: tools/check_record_sections.py
  • Class: c
  • Channel: repository gate

Technical limits (from the enforcement ledger)

Partial over a best-effort, adopter-configured surface. It proves preservation only for column-zero H2 headings that the adopter's own regular expressions select and that are newly introduced on the branch (present at the branch tip, absent at the unique merge-base); it catches a clean section-drop but MISSES a section that was reworded, renamed, or moved (a changed heading reads as a new-and-dropped pair, not a preserved one), never compares section bodies, does not cover edits to a heading already present at merge-base, and cannot tell a valid supersession from an opt-out marker an author simply added. Any record path or heading outside the config, and any grammar the pattern does not match, is outside its surface. The config is trusted adopter input to this preservation check: rewriting it so its heading-pattern selects fewer or no headings (semantic narrowing that makes real sections stop matching) is a change to that trusted config, not a dropped section, and defending the config against adversarial narrowing is out of this gate's threat model (it is the config-integrity surface's concern). Missing or ambiguous inputs inside the declared surface (a duplicate selected heading, a heading-pattern that cannot be compiled in the working or the first parent's config, an unreadable declared record, an unresolvable ref, a config path whose final or any parent component is a symlink, a non-unique merge-base on EITHER the default or the explicit-base path, an explicit --base-ref that is not that unique merge-base, an explicit --branch-ref that disagrees with the observable second parent of a two-parent post-merge commit, a default config the merge itself deleted, an uninferable branch tip) fail closed (exit 2); an explicit --base-ref is accepted only when the merge-base is unique and the ref names it, so it cannot mask a drop by naming one of several merge-bases that disagree on a section, and when the post-merge commit has two parents an explicit --branch-ref must name the observable second parent, so it cannot mask a drop by comparing against the first parent. It also strips the ambient GIT_ environment before every git call so a redirected GIT_DIR or GIT_WORK_TREE cannot swap in a decoy repository, and it does not follow a symlinked config at the final or any parent component. Only with no adopter config present (a genuinely absent, not-a-symlink default) AND none at the pre-merge target is the gate NOT APPLICABLE by design; a config the merge removed, or one whose removal cannot be evaluated because the post-merge ref is unresolvable, fails closed instead. The schema-version-2 table leg (row preservation for declared append-only tables, keyed on declared columns, against the post-merge commit's first parent and the branch tip) carries its own residue: key-stable mode lets a row's content be gutted while its key survives; only declared tables and the stated grammar are covered (HTML tables and lists are not), each row line must start with a pipe and END with an unescaped pipe (a GFM-style row without the trailing pipe is refused, and so is a line directly after the rows that carries a pipe without starting with one, an indented row or a row without its leading pipe; a pipe-less non-blank line directly after the rows ends the table although GFM renders it as a further row, so a row placed after such a line is not tracked, and a heading or block quote carrying a pipe directly after the rows is refused, an over-deny whose in-band fix is a blank line after the table), and the grammar is textual, so a table that still parses inside an HTML comment or other non-rendering wrapper is counted even though it is not rendered; frozen rows compare by parsed value with type-aware equality (an integer never equals a boolean or a float; floats and datetimes compare by exact repr and floats by sign too, so a signed-zero substitution, a NaN sign flip, or an instant rewritten in another UTC offset is an edit while an unchanged NaN is not), and Markdown cells are trimmed of ASCII spaces and tabs only, so a cell edited in other Unicode whitespace is an edit; each config declares at most one table per file, and a second table in the same file whose header carries the same cells as the declared header (padding aside) is refused as a duplicate header, so while such a file sits on the target every gated merge is refused, an over-deny; when a key absent at the merge-base is added by both sides of a frozen table with different content, every resolution is refused (keeping either side's row trips R3 against the other), an over-deny whose in-band exit is a fresh key; archive rotation of rows to another file reads as disappearance, an over-deny, since no archive path list exists; changing a declared table's header line beyond re-padding is refused while the first parent's declaration is in force, an over-deny for column renames or additions; a carriage return in a header-matching line of any compared snapshot fails closed (the CR-stripped candidate is formed before the leading-pipe test, so a CR-prefixed header line is caught too), an over-deny when the blocked change would have been benign line-ending normalization; a declared table that cannot be located in an existing file at the first parent or the branch tip fails closed when that snapshot's own config declared that same table (same path, format, and header cells or array name, so a declaration of another table at the same path does not count; at the branch tip a declaration in the merge-base's config counts too, so a branch that renamed or removed a table both sides had declared fails closed rather than shedding the branch's own row obligations, while a branch forked before the table was declared still reads as not-yet unless a declaration at the same path was withdrawn on the pre-merge target's first-parent history between the merge-base (judged against its own first parent, so a withdrawal commit the branch synced into itself, making it the merge-base, is included) and the target, whatever the withdrawn locator (the checked declaration's own, later re-declared verbatim, and one the target's config declares again both included), so a branch whose merge-base is the withdrawal step or predates it cannot shed rows it left at the old path (a stub, a copy, or a row added in place under the old locator), while a merge-base strictly after the withdrawal step leaves the withdrawal outside that span and such a leftover still reads as not-yet, its rows outside the gate like rows added while the declaration was withdrawn, and a merge-base unreachable along the target's first parents leaves the span unevaluable, exit 2; only an absent file the absent-file refusal that follows does not catch, or an existing file whose applicable configs had not yet declared the table and whose path carries no withdrawal in that evaluable span, reads as the table not existing yet), an over-deny when the unlocatable form was benign, the leftover carried no rows the branch added, the target's config declares the identical table again, or no withdrawal in fact happened where the span could not be evaluated; a declared table's file absent at the branch tip while it existed at the merge-base fails closed too when the merge-base's or the pre-merge target's config declares a table at that path, a decision keyed on the path alone whatever header cells, TOML array name or format the declarations carry, reading no history, so while either of those configs declares a table at the path, a withdrawal and a later re-declaration under a changed locator, however each step landed (directly on the target or through gated merges), does not make the absence read as the table not yet existing, and a branch forked before those steps that moved the declared file cannot shed the rows it added under the new path by deleting the old file, while when neither config declares a table at the path (the re-declaration arriving only in the merge's own working config while the branch's merge-base sits inside the withdrawal window) the absence still reads as the file genuinely absent, a disclosed asymmetry against the leftover forms, bounded because rows re-added under another path are invisible to this declaration either way; the path-keyed absent-file decision and that withdrawn-locator span rule together over-refuse these sequences, each exit 2: a branch that moved or deleted the declared file, or left any file at its path, and merges in after the declaration at that path was withdrawn and re-declared (under a changed header, TOML array name or format, or verbatim under the identical locator), when its merge-base is the withdrawal step or predates it (the deleted-file form also needing the merge-base's or the pre-merge target's config to declare a table at the path); a branch that moved or deleted the file while no table was declared at its path (before any was, or during a withdrawal window), merging in after the pre-merge target declared or re-declared one there; a merge whose working config re-declares a table at a path the merge-base's config declared, or at a path whose declaration was withdrawn in that span, while integrating a branch that moved, deleted or left any file at the old path (its exit: merge that branch while the declaration at the path is withdrawn, then re-declare in a later commit); and a gated merge whose merge-base is not reachable along the pre-merge target's first parents while a declared table is unlocatable at the branch tip with no applicable snapshot declaration; it is an over-deny too for a branch-side deletion of the declared file with no replacement or a move the branch added no rows to (a deletion cannot be told from a move whose branch rows would be shed); the in-band exit is to withdraw or narrow the declaration at that path in an earlier, separate commit, land that withdrawal on the target (directly or through its own gated merge) before the commit or merge that moves or removes the file, and then move or remove the file, directly or through a gated merge, or to restore the declared table at the path on the branch tip (merging the target in or rebasing) before the gated merge (a branch that carries both the withdrawal and the removal into one gated merge is still refused, because the pre-merge target's config still declares the table, which keeps the absent-file check running for that path, and the merge-base's config declares a table there), never a single commit on the target removing the file and its declaration together (the first parent's declaration is still in force there and fails the removal), and while the declaration is withdrawn, rows that in-flight branches add to the old path are outside the gate; a branch forked before the file existed, or a deleted file at a path neither the merge-base's nor the pre-merge target's config declares a table at (as when the declaration is adopted by the very merge under gate), still passes; the config stays trusted input, and reading the first parent's config closes declaration narrowing within the same merge (a schema downgrade included, with the first-parent config validated by the same parser and shape checks as the working config, so any invalid first-parent config fails closed) but not narrowing landed in an earlier, separate commit; declarations come from the working config and the first parent's config only (the branch tip's config, the merge-base's, and the configs along the pre-merge target's first-parent span are read solely to bound the unlocatable-table and absent-file refusals, never to add obligations, the absent-file bound also counting the first parent's own declarations by path, and an invalid config read for any of those purposes fails closed), so a resolver can drop a table declaration added only on the branch together with that table's rows; with a --config outside the repository root there is no in-repo config at any commit, so the first-parent declaration read and both branch-tip refusals never apply there and declarations come from the working config alone, a state only a direct run() caller can reach, since the command line refuses an out-of-root --config; a single-parent post-merge commit is compared only against its first parent, which checks a squash endpoint to endpoint but checks a multi-commit rebase or fast-forward integration only across its final commit, so a drop in an earlier commit of such an integration passes unless the gate runs per commit or on the two-parent merge; a pull-request check evaluates the test merge, so if the target moves afterwards the live run on the target is still needed; the gate detects drops, it does not prevent the resolver from running; and it does not prove that a row's claim is true, which stays the semantic burden of the completeness rules the records serve. Pack CI exercises only the synthetic self-test while the adopter owns the live post-merge invocation.
gate:reference-roster-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_reference_facts.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_reference_facts.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:release-cut
  • Platform: ci
  • Default: block
  • Entry point: tools/check_release_cut.py
  • Class: c
  • Channel: repository gate

Technical limits (from the enforcement ledger)

Checks declared root-changelog release transitions, evaluating HEAD, index and working-file snapshots separately. For pull_request events or when GITHUB_BASE_REF is set, requires that variable to name a resolvable origin tracking branch and a merge HEAD whose raw first parent equals that tracking tip; comparison uses that first parent with object substitution disabled. Missing bindings, non-merge HEADs and mismatched tips fail closed (exit 2). In GitHub Actions only pull_request and push events are supported; push requires --protected equal to refs/remotes/origin/GITHUB_REF_NAME (origin/BRANCH is normalized) and --base equal to the workflow-supplied PUSH_BEFORE value, with the event naming a branch and HEAD equal to its protected tracking tip. The full before OID must precede HEAD on its raw first-parent chain; every transition in that range is evaluated separately, including its declaration. Empty ranges and non-first-parent bases fail closed. An all-zero before is accepted only for a proven root HEAD; new branches with pre-existing history are refused. Every local path requires --protected (a full ref or origin/BRANCH) to resolve to the same commit as origin/HEAD's target, and omitting it selects that target; a local --base must equal the unique merge base of HEAD and that protected tip and then enables the same first-parent range check. A merge base equal to HEAD with a different target is refused; without --base the local tip path checks only the last first-parent transition, and a local --base at the tip is an empty range that fails closed; local runs off the tip without --base, and PR runs, compare endpoints only, so intermediate commits between the base and HEAD are not evaluated. Explicit refs must pass check-ref-format and resolve; origin/HEAD itself is refused as ambiguous. PR runs reject both overrides. Full event-payload authentication, the authority of the workflow environment and of local origin/HEAD configuration, and remote freshness remain outside coverage. Execution-report emission and tools/selftest_checks.toml registration remain deferred; wrapper coverage is not claimed. A matching declaration is author-controlled and does not prove authorization, readiness or semantic release scope. Other version sources, intermediate transitions inside a squash, atomicity of intermediate commits and freshness of unfetched remote refs remain outside coverage. Snapshots are sampled separately, so callers must exclude concurrent writers. Committed and index reads bypass checkout filters; transformed working files can cause refusal. Input-path submodules and symlinks are refused rather than traversed. Safe working-file traversal requires POSIX descriptor-relative no-follow operations. An optional .aiqt/pending-fixes.toml record is sampled the same way and refuses the cut (exit 1) while any item it lists is open, naming each open item; a cut that removes a record still listing open items is refused the same way (exit 1), naming them. A present record that is malformed or a symlink fails closed (exit 2) on every transition, so a malformed base record also blocks the very change that repairs it; the record's release binding is checked only on a cut, where a record bound to another release fails closed (exit 2), and a change that cuts nothing does not read the binding at all. Each id, rationale and target must hold a non-whitespace character; a blank or whitespace-only value fails closed (exit 2). The list is hand-kept and author-controlled, so it shows currency for the items it names, never completeness of what is wrong; when neither snapshot holds a record there is no pending-fix check at all, editing an item to done passes, and a deferred item passes on its recorded rationale and target, whose adequacy is not judged; apart from open items in a record the cut removes and a base record that cannot be parsed, the base record's contents do not affect the verdict. This repository holds no pending-fixes record today, so the pending-fix refusal can fire only after one is committed. Enforcement depends on trusted executable provenance, gate code, repository configuration and protected-branch review.
gate:relied-exit-pipes
  • Platform: ci
  • Default: block
  • Entry point: tools/check_relied_exit_pipes.py
  • Class: c
  • Channel: repository gate

Technical limits (from the enforcement ledger)

An adopter-parameterized scan that flags an adopter-declared RELIED-ON command (a producer whose own termination status is the evidence later steps rest on), at top level or inside a $( ) or backquote substitution body in a declared script surface, wherever the construct around it does not propagate that status, as three separate findings: PIPE, the command in a NON-FINAL stage of a shell pipeline, whose status the pipe discards because it reports the last stage's; LAUNCHER, the command behind setsid without a wait option or behind systemd-run without a wait-claiming option, where the launcher can report its own status before the command finishes, or behind systemd-run with --ignore-failure, which ignores the command's exit status; and BACKGROUND, the command anywhere in an AND-OR list ended by a trailing &, the list read to its own end at its own nesting depth (a command before a later subshell, brace group, if or case part of that list, and a command inside an if or case the & ends, included), a nohup before it included, where the shell reports the launch and not the command's exit (gatdis: a verdict-bearing exit is taken only from a construct that faithfully propagates it). It ships NO command, subcommand or path of its own: with no .aiqt/relied-exit-pipes.toml it is NOT APPLICABLE (only a CONFIRMED absence: a configuration path that cannot be inspected, such as one under an unreadable parent directory, fails closed instead), and a present but unusable or non-regular configuration (a FIFO is refused before open, never read) fails closed (exit 2), as does a declared selector matching nothing in its surface, a selector with an empty, dot or dot-dot component or containing a NUL character (named as malformed, never a traceback), a symlink anywhere in a selector's resolution (the surface file, an ancestor directory, or any directory or file met during a directory walk, refused rather than silently skipped), a .git or __pycache__ entry met during a directory walk, whether a directory, a symlink or a regular file (a declared subtree is scanned whole, never pruned), a non-regular or non-UTF-8 surface, a declared command inside or after shell it cannot parse (an unquoted heredoc, a here-string, a process substitution, an unterminated construct; the refusal covers the file's remainder), a lexed segment whose offset in the file cannot be proven (never guessed; no known input reaches this through the shared lexer, so it guards against a lexer change), a declared command, plain, path-qualified or quote-split, inside the raw text of an sh -c, bash -c, dash -c, zsh -c, ksh -c or eval argument (clustered options such as -ec included), among the argument words of xargs, flock or find (so a find -name operand spelling the declared name refuses too, an over-refusal, disclosed), beside a coproc, possibly hidden behind a wrapper's option words (ANY option word after env, nice, ionice, timeout, time, command, exec, nohup, stdbuf or sudo, attached or separate, so command -v, time -p and ionice -c are refused too, an over-refusal, disclosed), a declared command whose subcommand sits behind option words, a declared NAME inside a subshell (one inside a case arm included), brace group, while/until/for/select loop or function body, including a one-line function NAME { ... } definition and a non-brace body such as f() if ...; fi; a function definition whose body is not a compound form the gate models (a conditional or arithmetic command body) refuses in any file that mentions a declared command, and a subshell body is consumed at the definition and judged by the subshell rule, so the pending definition never leaks onto a later block (identity is read from the shell-dequoted words and, QUOTE-TOLERANTLY, from the unmasked raw text, so a quoted, split (to""ol) or escaped spelling and a command visible only inside $( ) still refuse; the name counts even where it is only another command's argument data, an over-refusal, disclosed; and a quoted reserved word such as a quoted select or if is read PER TOKEN as a command name, never as syntax, so a plain same-spelled argument elsewhere in the segment changes nothing; a token the per-token walk cannot align, past a redirection or an undecoded construct, is read as a command name too, never resolved through another same-spelled word, while a FIRST token the walk cannot decode at a command start refuses instead, exit 2, for an executing segment with a declared command in reach (an over-refusal, disclosed: bash reads a genuinely quoted first token as an ordinary command name); and a backslash-newline is removed before reading, exactly as bash removes it before word splitting, inside double quotes included, so a continued keyword is still that keyword and a continued quoted word is still one command name), and a grouped or compound pipeline producer (a subshell, brace group or loop piped onward) in a file that mentions a declared command, even when the group itself holds none (an over-refusal, disclosed). A declared command also refuses (exit 2) behind a setsid option it cannot classify (any letter outside c, f and w, -h, -V, --help, --version, a long name that prefixes none of ctty, fork and wait, a value attached with =, or a word it cannot read as plain), behind a waiting setsid (-w alone or clustered with -c or -f, or --wait or a prefix of it), because whether setsid -w passes on the command's exit is not a verified premise of this gate (no observation of it is recorded), behind a systemd-run option outside the closed table read from systemd 259's --help (a long option is matched only when spelled in full, so a prefix refuses), where a systemd-run option value word spells the declared name, and behind a wait-claiming systemd-run option (--wait, --scope, -P/--pipe, -t/--pty, -T/--pty-late, -S/--shell), because whether systemd-run passes on the command's exit in those modes is not a verified premise of this gate. A subshell or brace group ended by & stays cannot-evaluate under the subshell and group rule, inside a case arm too: there a pattern's parens open no subshell (an optional ( at its start opens nothing, an extglob group glued after ?, *, +, @ or ! nests its own parens, and only the ) at nesting zero ends the pattern), and the arm's ( and ) open and close subshells. A word in a case's subject or pattern position, a reserved word or function included (fi), if), done), x|fi), (esac), function), x|function)), is pattern text and never opens or closes a block or starts a function definition (a word before a ( in a pattern, as in @(a(b))), names no function); a | in a pattern, inside an extglob group or between groups (@(a|b)|*(c))), is alternation, never a pipe; only a plain esac at a pattern's start closes the case. A pattern word spelling a declared name still counts as a relied stage of its list, so a case ended by & with such a pattern gives a BACKGROUND finding though the pattern runs nothing (an over-report, disclosed). A declared name inside a $( ) or backquote substitution in an AND-OR list ended by & is cannot-evaluate, and so is a closing word or ) with no open construct (in a case arm, a ) with no subshell of the arm open, or an arm or case ended with one still open), or a case header or pattern this gate cannot read (a ( in a pattern, outside every extglob group, that is neither its optional opening paren nor glued after an extglob character, which bash refuses (inside a group any ( nests), or a separator inside a pattern), while a relied stage, or a declared name hidden in a substitution, is still pending in its list, because the list's end cannot be placed. One exemption clears a flagged pipeline (a PIPE finding only; it never clears a LAUNCHER or BACKGROUND finding), credited only for a closed form read with certainty. Pipefail is credited only through a closed whole-file LINE GRAMMAR: every line of the file must be blank, a comment, a standalone column-1 enabling set command spelled entirely from bash's own option-letter and set -o name vocabularies (an unknown letter or name, as in set -Zo pipefail or set -o nonexistent -o pipefail, makes bash reject the set command and run on with pipefail OFF, so it earns nothing, and a posix name earns nothing either: posix mode turns alias expansion on, state the grammar does not model), a plain assignment (never to a variable that is a view of the shell's own bindings or startup hooks, or that turns posix mode and with it alias expansion on: BASH_ALIASES, BASH_CMDS, BASH_ENV, SHELLOPTS, BASHOPTS, POSIXLY_CORRECT), or a plain simple-command line whose every command word is a literal name spelled plain in its own token (a quoted or escaped command word withdraws) that is neither in a refused set (set, eval, source, the dot command, builtin, command, exec, alias, unalias, enable, trap, function, sh/bash/dash/zsh/ksh, time, coproc and every reserved word, judged on the shell-DEQUOTED words, so sour''ce, "set" and e\val still withdraw) nor ANY bash builtin outside a small modelled allowlist (true, false, echo, pwd, test, times, type: every other builtin, printf, read, declare, mapfile and readarray among them, can write options, aliases, bindings or variables, or run callback text the gate never parses; shopt is admitted only as shopt -s with every name from a closed roster of glob-steering options, so expand_aliases and lastpipe withdraw; a builtin a future bash adds would read as external, the disclosed bound of the roster), carrying no '$', backquote, escape, parenthesis, brace, redirection or trailing comment; every mention of pipefail must itself sit on an enable line; ANY line outside the grammar withdraws the credit for the WHOLE file, so files that keep control flow (if/case/loops), redirections, expansions or trailing comments beside their enable are refused toward a finding (an over-refusal, disclosed); the enable must additionally be a top-level statement before the pipeline, never a pipeline stage, backgrounded, or an arm of && or || (a left-arm enable such as set -o pipefail && cmd is refused too: the line is not standalone). RESIDUAL, stated exactly: the gate reads only the file, so inherited environment the file does not show, BASH_ENV, an exported shell function shadowing a command word, and a parent shell's options such as SHELLOPTS or BASHOPTS, which can arrive with alias expansion already on, is not modelled. A PIPESTATUS read is NEVER credited: a relied-on command whose exit a pipe discards is flagged whatever the file reads from PIPESTATUS afterwards, a save such as rc=${PIPESTATUS[0]} on the very next line included, because bash can skip that save or change what runs before it (a DEBUG trap under extdebug, an aliased or redefined word, text sourced from a file the gate never reads), and modelling which files may keep the read kept meeting new spellings that skip it. The remedies the finding names are set -o pipefail in effect for that pipeline, or running the relied-on command outside the pipeline with its own status saved (its output redirected to a file, rc=$? on its own line, and the former sink reading that file afterwards). This over-refuses, disclosed: an ordinary script that saves PIPESTATUS correctly right after the pipeline is flagged until it adopts one of those remedies, and under the pipefail remedy the save line has to go too, because its $ takes the line outside the closed pipefail grammar and withdraws the pipefail credit for the whole file. VERIFIED PREMISES: bash 5.3 was observed propagating an enabled pipefail into $( ) and backquote command substitutions, so the pipefail exemption extends into substitution bodies; dash lists pipefail in its option table only from release 0.5.12 (observed in that release's binary; POSIX Issue 8 adds pipefail), and an older dash rejects the set line itself as an illegal option, failing the script there rather than running on with pipefail silently off. It covers a recognizable SUBSET (class c, partial), residuals disclosed, not chased: which shell actually runs a file is not proven; pipefail inherited from BASH_ENV, SHELLOPTS, a sourced file, the caller's bash -o pipefail or shopt -so is not credited, so the gate can over-fire there (the fix is a spelled-out top-level set -o pipefail); a conditional enable, including the portable (set -o pipefail) 2>/dev/null && set -o pipefail idiom, is likewise not credited and over-fires; a function, alias or variable that renames the tool, an executor or launcher this gate does not list (taskset, chrt, unshare, doas, strace and ssh among them, and detachers such as daemonize, start-stop-daemon, screen, tmux, at or a command that detaches internally), and a relied-on command the adopter did not declare, slip it; a launcher whose command word is not a literal (named through a variable such as $S, ${S} or a double-quoted $S, a $( ) or backquote substitution, or a glob such as /usr/bin/setsi?) is not read as a launcher, so a declared command behind it exits 0 with no finding and no refusal; a setsid without a wait option that does not in fact fork, because its caller does not make it a process-group leader, passes the status on and is still flagged (an over-fire, disclosed; running the command directly is the remedy); a declared command backgrounded and later collected with wait or $! is flagged (an over-refusal, disclosed: a later read earns no credit, as with PIPESTATUS); a deliberately detached command whose result is collected through its own record belongs in an undeclared helper script; status masking that is not a pipe, a non-waiting launcher or a trailing & (an always-succeeding trailer such as true or :, or a status-printing echo of the prior exit) is outside this gate: gatdis states it as rule text, and no hook applies this gate's declared-command judgement to it; interactive tool calls are outside this CI gate; contexts where set -e is suppressed (conditions, AND/OR lists) are not modelled; a tool that refuses work but exits 0 is invisible to any exit reader; and non-shell surfaces (Python shell=True, Makefiles, YAML run blocks) are outside it.
gate:renderers-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_renderers.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the renderer/generator declaration rendered from each adapter generator's statically parsed RENDERER_DECL and GENSRC_OUTPUTS plus its computed pack-local import closure and framed code digest. It proves the declaration matches the code as bytes; it does not judge whether a semantics-revision bump is honest, and the obligation-altering versus byte-only classification of a renderer change remains a human disposition at the release delta gate. The closure is computed from statically parsed imports, so a truly dynamic pack-local import is out of scope and disclosed at the tool.
gate:roadmap-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_roadmap.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_roadmap.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:rules-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_rules.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_rules.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:secret-patterns-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_secret_patterns.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_secret_patterns.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:secrets-scan
  • Platform: ci
  • Default: block
  • Entry point: tools/check_secrets.py
  • Class: c
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A pattern scanner for hardcoded credentials: it catches recognizable provider-token prefixes (now including Google, Stripe, GitLab, SendGrid, npm, PyPI upload, and Slack webhook shapes), assignments of a literal to a credential-named variable, and high-entropy literals of 40 or more characters assigned to a generic credential-like name (Shannon entropy at least 3.5 bits per character judged on the token's first 150 characters, letters and digits required, placeholders and environment lookups excluded, metadata-named variables excluded). It still misses a keyword-free high-entropy string (the opaque-name class, R1, the measured residue of 2026-08-08 shared with gitleaks), a generic-keyword value of 12 to 39 characters that gitleaks can flag below the local 40-character floor (R3, a deliberate noise-control choice, confirmed against pinned gitleaks 8.30.1), a real secret held under a metadata-named variable such as public_key or key_path (excluded by design in the safe direction, where CI still catches it, R4); a deferred provider family (a Twilio-class short-prefix-over-hex or a Telegram bot token, left to gitleaks, R2); a secret split across lines or assembled by concatenation, an encoded secret, or a secret in a binary (R5); and an unquoted value that begins with an environment-lookup root such as process.env., treated as a reference not a literal (R6). What it reads: a file is scanned only when its name is a dotenv name (.env, or starting .env., in any case), its suffix is on the curated text allow-list, or it has no suffix, and only outside the .git, node_modules, __pycache__ and .working directories and other than the scanner source itself; any other file (an image, a binary key store, a text format off the allow-list) is never read. A dotenv or allow-listed file that is not valid UTF-8 is still scanned, decoded losslessly with each undecodable byte kept as a placeholder that is neither a word character nor a line break, so an ASCII secret beside a single stray byte is still caught; two or more stray non-ASCII bytes that together form a valid UTF-8 letter decode as that letter, so beside a token they break its word boundary and can hide it (the same word-boundary behaviour as in valid UTF-8 text). A scanned file that starts with a UTF-32 byte-order mark is ALSO scanned through a UTF-32 wide view beside the lossless byte view (the decode keeps an unpaired surrogate via surrogatepass; a scalar above U+10FFFF or a truncated final unit falls back to a replace decode whose U+FFFD placeholders are neither word characters nor line breaks, so a token before or after a four-byte-aligned bad unit is still matched), and a file that starts with a UTF-16 byte-order mark and has even length is ALSO scanned as UTF-16 (surrogatepass; a file whose first four bytes form the little-endian UTF-32 mark, which begins with the UTF-16-LE mark, keeps the UTF-16 reading as a further view beside the UTF-32 and byte views when its UTF-32 surrogatepass decode fails and its length is even), the views merged, so a byte-order-mark-prefixed file that actually holds 8-bit or ASCII text (almost any even-length byte string decodes as UTF-16, so a successful decode alone proves little) keeps the lossless-view coverage it had before the wide views. The merge drops duplicates per reported line and label, never per secret: two DIFFERENT tokens that share a path, view-relative line number and label (one per view) are reported as ONE finding, so the FAIL total counts reported findings, not an inventory of distinct credentials. UTF-16 or UTF-32 without a byte-order mark and other non-UTF-8 encodings are not decoded as such, so a secret in them can be missed; a byte-order mark in the middle of a file selects no wide view; mixed-width content behind one leading mark is decoded at that mark width only (the overlapping little-endian UTF-32/UTF-16 mark case is the one kept mixed reading); a declared-text odd-length UTF-16-marked file keeps only the lossless byte view, where the NULs of a real UTF-16 secret defeat the patterns; an odd-length file that begins with the overlapping little-endian UTF-32 mark keeps no UTF-16 reading at all (its wide view is the UTF-32 replace decode), so a UTF-16 secret in it can be missed, and a suffixless one of that shape is still decoded and scanned rather than named on a SKIP line; and stray bytes in the middle of a UTF-32 file misalign every later unit, so only a truncated final unit is recovered. Otherwise only a suffixless file that is not valid UTF-8 and took no wide view (no UTF-32 mark; a UTF-16 mark with odd length) is treated as binary and skipped, named on a SKIP line, so an extensionless key file carrying one stray byte and no byte-order mark is skipped too. Detection here is a compensating control, never a substitute for keeping the secret out of the repository in the first place.
gate:selftest-execution
  • Platform: ci
  • Default: block
  • Entry point: tools/check_selftest_execution.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

Total over its declared surface, in three layers. Layer one, STATIC pre-launch: every check() call site in the registered runner's source is resolved to literal ids and that set must equal the hand-authored expectation manifest tools/selftest_checks.toml exactly before any child is launched; it resolves only direct bare-name check() call sites with string-literal or for-loop-literal ids (the loop attribution is the innermost enclosing for, lexical only: it does not model any intervening dataflow between the loop header and the call site, a rebinding of the loop variable or a nested function boundary, so such a site can mis-attribute its ids, caught, if at all, by the runtime set reconcile), and a non-call reference to the bare name check, a comprehension-bound id Name, any other unresolvable or duplicate source id, and source that does not parse are refused fail-closed, never silently resolved, while two static blind spots remain, bounded the same way: an invocation route the static layer never sees a call site for because it never names check as a bare Name in Load context (getattr, exec, an import that binds a different name and is then called through it, or a globals()['check'] lookup), and a rebinding of the name check the static layer cannot detect (any Store-class or non-Load binding: an assignment, a with-as or except-as target, a nested def check or class check, an import check, a del check, or a global or nonlocal declaration), which leaves the direct call sites visible and counted while they may no longer invoke the instrumented helper; both are caught, if at all, by the runtime set reconcile, a registered id whose only route is invisible (no direct call site at all) is refused by the static layer pre-launch as a registered id with no source check(), and the dead-direct-site decoy (a route that re-executes an already-registered id whose direct call site is dead) passes both set layers and is bounded only by the non-discrimination residual (a constant-true check counts as executed). Layer two, RUNTIME: the executed check-id set recorded at the instrumented check() choke point is emitted as a strict structured report and reconciled exactly against the same manifest, judged with the child's real exit code; missing, extra, or duplicate ids, a missing, malformed, wrong-suite, or non-regular report, a malformed manifest, and every cannot-evaluate fail closed, and a child exit 0 never overrides a missing or mismatched report. Layer three: the child carries its own in-run self-guard, reconciling the same set even on a direct developer run of an intact runner; the self-guard sits behind the runner's module entry, so it cannot fire once that entry is gone (the runner then exits 0 with no output and no report on a direct run, which this gate reports as cannot-evaluate only when it launches the runner itself), and the entry-guard gate holds that entry in place for every registered runner. The child suite's own behavioural assurance (the orchestrator-integrity acceptance vectors over hermetic fixtures, judged on structured verdicts) rides the child's verdict, which this gate requires; the gate launches that child under a git-neutral environment built in two layers (HOME and XDG_CONFIG_HOME pinned to a fresh empty directory, so the per-user global config, ignore, and attributes surfaces are empty for every descendant git call, including one made after a descendant scrubs the GIT_* environment and drops the GIT_* pins, plus, for the immediate child, ambient GIT_* dropped, the global and system config files pinned to os.devnull with GIT_CONFIG_NOSYSTEM, and core.excludesFile and core.attributesFile overridden to os.devnull by injected entries), and drops every ambient PYTHON*-prefixed variable from the child environment, transitively neutralizing PYTHONPATH/sitecustomize module injection into descendant interpreters (nothing downstream re-derives or re-sets a PYTHON* variable, and the runner's own interpreter grandchild launches carry -I -B as defence in depth for a direct developer run), which hardens the GATE-launched child only, the runner's own direct-invocation hermeticity being a separately tracked item; three launch-environment bounds are disclosed: the system-wide git config ($(prefix)/etc/gitconfig) is neutralized only for the immediate child (GIT_CONFIG_NOSYSTEM and the GIT_CONFIG_SYSTEM pin are themselves GIT_-prefixed, so a scrubbing descendant drops them) and the system-wide attributes file ($(prefix)/etc/gitattributes) is not neutralized at all (no GIT_ATTR_NOSYSTEM is injected, and a scrubbing descendant would drop it the same way), a hostile system-wide file being root-owned, the same trust tier as the git binary itself and so at the threat-model boundary, outside what this gate defends, and git is resolved through the ambient PATH, a trusted-toolchain concern outside this gate's threat model, and the environment drop neutralizes the PYTHON*-prefixed family only, a loader-level injection control such as LD_PRELOAD and the interpreter binary itself sitting at the same trusted-toolchain tier, outside this gate's threat model. It proves SET IDENTITY AND EXECUTION only: it does not prove an invoked assertion is discriminating (a constant-true check counts as executed), carries no mutation sensitivity, sees nothing outside the instrumented check() helper (an assertion made by a direct FAILURES.append outside the choke point, whose non-execution is invisible to the execution set, a direct EXECUTED.append or _EXECUTED_SET.add that manufactures execution evidence for a check that never really ran, or a suite not registered), and cannot distinguish a legitimate from an illegitimate coordinated deletion of a check and its manifest row in one reviewed change. The static scan and the launch read the runner's source at two moments, so a concurrent same-user writer between them is outside this repo's sole-orchestrator threat model (the runtime layer still reconciles what actually ran); repo-root confirmation is a .git-existence proxy anchored to the gate's own file, not a full git-identity check; and the expectation manifest is trusted hand-authored input whose schema is machine-validated but whose completeness rests on source review. A row may carry an optional args list of literal arguments, forwarded to the runner ahead of the gate's own --execution-report pair; each must be a string of at most 200 characters from a closed printable-ASCII set (a letter or digit first, or one or two dashes and then one), and an argument that names, abbreviates, or attaches a value to --execution-report is refused, while any other shape is refused too. The filter is lexical: no row can redirect the report through the named option, and nothing more is claimed about what an accepted argument means to a given runner; a runner that writes no report (one that stops parsing, exits early, or takes the rest of its argv as data) is a missing report, cannot-evaluate, never a pass. Rows whose runners resolve to one path form a group: before any launch, every row of the requested suite's group must carry args no other row of the group carries, the rows' expected sets must be pairwise disjoint, and their union must equal the runner's static check() set exactly; a gap, an overlap, an extra id, or a missing or repeated args list is cannot-evaluate, and a single-row runner is held to the whole set exactly as before. Membership resolves every row's runner strictly, so a missing path, a non-directory component, a symlink loop or a permission error in any row's runner is cannot-evaluate before any report directory is allocated or any runner launched, never an assumed non-member, a runner path carrying a control or format character (C0, DEL, C1, the bidi controls, U+2028 and U+2029) is refused by the schema, and a suite id outside the closed suite-id grammar (a letter or digit first, then letters, digits, '.', '_' and '-' only) is refused by name, so a space-joined launch identity can never stand for a registered suite. Before any launch, every registered row must also have its launch line (check_selftest_execution.py --suite <id>) in both tools/run_all_checks.sh and .github/workflows/quality.yml, each read as a regular file through the parity gate's validated loader; a missing, non-regular, unreadable or loader-rejected roster, or a row whose launch line is missing from at least one of the two, is cannot-evaluate; a roster member counts as a launch line only when it is exactly check_selftest_execution.py --suite <id>, the one argv shape the gate's command line accepts, so a member carrying any further argument, or a near-miss id, launches nothing. That check is lexical: it proves each launch line is present, not that it runs or passes. The group rule is checked for the requested suite's group only, once per invocation, and launches that one row, so that every other row is launched rests on the lexical launch-line check; nor does the group rule prove that the runner's handling of its args selects the declared subset other than through the runtime reconcile of the row actually launched; a runner reached through a hard link or a byte copy under another path forms its own group, held alone to the whole static set, so an undetected group member can only be refused, never pass with a subset. Registered surface: the suites registered in tools/selftest_checks.toml, the one authority for which suites exist; this residue does not list them, so no list here can drift from that registry.
gate:skill-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_skill.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_skill.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
gate:worker-pack-drift
  • Platform: ci
  • Default: block
  • Entry point: tools/gen_worker_pack.py
  • Class: a
  • Channel: repository gate

Technical limits (from the enforcement ledger)

A byte-identity drift gate over the artefact tools/gen_worker_pack.py generates from its declared source: it fails when the generated target differs from a fresh regeneration. It guards the generated artefact against a hand-edit or a stale source landing apart from it; it does not judge the semantic correctness of the source or of the generator, and it covers only the targets that generator declares.
hook:abs-paths
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Read|Write|Edit|MultiEdit|NotebookEdit|Glob|Grep
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

Covers the required-absolute file_path of Read, Write, and Edit and the required-absolute notebook_path of NotebookEdit (absolute by each tool's own contract, so the relative-to-a-named-root carve-out never applies to them), plus the OPTIONAL path search root of Glob and Grep (which should be absolute), and the MultiEdit file_path kept for LEGACY COMPATIBILITY. Each field name is bound to a Claude Code tool schema (Read/Write/Edit -> file_path, NotebookEdit -> notebook_path, Glob/Grep -> optional path); MultiEdit -> file_path is retained as a legacy-compatibility wire (MultiEdit is NOT in the current Claude Code built-in tool index, so its mapping is kept only so an adopter on an older or re-enabled MultiEdit is still covered, never an assertion that MultiEdit is a current tool). This STRUCTURED coverage binds ONLY those enumerated tool schemas: a path carried in any OTHER tool field, tool, or parameter, or in Bash command text, is out of scope here (the sibling abs-paths-bash floor covers only a cd/pushd destination and a redirect target in Bash, nothing else). Absoluteness is judged by the runtime's own path semantics (PurePosixPath/PureWindowsPath.is_absolute), so a drive-RELATIVE 'C:file', a bare 'C:', and a rooted-but-driveless leading-backslash path are correctly NOT absolute, while a POSIX '/x', a drive-absolute 'C:\x'/'C:/x', and a UNC '\\server\share' are; a '~'-prefixed path is not expanded and is not absolute. Honours the carve-out: the relative search PATTERN of Glob and Grep is legitimately relative to its named root and is never judged, and a rootless call (no path) names the current directory as that root and is allowed. Fails CLOSED: a tool_input that is not a readable mapping, or a required file_path/notebook_path that is absent or not a non-empty string, denies rather than passing (closing the earlier fail-open where a malformed tool_input collapsed to an empty mapping and took the search-root allow branch). Does NOT judge whether an absolute path points at the INTENDED target (absoluteness is not intent), and is platform=claude-code only. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took under 0.01 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:abs-paths-bash
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

A SEPARATE best-effort Bash layer of abspth, distinct from the structured typed-path guard (abs-paths), whose structured coverage binds only the enumerated tool schemas and never Bash command text. This is a CONSERVATIVE FLOOR that NEVER asks and, with ONE exception, NEVER denies: it ALLOWS-WITH-AN-INFORMATIONAL-NOTE (NO-ASK posture; the user's own permission flow still governs, and every ASK/ASKS below is the retired term for that allow-with-note outcome, since absolute paths are a convention, not a hazard). The ONE DENY exception (a review finding): a DESTRUCTIVE TRUNCATING redirect (a '>'/'>|'/'&>' or a '>&'-to-a-file) whose TARGET is RELATIVE or OPAQUE DENIES-and-educates, because truncation ZEROES its target and a cwd-dependent relative or unverified target could destroy the WRONG file; an append '>>', a read '<'/'<>', and every cd/pushd DESTINATION position never deny (they note). It judges ONLY two positions that silently resolve against the current working directory, a cd/pushd DESTINATION operand and a redirection TARGET, and NEVER an arbitrary command operand (a relative path argument to any other command is deliberately NOT judged, to avoid over-firing). A relative or unresolvable such position is NOTED; an absolute one, an UNQUOTED CURRENT-USER tilde destination ('~' or '~/x', which the shell expands to $HOME so it is cwd-independent), a bare 'cd'/'pushd' and the OLDPWD 'cd -' (both only absent ANY leading inline env-assignment, see below), the real cd/pushd option flags (cd -L/-P/-e/-@, pushd -n, singly or bundled), a pushd stack rotation (+N/-N), and a numeric/'-' descriptor duplication (2>&1) allow. The tilde-ALLOW is NARROWED to the current-user forms and gated on a WHOLE-TILDE-PREFIX-UNQUOTED signal, NOT on the broader token-wide opacity flag: it is True only when the token begins with an unquoted '~' AND the ENTIRE tilde-prefix (from that '~' up to the first UNQUOTED '/', or end of word) is unquoted and unescaped, which is exactly when bash performs tilde-expansion. So a QUOTED leading tilde, EVEN with an unquoted expansion/glob/brace TAIL (the "~"/x*, '~'/x?, "~"/$V, "~/repo"* forms), is NOT tilde-expanded (the '~' is quoted, the word stays RELATIVE) and ASKS, as does a fully-quoted '~'/'~/x', an escaped '\~/...', and an empty-quote-preceded '""~/x' (bash expands only a '~' at the very start of the word). It ALSO ASKS a leading unquoted '~' whose PREFIX itself carries a quote or escape BEFORE the first unquoted '/' - '~"/x"', '~'' (empty single-quote), '~""/x', '~'/x'', '~"/"$V', and a '~' followed by an escaped '/' - because any quote or escape in the tilde-prefix disables bash expansion and the word stays RELATIVE, EVEN when the DECODED token is fully literal '~/x' with no opacity. Only a quote or expansion AFTER the prefix-closing unquoted '/' does not block expansion, so the genuine 'cd ~/$VAR' (prefix is the bare '~', the '$VAR' sits after the '/') STAYS ALLOW. A later hardening pass propagates this SAME clean-leading-tilde signal into the redirect-TARGET classification (previously the redirect parser discarded it, so a '> ~/x' target was classed opaque and ASKED, inconsistent with 'cd ~/x'): a redirect target whose whole leading tilde-prefix is clean/unquoted now ALLOWS ('> ~/x', '> ~/"x"', '> ~/$V', and the bare '> ~'), while a quoted or complex-prefix tilde target ('> "~"/x', '> ~"/x"') keeps its opacity and ASKS - exactly matching the cd tilde rule. A '~user'/'~user/x' names another account's home whose EXISTENCE the hook cannot verify (an unresolved login name leaves the word RELATIVE), so it is NOT proven absolute and ASKS (the conservative call; a '~user' cd is rare); the '~-'/'~+'/'~N' directory-stack forms likewise ASK. Only the closed option forms are treated as non-destinations, and '--' ENDS option processing, so a genuine relative directory name that begins '-'/'+' is still the destination and ASKS ('cd -- -rel', 'cd +rel', 'cd -rel'), while a lone '-' AFTER '--' is still the $OLDPWD shortcut in bash and ALLOWS ('cd -- -') and a post-'--' directory literally named '--' stays relative and ASKS ('cd -- --'); an opaque '$VAR' destination cannot be proven absolute and ASKS. ANY leading inline env-assignment PREFIXING a cd/pushd (any variable name, '=' or the append '+=' form) can redirect where the cwd-INDEPENDENT destinations land: an inline OLDPWD=/OLDPWD+= redirects the lone '-' ($OLDPWD) shortcut and an inline HOME=/HOME+= redirects the bare 'cd' ($HOME default), in each case to a value the floor cannot prove absolute (e.g. '..'). CONSERVATIVE CONSOLIDATION: rather than enumerate OLDPWD/HOME by name (which missed the '+=' append form and the bare-cd HOME default entirely - the earlier under-blocks), the floor takes the stance that the mere PRESENCE of ANY leading assignment makes those two otherwise-cwd-independent destinations ASK, so 'OLDPWD=.. cd -- -', 'OLDPWD+=.. cd -', 'HOME=.. cd', 'HOME+=.. cd', and 'HOME=.. pushd' all ASK, while a plain zero-assignment 'cd -'/'cd -- -'/bare 'cd' keeps its ALLOW. This is DELIBERATELY conservative in two disclosed ways: (a) a BENIGN leading assignment on a cwd-independent form ('FOO=x cd -', 'FOO=x cd') also ASKS, the accepted cost of not name-enumerating; and (b) an assignment token whose NAME was quoted or escaped ('"OLDPWD"=.. cd -', 'OLD"PWD"=.. cd -', 'OLDP\WD=.. cd -') is a COMMAND to bash (command-not-found; cd never runs), but its DECODED form is assignment-shaped ('OLDPWD=..'), so the floor reads it as a leading assignment and ASKS - an intentional over-fire on an assignment-shaped token bash would reject anyway, NOT chased with assignment-name quote provenance. It reuses the shared quote/redirect-aware lexer (_lex_command), so a redirect anywhere is seen and a quoted metacharacter is not miscounted; but the lexer is NOT a shell interpreter: it cannot resolve an ALIAS, a shell FUNCTION, eval, or a VARIABLE EXPANSION, so a cd or redirect reached through any of those, or whose command word is not literally cd/pushd (a wrapper, a PATH-shadowed name, an absolute /usr/bin/cd is resolved by basename but an aliased 'cd' is not), is not judged; a cd or a non-truncating redirect inside a command substitution that a double-quoted string holds, or inside a backquote substitution's text as bash reads it, is not judged either (SUBSTITUTION TEXT below reads those texts for the truncating-redirect deny only). An operand carrying an unexpanded expansion never reads as absolute unless it is literally rooted, so 'cd $DIR' ASKS while 'cd /$X' allows; a redirect target formed from an expansion is classified opaque and ASKS. A bare filename passed to an unknown command is indistinguishable from data and is not judged. Absoluteness is NOT intent: an absolute cd or redirect can still point at the wrong directory. An unparseable construct (a heredoc, here-string, process substitution, '{fd}>' redirect, or an unbalanced quote or escape) is handled by PARTIAL lex: the COMPLETE segments before it AND the positions of the still-open IN-PROGRESS segment that were fully parsed BEFORE it are still inspected, so an earlier resolvable relative cd/redirect - whether in an earlier segment ('cd rel; cat <<EOF...' notes on rel) OR the redirect target of the same in-progress segment parsed before the construct ('cat > out.txt <<EOF...' and 'cat > out.txt <(...)' DENY on the truncating out.txt) - is still judged rather than discarded. The text from the construct onward (from where the full lex stopped) is not lexed, so the DENY tier is judged there on the raw text: a truncating redirect operator ('>', '>|', '&>', or a '>&' to a file) whose target, read as one word, is not an absolute static path, an unquoted current-user tilde, a process substitution, or (for '>&') a numeric or '-' descriptor DENIES, with a message that the redirect target could not be read and a request for an absolute one, so 'cat <<<y > out.txt', 'cat <(true) > out.txt', 'exec {fd}> out.txt' and 'cat <<EOF > out.txt' deny, while 'cat <<<y > /abs/out.txt', 'cat <<<y >> out.txt' and 'cat <<<y 2>&1' allow. That raw scan reads the text WITHOUT its quoting context (it cannot tell whether a character there is quoted), so a '>' inside a here-string, inside quoted text, inside an unbalanced quote's text, or inside a comment there reads as an operator too and DENIES, a disclosed OVER-DENY ('cat <<<"a > b"', 'echo "a > out.txt' and 'cat <<<y # a > b' deny). A backslash and the character after it are passed over, as bash reads an escape outside a backquote substitution. Inside one, bash first turns each pair of backslashes into one and leaves a backslash before a '>' in place, then reads the command there (bash 5.3.9 writes out.txt for 'cat <<<y; echo' followed by a backquote substitution holding 'echo a \\\> out.txt'), so where the command holds a backquote anywhere, that text is read a second time with each odd run of three or more backslashes before a '>' cut to two and each run of two or more before a '>>' cut to one, and a truncating redirect that reading finds DENIES too. An odd run leaves a single '>' an operator at some nesting depth of backquotes, a single backslash never does, and the first reading already reads an even run as an operator; before a '>>', a run of two or more leaves the first '>' escaped and the second a truncating '>' at some depth, where the first reading reads an append (bash 5.3.9 writes 'a >' to out.txt for 'cat <<<y; echo' followed by a backquote substitution holding 'echo a \\>> out.txt', the shell seeing two backslashes). The second reading is a disclosed OVER-DENY where bash still reads that '>' as escaped or quoted or the '>>' as an append (the run quoted, outside any backquote substitution, or at a depth that leaves it so, such as five backslashes before a '>' or four before a '>>' inside one backquote substitution, or three before a '>' inside two), and where the target is glued to the closing backquote, which it reads as opaque. Like the lexer (above), neither reading models eval, bash -c or another command that parses its argument again, so a redirect bash finds only on that second parse ('eval echo a \> out.txt' writes out.txt) is not judged. At most one span is left out unread: the text from the line after the opener of the here-document the full lex stopped at up to the first line that is exactly its delimiter (leading tabs stripped for the <<- form), and only when every one of these holds: the command's text before that opener's '<<' holds no '(', ')', '{', '}', quote, backslash, '$' or backquote, so no substitution, group or quote is open at the opener; the rest of its opener line after the delimiter holds none of those either; that delimiter line exists; no line between the opener and that delimiter line starts with the delimiter text (after its leading tabs for the <<- form); and the text between holds no backslash, no '$' and no backquote at all. This rule does not model where bash ends a here-document body nested in a substitution or group (bash 5.3.9 ends one opened inside '$(...)' at a line such as 'EOF)' or 'EOF );' and runs the text after it as commands), and it does not establish that bash reads the left-out span as data; it leaves a body out only in that narrow shape. Bash treats a backslash, '$' and backquote specially in an unquoted here-document body: it removes a backslash-newline there before it looks for the delimiter line, so a delimiter split as 'EO\<newline>F' ends the body earlier than its text shows, and it expands every '$' and backquote form, '${ cmd; }', '${| cmd; }' and '$\<newline>(' included. Every other here-document body in that text (one whose opener follows any of those characters earlier in the command, as in 'x=$(cat <<EOF' or '{ cat <<EOF', one with a line before its delimiter line that starts with the delimiter text, one holding a backslash, '$' or backquote, whatever form it takes, an unterminated one, or a later one, whose opener the scan cannot tell from quoted text) is read the same way, and a truncating redirect read there DENIES under the same disclosed over-deny, also where bash reads that body as data (a '$5' or 'a\b' in it, a backslash-newline that joins a body line to the delimiter line after it, a body after a quote, brace or parenthesis earlier in the command, or a top-level body line such as 'EOF) a > b'). A cd/pushd destination and a non-truncating redirect (an append '>>', a read '<'/'<>') WITHIN or AFTER the unparseable construct are still left uninspected and ALLOWED, a disclosed residual rather than a note on every such command. A '#' whose comment reading depends on context (the shared lexer's comment rule: right after a '(', ')' or backquote, or at the start of a word inside an open parenthesis, ${...} or backquote substitution) is judged under both readings, a comment to the end of its line and word text, so a cd or redirect either reading reaches is judged (stricter for some commands). The exception is such a '#' the lexer cannot judge that way (more than four of them, one in or right after a backquote substitution, or a reading that does not lex): the lexer reads nothing past it, so a command carrying one and a '>' anywhere in its text (also read with its backslash-newlines and then its quote characters and backslashes removed) DENIES, since a truncating redirect may sit past it (an over-deny of such a command whose '>' is harmless), and one whose text names cd or pushd or carries a '<' ALLOWS WITH A NOTE, as does one whose text holds quoting that removal does not decode (an ANSI-C $'...' or locale $"..." quote, or a quote character or backslash next to a letter), in which a cd may be spelled. This guard lexes such a command twice (the full lex, then the partial one), so it can make twice the thirty-one lexes of one judgement under both readings; that bounds how many lexes it makes, not how long they take, and each lex takes time linear in the command's length. SUBSTITUTION TEXT (every path, after the segments): the shared lexer skips a command substitution that a double-quoted string holds as part of one opaque word, ending it at the first ')' or '}' (a case pattern's ')' or a here-document body line holding one included), reads an unquoted backquote substitution's text in place as if no backquote held it, and ends a quoted here-document's body only at a line that is exactly its delimiter, so truncating redirects bash runs were allowed there (bash 5.3.9 writes out.txt for each): a '>' in a '$(...)', '${ cmd; }' or backquote substitution inside double quotes, also past a case pattern's ')' or a body line holding ')' or '}' there; a '>' after an odd run of three backslashes in a backquote substitution; and a '>' after a line where bash ends a here-document early. The guard therefore reads the whole command a second time, for the DENY tier only, as this reading models bash 5.3's parser in the contexts listed here and only those, apart from the shapes it does not yet judge (NOT YET JUDGED, below): a '$(...)' or '<(...)' ends at the ')' its grammar closes it with, and a '${ cmd; }' or '${| cmd; }' at a '}' standing where a reserved word may, not at a case pattern's ')' or a '}' that is a command's argument, while inside '${ ... }' such a '}' ends it even with a word character glued after it (bash 5.3.9 prints bx for 'echo "${ echo b; }x"'); a reserved word ('case', 'esac', '{', '}', '[[' and the rest) is one only where bash accepts one, never after another word such as an assignment (bash 5.3.9 runs '}' as a command for 'echo "${ x=1 }; echo c; }"'); a backquote substitution ends at its first unescaped backquote, and its text, with the backslash pairs bash removes there taken out, is read as a command of its own; a '#' that starts a token starts a comment, the first token after '$(' included; '((' and '$((' are arithmetic when their parentheses close as '))', a test made on the text as written, so a backslash-newline that bash removes between those characters makes this reading give up there (below); a '${...}' expansion runs each substitution it holds, inside double quotes also one between single quotes there; arithmetic text is expanded as if double-quoted, so a substitution between single quotes or in a $'...' string runs there and is read, in '((...))', '$((...))' and 'for ((...))' (outside a bare subscript there), in a '${...}' subscript, offset or length, and in the subscript of a word that assigns to an array element ('a[...]=1', 'a[...]+=1', an array's '[...]=' element, a declaration builtin's argument), a backslash-newline in that word removed first, as bash removes it; a quote glued to the '}' that closes a double-quoted '${ cmd; }' or '${| cmd; }' is text of the outer string; and a here-document body ends at its delimiter line or, while bash parses a '$(...)' or '<(...)', at a line that STARTS with the delimiter (after leading tabs for the <<- form) and has a ')' later on that line, and inside a '${ cmd; }' or '${| cmd; }' one with a '}', after which bash reads the rest of that line as commands (bash 5.3.9 writes out.txt for 'x=$(cat <<'EOF'', 'hi', 'EOFabc def)', 'echo y > out.txt', 'EOF' and ')' on six lines). That test belongs to the substitution bash is parsing, so a body at the top level, in a plain '( ... )' subshell or '{ ... }' group outside any substitution, or in a backquote substitution's text (which bash parses only when it runs it) ends only at its delimiter line, and a '>' after a line such as 'EOF)' there keeps its allow. A body under an unquoted delimiter is read for the substitutions bash expands in it, so the commit message form whose unquoted body holds '->' beside a backquote, '$' or backslash allows, while a backquote substitution in such a body that truncates a relative file denies, as bash runs it. A truncating redirect to a relative or opaque target found anywhere in that reading DENIES, with a message that its target could not be read as absolute; an absolute target, an append, a descriptor duplication and a current-user tilde keep their allow, as do a '>' inside '[[ ... ]]', a '((...))' or '$((...))' comparison, a '>' in a '${...}' pattern and a '>' in a quoted here-document body. Where the reading cannot tell how bash reads the text from some index on (a '$[...]', a 'coproc', a '(' that is an extglob pattern, a blank or metacharacter inside the subscript of a word that may assign to an array element, a backslash-newline that bash removes where the characters it joins may read as '((', '$((', '))', '$(', '${' or '$[', or before a '${...}' expansion's operator (in or after its name or subscript), a ')', '}', ';;' or 'esac' bash rejects, a second here-document on a line after one bash ended early, a body line under an unquoted delimiter that ends in a backslash, an unterminated quote or substitution, more than sixteen substitutions one inside another, or more failed '((' arithmetic tries than about four times the command's length allows), the text from there is read by the raw unread-text scan above, the fail-closed reading, except that a truncating redirect whose target word this reading cannot read DENIES at its operator, since that scan would start past it, and that single-quoted text this reading holds in arithmetic or a subscript until its '))' or ']=' is scanned from its first character, for the same reason. Disclosed OVER-DENIES of this reading, where bash writes nothing: a redirect in a complete command that bash then rejects as a syntax error still denies, such as a nested '$(...)' whose body bash ends at 'EOFX)' followed by an unmatched ')' ('x=$(echo $(cat <<'EOF'', 'hi', 'EOFX)', 'echo y > out.txt', 'EOF', '))'), or a here-document or case that never ends; a '${ cmd; }' nested inside a '${...}' expansion is read to the '}' standing where a reserved word may, while bash ends it at its first '}', so 'echo "${u:-${ echo }; echo a > out.txt; }}"' denies; and the raw scan's own over-denies apply from wherever this reading hands over to it. The lexer's top-level over-denies are unchanged: '(( 3 > 1 ))', '[[ b > a ]]', 'echo $(( 1 > 2 ))', and an absolute target inside an unquoted '${ cmd; }' at the top level ('echo ${ echo a > /abs/out.txt; }'), whose words the lexer reads as opaque, still deny there, while the same '${ cmd; }' inside a double-quoted '$(...)' is read by this reading and allows. Further disclosed OVER-DENIES of this reading: a substitution between single quotes in a bare subscript inside '((...))' or '$((...))' is read, though bash reports a syntax error there and writes nothing ('echo $(( a['$(echo a > out.txt)'] ))' denies); a subscript is read as arithmetic whatever the array, though bash reads an associative array's subscript as a string, and whatever command holds the 'a[...]=' word, though bash leaves an ordinary argument quoted (echo's argument a['$(echo a > out.txt)']=1 denies); and a truncating redirect whose target word this reading cannot read denies even where bash expands that word to a descriptor or an absolute path ('> /abs/$[1]'). Like the lexer, this reading does not model eval, bash -c or alias expansion. NOT YET JUDGED (known misses of this reading, disclosed): this reading models bash only as far as the contexts listed above, and a substitution bash evaluates anywhere else is not judged. Known misses among those: (1) a value bash evaluates as arithmetic after quote removal, in a command's argument (let, test -v, '[ -v ... ]', '[[ -v ... ]]', '[[ ... -eq ... ]]', '[[ ... -lt ... ]]', unset, read, printf -v, wait -n -p, a fully quoted declare, typeset or local argument such as "declare -a 'a[...]=1'", or a declare -i or local -i value such as "declare -i x='a[...]'") or in an assignment to an integer-attribute variable ("declare -i x; x='a[...]'", or x+='a[...]'), where a subscript's substitution runs; (2) an escape in a $'...' string in arithmetic text that changes what runs there: one that spells another character ('\x3e' for '>', '\x24' for '$'), which this reading does not decode, and '\c\', which consumes the backslash after it, so a '$(' after it runs where this reading reads it as escaped. A truncating redirect in any of these shapes can pass without a deny. It is one pass over the command, linear in its length apart from each failed '((' try (bounded as above) and each backquote substitution's text, which it copies once per level that holds it. A missing tool_name denies (the shared fail-closed contract); a missing or unreadable command string ASKS rather than silently allowing. It is best-effort against the accidental case, not an adversary, and is platform=claude-code only. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 1.20 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context); after the SUBSTITUTION TEXT reading was added, one in-process run on 2026-10-28 with Python 3.14 measured at most 0.79 s at 1 MB on this guard's own timing shapes (nest_cmdsubst, dq_case_substs and dq_case_substs_after included), and the self-test's deadline bounds hold every shape it times. Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:branch-root-guard
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: c
  • Channel: plugin

Technical limits (from the enforcement ledger)

Lexical trigger over an open command grammar. NO-ASK posture: this guard never returns an ask; branch rooting is a hazard class, so read every ASK, fail-SAFE ASK, and routes-to-ASK below as a DENY-and-educate fail-safe (a confirmed orphan start and any creation form this guard cannot prove rooted both DENY, naming the reachable correct action; the authoritative CI branch-root gate judges the actual post-creation ancestry). A git command that defines a git alias for itself (a -c or --config-env key in the alias, include or includeIf section, or a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n assignment naming one), its git word read wherever it stands in its segment (the command word, or a word behind a reserved word, a { group, !, time or a wrapper word; not inside the text of another word, such as an sh -c or eval body) runs a subcommand this guard cannot see, so it is a creation form this guard cannot classify and DENIES (on an unparseable command whenever its text names git and holds such a definition anywhere in its de-quoted words, past a here-string, an unquoted heredoc or a {fd} redirect the lexer stops at included; a -h or --help does not exempt it, since git runs the alias with the flag passed on to it, so git -c alias.c=VALUE c --help, which on git 2.53.0 only printed the alias, is refused too, a disclosed over-refusal), and so does every git command of a command whose text names GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT or GIT_CONFIG_KEY_ anywhere (sought once every quote character, backslash, dollar sign and newline is removed, so a quoted, escaped or continued spelling counts, in any word, a here-string, a comment or an echo) and names git as a word anywhere, parsed or unparseable alike (git wherever it stands: behind a reserved word or a wrapper, or inside an eval or sh -c word), whatever form sets the variable and whatever its value, since a shell sets one in more ways than a reading of forms can list (on git 2.53.0 with bash 5.3 an export, declare -x, typeset -x or local -x word, a bare assignment or a readonly word to a variable already exported, and under set -a also a bare assignment, read, printf -v, a for loop variable, a default-value expansion or a nameref each defined an alias), so a mention that defines no alias (an echo, a comment, an unset, an unrelated value) counts too, a disclosed over-refusal, or that runs under a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n naming one in the session environment the hook inherits, which counts as such a text does, git wherever it stands (not read: a variable name built at run time, such as an expansion after GIT_CONFIG_, a brace expansion or an escape decoded inside an ANSI-C quote, and a variable set outside the command text, in the startup files of the Bash tool shell or in a sourced file or a script the command runs). A definition counts however it is spelled: the lexer records how each word was spelled, and the rule is closed over the whole region after a git word through its subcommand word (every global option word, the value of any option and the word read as the subcommand), where a word that carries an unquoted parameter or command expansion (a dollar sign or backquote outside quotes, one bash reads literally included), which bash may split into several words such as a -c and a definition, or whose text came through an ANSI-C or locale quote (a dollar sign right before a quote character) defines a git alias whatever it decodes to, as does a word there holding an unquoted brace and a comma (bash expands it into several words), other than a -c or --config-env word and its value, and a -c value or a --config-env word whose key part (the text before its first =, or the whole word when it has none) is spelled through a parameter or command expansion, inside double quotes too, such a quote or a brace expansion; on an unparseable command the same rule reads the de-quoted words of each command, past a here-string, a heredoc or a {fd} redirect, where a quoted or escaped dollar sign, backquote or brace counts as an unquoted one would. On such a command a git region the lexer did not read denies, fail closed: where the partial lexer stops before the option region of a git command or inside it (a leading here-string, as in git <<<x -C DIR $X), that region is unread, scoped to a segment that runs git as its own command (its command word is git or a wrapper the guards resolve, never a git word another command merely carries); the text past where it stopped is read two ways, and a deny from either stands: a quote-aware re-lex of the text past each construct the reader can measure on the same text (the target word of a here-string, or the balanced parentheses of a process substitution with backslash escapes honoured), each recovered command read through its subcommand word, and the every-git-word reading, which walks the region of each git word in the raw text from the stop, denying where a region cannot be read to its subcommand word or holds a word built at run time. The re-lex alone decides only where its ground is proven: the construct measured with no backslash escape met, no git word inside the text of the measured construct itself (the body of a process substitution and the word of a here-string are run or expanded, so a git region there keeps the raw reading), the resume at a command separator, and every git word of the re-lexed text read as a word of a recovered command (one in a comment or a redirect target is not); there a git word the re-lex proves to be quoted text (an echoed git word past a stop, a pull-request or issue body naming git) allows, while a plain git word any command carries as a word of its own (under a wrapper the guards do not resolve, such as flock or ionice, included) is still walked and denies where its region runs open or holds a word built at run time. At every other point (an unquoted heredoc, a {fd} redirect or an unbalanced quote the reader cannot measure, a resume that is not at a separator, a measure that met a backslash escape, a git word inside the measured construct, one in text the re-lex gives no word for, or a git word beside a $ or backquote inside a re-lexed word) the every-git-word reading decides too, so a quoted or commented git word there still denies, a disclosed over-refusal; a literal region read in full keeps its decision either way. Disclosed over-refusal: a word naming git that is the last word read before such a construct and is its command word (git <<<x log) still denies, while a git word only an operand of the segment the lexer stopped inside (grep -c git <<<x) is not read as a git command, so it allows. Remaining residual of this reading: the every-git-word walk reads the region of a git word on the raw text, so from inside quoted, comment or here-document text it can only over-refuse, never allow a git command the re-lex read; a git command whose git word is itself built at run time (an expansion that expands to git) is outside both readings, as on a parsed command. A word reads as one word to bash only when no part of it can expand to several words: a double-quoted expansion of the positional list or of an array subscripted with @, in any parameter-expansion form, with or without an operator or substring, and an indirect expansion whose reference (a variable, an array element read through a subscript of its own, a positional parameter or $*) can hold a reference to such a list (a double-quoted $@, ${@:1}, ${A[@]}, ${A[@]:0}, ${!A[@]}, ${!R}, ${!A[0]}, ${!1} or ${!prefix@}), expands to one word per element, so it counts there as an unquoted expansion does. A word whose every expansion stands inside double quotes and expands to one word keeps the plain reading on a parsed command: behind a literal key, as another option value or as the subcommand it defines nothing. Disclosed over-refusal (the safe direction): an unquoted variable in a git option or subcommand position now denies (git -C $DIR log, git $SUB, git -c core.pager=$P log), as does one after any other word naming git (grep -r git $DIR) and a git command inside an unquoted backquote substitution whose closing backquote the lexer reads into its subcommand word; quoting the variable restores the plain reading. The deny reason names such a word as one the shell builds at run time, with the fix (quote the expansion, spell a -c key literally, write a list out, put a here-string after the subcommand word), and keeps the alias wording for an actual alias definition. A key that is literal in effect but spelled through an expansion or such a quote reads as an alias, and so does a brace word there that expands to no option. Not read: the git word itself, read by its decoded text alone (an unquoted expansion in it, or a brace word that is the git word, which bash can split into git and a definition), and a word in that region that bash builds by filename expansion (an unquoted star, question mark or opening bracket matching a file name such as -c) or by a brace sequence expression, which holds no comma, and a word whose every expansion stands inside double quotes and expands to one word, which keeps the plain reading by decision, so one that names an option such as -c at run time, before a literal definition, is not read as one. A branch created through a wrapper, an alias defined in a configuration file, a script, or shell indirection is not seen. A creation behind the reserved words of a compound command (!, {, if, then, elif, else, do, while, until, time or time -p, coproc, function NAME, for NAME do or select NAME do) or inside a backquote substitution is read from its command word in a reading the shared lexer adds after its own segments, the spelling record of each word kept, so a wrapped --orphan creation DENIES as the bare form does. That reading over-reads in the safe direction: the text after a here-document operator is read as commands, a quoted here-document body included, so gh pr create --body "$(cat <<'EOF' ... `git checkout --orphan x` ... EOF)" DENIES where bash runs only cat (confirmed against bash 5.3.9 with logging stand-ins). A command whose backquote bodies pass that reading's budget (524288 characters of body text, each body counted with 64 more, about 7,500 short distinct bodies) DENIES whatever it runs, on each lex this guard makes, since the partial recovery and its open-grammar allow could pass a creation that a full reading denies. When a later construct makes the whole command unparseable (a heredoc, a process substitution), the guard still PARTIAL-lexes and judges the creation recovered in the parseable prefix, so a proven orphan creation followed by an unsupported construct still DENIES (DENY outranks the parse error); only a creation lying ENTIRELY within the unparseable syntax stays unseen (the open-grammar residual). A '#' whose comment reading depends on context (the shared lexer's comment rule) is judged under both readings, a comment to the end of its line and word text, so a creation either reading reaches is judged; one the lexer cannot judge that way (more than four such '#', one in or right after a backquote substitution, or a reading that does not lex) stops the lexer before any text past it, so a command carrying one whose text (also with its backslash-newlines and then its quote characters and backslashes removed) names git and a branch-capable verb (checkout, switch, branch, worktree) DENIES fail-safe instead (an over-deny of such a command that creates no branch), and so does one whose text holds quoting that removal does not decode (an ANSI-C $'...' or locale $"..." quote, or a quote character or backslash next to a letter), whatever its words (an over-deny of every such command that creates no branch). Only creation forms are checked, so switching to an existing orphaned branch, continuing work on the current branch, or RENAMING a branch (-m/-M/--move, which reuses an existing branch's commits under a new name and so creates no new orphan-rootable ref) passes to the CI gate. It probes a creation's ancestry (and can DENY an orphaned start) ONLY for a CLEAN canonical creation: a create trigger (checkout -b/-B; switch -c/-C or --create/--force-create; a bare `git branch <name>`; a `git branch -c/-C/--copy` copy whose start is the SOURCE, an explicit <src> else the current HEAD; or `git worktree add -b/-B`), its operands, and NOTHING ELSE except a tiny allowlist of VALUELESS booleans (--quiet/--force, short q/f; git branch additionally exposes --verbose/-v, which checkout/switch/worktree do not). A confidently-recognized non-creation ACTION (git branch -d/-D/--delete, -m/-M/--move rename, -u/--set-upstream-to/--unset-upstream, --edit-description, --show-current) or a LISTING form (--list, -a/-r/--all/--remotes and the unambiguous listing letters) is ALLOWED. EVERY OTHER shape routes to a fail-SAFE ASK, never a silent allow of a possible creation and never a probe-based DENY of a non-creation: any value-taking option (--contains/--merged/--points-at/--recurse-submodules), any unknown option, any abbreviated (--cre, --orp, --tr) or negated form whose meaning is order- or version-dependent (--no-detach, `--list --no-list`, a clustered -l), any tracking/orphan/detach form (--track/-t/--orphan/--guess/--detach/-d), a copy combined with any non-clean option (git branch --format main -c ...), a bare `git worktree add <path> [<commit-ish>]` without -b, and any short cluster carrying a letter outside the clean/listing set. This MAXIMALLY-CONSERVATIVE posture (maintainer-approved 2026-09-02, after the simplify-to-ASK cap) trades precision for robustness: an exotic or option-laden git command earns an ASK rather than a precise allow or deny, and the authoritative CI branch-root gate judges the actual post-creation ancestry regardless. DISCLOSED residual: the classifier accepts any nonempty operand as a branch name, so a creation carrying a git-INVALID name (spaces, a `..`, a control character - forms git rejects with `check-ref-format` and creates nothing) can still receive a probe-based deny of that orphaned start; this is a safe-direction false-deny of a no-op command (re-issue with a valid name), and full git ref-name validation is out of this lexical classifier's scope (tracked as HYG-2). What it PROVES at creation time is only that the start point is ORPHANED (no merge base with origin/HEAD); a rooted-but-STALE or old start point (for example checkout -b x origin/HEAD~1) still has a merge base and PASSES this hook, and its staleness is caught only by the CI gate's configured --max-lag first-parent-lag threshold, not here. Replace refs are neutralized on the probe (GIT_NO_REPLACE_OBJECTS=1) so a grafted parent cannot make an orphan look rooted; the on-disk .git/info/grafts residual is out of scope (an accidental-case guardrail). A shallow clone cannot distinguish a missing merge base from unfetched history, so a merge-base miss in a shallow repository routes to ASK, never a deny; a FAILURE of the shallow-status probe itself (a non-zero or unreadable result) likewise routes to ASK, matching the CI gate's cannot-evaluate posture, never a false orphaned deny. A cd/pushd in an earlier segment of the compound command or a non-cosmetic ambient GIT_* variable leaves the target repository unreconcilable with the session cwd, so the creation routes to a fail-safe deny. A command-local redirect is resolved to the REPOSITORY the branch is created in and its ancestry probed THERE: a resolvable -C target is probed, and --work-tree is worktree-only so the repository is resolved from -C or the ambient cwd, never from the --work-tree value (a review finding); only a --git-dir/GIT_DIR/-c form or an unresolvable -C target cannot be pinned and allow-notes (the CI branch-root gate remains the backstop). The ancestry answer comes from the clone's remote-tracking state, which can be stale until the next fetch. The hook derives its PROTECTED ref from the clone's origin/HEAD (the local default-branch pointer), which may differ from the actual protected target the CI gate judges against (the PR base branch): where origin/HEAD points at a non-default branch, the hook can ALLOW a creation the CI gate later reports ORPHANED against the true protected line, since the hook has no PR/target context at creation time; the CI branch-root gate is the authoritative check on the actual post-creation ancestry. The hook resolves the `git` executable through the ambient PATH (a trusted-toolchain boundary, as the sibling gate residue discloses): a hostile PATH entry shadowing `git` could spoof the probe, an adversarial execution-environment concern outside this accidental-case hook's threat model, not a bypass it defends against. The env-assignment command form is best-effort, as for the sibling git hooks. The hook catches only EXPLICIT-start creation forms; an IMPLICIT-start (DWIM) form where git auto-creates a branch from a matching remote-tracking ref (a bare git checkout/switch <name> whose name matches a remote branch, or a --guess/--guess-remote guess) has a start git resolves at runtime and not lexically visible here, so it is routed to ASK where a guess flag is present and otherwise passes the hook and is caught by the authoritative CI branch-root gate, which judges the actual post-creation ancestry. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 0.45 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:commit-identity
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

A best-effort QUOTE-AWARE LEXICAL check over the Bash command string: tokenized with the shared quote/redirect-aware tokenizer (_lex_command), which respects quotes and REMOVES shell redirection from the token stream (so a redirect before or around the subcommand no longer hides a Co-Authored-By trailer, an --author value, or an identity assignment), reading bash locale quoting ($"...") as the double-quoted text bash reads when no message catalogue translates it, so a name that $"" splits inside a word is read joined (Cl$""aude is Claude) in the command and in each run line it reads, and grouped into segments on the unquoted shell operators ; | || && & ( ) and newlines, so a subshell-grouped commit (( git commit --author=... )) is judged too, and a trailer hiding inside a quoted -m message (-m "fix; Co-authored-by: Claude", which the old whitespace split broke at the ;) now lives intact in the single message token and is caught by a substring scan on that token. Each command and each run line is judged under two readings, the one above and one that keeps the $ of a locale quote as a literal character, as the tokenizer reads it for every other guard, and an AI identity that either reading finds denies: a name followed directly by a locale quote (Claude$"bot", which bash reads as Claudebot, a word that names no AI identity) still denies as it did before locale quoting was read, because a change toward allow is held until the maintainer decides it. A command or run line that only one reading can parse (a {$"x"}> word, which the locale reading takes for an fd-variable redirect) is judged under that reading. The run lines that each reading alone builds are read within a length bound kept for that reading, and those that only a mix of the two readings builds within a third (each the length bound below), so the lines that one reading does not build cannot spend the bound of the lines it reads alone, and a line past the bound of one reading is left unread even when the other reading reads it; the cost is up to three bounds of run lines rather than one, and a line holding $" is tokenized under both readings. On a git commit-family segment (commit, merge, cherry-pick, am, rebase, revert, commit-tree) it flags a Co-Authored-By trailer inside a token (every trailer name in the token is read, each with its own value of up to 160 characters to the end of its line, so a second trailer on the same line inside the value of the first, or one on a later line of the message, is judged too) or an --author value (--author=X or --author X) naming an AI; in ANY segment it flags a git-identity env var (GIT_AUTHOR_NAME/GIT_COMMITTER_NAME) or a user.name/user.email value (a -c user.name=X inline or a config user.name X pair) naming an AI, so setting the identity in a prior segment then committing is caught. A git command behind for NAME do or select NAME do, which the git command finder does not pass over, or inside a backquote substitution (x=`git commit --author=...`) is read too, in a reading the shared lexer adds after its own segments: each segment read from its command word past the reserved words of a compound command, and the commands of each backquote body read as a command line of its own. A command, or a run line git builds from an operand, whose backquote bodies pass the budget of that reading (524288 characters of body text, each body counted with 64 more, about 7,500 short distinct bodies) DENIES whatever it runs, never the raw-string fallback, which could allow an identity the full reading finds. The commit-family contexts are read on a segment whose git command has a commit-family subcommand, and the git command is found after leading shell reserved words (!, if, then, do, a { group and the rest), the NAME after function or coproc when a compound command follows it (function f { git commit ...; }, coproc C { git commit ...; }; coproc C env git ... runs C) and VAR=value words: the command word itself when it is git (a path to git included), and, when the command word is one of the wrappers env, timeout, command, exec, nohup, xargs, sudo, nice, setsid, stdbuf or time, a later word that is git (a path to git included, a VAR=value word excluded) starts a candidate git command, whatever options and operands the wrapper carries (env git commit, sudo -u ci git commit, sudo -u git git commit, timeout -s KILL 60 git commit, nested wrappers too), until a held candidate, whose git word does not stand right after a word that may be an option taking it as a value (a word starting with - other than -, -- or an option carrying =), has a subcommand that is a git command name (a git 2.55 built-in or listed command): that candidate is taken to be the git command the segment runs and a later git word is its operand, so a wrapped read-side command with a git-named operand allows (env git log --grep git commit --author=...), while a held candidate whose subcommand is not a git command name, such as a git-named operand of a program off the wrapper list, leaves later git words read (sudo flock /var/lock/git env git commit, sudo chroot /srv/git /usr/bin/env git commit); all of this in one pass over the words of the segment; command -v and command -V (a command whose options carry v or V) only look a name up and run no git command. A command that git runs from its own operands is read as a command line of its own, bare or wrapped: the operands of git submodule foreach and of git submodule--helper foreach, to which git submodule foreach hands them (its first operand, after an optional --, is shell text and the rest are its arguments, so foreach git commit ... and foreach "git commit ..." both run git commit), and of git bisect run (the words of one command), and the value of git rebase --exec or -x in any spelling git accepts (-x CMD, -xCMD, -ix CMD, --exec=CMD, --ex CMD), a shell command line; such lines are read nested up to 8 deep and up to 8 times the length of the command plus 4096 characters in all, and each git rebase reads its options only up to the next rebase subcommand of its segment, from where that rebase finds the same lines, so a segment of many wrapped rebase commands costs time linear in its length. A line past either bound is left unread, and so is a run line at any depth that the shared tokenizer cannot parse (an unbalanced quote in a foreach operand or an exec value), but every other segment of the command and of each line being read, and every other run line within the bounds, is still judged, so an AI identity there denies. Only when none sets one does the fallback judge the whole command. It first denies when an AI identity appears anywhere in the command once every backslash-newline, backslash and quote is removed, so quoting or escaping in the unread line that splits or builds an option name or value (--"auth"=..., --author=C"laude ...", a backslash-newline before the value) does not hide it, or once the $ before each double quote is removed as well, so locale quoting in the unread line (--author=Cl$""aude ...) does not hide it either, and otherwise the raw-string scan below still judges it; the cost is a safe-direction over-deny of such a command (a nest past a bound, or a run line that cannot be parsed) whose text merely names an AI product (-m "fix claude adapter", a read-side git log --author=..., a word in a quoted heredoc body). An identity the shell builds at run time in the unread line (an expansion, a command substitution, or ANSI-C quoting) is not seen, and on any line neither is the text that a message catalogue substitutes for a locale quote at run time. The cost of that one bounded wrapper rule is a safe-direction over-deny when a wrapped command that is not git carries git, a commit verb and an AI identity as its own operands (xargs echo git commit --author=...), when a wrapped read-side git word stands right after a wrapper option (sudo -E git log --grep git commit --author=...), when a wrapped read-side git command has an alias as its subcommand (env git lg --grep git commit --author=...), or when a command -v lookup stands behind another wrapper (sudo command -v git commit --author=...). Each word is read as a git commit option the way git 2.55 parses its options, so the value of a value-taking option is read as that value, never as an --author option (git commit -m --author=X and git commit -am --author=X record a message, not an author). One exception is kept: a next-word value of any value-taking option, in any spelling read below (-m, -F, -C, -c, -t, -U, --trailer, --date, --fixup, --squash, --template, --cleanup, --pathspec-from-file, --reuse-message, --reedit-message, --unified, --inter-hunk-context and --author itself), that has the form --author=VALUE naming an AI identity still denies as an --author value, and so does the word after a next-word value that is --author or a prefix git reads as --author (git commit -m --author X, git commit -m --auth X) when that word names an AI identity, bare, behind a wrapper word or in a run line; git takes the value word as that option value and records no author from it, so this is a safe-direction over-deny over every value-taking option, which the maintainer chose to keep (the wrapped and run-line forms, and the word after a prefix such as --auth, were allowed before wrapped commands, run lines and abbreviations were read, so denying them is stricter still). A value that shares its option word (-m--author=X, --message=--author=X) and a next-word value of the form --auth=X (a prefix shorter than --author, with =) are not so judged. A short option cluster is read letter by letter up to its first value-taking letter (m, F, C, c, t, U), whose value is the rest of the word or else the next word and only that word (git commit -am -m --author=X records the message -m and the author X), -S and -u take a value only from the rest of their own word, and a long option is matched by its exact name or by a prefix that only one git commit option starts with (--auth is --author, --mess is --message; a prefix that several options share takes no value, and git refuses it). A message value (-m, --message, --trailer) is still scanned for a co-author trailer, and the value of a file, commit, template, date, mode or number option (-F or --file, -C or --reuse-message, -c or --reedit-message, -t or --template, -U or --unified, --fixup, --squash, --date, --cleanup, --pathspec-from-file, --inter-hunk-context) is passed over. The other commit-family verbs are read with the same git commit option table, not their own. git global options in _GIT_ARG_OPTS are skipped together with the word after them, so an option value is not misread as the subcommand: -C, -c, --git-dir, --work-tree, --namespace, --config-env, --attr-source and --shallow-file take that word as their value, and --exec-path and --super-prefix take none (the word skipped after them is harmless, since git runs no subcommand there); an --opt=value word is skipped alone. A git command that defines a git alias for itself (a -c or --config-env key in the alias, include or includeIf section, or a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n assignment naming one) runs a subcommand the check cannot see, so it is read as a commit-family command, and so is every git command (found as above) of a command line whose text names GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT or GIT_CONFIG_KEY_ anywhere (sought once every quote character, backslash, dollar sign and newline is removed, so a quoted, escaped or continued spelling counts, in any word, a here-string, a comment or an echo), whatever form sets the variable and whatever its value, since a shell sets one in more ways than a reading of forms can list (on git 2.53.0 with bash 5.3 an export, declare -x, typeset -x or local -x word, a bare assignment or a readonly word to a variable already exported, and under set -a also a bare assignment, read, printf -v, a for loop variable, a default-value expansion or a nameref each defined an alias), so a mention that defines no alias (an echo, a comment, an unset, an unrelated value) counts too, a disclosed over-refusal, or that runs under a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n naming one in the session environment the hook inherits, which counts as such a text does, git wherever it stands (not read: a variable name built at run time, such as an expansion after GIT_CONFIG_, a brace expansion or an escape decoded inside an ANSI-C quote, and a variable set outside the command text, in the startup files of the Bash tool shell or in a sourced file or a script the command runs), together with each command line such a git command runs from its operands. The BARE message body is deliberately NOT matched against the identity set: a legitimate message that merely mentions an AI product name (-m "fix claude adapter") records no AI identity, and denying it would be a false positive; only a trailer or an --author value records identity. On a shell parse error (unbalanced quotes) in the command itself, with a run line left unread (one that cannot be parsed or one past the run-line bounds) and nothing read setting an AI identity, or with a command too long to read (below) that names an AI and holds no word that can record an identity, it fails SAFE: a conservative raw-string scan denies an apparent AI trailer/--author/env/config assignment whatever the git subcommand (so a git command running under a git alias the command defines is judged as a commit-family one), never a silent-allow; its --author context is every prefix git reads as --author (--au, --aut, --auth, --autho, --author) with = or whitespace before the value, and its co-author context reads every trailer name, as the reader does, and takes each whitespace run whole, so a long run of newlines after the trailer name costs time linear in its length rather than quadratic, with the same AI identities found, and so does its user.name/user.email context, so a long run of spaces after user.name before a quote that does not close costs time linear in its length too, with the same values found. A command longer than 131,072 characters is not read at all, so the reading ends well inside the 10-second hook timeout, which the platform contract this repository records does not say refuses a call when it runs out (the costliest commands measured took up to about 7.3 microseconds of CPU time per character to read, about 1 s at that length, which holds only while each regex the reader runs on a word, and each one the raw-string scan runs on a command of that length that the tokenizer cannot parse, costs time linear in the length it scans: the co-author trailer regex the reader runs on each word takes each whitespace run whole, as the co-author and user.name/user.email contexts of the raw-string scan do, since giving a run back one character at a time cost time quadratic in its length, about 53 s and 130 s of CPU time at that length for a commit message of a trailer name and a run of newlines and for user.name and a run of spaces before a quote that does not close, against about 0.01 s with each run taken whole, and a commit message of repeated trailer names, each read with its own value, in git rebase -x lines nested seven deep, the innermost ending in a locale quote, took up to about 0.15 s, against 0.41 s with each value searched whole): such a command is denied when an AI name appears anywhere in it once every quote, backslash, $ and newline is removed, with no word boundary needed around the name, and it also holds a word that can record an identity: git as a whole word (its letters with only quotes, backslashes, $ signs or newlines between them, and no letter, digit or underscore directly before or after it, a removed character counting as a word boundary, so a git at the start of a line is found), or a GIT_AUTHOR_ or GIT_COMMITTER_ variable name or user.name or user.email in any case once those characters are removed. Any other command that long that names an AI is judged by the conservative raw-string scan above, as one that cannot be parsed is, so an AI co-author trailer or --author value in it denies even when git runs under another name (gi? or "$G"), and one that names no AI is allowed at once, since that scan cannot find an AI identity in it either. This fallback has no length bound, so its own cost grows with the command length: on the costliest shapes measured, commands made of repeated co-author trailer names, each read with its own value of up to 160 characters, of which the AI identity search reads only the part past the end of the value before it, less 8 characters (the longest AI name the check matches between word boundaries, 9 characters, less one), the highest of three single runs on one host took about 0.26 s of CPU time at 1,048,576 characters, 4.0 s at 16,777,216 and 6.3 s at 25,165,824 (with each value searched whole, 0.59 s, 8.7 s and 14.0 s on the same host), and 7.3 s at 33,554,432, 8.9 s at 41,943,040 and 10.3 s at 50,331,648, so on the measuring host a command of about 40 to 48 MiB or longer can outlast the 10-second hook timeout, and on a slower host a shorter one can; since the platform contract this repository records does not say that a hook stopped at its timeout refuses the call, such a command may run with no deny from this check. The reader finds a git command only by a word that is git or a path ending in /git, reads a run line only under such a git command, and judges an identity assignment only in a word that starts with one of those names, so a name and a word that the reader would find in the command are found by these scans as well; a command that runs git under another name (a variable, a function or an alias) is outside the reader too. The cost is a safe-direction over-deny of such a long command whose text merely names an AI product or holds an AI name inside a longer word (Claudette) and holds git as a word or one of those names, a quoted heredoc body included; a long command that names an AI product and holds none of those words (a heredoc that writes a file and runs no git) is allowed unless the raw-string scan finds an AI co-author trailer, --author value or identity assignment in it, which denies it even when that is only text the heredoc writes, the same safe-direction over-deny as on a parse error. Being a raw-string scan, on a parse error in the command itself it does not see an option name or value that shell quoting or escaping splits or builds (--"auth"=..., --author=C"laude ...", --author=Cl$""aude ..., a backslash-newline before the value); with a run line left unread the scan for an AI identity anywhere, above, covers those forms. It is BEST-EFFORT: it targets the common/accidental case (the assistant own accidental violation), not an adversary determined to evade it. It does NOT defeat deliberate shell escaping (git \commit), obfuscation, an alias or function that renames the tool, a wrapper outside that list (builtin, doas, ionice, chrt, taskset, flock, busybox, watch, script, a wrapper script), a command string a shell or wrapper parses at run time (sh -c, bash -c, eval, env -S or --split-string with a quoted command), an identity a wrapper reads at run time (arguments xargs reads from its input), a git word built at run time (an expansion such as $GIT), a command git runs from somewhere other than the operands above (the exec lines of an interactive rebase todo list, git for-each-repo, an alias that starts with !, a configured editor, a filter-branch filter, or another git option or config value that names a program), a git-named operand of a program off the wrapper list followed by a word spelled as a git command name (sudo chroot /srv/git init ...), or persistent shell/env/config state such as an AI identity persisted in git config in a PRIOR TURN (not the same command), a trailer staged into a message file and committed with -F/-t, a commit made outside the Bash tool or the session, or an AI product name outside the recognized identity set. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 0.72 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context; a command past the 131,072-character read bound goes straight to the fallback). The deadline also bounds that fallback, whose cost grows with the command length: in one run on the same host (2026-10-16) a command of 48 MiB of repeated co-author trailer names ending in an AI name was denied by the fallback in about 7.6 s, and one of 64 MiB failed closed with exit 2 at the deadline, about 8.2 s after the hook process started, so a command long enough to outlast the hook timeout fails closed instead of running with no deny from this check, except where this paragraph leaves the wait outside the deadline. Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:commit-msg-subst
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

A BEST-EFFORT QUOTE-AWARE LEXICAL check via the shared tokenizer (_lex_command); it is DENY-and-educate (NO-ASK posture: this guard never returns an ask; read every ASK/ASKS/over-ASK below as that DENY-and-educate outcome, which NAMES the safe rewrite), so a detected substitution is denied with the reachable correct action rather than silently allowed, and it makes NO soundness or universal-coverage claim. In a parsed simple segment it resolves the effective command word past leading shell assignments and the recognized command modifiers command, exec, builtin, nohup, time, !, and env; for env it skips leading option tokens, the separated operands of common value-taking options, and NAME=VALUE assignments. When that word is literal git and the subcommand after recognized Git-global options is literal commit, it scans EVERY argv token after commit, including tokens after `--` or `--end-of-options`, for a backtick or $( marker in a substituting double-quoted or unquoted context, using the shared lexer's parallel per-token opacity plus the narrow unquoted-$( split form where a final opaque token ending in $ is immediately followed by the ( operator. It deliberately does no Git option binding because the shell expands argv before Git parses it. On an unparseable command, the raw fallback DENIES when a substitution-marker probe matches together with a coarse git-commit probe or a git alias the command defines (a git segment a partial lex recovers that defines one for itself or runs under one from the session environment, a text naming one of the git configuration variables below and git, or a text naming git that holds a definition anywhere in its de-quoted words, past a here-string, an unquoted heredoc or a {fd} redirect the lexer stops at included); that fallback can over-match quoted prose or literal markers. HONEST RESIDUALS: the option tokens belonging to a recognized command modifier are not skipped (for example command -p git commit, exec -c git commit, exec -a x git commit, and nohup -- git commit; time -p git commit is read past by the reading below), so resolution advances one word past the modifier, stops on the option, and silently ALLOWs a substitution-bearing commit; command modifiers outside the recognized set (notably sudo, nice, timeout, doas, stdbuf, and ionice) are not resolved, so for example sudo git commit -m "$(x)" silently ALLOWs; a git commit behind the reserved words of a compound command (!, {, if, then, elif, else, do, while, until, time or time -p, coproc, function NAME, for NAME do or select NAME do) or inside a backquote substitution IS read, in a reading the shared lexer adds after its own segments, so if git commit -m "$(x)"; then :; fi denies, while a $(...) inside a double-quoted string and a command an eval word or an sh -c string holds are not read as commands; a command whose backquote bodies pass that reading's budget (524288 characters of body text, each body counted with 64 more, about 7,500 short distinct bodies) DENIES whatever it runs, never the raw fallback, which could allow a substitution that a full reading denies; shell functions and aliases that rename git; git-config subcommand aliases defined in a configuration file (for example alias.ci=commit making git ci -m "$(x)") cannot be known lexically and silently ALLOW, while a git command that defines a git alias for itself (a -c or --config-env key in the alias, include or includeIf section, or a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n assignment naming one), its git word read wherever it stands in its segment (the command word, or a word behind a reserved word, a { group, !, time or a wrapper word; not inside the text of another word, such as an sh -c or eval body) has its arguments read as a commit's, and so does every git command of a command whose text names GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT or GIT_CONFIG_KEY_ anywhere (sought once every quote character, backslash, dollar sign and newline is removed, so a quoted, escaped or continued spelling counts, in any word, a here-string, a comment or an echo), a git command there being any segment with a word that names git as a word wherever it stands (behind a reserved word or a wrapper, or inside an eval or sh -c word), every word from that word on read as a commit argument, whatever form sets the variable and whatever its value, since a shell sets one in more ways than a reading of forms can list (on git 2.53.0 with bash 5.3 an export, declare -x, typeset -x or local -x word, a bare assignment or a readonly word to a variable already exported, and under set -a also a bare assignment, read, printf -v, a for loop variable, a default-value expansion or a nameref each defined an alias), so a mention that defines no alias (an echo, a comment, an unset, an unrelated value) counts too, a disclosed over-refusal, or that runs under a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n naming one in the session environment the hook inherits, which counts as such a text does, git wherever it stands (not read: a variable name built at run time, such as an expansion after GIT_CONFIG_, a brace expansion or an escape decoded inside an ANSI-C quote, and a variable set outside the command text, in the startup files of the Bash tool shell or in a sourced file or a script the command runs); sh -c or bash -c script bodies; eval or xargs indirection; redirect targets on a recognized commit segment are removed from argv by the lexer and are not scanned, so git commit -m ok > "$(x)" and git commit -m ok 2> "`x`" silently ALLOW; leading environment-assignment values and Git-global option/value tokens before commit; a pre-subcommand unquoted $( that fragments the lexer segment, letting the whole commit invocation including its message escape inspection; quote-mangled command words such as g''it are outside the claimed command-resolution surface even where the shared lexer happens to normalize a form to git; $VAR indirection, concatenation, PATH-shadowed commands, and any other non-literal command word that does not resolve lexically to git; commands outside the Bash tool; and unparseable commands beyond the raw fallback probes. Deliberate over-ASKs: $((...)) includes $(; a bare $ immediately before an escaped parenthesis (for example git commit -m $\(x\)) is marked opaque and the decoded-marker check sees $(, so it ASKS even though Bash passes literal $(x) and runs no substitution; ANSI-C $'...' is treated as an opaque marker-bearing token even though Bash does not substitute inside it; whole-token opacity means an escaped literal backtick or $( sharing a word with an unrelated unquoted expansion or glob prompts; and a post-boundary pathspec produced by substitution (for example git commit -- $(ls)) prompts because the shell still executes it. It targets the accidental case, not an adversary. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 0.35 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:constraint-reread
  • Platform: claude-code
  • Default: block
  • Entry point: Stop
  • Class: b
  • Channel: preview, installed by hand from .preview/constraint-reread.py; not in the plugin

Technical limits (from the enforcement ledger)

PREVIEW CHANNEL ONLY: a standalone file installed by hand from .preview/, not part of the pack's plugin, and off until AIQT_CONSTRAINT_RECORD names the project's durable record. After a compaction reported by the platform's own markers (SessionStart with source compact, or PreCompact), it reminds the assistant of the record's Constraint lines on every user prompt, and refuses the turn end, while the record holds no Constraints-reread entry dated after the compaction and no more than 60 seconds ahead of the hook's clock (an entry dated up to 60 seconds ahead counts); the refusals are capped at 3 in a row, after which each later stop is allowed with a warning and no entry, unless the host sends stop_hook_active false on a turn end that continues a refusal. A refusal whose count cannot be saved is allowed with a warning, also on a Stop whose stop_hook_active field is explicitly false. The refusal count is kept in the hook's state file, so the cap holds when the platform's stop_hook_active field is absent or true; the count is reset to 0 only by a UserPromptSubmit event, a new compaction, or a Stop whose stop_hook_active field is explicitly false, never by an allowed stop, so without UserPromptSubmit registered and without that field every later stop until the next compaction is allowed with the warning (a missed refusal), and a state file that cannot be parsed (an unknown count) has the same effect from its first Stop. An explicit false is trusted as a new turn, so a host that sends it on a Stop that continues a refusal is refused at every such Stop (the cap does not hold). It proves only that an entry was written, not that the record was read or a constraint honoured; it names only constraints written in the record; a compaction without one of those markers (a host without the events, a hook not registered for them, a new session) is not seen; with no state location or no session key in the payload a compaction is reminded once and then forgotten; if its state location exists but cannot be examined, it keeps reminding with the compaction time unknown (a reminder no entry can clear, which says so) and allows each turn end with a warning; a state file over its size limit is read as a compaction at the file's modification time; a state file deleted after a compaction forgets that compaction, so the reminder and the refusals stop silently; concurrent hook runs are serialized by a lock file beside the state file, and a call saves only while that lock file and the state file are the ones it locked and loaded: a call that cannot take the lock within 2 seconds saves nothing (a compaction not recorded, a count not reset) and allows a turn end it would refuse with a warning that names the lock, and a call whose lock file is deleted or replaced while it holds it (also one moved away and put back after another call saved), or whose state file another call saved meanwhile, saves nothing and allows a turn end it would refuse with a warning that the refusal count cannot be saved (a deletion, replacement or save that lands in the few system calls between its last check and its write can still let one stale save through, and so can a save whose state file matches the loaded one in device, inode, size, modification time and change time, all five fields it compares, as when an inode number is reused within the filesystem's timestamp granularity); while the lock cannot be taken at all (a lock path that is a symbolic link or not a regular file, a lock file it cannot open, a filesystem that refuses flock, a platform without flock), it keeps no state, so a compaction is reminded once and then forgotten and, with no compaction saved before then, every turn end passes silently (a missed reminder and a missed refusal), while a compaction saved before then is still reminded and each turn end is allowed with a warning; a state folder moved away or deleted during a session forgets the compaction silently, like a deleted state file; once a record is declared every call creates the state directory and an empty lock file for its session, and nothing removes the lock or state files; an entry beyond the first MiB of the record is not seen, and a line cut at that limit is dropped; and the host clock is trusted.
hook:diff-source
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

AIRTIGHT-NARROW (L11 redesign: the GD-112 philosophy applied to diff-dumps). NO-ASK posture: this guard never returns an ask; read every ASK/ASKS/OVER-ASK below as its no-ask resolution, an ALLOW with an informational note for an unproven producer-capable form (a console-readability convention, not a hazard), while a CONFIRMED console patch that fits no proof DENIES-and-educates (use --stat or a redirect/pager). A best-effort QUOTE/REDIRECT-AWARE LEXICAL check over the Bash command via the shared raw-command tokenizer (_lex_command), which decides quoting and REDIRECTION from raw character positions BEFORE tokenizing, so a redirection ANYWHERE in a command (leading, interspersed, or trailing) is recorded as metadata and REMOVED from the argv, closing the redirect-pollution class shared across the lexical Bash hooks. An unquoted # opens a COMMENT only in a CERTAIN context: at the start of the command, or right after an unquoted blank, a newline, or one of ; & | < >, while no parenthesis, ${...} parameter expansion or backquote substitution is open (the rest of the line is ignored, so a redirect or pipe that is commented out earns no proof); a mid-word # such as ticket#123 or ${#x} stays literal; ANY OTHER unquoted # is UNCERTAIN, because bash reads it as a comment or as word text depending on context the lexer does not parse (x=$(true)# is word text but (true)# a comment; a # after a blank is text inside ${x:- #} or a [[ =~ ]] regex group but a comment inside ${ cmd; }): a # right after a (, a ) or a backquote that may open a substitution, or at a word start while one of those is open, is judged under both readings, and where the lexer cannot judge it that way it raises and each guard takes its fallback, which can decide more strictly than reading the command would (both below); an escaped backquote is plain text to bash outside a backquote substitution, so a # right after it is word text; a backslash-newline is a LINE CONTINUATION that joins (comment handling is RE-APPLIED at the word boundary a continuation exposes, so git diff <backslash-newline># > out.txt still comments out the redirect, while a continuation that lands MID-word keeps a following # literal) and never injects a synthetic empty argv element (which would have mis-set the subcommand and defeated the sibling guards); and a redirect target is classified CONSERVATIVELY on BOTH its raw and its LEXICALLY-normalized form (os.path.normpath, never touching the filesystem): a target whose RAW form begins with /dev or /proc is treated as a device and DENIED even if a .. in it would normalize elsewhere (git diff > /dev/../tmp/out.txt DENIES, an over-block in the SAFE direction), an ABSOLUTE target that NORMALIZES to under /dev or /proc is an unambiguous console/device target and DENIES (git diff > /tmp/../dev/stdout), a RELATIVE target whose normalized FIRST component is dev or proc (git diff > dev/stdout, > ./dev/stdout, > proc/self/fd/1) is CWD-DEPENDENT - from cwd / or via a dev/proc symlink it IS that device, but from an ordinary working tree it is a plain relative file - so it cannot be proven a plain file and ASKS (opaque), never a silent ALLOW and never a hard DENY of a legitimate write into a dev/ or proc/ subdirectory of the working tree itself, a .. in a target NOT under /dev or /proc yields an OPAQUE target that cannot be proven a plain file and ASKS (git diff > /tmp/../out.txt), and a plain static non-/dev,/proc file target ALLOWS (git diff > /tmp/out.txt); a relative .. escape that normpath cannot resolve (../../../dev/stdout) is likewise opaque rather than a proven real file. Rather than ENUMERATE every dumping form of git (an unbounded shell + git-option grammar, GD-34/F-119), the ALLOW path PROVES the safe form: a git diff-PRODUCER is ALLOWED only when the WHOLE command matches one of SIX closed proofs. (A) EXACT help: a metacharacter-free git <producer> --help or -h (and the exact git stash show --help/-h). (B) EXACT summary-only: a metacharacter-free single simple command (a conservative character set of letters, digits, space, tab, and _ - . / = : @ , + % and no shell reserved word), whose first resolved words are literally git and a summary-capable producer (diff/show/range-diff/log/diff-tree/diff-index/diff-files, plus the explicit git stash show), carrying at least one EXACT summary selector (--stat/--shortstat/--numstat/--name-only/--name-status) before a --/--end-of-options boundary and no other option-like word before the boundary except an exact --no-patch (bare operands allowed). (C) DIRECT static real-file diversion: a SINGLE simple command whose command word is literally git, a possible producer, with no opaque shell feature, whose FINAL stdout redirect (last-redirect-wins over the raw redirect metadata) is proven to land on a static non-/dev,/proc file - supporting leading, interspersed, and trailing redirects, but never a dynamic target (a variable, command substitution, or tilde), a descriptor duplication (1>&2, or a 3>out 1>&3 fd-graph, classified opaque), or a /dev,/proc target. (D) EXACT terminal pager pipeline: a plain literal unwrapped git producer, an exact | (not |&), then exactly one bare pager word (less/more/most/pager) with no options, operands, or redirection and no later pipe. (E) BENIGN git log listing: a metacharacter-free single simple command whose first words are literally git and log, every option before a --/--end-of-options boundary drawn from an EXACT allowlist of VALUE-FREE, provably-diff-free display/traversal flags (--oneline, --graph, --decorate, --no-decorate, --abbrev-commit, --reverse, --all) with bare revision/pathspec operands allowed - a plain commit listing that provably emits no diff (git log renders a patch ONLY with a patch flag, and a summary --stat form is already proof B). A git log carrying ANY other flag - a patch flag, a pickaxe -G/-S (whose diff output depends on a co-present -p), a summary/file-list flag (--numstat/--shortstat/--name-only/--name-status, which are the proof-B summary escape, allowed there like git diff --name-only), a value-taking option such as --format/-n <count>/--author=/--since=/--grep=, or any unknown flag - is NOT admitted by proof E and routes to the default (ASK, or DENY on a confirmed patch). (F) EXACT git show BLOB READ: a metacharacter-free single simple command whose first resolved words are literally git and show and whose FIRST non-option argument after show is a BLOB SELECTOR (a non-option token containing a colon, the <ref>:<path> or :<path> form). In that form git show prints ONE file contents at a revision - no diff, no @@ hunk headers, no +/- lines - so it is cat against a revision (the normal way to read a file on an un-checked-out branch) and renders no console diff. A bare git show <commit> (no colon-bearing first operand) is NOT this proof and stays covered (ASK, or DENY on a confirmed patch); a compound or redirected form is outside the single-simple-command proof and ASKS. A producer behind the reserved words of a compound command (!, {, if, then, elif, else, do, while, until, time or time -p, coproc, function NAME, for NAME do or select NAME do) or inside a backquote substitution is also read as its own command, in a reading the shared lexer adds after its own segments, so a confirmed console dump there DENIES as the bare form does, while the closed proofs read only the segments the lexer itself returns, so an added segment never earns one. A producer read past the reserved words of a compound command writes where the closing word of the compound sends its output, so { git diff; } > out.txt, if true; then git diff; fi | less and a function body called with a redirect ALLOW with the note as a compound does, while one in a backquote substitution or a coprocess is judged as one in a $(...) substitution is (x=`git diff` DENIES as x=$(git diff) does). That added reading over-reads in the safe direction, so it can DENY a command that dumps no patch (each confirmed against bash 5.3.9 with logging stand-ins): the text after a here-document operator is read as commands, a quoted here-document body included (gh pr create --body "$(cat <<"EOF" ... `git diff` ... EOF)" DENIES where bash runs only cat, and so does a git commit message written that way), and the commands of a backquote body stand between the segment that opens it and the segment before that one, so a pipe from that segment reads as feeding the first command of the body (git diff | less -p "`head -1 f`" DENIES as git diff piped into head, where bash pipes the diff into less); a bare git diff --quiet DENIES as a confirmed dump, as before that reading, so the same command in a compound condition (if git diff --quiet; then echo clean; fi) DENIES too. A command whose backquote bodies pass the budget of that reading (524288 characters of body text, each body counted with 64 more, about 7,500 short distinct bodies) DENIES whatever it runs, since the fallback for a command the lexer cannot read could note or allow a dump that the full reading denies. This is a best-effort lexical heuristic (a separated option value containing a colon is a disclosed residual, erring toward reading a file, never toward a false diff-dump deny). The ALLOW path DELIBERATELY does NOT parse the full git/shell option grammar. Every OTHER possible producer is ASK or DENY: a producer CONFIRMED to emit a console patch (a default diff/range-diff, a default git show that is NOT the blob-read form, a patch-flagged listing, a summary token in a NON-option position such as git diff -- --stat, a producer piped into a known console/truncating sink cat/tee/head/tail, or a stdout redirect to /dev,/proc) and fitting no proof DENIES; any other producer-capable-but-unproven form ASKS. A producer that diverts its output with --output/-o writes the diff to THAT target, not stdout, so a shell redirect or a pager is a DECOY: proofs C and D do not apply, a --output to a /dev,/proc console DENIES, and a --output to a file or a dynamic target ASKS. A command word this guard cannot resolve to a literal git (an ANSI-C dollar-single-quote form or a $VAR expansion) beside a producer surface is never proven benign and never a CONFIRMED console dump: it ASKS. Across a compound or multiple producers, DENY outranks ASK, and a multi-command form is NEVER allowed merely because one segment is safe. The possible-producer scope is BROAD (a git word followed by a producer surface in the cleaned argv, robust to quote fragmentation, OR a raw-text probe that over-matches quoted prose), so it OVER-ASKS by design; on a tokenizer parse failure (an unbalanced quote or an unsupported construct such as a heredoc or process substitution) an apparent producer ASKS - never a silent allow, and no summary or redirect regex can earn an ALLOW from an unparseable command; a # whose comment reading depends on context (the shared lexer comment rule) is judged under both readings, a comment to the end of its line and word text, every reading judged (at most four such #, so at most sixteen readings), which is stricter for some commands; where the lexer cannot judge it that way (more than four, one in or right after a backquote substitution, or a reading that does not lex) an apparent producer in its text, also read with its backslash-newlines and then its quote characters and backslashes removed, DENIES, since the lexer read nothing past that # (an over-deny of such a command that dumps no patch); that removal is no decoder, so such a command whose text holds an ANSI-C or locale quote (a dollar sign right before a quote character) or a quote character or backslash next to a letter is read as cannot-evaluate and DENIES whatever its words, since a producer spelled in that quoting cannot be ruled out (an over-deny of every such command that dumps no patch, such as one that only echoes a quoted word). DELIBERATE OVER-ASKS, disclosed: a wrapper (env/sudo/command git diff), a pathed git (/usr/bin/git show), a leading assignment, any quote in a would-be summary command, a git log carrying any extra or unknown flag OUTSIDE the proof-E benign set (git log -5, git log -G/-S, git log --format, git log --author=), git grep diff, an extra summary modifier (git diff -M --stat, git diff -S --stat, git diff --stat=80), a compound, and quoted prose (an echo of the literal text git diff) all ASK rather than risk a false ALLOW. HONEST non-catches (best-effort, not chased): a git alias, a shell function, or a PATH-shadow name that omits a detectable git word; an expansion, eval, base64, or nested-script text that defeats both the cleaned-token scan and the raw fallback marker; a producer subcommand outside the declared roster; a diff produced by a NON-git tool or assembled across commands, or emitted outside the Bash tool; runtime or config behaviour that writes directly to a tty (a pager or GIT_PAGER/config set in a PRIOR turn); a static path target that is itself a symlink, FIFO, or device node, or that resolves to one through a symlinked ANCESTOR directory (a relative first-component dev/proc NAME is now caught lexically and ASKS, but the check never resolves symlinks, so a device reached through ANY symlinked ancestor, whether or not that ancestor is named dev/proc, is not distinguished), or that races after the check; and pager or PATH resolution at runtime. It is BEST-EFFORT: it targets the common/accidental case, not an adversary determined to evade it. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 2.87 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:diff-wall-stop
  • Platform: claude-code
  • Default: warn
  • Entry point: Stop
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

This Stop layer is a NON-BLOCKING surfacing WARN (systemMessage, exit 0), not a hard block: by Stop time the wall has already rendered, and because this layer keeps no loop bound of its own and does not read the stop_hook_active field of the Stop input, a hard block could wedge the session, so hard prevention lives in the PreToolUse diff-source layer instead. It catches only a unified-diff wall in the final assistant message of a turn: diff --git headers, paired @@ hunk headers, fenced diff/patch/udiff blocks (backtick or tilde) of 10 or more lines, and mixed +/- runs of 8 or more lines. It does not catch a diff dumped mid-turn between tool calls, side-by-side or word-diff renderings, or raw log or patch walls that carry none of those markers; a mixed +/- Markdown checklist can rarely trip the +/- run detector (accepted residual).
hook:gate-weakening-guard
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

A best-effort QUOTE-AWARE LEXICAL check over the Bash command string (the shared quote/redirect-aware tokenizer, which REMOVES shell redirection from the token stream, closing the F1/F2 redirect-pollution slips) with a THREE-PART posture (NO-ASK: this guard never returns an ask): the unambiguous git hook bypass DENIES; a narrow VERIFICATION RUN whose output reaches a truncating sink DENIES-and-educates (the deny tier, below); and the two broad gate-shaped heuristics ALLOW-WITH-NOTE (A REVIEW FINDING: a benign optional probe is common and is not a gate bypass), so read every ASK/ASKS/over-ASK below as that allow-with-note outcome unless it names the deny tier. The DENY core: on a git segment whose subcommand accepts --no-verify (commit, merge, push, pull, rebase, am; the roster and the short-flag semantics verified against the git 2.53.0 man pages, 2026-08-19), a --no-verify spelling denies, matched exact or by CONSERVATIVE LONG PREFIX (an ambiguous abbreviation git itself would reject, such as --no-ver, is treated as the bypass anyway, and a separated option VALUE that itself spells --no-verify is scanned too - both safe-direction over-matches that only deny, never allow), and the SHORT -n denies ONLY on the two verbs where -n IS --no-verify (commit and am), clustered or bare, with the value-taking short and long options skipped so an option value carrying the letter n (an attached -m value, a separated --message value, a post -- pathspec) is never read as the flag, and the option region ends at the FIRST real "--"/"--end-of-options" terminator with a value-aware boundary, so a "--" consumed as a value-taking option value (git commit -m -- -n and git commit --message -- -n: the "--" is the message) does not end the region and the following -n is still read as the bypass; on push -n is --dry-run and on merge and pull it is --no-stat, so -n there is deliberately NOT flagged (flagging it would block safe dry runs), and --no-verify-signatures merely re-asserts a default and is not flagged. On a git naming whose subcommand is on that roster, the scan holds the segment to a CLOSED LITERAL ALLOWLIST GRAMMAR judged directly on the RAW command string (the same fail-closed shape as the git-discard positive grammar): an accepted segment can hold only bare literal words, one standalone single-quoted literal per word, one standalone double-quoted literal per word whose interior stays on one line and holds no dollar sign, backquote, backslash or exclamation mark (bash reads such a word as exactly the literal between the quotes, so its decoded interior is judged like a bare word; an exclamation mark is refused because bash history expansion can rewrite it before git parses its options), literal global-option values, a literal subcommand and the >/dev/null, 2>/dev/null and 2>&1 redirections (which bash removes from the argument list and which write no file); EVERYTHING ELSE DENIES BY CONSTRUCTION with a re-issue-plainly reason - an expansion, substitution or backquote (a dash-free value such as git commit -m "$msg" or --author="$a" included, inside double quotes too), a process substitution, a backslash (a line continuation included, inside double quotes too), a double-quoted word that is glued, unterminated or spans a newline, a glob or brace character, a word-leading "#" comment or "~" tilde, an ANSI-C dollar-quoted word, a here-document, here-string or any other redirection, and a glued or unterminated quote. One here-document shape is accepted, and only on a command the shared lexer parses: the git commit message form, the value of a separated -m on git commit written as a double-quoted command substitution that holds nothing but cat reading one here-document whose delimiter is single-quoted (so bash reads the body as literal text) and whose body holds no dollar sign or backquote; the body is read as that -m value, a git named in the body is re-judged, and every other here-document shape (an unquoted or double-quoted delimiter, the tab-stripping <<- form, another command inside the substitution, a body holding a further substitution or expansion, a second such message on the segment, the --message or a clustered -am spelling) still denies, as does the same form on a command the lexer cannot parse. A git naming whose subcommand CANNOT be resolved, because a global option, a global-option value or the subcommand word itself is outside the grammar (git $g commit, git -C $d), also DENIES: the verb bash would run cannot be known. The accepted literal words are then checked for the bypass spellings themselves (so a single-quoted or double-quoted -n or --no-verify still denies while a value-taking option consumes its value, under the same value-aware "--" boundary as the parsed path), and EVERY raw git word is scanned wherever it sits - bash runs git wherever the word reaches a command position, and the gate deliberately does not model which positions those are - except inside a region an earlier accepted scan consumed, where it is data or an operand (a single-quoted -m value naming a git command stays allowed) - UNLESS that region is a quoted interior holding a shell operator, a newline or the other quote character, whose pairing the scan cannot resolve because it starts at a git word, not at the command start: such a span is NOT treated as consumed data, so a git inside it is re-judged as its own segment, catching a bypass hidden in the live command text that sits between a closing quote bash opened earlier and the next opening quote, while the enclosing word is still read for its own bypass spelling so a real flag after the quote is not missed. DISCLOSED OVER-DENIAL (the deliberate cost of the closed grammar): outside the one accepted here-document message form above, a roster-verb git command carrying ANY live expansion now denies even where it is a safe operand or value (git commit -m "$msg", git push origin "$branch", git merge "release-$v"), and so does one carrying a comment, a file redirection or a glob, and so does TEXT that merely names a roster-verb git command inside quoted data or a here-document body (an echoed or grepped mention, a heredoc line); a plain double-quoted literal that stays on one line and whose interior holds no dollar sign, backquote, backslash or exclamation mark is accepted like a bare word, and a benign operator message (git commit -m "a; b | c") still allows; but an interior exclamation mark denies (history expansion could rewrite it; single-quote the text instead), and a quoted interior holding a shell operator or the other quote character re-judges any git named inside it, so a message that names a git hook-bypass spelling after such an operator denies, while the same quoted value with no operator in it stays allowed; re-issue anything else plainly with every word literal or standalone-quoted, resolve expansions before the call, and keep roster-verb git spellings out of quoted data. Every hook-skip and grammar deny a commit whose quoted message only quotes a hook-skip spelling can draw (the parsed path, the de-quoted and parse-error readings, the raw fallback, and the opaque and bounded tests behind an unjudged #) keeps its decision and ends with one more sentence naming the way on: write the message to a file and commit with git commit -F FILE (or cat FILE | git commit -F -), which allows because the guard never opens the file; a < redirection or a here-document on the git segment, or a stage before it whose own text names the spelling (printf piped to git commit -F -), still denies, and behind an unjudged # the -F commit denies too until that # is fixed. A git word inside a quoted operand whose global-option walk runs off the end of the quoted text before any verb (the verb could sit in the next span bash opens) also fails closed and denies, and that git is matched at a word boundary anywhere inside a word, so a quoted URL or path ending in .git on any git line denies too, roster verb or not (git clone "https://example.com/a/b.git" d, git remote add origin "[email protected]:a/b.git", git commit -m "Ignore .git"), in either quote style; the same URL unquoted allows. DISCLOSED RESIDUAL: a -n or --no-verify assembled in a separate stage the shell runs and feeds to git (printf piped to xargs git commit) is not reconstructed, and an alias, wrapper or shell function that renames git is not resolved. Behind ANY other word of a segment, with no list of wrappers (ionice, doas, taskset, flock, chroot, builtin exec, env -S and sudo alike), the de-quoted text of that segment is read too, in four readings: a blank-split reading (ANSI-C escapes decoded, quote characters and backslashes removed, the text split at whitespace), and three bash-model readings, namely the text at its own quoting level as bash reads it, that result read once more as the shell it is handed to reads it (sh -c, eval, env -S), and the text with every ANSI-C dollar-quoted span the raw match finds decoded. The bash-model readings decode ANSI-C escapes as bash does and cut such a span at its first NUL escape as bash cuts it, split words only at space, tab and newline and at every shell operator character and opener (; & | ( ) < > and the backquote, so $(, <( and >( too), wherever each stands, and read the \_ of env -S as a separator; in every reading a git named by the attached value of an option word counts (env --split-string or env -S with a quoted git name). Each reading is judged by an any-word walk: after a git word, any later roster-verb word, and after that verb any no-verify prefix or, on commit and am, any single-dash word holding an n, with no -- terminator, option value or global option read, so a quoted span that splits into one can never swallow a real -n. So a roster git carrying a no-verify spelling there denies even where its name is quote-concatenated (ionice g"i"t commit -nv), escape-coded or cut at a NUL in ANSI-C quotes, glued to an operator inside a quoted script or to the opener of a process or command substitution (cat <(g"i"t commit -nv)), or followed by a quoted span holding -- or -m before the real -n; only the own git of the segment, the command word the wrapper peel resolves, keeps its value-aware reading: the text from that word on is read with only that git dropped and the text before it (assignments, wrapper options and values, redirections) with no git word dropped, so a hook-skipping git in a substitution there denies (A="$(g"i"t commit -nv)" git status, exec -a "$(g"i"t commit -nv)" git status, >"$(g"i"t commit -nv)" git status), and where the own git cannot be placed in the text no git word is dropped; a segment with no command word runs none, but bash still expands its redirection words, so the text of one holding a redirection is read too (>"$(g"i"t commit -nv)" denies); and the parse-error path reads every complete segment it recovers, the in-progress one and the unread text the same way, the unread text split also as the run-time rule below splits it (with every here-document body made inert, and from an ANSI-C escaped quote or a case word to the end of the text), so an unquoted here-document body holding an apostrophe does not cut a later g"i"t commit -m "a;b" -nv inside its quoted message. Disclosed over-deny of that reading: text that merely names such a git command behind any word denies (echo g"i"t commit -nv), and so does such text before the own git (exec -a "g"i"t commit -n" git status), in a redirection of a segment with no command word (>"g"i"t commit -nv") or in a comment after one (>x # g"i"t commit -nv); a quoted value holding a blank is split on a git before the own git or in such a redirection (A="$(g"i"t commit -m "fix -n")" git status denies), after an own git that cannot be placed, and after an own git with a git word before it, which the whole text, still read with its first git word dropped so that no earlier deny is lost, drops instead (exec -a git git commit -m "fix -n" denies); behind a command word the peel does not resolve to git a quoted value holding a blank is split into words, so sudo git commit -m "fix -n" denies; an option value holding an n counts after the verb (ionice git commit -uno -m x, ionice git commit -mnew); any later roster-verb word counts as the verb (ionice git log --grep commit -n 3); a quoted message or here-document body on a git line that names git, then a roster verb, then such a word denies (git commit -m "see git log; then commit -n"); a quoted operator or blank splits words inside any script, so a later unrelated -n there counts (sh -c "git commit -m x; ls -n"); and a \_ is read as a separator wherever it stands, not only inside an env -S value (env -S g"i"t\_commit\_-nv, which bash does not split, denies). A KNOWN RESIDUAL of that reading: it cannot see a git spelled with no run-time character in a here-document body that a shell reads as commands, or an ANSI-C dollar-quoted span that the two level readings do not decode (two or more shell levels in, or behind a quote inside a command substitution that stands in double quotes, which they pair at the outer level) and whose opener the raw match also pairs with an earlier dollar sign and quote. A GIT WORD BUILT AT RUN TIME has its own fail-closed rule: a word of a de-quoted reading that holds a dollar sign, a backquote, *, ?, [, { or an extglob opener (@(, +( or !(, which bash reads as a pattern under shopt -s extglob), or a tilde word (~, ~+, ~-, ~N, ~+N, ~-N or ~name with no / after it, which bash expands to a directory name; a quoted or escaped tilde counts only after the first word, where eval or sh -c reads it unquoted) (an expansion, a command substitution, a glob, an extglob pattern, a brace expansion or a tilde-prefix, as in $G commit -nv, "$G" commit -nv, g$(true)it commit -nv, g${x}it commit -nv, $(echo git) commit -nv, /usr/bin/g?t commit -nv, /usr/bin/+(gi)t commit -nv, HOME=/usr/bin/git; ~ commit -nv) may be git, so a later roster verb in the same raw command piece (split at ;, &, | and newline outside provable quoting; split again with every here-document body made inert, since a quote character or ) in a body bash reads as text re-pairs that split, as with a body line holding an apostrophe before $G commit -m "a;b" -nv; read from a piece of either split holding an ANSI-C dollar-quoted span with an escaped quote (backslash, quote) or a case word to the end of the text, since a split cuts inside those, as in $G $(case a in a) ;; esac) commit -nv, and a body quote that re-pairs the first split can carry such a span across its pieces, as with a body line holding an apostrophe before $G -c with an ANSI-C dollar-quoted value holding an escaped quote and a ;, then commit -nv; and read whole too where the text holds the characters of an extglob opener, since an extglob group keeps a blank or separator inside it in one word, as in /usr/bin/@(git|x;y) commit -nv) carrying a hook-skip spelling for it, or followed by any word built at run time, which may expand to one ($G commit $O, $G commit -$N, $G commit -${N}, $G commit {-nv,}, $G commit {--no-verify,}, $G commit ~ with HOME=-nv, as the closed literal grammar denies git commit $O), denies, behind a wrapper (sudo $G commit -nv, sudo ~ commit -nv), inside an sh -c or eval script (sh -c "~ commit -nv", eval \~ commit -nv) and on a here-document body line too; and each git that a de-quoted reading assembles and the closed literal grammar does not read as written (quote-concatenated, behind a wrapper or not) denies when its roster verb is followed by a word built at run time (g"i"t commit $O, sudo g"i"t commit ~, and sh -c "g"i"t log; g"i"t commit \$O", whose first git runs an off-roster verb), read with every standalone git (no letter, digit, underscore or - on either side) masked out of the piece, since the closed literal grammar judges each as written, so a git operand, message or option value in the same piece hides none (g"i"t commit $O git, g\it commit $O -m git, g"i"t commit $O --author=git); a git glued to a - is left intact, so an option name like --git-dir keeps its text, the option-skip walk skips its value, and a quote-assembled git before --git-dir <value> and a roster verb denies (g"i"t --git-dir .git commit $O); the grammar still scans every git at a word boundary, a dash-glued one included, and the walk can start from a git the mask leaves (echo -x-git commit $O denies). It is read over the whole raw text on the parsed path after every other deny, and on the ordinary parse-error path after every other deny there, the denies of the raw fallback included, so a command one of them denies keeps its message (git status; $G push --no-verify <<<x keeps the fallback reason) and one the fallback would only note or allow ($G commit -nv <<<x) denies. A word with no such character keeps the readings above (make commit -n, make g++ commit -n, make user@host commit -n, a~b commit -nv, ~/bin/x commit -nv and "~" commit -nv allow), and so does such a word with no roster verb after it carrying a hook-skip spelling or followed by a word built at run time ($G log -n 5, $G log -n $N, $G push -n origin x, $G commit -m x). Disclosed safe-direction over-deny: any such word counts, an operand or quoted text included, so pytest tests/*.py -k commit -n 4, find $HOME -name commit -print, find ~ -name commit -print, echo $((1+1)) commit -nv, git status # $X commit -n, gh pr create --title "$t" --body "denies commit -n", and a git commit -m "$(cat <<EOF ... EOF)" message whose body names a roster verb and then such a spelling deny too; a tilde word counts whatever it expands to (~root commit -nv), even where a quote in its prefix stops bash expanding it (~"" commit -nv); so does any word built at run time after the roster verb, a safe value included ($G commit -m "$msg", $G push origin "$branch", $G commit -m "see ~", g"i"t commit -m "$msg", and the here-document message form after $G commit); any quote-assembled git starts a walk, an operand included (g"i"t log -- g"i"t commit $O denies), and so does a git that the attached value of an option names, read as env -Sgit runs git (git log -Sgit commit $x denies); and where the text holds an extglob opener, the whole-text read spans separators (ls @(a|b); echo commit -n denies), as does the read from an ANSI-C escaped quote or a case word to the end of the text (case $x in a) ls;; esac; echo commit -n denies). Disclosed residual: a verb also built at run time ($G $V -nv, $G co$()mmit -nv, and $G {commit,-nv}, whose one brace word carries the skip option too), a name bound to git by hash -p, an alias or a shell function (hash -p /usr/bin/git G, then G commit -nv), words fed to git from another stage (echo commit -nv | xargs $G), a git builtin run by its dashed name (/usr/lib/git-core/git-commit -nv, /usr/lib/git-core/git-push --no-verify, $(git --exec-path)/git-commit -nv, $P/git-commit --no-verify) and a git alias defined on the command line of a git word built at run time ($G -c alias.ci=commit ci -nv), whose roster verb no reading sees, the here-document shapes the inert-body split misreads: a stray << inside quotes, in a comment or as an arithmetic shift, on the real operator line or a line before it (echo "<<X" && cat <<EOF, cat <<EOF # <<X, cat <<EOF "<<X", cat <<EOF >f; (( 1<<2 )), cat <<EOF; echo $((1<<2)), or echo "<<X" on an earlier line), which opens a body that never ends, and a delimiter with a line continuation inside it, an ANSI-C or locale quote, an escaped blank or a blank inside quotes (cat <<E\ then OF on the next line, cat <<$"EOF" or the same word in ANSI-C dollar quotes, cat <<E\ F, cat <<"E F"), which never matches the line that ends the body; after each a body quote can still re-pair the split, so a body line holding an apostrophe before $G commit -m "a;b" -nv allows; and, behind an unjudged # where the doubt test below decides instead of this rule, every skip option built at run time with no dash-prefixed option token in the text, whatever builds it and on every roster verb, behind a wrapper and after any word built at run time that may be git: an expansion, a command substitution, a brace, parameter or tilde expansion or a glob ($G commit $O, $G commit {-nv,}, $G commit {x,-nv}, $G commit ${P:--nv}, $G commit ${O:+-nv}, $G commit $(printf %s $O), $G commit ?nv, $G commit [-]nv, $G commit * beside a file named -nv, $G commit ~), $G am $O x.mbox, $G push, merge, pull or rebase $L, sudo $G commit $O, /usr/bin/g?t commit $O, HOME=/usr/bin/git then ~ commit $O, the same after an unjudged # inside a command substitution or a backquote, and a verb built at run time there ($G $V -nv), are not read. A verb off the roster is untouched (git log with any arguments, git rev-parse, git status). The unparseable fallback applies the SAME scan over the raw text. The ASK heuristics: a checker-shaped segment - a known checker command word (pytest, tox, nox, mypy, ruff, flake8, pylint, eslint, tsc, jest, mocha, rspec, shellcheck, gitleaks, pre-commit, ctest and kin), a command whose name PARTS (split on non-alphanumerics) contain test/check/lint/verify/validate/audit/selftest/conformance (run_all_checks.sh, selftest_aiqt_hooks.py; exact part match, so a word like "latest" never trips a substring), or a known runner (make, npm, yarn, pnpm, npx, go, cargo, mvn, gradle, python, bash and kin) invoking such an operand - whose failure is swallowed by an immediately following unquoted "|| true" or "|| :", or whose output is piped by an unquoted "|" into a truncating sink (head, tail) that can cut the failing output, while under default pipeline semantics the exit status of a pipeline is that of its final stage, not that of the checker, ASKS. The DENY tier holds to ONE canonical shape the lexer can prove, and both of its ends are ALLOW-LISTS, so a shape the lists do not name costs the note and never a refusal: the first simple command of a pipeline (at the start of the command or after ;, &&, & or a newline, never joined to the command before it by ||, since it then runs only when that command fails, or never, as after true ||; joined by && to known-benign commands it runs whenever they succeed, and the deny text holds whenever it runs, so cd /x && pytest | head denies as cd /x; pytest | head does; every earlier top-level segment of the command a known-benign command, that is cd with one directory operand, echo whose every argument is a plain literal word (no $, backslash or quote anywhere in the echo command, no glob or brace, no word starting with -), true, : or pwd with no argument, or an empty line, each with no redirection and no ${ or $[ expansion; printf is never known-benign, since a format can assign a variable (printf %n PATH sets PATH to 0); any other earlier command, such as an assignment, export, declare or typeset with any flag, set with any option, source or ., alias, unset or a function definition, can set the environment, options or configuration of the run; outside any case compound; no reserved word, no command substitution or backquote; its stdout not redirected; only the nice, timeout and time wrappers are peeled, each with its own option grammar; a leading VAR=value word or the env wrapper leaves the run outside the deny tier, since the environment can set its mode, as PYTEST_ADDOPTS=--collect-only and MAKEFLAGS=n do) is a KNOWN VERIFICATION INVOCATION on the producer allow-list (the _GATE_VERIFY_RUNS table in aiqt_hooks.py, plus the python and shell script forms), each tool written with the exact run forms it accepts: pytest with its run options and test-path operands (never -o, --override-ini or -c); python or python3 with only -I and -B, running -m pytest with a pytest run form, a script whose basename starts with selftest, check_ or run_all_checks with no argument, or any .py script whose only argument is --self-test, --selftest, --check or --verify; bash or sh running a script with one of those basename prefixes and no option or argument; make test, make check or make lint (one target; only -s, -k, -C, -f, -I, -j and -l, the GNU Make long forms --silent, --quiet, --keep-going, --directory, --file, --makefile, --include-dir, --jobs, --load-average and --max-load, and --no-print-directory; no VAR=value argument); npm test and npm run test, lint or check (no option); ruff check with its report options (never --config, --fix, --watch, --exit-zero, -e or a listing) and ruff format --check; mypy (never --config-file); eslint (never -c, --config, --fix or --print-config); go test (never -c, -list, -run ^$ or an unlisted flag, -shuffle only with off, on or an integer, -mod only with readonly or vendor); cargo test (never --no-run or a -- passthrough) and cargo fmt --check; tox (no subcommand but run, never -c) and nox with only the options -s, -e, -k, -p, -t and -f (each with its value), -r, -R, -v and -x, and the long options --session, --sessions, --keywords, --python, --tags, --noxfile, --reuse-existing-virtualenvs, --no-reuse-existing-virtualenvs, --verbose, --stop-on-first-error, --error-on-missing-interpreters and --no-error-on-missing-interpreters (no listing flag). These options, which supply configuration on the command line, are on no form: pytest -o, --override-ini and -c; ruff --config, which also takes inline settings such as fix-only=true; mypy --config-file; eslint -c and --config; tox -c. Other listed options also select rules or name a configuration file (eslint --rule, ruff --select, pytest --cov-config); none of them is known to change the mode of the run. The eslint options are those that eslint 6.4.0 --help lists (checked 2026-10-07); --no-config-lookup and --no-error-on-unmatched-pattern are not among them (eslint 6.4.0 rejects both), so they are not listed and note. A subcommand, positional operand or option outside a listed form leaves the run outside the deny tier, and a separated option value that starts with - is read as a possible unlisted option. The run pipes (| or |&, a newline after the pipe allowed) through zero or more stages on the pass-through allow-list (plain cat, tee with only -a or -i and its output files, tr with its own options, uniq with no option or operand; each with stdout not redirected) into a stage on the sink allow-list, a closed list of exact shapes: head or tail with no option or with exactly one of -n N, -nN, --lines=N or -N, N a positive decimal integer with no sign; grep, egrep or fgrep with exactly one limiting flag (-m N with N at least 1, -q, -c, -l or -L, each its own word) and otherwise exactly one pattern (one -e PATTERN, or a positional operand that is the last word); rg with exactly one of -q, -c or -l and one pattern (rg -L is --follow, not a sink); each through the env, nice, timeout and time wrappers, with no input file operand and stdin not redirected. cut is never on the list (cut -c1- keeps every byte), and neither is rg -m: an rg configuration file named by RIPGREP_CONFIG_PATH can add --passthru, under which rg -m 1 passes every line on (ripgrep 15.1.0, checked 2026-10-07), and the hook cannot see that file. Every other spelling (tail +N, head -n -N, -c, -f, --help, a long option, a cluster, a second option or pattern, --passthru) only notes. It DENIES with a message that claims only that shape (a command on the list of known verification runs whose output is piped into a sink on the list, naming what that sink passes on: only its first or last N lines, only the first N matching lines, nothing, a count or a name; for rg, what it normally passes on, since an rg configuration file named by RIPGREP_CONFIG_PATH, which the hook cannot see, can add an option that changes it, as --stats and --invert-match do, though no option tried made rg -q, -c or -l pass the input on (ripgrep 15.1.0, checked 2026-10-07), while GNU grep 3.12 reads no configuration file and ignores GREP_OPTIONS; naming the sink as typed, its wrappers and a grep pattern aside, with any count written without leading zeros; and saying that by default the exit status of a pipeline is that of its final stage, which the sink need not be) and names the fix: run the check alone with its full output in a file at an absolute path and no pipe after it, take the verdict from the exit status of that command itself, and read the file in a separate step. A real-file tee stage before the sink does not exempt the command (the status is still not that of the check; the message names the tee file, says it holds only the filtered output when a filter precedes the tee, and never suggests dropping a stage), and pipefail does not exempt it (set -o pipefail makes the pipeline fail when any stage fails, though not always with the status of the check, and the output is still cut; a set command earlier in the same line is not a known-benign command, so that line only notes, and pipefail set by an earlier call is invisible to the hook). A count is judged lexically (ASCII digits, at least one of them not 0), so a count of any length never reaches an integer conversion: a count of 4,400 zeros notes, and one of 4,401 nines denies. Everything outside that shape only NOTES (allow-with-note), and these are the disclosed misses of the deny tier: every tool, runner, subcommand, mode or option the producer allow-list does not name (pnpm, yarn, npx, uv, poetry, bundle, mvn, gradle, rake, deno, bun, ctest, pre-commit, jest, vitest, golangci-lint and the other checker words; a directly executed script; a script run with an interpreter option or an extra argument; python -m for any module but pytest; every information or maintenance mode of a listed tool, such as --help, --version, --collect-only, --cache-show, a listing, a config dump, a dry run, an installer or a fixer; and common forms left noting, pytest -q -- tests, python3 -IBm pytest, cargo fmt --all -- --check, a cargo test -- or npm test -- passthrough, go test -mod=mod); a producer whose environment the command sets (a leading VAR=value word, the env wrapper, a make VAR=value argument) or whose configuration it supplies on the command line; a producer pipeline after any earlier command in the same line that is not known-benign (export PYTEST_ADDOPTS=--collect-only; pytest | head, set -a, typeset -x, printf, a redirection, an earlier pipeline, make build && pytest | head) or joined by || to a known-benign one (true || pytest | head); any sed or awk program, as a filter stage or as the last stage (the hook parses no program, so 5q, $!d, NR>5{exit} and a plain substitution all note alike); a sort, grep or other filter stage outside the pass-through list; a compound or grouped producer or sink (a brace group, a subshell, a for, select, while, until, if or case compound, a ! negation, any pipeline inside a case compound); a producer that is not the first stage of its pipeline; a command substitution, a process substitution or a here-string; a wrapper beyond the four (command, sudo, nohup, stdbuf, exec, ionice, chrt, taskset, xvfb-run, unbuffer, script, watch, busybox, env -S); a filter stage outside the plain list; and a script known only by a weaker name part (./test.sh, gen_test_data.py, tools/run_tests.sh). These note when a checker-shaped command, or a command that carries a --self-test, --selftest, --check or --verify argument and is not a text or lookup command such as printf, grep or rg (seen through any of those wrappers, or right inside a substitution, which the hook reads from the raw text, so a substitution note says the substitution appears there and asserts neither that it runs nor where it sits, and a || is not read as a pipe), is followed by a pipe and, anywhere after it, by a stage that may pass on only part of its input (a sink on the allow-list, any other head, tail or cut reading its stdin, a grep-family stage with a limiting option, or any sed or awk program; the note names that stage and claims neither that it receives the output of the check nor that it drops any of it), an over-match by design (a later sibling pipeline, as in pytest; echo x | tail, also notes); R1 to R4 below stay outside both tiers. Whether a sink stage reads its stdin is judged by the option grammar of the installed tool, taken from its --help (GNU grep 3.12 at /usr/bin/grep, ripgrep 15.1.0 with the negations and alternative spellings its --help names, uutils coreutils 0.8.0 head, tail and cut; checked 2026-10-07), so a value such as rg --color never or grep --binary-files text is not read as an input file; an option outside that grammar (an unknown option, an abbreviated long option, an option only another grep has, such as the ugrep that a shell function may run as grep, whose options were not probed) leaves its arity unknown, so the stage notes rather than allowing silently, and a limiting long option is also matched by any abbreviation that is no exact option, at any length, as getopt resolves a prefix (--m 1, --m=1, --q, --s; an ambiguous --c that could be --count notes too). The operands are resolved before deciding: a stage reads its stdin when it has no input operand, recursive or not (GNU grep 3.12 under -r, -R or -d recurse with no input operand reads the working directory instead, unless a later -d read or -d skip sets the mode back, when it reads its stdin, checked 2026-10-08, but a recursive grep may still read its stdin, since a grep that is another implementation can: the ugrep that a shell function may run as grep reads its stdin under -r too, checked 2026-10-08; a -d or --directories value GNU grep rejects, attached, separated or as --directories=, and an option left without its value at the end, such as a final -d or --directories or cut log.txt -f, count as reading the stdin, conservatively: GNU grep exits on either, though an earlier -f - pattern file is read from its stdin first, checked 2026-10-08, while a value abbreviated as grep accepts is a mode) or when any input operand, the -f or --file pattern file of grep or rg, or the --exclude-from list of grep is - or one of /dev/stdin, /dev/fd/0, /proc/self/fd/0 and /proc/thread-self/fd/0, an absolute path being compared after its repeated slashes are collapsed and its . and .. are resolved lexically (so //dev/stdin, /dev/./stdin and /dev/../dev/stdin match), even beside other files or under -r. A grep word also counts as reading its stdin when its first operand is - or an operand comes before one of its -f or --file options with no later operand or a later one naming the stdin: GNU grep reads that - as the pattern and that operand as an input file, but the ugrep that a shell function may run as grep reads the - as its stdin and the operand as the pattern (reported 2026-10-08). A head, tail or cut stage that counts as reading its stdin notes, and so does a grep-family stage that also carries a limiting option. Disclosed silent misses of the note tier, as on main, for example: a limiting option the scan does not know (an option only another grep has, such as a ugrep line-range or file-count limit), a head, tail or cut option of another implementation that the uutils and GNU grammar reads as a known one of another arity, a stage that reads its stdin through any other path to it (a symlink to it or a path through one, such as /dev/fd/../../self/fd/0, which the kernel resolves through the /dev/fd symlink to /proc/self/fd/0, /proc/PID/fd/0 with a literal PID, a relative path such as ../dev/stdin) or through a file option not probed (rg --ignore-file), and another grep reading a GNU grep operand list in a way other than the two named above. Predicted false refusals of the deny tier, re-issued without the pipe: a listed run form that does not verify in a given project (a make test target that only builds, a .py script whose only argument is --check but which does not verify, a script named with a selftest, check_ or run_all_checks prefix that does not verify). It does NOT catch: a gate weakened at its SOURCE (a workflow edited to carry continue-on-error or allow_failure, a deleted test, a lowered threshold - file-edit surfaces, not Bash commands); an environment-variable skip a hook manager honours (SKIP= for the pre-commit framework, HUSKY=0); hooks disabled by CONFIGURATION (core.hooksPath pointed at an empty directory, inline -c or persisted); an alias defined in a configuration file, a wrapper, or a shell function that renames git or the checker (residual R2; a git command that defines a git alias for itself, through a -c or --config-env key in the alias, include or includeIf section or a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n assignment naming one, its git word read wherever it stands in its segment (the command word, or a word behind a reserved word, a { group, !, time or a wrapper word; not inside the text of another word, such as an sh -c or eval body), DENIES, on an unparseable command too when the partial lex recovers that git segment or the text names git and holds such a definition anywhere in its de-quoted words, past a here-string, an unquoted heredoc or a {fd} redirect the lexer stops at included, since the subcommand and options it runs cannot be read (a -h or --help does not exempt it, since git runs the alias with the flag passed on to it, so git -c alias.c=VALUE c --help, which on git 2.53.0 only printed the alias, is refused too, a disclosed over-refusal), and so does every git command of a command whose text names GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT or GIT_CONFIG_KEY_ anywhere (sought once every quote character, backslash, dollar sign and newline is removed, so a quoted, escaped or continued spelling counts, in any word, a here-string, a comment or an echo) and names git as a word anywhere, parsed or unparseable alike (git wherever it stands: behind a reserved word or a wrapper, or inside an eval or sh -c word), whatever form sets the variable and whatever its value, since a shell sets one in more ways than a reading of forms can list (on git 2.53.0 with bash 5.3 an export, declare -x, typeset -x or local -x word, a bare assignment or a readonly word to a variable already exported, and under set -a also a bare assignment, read, printf -v, a for loop variable, a default-value expansion or a nameref each defined an alias), so a mention that defines no alias (an echo, a comment, an unset, an unrelated value) counts too, a disclosed over-refusal, or that runs under a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n naming one in the session environment the hook inherits, which counts as such a text does, git wherever it stands (not read: a variable name built at run time, such as an expansion after GIT_CONFIG_, a brace expansion or an escape decoded inside an ANSI-C quote, and a variable set outside the command text, in the startup files of the Bash tool shell or in a sourced file or a script the command runs); a definition counts however it is spelled: the lexer records how each word was spelled, and the rule is closed over the whole region after a git word through its subcommand word (every global option word, the value of any option and the word read as the subcommand), where a word that carries an unquoted parameter or command expansion (a dollar sign or backquote outside quotes, one bash reads literally included), which bash may split into several words such as a -c and a definition, or whose text came through an ANSI-C or locale quote (a dollar sign right before a quote character) defines a git alias whatever it decodes to, as does a word there holding an unquoted brace and a comma (bash expands it into several words), other than a -c or --config-env word and its value, and a -c value or a --config-env word whose key part (the text before its first =, or the whole word when it has none) is spelled through a parameter or command expansion, inside double quotes too, such a quote or a brace expansion; on an unparseable command the same rule reads the de-quoted words of each command, past a here-string, a heredoc or a {fd} redirect, where a quoted or escaped dollar sign, backquote or brace counts as an unquoted one would; on such a command a git region the lexer did not read denies, fail closed: where the partial lexer stops before the option region of a git command or inside it (a leading here-string, as in git <<<x -C DIR $X), that region is unread, scoped to a segment that runs git as its own command (its command word is git or a wrapper the guards resolve, never a git word another command merely carries); the text past where it stopped is read two ways, and a deny from either stands: a quote-aware re-lex of the text past each construct the reader can measure on the same text (the target word of a here-string, or the balanced parentheses of a process substitution with backslash escapes honoured), each recovered command read through its subcommand word, and the every-git-word reading, which walks the region of each git word in the raw text from the stop, denying where a region cannot be read to its subcommand word or holds a word built at run time. The re-lex alone decides only where its ground is proven: the construct measured with no backslash escape met, no git word inside the text of the measured construct itself (the body of a process substitution and the word of a here-string are run or expanded, so a git region there keeps the raw reading), the resume at a command separator, and every git word of the re-lexed text read as a word of a recovered command (one in a comment or a redirect target is not); there a git word the re-lex proves to be quoted text (an echoed git word past a stop, a pull-request or issue body naming git) allows, while a plain git word any command carries as a word of its own (under a wrapper the guards do not resolve, such as flock or ionice, included) is still walked and denies where its region runs open or holds a word built at run time. At every other point (an unquoted heredoc, a {fd} redirect or an unbalanced quote the reader cannot measure, a resume that is not at a separator, a measure that met a backslash escape, a git word inside the measured construct, one in text the re-lex gives no word for, or a git word beside a $ or backquote inside a re-lexed word) the every-git-word reading decides too, so a quoted or commented git word there still denies, a disclosed over-refusal; a literal region read in full keeps its decision either way. Disclosed over-refusal: a word naming git that is the last word read before such a construct and is its command word (git <<<x log) still denies, while a git word only an operand of the segment the lexer stopped inside (grep -c git <<<x) is not read as a git command, so it allows. Remaining residual of this reading: the every-git-word walk reads the region of a git word on the raw text, so from inside quoted, comment or here-document text it can only over-refuse, never allow a git command the re-lex read; a git command whose git word is itself built at run time (an expansion that expands to git) is outside both readings, as on a parsed command; a word reads as one word to bash only when no part of it can expand to several words: a double-quoted expansion of the positional list or of an array subscripted with @, in any parameter-expansion form, with or without an operator or substring, and an indirect expansion whose reference (a variable, an array element read through a subscript of its own, a positional parameter or $*) can hold a reference to such a list (a double-quoted $@, ${@:1}, ${A[@]}, ${A[@]:0}, ${!A[@]}, ${!R}, ${!A[0]}, ${!1} or ${!prefix@}), expands to one word per element, so it counts there as an unquoted expansion does; a word whose every expansion stands inside double quotes and expands to one word keeps the plain reading on a parsed command, so behind a literal key, as another option value or as the subcommand it defines no alias; a disclosed over-refusal (the safe direction): an unquoted variable in a git option or subcommand position now denies (git -C $DIR log, git $SUB, git -c core.pager=$P log), as does one after any other word naming git (grep -r git $DIR) and a git command inside an unquoted backquote substitution whose closing backquote the lexer reads into its subcommand word, and quoting the variable restores the plain reading of this alias rule only, not an allow: the closed literal grammar above denies an expansion in the global options or the subcommand word of a git command, quoted or not (git -C "$DIR" log), and the deny reason names such a word as one the shell builds at run time, with the fix (quote the expansion, spell a -c key literally, write a list out, put a here-string after the subcommand word), and keeps the alias wording for an actual alias definition, and a key that is literal in effect but spelled through an expansion or such a quote reads as an alias, as does a brace word there that expands to no option; not read: the git word itself, read by its decoded text alone (an unquoted expansion in it, or a brace word that is the git word, which bash can split into git and a definition), and a word in that region that bash builds by filename expansion (an unquoted star, question mark or opening bracket matching a file name such as -c) or by a brace sequence expression, which holds no comma, and a word whose every expansion stands inside double quotes and expands to one word, which keeps the plain reading by decision, so one that names an option such as -c at run time, before a literal definition, is not read as one, though in the global options of a git command the closed literal grammar above denies it); a checker outside the lexicon whose name and options carry no checker-shaped part (residual R3: tools/doctor.py, sha256sum -c, a build; an unknown gate roster is the honest bound of a lexical checker notion); a swallow spelled differently (|| exit 0, || echo, ; true, a set +e, a conditional wrapper); for the note tier, a truncating stage that does not follow a pipe after the checker-shaped command; a plain "| grep PATTERN" filter or "| wc", which neither tier counts as a truncating sink (residual R4); a versioned interpreter name (python3.12 -m pytest | head), which neither tier reads as a checker, as on main; a script body inside sh -c or bash -c quotes or an eval/xargs indirection (one opaque token to the lexer; residual R1); a swallow separated from its checker by grouping parens (( pytest ) || true reads as adjacent to the paren, not the checker); pipefail state (under set -o pipefail a failing checker does make the pipeline fail, though not always with its own status, but the output is still cut and shell state is invisible here, so a piped checker still gets the note, or the deny in the deny tier); or any execution outside the Bash tool. A checker probe whose swallow is INTENTIONAL (a discovery run) and a non-gate command whose NAME merely looks checker-shaped (a test.sh that gates nothing) are accepted over-ASKS, the disclosed scoping cost of the heuristic; a gate-shaped command the lexicon does not name is the matching under-coverage, both stated rather than hidden. Two further safe-direction over-blocks are disclosed, both contrived and non-bypassing: a trailing --verify that cancels an earlier --no-verify (git models the pair as a boolean where the last wins, so git commit --no-verify --verify does NOT bypass hooks, yet this presence test is order-insensitive and DENIES it), and a clustered -h help flag (git commit -hn ..., where git prints help and does not commit, but the short scan reads the clustered n as the bypass and DENIES); re-issue without the contrived flag combination. On a shell parse error it fails SAFE with conservative raw probes (git plus a no-verify verb plus a --no-ver spelling DENIES; git commit or am plus a short -n cluster DENIES fail-safe too, since the raw string cannot bind the cluster to its subcommand and so reads it as the bypass, the safe direction; a checker keyword plus a raw "|| true", "|| :", "| head", or "| tail", or a head, tail or cut word beside a process substitution or here-string, ASKS; before that raw probe, the complete segments a partial lex recovers ahead of the unparseable construct are judged for the deny tier, so DENY outranks the parse error; the segment the lexer was still inside when it failed is dropped, since its unseen tokens could change it, so a sink in that segment, as in pytest | tail followed by an unbalanced quote, is not proof and keeps the raw-probe note: the parse-error residual of the deny tier; a recovered git segment, the one the lexer was still inside included, that runs under a git alias the command defines DENIES as on a parsed command, and so does an unparseable command whose text names one of those git configuration variables and git, or names git and holds a git alias definition anywhere in its de-quoted words (split at blanks and shell operators), past a here-string, an unquoted heredoc or a {fd} redirect the lexer stops at included, a definition spelling in any word counting, a disclosed over-refusal), which OVER-MATCH by design: prose naming the keywords, a --no-verbose, or an unrelated -n cluster can deny or ask an unparseable command that was safe - re-issue it parseable. The same parse-error fallback also reads each probe that can deny over de-quoted readings of the text (quote characters, backslashes and a $ before a quote removed, line continuations joined, and each ANSI-C quote with its escapes decoded), so a verification-hook bypass that the raw text hides behind quoting (a commit verb split by quote characters, a --no-verify split by a quoted dash or a line continuation, a -n assembled from quotes, or a --no-verify or commit spelled with ANSI-C hex, octal or Unicode escapes) DENIES as its plain spelling does. Quoting alone is no ground to deny there: an unparseable command whose quoted arguments spell no bypass in any reading (a quoted word in a here-string, quoted paths in process substitutions) keeps the raw-probe decision, and a bypass spelled so that no such reading assembles it (a variable, an alias) is the open-grammar residual; but a roster-verb git segment that carries a here-string or a process substitution DENIES through the closed literal grammar above, which refuses both, quoted message or not (git commit -m "fix bug" <<<x, git commit -m x <<<x, git commit -F <(echo "msg")), the stricter fail-closed reading. Behind a # whose comment reading depends on context (the shared lexer comment rule) that the lexer could not judge under both readings, the fallback is stricter and fail-closed BY CONSTRUCTION, with no spelling list for a new form to escape: text whose quoting the rough quote removal does not decode (an ANSI-C or locale quote, or a quote character or backslash next to a letter) DENIES; a head, tail, grep, egrep, fgrep or rg word in text holding a pipe DENIES, wherever each stands in it; text naming git, or holding a dollar sign, backquote, *, ?, [, {, a tilde or the @, + or ! of an extglob opener before such a subcommand (a word built at run time that may be git: $G commit -nv, /usr/bin/+(gi)t commit -nv, ~ commit -nv), with a subcommand that accepts --no-verify (commit, merge, push, pull, rebase, am) together with ANY dash-prefixed option token DENIES, because behind such a # nothing can bind an option to the grammar of its subcommand, so no dash-prefixed token can be proven harmless - a deliberate OVER-DENIAL, new with this rule and stated here: a harmless option there (git commit -m x behind that #, or make commit -j4 after a backquote that holds such a #) is denied too; put a blank before the # outside any grouping or substitution, or drop it. Remaining text holding a checker-shaped word (judged with the same name splitter and lexicon the readable checker heuristic uses, never a re-spelled probe, so a spelling the note tier marks is never quieter here and a non-ASCII letter separates name parts exactly as it does there) beside a pipe, process substitution or here-string allows-with-note. These stricter doubt tests run ONLY behind such an unjudged #: a parseable command and the failed-main-reading floor keep the decisions above, and the plain parse-error fallback keeps them with only the de-quoted bypass deny stated above added. Overlap with shipped controls is compatible, not duplicated: a git push --no-verify --force to a protected branch is denied by BOTH this control and protected-line-guard, a git commit --no-verify on a protected HEAD denies here while protected-line-guard asks (deny outranks at the platform), and no other shipped control covers --no-verify, "|| true", or a foreground truncating pipe on a non-diff command (the diff-source truncation reasoning governs diff producers only, and orch-truncation-guard, which is unchanged, denies only a background dispatch piped into head or tail in an orchestrated session). It is BEST-EFFORT: it targets the accidental case, not an adversary determined to evade it. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 3.29 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:gensrc-edit-guard
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Write|Edit|MultiEdit|Bash
  • Class: c
  • Channel: plugin

Technical limits (from the enforcement ledger)

A REGISTRY-DRIVEN path guard, not a content judge, on two channels: a Write/Edit/MultiEdit file_path, and the write sinks a Bash command spells literally. It fires only when a target resolves onto a kind=file or kind=tree entry of a .aiqt/gensrc.json read at decision time. The registry consulted is the one at the scrubbed git toplevel (every ambient GIT_* removed) of the TARGET, found from the target itself when it is an existing directory and otherwise from its nearest existing ancestor directory, not of the session cwd, so a write from one repository into a sibling repository or store is judged against that repository registry, and an ambient decoy repo cannot redirect the registry read; in a linked worktree or a submodule the toplevel is that worktree or submodule, and a target is judged against EVERY registry from its innermost repository up the chain of enclosing repositories to the session repository (at most 4 levels, more being a cannot-evaluate), so a repository nested inside a registered tree (a git init, clone or worktree add under it) never shadows the enclosing registry. A target outside every git work tree has no registry and is not judged (a Write/Edit there allows with an informational note; a Bash sink there allows silently). Coverage is exactly the registry: an unregistered generated artefact is not judged (the gensrc-registry drift gate is the backstop for a stale registry), and a repository with NO registry gets no coverage at all (an ABSENT registry is the inert ALLOW by design; adopters and private stores author their own version-1 .aiqt/gensrc.json at their git toplevel). kind=block entries are EXCLUDED by design: a mixed file carrying a generated block (CLAUDE.md, site/roadmap.html) is allowed whole, because a path guard cannot see which lines an edit touches; the block drift gates backstop those. A Write, Edit or MultiEdit to a sibling settings file such as .claude/settings.local.json is not judged by this guard either (no generator writes it, so no registry entry names it; that it holds only personal settings is a convention, not enforced), yet it can change hook behaviour for later sessions; the settings precedence Claude Code gives that file is not verified by this pack. BASH SINKS, lexed by the shared _lex_command in partial mode under every reading of the uncertain # characters the command holds (the comment rule of that lexer), each reading judged on its own from the session cwd, so a directory change one reading makes never reaches another (when that lex stops, it is retried once with each unquoted heredoc delimiter of plain word characters quoted, so a redirect on its opening line is still judged; a command is lexed first with no record for a segment holding no word and no redirect, and in full only where a segment can write), after peeling leading VAR=value words, leading reserved words, the env, nice, timeout and time wrappers with their option grammar, and the bare command, exec, builtin and sudo words: an output redirect (>, >>, >|, &>, &>>, <>, and >& to a file, with or without a source fd, every redirect of a command and not only the last) to a static path outside /dev and /proc; the file operands of tee (an operand of - is uncleared: a literal file to GNU tee, stdout to others); the file operands of sed with an in-place option (-i, -iSUFFIX, a short option cluster holding i, --in-place[=SUFFIX]); the file operands of perl with -i in a switch cluster (a cluster holding a switch with its own argument grammar, one of -0, -l, -F, -x, -C, -d, -D, -I, -M or -m, cannot be fully resolved, -lpi being -l -p -i to perl, so it is uncleared); the destination of cp, mv, ln and install (the last operand, a -t or --target-directory value, the destination joined to each source last component when it is an existing directory, the whole source under cp --parents, the working directory for ln with one operand, and each operand of install -d; a backup option of any of them, -b, --backup, -S or --suffix, writes a side file beside the destination under a caller-chosen name, so it is uncleared beside the judged destination); the local destination of rsync (not under -n or --dry-run; a remote SOURCE is uncleared, because the local file a transfer writes is not the operand spelling, and the side-file options -b, --backup, --backup-dir, --suffix, --partial-dir and --temp-dir are deliberately not modelled, so a command carrying one is uncleared); the operands of truncate; the of= value of dd; the -o or --output file of the time wrapper; a string-literal path opened for writing (a mode holding w, a, x or +), or written through Path(...).write_text, write_bytes or open, or shutil.copy, copy2, copyfile or move, in the literal program of python or python3 -c, parsed with ast.parse and never executed (a shutil copy or move from a computed source is uncleared beside its judged destination, and a program that changes directory, a chdir or fchdir call however reached, makes every relative constant write in it uncleared, absolute ones staying judged); and git checkout or git restore of a path from a named revision other than HEAD, which also denies a registered target inside a directory path. A relative sink resolves against the session cwd carried through a literal cd or pushd operand only while that directory change is CERTAIN for the segments that can see it: a cd in a pipeline element or a background job changes nothing (a subshell), a subshell saves and restores the directory, and after a tracked cd only segments joined by && (or a pipeline, which runs whole) run exactly when it succeeded, so a cd reached through || and any segment past the first ;, &, newline or || after a cd see an UNKNOWN directory (a skipped or failed cd leaves them in the original one), every relative sink there being uncleared, never resolved to a guessed path. A cd or pushd is UNCERTAIN when something besides its literal operand may steer it, and the directory after it is then UNKNOWN in the same way: a pushd stack rotation (+N or -N) or any pushd option (pushd -n moves only the stack); a cd option other than -L or -P; physical mode (cd -P, or a set or shopt segment that may turn it on, such as set -P or set -o physical) where the physical (realpath) reading of the operand differs from its lexical one; a .. component whose two readings name different directories (bash takes the lexical one but falls back to the physical one when the lexical one does not exist); and an operand that does not start with /, ./ or ../ and is not . or .., when a word of the command spells CDPATH or cdable_vars or the environment of the hook process carries a non-empty CDPATH; a ~ or ~/x operand when a word of the command spells HOME (below); an operand holding a ~ after = or : (cd x=~/d, below), which reaches no directory the guard names; and a cd or pushd run through env, nice, timeout, sudo or exec, through a time word bash reads as the time program rather than its keyword, or under a command word spelled other than cd, pushd or popd (a path such as /usr/bin/cd or ./cd, or another case such as CD, which names a program and not the builtin), which runs as a child process (or fails where no such program is installed) and never moves the shell. That time reading is taken from the raw segment, never from the decoded word: time is the keyword only where it is spelled exactly time and nothing but blanks, bare reserved words ({, !, if, then, ...) and a keyword time with its -p stands before it in its command; a time with any quote or backslash in it (\time, "time", t\ime, a single-quoted time), a time spelled as a path (/usr/bin/time), a time after a VAR=value word, a redirection, or a command or builtin word, and a time word whose place in the raw segment the guard cannot find are all read as the program. POSIX MODE: bash in posix mode does not read time as its keyword before a word starting with - (time -p, time --, time -o FILE), so when a word of the command may turn posix mode on (a word holding posix, as set -o posix does in any form the lexer decodes, or holding POSIXLY_CORRECT, as an assignment or export of it does) or the environment of the hook process carries POSIXLY_CORRECT (which starts bash in posix mode), such a time is read as the program too; this over-reads a set -o posix that bash runs only after it has parsed that time (set -o posix; time -p cd on one line, where bash still reads the keyword, an over-denial), and posix mode turned on in a way no word of the command spells (a sourced file, eval, an opaque word such as set -o $m, the shell startup files or snapshot, a shell started as sh or with --posix) is not seen. Two keyword readings are not followed, each an over-denial: a time after time -- or time -p -- (time -- time cd), which bash reads as the keyword, and a time spelled across a line continuation (ti, a backslash and a newline, then me), which bash joins into the keyword; both are read as the program. Every relative sink after such a change is also judged, as any sink is, from each directory the guard can name that the change may reach, at most 8: both readings, the operand under each literal CDPATH entry and each literal value of the variable it names, a ~ operand under each value HOME may hold, the unchanged directory under a pushd option or for a child cd, and for a rotation every directory the command has reached or may have reached before it; so a write that would land on a registered target from any of them DENIES, and one that would not is uncleared. This over-denies (a .. across a link is judged from both readings even where bash takes the lexical one, and a rotation from every directory reached), reads links as they stand at decision time (a link the command itself creates before the cd is not seen), and does not see a CDPATH, cdable_vars or physical mode that no word of the command spells (one set by a sourced file, eval, or the shell startup files or snapshot), except a CDPATH in the environment of the hook process. A ~ UNDER A SPELLED HOME: when a word of the command spells HOME (HOME=VALUE, export HOME=VALUE, HOME+=, read HOME, unset HOME, a HOME prefix on the command itself, or any other word holding HOME, such as XDG_CONFIG_HOME or "$HOME", a disclosed over-reading), a clean leading ~ or ~/x is never resolved from the HOME of the hook process alone: a cd or pushd operand is UNCERTAIN as above, every sink word (a redirection target, a sink program operand or destination, a time -o file) is UNCLEARED, and each is also read, as any sink is, under every value HOME may hold that the guard can name, at most 8: the HOME of the hook process (the one the shell is assumed to inherit, and the one a HOME prefix on a command word leaves in place, since bash assigns that prefix only after the words and redirections of its command expand; with no command word, as in HOME=VALUE > ~/x, bash assigns it first, and the literal value read next covers that) and each literal value of a HOME=VALUE word, an empty value, one starting with ~ and one set any other way (HOME+=, read HOME, a substitution) being only unknown; so a write that would land on a registered target under any of them DENIES, naming that value or the directory it reaches, and one that would not is uncleared. Those readings cover at most 512 words (argv words and redirections) of the segments holding such a ~ in each reading of the uncertain # characters of the command (each reading is walked on its own, so a command with several readings, at most 16, reads up to that many times 512 words); past that budget a ~ is read under no value (its sink uncleared, with a note that HOME may hold a literal value the command sets, and its cd unknown). The budget bounds the words read, not the decision time, which only the DEADLINE below bounds. With no such word, ~ resolves from the HOME of the hook process, and a HOME that no word of the command spells (one set by a sourced file, eval, or the shell startup files or snapshot) is not seen. A ~ AFTER = OR : inside a word (dd of=~/x, x=a:~/b) is a tilde bash expands in an argument or a redirection target shaped like an assignment, outside posix mode, and keeps literal inside it, so it never resolves as a literal ~ path: a sink word holding one (a redirection target, a sink program operand or destination, a time -o file) is UNCLEARED, a dd of= value that begins with it is also judged as a leading ~ is (under the HOME of the hook process, or under every value above when a word spells HOME), and a cd or pushd operand holding one leaves the directory UNKNOWN. This reads the decoded word, so a quoted ~ after = or : and one in a word bash does not expand (--output=~/x, a:~/x) are read the same way (an over-reading), and only a dd of= value is judged under a value of HOME: any other such word, and a ~user or ~+ after =, is only uncleared. READS ARE NEVER JUDGED: a command that lexes completely and spells no recognized sink is ALLOWED before any registry is read or any git process runs, and so is a command whose # readings the lexer cannot judge (below) when its text holds no >, sink program or wrapper word, in quoting the shared fallback decodes: nothing in it can write. That is the structural regeneration allowance: a generator (python3 tools/gen_*.py, opf render) writes from inside its own process and spells no shell sink, so it runs, and the registry repair (python3 tools/gen_gensrc.py) stays open even when the registry is corrupt; a sink on a generator run (python3 tools/gen_x.py > X) is judged like any other sink, and no regenerate string is ever executed, compared as text, or used as an allowance. git checkout -- P, git checkout HEAD -- P and git restore P without a source other than HEAD are never denied here: they restore the index or HEAD bytes their options choose (the index may hold staged, uncommitted content), so they ALLOW, with a note to regenerate and run --check when P is registered; git restore --staged alone writes no working-tree file and is not judged. The decision on a CONFIRMED registry match is a DENY-and-educate that names the target, its sources and its regenerate command (NO-ASK posture: this guard never returns an ask; read every ASK below accordingly). Among registry states, only a genuinely ABSENT registry (no entry at .aiqt/gensrc.json), or a readable one holding no file or tree entry (or, in a tree without the authoring marker, none but authoring-scoped ones), allows silently whatever the target. A PRESENT registry that cannot be read DENIES fail-closed every covered write that lands in its repository (a Write/Edit/MultiEdit target, a Bash sink, and an uncleared Bash write when it is the session repository registry; a restore is never denied, so a git restore of the registry stays open) and names the repair: a registry that is not a regular file (a symlink, FIFO, directory, ...; rejected by an lstat/S_ISREG probe before the open, which reads a STATIONARY non-regular registry as bad and does not block on a FIFO - a best-effort defence against the accidental case, with a registry concurrently SWAPPED to another type in the lstat-to-open window a disclosed residual, sibling to the hard-link and case/NFC-NFD aliases below), a registry that disappears in that same window (a delete race, which fails safe to DENY rather than reading as absent), an unreadable, non-UTF-8, oversized (over 1,000,000 bytes) or malformed registry, an unknown or non-integer registry version, and a malformed entry (including a control character in any entry target, or a scope other than authoring, a kind=block entry included). An entry scoped authoring (tools/gen_gensrc.py marks the tools/gen_hooks.py AUTHORING_ONLY_REL targets, so the shipped registry scopes .claude/settings.json) is honoured only in the pack authoring tree. On its match, by a Write/Edit/MultiEdit target or a Bash sink, the guard lstats .aiqt/authoring.toml at the repo root: an ABSENT marker (every adopter tree) skips that entry silently, so an adopter keeps its own .claude/settings.json, and matching goes on with the other entries; a regular file holding exactly format-version = 1 (the integer, never true) and role = "pack-authoring-repository" honours it with the DENY above; any other PRESENT marker (other content, unparseable or non-UTF-8 TOML, a symlink, FIFO or directory, an unreadable file, a file over 1,000,000 bytes, or one gone between the lstat and the open) DENIES fail-closed and names the marker repair, as tools/gen_hooks.py exits 2 on it (the two apply the same size bound). The marker is read only on an authoring-scoped match (or mention, below), so a damaged marker blocks no other edit; a marker swapped to another type in that window is the same disclosed residual as the registry. The field keeps registry version 1: a guard older than it tolerates scope as an unknown key and so honours the entry in every tree, denying an adopter edit of its own .claude/settings.json until that adopter runs this guard. These cannot-evaluate branches ALLOW WITH AN INFORMATIONAL NOTE: for Write/Edit/MultiEdit, a non-object tool_input, a missing or unreadable file_path or a control character in it, a relative file_path with no session cwd, an unresolvable target or one over the 4,096-byte resolution budget AFTER lexical normalization (a resolution budget, not a syscall limit: a short relative operand in a deep directory writes past any absolute cutoff), a target outside every git work tree or under more than 4 nested repositories, a target whose real path leaves its own resolved toplevel or a containment check that cannot be cleared, and an unresolvable registry entry; for both, an unreadable or malformed (empty/list/bool) tool_name. For Bash, a write the detector cannot clear is an UNCLEARED write, never resolved to a guessed path: the command DENIES when its text names a registered target of any consulted readable registry (every entry both by its basename and by its repository-relative path, case-sensitively as this filesystem is, each on path-word boundaries; an authoring-scoped entry only where the authoring marker is present) - a deliberate, disclosed OVER-DENIAL, since naming a registered target in a command whose writes cannot be cleared is treated as touching it, and a command that does not touch it is denied with it - and otherwise runs, ALLOWED WITH A NOTE saying its writes were not cleared. The uncleared states are: a sink whose target is opaque (a variable, substitution, glob, brace, a .. component, or a relative dev or proc path), a ~ sink in a command a word of which spells HOME (also judged under each value HOME may hold that the guard can name, above), a sink word holding a ~ after = or : (above), a sink whose normalized target is over the 4,096-byte resolution budget, a relative sink after a directory change the guard cannot resolve with certainty (cd -, a bare cd, an opaque operand, popd, an uncertain cd or pushd as above, an unmatched close-paren, a cd reached through ||, and any segment past the first ;, &, newline or || after a cd, where a skipped or failed cd leaves the shell in the original directory) or under env -C, an option the guard does not model on a sink program or a wrapper (a perl switch cluster it cannot fully resolve, an rsync side-file or backup option of cp, mv, ln or install, an in-place sed or perl suffix whose backup lands beside each file, and a tee operand of - included), an rsync remote source, a git checkout without -- that names an operand, git pathspec magic, a glob or --pathspec-from-file, a git --git-dir, --work-tree, GIT_DIR or GIT_WORK_TREE, a recursive copy into a directory that holds a registered target, a python -c program that does not parse, is formed with an expansion, writes a computed path or mode, copies from a computed source, or changes directory (every relative constant write in it is then uncleared), a stdin-fed python interpreter, a command the lexer cannot finish (a here-string, a process substitution, an fd-variable redirect, an unbalanced quote), a command whose # readings the lexer cannot judge (more than four uncertain #, or one in or right after a backquote substitution; it reads nothing past the first) when its text, as written or with its quotes and backslashes removed, lowercased, holds a >, a sink program or a wrapper word, or quoting that removal does not decode (an ANSI-C or locale quote, or a quote or backslash next to a letter), a sink whose real path leaves its own resolved toplevel, and sinks into more than 4 distinct or nested repository roots, under more than 32 distinct directories, or onto more than 512 distinct paths in one command (only those are judged). The mention filter is an over-denial trigger and noise control, not a coverage claim. Besides a confirmed registry match, a present-but-unreadable registry and a present-but-malformed authoring marker on an authoring-scoped match, only a MISSING tool_name (the shared fail-closed contract), a Bash payload with no readable command string, and a fault inside the Bash detector itself (it fails closed rather than let an unread command through) deny, and a registration on any event other than PreToolUse hard-blocks (exit 2). DISCLOSED RESIDUALS (not caught): a write whose target or program comes from a variable, eval, a sourced or invoked script (./fix.sh, bash fix.sh), a shell function or alias, command or process substitution (including one inside an unquoted heredoc body), xargs, find -exec, or an sh -c or bash -c string; a heredoc-fed or stdin-fed interpreter (python3 - <<EOF, only noted) and any interpreter program other than a literal python -c (node -e, ruby, perl -e without -i, awk -i inplace, ed, ex, vim), and in a python -c program any write API outside the list above (os.open, a from-import of shutil names, a Path built by joining); a sed w command or a script that opens its own output; patch, git apply, git am, git stash pop, git merge, git pull, git reset --hard, branch switches and other whole-tree git operations (the git-discard control governs those); tar -x, unzip, cpio, curl -o, wget -O and every program outside the sink list above, and a side file a modelled program writes through an option it does not model; a remote rsync destination; deleting or moving a generated file away (rm X, mv X elsewhere), which the drift gates catch; a cd in a pipeline under shopt -s lastpipe, and a { cd ...; } group, read as plain segments; NotebookEdit, MCP filesystem tools and any write outside Claude Code; a hard link, a case-insensitive-filesystem alias, or a macOS NFC/NFD Unicode-normalization alias that defeats realpath equality; and a change between the check and the write (TOCTOU). Matching is on canonicalized real paths with component-boundary tree containment, never a raw string prefix, so gen-extra/ never matches gen/ and GEN.md.bak never matches GEN.md. OPF BOUNDARY: the OPF store tree (.working/**) and OPF declared views are owned by the OPF deny hook (opf/enforcement/claude/pretooluse_deny.py), which reads them from the OPF manifest at decision time; tools/gen_gensrc.py refuses a registry target with a .working component, so a generated registry never registers a path in an OPF store tree. It does not read the OPF manifests, and a declared view lies outside .working (TODO.md, docs/STATUS.md), so a generator that declares a view destination is not refused and that path is then on both rosters; a hand-authored registry entry, with or without a .working component, is honoured too. Either way both guards then judge a write to that path. In this repository the control is registered for project sessions by the generated .claude/settings.json (tools/gen_hooks.py); a session that loads neither that file nor the plugin is not guarded. This is accidental-write prevention, not a sandbox; the drift gates remain the real gate. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, timed in a repository carrying a populated registry so the shapes reach the registry read and the per-entry matching, its slowest call took about 1.5 s in-process on a 24-core host with Python 3.14.4 (2026-10-22). On the uncertain # shapes the self-test times for every handler, its slowest call took 0.42 s at 1 MB in-process on an 80-core host with Python 3.14.4 (2026-10-29 UTC, one run), where its slowest shape above, a run of redirects, took 2.65 s. Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it. A derivation that writes back to a source original, or in-place derivation work outside its copy area, is not judged, because the guard matches only registered generated targets; that obligation is prose-only.
hook:git-discard
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

An ULTRA-CONSERVATIVE "recover-then-allow unless PRISTINE and provably clean" guard (EN-6; supersedes the GD-41 coarse cut and the EN-4 fail-open-lexical hook) with THREE outcomes: ALLOW (exit 0 silent), SNAPSHOT-THEN-ALLOW (an inert refs/aiqt-recovery/ snapshot is taken, then the discard is ALLOWED with a recovery note), and DENY (permissionDecision deny, blocks). NO-ASK posture: this guard never returns an ask; read every ASK below as that no-ask resolution, SNAPSHOT-THEN-ALLOW for a recoverable discard and DENY-and-educate when a warranted recovery snapshot cannot be made or the command cannot be classified or resolved. The recoverable SNAPSHOT-THEN-ALLOW is the characteristic behaviour; a confirmed whole-tree loss on a probed-dirty tree DENIES, BUT under a non-cosmetic ambient GIT_* var (an unresolvable repository view) even a whole-tree clobber is SNAPSHOT-THEN-ALLOWED with a best-effort cwd snapshot instead, because the guard cannot prove the target IS the session tree (only WITH a usable session cwd: with none, a destructive form under such an override DENIES, fail closed). It fails open (ALLOW) ONLY at the true boundary - a command with no git command-word, no recognized lossy verb, or an unparseable command (a tokenizer ValueError) that names no lossy verb keyword, whose recovered git segments run under no git alias the command defines, whose text does not name both git and one of the git configuration variables named below, and whose text does not both name git and hold a git alias definition anywhere in its de-quoted words - because that is not a discard it can reason about; a Bash payload whose tool_input.command is missing or not a string is NOT that boundary - it is a cannot-evaluate input and DENIES, fail closed, like the other PreToolUse controls; and a payload with no tool_name is malformed, not another tool, and DENIES under the shared missing-tool_name contract (a present non-Bash tool_name is out of scope). A # whose comment reading depends on context (the shared tokenizer comment rule) is judged under both readings, a comment to the end of its line and word text; a command the tokenizer cannot judge that way (more than four such #, one in or right after a backquote substitution, or a reading that does not lex) is raw-scanned with its backslash-newlines and then its quote characters and backslashes removed as well, so a continued spelling (git, a backslash-newline, then reset --hard) behind such a # is still flagged and DENIED; that removal is no decoder, so one whose text holds an ANSI-C or locale quote (a dollar sign right before a quote character) or a quote character or backslash next to a letter (g"it" re"set" --hard) is read as cannot-evaluate and DENIED whatever its words (an over-deny of every such command that loses no work, such as one that only echoes a quoted word). No such command reaches a recovery snapshot: its parenthesis, backquote or dollar-brace is outside the closed allowlist grammar and its raw text is never pristine. WITHIN scope (a recognized lossy verb: checkout/switch/restore/reset/clean/stash/rm/branch) the outcome is ASK UNLESS the command is a PRISTINE SINGLE BARE `git <verb>` invocation AND (for a lossy form) the whole working tree is PROVABLY CLEAN, or the form is genuinely non-destructive, in which cases ALLOW. PRISTINE SINGLE BARE is judged PURELY LEXICALLY (the bash grammar is NEVER parsed): after optional leading KEY=value assignments the command is exactly `git <args>` as ONE simple command, and the RAW string carries NO shell metacharacter ANYWHERE, EVEN INSIDE QUOTES - none of `;` `|` `&` `<` `>` `(` `)` `{` `}` `$` backtick backslash `!` newline, which by construction also rules out `&&`/`||`/`|&`, EVERY redirection form (`>`, `>>`, `<`, `2>`, `&>`, `>&`, `n>&m`, a leading or interspersed redirect), and EVERY command/process substitution (backtick, `$( )`, `<( )`, `>( )`) - AND no shell reserved word (if/then/elif/else/fi/for/while/until/do/done/case/esac/function/select/`[[`/`]]`/`{`/`}`) AND a command word that is LITERALLY `git` (not a path like /usr/bin/git, and not a wrapper: command/exec/builtin/env/xargs/time/`!`/sudo/nice/timeout/nohup/sh -c/bash -c). ANY shell metacharacter, wrapper, redirect, reserved word, second command, or option the classifier cannot resolve makes the command not-pristine, and it ASKS WITHOUT consulting the probe (a deliberate safe over-ask): treating the mere presence of shell structure in a lossy-verb command as an ASK is how the guard avoids modelling shell semantics it cannot model soundly. Only a metacharacter-free `git <verb> <plain args>` reaches the probe. PROVABLY CLEAN means the ONE read-only probe - `git -c status.showUntrackedFiles=all status --porcelain --untracked-files=all` (config-forced so a repo-local status.showUntrackedFiles=no CANNOT hide an untracked file, 5s timeout, offline) - reports NO tracked change (staged or unstaged, either column) AND NO untracked (`??`) entry; an untracked file is uncommitted work a force-checkout/clean/reset can destroy, so it counts as DIRTY (a `!!` line, never printed without --ignored, reads as malformed: cannot evaluate, never clean). When a pristine bare lossy form is on a provably-clean tree it ALLOWs (nothing the probe can see to lose); a pristine bare WHOLE-TREE-clobbering verb (reset --hard by effective last-wins mode, checkout -f with no pathspec, switch --force/--discard-changes) on a probed-DIRTY tree DENIES (the reason wording covers untracked-only-dirty too); everything else in scope ASKS. There is NO claim that any compound, wrapped, redirected, or non-pristine form the guard RECOGNIZES as in-scope is proven safe: those are never ALLOWED, only ASKED (no in-band override exists); the one exception is a command word `git` or a verb built in a way no raw, joined or de-quoted reading of the text assembles and the git alias reading does not deny (a disclosed residual below), which reads as out-of-scope and is silently ALLOWED, not ASKED. A dirty tree ALONE does not force that outcome on a PRISTINE bare form: a dirty-tree WHOLE-TREE clobber DENIES and other non-pristine or redirected forms ASK, but a genuinely non-destructive form (reset --soft, plain switch, checkout -b, clean -n) can still ALLOW on a dirty tree when no redirect or ambient override applies. Verb forms (each ALLOWs only as a pristine bare command on a provably-clean tree, except the unconditional-allow and unconditional-ask forms noted): checkout - a plain unforced branch-create (-b/--orphan) with NO merge option is an unconditional allow (git carries changes and aborts on conflict), and the NEW-BRANCH NAME argument of -b/-B (attached -bfoo or separated -b foo) is parsed as that name, never char-scanned as clustered force flags, so -bfoo/-bBranch ALLOW and are never mis-read as carrying -f/-B (F-85); -B is a force branch-create/RESET that overwrites an existing branch ref and can orphan committed commits, so it ASKS (including when combined with other flags); a -m/--merge or --conflict[=<style>] merge-switch, WITH or WITHOUT a -b create, does a three-way merge that can overwrite local changes and is worktree-scoped, decided before the branch-create allow (F-88); -p/--patch, any pathspec/bare-operand form, or a forced -f -b branch-create is worktree-scoped; -f with no pathspec is a whole-tree clobber. switch - -f/--force/--discard-changes is a clobber; -C/--force-create is a force branch-create/RESET that overwrites an existing branch ref and can orphan committed commits, so it ASKS; --merge/-m and --conflict[=<style>] are worktree-scoped (a three-way merge can overwrite local changes); a plain switch or an unforced -c create with NO merge option preserves non-conflicting local changes and aborts rather than lose them, so it is an unconditional allow, and the NEW-BRANCH NAME argument of -c/-C (attached -cfoo or separated -c foo) is parsed as that name, never char-scanned as clustered force flags, so -cfeature ALLOWs and is never mis-read as carrying -f/-C (F-85). restore - EVERY form is worktree-scoped, including --staged (it can erase staged-only content). reset - effective --hard clobbers; --soft is an allow (HEAD-only, index and worktree intact) unless its commit operand fails the ancestry check; --merge/--mixed/--keep/path-scoped/no-mode are scoped (index-changing); an ambiguous bare --m is scoped. ANCESTRY CHECK (brnrot): a --soft, --mixed or no-mode reset with exactly one commit operand (last mode wins; no path after --; never --hard, --keep or --merge, which rewrite tracked content to the target tree and leave no older tree to commit) first asks git, read-only and offline, in the repository it acts on (the session directory, a -C target, or the directory the closed allowlist grammar resolved), whether that commit is an ancestor of HEAD, and DENIES when it is not (a newer main, a sibling, a descendant, or the quoted undo-amend form HEAD@{1}, refused with no exemption) or when the probe cannot evaluate (git cannot launch or finish, HEAD is unborn or unreadable, the ancestry walk exits with neither 0 nor 1, a shallow repository answers no, an ambient GIT_* override or a --git-dir/--work-tree/--bare/GIT_DIR/GIT_WORK_TREE redirect or an unresolvable -C hides the repository, an unprovable compound directory, or an earlier lossy segment of the same compound that may move HEAD first). The operand is resolved alone and the object it names is then peeled to a commit. A soft reset whose operand resolves to no object denies; a mixed reset whose single operand resolves to no object is a path reset and keeps its decision; an operand that resolves to an object that peels to no commit (a tree or a blob) denies in every mode. A reset with no commit operand is not probed. Without the check a reset of a branch onto a newer main kept the older tree, so the next commit reverted every change main made in between. The deny gives the portable recipe: git merge-base HEAD <ref>, then git reset --soft <printed sha>. Residual of that check: plumbing that builds the same commit (commit-tree with update-ref, read-tree); a reset to an older ancestor (absorbing a branch into a stale base); --hard, --keep and --merge; mistakes inside rebased content; non-Bash tools, platforms other than claude-code, and humans; a concurrent fetch that moves a remote-tracking ref between the probe and the reset (a time-of-check versus time-of-use race, disclosed rather than closed by requiring full shas); and the over-refusal of the undo-amend reset. rm - EVERY form is worktree-scoped, including --cached. clean - ANY non-dry-run clean is an unconditional ASK (untracked files are a different, unrecoverable asset, and the guard does not model whether the clean would fire, closing the clean.requireForce/-q/-i/clustered-flag edges); a -n/--dry-run judged before any `--` allows, BUT when an arg-consuming option -e/--exclude is present the `-n` is not trusted (it may be the exclude pattern) so the clean routes to its ASK. Destructive options are matched by CONSERVATIVE PREFIX (--patc=--patch, --del=--delete, --dis=--discard-changes, --har=--hard, --mer=--merge are unambiguous abbreviations git accepts; an AMBIGUOUS force-prefix like --for, which git itself REJECTS for branch as ambiguous with --format and for switch as ambiguous with --force-create, is treated as force ANYWAY, erring safe rather than recognizing only spellings git would accept); a `--`/`--end-of-options` boundary is respected so an operand after it (git clean -f -- -nasty, git rm -f -- --cached) is a pathspec, never a safe-looking option. stash drop/clear ASK, and stash export ASK for every spelling (F-95: export writes stash state to a ref, and --to-ref overwrites an arbitrary ref unconditionally); branch force forms ASK - -D/--delete --force (force delete), -M/--move --force (force rename), -C/--copy --force (force copy), -f/--force with an operand (force reset), and a delete (-d/-D/--delete) combined with -r/--remotes (F-94: deleting remote-tracking refs, which git force-removes past the merged-branch safeguard) - while a plain create/list, a local non-remotes -d, and an unforced -m/-c stay allow, and an attached -u<upstream> value (-ufoo, -u <upstream>) is parsed as the upstream, never scanned as a clustered force flag. A `git -C <dir> ...` or a GIT_DIR/GIT_WORK_TREE-prefixed form is pristine-lexically but names a worktree the guard cannot resolve to the session cwd, so it ASKS rather than trust a session-dir probe. For a whole-tree clobber that ASK resolves exactly as the bare form does: when the resolved target probes dirty it DENIES with its recovery snapshot, whatever git global option (value-taking, flag-only or unknown) or leading assignment the command carries; an AMBIENT override (below) keeps its best-effort snapshot-then-allow. A git alias the command defines or runs under (below) DENIES before either, with no snapshot taken. A destructive pristine form carrying a path-valued redirect (--git-dir, --work-tree or --bare, or a leading GIT_DIR=/GIT_WORK_TREE= assignment) is resolved by git itself: a read-only `git -C <session dir> <the same -C, --git-dir, --work-tree and --bare options> rev-parse --absolute-git-dir --show-toplevel`, with every ambient GIT_* scrubbed and the leading GIT_DIR/GIT_WORK_TREE re-applied, reports the git dir and worktree the command acts on, each relative value taken after the whole -C chain as git takes it. The discard is snapshotted at that worktree, then allowed (a whole-tree clobber on a dirty target still denies there, as above), only when git discovers the same git dir from it; an index-only form may use the -C directory or the session directory instead when git discovers that git dir there; every other case DENIES (a git dir and worktree split across repositories, a target git cannot resolve read-only, a global option beside the redirect other than -C, --git-dir, --work-tree, --bare and the value-less -P, --no-pager, --literal-pathspecs, --glob-pathspecs, --noglob-pathspecs, --icase-pathspecs, --no-replace-objects and --no-optional-locks, which git is asked with, a leading GIT_* assignment beside the redirect outside the cosmetic allowlist of the ambient check below, or a non-cosmetic ambient GIT_* variable), with a best-effort recovery snapshot of the repository holding that worktree when it lies in one and is not provably clean. Two checks that read the redirect from the session directory stay as a floor under that answer, so resolving through git never allows what they denied. A --git-dir or leading GIT_DIR= or GIT_DIR+= whose value, read from the session directory, is not the git dir of the session repository, and a worktree-content discard whose --work-tree or leading GIT_WORK_TREE= worktree lies outside the repository read from the -C or session directory, each still DENIES where git resolves one repository (git -C <repo> --git-dir=<repo>/.git, git -C <repo> --git-dir=.git --work-tree=.), with that repository snapshotted first when it is not provably clean; a GIT_DIR+= that follows another GIT_DIR assignment on the command builds a value that read does not trust, so it DENIES too, while a leading GIT_WORK_TREE+= is not read by the --work-tree check, so a GIT_WORK_TREE+= worktree that git resolves inside the git dir of its own repository is snapshotted there and allowed where the GIT_WORK_TREE= spelling DENIES (a split across repositories still DENIES). And the -C or session directory such a redirect was read from (the session directory beside a --git-dir or leading GIT_DIR=) is still probed whenever it lies in another repository than the one git resolved, and snapshotted as well when it is not provably clean; a successful snapshot there is named beside the resolved snapshot in the note or reason. A failed one (over the size cap, a temp dir resolving inside it, or any other snapshot failure) is allowed past, with that failure named in the note, only when the repository git resolved, the one the command acts on, was probed and is provably clean or was snapshotted first; when that snapshot fails or that probe does not complete it DENIES with the failure named, folding in the outcome of the resolved repository (a whole-tree clobber on a dirty target still DENIES there, as above, naming the failure). That oracle runs under the hook environment, not the command one, so a leading non-GIT_ assignment on the command (HOME=..., say) is not modelled; and a leading GIT_INDEX_FILE, GIT_COMMON_DIR or other GIT_* assignment with no path-valued redirect keeps the session-directory or -C snapshot, which does not hold an alternate index file. A stash drop/clear carrying a leading GIT_DIR+= assignment denies exactly as its GIT_DIR= spelling does. An AMBIENT GIT_*-prefixed var in the process env also forces ASK unless it is one of a small COSMETIC allowlist (UI/editor/pager/prompt/lock/identity vars that cannot change the discard target, the object/ref view, the index, or dirtiness detection): the probe scrubs every ambient GIT_* and reads the real cwd repo, but the guard cannot scrub them from the ACTUAL command, which still inherits them and may act on a different git dir, work tree, index, or object/ref view, so the guard cannot prove the repository view the ACTUAL command uses IS the session cwd. Under any such non-cosmetic ambient var EVERY in-scope pristine form ASKS - a destructive clobber AND an otherwise-non-destructive allow form (reset --soft, plain switch, clean -n, checkout -b) alike, checked BEFORE the role/allow logic - each DESTRUCTIVE form with a best-effort cwd snapshot WHEN the session cwd is a not-provably-clean snappable tree (a provably-clean cwd takes none, a NON-DESTRUCTIVE allow form takes none, and a stash/branch-only form takes none; NO usable session cwd DENIES a destructive form, fail closed, and takes none; the snapshot may not capture a redirected tree), so no allow form and no clean cwd probe wins a silent ALLOW under such a redirect. A GIT_TRACE* var is NOT cosmetic: an absolute GIT_TRACE value makes the ACTUAL command append trace output to that path (which could be a repo file), so it too forces ASK (the recovery/probe git calls still scrub the whole GIT_TRACE family, so THEY write no trace, but the ambient GIT_TRACE on the command itself is not neutralized). This is FAIL-SAFE by construction rather than a fixed enumeration (which kept missing new members - GIT_NO_REPLACE_OBJECTS, GIT_REPLACE_REF_BASE, GIT_REFERENCE_BACKEND - and more will exist): an unknown or new non-cosmetic GIT_* var ASKS rather than winning a silent ALLOW. This control is SIDE-EFFECTING (EN-6 recovery layer): before it ALLOWS or ASKS a snapshottable in-scope discard (checkout/switch/restore/reset/rm/clean) whose worktree it can resolve to the session cwd AND whose tree is NOT provably clean, it takes an INERT recovery snapshot of the uncommitted work and appends one line to an EXTERNAL per-user ledger, so a discard that is asked-then-approved OR wrongly allowed by a mis-parse is still recoverable (the hook fires once, with no post-approval callback). An ACCEPTED non-pristine in-scope command (one the closed allowlist grammar below admits) is snapshot-backed against EACH worktree the grammar resolves - the literal -C chain of a plain git segment over the &&-followed literal cd chain - PLUS the SESSION CWD repo as a best-effort catch-all for EVERY in-scope command (a verb that actually acts on the session repository stays recoverable even when no segment resolved it); a resolved plain stash drop/clear first preserves the stash of its target repository under durable refs or DENIES (worktree cleanliness never establishes stash safety); a discard-capable segment whose directory the grammar cannot establish DENIES (fail closed); on snapshot fail the decision stays ASK; and with NO session cwd EVERY non-pristine in-scope command DENIES (fail closed: no catch-all snapshot base resolves) - a DELIBERATELY CONSERVATIVE cost that also denies otherwise-legitimate no-cwd forms whose visible shape is non-destructive or non-snapshottable, such as a compound checkout -b, a piped git log, or an assignment-prefixed clean -n or reset --soft, each of which allows in its bare or with-cwd form. The snapshot uses a TEMPORARY GIT_INDEX_FILE outside the repo (seeded by copying the real index), `git add --all` (captures tracked-modified + UNTRACKED; ignored excluded), `git write-tree`, `git commit-tree` (parent = HEAD when present), and a CREATE-ONLY `git update-ref refs/aiqt-recovery/<utc-ts>-<pid> <sha>` with an empty expected-old value (a name collision FAILS the snapshot rather than clobbering a prior recovery ref). The snapshot writes only git objects and one private ref (plus a reflog entry for it when core.logAllRefUpdates=always, or when a reflog already exists for the ref) and does NOT itself touch the real index, worktree, HEAD, or any branch; the ref is invisible to plain `git status`, `git branch`, and `git log` but is reachable via `git log --all` / `git for-each-ref refs/aiqt-recovery` / `git show-ref` (a real ref, not hidden). Every real-state git call in this control takes an ALLOWLIST posture, scrubbing EVERY ambient GIT_*-prefixed var rather than enumerating a family (successive reviews kept finding new members: GIT_CONFIG_*, then GIT_TRACE*, then GIT_ATTR_SOURCE), so an ambient decoy repo (GIT_DIR/GIT_WORK_TREE), an ambient `git -c` (GIT_CONFIG_PARAMETERS) config injection, an ambient GIT_ATTR_SOURCE attribute-source redirect, or an ambient GIT_TRACE that would make a read-only call WRITE a trace file cannot reach these calls; the scrub itself re-asserts GIT_NO_LAZY_FETCH=1 and GIT_TERMINAL_PROMPT=0 (so the scrub cannot strip the offline + non-interactive posture: no partial-clone lazy-fetch over the network, no prompting) plus a FIXED INTERNAL recovery identity (GIT_AUTHOR_NAME/GIT_COMMITTER_NAME=aiqt-recovery, GIT_AUTHOR_EMAIL/GIT_COMMITTER_EMAIL=aiqt-recovery@localhost) so the snapshot `git commit-tree` never depends on an ambient or on-disk user.name/user.email the scrub removed - which would make it FAIL where no git identity is configured (a CI runner or fresh install with no global gitconfig), silently disabling the recovery backstop - and a recovery commit is deterministically attributed to the guard, not the user, the callers re-apply only the few vars they set themselves AFTER the scrub (GIT_OPTIONAL_LOCKS for the read-only probe, a temp GIT_INDEX_FILE for a snapshot), and any GIT_* the call genuinely needed but was scrubbed fails SAFE to a probe/snapshot failure (fail-to-ASK, never a silent allow). This closes the whole ambient-env class at once; the residual is NOT limited to on-disk config - it spans the non-GIT_ environment (HOME/XDG_CONFIG_HOME/PATH/TMPDIR), on-disk git configuration AND attributes (repo/global/system .gitconfig and .gitattributes), index and ignore state, submodules and embedded repos, configured hooks and filters, PATH-based git resolution, and partial-clone object availability, all read by git by design. RESIDUAL (the honest bound on "inert"): the inert property is BOUNDED, not categorical, because git may ADDITIONALLY run a git-configured (repo, global, system, or command-scope) program during the snapshot whose effect is outside this guard control - a clean/process filter runs on `git add --all` (the CHECK-IN / clean direction, NOT smudge, which would run only on a restore/checkout), an fsmonitor hook runs on the `git status` probe, and a reference-transaction hook plus a post-index-change hook can run on the temp-index write and on the `git update-ref`; a clean filter can TRANSFORM the captured bytes, so the snapshot is NOT guaranteed byte-exact on a filtered repo; and a single overlaid tree cannot capture dirty content inside a SUBMODULE or an untracked EMBEDDED git repo (it stores only the gitlink) and flattens staged-vs-unstaged into ONE tree. The ledger is `${XDG_STATE_HOME:-$HOME/.local/state}/aiqt-guardrails/recovery.jsonl` (normally outside the repo, so a `git clean` cannot destroy it). The ledger write is BEST-EFFORT: normally one line per snapshot, but skipped when no per-user location resolves (absent HOME/XDG), when that path would resolve inside the repo, or when the directory or write fails. A written line records: utc ts, repo path, the triggering git VERB (never the raw command or file contents), the recovery ref, the snapshot sha, the covered classes (tracked/staged/untracked), and the exact restore command (`git checkout <ref> -- :/`, OVERLAY mode: it brings back modified and new content but does NOT re-apply a recorded file deletion; for an exact, deletion-inclusive restore use an isolated branch via `git switch -c aiqt-recover-<id> <ref>`). The snapshot is decision-INDEPENDENT (it does not trust the form-classifier), so it also fires on a genuinely-safe snapshottable form (reset --soft, plain switch, checkout -b, clean -n) on a dirty tree, an accepted over-snapshot; it is SKIPPED on a provably-clean tree, out of scope, and for stash/branch (a worktree snapshot cannot capture stash entries or branch commits). FAIL POSTURE: when a warranted snapshot cannot be made (probe/write error, an undecodable non-UTF-8 path, over a ~100 MiB changed-content size cap, a bare or broken git dir, or a temp dir resolving inside the repo) it NEVER becomes a silent allow of a not-provably-clean discard - a would-be ALLOW is DOWNGRADED to ASK ("no recovery point could be created"), and an already-ASK/DENY decision is left as-is with the failure surfaced. Retention is NOT handled here (refs accumulate; auto-pruning is a separate maintenance op, never inside PreToolUse). NO IN-BAND OVERRIDE: the former `GUARDRAIL_ALLOW_DISCARD=1` command prefix is REMOVED. The guarded assistant could type it into the very command under guard, so it could not carry the operator-only authority it claimed, and it skipped the recovery snapshot. A leading GUARDRAIL_ALLOW_DISCARD assignment (`=` or `+=`, any value) is now an ordinary env assignment, and no code path reads its value to permit anything. Two code paths read the name, and only these two: `_segment_dir_simple` counts it as a benign assignment that redirects nothing (any other leading assignment, such as `FOO=1`, counts as a possible redirect), and the migration sentence below, which adds reason text only. BOUNDED EQUIVALENCE: when the same command without the prefix already names a recognized work-losing git verb, and the prefixed command is a pristine single bare git command (the shell lexer parses it and its raw string carries no shell metacharacter), the prefixed command gets the decision and the snapshot of the command without it, because past the pristine gate the guard decides from the token list plus the raw-scan lossy-keyword flag, which the command without the prefix already set. When the command without the prefix names no such verb, it is allowed silently at the true boundary, and a pristine prefixed command can get nothing more permissive than that. Outside that bound the value is ordinary text in the raw command string, which the conservative raw scans of the guard read like any other text, so the decision can differ from the one the command gets without the prefix: (a) a value carrying a shell metacharacter, even quoted (`GUARDRAIL_ALLOW_DISCARD=";" git reset --hard`), makes the command not pristine; (b) a value that leaves a quote unbalanced (`GUARDRAIL_ALLOW_DISCARD="1 git reset --hard`, which bash itself rejects) makes the command unparseable; (c) on an unparseable command every raw-flagged form is denied by the unparseable-command fallback, so the prefixed, the FOO-prefixed and the unprefixed spellings of an unparseable `git reset --hard <<EOF` whose heredoc body holds a lone quote all DENY alike; (d) the scope scan reads a git or work-losing-verb keyword inside the value, so `GUARDRAIL_ALLOW_DISCARD=reset git log -1` is in scope and DENIES as a flagged subcommand outside the recognized set, where `git log -1` is allowed. In case (a) on a dirty session tree the command gets a best-effort snapshot then ALLOW-with-note (DENY if the snapshot fails) where the bare whole-tree clobber DENIES, and with no session cwd a DENY (fail closed: the session-directory snapshot base cannot be resolved, exactly as for `FOO=<the same value>`); in case (b) it gets the unparseable-command fallback, where every raw-flagged unparseable command DENIES, exactly as the bare unparseable form does. No code on the non-pristine path or the unparseable-command fallback reads the name, so there the prefixed command gets exactly what the same command with `FOO=<the same value>` gets. These are existing behaviours of the raw scans and the non-pristine path that any leading assignment, metacharacter or second command reaches, not a property of this prefix. For this release only, a truthy one on a pristine bare command also adds a reason sentence stating that the override was removed and that `git stash` then the discard is the route (an unparseable or non-pristine command gets no such sentence); the next release deletes that sentence, the helper that adds it and its self-test vectors. For an intentional discard on a dirty tree, `git stash` (with `-u` for untracked files) then the discard reaches the provably-clean ALLOW with the work recoverable in the stash. The prefix no longer skips a recovery snapshot: no code path reads its value to skip one, so within the bounded equivalence above a prefixed command takes the snapshot the command takes without it, and on the non-pristine path and the unparseable-command fallback the snapshot `FOO=<the same value>` takes (the forms that take none, such as a ref-level or stash/branch asset and an ignored file, take none with or without it; the no-session-cwd non-pristine path above DENIES and takes none). The operator channel for a standing exemption is the hook configuration (disable or reconfigure the hook). HONEST RESIDUAL: being a LEXICAL hook it does NOT catch a git alias defined in a configuration file, or a shell function, that renames git or expands to a work-losing verb, a discard performed OUTSIDE the Bash tool, or persistent shell/config state set in a PRIOR turn. A git alias the command defines for itself is caught instead: a git command that defines one (a -c or --config-env key in the alias, include or includeIf section, or a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n assignment naming one), its git word read wherever it stands in its segment (the command word, or a word behind a reserved word, a { group, !, time or a wrapper word; not inside the text of another word, such as an sh -c or eval body), and every git command of a command whose text names GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT or GIT_CONFIG_KEY_ anywhere (sought once every quote character, backslash, dollar sign and newline is removed, so a quoted, escaped or continued spelling counts, in any word, a here-string, a comment or an echo) and names git as a word anywhere, parsed or unparseable alike (git wherever it stands: behind a reserved word or a wrapper, or inside an eval or sh -c word), whatever form sets the variable and whatever its value, since a shell sets one in more ways than a reading of forms can list (on git 2.53.0 with bash 5.3 an export, declare -x, typeset -x or local -x word, a bare assignment or a readonly word to a variable already exported, and under set -a also a bare assignment, read, printf -v, a for loop variable, a default-value expansion or a nameref each defined an alias), so a mention that defines no alias (an echo, a comment, an unset, an unrelated value) counts too, a disclosed over-refusal, or that runs under a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n naming one in the session environment the hook inherits, which counts as such a text does, git wherever it stands (not read: a variable name built at run time, such as an expansion after GIT_CONFIG_, a brace expansion or an escape decoded inside an ANSI-C quote, and a variable set outside the command text, in the startup files of the Bash tool shell or in a sourced file or a script the command runs), DENIES on a clean or dirty tree, on an unparseable command too (a git segment the partial lex recovers, the one the lexer was still inside included, a definition anywhere in the de-quoted words of a text naming git, past a here-string, an unquoted heredoc or a {fd} redirect the lexer stops at included, or, for a command naming such a variable, git named anywhere in its text), since it may expand to a work-losing verb this guard cannot resolve or snapshot. A definition counts however it is spelled: the lexer records how each word was spelled, and the rule is closed over the whole region after a git word through its subcommand word (every global option word, the value of any option and the word read as the subcommand), where a word that carries an unquoted parameter or command expansion (a dollar sign or backquote outside quotes, one bash reads literally included), which bash may split into several words such as a -c and a definition, or whose text came through an ANSI-C or locale quote (a dollar sign right before a quote character) defines a git alias whatever it decodes to, as does a word there holding an unquoted brace and a comma (bash expands it into several words), other than a -c or --config-env word and its value, and a -c value or a --config-env word whose key part (the text before its first =, or the whole word when it has none) is spelled through a parameter or command expansion, inside double quotes too, such a quote or a brace expansion; on an unparseable command the same rule reads the de-quoted words of each command, past a here-string, a heredoc or a {fd} redirect, where a quoted or escaped dollar sign, backquote or brace counts as an unquoted one would. On such a command a git region the lexer did not read denies, fail closed: where the partial lexer stops before the option region of a git command or inside it (a leading here-string, as in git <<<x -C DIR $X), that region is unread, scoped to a segment that runs git as its own command (its command word is git or a wrapper the guards resolve, never a git word another command merely carries); the text past where it stopped is read two ways, and a deny from either stands: a quote-aware re-lex of the text past each construct the reader can measure on the same text (the target word of a here-string, or the balanced parentheses of a process substitution with backslash escapes honoured), each recovered command read through its subcommand word, and the every-git-word reading, which walks the region of each git word in the raw text from the stop, denying where a region cannot be read to its subcommand word or holds a word built at run time. The re-lex alone decides only where its ground is proven: the construct measured with no backslash escape met, no git word inside the text of the measured construct itself (the body of a process substitution and the word of a here-string are run or expanded, so a git region there keeps the raw reading), the resume at a command separator, and every git word of the re-lexed text read as a word of a recovered command (one in a comment or a redirect target is not); there a git word the re-lex proves to be quoted text (an echoed git word past a stop, a pull-request or issue body naming git) allows, while a plain git word any command carries as a word of its own (under a wrapper the guards do not resolve, such as flock or ionice, included) is still walked and denies where its region runs open or holds a word built at run time. At every other point (an unquoted heredoc, a {fd} redirect or an unbalanced quote the reader cannot measure, a resume that is not at a separator, a measure that met a backslash escape, a git word inside the measured construct, one in text the re-lex gives no word for, or a git word beside a $ or backquote inside a re-lexed word) the every-git-word reading decides too, so a quoted or commented git word there still denies, a disclosed over-refusal; a literal region read in full keeps its decision either way. Disclosed over-refusal: a word naming git that is the last word read before such a construct and is its command word (git <<<x log) still denies, while a git word only an operand of the segment the lexer stopped inside (grep -c git <<<x) is not read as a git command, so it allows. Remaining residual of this reading: the every-git-word walk reads the region of a git word on the raw text, so from inside quoted, comment or here-document text it can only over-refuse, never allow a git command the re-lex read; a git command whose git word is itself built at run time (an expansion that expands to git) is outside both readings, as on a parsed command. A word reads as one word to bash only when no part of it can expand to several words: a double-quoted expansion of the positional list or of an array subscripted with @, in any parameter-expansion form, with or without an operator or substring, and an indirect expansion whose reference (a variable, an array element read through a subscript of its own, a positional parameter or $*) can hold a reference to such a list (a double-quoted $@, ${@:1}, ${A[@]}, ${A[@]:0}, ${!A[@]}, ${!R}, ${!A[0]}, ${!1} or ${!prefix@}), expands to one word per element, so it counts there as an unquoted expansion does. A word whose every expansion stands inside double quotes and expands to one word keeps the plain reading on a parsed command: behind a literal key, as another option value or as the subcommand it defines nothing. Disclosed over-refusal (the safe direction): an unquoted variable in a git option or subcommand position now denies (git -C $DIR log, git $SUB, git -c core.pager=$P log), as does one after any other word naming git (grep -r git $DIR) and a git command inside an unquoted backquote substitution whose closing backquote the lexer reads into its subcommand word; quoting the variable restores the plain reading. The deny reason names such a word as one the shell builds at run time, with the fix (quote the expansion, spell a -c key literally, write a list out, put a here-string after the subcommand word), and keeps the alias wording for an actual alias definition. A key that is literal in effect but spelled through an expansion or such a quote reads as an alias, and so does a brace word there that expands to no option. Not read: the git word itself, read by its decoded text alone (an unquoted expansion in it, or a brace word that is the git word, which bash can split into git and a definition), and a word in that region that bash builds by filename expansion (an unquoted star, question mark or opening bracket matching a file name such as -c) or by a brace sequence expression, which holds no comma, and a word whose every expansion stands inside double quotes and expands to one word, which keeps the plain reading by decision, so one that names an option such as -c at run time, before a literal definition, is not read as one. A missing or non-string command DENIES fail-closed, as in the other git guards. It also does NOT catch a real discard whose VERB is OUTSIDE the recognized set AND unflagged by the raw scan: a `git worktree remove -f` (or `--force`) of a dirty linked worktree discards the uncommitted work in that worktree, but `worktree` matches no lossy keyword, so the command is allowed at the true boundary; that verb is deliberately NOT added this round (disclosure matches the best-effort posture). By contrast (F-97), a command the raw scan DOES flag as in-scope whose resolved subcommand is nonetheless outside the recognized set (`git checkout-index -a -f`, `git read-tree -u --reset HEAD`, flagged by their `checkout`/`reset` substring) is NO LONGER silently allowed - it ASKS, since a flagged sub the classifier cannot resolve to a known verb cannot be proven non-destructive, and its sub is not snapshottable so no recovery point exists. It also does NOT catch obfuscation of the COMMAND WORD `git` ITSELF or of the verb that no reading of the text assembles. The scope scan reads the raw text, the text with its line continuations joined, and a copy with quotes, a $ before a quote, and backslashes removed, all unstripped, on a certain and an uncertain reading alike, so a wrapped reset fragmented by quotes or a backslash (env git followed by a quote-broken re-set, alone or after another git command) or a command word quote-broken so it spells git only after the shell strips the quotes (under a wrapper such as command or env) is in scope, where the closed allowlist grammar below DENIES it (no wrapper is an accepted command word). A word built another way (an escape decoded inside an ANSI-C quote, or a parameter expansion or command substitution evaluated at run time) reads as "no recognized lossy verb": as the subcommand word, spelled through an ANSI-C quote or an unquoted expansion, it DENIES as a git alias (below), but a verb built inside double quotes and a command word built at run time are silently ALLOWED, so the claim that any wrapper ASKS is NOT categorical: it holds only while one of those readings sees a contiguous git+verb keyword. This is a disclosed best-effort residual (the guard targets the accidental case, not an adversary deliberately building the command word or the verb at run time). When the shell reading is uncertain (any unquoted #, <<, ( or ), a lone { or } word, any backtick or $(, a line continuation the lexer reads differently once joined, or a form the lexer refuses), the same readings decide the scope, so a quote-fragmented verb in such a command is in scope. The joined reading brings a verb that a continuation splits under a wrapper into scope, and the de-quoted copy makes a quote-broken subcommand outside the recognized set (a fragmented checkout-index) deny like its plain spelling; a command the lexer refuses is flagged from its de-quoted copy too and then denies like every flagged unparseable command, so a split or quote-fragmented -C, --git-dir, --work-tree or cd redirect never reaches a session-directory snapshot. The probe ITSELF can be made to under-report dirtiness by git config or index state: an `assume-unchanged` or `skip-worktree` mark on a tracked file, or a `submodule.<name>.ignore` setting, hides real modifications from `git status`, so the guard can read a genuinely-dirty tree as provably clean and ALLOW a discard of that hidden work; and the recovery snapshot ALSO cannot capture assume-unchanged/skip-worktree content (it is not in what `git add --all` re-stages) nor IGNORED files (git add --all excludes them; an ignored file overwritten by a forced checkout or removed by `clean -x` is unprobed - to avoid asking on every repo carrying build artifacts - and unsnapshotted). Beyond that, being ultra-conservative it OVER-FIRES heavily - every compound or metacharacter-bearing command that names a lossy verb is at best snapshot-then-allowed and at worst denied even when it would have been safe (no in-band override exists); a command that pipes or wraps a plain git segment (`git log | grep branch`) and a NON-GIT segment that contains a git word as TEXT (`echo git branch`) each DENY under the positive grammar when a lossy keyword puts the command in scope, because the raw scan matches the keywords wherever they appear and the grammar refuses every byte and command word outside its closed lists (a safe-direction over-deny, not narrowed: narrowing the raw scan is the whack-a-mole GD-34 rejected); the copy with quotes, a $ before a quote, and backslashes removed is matched the same way, so quoted prose whose words spell git and a lossy verb only once the quotes are removed (an echo or printf of a quote-broken git re-set, such as `echo "git re""set --hard"`) is in scope although the shell runs only echo or printf, and the grammar DENIES that harmless command (echo, printf and a double quote are outside it), while a read-only git subcommand whose quoted argument spells a lossy keyword only once the quotes are removed (`git log --grep="re""set"`) DENIES as an unrecognized flagged subcommand, as its plain spelling already did (kept, not narrowed: quoted text is code under eval, bash -c or sh -c, and this reading is what brings a fragmented verb there into scope); and a pristine whole-tree clobber over-DENIES on a dirty tree even when the specific target would have lost nothing - an accepted, recoverable cost of never silently allowing a real WORKING-TREE-CONTENT discard for a recognized command that is NOT covered by a disclosed residual. That no-silent-allow property is bounded to working-tree content: ref-level moves (reset --soft moving HEAD, a merged-branch delete) are reflog-recoverable (a reset that moves HEAD to a commit that is not an ancestor of HEAD is denied by the ancestry check), and the obfuscation/config residuals above are best-effort, not categorical. PR #423 replaces the earlier modelling walk with a POSITIVE LITERAL closed allowlist grammar, judged directly on the RAW string of every non-pristine in-scope command, so shell behaviour is never modelled on this path: anything the grammar does not spell out is denied by construction. THE GRAMMAR, exactly: every token is a bare literal word over the character class of letters, digits and _ . / @ : = + , % -, or ONE single-quoted literal containing no quote, standing alone as a whole token; the accepted command words, spelled bare and unquoted, are git, cd (option flags -P and -L only, exactly one operand, an absolute literal path with no parent-directory component whose realpath equals its normpath, so no symlink component), and true or : with no arguments; no assignment word (NAME=value) is accepted in or before a command-word position, and a GIT_* assignment-shaped token ANYWHERE in the command denies; a git segment accepts no global option but -C with an absolute literal path under the same rules, and its bare subcommand must be on the explicit read-only allowlist (status, log, rev-parse, rev-list, ls-files, ls-tree, cat-file, grep, describe, shortlog, name-rev, merge-base, check-ignore, check-attr, count-objects, show-ref, for-each-ref, var, version; ls-remote is DELISTED, because its --upload-pack/-u option runs an arbitrary command string and the subcommand itself opens network access, and help, diff, show and blame are DELISTED, because their ACCEPTED command line can run a configured program through git itself: git help launches a man viewer or browser and a configured man.<tool>.cmd is shell-evaluated, while the patch output of diff, show and blame runs configured textconv and external-diff drivers; -p/--patch is outside every closed option allowlist for the same reason - removed from every read-only option list AND denied on the modelled lossy verbs checkout, restore, reset and stash (the push and save forms included; a digit-led cluster such as -2p and a flag hidden behind a consumed separated option value included, every declared option value consumed by its arity first), on the pristine single-command path and the grammar path alike, because interactive patch mode renders its hunks through the configured interactive.diffFilter program - and for-each-ref --format AND --sort are delisted because the %(...) placeholders and the sort atoms include signature fields that run the configured signature program) AND, for a read-only subcommand, carry ONLY the VETTED option spellings of that subcommand (exact value-less flags plus value-taking options accepted ONLY in the joined --opt=value spelling git itself reads joined - NEVER a separated value word, which git reads as a token in its own right: an earlier revision consumed a following word as an optional value, so git log --pretty --output=<path> passed vetting while git read --output as an option of its own and truncated the path - with no abbreviation even where git accepts an unambiguous prefix, each option value shape-validated, the log --pretty/--format value restricted to EXACTLY the seven named formats oneline, short, medium, full, fuller, reference and raw or to a string carrying the format: or tformat: prefix over an explicit placeholder allowlist that excludes every %G signature placeholder (EVERY other value denies, abbreviations and further built-in names such as email included, because git resolves an unprefixed value as a built-in format name or a pretty.<name> CONFIGURATION alias, and a configured alias can itself carry %G), a -- token ending the option region with every later token a positional operand, and a non-option token passed through as a positional operand - a rev, path, pattern or object name: DATA to the subcommand that receives it, never inert), because a read-only SUBCOMMAND is not a read-only COMMAND LINE: an unvetted option can run a program (git grep --open-files-in-pager/-O runs one per matching file) or write a file (git log --output=<path> truncates that file), so any option outside the vetted set denies naming itself; an explicit git stash list carries NO argument and an explicit git stash show carries AT MOST ONE positional stash reference and nothing else, no option-shaped token and no "--" (git forwards stash list arguments to git log and stash show arguments to git diff, where they would ride around the vetted grammar and the delisted diff class, and a retained "--" makes git stash show itself select patch output), a rule applied on the pristine single-command path and the grammar path alike; or a modelled lossy verb, which is then preserve-then-allowed exactly as the pin does (a resolved stash drop/clear preserves the stash of that repository under durable refs first or denies; a resolved worktree discard is snapshotted at its resolved target or denies; and the session cwd is ALWAYS snapshotted as the catch-all for every in-scope command), carrying ONLY the CLOSED option allowlist of that verb - exact spellings with each value-taking option consuming its value by arity; any other option denies naming itself, and git branch lists no --sort, --format, --edit-description or --list spelling, because the sort atoms and format placeholders include signature fields that run the configured signature program and --edit-description launches the editor (the ACCEPTED -m/--merge and --conflict spellings and the accepted stash pop and apply forms DO run the git-internal content merge, which honours a configured merge.<driver>.driver program on paths whose attributes select that driver - a DISCLOSED residual among the configured programs, bounded like the clean-filter and hook classes above, not a claim that an accepted lossy form runs no configured program) - while a compound that CONSUMES a stash entry (git stash pop, git stash branch) together with another lossy verb denies outright, because a successful pop applies then drops the entry, so the following verb can destroy the just-applied content while the dropped entry is gone and the pre-command snapshot cannot contain it (a bare consuming command alone keeps its handling); a git stash resolves its operation ONLY from its FIRST argument: a bare first word is the explicit operation, while no argument, a leading option of any spelling (-q, -m <msg>, an attached -m<msg>, --message=<msg>) or a leading -- token reads as a bare or implicit push (safe) - git itself refuses an implicit push carrying a stray bare token, and never runs one as a drop or clear - so a message value or a pathspec named drop or clear is never read as the operation; the joins are the && operator, the semicolon and the newline only, with a trailing semicolon and leading or trailing newlines accepted (bash runs the same commands) while a trailing && and any doubled or misplaced operator denies, and a cd is honoured for a later segment only across && joins (across a semicolon or newline the cd may have failed with the next command still running, so the directory becomes unprovable and a later discard-capable segment denies); the redirections accepted are >/dev/null, 2>/dev/null and 2>&1 only, none of which writes a file; an accepted redirect may directly follow a completed bare or quoted word (the word ends at the operator, exactly as bash reads it), an all-digit word standing immediately before the > operator is an IO number of which only 2 is accepted, and repeated accepted redirects are accepted. ANYTHING ELSE DENIES, naming the violation class: every other byte (a dollar sign, a backquote, a double quote, a backslash, a glob or brace character, a tilde, a comment hash, any <, which rules out every heredoc, here-string, process substitution and input or read-write redirect, a parenthesis, a pipe, a stray ampersand, a control byte), every quoting form beyond one standalone single-quoted literal, every other command word (echo, printf, test, [, pwd, pushd, popd, export, eval, source, ., set, unset, alias, every wrapper and interpreter, every quoted, concatenated or pathed spelling), every assignment word, every other git global option (-c, --git-dir, --work-tree, --namespace, --no-pager and the rest), every unlisted subcommand (git commit or git push inside an in-scope compound included, and the delisted ls-remote), every unvetted or abbreviated read-only option (the deny names the offending spelling), every unresolvable or symlinked cd or -C path, every other join or redirect, and a trailing && (a trailing semicolon or newline is accepted, as stated above). THE FALLBACK COST, disclosed: an UNPARSEABLE command (a tokenizer ValueError) whose FULL raw text or its de-quoted copy the scan flags - git plus a work-losing verb keyword; heredoc bodies are NO LONGER stripped anywhere on this guard, because stripping removes text and so can only hide a real discard - is DENIED outright, read-only and prose-only forms included, with NO session snapshot and NO stash preservation; only an unparseable command the scan clears on both readings is allowed at the true boundary. The same no-stripping rule brings a PARSEABLE command whose quoted-heredoc prose merely mentions a lossy keyword in scope, where the grammar denies the heredoc operator itself: a disclosed over-deny in the safe direction. WHAT THE GRAMMAR DOES NOT COVER, disclosed: it proves nothing about a command it accepts beyond the shapes above - the vetted read-only option lists are a point-in-time audit of the named spellings against the program-running and file-writing options git documents (a future or renamed git option outside the vetted set denies rather than runs, an over-deny by design); REPOSITORY, GLOBAL OR SYSTEM CONFIGURATION CAN MAKE EVEN AN ACCEPTED READ-ONLY GIT COMMAND RUN A CONFIGURED PROGRAM - core.fsmonitor runs its monitor on git status, textconv and external-diff drivers run where file content or patches are rendered, a configured pager or viewer can run on any command that pages, a configured signature program runs where signatures are read, and configured clean/smudge/process FILTER DRIVERS (filter.<driver>.clean, .smudge and the long-running .process) run where git checks content in or out - so the grammar bounds the COMMAND LINE, never configuration, and a positional operand is DATA passed to the read-only subcommand that receives it, not inert; an accepted literal path is checked lexically and by realpath only (a mount, bind mount or rename race can still make the directory git uses diverge); acceptance of true and : assumes the shell builtins, which a function defined in a PRIOR turn outside this command could shadow; and the raw-scan scope residual of the pin is unchanged (a real discard whose verb is outside the recognized set AND unflagged, such as git worktree remove -f, is still allowed at the true boundary, and a git or verb spelling fragmented in a way no raw, joined or de-quoted reading assembles reads as out of scope where the git alias reading does not deny it). DEFERRED, tracked as later work: accepting a vetted wrapper form (env/nice/timeout with an inert prefix), a vetted interpreter form (sh -c or bash -c with a single literal body), and a vetted xargs form; each now denies with a re-issue-plainly education. Newly denied legitimate spellings, each with its plain allowed alternative: echo or printf beside a discard in one command denies, run the discard alone or join plain git, cd, true and : segments with &&; a relative or symlinked cd or -C path denies, use a literal absolute physical path; a cosmetic GIT_* assignment or a colour/pager -c key on a non-pristine discard denies, drop the assignment or option (the pristine single-command spelling keeps them); a heredoc in an in-scope command denies, feed data from a file instead; an expansion, substitution or double-quoted token denies, spell the operand as a bare or single-quoted literal. The plain read-only and modelled forms (git stash list, show and apply, git status, git log, an &&-joined literal absolute cd before a plain discard, and a literal git -C <abs> discard, each preserve-then-allowed) still allow. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 0.58 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:git-explicit-binding
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

A best-effort LEXICAL check over the Bash command via the shared quote/redirect-aware tokenizer (_lex_command), ALLOW-WITH-A-NOTE by design (NO-ASK posture: it never returns an ask; read every ASK/ASKS below as that allow-with-informational-note outcome, an ambient-binding convention rather than a hazard; the EN-9 lesson: an open shell grammar has an irreducible undecidable tail), with ONE deny on a finding: a whole-tree breadth stage followed by a git push later in the same command DENIES-and-educates, on the parsed path and on every fallback alike; the shared missing-tool_name contract also denies. SCOPE ALIGNMENT with the rule text: expbnd keeps prefer-the-explicit-binding as a PRINCIPLE and makes the MECHANICAL requirement MANDATORY only in the ambient-target-CONFUSION cases (a directory change PRECEDING a git mutation, a wrapper that redirects git, or a breadth sweep that a relocation PRECEDES or that PRECEDES a publish (push)); this control matches that scope and does NOT and deliberately should NOT ASK on every bare ambient git. It fires on exactly TWO patterns, both ORDER-SENSITIVE (a shift confuses a git mutation OR a breadth op it PRECEDES; a breadth op is ADDITIONALLY a hazard when it PRECEDES a git push): (A) a cd/pushd/popd segment EARLIER in the SAME command feeding a later recognized mutating git subcommand that carries no ABSOLUTE explicit binding (a trailing shift, e.g. 'git commit && cd /x', does not confuse the earlier mutation and ALLOWS), and (B) a whole-tree breadth stage (git add -A/--all/'.'/':/'; git commit -a/--all/-am) that FOLLOWS a cd/pushd/popd shift (the relocate note) OR PRECEDES a git push (the one DENY above; 'git add -A && cd /x' ALLOWS -- staging happened in the correct repo; 'git push && git add -A' ALLOWS -- the push published the pre-breadth state). Both orders are read within ONE reading of any context-dependent '#' at a time (the shared tokenizer's comment rule), the strictest decision across the readings standing, so a before/after sequence no single reading holds never fires. DELIBERATE NON-FIRES, disclosed: a lone bare mutating git command in the session cwd (no cd, no push, no breadth) is ALLOWED, as is a lone 'git commit -am'/'git add -A' with no cd/push; the wrong-session-cwd residual is owned by confirm-execution-target (exetgt) at the reasoning layer and by git-discard (prsunc) for lossy verbs, and the lone-breadth sweep by separate-task-changes (septsk); asking on every bare git mutation would over-fire and train its own bypass. EXPLICIT-BINDING CREDIT: a binding is credited ONLY when it is an ABSOLUTE -C or an ABSOLUTE --git-dir; a RELATIVE -C/--git-dir still resolves against the ambient cwd and a lone --work-tree without --git-dir does not name which repository, so neither is a complete explicit binding and both route to the same ASK a bare ambient mutation would; even a credited absolute binding is NOT verified to point at the intended repository (absoluteness and explicitness are not intent, which stays exetgt's obligation). READ-ONLY EXEMPTIONS: the branch/tag mutation classifier is FAIL-SAFE / DEFAULT-MUTATING -- a git branch/tag command is treated as a MUTATION unless EVERY option token positively resolves to a recognized read-neutral role AND no create/rename/delete target is present (before or after the '--' end-of-options marker); it handles option abbreviations, attached-short values, optional-value flags, clustered shorts, and post-'--' targets, so unknown, ambiguous (non-unique prefix), or malformed forms resolve to mutating (this replaced an enumerate-the-reads design). The read cases are a filtered/verify LISTING ('git branch -l/--list', '--contains/--merged/--points-at', 'git tag -v/--verify') and a bare 'git branch'/'git tag' listing; likewise 'git branch --show-current', 'git tag --points-at', 'git config --get' or a bare 'git config <key>' read, and a bare 'git config' listing, are exempted even under a cd. Option recognition STOPS at a '--' end-of-options marker, so a file literally named '--all'/'-A' after '--' is an operand, not a breadth selector, while a genuine whole-tree pathspec ('.'/':/') still counts. WRAPPER HANDLING: the common BARE wrappers command/exec/builtin/env and a leading sudo ARE peeled, so 'command git ...', 'sudo git ...', and 'command cd ...' are judged like the bare form; a wrapper carrying its own option or assignment ('env -i', 'env FOO=1', 'sudo -u u') is a DISCLOSED residual left unpeeled (its option grammar is not guessed), as is any wrapper outside that set or one that fragments the git/cd command word. A git command behind the reserved words of a compound command (!, {, if, then, elif, else, do, while, until, time or time -p, coproc, function NAME, for NAME do or select NAME do) or inside a backquote substitution is read from its command word in a reading the shared lexer adds after its own, judged on its own as each reading is, so a breadth stage and then a push there DENY as the bare pair does (if git add -A && git push origin x; then :; fi). A command whose backquote bodies pass that reading's budget (524288 characters of body text, each body counted with 64 more, about 7,500 short distinct bodies) DENIES whatever it runs, never the fallback note, which could miss a breadth stage and push that a full reading denies. A git command that defines a git alias for itself (a -c or --config-env key in the alias, include or includeIf section, or a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n assignment naming one), its git word read wherever it stands in its segment (the command word, or a word behind a reserved word, a { group, !, time or a wrapper word; not inside the text of another word, such as an sh -c or eval body) runs a subcommand this check cannot see, so it reads as a mutation and as both a possible breadth stage and a possible publish, and so does every git command of a command whose text names GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT or GIT_CONFIG_KEY_ anywhere (sought once every quote character, backslash, dollar sign and newline is removed, so a quoted, escaped or continued spelling counts, in any word, a here-string, a comment or an echo), a git command there being any segment with a word that names git as a word wherever it stands (behind a reserved word or a wrapper, or inside an eval or sh -c word), whatever form sets the variable and whatever its value, since a shell sets one in more ways than a reading of forms can list (on git 2.53.0 with bash 5.3 an export, declare -x, typeset -x or local -x word, a bare assignment or a readonly word to a variable already exported, and under set -a also a bare assignment, read, printf -v, a for loop variable, a default-value expansion or a nameref each defined an alias), so a mention that defines no alias (an echo, a comment, an unset, an unrelated value) counts too, a disclosed over-refusal, or that runs under a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n naming one in the session environment the hook inherits, which counts as such a text does, git wherever it stands (not read: a variable name built at run time, such as an expansion after GIT_CONFIG_, a brace expansion or an escape decoded inside an ANSI-C quote, and a variable set outside the command text, in the startup files of the Bash tool shell or in a sourced file or a script the command runs). NOT CAUGHT (best-effort, not chased): an alias, shell function, eval, or fragmentation that removes a contiguous literal 'git' or cd/pushd command word; a cwd shift made in a PRIOR Bash call or via GIT_DIR=/GIT_WORK_TREE= environment (an inline GIT_DIR= assignment does NOT count as an explicit target, a disclosed conservative over-ask); a mutating subcommand outside the declared verb roster; plain (non-pruning) git fetch, deliberately excluded to hold the noise floor; mutations outside the Bash tool. A dynamic cd operand (cd "$D") still trips case A (the pattern, not the resolved dir, is judged). On a tokenizer parse failure a conservative raw scan DENIES on a visible breadth+push pairing (tested before the directory-change note, so a cd in the same text cannot mask that denial), ASKS on a visible cd+mutate pairing (which can over-ask on quoted prose inside the unparseable command), denies a text that names one of those git configuration variables and names git as a word twice or more (as many as a breadth stage and a later publish need), notes one that names such a variable and git beside a cd, and otherwise allows; an unreadable command string ASKS; a missing tool_name denies (shared fail-closed contract). Overlaps by design (defence in depth, consistent ASK, never conflicting): abs-paths-bash also asks on a RELATIVE cd, git-discard also asks on a compound lossy discard, and git-stash-ref also notes on a relocated pop/apply (cd /x && git stash pop double-notes with this hook under the same expbnd rule). DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 0.40 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:git-stash-ref
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

A best-effort LEXICAL check over Bash commands via the shared quote/redirect-aware tokenizer and the git-explicit-binding wrapper peel. ALLOW-WITH-A-NOTE by design (NO-ASK): a recognized bare git stash pop/apply emits one informational systemMessage per command, with no permissionDecision; any positional operand silently allows. The manifest block default is the fail-closed handler-error class marker, not a blocking verdict on a bare stash operation. PRESENCE IS NOT IDENTITY: no validation of well-formedness, existence, or intended entry; a SHA-shaped operand is not object validation, and stash@{N} can change referent between listing and execution. Dynamic operands, including "$REF" and command substitutions, count as explicit even if empty at runtime; an empty quoted operand also counts. This is the accepted under-warn direction. No automatic stash@{0} substitution and no recovery snapshot. NOT RESOLVED: git aliases, shell aliases/functions, eval, unknown wrappers, and wrappers carrying their own options or assignments (env -i, env FOO=x, sudo -u u). The common BARE command/exec/builtin/env/sudo wrappers are peeled exactly as in git-explicit-binding. Simple quote fragments ARE decoded by the shared tokenizer; fragmentation or obfuscation that does not resolve to recognized command/verb tokens remains outside coverage. This targets accidental ambient binding, not an adversary. The shared segmenter splits ordinary separators (;, |, &&, ||, newlines) and (...) subshells, and a reading the shared lexer adds after its own segments reads each segment from its command word, past the reserved words a compound command puts before it (a { group opener, !, if, then, elif, else, do, while, until, time or time -p, coproc, function NAME, for NAME do or select NAME do), and reads the commands of each backquote substitution, so a bare pop/apply there warns as a bare one does ({ git stash pop; }, if git stash pop; then :; fi). Still not read: a $(...) inside a double-quoted string, and a command an eval word or an sh -c string holds. A command whose backquote bodies pass that reading's budget (524288 characters of body text, each body counted with 64 more, about 7,500 short distinct bodies) gets the note whatever its text names. SIBLING NOTES: this note can co-occur with git_discard's raw/pristine fallback note on a wrapped or compound pop/apply, and with git-explicit-binding's note on a relocated form (e.g. cd /x && git stash pop); such sibling notes are consistent, never conflicting. OPTION-GRAMMAR PIN: the current pop/apply grammar has no value-taking options (-q/--quiet/--index). A future value-taking option can make its value read as an explicit operand and falsely allow; re-validate on git upgrade. Option validity itself is not checked. STACK-BLIND: no git calls or filesystem probes. Every recognized bare pop/apply warns regardless of actual stack state, including a freshly created single-entry stack; conversely a dangerous stack receives no escalation. This accepted over-warning supports WARN rather than DENY. SURFACE BOUND: Bash PreToolUse only, with no resolution of prior-turn shell state. Other stash verbs, including bare git stash (push), are outside this guard; drop/clear/export belong to git_discard. FAIL POSTURE: a tokenizer ValueError never passes clean where a bare pop/apply cannot be ruled out. On an ordinary parse failure (an unbalanced quote, a here-string, a process substitution) a command whose text, as written or in a de-quoted reading (quote characters, backslashes and a $ before a quote removed, line continuations joined, each ANSI-C $'...' quote with its escapes decoded), names git and then stash gets the note (so does one naming another stash verb, or an entry the tokenizer could not read: an over-note, never a refusal), and text naming no such pair silently allows. On one raised at a '#' whose comment reading depends on context (the shared tokenizer's comment rule) that the tokenizer cannot judge under both readings (more than four such '#', one in or right after a backquote substitution, or a reading that does not lex; any other such '#' is judged under both readings, a comment to the end of its line and word text, and a bare pop/apply either reading reaches gets the note): the tokenizer reads nothing past that '#', so a command whose text (also with its backslash-newlines and then its quote characters and backslashes removed) names git and then stash gets the note, and so does one whose text holds quoting that removal does not decode (an ANSI-C $'...' or locale $"..." quote, or a quote character or backslash next to a letter), whatever its words, since a bare pop/apply spelled in it cannot be ruled out. An absent/unreadable command emits the sibling's exact binding-convention note. Only the shared missing-tool_name contract returns a structured deny; an unexpected event hard-blocks and dispatcher errors retain the fail-closed manifest default. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 0.52 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:orch-ask-unattended
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on AskUserQuestion
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

Registry-scoped: inert with no registry at the session cwd's git-resolved toplevel (no ancestor walk, unlike orch-truncation-guard; AIQT_ORCH_REQUIRE_REGISTRY is not read here), and an unreadable registry allows silently with no guard-events row. TWO postures by operating mode. UNATTENDED: blocks a blocking question when the declared operating-mode record reads as unattended (this guards one known mistake shape, a blocking prompt idling an unattended run; it is not a security boundary). The mode record fails CLOSED to that guards-armed (unattended) posture, so the question is denied, for every read of the mode path that ends in neither not found nor a decoded text, the deny naming the reason: a mode path holding a NUL character, or one that is not strict UTF-8 (a lone surrogate, which is also how a surrogate-escaped non-UTF-8 name arrives), refused fail-closed before any open even where the OS could open it; a file that cannot be opened or read (a permission error, a socket, whose non-blocking open itself fails (ENXIO on Linux), or a missing file under a parent path that is a regular file, whose open reports not a directory rather than not found); one that is not a regular file (a directory, a FIFO, or a device such as /dev/zero, refused by an fstat after the non-blocking open and before any read, so a FIFO with no writer does not wait); one larger than 1048576 bytes (the reader reads at most one byte more), or one whose bytes are not valid UTF-8; and, for the text read, in these cases: an Operating-mode: declaration whose value is empty or begins with neither attended nor unattended; or, with no declaration line, content that parses as JSON but is not exactly an object whose one key, mode, holds a string beginning with attended or unattended (a bare JSON scalar such as true, 42, null or a quoted string, an array, an object with other keys, or one whose mode is missing, not a string or unrecognized), or content beginning with {, [ or a double quote that does not parse as JSON (malformed, partial, trailing text, duplicate keys, or nesting too deep to parse). It fails OPEN, allowing the question, in these cases: no declared mode path, a file whose open reports not found, an empty or whitespace-only file, or content with no declaration line that does not parse as JSON and does not begin with {, [ or a double quote (prose, including prose beginning with a number or a word, such as 42 items done or true trailing); that fail-open is logged to guard-events (where that append fails, the call is still allowed, with a note saying the row could not be written). Every input ends in one of these answers or the attended judgement below; only a stall the hook timeout bounds can delay it (a path lookup on a hung mount, a read from a stalled filesystem, or what a device driver's open does). The unattended deny instruction distinguishes record-and-continue for ordinary decisions from record-and-HOLD for anything at the human-oversight threshold; the hook redirects the question and never licenses acting without the answer. Each denied unattended ask appends ONE idempotent machine row keyed on session_id plus tool_use_id, carrying a bounded redacted summary (question count, length, digest), never the question text, and a guard-events row; a failed guard-events append adds a short recording-failure warning to the deny reason and the banner while the deny stands. ATTENDED: an attended-family mode no longer allows unconditionally. Where the registry also declares a backlog enumerator, the question is judged against the same FRESH enumeration the yield guard uses (the shared decision core, ctx kind ask: fail-closed, with NO wake-naming requirement, since a question names no recheck wake), because a blocking question parks the run on its answer exactly as a scheduled idle parks it on a timer. The judgement is session-scoped exactly as the yield guard is: a session outside the live scope (a co-located worker a holder-scoped lease does not name) does not inherit the global backlog, so its question is allowed; under a holder-scoped lease (holder_is_session_id) the judgement is likewise off for EVERY session, the orchestrator included, where no holder can be confirmed, exactly as in the yield guard: a lease file that cannot be stat'ed, and, for a NON-EMPTY lease the session-id and holder-token checks run on (one the age classifier reads as fresh or cannot-evaluate under a declared max_age_hours, or, with no horizon declared, every non-empty lease whatever its mtime), a lease that cannot be read, a call that carries no session id, or a lease that names no session. Every other lease shape skips the holder check and falls through to the live-mode scope source: an empty lease, a lease that declares no path (an absent, empty or non-string path), a lease stale past a declared max_age_hours, and a lease future-dated past the tolerated clock skew under a declared usable max_age_hours (an unusable one reads cannot-evaluate whatever the mtime, so the holder check runs); a live mode record then keeps EVERY session in scope there, the sessions the lease does not name and an id-less caller included, so the judgement stays on (erring toward denying, never toward letting a question through). Within scope the question is DENIED while granted open items are actionable, neither dispatched (waiting on a live tracked task in the dispatch ledger) nor blocked by a human decision recorded at a row-id position in the declared pending-decisions record (fresh external or foreign-lease evidence, its attestation narrowing included, and a not-before time also block, the stop guard's blocker grammar), the deny naming up to ten actionable items and the two ways forward: DISPATCH them, or RECORD each as blocked by this decision (a human-decision blocker on the item plus a matching pending-decisions row), then re-issue the question. That actionable deny is NEVER cap-relieved: the fourth unchanged question is denied exactly as the first, because both named exits stay available however often it is re-asked, and a backlog holding both an actionable item and a cannot-evaluate item keeps that actionable deny, which then also names the held items in the same reason (held work never buys relief past work the enumeration still classifies as actionable). An enumerator error, and any open item whose proof source cannot be read, is a cannot-evaluate that DENIES (missing evidence never licenses parking the run on a question), and ONLY these cannot-evaluate denies, and only while no item is actionable, are bounded by the three-denial cap on an unchanged basis as on the schedule path, past which the question is allowed with findings and marked forced_unresolved when open work remains (a guard that can be farmed trains its own bypass, and a broken enumerator or proof source may be outside the session's power to repair). The actionable narrowing reads the CLASSIFIED sets only: a proof-source failure that holds every otherwise-actionable item (examples: one schema-invalid dispatch-ledger line, a shape the session can append itself; an unreadable or undeclared pending-decisions record, which includes one that is not a regular file (a FIFO, a device or a directory, opened without waiting for a writer) or is larger than the 65536-byte bound; or a declared attestation register whose validated snapshot is absent or stale) or an enumeration failure that hides items (a duplicate item id fails the whole enumeration) leaves no item classified actionable, so three unchanged denials still buy the cap relief past that work; each relief ATTEMPTS a forced_unresolved record and a guard-events row, a failed write is reported in the released message, and the relief still proceeds; the ask denials are counted by their OWN pair (ask_denials and ask_basis in turn-state.json), so ask denials never buy the yield guard premature cap relief and schedule denials never relieve a question; genuine human input (a prompt the stamp recorder does not classify as a registered timer wake) resets the ask pair exactly as it resets the schedule pair; and the counter save needs the same state-directory modes as the yield guard's, a deny whose counter cannot be saved standing uncounted and saying so. A registry that declares NO enumerator key leaves the attended judgement out of scope and allows, while a DECLARED enumerator that is not an object is a configuration error denied as cannot-evaluate with a guard-events row, named as such, exactly as the yield guard denies it (never a silent allow); an absent registry keeps the whole hook inert; the operator-owned escape sentinel releases the deny under the same enforced acceptance as the yield guard, its use recorded; a judged clean allow appends a best-effort guard-events row (the over-fire metric). The fresh enumeration runs the registry-declared enumerator inside the hook deadline, so a slow enumerator fails closed at it. Its behaviour is verified by the behaviour self-test. RESIDUALS this hook cannot see: a mode change AFTER the question was asked (a PreToolUse hook runs once, when the tool is called, and no pack hook can cancel a pending interactive call; that needs host-side handling); work the enumerator does not list (deliveries to read, merges, or scheduled reviews not modelled as backlog items); an orchestrator that wrongly marks independent items as blocked by the question; whether each question is genuine or already has a recorded default; and a blocking prompt raised outside the AskUserQuestion tool, any other question tool included. Whether the platform honours a PreToolUse deny on AskUserQuestion, and whether scheduled wakes fire while a question is pending, is unverified platform behaviour. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took under 0.01 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:orch-dispatch-ledger
  • Platform: claude-code
  • Default: warn
  • Entry point: PostToolUse on Bash|TaskOutput
  • Class: c
  • Channel: plugin

Technical limits (from the enforcement ledger)

A RECORDING substrate, not an enforcement point: it appends launch rows (a Bash call with run_in_background true, or a registry-declared dispatch tool) and completion rows (a TaskOutput read) to the machine-written dispatch ledger the stop guard consumes as its Gap-B oracle. It never blocks; a failed ledger write is surfaced as a non-blocking systemMessage, never swallowed, and a lease max_age_hours horizon that cannot be evaluated does not turn this recorder off: the row is recorded anyway and the cannot-evaluate is surfaced as a note (armed and surfaced, never a silent skip). Coverage is exactly the hooked tools: a launch through an unhooked tool is invisible (costing one spurious stop-guard deny), a completion that never passes through TaskOutput is invisible until the staleness horizon expires the row, and the task-id extraction from tool responses is best-effort field matching. Both blind-spot directions are bounded by staleness plus the resume audit, and disclosed at the stop guard.
hook:orch-lease-heartbeat-post
  • Platform: claude-code
  • Default: warn
  • Entry point: PostToolUse on .*
  • Class: c
  • Channel: plugin

Technical limits (from the enforcement ledger)

REGISTRY- AND DECLARATION-SCOPED keeper, inert by default: it runs only where the orchestration registry's lease declares holder_is_session_id and a valid heartbeat block (the registry is the confinement's trust root, so enabling the keeper, and the wider refresh mode, is an operator act there; an invalid block never writes, never falls back to another mode, and is surfaced as a Stop warning and a resume-audit finding (never a guard-events row or a memory save: no descriptor proof exists before the first open, and a bookkeeping writer with no held descriptor withholds)). It WRITES only in the declared refresh mode, and only when every proof holds: one parsed holder field EXACTLY equals the payload session_id (never a whole-token presence test, which a decoy line such as a previous_holder entry passes), the lease is a single-link regular file owned by the effective uid, reached by a component-wise no-follow descriptor walk (a '..' component is refused and a symlink at ANY component refuses, ancestors included), its declared state (where one is declared) is an active value, its recorded times (the file mtime, and the declared heartbeat field where one is declared, judged on the OLDER of the two) are neither future-dated nor past the refresh ceiling of two clock skews under max_age_hours, and the file's identity is re-checked against the proof before the touch or the exclusive-temporary-file atomic replace; a declared heartbeat field is rewritten with a clock read at the event, never a recalled value, and a lease inside refresh_after_minutes is left alone (idempotent). It NEVER creates, seizes, revives or releases a lease, never renews a LAPSED one (refreshing a live, proven-held lease in the declared refresh mode is the feature), and it never rewrites one that is absent, foreign, released, unreadable, oversized, non-UTF-8, unparsable, duplicate-keyed, git-tracked, hard-linked, foreign-owned, on a protected path (a .git, .aiqt or .claude component, plugin/, anywhere inside the state directory's or another protected directory's subtree, judged by ancestor-identity containment rather than child enumeration, or an inode alias of a registry file; a protection check that cannot be evaluated refuses, and the protected-path and tracking gates cover BOTH refresh forms, the mtime touch included, because there the mtime IS the recorded liveness value), while a lease outside the repository work tree passes the git tracking gate (it cannot be tracked there; outside-ness is decided on resolved paths, never a lexical spelling), or stale or future-dated (those are warned about, never renewed; a future value is left in place as a record of the clock skew or tampering). On this event it is SILENT on stdout (it never exits 2, never blocks and shows no message: its result is exit 0, and only a stop raised inside the core, an interrupt or an exit, propagates and can end the hook with another non-zero status, never 2, because the binding re-raises an exit whose status would be 2 as an exit with status 1; a withheld or failed bookkeeping write, a written-unverified refresh and a written-not-durable refresh ride the result's error text to the dispatcher's stderr): guard-events rows and that report are the only traces (one row per persisting refusal state, never one per call, and the git tracking probe of an unchanged refusal is backed off), the Stop binding owns the warning, and the binding catches the core's own faults (a fault inside the judged table becomes one row ONLY on the branches where the proven descriptor is held, recorded while it is held; on a busy or faulted lock the table runs with no held descriptor, so a fault there records no row and joins the Stop-only deferrals, dropped with a PostToolUse event's holder; a fault outside the table records no row and rides the result of its own event), never a per-tool-call banner. Residuals: a session that makes no tool call and ends no turn produces no heartbeat (staleness is then the correct outcome, and a long foreground tool call delays the refresh by up to its own length); two processes sharing one session_id both refresh and the double run is not detected; the optional declared flock lock is taken BEFORE the lease is read and held across the whole read-prove-write span, so every proof holds inside its critical section and an in-place change by a writer that used the lock is read there and refuses (the lease is RE-OPENED under the held lock, so a cooperating takeover between the first open and the lock reads as taken or lost, never a cannot-evaluate, and a flock fault other than busy is a named refusal surfaced on Stop with the fault named exactly once per turn end (a due refresh's banner names the fault and carries the bookkeeping consequence, with the deferral dropped rather than rendered a second time), never a silent skip or a per-call report; the lock must be a STABLE, SEPARATE coordination file NO writer of this plugin ever replaces, so a lock_path that names, or resolves to the same identity as, the declared lease or ANY file a whole-file writer of this plugin replaces (the past-holder memory, the guard-events log, every _orch_write_json_atomic target such as turn-state.json and backlog-checkpoint.json, and anything else inside the state directory) is refused by one SHARED read-only validation on paths and destinations resolved from the authoritative root, at declaration validation, at the resume audit and at the entry check (resolved path, resolved-path containment in the state directory, parent identity and inode, with a stat fault other than absence the named refusal and nothing created), and again by descriptor identity under the held lock, where every state-directory entry is also swept by fstat identity against the held lock descriptor, because flock attaches to the open file description, so a keeper whose own save or refresh replaced its lock file would leave the next cooperating keeper locking the replacement while this one still holds the orphaned inode, voiding the critical section), while a writer that ignores the lock (or any writer where none is declared) can still race the re-check-to-replace window, where an unlinked lease is revived by the replace or a swapped-in acquire is overwritten, a window the identity re-check narrows but cannot remove, and one the post-replace verification does not report (it reads the replace's own content, which carries this session's refreshed value, so the revived lease or the overwritten acquire reads as a clean refresh; it can report a change that lands between the replace and its own re-read, or a verification fault, where the refreshed lease cannot be re-opened, re-read or re-parsed, and it can undo nothing); Bash can still rewrite the lease or the registry (the standing write-scope residual); the past-holder memory and the guard-events append are best-effort (a lost row or memory only widens silence, never a write), and BOTH destinations are checked distinct from the declared lease at the entry, by resolved path, by inode and by parent identity (a lease path that IS the state directory or an ancestor of it is refused BEFORE that directory would be created, with nothing created; otherwise an absent state directory is created first, idempotently under concurrent creation, so that leg always compares real identities, and a creation fault is the named cannot-evaluate whose remediation names the state-record fault while a faulted lease access names the lease path, each chosen from the fault itself, never from a substring of the detail), and AGAIN AT EVERY WRITE: each append opens its destination by NAME relative to the held state-directory descriptor (dir_fd) with O_WRONLY|O_APPEND|O_CREAT|O_NOFOLLOW|O_NONBLOCK|O_NOCTTY (an absent log is created, so a refused append can still have CREATED an empty destination file, the disclosed O_CREAT side effect, with no row byte landing anywhere; a planted symlink is refused, never followed; a FIFO, with or without a reader, returns promptly and is refused on the descriptor's own stat, never a blocked hook), fstats the very descriptor it writes through and writes only a regular file with exactly one hard link whose identity differs from the held lease descriptor's and from an lstat of the lease name taken relative to the held lease parent-directory descriptor AFTER the destination fstat, the memory and backoff saves create their temporary file exclusively, check the temporary DESCRIPTOR before its first byte (a regular file with exactly one hard link, distinct from the held lease descriptor and from a fresh lstat of the lease name, so a temporary renamed onto the lease name, hard-linked or unlinked before the first write is refused with nothing written through the descriptor) and replace relative to that same held state-directory descriptor on both sides (src_dir_fd and dst_dir_fd) after the same identity checks on the replace target and an identity re-check of the temporary NAME immediately before the replace (a re-pointed temporary name publishes nothing, and the failure cleanup unlinks the temporary name only when one stat just before the unlink finds it still carrying the inode the save created, running from a finally for ANY exception class, the unlink itself by name since POSIX offers no unlink-if-inode; one whose identity was never learned, because the pre-write stat itself faulted, is disclosed in the report and never unlinked, and a cleanup stat fault other than confirmed absence also never unlinks and discloses the name and the fault), and a refused or failed write is withheld and reported through the hook result (the Stop banner, or the result's error text, which the dispatcher writes to stderr), never into a file (the DISCLOSED residual of the bookkeeping is the SAME-USER CONCURRENCY CLASS, stated once and completely: a concurrently running process of the same user can rename, re-point, link or replace ANY entry between ANY check and its use, the state directory, the lease's parent directory, the lease, a bookkeeping destination or a temporary file, so a row can land at a declared path, which the suite keeps a disclosed-residual WITNESS vector for, re-pointing the lease's parent directory at the state directory between the destination open and the identity read and showing the row landing at the declared lease path, or a bookkeeping write can be lost, and the failure cleanup of a field write or a save, unlinking by name after one identity stat, removes an entry swapped onto the temporary name between that stat and the unlink in its place, leaving the moved temporary undisclosed, which the suite keeps a WITNESS vector for too; the checks cover ACCIDENTAL mis-targeting and a stale or replaced lease, and they do not guarantee a boundary against a same-user concurrent actor, which needs OS isolation, distinct users or a state directory and lease directory private to this keeper), with the destinations, the HELD, separation-proven lease descriptor, the lease parent-directory descriptor and the state-directory descriptor (opened once, O_DIRECTORY|O_NOFOLLOW, when the proof passed; the holder is refused outright when the state directory and the lease's parent are the same directory by fstat identity) carried as ONE per-event holder that EVERY bookkeeping writer consumes, the row append, the event append, the memory save, the tracking backoff save and the crash recorder included (none re-reads anything, and none writes without the held descriptor); the holder is emptied by the first open, by taking the declared lock and by the under-lock re-open, filled only by the separation re-proof on the descriptor then in hand, and emptied again BEFORE any descriptor or lock is released (a core fault inside the judged table records its one crash row from within the critical section, before descriptors or lock are released), so the entry path before the first open, an invalid declaration, a failed first open, a failed under-lock re-open, a busy or faulted lock, a crash before the proof or after cleanup, and an unknown or unusable lease path each write and record NOTHING, a disclosed narrowing surfaced through the hook result (the busy- and faulted-lock branches, the no-holder branches that still run the decision table, record their bookkeeping refusal as a deferral surfaced on Stop only, at most once per turn end, the faulted-lock deferral naming the fault and rendered once, a due refresh's banner naming the fault with the deferral dropped, never a report on every PostToolUse call; a deferral made on a PostToolUse event is dropped with that event's holder, so a core fault deferred there surfaces only where the Stop event reproduces it, while every other no-holder refusal rides the result's error text of its own event, PostToolUse included, and a core fault recorded on those branches surfaces as the named fault, never folded into the lock deferral; a refusal never records into the refused lease; while a collision persists the keeper writes and records nothing anywhere and the Stop warning is the only surfacing); the declared lease path and lock_path values are validated on the strings alone BEFORE any path resolution (an embedded NUL, or an empty component from a doubled, leading or trailing separator, is the named refusal on Stop, never a resolution fault or a silent allow) and descriptor ownership is exception-safe from the moment of acquisition (any fault, not only OSError, one inside the first separation proof included, closes each held descriptor exactly once; the holder build owns its freshly opened state-directory descriptor in a try/finally from the open until its transfer to the holder, across both identity fstats and the replacement of a previous holder descriptor); a field write changes only the declared field's value text (in json the value is replaced in place, never re-serialized, and the write is refused unless every other member's value text stays byte-identical and its parsed value exactly equal), refuses a lease carrying a setuid, setgid or sticky bit (the field write only: the mtime touch changes no permission bit and still runs on such a lease) and otherwise carries every permission bit over, gives its temporary the lease's group before the first byte (on Linux a new file starts in the creating process's effective group, or in the directory's group where the directory is setgid or the filesystem is mounted with grpid; a failure to set it is the named no-write refusal, with nothing published, so where it always fails, for example for a process without CAP_CHOWN whose effective and supplementary groups do not include the lease's group, writing where a new file does not start in that group, or on a filesystem that refuses the change, every field refresh is refused: Stop warns of that refusal only while the lease is still eligible for a refresh, and once the lease is past the refresh ceiling Stop warns instead that it is too close to renew, then, once stale, that it has lapsed), and reports a failed directory fsync after the replace as a write that happened but may not be durable, never as plain success (the Stop warning, or on PostToolUse the result's error text, with an attempted guard-events row; a failed append is reported through the withheld-bookkeeping warning instead), and reports a failed post-replace verification the same way on both events, as a write that happened, never claimed clean, while extended attributes and ACLs are not carried over by a field write, and the final parent directory needs read permission for the fsync; a temporary file that may remain after a fault inside the failure cleanup of a field write or a bookkeeping save is named in the withheld report by a record whose fixed part is formatted before the temporary is created, and a stop raised while that record is built, recorded or replaced never displaces a stop already propagating, but only for faults raised inside those guards: a stop raised while a withheld record naming a temporary that may remain (this one, or one the failure cleanup makes for any other cause, such as an identity never learned) is itself appended, before that append has taken effect, propagates without that record, ordered as a stop raised in the cleanup (nothing is recorded, and no preformatted record can be, because recording one needs the very append that stopped), a stop raised while a field write's refusal detail naming that temporary is built propagates with the refusal never returned, memory exhaustion so severe that a fixed-size step fails (a call, the append that records the entry) can lose that record, and a stop delivered as a signal at an instant outside the guards leaves the failure cleanup at that point, unordered; and a fresh heartbeat proves only that the session is alive, never that it is making progress, so it must never count as activity for yield decisions.
hook:orch-lease-heartbeat-stop
  • Platform: claude-code
  • Default: warn
  • Entry point: Stop
  • Class: c
  • Channel: plugin

Technical limits (from the enforcement ledger)

The Stop binding of the same core as orch-lease-heartbeat-post, with the same scoping, holder proof, write discipline, fault directions and residuals; it covers a turn that made no tool calls, and it is the ONLY binding that surfaces a message (exit 0 systemMessage, at most one per turn end): a declared lease that coincides with the keeper's own bookkeeping destinations, by resolved path, inode or parent identity, whether found at the entry proof, on the first opened descriptor or at the under-lock re-open (nothing written, nothing recorded, and nothing created: a lease path at or above the keeper's own state directory is refused before that directory would be created; a refusal branch holding no proven descriptor, the entry path before the first open, an invalid declaration, a failed open or re-open, a busy or faulted lock, likewise records nothing and surfaces through the hook result (on Stop this banner, or the result's error text, joined by any deferred or withheld bookkeeping report; a no-holder refusal outside the lock branches rides the error text of the event it happened on, PostToolUse included: a busy or faulted declared lock defers its report to Stop at most once per turn end with the fault named and rendered exactly once, a due refresh's banner naming the fault with the deferral dropped, a deferral made on a PostToolUse event is dropped with that event's holder, and a core fault recorded there surfaces as the named fault, never folded into the lock deferral), while every write a proven branch does make goes through a destination opened relative to the held state-directory descriptor and fstat'ed at write time, so a destination aliased onto the lease is refused there too, with the lease-name identity read AFTER that stat, reported in this banner or as the result's error text (the dispatcher's stderr), never into a file); a declared lock_path that names, or resolves to or is aliased by identity to, the lease or ANY file a whole-file writer of this plugin replaces, the bookkeeping targets, every _orch_write_json_atomic target such as turn-state.json and anything else inside the state directory included (refused by one shared read-only validation on paths resolved from the authoritative root, at declaration validation, at the resume audit and at the entry check, by resolved path, state-directory containment, parent identity and inode, and by descriptor identity under the held lock, where every state-directory entry is swept against the held lock descriptor: the lock must stay a stable coordination file no writer of this plugin ever replaces); an invalid heartbeat declaration (an unusable declared path value is refused on the string alone, before any path resolution; no row and no memory save, this warning and the resume audit are the only surfacing); a heartbeat gap past warn_after_minutes (refreshed in refresh mode, named as unrefreshed in warn mode, which never writes the lease, though its bookkeeping rows and saves still run through the held-descriptor discipline and a failed one is reported in this banner); a lapsed, future-dated, too-close-to-staleness or not-active lease; a cannot-evaluate (an unreadable, symlinked, hard-linked, oversized, malformed or ambiguous lease: a named no-write warning, never a write); a lost or taken lease, surfaced only to the session the state-directory memory records as the last proven holder; a refused refresh; a verified replace whose directory fsync failed, reported as a write that HAPPENED but may not be durable, never as plain success (with an attempted guard-events row; a failed append joins this banner as the withheld-bookkeeping report); and a post-replace verification failure, which is reported as a write that HAPPENED, never claimed clean. It never exits 2 (a stop raised inside the core propagates, but an exit whose status would be 2 is re-raised as an exit with status 1), never blocks, never asks, and never alters orch-stop-guard's decision: a refresh only keeps a fresh lease fresh, it never re-arms a lapsed lease and never disarms a live one, so its order against the stop guard on the Stop event does not matter.
hook:orch-prompt-stamp
  • Platform: claude-code
  • Default: warn
  • Entry point: UserPromptSubmit
  • Class: c
  • Channel: plugin

Technical limits (from the enforcement ledger)

A RECORDING substrate: stamps the clock-read UTC time of each GENUINE human prompt into turn-state (resetting the stop guard's deny counters), classifies a prompt matching a registered wake as timer-originated (not stamping it), and injects a measured-gap context line so any quiet-duration reasoning uses the measured figure. It never blocks, and a lease max_age_hours horizon that cannot be evaluated does not turn this recorder off: the stamp or classification proceeds anyway and the cannot-evaluate is surfaced as a note (armed and surfaced, never a silent skip). Turn-state is saved through the state directory opened once as a directory descriptor (with O_PATH where the platform has it, an open that needs no permission on that directory itself, only search permission on its ancestors, while the later stat, create, rename and cleanup need search permission on it, the last three write permission too; where O_PATH is absent the fallback open also needs read permission on that directory), every later step bound to it, so that directory swapped for a symlink after the open cannot redirect the save or its cleanup (the directory path itself is resolved once, at that open, a directory moved after the open still receives the save under its new name, and the writing uid is not a boundary): the hook creates a temporary file of its own beside turn-state.json (an exclusive create under a random name that never follows a symlink, so nothing already at a temporary-like name is written or removed), giving it the existing file's permission bits (0600 for a new file), writing and fsyncing it, and renaming it onto turn-state.json: a reader sees the previous file or the new one, never a part of either, and a failed save leaves the previous file (or its absence) in place and is named with its error in the note, which also names a temporary file whose removal after the failure failed, by the state directory path as given, which after a swap may no longer lead to it (one left by a process killed mid-save is removed by nothing). Concurrent saves never share a temporary file, but the last rename wins, so two hooks that update turn-state at once can lose one update (the read-modify-write is not serialized) while each reports its save as succeeded. Where turn-state cannot be written, a genuine prompt still proceeds, with a note saying its time was not stamped and the deny counters were not reset, and a timer-originated prompt's context line and a systemMessage say consuming its wake digest failed, so a later prompt with the same text may read as timer-originated or as genuine human input. Classification is digest matching against wake registrations this suite itself recorded: a wake fired through a mechanism the suite never saw is stamped as genuine human input (the safe direction: it only narrows guard autonomy). An unknown prior stamp yields an 'unknown' gap that authorizes nothing.
hook:orch-resume-audit
  • Platform: claude-code
  • Default: warn
  • Entry point: SessionStart
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

SessionStart cannot block at the platform (exit 2 shows stderr only, doc-confirmed 2026-08-29), so this layer SURFACES divergence and ARMS the PreToolUse resume barrier; the barrier is the mechanism. Probes, each failing closed to a finding rather than a silent pass except where noted: the declared handoff's Branch line against the real HEAD (scrubbed read-only probe; a HEAD whose branch cannot be read, a detached HEAD included, gives no Branch finding), a handoff gate marker's commit against the real HEAD commit, lease staleness (a lease file that does not exist yet is no finding), every declared record surface (handoff, findings, pending_decisions) readable as a regular file, opened without waiting for a writer and read to at most a 65536-byte bound plus one byte (a declared surface that cannot be read, or is a FIFO, a device, a directory or anything else not a regular file, is a cannot-evaluate FINDING, never nothing-to-check, as is a handoff or findings register larger than the bound, whose content is parsed; a pending_decisions file larger than the bound is only required to be readable here; a record key present with an unusable value, an empty string, null, a number, a boolean, a list or an object, and a record block that is present but not a table, are cannot-evaluate findings too; a CR or CRLF line ending in any of the three reads as LF, as a text-mode read gives it, so a Branch or Gate line or a findings row after a CR is still parsed; only these three record surfaces are read this way: another path the registry declares that these probes read, such as the attestations register, is still opened by a read that waits for a FIFO writer, so a FIFO with no writer there still stalls these probes), and merge-pending rows against first-parent merge evidence; with a registry present, also a registry the loader reports bad and the truncation guard's deny at its scope check for a Bash call from the repository root (the same finding list tools/orch_doctor.py --resume-audit writes): a root git resolves but the walk cannot carry out (for example one this process can enter but not read) arms the barrier in EVERY mode (where the session cwd is missing, empty or not a string, or git resolves no root for it, for any reason, such as an unreadable or broken config, a dangling gitfile, a dubious-ownership refusal, no git binary, a timeout, or a cwd this process cannot enter, or git exits 0 but prints output that cannot be decoded, is empty, or is not absolute, the audit returns silently in both modes before it reads the registry, and for a non-empty string cwd whether the truncation guard's scope check denies a Bash call from it depends only on its own ancestor walk of that cwd and the mode; git can still resolve a root this process cannot enter, through core.worktree from a session cwd inside the repository's git directory, and the audit then warns and arms the barrier in both modes where that root exists without search permission or is not a directory, because the registry loader reports it bad (as it does for a regular file named .aiqt at any root), and stays silent where that root does not exist, because the loader reports the registry absent), and an absent or unconfirmable registry scope (a symlinked registry, for example) arms it when AIQT_ORCH_REQUIRE_REGISTRY selects registry-required mode, so this layer is mode-sensitive. The barrier does not record that mode, so the two writers agree only when run with the same value; a doctor run without it can clear a barrier a registry-required SessionStart armed for a scope deny; clearing it does not allow any Bash call, because the barrier only warns and the guard denies on its own scope check. Arming and clearing are BEST-EFFORT and ATOMIC: both writers create a temporary file beside the barrier file, flush and fsync it, then rename it onto the barrier file with os.replace. Where the barrier cannot be written (its state directory cannot be created, the temporary file cannot be created or written, or the rename fails, for example XDG_STATE_HOME naming a regular file, or a full disk), the SessionStart audit removes the temporary file it created and ignores the error so SessionStart never wedges, the previous barrier file (armed or clear) or its absence is left byte-identical, the PreToolUse barrier therefore does not surface this run's findings, and the session still sees the SessionStart warning naming them (where opening the forced-exit log fails other than as not found, because the state directory cannot be searched or is not a directory, as with that regular file, the warning also names the forced-exit log as unreadable), and that warning then also says the barrier was not persisted (this audit did not arm it) and asks for a manual record instead of saying a re-run clears the barrier, naming the remedy (remove a directory at the barrier path, which neither audit can replace; otherwise make the state directory creatable and writable), while a clean audit that cannot clear the barrier adds no warning (a barrier it leaves armed still reads as armed, noted once per arming where its warned flag can be written, otherwise on each mutation outside the record surfaces, so one already warned about stays silent; a directory in its place reads as armed and is noted on each mutation outside the record surfaces); tools/orch_doctor.py --resume-audit does not ignore that error, its run ends with it, and the previous barrier file is left unchanged. The PreToolUse barrier reads the barrier file only where the registry loader reads the registry ok, and reads a barrier file that exists but is unreadable or malformed as armed (see orch-resume-barrier). Registry-scoped: silent where the registry loader reads the root's registry as absent; a root the loader cannot examine reads bad, not absent, and the audit warns. It reads records, git state, and the truncation guard's registry discovery only; it cannot detect a handoff that is stale in ways its declared grammar does not carry (free-text drift). Lease MISMATCH detection at this audit stays bounded to staleness, over the file mtime and, where the registry declares a lease heartbeat block, the declared heartbeat field too (an invalid declaration, an unevaluable heartbeat, and a stale or future heartbeat field are findings): these probes are shared with tools/orch_doctor.py --resume-audit, which runs with no session identity, so the holder field is not compared HERE; where the lease declares holder_is_session_id, each lease-scoped guard proves the payload session identity against the holder field per event, and the orch-lease-heartbeat controls surface a lost or taken lease to the session that last provably held it.
hook:orch-resume-barrier
  • Platform: claude-code
  • Default: warn
  • Entry point: PreToolUse on Write|Edit|MultiEdit|NotebookEdit|Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

The mutation barrier behind orch-resume-audit, shipped WARN-FIRST (stage bake, a recorded posture): while the barrier state is armed it surfaces a systemMessage on the first mutation outside the allowlist (the record surfaces, the registry files, and the suite's own state directory stay writable, so the only exit, correcting the record, is never blocked), and it blocks nothing during the bake. The flip to deny is a manifest edit (stage to enforce, default to ask/deny) taken on recorded guard-events evidence after the bake period, per the guardrail-config-integrity discipline. Bash targets are not path-judged (a command's write targets are not lexically resolvable), so Bash mutations pass with the same surfacing only. Clearing the barrier requires re-running the audit (tools/orch_doctor.py --resume-audit) against corrected records; acknowledgement alone does not clear it. An absent barrier file (a dangling symlink included) reads as clear, and a symlink to a regular file reads as that file. The barrier is read with a non-blocking open (a FIFO with no writer does not wait for one), an fstat that refuses anything not a regular file before any read (so a FIFO or a device such as /dev/zero is never read), and a read bounded at 64 KiB. One that exists but is not a regular file (a FIFO, a socket, a device, a directory, or a symlink to any of these), is larger than the bound, cannot be read or parsed for any reason (including a RecursionError from deeply nested JSON), or is not a well-formed barrier (a JSON object whose keys are exactly a boolean active and a list of string findings, both required, plus an optional boolean warned and an optional string ts; a truncated write by an earlier writer is not), reads as ARMED, not absent: every mutation outside the allowlist then surfaces a note naming the file as unreadable or malformed with the reason (not once per arming, since there is no readable warned flag to record), which blocks nothing during the bake, and a mutation on the allowlist is allowed after the same read (a regular file on a filesystem that stalls, such as a hung network mount, can still stall the read until the hook timeout, as any file read can); it clears when the doctor's --resume-audit or the next SessionStart audit replaces the file (the rename replaces a FIFO, a device node or a symlink at the path and leaves a symlink's target intact), or when the user corrects or removes it (the state directory is on the allowlist), and where the state directory cannot be searched the replace fails too, so the note persists until its permissions are restored. Neither audit can replace a directory at the barrier path itself (the rename fails with IsADirectoryError), so for a directory the note says to remove the directory; a symlink to a directory gets the generic note, since the rename replaces the link. A writer removes the temporary file it created when any later step fails (writing, closing or renaming it) and never removes a temporary name it did not create; a process killed between creating and renaming it leaves that temporary file beside the barrier file. Recording the warned flag replaces the file atomically too; a failed replace leaves it unchanged, so the note surfaces again on the next mutation. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took under 0.01 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:orch-stop-guard
  • Platform: claude-code
  • Default: block
  • Entry point: Stop
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

REGISTRY-SCOPED: entirely inert unless an orchestration registry (.aiqt/orchestration.local.json or .aiqt/orchestration.json) is present at the session cwd's git-resolved toplevel (no ancestor walk, unlike orch-truncation-guard; AIQT_ORCH_REQUIRE_REGISTRY is not read here) AND a declared orchestrator lease or mode record is live, so plain Q&A sessions and bounded workers never inherit the global backlog. FAIL-OPEN on its own I/O errors, none of which wedges the session: an unresolvable cwd, or an inactive or unreadable lease with no mode record read as declared, SILENTLY ALLOWS the stop (no warn banner, no guard-event); a DECLARED lease max_age_hours that is not usable (a boolean, zero, negative, NaN, infinite or a non-number) on an otherwise-live lease is a scope cannot-evaluate that DENIES through the same cannot-evaluate path as an unreadable item proof, never a silent guards-off (the loop bound and the operator escape release, a live mode record outranks it, and a session the lease does not name stays a non-holder; a CHANGE for adopters: the scope check previously read zero, a negative number, a non-number such as a string, false or NaN as no horizon, true as a one-hour horizon and infinity as a horizon never reached, so such a registry now denies the stop until the value is corrected to a positive finite number of hours), while a mode record the mode reader fails closed on counts as declared, so scope is live: one that cannot be opened (a socket included) or read, one that is not a regular file, one over its size bound, one whose bytes are not valid UTF-8, one at a path holding a NUL or not strict UTF-8, and one read as text whose declaration or JSON marker is malformed or unrecognized (an empty or unrecognized Operating-mode: value, or a JSON-shaped marker that does not parse or is not exactly a single string mode key); an unreadable registry ALLOWS with a warn banner but no guard-event; and a turn-state.json that cannot be read or does not hold a JSON object, or a stop_denials value in it that validation rejects (anything but a JSON integer from 0 to 9999, so a float such as 1.0, a boolean or a string), in a state directory the hook can search takes the bounded loop-bound exit, while a state directory the hook cannot search (owner modes 0000, 0200, 0400 and 0600; this and every other owner-mode list in this residue hold for a hook that runs without root as the state directory's owner and holds neither CAP_DAC_OVERRIDE, which searches and writes the directory in every mode, nor CAP_DAC_READ_SEARCH, which searches it and reads turn-state.json in every mode, with the state directory on a filesystem mounted read-write and not marked immutable, since a read-only mount or an immutable directory fails every save) reads as a fresh turn-state, so a count already at the bound is not seen there and the stop is judged afresh (a deny there then allows with findings, because its denial counter cannot be saved). A deny whose denial counter cannot be saved fails open with findings. The save needs a state directory the hook can write and search, and read where the platform lacks O_PATH: opening the directory needs read permission on it only without O_PATH; after that open, the stat of turn-state.json, the creation of the save's temporary file, the replacement and, after a failed step, the removal of that temporary file each look a name up in it and so need search permission (the fchmod, write and fsync on the temporary file's descriptor look no name up), and the creation, the replacement and that removal also write to it (a temporary file left by a hook process killed mid-save stays, since no later save removes it). So of the eight owner modes of the state directory, a stop the decision core denies is blocked only in 0300 and 0700 with O_PATH and only in 0700 without it, and allows with findings in every other mode, a writable turn-state.json in a state directory the hook cannot write included (the earlier in-place overwrite let that deny stand). But (FIX 1) an enumerator error or timeout, and any cannot-evaluate (an unreadable proof source), FAIL CLOSED-CONTINUE: the stop is DENIED (ignorance refuses the wind-down), releasable by the operator-owned escape sentinel and never a clean close, bounded by the guard-owned loop bound past which it becomes a deliberate, recorded forced exit. Each deny appends a guard-events row; a failed append leaves no row and adds a short recording-failure warning to the block reason (exit 2 carries no banner) while the deny stands. The row of an ALLOW released by the operator escape is the only record that the override was used, so a failed append there adds a warning to the banner while the ALLOW stands; the row of a clean ALLOW with no escape is the over-fire metric only and best effort, a failed append there not reported. The deny is BOUNDED by two separate triggers, either of which ALLOWS WITH FINDINGS. The first is the guard-owned counter: stop_denials in turn-state.json, which the guard raises by one with each saved deny and which genuine human input resets, allows at most two saved denies per epoch, and works only where the state directory lets the hook read and save it (above). The second is the platform's own loop signal: the Claude Code hooks reference documents a stop_hook_active field in the Stop input, true when Claude Code is already continuing as a result of a stop hook, and a Stop whose payload carries it as the JSON value true takes the same exit whatever the count (a string value is not the signal); the guard only reads this field from the payload and never saves it, so it relieves whatever the mode of the state directory. The operator escape sentinel yields the clean ALLOW only when it passes enforced acceptance: a regular file (never a symlink), owned by a uid other than the guard's effective uid, and not group- or other-writable; a present sentinel failing any condition is IGNORED with the decision unchanged, recorded to guard-events and escape-spoof.json (a failed write is warned about in the hook's banner, or in the block reason of a denied stop, which has no banner; the warning asks for a manual record), and normally raised once by the next resume audit (again while its rename fails; a later sentinel recorded before that audit overwrites escape-spoof.json, leaving the earlier one only its guard-events row, where that append succeeded). Uid inequality proves not-self, never operator identity; on a shared-uid host no file either party can create passes, so the clean-ALLOW channel is unavailable there (recovery: a differently-owned sentinel such as a root-owned one, operator registry or mode maintenance, or the bounded marked exit; split-uid deployment is the recommended posture). The platform interrupt is a second, structurally unguardable escape, left uncovered on purpose. Blocker proofs are validated against their declared sources (the dispatch ledger, the pending-decisions surface, evidence freshness, the clock), but that whole substantiation surface (the backlog state/granted fields, a pending-decision row, the dispatch ledger, a not-before value, and free-text external evidence) is largely ACTOR-AUTHORABLE: the guard raises the bar (it rejects an unknown blocker kind and requires a substantiating record in the declared authoritative source) yet cannot categorically enforce that a block is granted by an authority other than the assistant, closed categorically only by operator-owned, assistant-non-writable records at the OS layer (SYSTEM-HARDENING.md entry 2). Plausibly FABRICATED external evidence in particular is not mechanically closable at this layer where no attestation register is declared; where one is declared, an external or foreign-lease blocker classifies blocked only when its ref is covered by a fresh validated attestation whose snapshot is re-anchored to the register authority and whose refs are re-derived from the register content at yield, so a swapped register or a fabricated-refs snapshot substantiates nothing; this raises the bar over free-text evidence but is not categorically forgery-proof, since the register is a separately-declared surface and filesystem write to it, wherever it is declared, forges an attestation by appending a valid approved row (uncommitted; the git or anchor authority checks only the committed or anchored prefix), closed categorically only by an operator-owned, assistant-non-writable register at the OS layer; the remaining residuals are cadence, since tamper misleads until the next audit or gate run, and relevance, since a resolving pointer can still be a misappropriated pretext that triage refutes). The human-decision class proves only that a pending-decision row exists, never that the item genuinely crosses the human-oversight threshold, which stays human judgement. Coverage is the rendered roster (Stop, TeammateIdle, the yield-tool matcher): a yield primitive outside that roster is uncovered, reconciled when the toolchain register changes. The dispatch ledger sees only hooked launch tools, so a task launched through an unhooked tool costs one spurious deny and a dead task read as live permits one idle until staleness catches it, both directions bounded and logged. An open or proposed id that vanishes from the enumeration without a closed receipt, OR (FIX 2) a previously-actionable id DEMOTED to proposed/ungranted without a closed receipt (the checkpoint records each id's eligibility for this), is injected as a cannot-evaluate by the anti-shrinkage checkpoint (backlog-checkpoint.json: the first window has no union to compare, and a drop forced by the checkpoint's id bound is logged to guard-events, a failed append warned about in the hook's output, so a drop is never silent; a checkpoint or init marker that cannot be written is named in the hook's output whether or not its guard-events row is written, and an unwritten checkpoint does not record that window's items, so one that then vanishes or is demoted before a later checkpoint write succeeds is not held as such unless an earlier checkpoint records it); that eligibility memory is itself in the actor-writable state dir, the same categorical residual. A bound-released ALLOW WITH FINDINGS past ANY non-closed disposition (actionable, cannot-evaluate, waiting, blocked, or proposed) is marked forced_unresolved (a guard-events row plus an append-only row in forced-exit.jsonl, each normally raised once, at least once if recording it in forced-exit-surfaced.json fails, a failed append of either surfacing in the warn banner itself) and is triaged by the next resume audit; no register row, attestation, or escape-adjacent artefact suppresses that record.
hook:orch-teammate-idle-guard
  • Platform: claude-code
  • Default: block
  • Entry point: TeammateIdle
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

The TeammateIdle binding of the same decision core as orch-stop-guard, sharing its registry/lease scoping, escape, counter bound, and guard-events logging. A deny (exit 2) prevents the teammate from going idle past enumerated actionable work. On cannot-evaluate (an enumerator error or an unreadable proof source) it takes the STOP branch, which (FIX 1) now DENIES the idle (ignorance refuses the wind-down; a denied TeammateIdle cannot wedge the session, and the operator escape releases a genuine block); an unreadable registry or unresolvable cwd still fails OPEN at the binding. Same disclosed bounds as orch-stop-guard on ledger blind spots, fabricated blocker evidence and its attestation narrowing, escape-sentinel acceptance, the anti-shrinkage checkpoint, and the forced_unresolved marking of bound-released exits. Fires only where the platform emits TeammateIdle (doc-confirmed 2026-08-29); platforms without the event keep only the Stop and yield-tool bindings.
hook:orch-truncation-guard
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

AIRTIGHT-NARROW (maintainer-directed simplification): this guard's background ALLOW test does NO shell parsing, so no lexical, quoting, or operator edge can make it credit a capture that does not exist (its deny paths and the foreground detach detector below read the command through the shared lexer, only to deny or, for that detector, to lift the scan's deny in three named shapes). Within the registry scope (the REGISTRY-SCOPE RESIDUAL, OUT-OF-SCOPE NOTE and REGISTRY-REQUIRED MODE below change only the outcome when no registry is found), it governs a background dispatch (run_in_background is true) with the capture-proof test below, and adds ONE narrow foreground branch: a foreground call (run_in_background not true) is out of scope EXCEPT that a bare '&' control operator, which detaches a child into asynchronous work this tool call does not track, is a DENY-and-educate steering the operator to the tracked background dispatch (run_in_background) or to running it in the foreground and waiting; BY DEFAULT with no registry found, that foreground branch gives the OUT-OF-SCOPE NOTE below instead of the deny. That foreground detector reads the command through the shared lexer beside a dedicated quote- and escape-tracking scan: a bare '&' separator in any lexer reading of the command's uncertain '#' characters DENIES whatever the scan reads, a command the lexer refuses (an unquoted here-document, a quoted one it cannot place - a '<<', or the newline that would start its body, read while a '${' is open, which bash reads as text inside a '${...}' parameter expansion but as an operator inside a bash 5.3 '${ cmd; }' substitution, or while a '((' that may open an arithmetic command or expansion is open (two adjacent '(', a backslash-newline between them aside, the '$(' of a '$((' included), where bash reads the '<<' as a shift and the newline as text but in nested subshells reads an operator, or while a '$[', the subscript of a word that is so far an identifier, an array assignment's '(' or a pattern's '(' (one right after '?', '*', '+', '@' or '!') is open, where bash reads the '<<' as text or as a syntax error after which it reads the next line, or the newline that would start its body read while a command or backquote substitution opened after it is still open, which bash starts the body past, or after a backquote substitution the '<<' sits in has closed, which leaves it no body, or a body read inside a substitution or group whose end is uncertain - a here-string, a process substitution, an open quote, a NUL, or '#' readings it cannot judge) is decided by the scan alone, and where the lexer reads no bare '&' the scan's verdict stands with ONE narrowing, the only one: the scan's detach is lifted only when both scan rules read the whole command (no quote open at the end and no uncertain '#') and the lexer, in its one reading of the command, places every '&' the scan reads as bare inside one of three shapes, (1) the body of a here-document under a quoted delimiter (<<'EOF' or <<"EOF") whose end the lexer can prove is where bash ends it - one read at the top level of the command, with nothing open on the nesting stack and no '(', backquote or '${' anywhere before the body, or the commit-message form's body in "$(cat <<'EOF' ... EOF)", whose exact close the lexer itself resolves; a body read inside or after any other '(' group, '${' or backquote is NEVER lifted, failing closed, because bash 5.3 can end such a body inside a substitution at a line that starts with the delimiter and holds the closer (')' or '}') anywhere after it, or inside backquotes at a backquote (after x=$(cat <<'EOF', a body line, then the line 'EOF)', bash runs a 'sleep 0 &' written on the next line, a real detach this guard denies, where an exact-delimiter-line body end read that detach as body data and silently allowed it) - (2) a single- or double-quoted string, or (3) a '#' comment. Every other case where the lexer and the scan disagree still DENIES: an '&' the lexer places in a substitution or expansion that a double-quoted string holds (one in a quoted string or comment nested there included, since the lexer skips it whole), in an ANSI-C $'...' quote or behind an escape; a scan that ends inside a quote or meets an uncertain '#' (an apostrophe in a here-document body the scan reads as code included); and a command the lexer reads in more than one way (an uncertain '#', or a commit-message body that a reading of the substitution without the body reads otherwise). The scan distinguishes the detach '&' from the shell forms that also carry an ampersand but do not detach ('&&', '&>', '&>>', '<&', '>&', '|&', and any single-quoted, double-quoted, or backslash-escaped ampersand, and an '&' inside an unquoted word-start '#' comment, which is dropped). Where a '#' opens a comment is read under two rules, the historical one (after any Unicode whitespace character) and bash's (only after a space, tab, newline, or one of ; & | < >), and a detach seen under either rule DENIES, so the bash rule only adds denies: a '#' after a carriage return, vertical tab, form feed, no-break space, 0x1c-0x1f separator, NEL, or other Unicode space is not a comment to bash and the '&' after it is caught. Under both rules the shared lexer's comment rule applies: such a word-start '#' is a comment only while no parenthesis, ${...} parameter expansion or backquote substitution is open, and a '#' whose comment reading depends on context (right after a '(', ')' or backquote, or at a word start while one of those is open) ends the scan as an open quote does, so it DENIES (an over-deny of such a command that detaches nothing). A backslash-newline before a '#' changes nothing (bash removes it first), and a '#' that is not right after a word character inside a "$(...)" or backquote substitution in a double-quoted string, outside a single-quoted span there, is uncertain too. The scan reads a here-document body as literal data, skipping it to past its delimiter line, in ONE case only: the WHOLE command consists of nothing but one or more plain writes cat > FILE <<'DELIM' (or cat >> FILE, or a double-quoted delimiter), each line holding only that cat, that one output redirect to a plain FILE word outside /dev and /proc with no '..' component, and that one quoted here-document operator, each followed by its body and a line that is exactly its delimiter, the writes separated only by newlines, with no other command word anywhere in the command (no hash, alias, function, enable, builtin, command, PATH or other assignment, no '.', source, exec, eval, shell, xargs, no ./path or other path invocation, no pipe, no redirection but the one output file, no '&&', ';' or '||'); the scan's earlier conditions also still hold (a delimiter it parses, opened with nothing open on its nesting stack and no '(', backquote or '$[' before it, and no body before it read as code). In that command cat only copies each body into its file and nothing in the command runs it, so a quote, backquote, '$(' or '&' in such a body changes nothing. In EVERY other command every here-document body is read as code exactly as before this skip existed, whatever runs it: a write followed by a command that runs the written file (. ./s.sh, bash s.sh), a write after a line that can make cat run something else (hash -p /usr/bin/bash cat, a function or alias named cat), a write followed or preceded by any other command, a write in another form (the here-document before the redirect, '>|', '<<-', a second redirect or here-document on its line), cat to standard output, tee, a body fed to or piped into a shell, an unquoted delimiter's body, and the commit-message form's. A FILE that reaches the shell's own output only through a changed working directory or a symlink, a FILE another process reads and runs, a function, alias or builtin named cat that the shell already holds before the command starts (an inherited or exported definition), and an inherited PATH or other environment the command starts with that makes the word cat run a shell or another program are not seen: the guard trusts the word cat as written and does not resolve it, since it cannot know the PATH of the shell that will run the command, and such a cat can run a body the scan skipped as data, so a detach in it is silently allowed. It looks ONLY for a bare '&' operator at the outer quoting level AS ITS OWN QUOTE TRACKING READS IT; where that reading diverges from bash's, a bare outer '&' is missed (the false-allow residual below, whose list of known cases is not complete). A scan that ENDS still inside an unbalanced quote (including one left open by an ANSI-C dollar-single-quote construct ($'...') this scan does not model, or by an apostrophe in a here-document body the scan reads as code) could hide a real trailing '&', so it FAILS TOWARD THE DENY, with a reason naming the open quote rather than a bare '&'; this covers only a quote still open at the end, not one misread in mid-string. KNOWN FALSE-ALLOW RESIDUAL OF THE SCAN (confirmed against real bash; the scanner docstring lists the same cases): the scan reads only the outer quoting level, and its quote and comment tracking can diverge from bash's in further ways than those listed here, so this list is NOT complete. The lexer reading denies a bare '&' the lexer reads as a separator, which catches the (5) and (6) examples below and the ANSI-C example echo $'\'' & echo x\' of (3), so those remain silent allows only where the lexer refuses the command; the lexer misses the (1) and (2) examples as the scan does, and a (4) whose substitution sits in double quotes, as in echo $(( a["$(job &)"] )). Known cases include a real detach that is NOT seen and is SILENTLY ALLOWED (1) when its '&' sits inside a command substitution or backtick wrapped in double quotes (echo "$(job &)"); (2) inside a string that eval or quote removal re-reads as code (e'v'al 'job &', {eval,} 'job &', \eval 'job &'); (3) after an ANSI-C $'...' quote with an escaped quote that leaves the scan balanced but misaligned; (4) inside an arithmetic subscript that runs a substitution; (5) QUOTE SHIFT: after a quote character that bash reads as data but the scan reads as a quote, above all an apostrophe or double quote in a here-document body the scan reads as code (an unquoted delimiter's, a quoted one's it does not skip, the double-quoted commit-message form's), which flips the scan's quote state so that a LATER real bare '&' (between two here-documents, or before a second stray quote that rebalances the scan) reads as quoted text and is allowed; and (6) COMMENT SHIFT: after a '#' that bash reads as quoted text but the scan reads as a comment, where a double-quoted string nested inside a double-quoted "$(...)" ends the scan's double quote early, so a '#' inside the nested string reads as a word-start comment and a real bare '&' later on that line is skipped (echo "$(echo "a" "b # ")" & job # "). LEXER-PATH FALSE-ALLOW RESIDUAL (disclosed): the shared lexer's quoting model is a best-effort reading of bash, not a guarantee, so where it places an '&' inside one of the three narrowing shapes (a quoted here-document body, a quoted string, a comment) but bash reads that '&' as an operator, the scan's detach is lifted and the call is SILENTLY ALLOWED; the agreement of the lexer with the scan was checked on the self-test vectors only, which is not exhaustive. The shared lexer reads a here-document delimiter word as bash does, checked against real bash on the self-test's delimiter shapes and on its generated differential-oracle corpus of 506 here-document shapes, each run through real bash recording only whether $! is set, where a real detach the registry scope allows, or a deny of a shape the narrowing lifts that bash does not background, fails the self-test: a backslash-newline in the word, between '<<' and the word, or between '<<' and the '-' of the <<- form is a line continuation bash removes, never quoting (inside single quotes it stays literal), so the joined word is the delimiter, quoted only by a quoting character left after that removal, and the body ends at that delimiter, with what follows read as commands again; a word holding a '$(', '${' or '$[' span or a backquote, one glued to a '(' or to a process substitution, and a '#' where the word would start are refused, so the scan decides. Reading such a backslash-newline as an escaped newline made a delimiter no line matches, so the narrowing lifted a real detach after the body. HONEST RESIDUAL (disclose-guard-residuals): inside registry scope, detachment achieved WITHOUT a bare '&' in the outer command is NOT caught and is SILENTLY ALLOWED, not over-denied (outside registry scope some of it draws the OUT-OF-SCOPE NOTE below) - a runtime detacher that backgrounds with no '&' (setsid, disown, coproc; nohup is normally written with a trailing '&', which IS caught), a nested shell string whose '&' sits inside quotes (bash -c '... &'), a here-document body a shell runs under a quoted delimiter (bash <<'EOF' then job & then EOF, or cat <<'EOF' | bash, or a body written with cat > s.sh and then run in the same command by . ./s.sh or bash s.sh): the detach scan judges only the top-level shell's '&' and reads such a body as code, so a stray quote in it still denies, but where the scan reads the command to its end the narrowing (1) above lifts the body's '&', as it did before the scan skipped any body, and an alias or function that renames a detacher (or a function, alias or builtin named cat that the shell holds before the command starts, or an inherited PATH or other environment that resolves the word cat to a shell or another program, whose bodies in a command of nothing but cat writes the scan skips as data); the grammar cases that OVER-fire instead are NOT a single case: any unquoted '&' this scan does not model as a detach still reaches the scan and reads as an operator, so it DENIES when no detach is present. Examples are a here-document body carrying a literal '&' under an unquoted delimiter (or under a quoted one the scan reads as code, where it cannot read the rest of the command to its end), and an arithmetic expansion such as $(( a & b )) where '&' is bitwise-AND; any other construct this scan does not model that leaves a non-detach '&' unquoted in the outer string over-DENIES the same way. OVER-REFUSAL OF SAFE HERE-DOCUMENTS (disclosed): the scan reads as code every here-document body it does not skip as literal data (above), a quoted delimiter's included in every command that is not made of nothing but plain cat writes to files (the commit-message form, cat to standard output or tee, a cat write followed or preceded by any other command, a write in another form, one in a subshell or after any '(' or backquote, one with no delimiter line); the lexer reading lifts that for an '&' only, so a body '&' there (say "a & b" in the double-quoted git commit-message form) is allowed where the scan reads the rest of the command to its end, while such a body read at the outer quoting level with an unbalanced apostrophe (the user's file, under cat to standard output, in a cat write followed by another command, or with no delimiter line), a body quote that leaves the scan open at the end, and a body '&' under an unquoted delimiter (which the lexer refuses) are still DENIED although nothing detaches. The commit-message form's body is NOT denied for an unbalanced apostrophe: the apostrophe sits inside the outer double quotes, where the scan reads it as a literal character, so that body is allowed. The remedy is to quote the delimiter and write the body with cat > file in a call of its own that holds nothing but such writes, then run any other command in a separate call, or to write the text to a file and pass the file. This misreading is NOT only in the safe direction: the same body quotes cause the quote-shift false-allow (5) above, which the lexer reading catches wherever the lexer reads the command. The guard locates its registry scope by the UNION of two legs: a walk of the session cwd's physical ancestor chain with no-follow, descriptor-anchored lookups, and, where git DOES resolve a toplevel for the cwd (the scrubbed rev-parse primitive the sibling orchestration guards root via), that toplevel's registry too, because core.worktree (set in a repository config or a gitfile's gitdir target) can point the work tree OFF the cwd's ancestor chain, where the walk alone never visits its registry (a review finding: from inside such a repository's metadata directory the walk alone turned the old rev-parse scoping's deny into an allow); BY DEFAULT git success can only ADD a deny, a git failure alone never denies, and the walk alone still decides where git cannot run or answer, while in the opt-in registry-required mode below a git failure where the registry is reachable only through the git toplevel reads as ABSENT and is denied, and git success can then remove that deny; the AIRTIGHT-NARROW claim is about the background ALLOW test doing NO shell parsing. Overriding that DENY does not itself create tracking; only converting to run_in_background=true lets the dispatch ledger record the launch. The foreground DENY stays registry-scoped, so BY DEFAULT a non-orchestrated session acquires no bare-'&' deny and no prompt and only the pre-scope denies below (a malformed or control-character tool_name, a malformed cwd, or a registry walk that cannot be carried out) apply in every session; in the opt-in registry-required mode below a session with no registry found is instead denied on every Bash call that passes those checks. OUT-OF-SCOPE NOTE: by default, where no registry is found, a readable foreground command (run_in_background false or omitted, and a string command) that may detach work is ALLOWED WITH ONE NOTE (a systemMessage with no permissionDecision) naming rule trkasy and the tracked background mechanism: use run_in_background and collect its completion, or run it in the foreground and wait, or record a deliberate detach. The note comes from the first of two readers that flags the command. The first is the foreground detector the registry scope denies on: a command it reads as a bare-'&' detach, or whose quoting it cannot read to its end (an open quote or an uncertain '#', where a later '&' can be a hidden detach), gets its note, the second saying the command could not be read to its end (a later release turns these notes into the deny an orchestrated session gives); that detector reads a here-document body under an unquoted delimiter and arithmetic as code, so a body '&' there and a bitwise '&' in $(( )) or (( )) get its note too, accepted over-notes for this warn-first release, as does a safe command it cannot read to its end, such as a quoted here-document whose body it reads as code and holds an unbalanced apostrophe (the user's file, in a cat write followed by another command or with no delimiter line), which the registry scope over-denies (OVER-REFUSAL OF SAFE HERE-DOCUMENTS above); it also notes a real detach the note scan misses where it reads that '&' as a separator. Otherwise the second reader, the note scan below, decides. A command whose every bare '&' the first reader lifts (the three-shape narrowing above) is no detach, as in registry scope, and the lift suppresses ONLY the notes its own vouched shapes cause: the note scan runs again in a lifted mode that reads the two vouched here-document shapes as data (a quoted-delimiter body opened with nothing open on the scan's stack or substitution count, skipped at its exact delimiter line or, where that line never comes, to the end of the command, as bash reads a literal body, and the commit-message form resolved to its exact close), so a setsid, nohup or disown in command position anywhere in the command (one past a quoted here-document body included), a backslash-newline line continuation, an unclosed expansion, and every other cause outside those shapes keeps its note, the single note being the true union of both readers, while a quoted here-document whose body holds no '&' still draws the note scan's unreadable note (an over-note). It notes a bare '&' read as the in-scope scan reads quotes, escapes and '#' comments, by the same shared comment rule, so a '#' whose reading depends on context (one right after a '(', a ')' or a backquote, as in x=$(true)# & job, one at a word start inside an open parenthesis, ${...} or backquote substitution, as in echo ${x:- #} & job, or one not right after a word character inside a double-quoted "$(...)" or backquote substitution) draws the note itself, never a deny, where the in-scope scan denies it; a backslash-newline line continuation anywhere outside a single-quoted span draws the note itself, as unreadable, wherever the scan meets it (in live text, in a double-quoted string, on a certain comment's line, in a skipped arithmetic span or in a skipped here-document body): bash removes the pair before it tokenizes, so ANY token can be split across one (a '<' or '>' split from its '(' still opens a process substitution bash counts and this raw scan would not, a split detacher or 'case' keyword is still that word to bash), and the class is closed as a whole rather than shape by shape, as far as the scan's quote reading matches bash's (a continuation in text the scan misreads as quoted, as after the ANSI-C false-allow above, is missed with the rest of that divergence), while a pair inside a single-quoted span (one inside a double-quoted command substitution included, where a backslash is literal data too) is literal data to bash and stays silent, as does a backslash before any character but a newline; with two differences: the body of every here-document opened at the outer quoting level, while no parenthesis, '${...}' or backquote substitution is open, under a PROVABLY SIMPLE delimiter spec (an allow-list: a whole word of letters, digits and '_ . + -' that bash ends where the scan does, at a blank, a newline, the end of the command, one of '; & | )', or a '<' or '>' that opens no process substitution, over a body with no backslash-newline up to its closing line) is skipped as data at its exact delimiter line; and every arithmetic $(( )) or (( )) is skipped, so an '&' or an apostrophe there neither notes nor shifts the quote state; a span the scan would skip but cannot read with certainty draws the note itself instead of a silent skip or a guessed delimiter (never a deny: no command content raises out of the handler): every here-document delimiter that fails that allow-list (a quoted, ANSI-C or escaped fragment, a delimiter continued with a backslash-newline, which bash joins while this raw scan does not, a '$', backquote or brace in the word, since bash keeps a '$(...)', '$((...))', '${...}' or backquoted span in one delimiter word with any blank or metacharacter inside it, as in <<$(true) or <<${x:-a b}, a '(' or a '<(' or '>(' glued to the word, which bash reads as part of it, as in an extglob <<a+(b c), a quote left open, or no delimiter word at all), a here-document opened while a parenthesis, '${...}' or backquote substitution is open (bash 5.3 ends such a body inside a command or process substitution or backquotes at a line that merely starts with the delimiter when a ')' or a backquote follows it on that line, as in x=$(cat <<EOF ... EOF), and reads the rest of that line as commands, so where such a body ends is unknown; whether such a substitution is open is read from the scan's own count of unquoted '$(', '<(' and '>(' openers as well as from its nesting stack, because a bare case pattern's ')' pops the stack's parenthesis, as in x=$(case y in y) :;; esac; cat <<EOF; the count falls only at a ')' that same stack matches against a '(' on top, so a ')' that is text inside a '${...}' expansion or a backquote span, as in x=$(echo ${y%)}; ...), closes nothing and the substitution stays counted open to the end of the command, and once a 'case' keyword has been read in command position no ')' is counted as closing a substitution, so a here-document after a closed one in such a command draws the note too, an over-note; a well-formed one, its delimiter on its own line before the ')', draws the same note, an over-note), a body holding a backslash-newline anywhere up to its closing delimiter line (bash joins a body line ending in a backslash with the next before it matches the closing line, so the raw line match can end the body early or late), a '${...}' redirect target left unclosed, not opened by a name (a bash 5.3 '${ cmd; }' or '${| cmd; }' command substitution, which runs its command even in a redirect target, included) or holding any quote, backslash, '$', backquote, '<' or '>' (a '$(...)' or backtick command substitution and a '<(...)' or '>(...)' process substitution run even in a redirect target, quoting, nested or not, can hide one from a lexical skip, and an escaped '}' does not close the expansion to bash), and any further '${' anywhere in a redirect target operand, double-quoted or not; nothing shows that these excluded forms are every way bash can read further than the scan or run a command there, which is why each rule admits a fixed set rather than refusing a list; a '<<' met inside a double-quoted command or backquote substitution (the commit-message form) draws the note itself at the operator, whatever follows it, as a BEST-EFFORT rule (a '<<<' here-string there included, an over-note; the scan tracks quotes and parentheses inside such a substitution only approximately, so a shape whose quote or parenthesis state it misreads can hide the operator, the known misses listed below): bash reads such a here-document body under its own rules, while this scan read it as double-quoted text whose quote state each body quote toggles, so a stray quote in the body once shifted that state onto a later REAL bare '&' outside the substitution, which read as quoted text and was silently allowed (confirmed against bash; closing that miss is why the operator itself notes); a scan that ends with an opened construct still open draws the note too (an unterminated here-document body, a here-document opener with no body line, an unclosed parenthesis, '${...}' or backquote substitution, or a trailing backslash that escapes nothing): bash would still be reading input there, or rejects the command, so nothing the open construct holds was checked (a substitution whose '(' the scan already read as closed at a ')' that closes nothing to bash, such as a bare case pattern's, is not seen by that end check: its here-documents are still noted through the count above, while with no here-document and no detach after it the scan stays silent, a disclosed miss); and it notes a setsid (without -w, --wait, or a unique prefix of --wait such as --w), nohup or disown in command position (at a command start, after an operator or a reserved word, after a wrapper such as env, nice, sudo or timeout and its options, numbers and assignments, after a case pattern's ')', parenthesised or bare (a word directly after a subshell's ')' is a syntax error to bash, so reading it in command position too can only over-note), or inside a function body the command itself defines, f() { ...; } or function f { ...; }; a redirect operator and its target operand, leading or not, leave that position undisturbed, including a here-string '<<<', whose word is never read as a here-document delimiter, the noclobber '>|', read as one operator rather than a redirect then a pipe, a named descriptor written directly before its operator such as {fd}> (but a number or {fd} before '&>' is a word to bash, never an fd prefix, and is read as one), a '${...}' parameter expansion that is the whole redirect target, read as one word when it is provably simple (a name, then only literal characters up to the first '}') so a blank inside a '${x:-a b}' default does not split the operand (a literal '{' in a default does not nest, as in bash, so the expansion ends at its matching '}' and the command after the target is still read), and a redirect between setsid and its wait option, which bash removes from the argument list so the options past it are still read). Inside registry scope the note scan changes nothing: the deny reads arithmetic and a here-document body under an unquoted delimiter as code (the over-refusals above stand), lifts a body '&' only through the three-shape narrowing above, and does not read setsid, nohup or disown. The note's residual: it does not stop the detached work, and whether the model, and not only the operator, sees a PreToolUse systemMessage is unverified. A scan that ends inside an open quote draws the note itself (unreadable), never a silent allow; a command bash reads as balanced can end this scan inside an open quote where its quote reading diverges from bash's, which notes the same way (the over-note list below). It stays SILENT for a detacher it cannot read in command position (a quoted or backslash-escaped command word, a command word after a backtick, an unquoted expansion as the command word ($SUDO setsid -f job, $(true) nohup job: an empty expansion vanishes to bash, which runs the word after it as the command, while the scan reads that word as an argument), a wrapper option that takes a separate value such as sudo -u name, an alias or a function defined outside the command (a detacher inside a function body the command itself defines, f() { ...; } or function f { ...; }, is read in command position), coproc, a nested bash -c string, eval, a program that daemonizes itself, a detacher after a redirect target written as an arithmetic expansion, which drops the pending command position, a detacher word inside a bash 5.3 '${ cmd; }' or '${| cmd; }' command substitution outside a redirect target (echo ${ setsid -f job; }), which is read char by char and not in command position); for an '&' or a detacher word inside a double-quoted command substitution, inside a command substitution in an unquoted here-document body (a bash 5.3 '${ cmd; }' included; the body under a provably simple delimiter is skipped whole as data, so nothing in it is read, while bash expands a command substitution there and runs what it holds), or inside a command substitution in skipped arithmetic; for the lines after a '<<' inside the old-style $[ ] arithmetic, which this scan reads as a here-document opener although bash does not; for a here-document inside a command or process substitution whose 'case' keyword this scan does not read in command position, such as one after coproc, which is not a recognized command prefix (a spelling hidden by a backslash-newline line continuation, split by one or followed by one, draws the continuation note above instead), where a bare pattern's ')' still reads the substitution as closed on the count exactly as it pops the stack, so the body can be skipped at a wrong line; and for the false-allow cases listed above, whose here-document part of the quote shift in case (5) is narrowed, not closed: the here-document rule inside a double-quoted command or backquote substitution is BEST EFFORT, because this scan tracks quotes and parentheses inside such a substitution only approximately (it reads every unescaped double quote there as the end of the outer string, and a ')' there as closing the innermost open parenthesis, where bash may read the character otherwise), so any shape whose quote or parenthesis state it misreads can leave a real background operator unnoted: such a shape can hide a later '<<' in the substitution from the rule, its body is then read as double-quoted text again, and a stray quote in it can carry a real bare '&' after the substitution into quoted text, or it can shift the quote state onto that '&' directly; known examples, not a complete list, are a nested double-quoted string (: "$(cat "-" <<'EOF' ..., or "$(x="a"; cat <<'EOF' ...), a single-quoted span holding a double quote (echo "$(echo 'x"y')" & job # '), a case pattern's close paren (: "$(case a in a) cat <<'EOF' ...) and a close paren inside '${...}' (: "$(echo ${y%)}; cat <<'EOF' ...); in each the '&' stays SILENT while bash backgrounds the job (confirmed against bash 5.3.9) (the in-scope deny's residual stands unchanged). It OVER-NOTES a nohup run that does not itself return early, a disown with no job to drop, a deliberate and recorded detach it cannot tell from a careless one, a setsid that in fact waits because its wait option sits behind a redirect target that is not a plain word (an expansion or a quoted word), where the scan reads the redirect as the end of the options and still notes the setsid, any command holding a skipped span that is not provably simple (the cannot-read cases above) even where nothing detaches (for example a quoted here-document delimiter such as <<'EOF' over a body of pure data, a plain delimiter holding a character outside the allow-list such as <<END/1, a body holding a backslash-newline, a '${' after the start of a redirect target such as > a${x}, a redirect-target '${...}' default holding a command substitution that does not detach, a quoted or nested default that hides nothing - a single-quoted '$(' inside it is literal to bash, but the quote already makes the span one this scan declines to model - or a here-document the shell would reject as a syntax error), any command holding a '#' whose comment reading depends on context even where nothing detaches (for example (true)# done), a detacher word written directly after a subshell's ')' (a syntax error to bash) or after an empty '$()' (an argument word to bash), each read in command position, a detacher word after a ')' that is text inside a '${...}' expansion (x=$(echo ${y%)setsid -f job}): the text up to the expansion's real '}' is read in command position, while bash reads it as pattern text), a function definition whose name is a detacher (function setsid { :; }), whose name is read in command position though the command only defines it, a command bash reads as balanced that ends the scan inside an open quote (an ANSI-C $'...' word whose escaped quote the scan reads as a close, echo $'don\'t stop'), a here-document after any closed command or process substitution in a command that also read a 'case' keyword in command position (case a in a) :;; esac; x=$(true); cat <<EOF ...), and a here-document opened inside a double-quoted command substitution (the git commit-message form, git commit -m "$(cat <<'EOF' ... EOF)"), noted at its '<<' whatever the body holds wherever the scan reads that '<<' as inside the substitution (a best-effort reading, the known misses above), where a '#' not right after a word character in such a substitution (Fixes #12) reads as uncertain and notes too; an arithmetic expression holding a quoted parenthesis can misalign the arithmetic match either way. Its scan adds two linear passes per out-of-scope Bash call, one per comment word-start rule, each reading the command in place (a glued substitution's ')' once tested the enclosed text for emptiness by slicing it, so nested glued substitutions, x=$(x=$(...)), copied characters growing with the square of the nesting depth; that test now walks a running index and reads each enclosed position at most once) (see DEADLINE). REGISTRY-SCOPE RESIDUAL (disclose-guard-residuals, decided against check-fails-closed-on-unreadable): the orchestration registry is the whole suite's scope declaration, not an input this guard is declared to cover, and the hook is wired by installing the pack in every session, orchestrated or not; so with NO registry entry on the cwd's physical ancestor chain (.aiqt/orchestration.local.json and .aiqt/orchestration.json a clean not-found at every chain directory, judged by a no-follow, descriptor-anchored walk of that chain and its path-anchored recheck) AND none at a git-resolved toplevel (the union leg above) the guard, BY DEFAULT, denies nothing and ALLOWS every Bash call that passes the pre-scope checks below (a readable tool_name and a walkable cwd; in the opt-in registry-required mode below it DENIES them instead), silently except for the OUT-OF-SCOPE NOTE above on a foreground detach: a real bare-'&' detach is allowed with that note, and a truncating-sink background dispatch is allowed silently, while every call whose tool_input is missing, null or not an object, whose command is missing or not a string, or whose run_in_background is present and not a real boolean (a malformed flag such as the string 'true') is allowed WITH A NOTE naming the field the guard could not read: no note scan runs on those shapes, so a detach they carry is still not named, but the allow is no longer silent, and a readable background dispatch (run_in_background exactly the boolean true) stays the silent allow; the same holds in a session whose GIT DISCOVERY FAILS (no git binary on PATH, a dubious-ownership refusal, a broken global config, a bare repository, a cwd inside a .git directory, a discovery timeout or error): BY DEFAULT a git failure alone is out of this guard's scope, never a deny (the earlier lockout, where any such failure denied every Bash call and the denied session could not even run the git or install command that repairs it, is withdrawn); in registry-required mode a git failure where the registry is reachable only through the git toplevel reads as ABSENT and is denied. Failing closed there BY DEFAULT would deny Bash in every non-orchestrated repository. REGISTRY-REQUIRED MODE (opt-in; default unchanged): an adopter who wants exactly that fail-closed posture sets the environment variable AIQT_ORCH_REQUIRE_REGISTRY (any value except an explicit off: '', '0', 'false', 'no', 'off', ASCII case-insensitive and matched exactly with nothing stripped, so an off word padded with a space, tab, newline, or no-break space reads as ON; a garbled value reads as ON, the deny-safe direction for an explicitly configured strict mode), and an ABSENT registry (no chain entry and no git-toplevel entry) then fails closed on every Bash call that passes the pre-scope checks, denying it with a reason naming the mode and its repair (commit the registry, run from inside the orchestrated tree, or unset the variable); a registry discovery that cannot be evaluated (the three-valued discovery below) is denied the same way, with a reason naming the unconfirmable entry and its repair. It is an environment variable rather than a pack config key because every pack config surface is a per-repo file located by the same cwd-anchored lookup whose empty result this mode exists to fail closed on, so a file-based key cannot speak exactly when it is needed; the hook's execution environment is the one channel independent of that lookup. A tree that wants this control commits the registry; the walk finds it from any cwd under the tree, with or without a working git, so the same dubious-ownership, missing-git, or broken-config session INSIDE an orchestrated tree still applies the guard. The walk does not stop at the repository root or a filesystem boundary (git discovery stops at a mount point), so a registry in ANY ancestor directory - above a nested repository, above a mount point, or in a non-git tree - scopes the guard in; it is the UNION with the git-toplevel leg, not the walk alone, that makes this scoping a deny-safe widening over the old rev-parse scoping (the walk alone removed the old scoping's deny where core.worktree points the work tree off the cwd's ancestor chain; with the union, git success can only add coverage, and BY DEFAULT a git failure alone never removes a deny; in registry-required mode added coverage can remove the absent-registry deny, and a git failure that hides a registry reachable only through the git toplevel adds one). CONCURRENT-MOVE DETECTION AND ITS BOUND: descriptor anchoring preserves each opened directory's identity, not its parent relationship, so a mid-walk rename of an ancestor could redirect the walk past a continuously present registry; a walk that finds nothing therefore re-resolves the chain BY PATH and re-probes every chain directory, scoping the session IN when that recheck finds a registry and DENYING (never allowing) when the re-resolved dev/ino chain does not match the chain the walk visited or cannot be re-resolved. The race limit is disclosed, not closed: the walk, the recheck, and the Bash call they gate are not one atomic step, and neither the walk nor the recheck is one atomic read of the chain (each is a sequence of lookups), so two windows are out of view. (1) DURING the walk and the recheck: a concurrent rename of an ancestor directory timed against the walk AND the recheck can hide a registry and the call is then allowed (denied under registry-required mode), for example a registry relocated within the chain so it is never where either pass probes, or a sibling directory swapped in under a textual chain path during the recheck so that it reports the same dev/ino the redirected walk recorded and the two chains agree. (2) AFTER the recheck returns: any change to the chain or a registry - including a registry that appears only then - is out of view, as it is for any pre-execution check. Both windows are outside this guard's threat model: it exists to stop ACCIDENTAL truncation in an orchestrated tree, and a party able to rename this host's ancestor directories concurrently with the hook is not that case; a mid-walk rename that is not also timed against the recheck is caught (a found or a deny), and no further race machinery is added. A directory bind-mounted onto its own child repeats one dev/ino between parent and child without being the filesystem root; the walk steps THROUGH such a repeat rather than misreading it as the root (only the filesystem root's own identity ends the walk, and a walk the root identity cannot bound fails closed at its depth bound), so a registry above the mount point is still reached - verified by simulation, since these hosts cannot create mounts, with the path-anchored recheck as the behavioural backstop (an fd-walk miss at a mount edge surfaces as a found or a deny on the recheck, never an allow). Registry discovery is THREE-VALUED: a chain or toplevel entry is cleanly ABSENT, a CONFIRMED registry (the first registry name present in a real '.aiqt' directory, the local name first, is a regular file under a no-follow stat, whatever its content), or CANNOT-EVALUATE (a '.aiqt' chain entry that is a symlink, a regular file, or a directory this process lacks search (execute) permission on, such as mode 0600 or 0000 for a process those modes bind (mode 0100 evaluates normally for its owner where O_PATH exists and for a process the mode bits do not bind, such as root or one holding CAP_DAC_READ_SEARCH or CAP_DAC_OVERRIDE; it cannot be evaluated by any other process that is not its owner, nor by its owner where the O_RDONLY fallback, which also needs read permission, is in use), a first present registry name that is not a regular file or whose no-follow stat faults, or a git-resolved toplevel that exists but cannot be opened; one that does not exist reads as absent). BY DEFAULT a cannot-evaluate entry reads as PRESENT and keeps the guard ACTIVE, never in the denies-nothing no-registry posture, exactly as a PRESENT registry that cannot be read or validated (a read or parse error, or a non-version-1 object) keeps it ACTIVE; in the opt-in registry-required mode a cannot-evaluate entry is NOT a registry, so a Bash call that passes the pre-scope checks is DENIED there (the walk stops at the nearest non-absent chain entry, so a cannot-evaluate entry there denies in that mode even where a registry sits further up the chain or at the git toplevel; with nothing on the chain, a cannot-evaluate git toplevel denies), so a discovery fault never satisfies that mode, while a confirmed registry satisfies it whether or not it can be read or validated. MALFORMED OR UNWALKABLE INPUT FAILS CLOSED (check-fails-closed-on-unreadable): a tool_name that is missing, null, empty, not a string, or carrying a NUL or any other control character (a control character never appears in a real tool name, so such a name is never read as an ordinary non-Bash tool); a cwd that is missing, null, empty, or not a string; and a string cwd whose registry walk cannot be carried out (it contains a NUL, is not an existing directory this process can read and enter, or has an ancestor directory the walk cannot open or examine) are DENIED with a reason naming the defect and an action that repairs it, before the registry scope is read, so in every session, orchestrated or not, while a plain non-Bash string tool_name is out of scope; within scope, a tool_input that is missing, null, or not a JSON object, a run_in_background that is present but not a real boolean (the string "true" is malformed, never read as foreground), and a foreground command that is not a string are each DENIED with a reason naming the defect; an omitted run_in_background is a foreground call. A background dispatch is ALLOWED only when the whole command is drawn from a conservative safe character set (letters, digits, space and tab, and the punctuation '_ - . / = : @ , + %'), so it carries NO shell metacharacter (no pipe, redirect, separator, quote, expansion, command or process substitution, grouping, glob, comment, tilde, history, or newline) and is not a shell reserved word (coproc, which backgrounds a coprocess whose output the harness does not capture, or a compound/prefix keyword such as if/for/while/time) and its full stdout reaches the harness capture that run_in_background binds to the TaskOutput completion signal IN THE COMMON CASE - unless the invoked program or wrapper itself detaches, daemonizes, or redirects that output, which is out of view (see the program-behaviour residual below). ANY other command is ALLOWED WITH AN INFORMATIONAL NOTE: the guard does not parse shell syntax to prove or disprove capture, and an unprovable background dispatch is not a hazard (a dispatch that redirects or pipes its own output is a normal, legitimate form), so it allows and notes that the operator should make sure the full output lands in a durable capture (redirect the producer's own stdout to a real file, or make a real-file tee the final stage). DISCLOSED COVERAGE BOUND (deliberate, not a defect): this trades COVERAGE for an assurance of NO SHELL-SYNTAX FALSE-ALLOW (no quoting or parsing edge in the dispatch string can fabricate a capture). A legitimate captured background dispatch that uses any shell syntax (a real-file redirect, a tee, a glob, an env-assignment prefix carrying a quote) is ALLOWED WITH A NOTE rather than proven; the guard DENIES a background dispatch on capture grounds ONLY when it pipes a producer into a TRUNCATING SINK (head/tail), which discards the producer's full output and exit status (a review finding), or carries a '#' whose comment reading depends on context that the shared lexer cannot judge under both readings (any other such '#' is judged under both, so a sink either reading reaches denies), past which it reads nothing, so such a sink cannot be ruled out; a pipe into a sink inside a backquote substitution (x=`make | head`), which the shared lexer splits at the pipe into words that keep the backquotes, is read too, in a reading the lexer adds that reads each backquote body as a command line of its own, so it DENIES as the bare pipe does; a background dispatch whose backquote bodies pass that reading's budget (524288 characters of body text, each body counted with 64 more, about 7,500 short distinct bodies) DENIES too, with a reason that names the budget; the sink is resolved THROUGH leading shell command-modifier wrappers (command/env/builtin/exec/nice/nohup/stdbuf/time and their own option/assignment args) so a sink hidden behind one (| command head, | env head) is still caught (a review finding), while a wrapper resolving to a non-truncating command still allows-with-note; and otherwise the informational note and the vrfdlv delivery-marker discipline are the compensating layers. What this buys over a lexical capture-proof: the ALLOW rests on a raw-character test that no quoting or parsing can fool, so there is no shell-syntax edge that can silently open a false allow. The residual is RUNTIME behaviour beyond the dispatch string: a plain command can still fail to deliver its full output to durable capture for reasons no pre-execution command-string check can show. Two classes. (a) The invoked program or wrapper's own behaviour - a terminal multiplexer or setsid that detaches, a start-stop-daemon or nohup-style wrapper that backgrounds or redirects the producer's stdout (e.g. --output /dev/null), a program that internally pipes to head/tail, or one that sources a script or resolves a PATH name to a truncating tool. (b) Platform and resource limits - the harness's own TaskOutput capture has a size ceiling (Claude Code terminates a background task whose output exceeds a documented limit), so a very large stream is lost at the harness regardless of the command, and disk-full, a kill signal, or other run-time conditions can likewise cut delivery short. None of this appears in the command string. The guard's ALLOW therefore asserts only that no shell SYNTAX in the dispatch string can defeat capture; whether the output actually and fully reaches durable capture is a run-time property that the post-execution vrfdlv delivery-marker discipline, not this guard, confirms. The guard DENIES exactly these. In every session (pre-scope): a tool_name that is missing, null, empty, not a string, or carrying a NUL or another control character; and a cwd that is missing, null, empty, or not a string, contains a NUL, is not an existing directory this process can read and enter, has an ancestor directory the registry walk cannot open or examine, or has an ancestor chain that changed while the walk read it. Only within the registry scope: a tool_input that is not a JSON object; a run_in_background that is present but not a boolean; a foreground command that is not a string; a FOREGROUND command whose scan ends inside an open quote or meets a '#' whose comment reading depends on context, denied with a reason naming both (unless the shared lexer reads a bare '&' separator, which denies as a detach); a FOREGROUND bare-'&' detach read by the lexer or by the scan and not lifted by the three-shape narrowing above (untracked asynchronous work, steered to a tracked run_in_background dispatch or to a foreground wait), which with the open-quote deny also covers the over-refusals above; a BACKGROUND dispatch whose command is not a string or is empty; a BACKGROUND dispatch that pipes a producer into a TRUNCATING SINK (head/tail), whose completion signal would bind to the sink's truncated output so a failing producer would read as a clean, finished run (a review finding); and a BACKGROUND dispatch carrying a '#' whose comment reading depends on context that the shared lexer cannot judge under both readings, past which such a sink cannot be ruled out. Each malformed-input deny carries a reason naming the defect. It WARNS (allows with a note, never an ask) exactly this: BY DEFAULT with no orchestration registry found, a readable foreground command one of the two readers of the OUT-OF-SCOPE NOTE flags, and a call whose tool_input is missing, null or not an object, whose command is missing or not a string, or whose run_in_background is present and not a real boolean. Under opt-in registry-required mode only (AIQT_ORCH_REQUIRE_REGISTRY set): every Bash call that passes the pre-scope checks when no orchestration registry is found (including a registry reachable only through the git toplevel when git fails, which reads as not found), and every such call whose registry discovery cannot be evaluated (a '.aiqt' that is a regular file, a symlink, or a directory without search (execute) permission, a first present registry name that is not a regular file or cannot be stat'ed, or a git toplevel that exists but cannot be opened), denied with a reason naming the mode and its repair. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 1.72 s of CPU in-process on a 24-core host with Python 3.14.4 (the maximum of three runs per shape on 2026-10-25, on seps_hash_lines at 1 MB, after the foreground branch began reading the shared lexer beside its scan; the shapes whose comment reading depends on context included; the narrowing and no-registry warn paths measured at most 0.52 s on 1 MB shapes built for them). The OUT-OF-SCOPE NOTE's scan, timed from a no-registry cwd on the foreground shapes the hooks self-test adds for it (up to 1 MB), took at most about 0.52 s of wall-clock time for the whole call in-process, growing linearly with size, on a 24-core host with Python 3.14.4 (2026-10-20); the nested glued substitution shape added to those fixtures later measured about 0.04 s of CPU for both scan passes at 64 KB, 0.14 s at 256 KB and 0.49 s at 1 MB on a 24-core host with Python 3.14.4 (2026-10-24, one run), against about 0.05 s, 0.34 s and 3.6 s for the sliced close test it replaces, measured the same way; the slice stayed under the 1 MB CPU bound, so the bounded timed rows are not what pins the running index: the orch-behaviour self-test pins it with a semantics row over the walk and a row that counts the scan's calls into the walk on a nested glued shape and bounds the positions the walk reads by the command's length, so a call site reverted to the slice (no calls) and a walk body reverted to a slice copy (reads quadratic in the nesting depth) each fail that row. Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:orch-untracked-wait-loop
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

Registry-scoped deny companion to orch-truncation-guard's bare-& DENY-and-educate (defence in depth on the same PreToolUse Bash event): inert with no orchestration registry at the session cwd's git-resolved toplevel (it roots there only, with no ancestor walk and no union, so it is inert where git resolves no toplevel, or where the truncation guard's walk finds a registry above or beside the toplevel; it never reads AIQT_ORCH_REQUIRE_REGISTRY, so it stays inert on an absent registry in that mode too), and NOT lease-scoped (a bounded worker building a fire-and-forget poll is equally wrong). It DENIES only an UNAMBIGUOUS canonical shape recognized by a best-effort LEXICAL heuristic, a status-polling loop detached with a SINGLE bare '&' that is the command's FINAL operator: exactly ONE for/while/until loop closed by a bare-'&' 'done', whose body carries a 'sleep' command word and whose construct (the loop condition through 'done') carries a status probe. The probe test is a deliberate HEURISTIC, not an enumeration of every status tool: a 'gh' or 'curl' command word, a '--watch' token, or a token with an 'actions/runs' path component; a poll driven by another probe command is a disclosed miss, not a match. Reserved words (for/while/until/do/done) are recognized ONLY as the EXACT raw-unquoted token: because argv is quote-decoded and the command-word helper basenames, a bare '&' closing a command whose name was written quoted, or a path such as '/tmp/done', is NOT read as the loop terminator (it classifies 'none'), and an inner loop header that shares a segment with the outer 'do' is still counted from the raw-unquoted tokens rather than borrowing the outer loop's probe. The classifier is THREE-VALUED over the shared quote/redirect-aware lexer (_lex_command). It is 'none' on a full parse that is simply not the canonical shape: no bare '&'; a bare '&' that does not close a raw-unquoted 'done' terminator (for example 'sleep 30 &', a quoted or path 'done', or a 'done' carrying a redirect such as 'done >log &'); or a clean single loop lacking the sleep or the probe. It is 'indeterminate', emitting nothing and deferring to the truncation guard's generic bare-'&' DENY-and-educate on the same event, whenever the structure cannot be soundly attributed to one canonical loop: an unparseable construct (heredoc, here-string, process substitution, unbalanced quote, NUL, malformed redirect, or a '#' whose comment reading depends on context under the shared lexer's comment rule that the lexer cannot judge under both readings and whose command text lacks a word of the canonical shape, the one over-match stated below; such a '#' the lexer can judge is classified under each reading on its own, keeping the strictest answer, 'match' before 'indeterminate'); any subshell or C-style '(( ))' grouping (a '(' or ')' separator); more than one bare '&'; a command trailing the bare-'&' 'done'; a nested or extra loop keyword (a second for/while/until/do or a second 'done', counted from the raw-unquoted tokens); a conditional reserved word (if/then/elif/else/fi/case/esac or a '!' negation) anywhere in the loop span; or brace grouping ('{' or '}') in the loop span. This conservative posture is deliberate for a BLOCK guard: a false 'match' would strand a session, so every ambiguity but one defers to the truncation guard rather than denying here. The one exception is a DISCLOSED OVER-MATCH: behind a '#' the lexer cannot judge under both readings, a command whose text holds a loop header (for/while/until), 'done', an '&', 'sleep' and a probe gives 'match' and denies here even where those words sit in a comment or in quoted text that bash does not run as such a loop; the probe counts as present wherever that text holds quoting the fallback does not decode, since the classifier reads the --watch and actions/runs probes from decoded tokens. HONEST RESIDUALS (disclose-guard-residuals): detachment with no bare '&' in the outer command (setsid, disown, coproc, an aliased or function-renamed detacher, a nested bash -c '... &'), and a poll loop hidden inside a command substitution (opaque to the segment walk), are NOT caught and are silently allowed here; a nested loop, a conditional or brace-grouped body, a C-style for '(( ))', a multi-detach command, and a subshell-grouped or unparseable detach are 'indeterminate' here and left to the truncation guard's DENY-and-educate, not denied by this hook. Quoting is derived from raw character positions (argv_opaque flags an unquoted expansion/glob/brace/tilde but NOT plain quoting), so the raw-unquoted reserved-word checks read seg.raw directly. A payload with no tool_name is malformed, not another tool, and DENIES in every session (the shared missing-tool_name contract, checked before the registry scope as orch-truncation-guard checks it). Fail-open (silent allow) on a present non-Bash tool, no git toplevel, an absent registry, and an empty command (it runs nothing); in registry scope a missing, non-object or non-string command DENIES, fail closed, never a clean pass; a present-but-malformed registry leaves the guard active (deny-safe direction). The 'trkasy' rule text already carries the untracked-wait-loop clause; this hook enforces it and adds no rule. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 2.08 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:orch-yield-tool-guard
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on ScheduleWakeup|CronCreate
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

The scheduled-yield binding of the decision core: a call that parks the run (schedule_idle) or ends the loop (a ScheduleWakeup with stop=true is classified stop) is judged against a fresh enumeration. Registry-scoped and lease-scoped like orch-stop-guard; a tool the registry's yield_tools list does not name is out of scope even inside the matcher, and a registry yield tool OUTSIDE this fixed matcher is NOT covered (the doctor flags it; a manifest matcher is fixed at generation). On the schedule path a cannot-evaluate DENIES (missing evidence never licenses new autonomy; a denied tool call cannot wedge a session), bounded by a three-denial cap on an unchanged basis, after which it ALLOWS WITH FINDINGS so the guard cannot be farmed into bypass training. Each deny, the quiet-claim deny included, appends a guard-events row; a failed append leaves no row and adds a short recording-failure warning to the deny reason and the banner while the deny stands. The cap, and the loop bound of a stop=true call, are judged on the count the call reads from turn-state.json before any save, and only a saved denial adds to it. A save needs a state directory the hook can write and search, and read where the platform lacks O_PATH. Every owner-mode list in this residue holds for a hook that runs without root as the state directory's owner and holds neither CAP_DAC_OVERRIDE, which searches and writes the directory in every mode, nor CAP_DAC_READ_SEARCH, which searches it and reads turn-state.json in every mode, with the state directory on a filesystem mounted read-write and not marked immutable, since a read-only mount or an immutable directory fails every save: of the eight owner modes of the state directory, 0300 and 0700 save with O_PATH and only 0700 saves without it. A deny whose denial counter cannot be written leaves the count on disk unchanged and says so in its deny reason and banner (it does not count toward the cap, or toward the loop bound for a stop=true call); the deny stands. A state directory the hook cannot search (0000, 0200, 0400 and 0600) reads as a fresh turn-state, with no count and no recorded basis, so a count already earned there is not seen; a turn-state.json that cannot be read or does not hold a JSON object, in a state directory the hook can search, reads as a schedule count of zero with no recorded basis, and as the loop bound reached for a stop=true call, and a deny saved over it starts the count afresh at one. So a schedule call the decision core would deny allows with findings exactly when the count it reads is at or above the cap on an unchanged basis (a schedule_denials value that validation rejects reads as zero, and a stop_hook_active field has no effect on a schedule call), and a stop=true call does so exactly when one of four holds: the count it reads is at or above the loop bound; turn-state.json cannot be read, or does not hold a JSON object, in a state directory the hook can search; its stop_denials value is present but is not a JSON integer from 0 to 9999 (for example a float such as 1.0, a boolean, a string, null, a negative number or a number above 9999), which reads as the loop bound; or the call's payload carries a stop_hook_active field set to the JSON value true, which relieves it whatever the mode of the state directory (the Claude Code hooks reference documents that field in the Stop input and does not list it in the PreToolUse input, so a stop=true call is relieved this way only by a payload that carries it anyway). Relief already earned therefore survives a later save failure only while the state directory stays searchable and turn-state.json readable (0100 and 0500, and 0300 without O_PATH, relieve a schedule call and name its failed wake-digest save in the note), and is lost while the state directory cannot be searched (0000, 0200, 0400 and 0600 deny, the count on disk unchanged). No relief reaches the quiet-duration deny or the unreadable-registry deny: both are decided before the decision core, so both deny a schedule call even with a readable count at or above the cap on an unchanged basis, and the unreadable-registry deny also denies a stop=true call that any of the four loop-bound triggers would relieve, a stop_hook_active field set to true included (the quiet-duration deny judges only schedule calls); the quiet-duration deny needs the measured gap in turn-state.json, so a state directory the hook cannot search never reaches it. An allowed schedule whose wake digest cannot be written to turn-state says so in its note (the returning prompt may then read as genuine human input or as timer-originated, so the note promises neither); neither failure changes the decision. Wake hygiene: a permitted schedule must name the tracked item or blocker it will recheck in its own text, judged by token containment (a paraphrase that names neither id nor ref denies once, a disclosed over-fire). The quiet-duration check is a best-effort regex: it fires only when the call text carries a parseable minutes claim AND turn-state carries a measured gap, and then the measured figure wins; an unparseable claim or an unknown measured endpoint authorizes nothing and is not invented. A fired timer or an existing cron is never counted as a live task or a blocker. Escape-sentinel acceptance, the attestation narrowing of external and foreign-lease evidence, and the anti-shrinkage checkpoint are shared with orch-stop-guard; cap or bound relief past any non-closed disposition (actionable, cannot-evaluate, waiting, blocked, or proposed) is marked forced_unresolved (a guard-events row plus an append-only row in forced-exit.jsonl, normally raised once, at least once if recording it in forced-exit-surfaced.json fails), a failed record surfacing in the returned systemMessage. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took under 0.01 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:pgrep-self-match
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

A BEST-EFFORT LEXICAL guard over the RAW command text, not the shared lexer's segments, DENY-and-educate with no ask. pgrep -f, pkill -f, pidwait -f and a filter fed by ps match a pattern against every full command line, and the shell running a Bash command carries the command text on its own command line, as do each ancestor shell (the agent's Bash tool wrapper included) and every pipeline stage or background subshell it forks; a forked copy is not an ancestor, so pgrep -A does not leave it out. The pattern written once therefore already matches the checking shell (a wait that never ends, a presence check that always succeeds). The scan drops every quote character and backslash, splits the text into words at blanks and at the operators ; & | ( ) ` and newline, leaves out a redirect and its target word, and reads every simple command it finds, so a check inside a quoted $(...), backticks, an eval or bash -c string, a here-document body, a comment or prose is read too. It DENIES: pgrep, pkill or pidwait (the word, or a path ending in it, wherever it stands in a simple command) given -f or --full, with or without -A or --ignore-ancestors, in any cluster such as -af, -fA or -lf, before or after the pattern, as a unique abbreviation such as --fu, past the values of the options that take one as procps-ng 4.0.4 pgrep, pkill and pidwait --help list them (-d, -g, -G, -O, -P, -s, -t, -u, -U, -F, -r, pkill's -q and --delimiter, --pgroup, --group, --older, --parent, --session, --signal, --terminal, --euid, --uid, --pidfile, --runstates, --cgroup, --ns, --nslist, --queue, glued or separate) and past a pkill signal written as an option (-9, -TERM, -SIGTERM, a signal name of two or more letters), so a single option letter such as -P or -G is read as the option, with an empty pattern or none too. It also DENIES a ps standing as the command word of a simple command (past leading reserved words, VAR=value words, and the wrappers env, timeout, command, exec, nohup, xargs, sudo, nice, setsid, stdbuf, time, builtin, doas, ionice, watch, eval, su, sh, bash, dash, zsh and ksh with their options, a duration word and any word right after an option), unless that ps selects by PID only, piped through any number of stages into a program given an argument that may carry a pattern: an operand, or an option word unless the program's option table knows it takes no pattern. Tables exist for grep, egrep, fgrep, zgrep, zegrep, zfgrep (GNU grep 3.12), sed (GNU sed 4.9), wc, head and sort; a short cluster holding e (or f for grep and sed) anywhere, inside another option's glued value too, any abbreviation of --regexp, --expression or --file, an option word or letter a table does not classify, and every option word of a program with no table (rg, ag, awk, tail and the rest) carry a pattern, so an empty pattern, an extra -e pattern or a glued -e value does not hide a real one. A ps selects by PID only when it is given -p or --pid (in a cluster such as -fp, glued or separate, with -o and other output options anywhere) and no other selection (-e, -A, -a, -d, -N, -C, -g, -G, -q, -s, -t, -u, -U, their long forms, or any word without a dash, BSD aux and p included). It ALLOWS: pgrep, pkill or pidwait without -f (pgrep -x included); a pattern written as the bracket idiom, a character class of letters, digits or underscores followed by a literal character ([j]ob7), which cannot match its own text, for pgrep -f, pkill -f, pidwait -f, an -e or --regexp value, and an operand after ps alike, unless a literal the idiom matches (job7, in any case) appears elsewhere in the de-quoted command text, where the shell's command line would match it; each judged idiom scans the command once for its literal and tests the character before each occurrence against the class letters as a set, and past 64 bracket patterns in one command the rest deny unjudged, so the cost stays linear in the command length (the hooks self-test times 400 KB idioms under 2 s of CPU each); a ps that selects by PID only, alone or piped; a ps that is not the command word (docker ps, podman ps, docker compose ps); a ps piped into a program whose table classifies every option and that has no operand (wc -l, head -n 5, sort -rk3); and a filter that ps does not feed through a pipe. DISCLOSED OVER-DENIES: pgrep -f, pkill -f, pidwait -f or a ps pipeline written as prose or data (an echo, a commit message, a comment, a search for the text pgrep -f); a ps piped into a program whose argument is not a pattern (awk '{print $2}', tee out, tail -n +2, grep -v grep, a file operand); an unusual option spelling a table does not classify, or an option of a program with no table (sort is tabled, column -t and less -S are not); an e or f inside another option's value (grep -m1e); a bracket idiom whose class letter can stand before an overlapping occurrence of its own literal ([a]aa), which is not judged; an anchored pattern (pgrep -f '^name'), which cannot match the shell; pgrep -fx with a literal name; BSD ps p PID and ps -q PID; a ps right after a wrapper's flag that takes no value, read as a possible value (sudo -E docker ps); a quoted word holding a blank or one of ; & | ( ), which the scan splits; and a pkill-only option that takes a value glued in a cluster before an f. A pattern or filter option built at run time (pgrep -f "$P", ps aux | grep "$P") is DENIED, failing closed, since it is not a bracket idiom. HONEST RESIDUALS (each a silent allow): a command word built at run time (the word pgrep, pkill, pidwait or ps itself built through a variable, an alias or function, an ANSI-C quote, a brace expansion or substitution, a script file, or xargs reading the command from its input); a pgrep, pkill or pidwait -f option built at run time, so no -f is seen; other process lookups (pidof, killall, top -b -c, grep over /proc/*/cmdline, ps -C name with no filter); a ps behind a wrapper off the list above (flock FILE ps, chrt 5 ps); ps output reaching the filter other than through a pipe (a file, a process substitution, a here-string) or from a ( ) group whose last command is not the ps, or from a { } group. A missing tool_name takes the shared missing-tool deny and a non-string command denies fail-safe, as the sibling deny hooks do; a present non-Bash tool and an empty command allow. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 0.52 s of CPU in-process on an 80-core host with Python 3.14.4 (one run on 2026-10-29, on nest_parens and pgrep_self_after at 1 MB). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:protected-line-guard
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: b
  • Channel: plugin

Technical limits (from the enforcement ledger)

Within the Bash hook's recognized or apparent direct-commit surface, commits deny unless an admitted command shape proves their target worktree and non-protected local branch, or the narrow lone no-remote policy exemption applies. Unknown wrappers, unsupported preceding effects or control flow, accompanying repository/configuration mechanisms, non-cosmetic ambient GIT_* overrides, and failed probes deny. A plain absolute git -C /absolute/repo commit is supported only when its applicable branch is proved non-protected and no accompanying mechanism or unknown preceding effect invalidates that proof. Protected names are exactly main and master, case-sensitive. Unknown and proved-protected states are distinct internally; both deny without an applicable certificate. There is no unproved-commit allow-note and no ASK outcome. Certificates: A is a plain commit in its resolved directory (the latest preceding admitted cd or pushd operand, else the absolute cwd) with validated non-protected local HEAD. B is a commit with exactly one separated, nonempty, absolute literal -C operand, a proved worktree identity, and applicable non-protected branch state. C is a commit gated through an uninterrupted && chain by an admitted switch addressing the same proved worktree. D is a policy exemption, not evidence of a non-protected destination: exactly one commit, accompanied by nothing but admitted cd or pushd segments, on a valid protected local HEAD of the repository that commit targets (its absolute -C operand, else its resolved directory as for A), with remote absence of that repository proved by successful configuration and legacy-common-directory checks. Unknown remote state, or a target that cannot be resolved or read, withholds D. Other compounds, redirects, wrappers, assignments, and opaque arguments cannot obtain D. A valid unborn symbolic local HEAD can establish a branch name; detached, malformed, missing, or unreadable HEAD cannot. Every committing shape requires complete admitted syntax and literal arguments, including quoted literal message data. The only Git prefixes are git commit and git -C /absolute/repo commit; arguments after the commit verb remain commit arguments, judged by other guards for their own policies. Compounds admit only &&-connected segments: argument-free :, true, or false; exactly printf %s LITERAL; exactly cd or pushd ABSOLUTE_LITERAL_DIRECTORY; git switch EXISTING_LOCAL_BRANCH; git switch -c NEW_LOCAL_BRANCH; git checkout -b NEW_LOCAL_BRANCH; and separately certified commits. Switch forms may use the same exact absolute -C prefix. A preceding admitted cd or pushd sets the directory of later segments without -C; an operand with a . or .. component leaves that directory unresolved, so such a later segment denies. An explicit target can establish B afterward. An admitted trailing cd cannot change an earlier commit destination. Unknown segments before or after a commit deny the whole commit-bearing command. Exactly lone literal git commit --help and git commit -h are non-committing help forms; message values, pathspecs, and global-option values spelling --help do not supply that exemption. INTENTIONAL OVER-DENY: harmless global options, leading assignments (including the removed discard-override prefix), wrappers even with harmless options, attached/relative/repeated/empty -C, redirects, unsupported switch syntax, complex compounds and non-&& separators, and detached commits are rejected even when a particular execution would be harmless. Absolute -C does not repair protected branch state, unknown prior mutations, ambient overrides, or failed probes. After arbitrary work such as make test, issue the commit as a SEPARATE hook invocation that obtains fresh state; make test && git -C /absolute/repo commit is outside the admitted grammar. Use a plain feature-branch commit or an exact absolute-target commit in that fresh invocation; an admitted switch or literal cd may participate only under the stated grammar. Worktree identity and terminal full symbolic HEAD are established by scrubbed, bounded read-only probes. Alternate paths to one Git directory share conditional branch state; linked worktrees retain separate HEAD state. Existing switch destinations must be validated local branches, without symbolic-alias or remote-guess ambiguity. Probe failures, malformed output, timeouts, and exhausted input/output/evaluation budgets withhold proof. Literal detection examines decoded and raw unclassified segments independently of ordinary Git segments; admitted literal message/printf data is not executable syntax. A git command that defines a git alias for itself (a -c or --config-env key in the alias, include or includeIf section, or a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n assignment naming one), its git word read wherever it stands in its segment (the command word, or a word behind a reserved word, a { group, !, time or a wrapper word; not inside the text of another word, such as an sh -c or eval body) runs a subcommand neither the commit nor the push reading can see, so it denies on any branch before either reading, and the literal detector reads it as an apparent commit (on an unparseable command whenever its text names git and holds such a definition anywhere in its de-quoted words, past a here-string, an unquoted heredoc or a {fd} redirect the lexer stops at included, which denies as an unverifiable commit); so does every git command of a command whose text names GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT or GIT_CONFIG_KEY_ anywhere (sought once every quote character, backslash, dollar sign and newline is removed, so a quoted, escaped or continued spelling counts, in any word, a here-string, a comment or an echo) and names git as a word anywhere, parsed or unparseable alike (git wherever it stands: behind a reserved word or a wrapper, or inside an eval or sh -c word), whatever form sets the variable and whatever its value, since a shell sets one in more ways than a reading of forms can list (on git 2.53.0 with bash 5.3 an export, declare -x, typeset -x or local -x word, a bare assignment or a readonly word to a variable already exported, and under set -a also a bare assignment, read, printf -v, a for loop variable, a default-value expansion or a nameref each defined an alias), so a mention that defines no alias (an echo, a comment, an unset, an unrelated value) counts too, a disclosed over-refusal, or that runs under a GIT_CONFIG_PARAMETERS or GIT_CONFIG_KEY_n naming one in the session environment the hook inherits, which counts as such a text does, git wherever it stands (not read: a variable name built at run time, such as an expansion after GIT_CONFIG_, a brace expansion or an escape decoded inside an ANSI-C quote, and a variable set outside the command text, in the startup files of the Bash tool shell or in a sourced file or a script the command runs). A definition counts however it is spelled: the lexer records how each word was spelled, and the rule is closed over the whole region after a git word through its subcommand word (every global option word, the value of any option and the word read as the subcommand), where a word that carries an unquoted parameter or command expansion (a dollar sign or backquote outside quotes, one bash reads literally included), which bash may split into several words such as a -c and a definition, or whose text came through an ANSI-C or locale quote (a dollar sign right before a quote character) defines a git alias whatever it decodes to, as does a word there holding an unquoted brace and a comma (bash expands it into several words), other than a -c or --config-env word and its value, and a -c value or a --config-env word whose key part (the text before its first =, or the whole word when it has none) is spelled through a parameter or command expansion, inside double quotes too, such a quote or a brace expansion; on an unparseable command the same rule reads the de-quoted words of each command, past a here-string, a heredoc or a {fd} redirect, where a quoted or escaped dollar sign, backquote or brace counts as an unquoted one would. On such a command a git region the lexer did not read denies, fail closed: where the partial lexer stops before the option region of a git command or inside it (a leading here-string, as in git <<<x -C DIR $X), that region is unread, scoped to a segment that runs git as its own command (its command word is git or a wrapper the guards resolve, never a git word another command merely carries); the text past where it stopped is read two ways, and a deny from either stands: a quote-aware re-lex of the text past each construct the reader can measure on the same text (the target word of a here-string, or the balanced parentheses of a process substitution with backslash escapes honoured), each recovered command read through its subcommand word, and the every-git-word reading, which walks the region of each git word in the raw text from the stop, denying where a region cannot be read to its subcommand word or holds a word built at run time. The re-lex alone decides only where its ground is proven: the construct measured with no backslash escape met, no git word inside the text of the measured construct itself (the body of a process substitution and the word of a here-string are run or expanded, so a git region there keeps the raw reading), the resume at a command separator, and every git word of the re-lexed text read as a word of a recovered command (one in a comment or a redirect target is not); there a git word the re-lex proves to be quoted text (an echoed git word past a stop, a pull-request or issue body naming git) allows, while a plain git word any command carries as a word of its own (under a wrapper the guards do not resolve, such as flock or ionice, included) is still walked and denies where its region runs open or holds a word built at run time. At every other point (an unquoted heredoc, a {fd} redirect or an unbalanced quote the reader cannot measure, a resume that is not at a separator, a measure that met a backslash escape, a git word inside the measured construct, one in text the re-lex gives no word for, or a git word beside a $ or backquote inside a re-lexed word) the every-git-word reading decides too, so a quoted or commented git word there still denies, a disclosed over-refusal; a literal region read in full keeps its decision either way. Disclosed over-refusal: a word naming git that is the last word read before such a construct and is its command word (git <<<x log) still denies, while a git word only an operand of the segment the lexer stopped inside (grep -c git <<<x) is not read as a git command, so it allows. Remaining residual of this reading: the every-git-word walk reads the region of a git word on the raw text, so from inside quoted, comment or here-document text it can only over-refuse, never allow a git command the re-lex read; a git command whose git word is itself built at run time (an expansion that expands to git) is outside both readings, as on a parsed command. A word reads as one word to bash only when no part of it can expand to several words: a double-quoted expansion of the positional list or of an array subscripted with @, in any parameter-expansion form, with or without an operator or substring, and an indirect expansion whose reference (a variable, an array element read through a subscript of its own, a positional parameter or $*) can hold a reference to such a list (a double-quoted $@, ${@:1}, ${A[@]}, ${A[@]:0}, ${!A[@]}, ${!R}, ${!A[0]}, ${!1} or ${!prefix@}), expands to one word per element, so it counts there as an unquoted expansion does. A word whose every expansion stands inside double quotes and expands to one word keeps the plain reading on a parsed command: behind a literal key, as another option value or as the subcommand it defines nothing. Disclosed over-refusal (the safe direction): an unquoted variable in a git option or subcommand position now denies (git -C $DIR log, git $SUB, git -c core.pager=$P log), as does one after any other word naming git (grep -r git $DIR) and a git command inside an unquoted backquote substitution whose closing backquote the lexer reads into its subcommand word; quoting the variable restores the plain reading. The deny reason names such a word as one the shell builds at run time, with the fix (quote the expansion, spell a -c key literally, write a list out, put a here-string after the subcommand word), and keeps the alias wording for an actual alias definition. A key that is literal in effect but spelled through an expansion or such a quote reads as an alias, and so does a brace word there that expands to no option. Not read: the git word itself, read by its decoded text alone (an unquoted expansion in it, or a brace word that is the git word, which bash can split into git and a definition), and a word in that region that bash builds by filename expansion (an unquoted star, question mark or opening bracket matching a file name such as -c) or by a brace sequence expression, which holds no comma, and a word whose every expansion stands inside double quotes and expands to one word, which keeps the plain reading by decision, so one that names an option such as -c at run time, before a literal definition, is not read as one. This finite grammar and literal detector do not discover every commit hidden behind renamed executables, aliases defined in configuration files, functions, opaque scripts, or unparseable fragmentation. Other tools and commit-producing verbs such as merge, cherry-pick, revert, am, and rebase remain outside this direct-commit boundary. TRUST ASSUMPTIONS: trusted executable/builtin resolution; correspondence between the hook and execution environments; stable paths and configuration; and non-hostile hooks, editors, and helpers. Concurrent changes and probe-to-execution races remain residual risks. Server-side protection is a separate layer and does not validate this local pre-execution proof. PUSH COVERAGE REMAINS SEPARATE. A raw-text backstop scans every submitted command unstripped, before any parsing, one command segment at a time (backslash-newline continuations are joined first, as bash joins them, then the text is split at newlines and at ;, &, | separators outside provable quoting, a # opening a comment only where bash starts a word; an unterminated construct leaves the remainder one segment), reads each segment as written, with its line continuations joined, and with quotes, a $ before a quote, and backslashes removed, and denies when one segment pairs a git push with a force, delete, mirror or --all/--branches sweep spelling and a protected branch name. Each of those long options is matched at every abbreviation git 2.55 accepts as unambiguous, derived from the long-option set of git push: --force, --force-w for --force-with-lease, --force-i for --force-if-includes, --m for --mirror, --al for --all, --b for --branches, --de for --delete and --pru for --prune (the shorter --for and --forc, which git rejects as ambiguous, match too); push-shaped prose pairing those inside one segment, in a heredoc body line, quoted string, comment or commit message, is denied too, a disclosed over-denial in the safe direction, while a force spelling, deletion flag, protected name or option value that appears only in a different segment (a rebase onto origin/main beside a feature force-push, an rm -rf beside a plain push, a --format or --depth value) does not pair. Commit-free commands retain the existing lexical push classifier: force and protected deletion deny; an unprovable sweep or apparent force/delete denies fail-safe. Force includes short clusters, conservative long prefixes, leases, --force-if-includes, --mirror, and + refspecs. Sweeps include forced --all/--branches, matching :/+:, wildcard force/deletion, and --prune with wildcard/matching refspecs or --all/--branches. Operand roles distinguish the repository from destination refspecs; supported value-taking options and --/--end-of-options are consumed before flags and destinations are judged. Command-local remote.<name>.mirror configuration via separated -c is value-aware and last-value-wins for a direct same-key assignment; --config-env for that key denies regardless of the unread environment value. A refspec-less force or a forced/deleted HEAD/@ uses the separate scrubbed session-HEAD probe; unknown HEAD or an unresolved repository view denies. Deleted HEAD/@ are conservatively denied even though Git can reject those delete spellings. Plain non-force pushes and explicit non-protected destinations can allow. Push residuals include execution outside Bash; renamed executables, aliases defined in configuration files (an alias the command defines for itself denies, above), functions, and scripts; runtime-expanded destinations; fragmentation beyond parsed, raw and de-quoted detection (such as an escape decoded inside an ANSI-C quote); a wrapped force, delete, mirror or --all/--branches push naming no protected branch when another earlier or later segment parses as ordinary Git and suppresses the broad push fallback (one pairing a protected branch name with a force, delete, mirror or sweep spelling in the same segment is caught by the raw-text backstop, including a spelling fragmented by quotes or a backslash under a command-word wrapper); protected branches under names other than main/master; remote-side or hosting-provider actions; persisted repository/worktree/global/system configuration and includes; command-local remote.<name>.push refspecs; GIT_CONFIG_* protocols from the session environment (a command whose text names GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT or GIT_CONFIG_KEY_ denies, above), including the explicit-refspec ambient-override gap; and upstream/triangular mappings that can diverge from the session-HEAD proxy. push.default selects refs rather than forcing or deleting by itself. The raw mirror-config fallback recognizes common quoted spellings but cannot model arbitrary option/key/value fragmentation or cross-mechanism precedence. The abbreviation match follows the recorded git 2.55 push option set: an abbreviation that a later git accepts because it drops or renames a push option is not matched until that recorded set is updated, and --tags and --follow-tags are not matched (they add tags and never update or delete a branch). Negated force/delete flags, standalone --force-if-includes, and dry-run force/delete can over-deny. Parse-error/raw fallback scans and the raw-text backstop can also over-match quoted prose or unrelated option values that share a segment with a push; the push fallback scans the RAW command text with NO heredoc stripping at all (stripping removes text, so any stripping defect can only hide a live push - an earlier stripper still erased a real force-push behind a stacked heredoc and behind a quoted-heredoc lookalike nested in quotes inside a command substitution), so force-push-shaped prose inside ANY heredoc body, quoted bodies included, can over-deny on this fallback path, including prose naming only a feature ref when the command is unparseable or wrapper-hidden; the backstop reads the heredoc body lines, quoted strings and comments inside a segment as text. When the shell reading is uncertain (any unquoted #, <<, ( or ), a lone { or } word, any backtick or $(, a line continuation the lexer reads differently once joined, or a form the lexer refuses), the broad fallback decides even beside an ordinary Git segment and reads the text both as written and with quotes, a $ before a quote, and backslashes removed, so a force, delete or mirror spelling anywhere in such a command denies, including a force-push to a feature branch inside a subshell, inside a { } brace group beside another Git command, beside a command substitution, or carrying a # inside a word: a disclosed over-denial in the safe direction. Push coverage is best-effort and is not widened by the commit certificates. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 1.01 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context), because the raw-text push backstop reads the whole command, one segment at a time, before the 65,536-character size cap of the commit reading denies a longer one. Those shapes now include two that were measured separately, on 2026-10-18: a run of quoted '-c' mirror options with no blank, and a run of adjacent '<(' or '>(' process substitutions, each before a force-push to a protected branch. While the backstop read the first with the single mirror-configuration regex, whose cost grew with the square of the run's length, one call of this check on it took 14.0 s of CPU time at 256 KB and reached no decision within the 60-second hang guard of the hooks self-test at 1 MB; with the linear probe that replaced that regex, it took at most 0.47 s of CPU time at 1 MB. On the second, which the backstop's segment split now walks in place instead of copying the rest of the command for each substitution, it took about 0.17 s of CPU time at 1 MB. Each figure was measured in-process on a 24-core host with Python 3.14.4. Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:rerun-pass-check
  • Platform: claude-code
  • Default: block
  • Entry point: Stop
  • Class: b
  • Channel: preview, installed by hand from .preview/rerun-pass-check.py; not in the plugin

Technical limits (from the enforcement ledger)

PREVIEW CHANNEL ONLY: a standalone file installed by hand from .preview/, not part of the pack's plugin. After a CI rerun command that succeeds (gh run rerun, glab ci retry: a simple command, in a small closed shell grammar the hook reads exactly, whose words hold gh, run and rerun, or glab, ci and retry, in that order at any positions), or a recognized check command that failed and then passed with the same words, each as written with its quoting (pytest 'a b' and pytest 'a b' differ; blanks and comments between words do not count), and no recorded change between, it adds a note after the tool call, and at turn end it refuses a final message that presents a pass as conclusive without naming the earlier failure, at most 2 times in a row, after which each later conclusive stop is allowed with a warning and the reruns stay outstanding. The refusal count is kept in the hook's state file, so the cap holds when the platform's stop_hook_active field is absent or true; the count is reset to 0 only by a UserPromptSubmit event, a Stop whose stop_hook_active field is explicitly false, or a final message that names a disclosure word, never by a tool call or an allowed stop, so without UserPromptSubmit registered and without that field, after 2 refusals each later conclusive stop is allowed with the warning until a disclosure (a missed refusal). An explicit false is trusted as a new turn, so a host that sends it on a Stop that continues a refusal is refused at every such Stop (the cap does not hold). A state file that is missing (the first use of a session cannot be told from a lost one), unreadable or malformed gives an unknown refusal count, never a fresh 0; while the state file cannot be read or is malformed, a conclusive turn end, or one with no final message, is allowed with a warning that the state could not be read, and once it is missing, or a tool call has written back a lost or unreadable one, until one of those reset signals a conclusive turn end is allowed with a warning in place of a refusal only while a rerun seen since is outstanding; any other is silent, and a lost record of earlier reruns is silent (a missed refusal); with neither UserPromptSubmit registered nor stop_hook_active explicitly false, every refusal is replaced by that warning until a disclosure (a missed refusal). It recognizes only the listed CI rerun and check commands run through the shell tool. A rerun through a web page, a pushed empty commit, a runner's own retry option, an API call such as gh api .../rerun, a gh alias, or a changed command line is not seen. The shell reading is exact only for the closed grammar described in the hook's docstring, which reads a command of at most 8192 characters. Inside the grammar, a CI rerun is missed only when its words come into existence when the command runs, or bash runs gh under another name: an expansion that has a value ($c run rerun, with c=gh), a tilde expansion (~ run rerun with HOME=/path/to/gh, ~+ run rerun with PWD=/path/to/gh, or ~-; bash tilde-expands a word the hook reads as a plain ~), a pathname expansion (/usr/bin/g? run rerun), an alias, a shell function, a hashed command name (hash -p /path/to/gh x; x run rerun), or a command string handed to another program (sh -c '...', eval '...', ssh, xargs input, a script). The name rule ignores command position, so a simple command that names gh, run and rerun in order without running them is noted anyway: echo gh run rerun, unquoted or with each word quoted alone (echo "gh" "run" "rerun"), command -v gh run rerun, a function body that is defined but never called, false && gh run rerun 7; true (a false note, and a false refusal when the call succeeds). The hook reads an exit status only from an integer exit-code field of the tool response, where one is present, and that status is the whole call's: a later command, || true, a background &, a ! or an if or while condition can hide a gh call the server refused (HTTP 403), with or without pipefail set, and so can a pipe under default bash options, so gh run rerun 7 2>&1 | tail -5, gh run rerun 7 || true, gh run rerun 7; echo done, gh run rerun 7 &, ! gh run rerun 7 and if gh run rerun 7; then :; fi exit 0 and are kept as certain (a false refusal); with pipefail set, the pipe form exits nonzero and gets the uncertain note only, and the other forms still exit 0. The note for a CI rerun call that succeeds says only that the call was not reported as failed and asserts neither a rerun nor an earlier failure, and the refusal asks the assistant to state any earlier failure; neither it nor the warning given when a refusal is capped or its count cannot be saved asserts a CI rerun (each says only that a command naming one was not reported as failed). Every command outside the grammar is a possible CI rerun by construction: the hook could not parse it, so a CI rerun cannot be ruled out, and no search of its text decides otherwise; this includes echo "$(date)", a here-document, a line continuation, ${x:-y}, $'...', a redirection spelling not listed, and any command longer than 8192 characters. Each nonblank one gets a note, even when it fails (a blank command gets no note), so an off-grammar command that reruns nothing (a here-document commit, notably) gets a false note. Such a command, and a CI rerun call that fails (the rerun may or may not have started), is an uncertain rerun: its note says that a CI rerun cannot be ruled out and asserts no rerun and no earlier check failure, and it is never kept, so it never brings a turn-end refusal; a real CI rerun written outside the grammar, a failed call that did start a rerun, and an off-grammar check command that fails and then passes each get their note and no refusal (a missed refusal). An off-grammar command also counts as a change unless it fails, so a local rerun across it gets no local-rerun note of its own, and a check command outside the grammar is never compared as a check. Read-only is decided only inside the grammar: env with any argument, an assignment prefix (X=1 ls), a command word holding an expansion, and any output redirection (> >> >| &> &>>) whose target is not the unquoted word /dev/null count as a change; an input redirection (< <<<) and a descriptor duplication or close (>&2, <&0, >&-) write nothing. Also: a failed call is never counted as a change, so a fix made by a command that then failed reads as no change (a false note); a change made outside the tool calls it sees reads as no change (a false note), and a read-only-looking command with a side effect reads as no change; a setup command that is not read-only beside a check (source venv/bin/activate && pytest, an export; cd, pushd, popd, set and tee are not counted) counts as a change, so a later rerun of that check alone is not flagged; pass and fail are read from the event name and a few response fields, not the output; the Stop check reads only the final message against fixed phrase lists, so a claim worded otherwise passes, a claim after an unlisted negation is refused, any disclosure word clears the reruns wherever it stands, even inside a denial such as I did not rerun CI, whether or not the failure is recorded; it does not record or investigate the failure itself; with a state file that cannot be read or is malformed, a final message that presents a pass as conclusive without a disclosure word, or a payload with no final message, is allowed with a warning that the state could not be read, and any other final message is allowed silently; while reruns are outstanding, a payload with no final message is allowed with a warning that says so; with no state location at all (no session key in the payload, or no absolute state directory) nothing is kept and every stop is allowed silently; a refusal whose count cannot be saved is allowed with a warning, also on a Stop whose stop_hook_active field is explicitly false; concurrent hook runs are serialized by a lock file beside the state file, and a run saves only while that lock file and the state file are the ones it locked and loaded: a run that cannot take the lock within 2 seconds saves nothing (a tool call's change or rerun is not recorded, a prompt does not reset the count) and allows a turn end it would refuse with a warning that names the lock, and a run whose lock file is deleted or replaced while it holds it (also one moved away and put back after another run saved), or whose state file another run saved meanwhile, saves nothing and allows a turn end it would refuse with a warning that the refusal count cannot be saved (a deletion, replacement or save that lands in the few system calls between its last check and its write can still let one stale save through, and so can a save whose state file matches the loaded one in device, inode, size, modification time and change time, all five fields it compares, as when an inode number is reused within the filesystem's timestamp granularity); while the lock cannot be taken at all (a lock path that is a symbolic link or not a regular file, a lock file it cannot open, a filesystem that refuses flock, a platform without flock), it keeps no state, so it only notes after each tool call and, with no state saved before then, every turn end passes silently (a missed refusal), while the outstanding reruns of a state saved before then still bring a warning; a state folder moved away or deleted during a session is read as no state, so the next turn end passes silently (a missed refusal); nothing removes its state and lock files, one of each per session; a state file written by an earlier revision of the hook (without the current state version) is discarded and read as no earlier runs, so none of its flags arms a refusal and a local rerun across it is missed; a state file of the current version holding a flag of a kind this version never keeps is malformed, but the state file is trusted: a hand-edited state of the current version whose flags carry a prefix this version keeps still arms a refusal; a check run again with a word quoted another way ('a b', then "a b") is a different check (a missed note), and so is one with an operator spelled another way (pytest -q; ls, then pytest -q && ls, or a newline in place of the ;), while leading and trailing newlines and a final ; do not count; a check command whose words hold an expansion (a $ expansion, or an unquoted * ? or ~) is never compared with another run, so its fail-then-pass gets no local-rerun note (a missed note, never a false one); a command holding a tab, a newline or another control character (C0, DEL or C1) is shown in a note as one $'...' string with those characters escaped; and the hook fails open on its own failure (warn-only by design: an advisory hook must not block on its own failure): an internal error exits 0 with no output, an unreadable state file is read as no earlier runs (so a local rerun across it is missed) with an unknown refusal count (a Stop that reads it allows a conclusive stop with a warning that the state could not be read), though the current call's own CI rerun or possible CI rerun note is still given, and a note or refusal that cannot be written to stdout is dropped.
hook:review-dispatch-pin
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: c
  • Channel: plugin

Technical limits (from the enforcement ledger)

Registry scoped and inert without a review dispatch binding. It checks only a provably plain dispatch, decided on the raw characters by the shared plain-command specification: printable ASCII only, no shell character outside a single-quoted segment, a double-quoted segment holding none, words separated by spaces, and a bare, unquoted command word that is a declared dispatch command (compared without regard to case), so the dispatch is the only command of the call (no operator, no redirection, not even </dev/null), with the brief given once and last (a short brief option never in its = form). In a session a binding scopes, every Bash call that is not one plain command is withheld as UNVERIFIABLE, whatever it names, since a dispatcher name assembled at run time is not in the raw text, and so is a plain command that names a declared command other than as its command word; this refuses many harmless commands (a pipeline, a redirection, a variable, a glob, a mention such as grep of the command name). A plain command's word is a bare name, or a path whose directory is /usr/bin, /bin, /usr/local/bin or /usr/sbin, of a declared dispatch command or a program on an allowlist (git, opf, and programs that cannot run another, such as ls, cat, grep, cp, mv and rm); every other program (a shell, an interpreter at any version, find, sed, tar, sort, make, cd, an undeclared dispatcher) is withheld as well. A plain command whose program runs an argument or its configuration as shell text (a git alias set before the session, a package script) can still assemble a name the hook does not see. A brief path under /proc or /dev is withheld, since it can name a different file in the hook's process. The brief's labels are read only at column 0, as the exact label followed by one space. Every line of the brief is screened, a line inside a fenced code block included, by two closed rules, either of which withholds the dispatch as UNVERIFIABLE with a reason naming the first line at fault. First, a control character (category Cc: the C0 controls, DEL and the C1 controls) other than a newline or a tab, or a format character (category Cf: a bidirectional control, a zero width character, a soft hyphen, a byte-order mark other than one that opens the file, a tag character), anywhere in the brief, the reason naming the character and its column; a carriage return is one, so a brief saved with CRLF line ends is withheld. Second, a line, a line read as a label by its value alone, that holds a label's name with a colon after it: after canonical decomposition, case folding, NFKD, case folding and NFKD, the name's ASCII letters and digits in order, with no other ASCII letter or digit between them, and later on the line a colon, an ASCII colon or any character outside ASCII that is not a letter or digit (a category other than L and N: a punctuation mark, a symbol, a mark, a space, a private-use or unassigned code point); every other character between the name's letters is passed over (punctuation, spaces, markup with no letter or digit, a letter or digit outside ASCII), and so is everything between the name and the colon, unless an ASCII letter or digit follows the name's last letter directly, which makes the name the start of a longer word; a letter or digit outside ASCII that stands next to an ASCII letter or digit, once the characters that are not letters or digits are passed over, may also stand for one letter of the name. A label with no letter or digit, one outside ASCII, or no colon after its last one is read only in its exact form. Each rule makes a fixed number of passes over the brief and uses no backtracking pattern, so its time grows linearly with the brief: on seven adversarial briefs of 1 MiB (a line of backticks ending in a letter and a backtick, a name's first eight letters with a look-alike and a colon repeated, a letter and a look-alike repeated, a short line repeated, a whole name and a word repeated on one line that ends in a colon, a letter and a look-alike between two marks repeated, and a mark repeated) the screen took at most 0.39 s of CPU time and each about four times its time at 256 KiB, and the whole check at most 0.48 s, in-process on an 80-CPU host with Python 3.14.4 (one run on 2026-10-30). So a label is withheld behind any prefix (a list marker, a checkbox, an HTML tag, another label's value), in any case, in fullwidth or accented letters or with look-alike letters outside ASCII in runs of one or two, with any character but an ASCII letter or digit inside its name (a blank letter such as U+3164 or U+13441, a mark such as U+0903, a modifier letter such as U+0640, markup with no letter or digit), with anything between its name and its colon that does not begin with an ASCII letter or digit (a Markdown link destination or reference, such as the label [Review-path] followed directly by (x) and the colon, an HTML tag that holds letters such as <span hidden>x</span>), with no space, a tab or a no-break space after its colon, and with a look-alike colon that is a punctuation mark, a symbol, a mark (U+0903 DEVANAGARI SIGN VISARGA) or a space; a terminal escape, a backspace, a carriage return, a bidirectional control or an invisible format character withholds the brief wherever it stands; and so does every line that holds a label's name, other than as the start of a longer word, with a colon anywhere after it (a URL or a time included) in prose, a list, a table, a heading, a code span, a quote or a fenced code block, or a label's name followed by a curly quote, a dash, an arrow or a mark (to mention a label, drop the colon and keep any other colon off that line). A label is neither read nor refused when an ASCII letter or digit stands inside its name (an HTML tag that holds letters inside it, a character reference such as Review&#45;path:) or right after its last letter (Review-pathx:, which reads as another word), when a letter of its name is written as another ASCII letter or digit (Rev1ew-path:, &#82;eview-path:) or as a letter outside ASCII with no ASCII letter or digit next to it, colons and spaces passed over (three look-alikes in a row), when its colon is missing or written as a character reference (Review-path&#58;) or is a look-alike letter (U+02D0 MODIFIER LETTER TRIANGULAR COLON), and when it is split across lines. A dispatch through an undeclared command, an alias, a function, a script that dispatches internally, a nested shell string, or a non-Bash tool is not seen and is allowed. A linked worktree is scoped by its main worktree's registry, located through git or, where git cannot run, through the raw .git and commondir files; a linked worktree whose main worktree cannot be located (a separated git directory, with git running or not) withholds every Bash call. A linked worktree of a bare repository (core.bare true) has no main worktree and is not withheld, and a failed git probe where the .git is a directory is no linked worktree, so a session with no registry anywhere is not withheld; a core.bare of true set in a separated git directory's configuration would end that withholding. A registry without a binding never ends the search: the session's registry, its main worktree's and every ancestor registry are read, nearest first, and the first binding on each way decides; each registry file is read on its own, so a local registry without a binding cannot hide the committed one's. In a bound session a plain command that names a registry file or the .aiqt directory is refused unless its command word only reads (cat, head, tail, wc, ls, stat, grep, cmp, diff); the directory counts as named through a value glued to a short option, a git pathspec after its magic, a quoted glob matching it (so a quoted * is refused too), a .. resolved against the cwd, or a subdirectory of it entered in a command whose words also climb with a .. component. A plain git command carrying an option or subcommand that runs a program, named in it or configured, is refused whatever it names, since that program's shell text can hold a name no word check sees: a global -p or --paginate, --ext-diff, --textconv, cat-file --filters, --show-signature, a %G placeholder (also with a modifier: %+G?, %-GG, % GK) or a %(signature) or %(*signature) atom in a --format or --pretty value or in a shortlog --group value (git reads a group holding % as a pretty format), a signature sort key (signature, -signature, v:*signature:grade, and any key holding the word, such as contents:signature, which is refused too) in a for-each-ref, branch or tag --sort value, grep -O or --open-files-in-pager, rebase -x or --exec, difftool -x or --extcmd, --upload-pack, --receive-pack, --exec (as archive and push take it), clone -u, -c or --config, send-email's --sendmail-cmd, --to-cmd, --cc-cmd, --header-cmd and --smtp-server, a global --exec-path=DIR, bisect run, submodule foreach and filter-branch, each long option abbreviated or not. A plain git command whose subcommand only reads (status, log, diff, show, rev-parse, ls-files, blame, grep, cat-file, or config in a read form), with no -c or --config-env global and no --output option, treats every word as data, so a registry name, a glob or a repository key in it is not refused, unless, for log, show and diff, an option asks for patch output (such as -p, -u, -U, -c, --patch or --cc), to which a textconv filter applies, which gets the full checks; options are read with their operands up to a -- ending them, so -Sconfig, --grep -c and -n 3 ask for none. A plain git command runs only a subcommand on a closed allowlist, matched exactly (status, log, diff, show, rev-parse, ls-files, ls-tree, cat-file, blame, grep, describe, shortlog, merge-base, rev-list, for-each-ref, show-ref, branch, tag, remote, config, add, rm, mv, commit, push, fetch, stash, switch, checkout, restore, reset, merge, rebase, cherry-pick, revert, worktree, clone), or is git alone or with only --version, --exec-path, --html-path, --man-path or --info-path; every other subcommand (instaweb, difftool, mergetool, send-email, filter-branch, bisect, submodule, daemon, web--browse, the credential commands, help, an alias, an external git-NAME program, one behind a global option the hook does not know) is refused, whatever it names. For an allowlisted subcommand, the options of its own that run a program, read from git SUB -h on git 2.53, are refused too: signing and signature verification (--gpg-sign and -S of commit, merge, rebase, cherry-pick and revert, tag -s, -u and -v, merge --verify-signatures, push --signed), a merge strategy (--strategy, merge and rebase -s), patch and interactive modes (-p and --patch of add, commit, checkout, restore, reset and stash, add -i, --interactive), --trailer, a --help after the subcommand, and a TRANSPORT::ADDRESS URL for fetch, push, clone or remote (a remote helper on PATH); options are read with their operands first, attached or the next word, so git log --grep --ext-diff searches for --ext-diff and is allowed; for status, log, diff, show, rev-list, shortlog, ls-files, blame, grep, add, rm, commit, checkout, restore and reset, whose options end at a -- (checked on git 2.53), a -- first after the subcommand or after a word that is no option ends that reading, so git diff -- --ext-diff and git log -- --show-signature name paths and are allowed, while for every other subcommand a word after -- is judged too (git stash show -- --ext-diff is refused). These refusals include harmless forms (git merge -s ours, git merge --verify, git stash show -p). A git command whose operand is spelled like an option after option-value consumption may be falsely denied (git stash push -m --patch, whose message --patch is judged as the patch option), and so may one with a long option, written without =, that the hook does not list as taking no value, since that option is read as possibly taking the next word, which is then judged and cannot end the options as a -- (git log --follow -- --ext-diff); this is a fail-safe refusal of a harmless form, never an allow. The editor (core.editor, sequence.editor), which git runs by default, is not refused, and neither are hooks and configuration written before the session: an allowlisted subcommand still runs a hook (pre-commit, post-checkout, pre-push and the rest, from .git/hooks or core.hooksPath) and configured programs (diff.external, filter and textconv drivers, core.fsmonitor, credential helpers, commit.gpgSign with gpg.program). Since git configuration can make any git command, a read among them, run a program (diff.external, a diff driver's command or textconv, core.pager, core.editor, core.fsmonitor, core.hooksPath, an alias, a filter or merge driver, credential.helper, gpg.program and more), every git config write, whatever the key, and every -c or --config-env global is refused in a bound session; a git config call reads only as --get, --get-all or --get-regexp with their operands, --list or -l, or one key alone, each after modifiers only (--local, --global, --system, --worktree, --file PATH, --blob BLOB, --show-origin, --show-scope, --type and the like), since git config takes a word after the key as its value (git config core.bare --local sets it), so newer git's git config get KEY is refused. A plain command that may write into a .git directory, a global or system configuration file (.gitconfig, gitconfig, git/config) or, as git --template, hooks is refused too, unless its program only reads or prints. Configuration already present when the session starts, set where the hook cannot see it, can still make a read run a program: git diff runs diff.external and textconv filters by default, and git status, diff and blame can run core.fsmonitor, filters, textconv filters and hooks, so a session whose configuration was prepared beforehand can still remove the registry through a read. A configuration file or hook written by a non-Bash tool, through a git directory that is not named .git, or by a git subcommand other than config that writes fixed keys (remote, branch, submodule) is not judged either. Any other git subcommand that names one of those keys or a separated git directory in any word is refused, a commit message naming core.worktree included. A command that changes the registry without naming it (a git command that writes or removes work-tree files, a checkout from a subtree, the removal of a parent directory, a copy of a directory whose contents hold a .aiqt directory, an archive or a patch, a script) is not predicted; instead, when the hook first sees a registry of the session repository or its main worktree bind review dispatch, it records the binding (its digest, the binding and the registry paths) in hook-owned state outside the work tree, GIT_COMMON_DIR/aiqt/review-dispatch-binding/KEY.json (KEY the sha256 of the worktree's identity, its git directory relative to the common git directory, paired with the registry's path relative to that worktree's top level, so a renamed or moved repository keeps its record; written without following a symbolic link, mode 0600), where no allowlisted git subcommand writes, and while a record exists a registry that is missing, unreadable, without a binding or binding differently withholds every dispatch as UNVERIFIABLE, naming the change and the record, until an operator restores the recorded binding or removes the record outside the session (the next check then records the binding in force); a record that cannot be read withholds every Bash call, and a binding that cannot be recorded withholds every dispatch. A process that removes both the registry and the record (an operator, a non-Bash tool, a plain command naming the record through a separated git directory not named .git) is not caught, and neither is a registry changed before the hook first saw it bind: a repository where no check ran while it was bound has no record, and where git cannot resolve the session repository no record is read. Ordinary git commands (add, commit, checkout, restore, reset) are allowed whatever the registry's git state; git clean is off the allowlist and refused. A linked worktree outside its main worktree, with no registry on its ancestors, is unscoped by a plain command that moves or removes its .git file (mv .git x), which is not seen either. Where git cannot resolve the session repository, or resolves one without a binding, the registry is looked for on the cwd's ancestors (so core.worktree cannot unscope a session); those registries are read in every session, and where one binds while the repository git resolves has a different registry that binds too (core.worktree moved onto another bound tree), the cwd's binding governs and every dispatch either binding declares is withheld; and with no resolved repository, or a resolved one that is not the registry's own (a nested repository, a moved top level), a found binding withholds every dispatch; the authority runs from the registry's worktree with every GIT_ variable removed from its environment; a dispatch is withheld while the environment sets a variable that moves the repository, index, object store or history git reads (GIT_DIR, GIT_WORK_TREE and similar), which the dispatch would inherit; an ancestor search that cannot be made withholds every Bash call. It checks the declaration and the repository, not what the worker reads: a brief that pins the right revision while the worker reads the ambient working tree or another path passes. Only a worker bound to a checkout of the pinned revision closes that gap. A brief marked as targeting a working tree, or as not a review, is allowed with a note and a guard-events row (where that append fails, the note says the row could not be written), so a misdeclared target bypasses the check. The authority is adopter code whose own correctness the hook cannot judge. A path the change needs that is missing from both the commit and the declaration (unless the brief names the worker draft that drafts it, below), an undeclared context file, uncommitted changes inside a checked-out submodule whose HEAD is at the pinned commit, a later commit that supersedes the pin, and edits to the brief or the tree between this check and the dispatch running are not caught. Where the binding declares the optional draft and skip labels (both or neither; one alone is a malformed binding), a revision brief may name the worker draft the commit applies, a git diff named by an absolute path and read once by this hook under max_brief_bytes (a path under /proc or /dev is read too, since the census is what the hook reads): the declared review set is then the paths the draft's headers name (diff --git, --- and +++, rename from and rename to, copy to; each hunk consumed by its own line counts, so a content line that reads as a header is content, and by git's exact no-newline marker only after the last line of each side of the file it marks, where its hunk header's count for each such side is met, in the section's last hunk; a section names at most one old and one new path, which its diff --git line names; a rename names both paths, a copy only its destination), less the paths the brief records as skipped, together with any declared path lines, so a drafted path neither committed nor recorded as skipped is refused and named as drafted. The draft is read conservatively, as the diff output git writes by default, so some forms git apply accepts but git does not write are refused (a localized marker, a context marker that only added lines follow, an added-line marker that more added lines follow, and a backslash line other than the exact marker right after a hunk's counted lines or their marker), while others are read (an added-line marker that a removed line follows, an empty line as context); after a hunk's counted lines, git's exact no-newline marker is read as the marker; any other line beginning with a backslash there is refused; a hunk header, a diff --git line or a --- line naming a path goes on to the next hunk or section; a diff --cc or diff --combined line is refused as a combined diff; a line that begins as another diff header is refused as outside a file section; a Content-Transfer-Encoding line naming an encoding other than 7bit or 8bit is refused as a transfer-encoded mailbox part; an inline submodule header (written by --submodule=log or --submodule=diff) is refused as a submodule shown inline; and any other line is read as prose. Whatever line follows it, each hunk is consumed by its header's counts, so no path is hidden. Among a hunk's counted lines, git apply reads a line beginning with a backslash and a space as a no-newline marker when it is at least eleven bytes long (counted in bytes before the line feed, so a short line of non-ASCII text can reach it) and rejects a shorter one as a corrupt patch; right after a hunk's counted lines it accepts any such line, reading one of eleven bytes or more as a marker and passing over a shorter one; the census refuses every such line other than git's exact marker in both places. Outside a file section a line is prose (the mailbox headers and message, the --- separator, the diffstat, the -- signature, a binary patch's encoded lines, blank and indented lines) unless it begins as a diff header or a no-newline marker does, and such a line (a +++ line, a hunk header, an extended header such as index or rename from, a binary marker, a line beginning with a backslash and a space, a diff command line other than diff --git) is never prose, wherever it stands, so a section cut short or mangled is not skipped unseen. A skip naming a path the draft does not name or one the commit changes, and skip lines with no draft, are refused; a second draft line, a relative draft path, an unreadable draft, an empty skip, a draft naming no file in a header, a section the census cannot read (a combined diff, a header path without git's a/ or b/ prefix in its place, as with git diff -R (a diff --git line naming b/ before a/ in a section with no --- line, which is a mode change, a binary change, an empty file, a pure rename or a pure copy, is refused with a reason naming the swap; a section with one is refused by its --- or +++ line's prefix), a --- line naming a path with no +++ line, a context-format section's --- line among them, a +++ line with no hunk, a section that ends with no hunk, binary marker, or creation, deletion, mode, rename or copy header, a rename, copy or mode header without its pair, a diff --git line naming two paths with no rename or copy header, ---, +++, rename or copy headers naming two old or two new paths or other paths than the diff --git line, an unreadable quoted name, a malformed or truncated hunk, a hunk line beginning with a backslash that is not git's exact no-newline marker after a context, removed or added line, a marker before the last line its hunk header counts for a side it marks, a hunk after a marker in its section, any other line beginning with a backslash right after a hunk's counted lines or their marker), a diff header or marker line outside a section, a header or hunk line that ends in a carriage return before its LF (a CRLF draft, or a hunk of a file whose lines end in CRLF, withheld with a reason naming CRLF), a mailbox part transfer-encoded other than 7bit or 8bit, and a submodule shown inline by --submodule=log or --submodule=diff are UNVERIFIABLE, each with a reason naming its cause; a mailbox message line that begins as a diff header or a no-newline marker does, or reads as an inline submodule header or a transfer encoding other than 7bit or 8bit, is refused with them, which can withhold a correct draft. The census reads paths, not content: a draft that names a file only in prose, a section indented from column 0 (prose, as git apply reads it), a partial apply inside one file (a hunk dropped from a file the commit still changes), and an apply whose brief names no draft are not caught. The census reads the draft the brief names, which is not proven to be the draft the commit applied: a trimmed copy, or a draft cut short between two complete sections, reads as a complete draft of fewer files. The working tree is compared by content, so index flags, the stat cache and filters cannot hide an edit, but a declared file whose checkout was converted (line endings or a filter) is refused. The whole check runs in a worker thread bounded by an 8 second budget and withholds the dispatch when it runs out, even when a read is blocked in the kernel. Submodule ignore settings are overridden for the changed set and staged state. A malformed binding or an unreadable registry refuses every Bash call, foreground or background, until an operator repairs it. Delivery acceptance is not checked. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 0.02 s of wall-clock time in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Those shapes are commands; a brief's label screen is timed apart (above): through the whole check, a 1 MiB brief took up to 0.48 s, and a 16 MiB brief, the max_brief_bytes ceiling, up to 7.1 s, in which one pass over the text held the interpreter for about 1.4 s (the same host and date), so on a slower host such a brief can reach the deadline. Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:review-workdir-hold
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Bash
  • Class: c
  • Channel: plugin

Technical limits (from the enforcement ledger)

ADOPTER-ARMED and otherwise INERT: the hold is a marker file the dispatcher writes in GIT_COMMON_DIR/aiqt/review-holds/ before the reviewer starts and removes when the review returns or is abandoned; where no marker directory exists the hook is a silent allow, so adopters who write no markers see nothing, provided the payload's cwd is absolute: a cwd that is missing, relative, carries a NUL byte or names no directory that exists fails closed (UNVERIFIABLE) for every git command outside the read-only grammar and for an absent command, in the standalone preflight as in the composed hook, and so does any consulted directory whose git identity probe cannot answer (git could not be run, timed out, ran past the decision deadline, exited nonzero or on a signal with anything but its specific not-a-git-repository answer, a dubious-ownership fatal included, or exited 0 with a reply that is not EXACTLY the expected two newline-terminated path lines with the first line absolute, empty, one line, carrying extra lines, missing its final newline, an echoed option or a relative first line, which would otherwise build an invented identity whose absent marker directory reads as clean, or answered a core.worktree lookup with neither the set nor the unset reply or with an ambiguous one, an exit-0 reply that is empty or not newline-terminated, a value empty once its final newline is removed, or an exit-1 reply carrying output, each refusal naming the configuration cause rather than the identity probe; a candidate path realpath cannot even process is the same probe error): the probe failure is never read as 'not a repository', which would hide a live hold there (the holds are exactly what cannot be read, so this fires even where no live hold is known), while git's specific not-a-git-repository answer, matched at the start of one of the probe's own C-locale stderr lines (never as a substring a path in the message could echo), stays the inert allow; a hold-set candidate path is resolved PHYSICALLY first (os.path.realpath, so a '..' component and every symlink are applied the way the kernel would apply them, never stripped textually) and one that names no existing directory is consulted at its NEAREST EXISTING ancestor, so a directory the command itself creates first, a '..' spelling through it, and a write operand's file path, still read the markers of the repository each lands in; a candidate whose existence cannot be read at all is probed itself, failing closed; a PROCESS-RELATIVE candidate in any structural position (an operand, an option value, a -C, cd or pushd target, a --git-dir or --work-tree value, a GIT_ assignment, an ln operand: /proc/self/..., /proc/<pid>/..., /dev/fd/..., judged on the LITERAL components with a run of leading slashes collapsed first, so a '//proc' spelling and a '..' spelling a textual collapse would erase are read as the kernel reads them) cannot be resolved by the hook at all, its meaning depending on the process that opens it, so it counts as held and withholds the command outright (the read-only grammar still passes such a read), while one seen only by the raw-text absolute-path widening withholds while a hold is live, naming the process-relative cause; an ln segment carrying any option word other than -s, -r, -sr, -rs, --symbolic or --relative (another cluster such as -sf, -t or --target-directory, -T, -n, or an unknown option) leaves where that link lands or points unmodelled, so it withholds every git-word command in the same Bash call while any hold the rule can see is live, its operands still consulted under the modelled readings at once, for every operand count (every operand at the directory the segment runs from, every operand before the last, a target, also at the last operand and at the last operand's parent, and a two-operand link recorded for the rewrite even over an existing directory, since -n and -T read a link name that points to a directory as the name itself, not as a directory to enter), except that a -t or --target-directory value, the link's directory in that form, is never read as a target base because the option is not paired with its value (an allow residual listed below), and with no live hold in any consulted repository the decision is the inert allow, unchanged for adopters who write no markers; and a marker-directory path that exists but does not lead to a directory (a dangling review-holds or aiqt symlink) is malformed marker state, never absence. There is no host-wide view of holds: the hook reads only the markers of the repositories a command names or sits in. NO-ASK posture: block or note only. The rule is ONE-PLAIN-GIT-or-fail-closed, judged from the RAW command text, so shell grammar is never modelled construct by construct. Every command is judged against its HOLD SET: the repositories of the session cwd, of every literal path the raw text names after -C, --git-dir, --work-tree (the = and the separated form), cd, pushd or a GIT_ assignment, with one level of surrounding quotes stripped (quoted sh -c bodies, heredoc bodies and option values included), of every other literal ABSOLUTE path any segment's tokens carry (a bare operand, the payload after the first = of any token, or an attached short-option value), of every operand of an ln segment (the name of a link the command creates resolved against the directory the segment runs from; a relative TARGET of a plain symbolic link (-s without -r) resolved against the link's own directory, where the kernel resolves it, and that of a hard link or a -r, -sr or --relative link resolved against the directory the segment runs from, where ln itself opens it; the plain two-operand link recorded so a later candidate stepping through it is also consulted where the link will point, one rewrite level in text order, matched on the path's text; an operand no literal text resolves withheld while a hold is live; and any other ln option word withheld while a hold is live, as stated above), and of every literal ABSOLUTE path anywhere in the raw text (a quoted shell body or heredoc body included, a run of leading slashes collapsed to one, and a QUOTED span that is one whole absolute path read whole with its spaces; a path glued to a word is left to the token scan, and an unquoted path containing spaces is truncated at the space, a disclosed residual), every candidate resolved PHYSICALLY first, a missing one consulted at its nearest existing ancestor, the operand widening additive only (a write operand never becomes a resolution base), and each expanded through its configured core.worktree (a lookup that cannot answer or answers ambiguously, and a configured worktree that is process-relative, fail closed naming the configuration cause; a set value is kept byte-exact apart from its final newline, so a configured path carrying whitespace is consulted where git writes) (a repository-file core.worktree adds the configured worktree's repository to the set, reaching a mutation hidden behind a wrapper or override). While ANY repository in that set has a live hold, ANY command whose raw text carries a git word (the word git, the git of a git-<name> executable, the git ending an interpreter path such as /usr/bin/git), anywhere, inside quotes, substitutions, heredoc bodies and option values included, is denied (a git segment carrying a program-running form with a plain reason, see PROGRAM-RUNNING FORMS; anything else with an UNVERIFIABLE: prefix), EXCEPT one plain git segment: exactly one segment, no wrapper or reserved word before git (a bare command/exec/builtin/env/sudo prefix is peeled by the shared peel), the command word the literal word git, and no $(, backquote or heredoc operator anywhere in the raw text. That one plain segment passes only when it is the CLOSED LITERAL READ-ONLY GRAMMAR: no leading assignment of any kind (a GIT_CONFIG_*, GIT_EXTERNAL_DIFF or any other inline override can make a listed read verb run a program or re-point its repository), the literal word git first, no git global option at all except a literal -C directory (-c/--config-env, --git-dir/--work-tree, --exec-path, -P/--no-pager and every other global spelling drop the pass), a subcommand on the ENUMERATED read-only allowlist in a non-mutating form (a verb outside the allowlist, read-tree, checkout-index, bisect or sparse-checkout included, is treated as mutating, and git config edit is a write), and only options from the verb's CLOSED per-verb read-only set, which names nothing that writes a file (--output), runs a program named in the command or in configuration (-O/--open-files-in-pager, --upload-pack, --ext-diff, --textconv, --show-signature, tag -v, the --format/--pretty/--sort families, whose signature placeholders and atoms can run the signing program) or edits configuration; read-only git so judged is never withheld by a hold, everything else is judged against the hold set, and a command also passes when its target is CERTAIN and resolves outside every held worktree: only literal -C moves resolved physically (os.path.realpath), no --git-dir/--work-tree or -c/--config-env override, no GIT_ variable assigned, exported or unset in the command, none of the inherited variables GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY, GIT_ALTERNATE_OBJECT_DIRECTORIES, GIT_QUARANTINE_PATH, GIT_NAMESPACE, GIT_REPLACE_REF_BASE, GIT_GRAFT_FILE or GIT_SHALLOW_FILE set in the hook's own environment (EXACTLY that list: another inherited variable, the GIT_CONFIG_* family included, does not block certainty, a disclosed limit), and the resolved repository's configuration setting no core.worktree. A certain target that IS a held worktree is DENIED; worktree identity is compared by git's absolute git directory on physically resolved paths (never by path string); a certain target whose repository TOP LEVEL (rev-parse --show-toplevel, never the -C directory alone) ENCLOSES a held worktree on disk (a worktree nested under another, as with worktrees kept in a subdirectory of the main checkout) is denied for every command outside the read-only grammar, since a whole-tree mutation of the enclosing tree (git clean -ffdx removes a nested worktree the way the denied worktree-remove would, and run from a subdirectory a :/ or .. pathspec still sweeps the whole tree) can delete or rewrite the held directory, a safe over-deny for the forms that cannot reach it, and a target whose top level git cannot report (a bare repository, an unreadable tree) fails closed as UNVERIFIABLE while a hold is live; 'git fetch', 'git pull', 'git push' and 'git send-pack' whose certain target is any worktree of a repository with a live hold are denied outright (an explicit refspec reaches any ref of that repository, the held branch included, --update-head-ok lifts git's own checked-out-branch refusal, and send-pack is push's plumbing, limited only by the receiving side's own refusal, which configuration can relax); and 'git worktree remove/move' of the held worktree (by git's last-components name shorthand or exact path BEFORE any path resolution, since git prefers the registered name over an ordinary directory of the same name, and by resolved identity after it) and 'git update-ref' or 'git symbolic-ref' writes of its HEAD, its cross-worktree worktrees/<id>/HEAD and main-worktree/HEAD paths, or ANY ref in its symbolic HEAD chain (the checked-out branch included; a delete in any accepted spelling, the --del abbreviation, clustered forms such as -qd and -dm, and clusters whose m consumes a reason, attached or separated, included) are denied from any worktree of the repository, where update-ref's written ref is resolved through its own symbolic chain in the target repository unless --no-deref is given, so a different chain converging on the held branch is denied too; a worktree remove/move operand that resolves to no worktree, an update-ref or symbolic-ref option cluster whose written operand cannot be determined, an update-ref batch, and a written ref whose symbolic chain git cannot be asked about are denied as UNVERIFIABLE while a hold is live. PROGRAM-RUNNING FORMS: while ANY hold the rule can see is live (in the hold set, the session's repository or the target's repository), every git segment carrying a form that runs a program or shell text is DENIED with a plain reason, whatever its target and without resolving where the program runs: any leading assignment, any global option other than a literal -C, the curated program-option model (--upload-pack, --receive-pack, --exec, rebase -x, grep -O, difftool -x, submodule foreach, bisect run, filter-branch, signing options, a TRANSPORT::ADDRESS URL, --help and the rest), the subcommands mergetool, difftool, instaweb, web--browse, hook, shell, daemon, credential, send-email, imap-send, svn, verify-commit and verify-tag, for-each-repo (it runs a git command in every repository a configuration key lists, and an allowed command can write that key first) and maintenance (register and unregister write that very list, start and stop run a platform scheduler over it), the connection services upload-pack, receive-pack, upload-archive and http-backend (each runs in the repository its operand or request names; receive-pack runs that repository's hooks), every config form outside the read-only grammar, clone and init --template, and help --web, --info and --man; and a git segment carrying a FILE-WRITING PATH OPTION is denied the same way, whatever the operand names: checkout-index --prefix (it re-roots the whole checkout under any prefix), the --output of the diff and log families (diff, log, show, whatchanged, stash, the diff-files/diff-index/diff-tree plumbing, range-diff, rev-list, shortlog and reflog), archive -o/--output, format-patch -o/--output-directory, bugreport and diagnose -o/--output-directory, index-pack -o, fast-import --export-marks and --export-pack-edges, fast-export --export-marks, init and clone --separate-git-dir, credential-store --file, and mailsplit -o, plus the two options that ADMIT write paths carried in the command's INPUT rather than in an operand, apply --unsafe-paths (the patch content can name paths outside the target tree, which git itself refuses without the option) and fast-import --allow-unsafe-features (the stream's own export-marks/export-pack-edges features name any path, which git refuses without it), since such a path is not a directory move (or is not in argv at all), so where it lands is not resolved, and from an unrelated unheld target it can still write into the held worktree. HELD-PATH OPERANDS: while any hold the rule can see is live, a git segment carrying a literal ABSOLUTE option value or operand that resolves physically to a held worktree or strictly inside one is denied too, whatever the word means to its verb (a file-writing option outside the enumerated tables, or a path-writing positional operand, can land a write at that path, and telling it from a read spelling would mean re-implementing each verb's grammar), a stated over-refusal for the spellings that only read, with a -C operand into the held worktree reported with the direct hold reason rather than this gate's over-refusal text; a command in the closed read-only grammar passed before this gate and is unaffected, and every such literal absolute path also JOINS THE HOLD SET (resolved physically, a missing path at its nearest existing ancestor), a quoted shell body or heredoc body included, so the hold it names is seen, and these gates fire, from a session outside every held repository too. An unrelated rebase -x, a harmless leading assignment, a git config write in an unrelated repository and an --output into the unrelated target's own tree are refused too while some hold is live, a stated over-refusal. STATED OVER-DENIAL: while a hold-set repository is held, a wrapped or quoted read-only git (timeout 5 git status, sh -c 'git worktree list', env X=1 git worktree list) is DENIED, not passed: the raw-text rule classifies no shell bodies, so only the one plain git segment earns the read-only pass, and any other text carrying a git word, however read-only or data-only (a message or path operand that merely names git included), fails closed until the hold ends. The closed grammar over-refuses the same way on a held or uncertain target: a read with ANY leading assignment (X=1 git -C <held> status), with any global option other than a literal -C (git -P, git --no-pager, git --literal-pathspecs), or with a read option the verb's closed set does not name (an unmodelled flag, an attached-value short such as -U5 or -n5, a pickaxe -S/-G spelling) is withheld while the hold is live, as is every mutation whose target sits anywhere under a tree that encloses a held worktree (a scoped git clean in a subdirectory beside the nested hold included) and every certain-target command whose repository top level cannot be read (a bare repository), and a command whose raw text names a held worktree its resolved target does not account for (a held path carried in a program-running option value such as --upload-pack, grep -O, rebase -x, submodule foreach or difftool -x, or in a -C chain step that is not the resolved target), since the one-plain-git-on-a-certain-target reasoning cannot show that held worktree is left untouched; each is a safe over-refusal taken instead of under-blocking, and none fires with no live hold. ALLOW RESIDUAL, the shapes this BEST-EFFORT guard is known not to catch from a session outside every held repository whose command text names no held path (those found so far, not a proof that no other shape escapes); judge the hook against this claim, since no further widening rounds are planned against these: with no host-wide view of holds, a program-running form whose program reaches a held worktree only through a path the hold set does not resolve (a symlinked -C into a subdirectory, a submodule foreach program, two relative steps in a row) is ALLOWED from a session outside every held repository when no other hold is seen. The hold set is built from literal text, so a path the shell builds at RUN TIME (an operand only an expansion resolves: a -C or cd path containing '$' or a backquote, a glob or brace character, a leading '~') names no repository, and from a session outside every held repository such a command stays ALLOWED even where the expansion would land in a held worktree; a RELATIVE path inside a quoted body that no -C or cd spelling introduces, an attached short-option value that appears only inside a quoted body, the fragment a quote-spliced operand leaves, and an UNQUOTED path containing spaces in raw text (truncated at the space; only a quoted span is read whole) join no hold set or join it at the wrong directory, the same residual; a link created by a LATER command or by another tool is not modelled, and the created-link rewrite reads only links the SAME command creates, one rewrite level, in text order, matched on the path's text, so a step through such a link reaches the directory it points to with that directory's holds unread when it goes through a chain of two created links, when a loop runs a later segment first, when the step spells the link's path another way ('.', '..' or another link in it), when the link is created INSIDE a directory that already exists (a link is recorded under its own name only), when the command already named the step's path earlier, and when the rewritten path is process-relative, which is dropped from the hold set rather than refused (a link to / and then -C <link>/proc/self/cwd/...); a relative target of a plain symbolic link (no -r) made with ln -t or --target-directory is resolved by the kernel against the option's directory, which the hold set never reads as a target base (the option is not paired with its value), so ln -s -t <dir> ../held followed by git -C <dir>/held reset reaches the held worktree with its hold unread; a symlink already on disk that points through a process-relative magic link is resolved by the hook's own process when a candidate traverses it, which can differ from git's resolution; and a git reached through an opaque program name (a wrapper script, an alias, a shell function) carries no git word at all. An unreadable or malformed marker directory (a non-marker entry, a symlinked entry, wrong ownership, bad JSON, missing or malformed required fields, a review-holds path that exists but is not a directory, and a dangling review-holds or aiqt symlink, which raises the same lookup error as absence but still exists), in any consulted repository, denies git-word commands other than the one plain read-only form; non-git commands pass, and a dry-run spelling of a mutating verb (git clean -n, git commit --dry-run) is read as mutating, a safe over-deny. An EXPIRED marker (expires_utc is required) is ignored with a note, so a crashed dispatcher cannot pin a worktree forever; no maximum lifetime is enforced, so a far-future expiry pins its worktree until the marker is removed. A live marker whose recorded head differs from the held worktree's current HEAD surfaces the drift note (re-dispatch per rvwpin.4) on each check WITHOUT denying, which catches a move made outside the session only after the fact, on the next hooked Bash call. DISCLOSED RESIDUALS, the remainder of the stated claim above: movers the hook cannot see (a resync from cron, another terminal or session without the plugin, any worker or tool that does not pass through this PreToolUse Bash event) are caught only by the drift note and are prevented only where the adopter's tool runs the preflight (pipe the PreToolUse payload to aiqt_hooks.py review_workdir_hold); indirect git (an alias, a shell function, a script, or git configured to run programs such as a post-checkout hook or core.fsmonitor) is not seen, and a relative cd resolved through CDPATH can land in a repository the hold set does not name; the closed per-verb option sets drop --ext-diff/--textconv/-O, but a read verb still runs any program git's configuration wires to its DEFAULT form (diff.external and textconv for diff, log -p, show and blame, a configured pager) with no option at all, since the rule judges argv, not configuration; a read verb the grammar passes may still write git's on-disk index, since git status and other reads refresh the held worktree's index stat cache in the background, a metadata-only write read as read-only because it changes no tracked content; FILE EDITS inside a held worktree (Write/Edit tools, sed -i, cp) are covered by the rule but not by this hook (extending write-scope is a separate follow-on), a shell redirection (> or >>) is stripped from the one plain git segment before the grammar reads it, so a read verb redirected into a file inside the held worktree writes it outside the grammar's view, the same file-edit residual, and the EXACT remainder of the file-writing class is in that residual too: a file-writing option outside the enumerated tables, or a path-writing POSITIONAL operand (worktree add, the clone and init directory operands, bundle create, pack-objects' base name, merge-file, mailinfo), whose operand is RELATIVE or built by an expansion ($, a backquote, a glob or brace character, a leading ~), since such an operand cannot be told from a ref or pathspec without re-implementing each verb's grammar, while the enumerated file-writing path options are refused whatever their operand, the input-path admissions (apply --unsafe-paths, fast-import --allow-unsafe-features) are refused while any hold the rule can see is live, so a write path carried in patch or stream content cannot escape the target tree where a hold is visible, while from a session outside every held repository whose command text names no held path the content is unread, the hold unseen and the admission not refused, the same residual, and the held-path gate refuses every literal absolute operand that resolves into a held worktree, from a session outside every held repository too, since the operand itself joins the hold set; marker accuracy is the adopter's (a missing marker, an early removal, a wrong workdir, a review outlasting expires_utc); a race between the check and the command running remains; and the marker directory is writable by the assistant, so a determined actor can remove a hold, the same suite-level limit ORCHESTRATION.md already discloses. Implementation residuals: the held BRANCH is pinned against the ref writes named above and against fetch/pull/push/send-pack anywhere in the held repository, and a certain target in ANY worktree of the held repository is withheld for every form outside the read-only grammar, so a 'git switch --ignore-other-worktrees' checkout or a 'git branch -f' from a sibling worktree is withheld too; what can still move the held branch is a push from ANOTHER repository naming this one by URL or relative operand (a URL or relative operand joins no hold set; an absolute path operand joins it, but a hold reached only that way does not withhold a certain unrelated target with no unaccounted directory step, and a push operand naming the held worktree's own path is refused by the held-path gate), surfaced after the fact by the drift note; a held worktree nested under a worktree of a DIFFERENT repository is protected only where the command's hold set names the held repository (the session cwd or a literal operand), since a mutation bound to the enclosing foreign repository alone reads no marker of the nested one; a git too old to answer --no-recurse walks only the terminal of a symbolic-ref chain, and one too old to answer --git-common-dir echoes the option back on exit 0, an unreadable reply that now FAILS CLOSED like every other probe failure (earlier releases read it as inert); only git's specific not-a-git-repository answer, matched at the start of one of the probe's own C-locale stderr lines, reads as an absent repository, and a hold-set candidate is resolved physically first, a missing one consulted at its nearest existing ancestor; HOME is not resolved, so a bare 'cd' contributes no hold-set entry; and the core.worktree expansion chain is bounded, so an absurd or cyclic configuration chain stops expanding at its bound, under-extending only what the chain itself added, never a candidate the command names. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; its git identity probes are capped and cached per check. Interpreter start-up and a wait the interpreter cannot interrupt fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:secrets-shift-left
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Write|Edit|MultiEdit|Bash
  • Class: c
  • Channel: plugin

Technical limits (from the enforcement ledger)

A COMPENSATING control, not a replacement: the CI secret-scan and gitleaks gates remain the broader backstop, and this layer moves the same high-signal detection to the moment a secret would be written, so an accidental paste is caught before it lands on disk rather than only at commit or CI time. It SINGLE-SOURCES its shapes from tools/check_secrets.py (the pack source of truth), rendered into the handler by tools/gen_secret_patterns.py and drift-gated so the two can never fork: the recognizable provider-token PREFIX shapes (GitHub, GitHub fine-grained PAT, OpenAI-style, OpenAI project key, Anthropic, AWS access key id, Slack, Slack app-level token, a private key block header, a JWT, and (GD-121) Google API key, Stripe live and test, GitLab personal access token, SendGrid, npm, PyPI upload token, and a Slack incoming-webhook URL), the credential-named ASSIGN shape (a passwd/password/secret/token/api-key/access-key/client-secret/auth-token/private-key/credential variable assigned a literal of real length), and (GD-121) an ENTROPY-GATED generic-assignment shape: a token of 40 or more characters (Shannon entropy at least 3.5 bits per character judged on its first 150 characters, letters and digits required) assigned to a generic credential-like name matched on EXACT components (key/auth/token/secret/passphrase/creds/... so apiKey and client-secret match while monkey and author do not), with placeholders and environment lookups excluded before the entropy test and a metadata-named left-hand side (id/name/version/url/path/public/...) excluded. It EXCLUDES obvious placeholders (the shared PLACEHOLDER shape: your/my/the-prefixed names, change-me, example, sample, dummy, redacted, fake, test, todo, none, null, n/a, an angle-bracket or dollar-brace or dollar-VAR template, and runs of x/dot/star), and for an UNQUOTED assignment value it additionally requires both a letter and a digit, exactly as check_secrets.py does, so ordinary prose does not trip it. It scans the content a Write would write, the new_string an Edit would introduce, the concatenation of the new_string values a MultiEdit would introduce across its edits, or the command string a Bash call would emit, and DENIES on a real match, naming only the pattern label and never echoing the secret value. DISCLOSED non-catches: a KEYWORD-FREE high-entropy value (an opaque = <hex> with no credential keyword near it): both this scanner and gitleaks miss it (R1, the measured residue of 2026-08-08); a generic-keyword value of 12 to 39 characters, below the local 40-character floor, which gitleaks can still flag (R3, a deliberate noise-control choice confirmed against pinned gitleaks 8.30.1); a real secret held under a metadata-named variable such as public_key or key_path, excluded here in the safe direction where the CI scan still catches it (R4); a secret written by a tool other than Write, Edit, MultiEdit, or Bash (other content tools such as NotebookEdit are out of scope, routed to a periodic pattern review); a secret split across tokens or lines or assembled by concatenation, since the scan is per-line and lexical; on the Bash path the command string is scanned as RAW TEXT (it is not shlex-tokenized and is not executed), so a secret assembled by concatenation or supplied through a shell variable or expansion is missed, but a redirect or an embedded # does NOT cause a Bash-path miss; the ASSIGN shape also does not match a JSON-style quoted credential key ("api_key": "...") or a triple-quoted value, a curated-pattern-set format-completeness gap routed to the periodic pattern review (backlog L12); a deferred provider family (a Twilio-class short-prefix-over-hex or a Telegram bot token, left to gitleaks, R2); an unquoted value beginning with an environment-lookup root such as process.env., treated as a reference not a literal (R6); and a base64-encoded or otherwise obfuscated form. gitleaks in CI is a BROADER independent scanner and a stronger net, but it is NOT exhaustive (it too misses the keyword-free entropy class R1), so it is a compensating layer, never a guarantee. It is BEST-EFFORT: it targets the accidental paste or commit, not an adversary deliberately encoding or fragmenting a secret to evade it. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 0.29 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.
hook:write-scope
  • Platform: claude-code
  • Default: block
  • Entry point: PreToolUse on Write|Edit|MultiEdit
  • Class: c
  • Channel: plugin

Technical limits (from the enforcement ledger)

A PATH guard, not a content judge: it decides where a write lands, never what it writes. Covers only PreToolUse Write, Edit, and MultiEdit calls carrying a readable file_path. TWO arming inputs, each fail-open on genuine ABSENCE only: the per-slice declaration write-scope.json, read at the orchestration registry-declared state_dir (resolved from the registry the guard reads EXACTLY ONCE, via the pure _state_dir_from_registry helper, never a second independently-faulting read; out of the slice tree by default but MAY be in-tree when the registry declares an in-tree state_dir), arms SLICE CONFINEMENT; the in-tree committed frozen floor .aiqt/frozen.json (generated by tools/gen_manifest.py from the ownership classification, drift-gated) arms the FROZEN layer. The structural other-repo/nested-repo denial applies to every covered write whose repository root resolves; the frozen-floor denial fires whenever a floor is PRESENT, and an armed session additionally requires one, so neither is unconditionally always-on and only an un-armed session with a genuinely-absent floor leaves the frozen layer inert. Slice confinement is fail-open on a missing declaration. The principled fail-open is genuine ABSENCE (of a declaration or floor: no confinement in effect, the same inert boundary gensrc and, by default, the orchestration suite use, whose truncation guard denies an absent registry in the opt-in registry-required mode); a cannot-evaluate FAULT is not absence and denies (see the fail posture below). The registry that LOCATES the declaration is part of the read: a bad or unreadable registry (an lstat fault, a read/parse error, a non-version-1 object, or a present-but-malformed state_dir) is a cannot-evaluate that DENIES rather than silently falling back to XDG and disarming confinement. Out-of-scope is a DENY, never an ASK (an ASK would hand the confined actor the click-through on its own confinement and would wedge an unattended run); the remedy is the harness re-declaring the slice, so scope-widening stays on the harness side, never a guarded-tool call. The declaration is machine-set inert data, never authored by the assistant through a guarded tool; the residual Bash-write path to the state dir remains, so the harness-set integrity is defence in depth, not categorical. Fail posture, by regime: a MISSING tool_name denies (the shared fail-closed contract); a malformed tool_name, an unreadable tool_input, a missing/empty file_path, or a control-character file_path denies as a malformed call in either regime; a covered write whose session repository root cannot be resolved (a missing/unreadable cwd or a non-git session) DENIES fail-closed in both regimes, because the root is resolved FIRST and containment cannot be evaluated without it, so this denial precedes any relative-path handling (a non-git relative-path write DENIES, it does not defer); only WHEN the root resolves does an un-armed relative file_path defer to the sibling absolute_paths hook (which owns the relative case, EN-8 making no cwd-trusting determination) and allow, while an armed relative file_path denies; an un-armed realpath/containment/nested-repo-probe FAULT now DENIES too (a cannot-evaluate: the guard cannot prove the target is in-repo and not nested, so it denies rather than allowing an unverified write), as the same fault does armed; a target proven OUTSIDE the session toplevel, or in a NESTED or FOREIGN git repository under or beside it (resolved via the scrubbed git toplevel of the target nearest existing ancestor, every ambient GIT_* removed so a decoy repo cannot redirect the read), denies in BOTH regimes UNLESS the target repo is an adopter-DECLARED COMPANION STORE (the sanctioned cross-repo path below); a present frozen floor with the target on it denies in both regimes (the floor outranks the allowlist, deny over allow), a present-but-malformed floor denies (cannot-evaluate), and an ABSENT floor is inert un-armed but denies while armed (an armed session must commit a floor, an explicit empty list being valid, so a Bash-deleted floor cannot silently downgrade an armed session); armed, a declaration that is unreadable, malformed, unknown-version, or whose worktree_root does not resolve to this repo (a stale, copied, or moved declaration) denies; an allow entry wholly on the frozen floor makes the declaration malformed and denies (a declaration cannot declare past the floor); a target in repo, not frozen, and in the declared slice scope allows silently, while one not in scope denies. Every deny appends a guard-events row (the over-fire metric) where the root is resolvable (with no resolvable root no row is attempted); a failed append leaves no row and adds a short recording-failure warning to the deny reason and the banner, never blocking or altering the denial. Matching is on canonicalized real paths with component-boundary tree containment (the gensrc idiom), never a raw string prefix; the declaration and floor share one entry grammar (a repo-root-relative POSIX path, a trailing / marks a tree, no absolute/backslash/./../control-char/wildcard component). The floor is enforced against the manifest CLASS set {derived, manifest-self, archive}. The RUNTIME value of that floor is un-lowerable by the constrained actor through the guarded tools (a guarded write to .aiqt/frozen.json is itself on the floor and denied); but its DEFINITION (FROZEN_CLASSES in tools/gen_manifest.py) is generator source that lands through pull-request review PLUS the drift gate, which detects an INCONSISTENT floor (a committed frozen.json that does not match a fresh regeneration), NOT an unauthorized-but-internally-consistent redefinition of the class set followed by regeneration; an independent floor-authorization mechanism is out of scope for this hook. The floor proves membership in its declared selector set only, not that the generation-time class mapping is semantically complete. Class c (partial), matching the sibling gensrc_guard: the control fires deterministically inside its Write|Edit|MultiEdit matcher, but the rules it enforces span write paths the matcher cannot see (Bash-mediated writes, NotebookEdit, filesystem MCP tools, any write outside the platform hook path), and its slice-arming state is machine-local, so CI cannot observe the confinement. The committed frozen floor .aiqt/frozen.json is a generated, drift-gated output backstopped at merge by the manifest-gen-drift gate (tools/gen_manifest.py --check), the gensrc-registry-drift gate (tools/gen_gensrc.py --check), and the independent manifest-integrity verifier (tools/check_manifest.py) (defence in depth, each catching the other residual; these CI gates are not removed or softened), but nothing backstops an uncommitted, out-of-scope-mutable, or cross-repository write through the uncovered channels. Realpath closes symlink escapes present at check time but not a post-check swap, hard-link alias, bind-mount change, or case-fold/Unicode alias between the lstat probe and the tool write (TOCTOU); an OS-level write sandbox is the stronger overlapping control where available. It is single-author by design: exactly one declaration, one writer, harness-replaced; it is confinement of the sole writer, not parallel-write collision arbitration. SANCTIONED COMPANION-STORE declaration (adopter-controlled config): the orchestration registry MAY declare companion_stores, a list of ABSOLUTE paths to git repo TOPLEVELS beside the session repo (for example the durable store the sole orchestrator owns, which is by design a SECOND git repo next to the code repo). A covered write whose target resolves - by EXACT repo-root match, the resolved git toplevel of the target EQUALLING a declared store root, never a prefix/substring - into a DECLARED companion store is ALLOWED and AUDITED (a guard-event row, kind wrtscp, decision allow, naming the declared store; where that append fails the write is still allowed, with a note saying the row could not be written) rather than denied, so the single most routine legitimate cross-repo write no longer inverts to deny-the-audited-path while the Bash residual stays open. The hook only HONOURS a validly-declared store, it never self-widens: per guard-input-soundness a declaration is honoured only if it is a non-empty absolute string resolving to a REAL git toplevel that IS the declared root itself; a MALFORMED, unresolvable, non-repo, or non-root declaration is fail-closed (silently dropped, the cross-repo write still DENIES), a bad entry can only remove a would-be allow and never open a hole, and the exact-root match means a write into a repo NESTED inside a declared store, or a sibling merely beside it, does NOT match. Declaring a store never defeats the frozen-floor or the nested-in-session denials. Declaring a companion store is a GUARDRAIL-CONFIGURATION change: companion_stores widens where covered writes may land (a cross-repo scope widening), so it is under the SAME human-authorization discipline as any other scope widening (guardrail-config-integrity) - the harness/adopter sets it, never the constrained actor. For that reason the orchestration LOCAL registry (.aiqt/orchestration.local.json) is on the hook-side ALWAYS-FROZEN set: a covered Write/Edit/MultiEdit to it is DENIED in BOTH regimes and INDEPENDENT of the committed frozen floor, so the actor cannot self-declare a companion_stores widening through a guarded tool (the general Bash-write path stays the disclosed residual). The companion_stores declaration is honoured only inside a VERSION-1 registry OBJECT: a registry with a missing or non-1 version reads as bad (a cannot-evaluate), so a declaration lacking "version": 1 yields NO stores and the cross-repo write FAILS CLOSED (denies), never silently taking effect. The declaration lives in the adopter/harness registry surface (exactly as state_dir does); this path guard reads it, it does not police who wrote the registry. DEADLINE: main() gives this check 8 seconds (the 10-second hook timeout less 2), counted from before its payload read, and fails closed with exit 2 when it has not decided by then; on the worst shapes the hooks self-test times, up to 1 MB, its slowest call took about 0.03 s in-process on a 24-core host with Python 3.14.4 (one run on 2026-10-18, including the shapes whose comment reading depends on context). Interpreter start-up and a wait the interpreter cannot interrupt (a read from a hung mount, a long call into C code) fall outside that deadline, and whether the platform blocks a call whose hook it stops at its own timeout is unverified. Where SIGALRM, setitimer or the main thread is unavailable, or SIGALRM is blocked, the check runs with no deadline; cleanup that runs while the deadline unwinds is outside it.