vUnreleased
Changed — blocking is now opt-in (BREAKING for anyone relying on the old default)
- ▸`atr guard` no longer emits a `permissionDecision` unless blocking is enabled. The Claude Code PreToolUse payload now carries the detection (
atr_decision,atr_reason,matched_rules,atr_advisory: true) and drops thehookSpecificOutputenvelope entirely, so the host applies its own permission flow.toClaudeCodePostToolUselikewise omitsdecision: 'block'(its envelope is kept: a permissive verdict has always emitted it with no decision inside). The whole envelope goes rather than just the decision field as a legibility choice, not a compatibility one. Both shapes are in fact accepted: the hook-output schema in the shipped Claude Code 2.1.76 bundle declareshookSpecificOutputoptional, its PreToolUse member declarespermissionDecisionoptional, and the object is not strict, so unknown top-level keys are stripped rather than rejected — which is also whyatr_decisionandmatched_ruleshave shipped alongside it all along. A payload with no envelope simply cannot be misread as a decision that failed to serialise. - ▸ATR never emits `permissionDecision: "allow"` — in either mode. This is wider than the blocking switch and is the part most likely to surprise: turning blocking ON does not bring the affirmative decision back. In the PreToolUse contract
allowis not neutral; it is an approval that suppresses the host's own permission prompt, so a hooked session would permit *more* than an unhooked one on every operation ATR simply had not looked for. "No rule matched" is ATR having nothing to say, and the way to say nothing on that channel is to omit the field. A decision is therefore emitted only for the two verdicts that restrain —denyandask— and only when blocking is on. Everything else travels inatr_decision/atr_reason/matched_rules. Consequence for the payload shape: with blocking ON and a permissive verdict thehookSpecificOutputenvelope is dropped too, exactly as in advisory mode.atr_advisory: truemarks the *mode*, not the verdict, so it is the one key that distinguishes the two: - ▸BREAKING for embedders: the library no longer reads the environment at all.
ATR_LANEandATR_BLOCKINGare CLI-only.ATREngine,ActionExecutorandHookHandlertake their posture from explicit config or the built-in default, and nothing else. If you embedded ATR and were relying on a variable in the ambient environment to select a lane, that no longer works — passnew ATREngine({ lane })instead. - ▸Scope limit, stated so the headline is not read wider than it is. This covers the two channels the engine itself drives: the Claude Code hook contract and
ActionExecutor. It does NOT cover the framework adapters, which keep their own severity floor:src/adapters/mastra.tsdefaultsblockSeveritiesto["critical", "high"], and theopenshell-filterandnemoclaw-preflightCLI entry points defaultATR_MIN_SEVERITYtohigh. (TheNemoClawPreflightandOpenShellFilterclasses take no default at all — an embedder must passminSeverity. Only the CLI wrappers supply one.) - ▸An unrecognised `ATR_LANE` or `ATR_BLOCKING` warns on stderr, falls back to the safe default, and the guard keeps running (exit 0).
maindid not read either variable, so a typo was previously inert. An earlier revision of this branch threw from the constructor instead, which measured badly:ATR_BLOCKING=enabled npx atr guardexited 1 with an empty stdout and no guard running — landing hardest on the operator trying to turn enforcement ON.atr initinstallsatr guardas a PreToolUse command hook, and Claude Code 2.1.76 maps any exit status other than 0 or 2 to a non-blocking error: it runs the tool anyway, hands the model nothing, and renders only<hookName> hook errorwithout the captured stderr. Exiting would have discarded every detection and still not told the operator why; status 2, the only loud one, blocks the tool outright, which a stray shell variable must never do. The warnings are printed once, at startup, by the CLI — they are not deduplicated per value, and theresolveLaneOrWarn/resolveBlockingOrWarn/resetEnforcementWarningshelpers that did that are gone. Verbatim, prefixed[atr](red on a TTY): - ▸An unreadable `ATR_LANE` forces blocking off, even against an explicit `--blocking`. Falling back to
huntwhile blocking stays on is the one degraded posture that is *more* dangerous than the one requested: the operator asked to enforce onmaturity: stablerules only and would instead enforce on every maturity.--blockingsays "you may block", not "block on a lane I never chose".ATR_LANE=enfroce atr guard --blockingtherefore exits 0 in advisory mode, having printed the lane warning above plus: - ▸Both switches now trim whitespace and ignore case, identically. They were asymmetric:
ATR_BLOCKING=ONworked whileATR_LANE=ENFORCEdid not, so the pairATR_LANE=ENFORCE ATR_BLOCKING=ONturned blocking on and silently widened the lane tohunt— the operator asked for the narrowest enforcement posture and got the broadest one.ATR_LANE=ENFORCE,ATR_LANE=" alert "and--lane Alertare all honoured now. - ▸A non-boolean explicit `blocking` throws instead of enabling blocking.
new ActionExecutor({ adapter, blocking: "false" })andnew HookHandler({ engine, executor, blocking: "false" })previously turned blocking on, because every non-empty string is truthy — reading, to their author, as an explicit "off". Both now raise aTypeError, whose message in full is: - ▸`ActionExecutor` no longer dispatches response actions above the `observe` blast-radius tier unless blocking is enabled.
alert/snapshot/shadow/escalaterun exactly as before;block_input/block_output/block_tool/reduce_permissions/reset_context/quarantine_session/kill_agentare recorded as suppressed and the adapter is never called. The tier ladder is read fromsrc/quality/action-eligibility.ts— this change does not introduce a second classification of what is destructive. This closes the dual-channel contradiction where a benignBash{command:"ls -la"}producedpermissionDecision: "allow"while the executor really invokedblockToolon the adapter. - ▸Both channels are governed by one switch: the
blockingconfig field onActionExecutorandHookHandlerfor embedders, and--blocking/--no-blockingorATR_BLOCKINGonatr guardfor the CLI. Default off. The environment variable reaches the CLI only — see the embedder entry above. - ▸Why:
SPEC.md§5.5 (Response) is the engine-wide requirement, quoted whole because it is short enough to be: "Engines MUST NOT execute response actions automatically without an explicit configuration directive from the operator. Theresponsefield is a recommendation expressed by the Rule author, not a directive to the Engine." That directive had no implementation — no CLI flag, no environment variable, no documented config key by which an operator could express it. This change is that implementation. Two narrower statements point the same way. Neither is the requirement, and neither may be quoted as a general rule: -spec/atr-method-v1.1.md§5.6 (Provenance and Trust) sits under §5 Signature Method and is scoped to hash matches: "Engines SHOULD NOT auto-block on a hash match without operator policy explicitly enabling it; the default response action SHOULD belog_alertuntil provenance is operator-trusted." An earlier revision of this entry quoted that sentence with "on a hash match" elided, which turned a Signature-Rule SHOULD NOT into an engine-wide one. It is not one. Quote it whole or cite §5.5 instead. -docs/QUALITY-STANDARD.md("For Consumers") restricts blocking tomaturity: stablewith confidence ≥ 80. That is deployment guidance addressed to consumers, not a normative requirement on engines. - ▸Turning blocking on reproduces the previous behaviour on every verdict that restrains — and only those. An earlier revision of this entry claimed the two transcripts were "byte-identical". They are not, and the difference is the point of the change. Measured by replaying the 850 samples of
data/pint-benchmark/pint-corpus.jsonas 850 hook events per hook type (one JSON line in, one JSON line out) throughatr guard's stdio loop —HookHandler→evaluateWithVerdict→ActionExecutor— on the pre-change merge-base994b01b2bwith no flags, and onb9da8d710with--blocking. The rule corpus is byte-for-byte identical at those two commits (git diff --name-only 994b01b2b..b9da8d710 -- rulesis empty), so every difference below is the contract change and nothing else. - ▸Detection is unchanged in both modes. Over the same 850 × 2 events,
matched_rulesis identical to the baseline on 850/850 for both hooks in both modes, andatr_decisionequals the baseline's internal decision on 850/850. Blocking changes what the engine *does*, never what it *sees*.
Added — the detection lane finally has an entrance
- ▸
ATREngineConfig.laneexisted and worked but no shipped code path ever set it and no user-facing entry point could, sohuntwas the only reachable setting. Added--lane <enforce|alert|hunt>toatr guardandatr scan, theATR_LANEenvironment variable, and alaneinput on the GitHub Action.ATR_LANEis read by the CLI surfaces only —atr guard,atr scan, andsrc/mcp-server.tswhen it is the process entry point. It is *not* honoured by everyATREngine: an engine constructed by an embedder ignores it entirely. See the library/environment entry under "Changed" above. - ▸There is no single resolution order any more, and conflating the two was the bug. They are different chains on different surfaces:
- ▸
atr guardnow prints its posture on stderr, preceded by[atr-guard] Loaded <n> rules from <dir>. Both modes carry a parenthetical, and the blocking one names the limit rather than claiming enforcement outright: - ▸Provenance of the console output quoted in this entry. Every payload, warning and posture line above is captured stdout/stderr from
fix/review-never-affirmative-allowatb9da8d710, pasted unedited apart from stripping the ANSI colour codes on the[atr]warnings and rewriting the absolute rules path. This branch carries the documentation;b9da8d710carries the implementation. The commands reproduce these outputs once both have landed — not before.
Fixed
- ▸
src/hook-handler.tscarried two contradictory comments about failure behaviour: the module header said fail-open, an inline comment instartStdioLoopsaid the error path "fail-closes to a deny". The header was right —failOpendefaults totruein both the constructor and the CLI. The inline comment is corrected; the behaviour is unchanged.
Fixed — published benchmark numbers
- ▸Withdrew two garak figures that no measurement file backed. From 2026-08-04 to 2026-08-05,
README.mdandstats.jsoncited 91.5% (garakin-the-wild) and 56.9% (garak-full) at ATR 3.5.11. Neither run produced a file underdata/measurements/; the newest files there were 95.7% at 3.5.8 and 38.3% at 3.5.0 respectively. Re-measured on 2026-08-05 at 780 rules: 92.5% (garak, 601/650) and 57.2% (garak-full, 1989/3475), both written todata/measurements/with the commit that produced them. - ▸Four eval harnesses built `type: 'llm_io'`, which is a rule *source* and not an
AgentEventType.src/engine.tscould not map it to a source, and its source-type filter is skipped when the mapping is absent — so instead of the narrow llm_io channel those harnesses documented, every rule of every source ran against the event. Affected:eval-garak-inthewild.ts,eval-academic-raw.ts,eval-small-corpora.ts,atr_recall_analysis.ts.check-new-rules-on-benign.tshad the same class of bug withtype: 'user_input'behind anas AgentEventcast. Event shapes now come fromscripts/lib/corpus-event.ts. - ▸`scripts/` is now typechecked (
tsconfig.scripts.json,npm run typecheck:scripts, wired into CI).tsconfig.jsoncovered onlysrc/**, which is why every one of the above was an unreported TS2322/TS2345. - ▸`.github/workflows/rule-quality.yml`'s "Run PINT eval regression check" ran
npm run eval(the internal self-test harness, not PINT), read a git-committed report that step does not write, and looked up a key that does not exist on it — printingPINT recall: undefinedwhile no downstream step read the result. It now runsnpm run eval:pintand gates on recall/FP against the committed baseline. - ▸`scripts/check-benchmark-citations.ts` (new, runs in CI) fails the build when
README.md's benchmark table or rootstats.jsoncites a number, ATR version, or date thatdata/measurements/does not back.