feat(agentihooks): injection robustness pass — size budget, content-hash dedup, doctor probe#48
Merged
nestorcolt merged 10 commits intomainfrom May 5, 2026
Merged
feat(agentihooks): injection robustness pass — size budget, content-hash dedup, doctor probe#48nestorcolt merged 10 commits intomainfrom
nestorcolt merged 10 commits intomainfrom
Conversation
…ontent-hash dedup, severity display label
P0.1 — Cap PreToolUse injections at 4096 bytes and UserPromptSubmit at 8192
bytes (configurable via BROADCAST_MAX_BYTES_PRETOOL / _PROMPT). Claude Code's
additionalContext has a 10K hard limit; exceeding it makes the harness write
to a temp file and the model receives a filepath instead of body content.
format_broadcast_banner and format_critical_context now truncate per message
and enforce a cumulative byte cap, with [...truncated] marker.
P0.2 — Suppress broadcasts whose body conveys no signal ("No active signals.",
"No inject blocks.", "No data.", "None.", empty/whitespace). New helper
_body_is_empty() applied in check_and_inject_broadcasts loop and
format_critical_context. Eliminates the [ALERT] No active signals. PreToolUse
injection that issue #34713 documents as a hook-error contamination vector.
A.1 — Per-call dedup across messages with the same content_hash inside one
formatting pass. When multiple messages share content_hash, keep only the
newest by created_at. Falls back to _msg_hash() when content_hash field is
absent so existing broadcasts dedup too.
A.3 — Display-label map decouples canonical severity (info/alert/critical)
from header rendering. Prevents low-priority info content from rendering with
[ALERT] framing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
BROADCAST_CRITICAL_ON_PRETOOL: true → false BROADCAST_PRETOOL_MIN_SEVERITY: alert → critical Routine alert-tier broadcasts no longer fire on every tool call. Only true emergencies (critical) reach PreToolUse. Routine alerts still land at UserPromptSubmit once per turn. Eliminates the per-tool-call [ALERT]-shaped injections that issue #34713 documents as eroding model confidence in hook-dense setups. Operators on active incident response can opt back in via ~/.agentihooks/.env (existing user values still win via os.environ.setdefault precedence at config.py:38). .env.example gains a BROADCAST SYSTEM section documenting the new defaults and the size-cap knobs from the previous commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Ds (P1.2) Adds a stable `content_hash` field (sha256(channel|severity|message)[:16]) to every broadcast entry written by create_broadcast. Decouples dedup from the random UUID minted on every call. brain_adapter._publish_entries now does a diff-based clear instead of clear-and-republish: entries on BRAIN_CHANNEL whose content_hash is also in the new batch survive untouched. Only genuinely new content gets a fresh UUID. Why: brain adapter republishes the same hot-arcs / operator-intent / tick-diff content every refresh tick. Old code did `clear_broadcasts(channel) -> create_broadcast` which minted new UUIDs unconditionally — so the per-(session, message_id) delivery state in broadcast_delivery_state.json never matched, and dedup never fired even for unchanged content. Sessions saw the same banner repeatedly under different ids. New helper `find_broadcast_by_content_hash(hash, channel=None)` returns the most recent broadcast matching a content hash, scoped to a channel. Tests: 4 new in TestContentHashField. All 48 existing broadcast tests pass. Brain HTTP tests untouched (14 pass). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… normalization (P1.3, P1.4, A.2)
P1.3 — Brain entries pass through _scrub_halt_phrases() before publish.
Substring rewrites (case-insensitive):
"do you understand" → "is this clear"
"elaborate to me" → "expand on"
"stop all tool calls" → "pause tool usage"
"wait for confirmation" → "awaiting input"
operator-behavior.md treats these phrases as STOP signals. Brain content
is system-channel state, never an operator directive — but a literal
substring match in the model's halt rule cannot distinguish source.
P1.4 — _normalize_severity_for_empty() forces severity=info when an
"Active Signals" / "Amygdala" entry has an empty body ("No active
signals.", "No data.", "None.", ""). The #34713 contamination shape is
[ALERT]-tier framing of empty payloads; downgrading at the source
prevents it regardless of what upstream emits. Real signals keep their
declared severity.
A.2 — _wrap_with_framing() prefixes long natural-language broadcasts
("Operator Intent", "Active Hot Arcs", "last-tick-diff", "Tick Diff")
with "STATUS BROADCAST (informational, no action required):" so the
model parser is less likely to treat the system-reminder as a redirect
from the operator.
broadcast._VALID_SEVERITIES expanded to {nuclear, critical, alert,
warning, info, resolved} — the full _SEVERITY_RANK vocabulary. Old code
silently clobbered any non-{critical,alert,info} severity to "alert",
which is how brain_keeper's `severity: warning` empty-signals entries
ended up rendered as [ALERT]. _DEFAULT_TTL and _DEFAULT_PERSISTENT
extended to match.
Tests: 8 new in TestBrainAdapterHelpers. All 254 context tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Importing any hooks/* module must NOT write to stdout. Claude Code parses hook-process stdout as JSON; any module-level print or import-time warning silently corrupts the parse and drops additionalContext — broadcast injection silently disappears. New tests/test_import_hygiene.py spawns a subprocess per module and asserts captured stdout is empty after import. 75 modules covered. _ALLOWED_NOISY quarantine set is empty by design and documented as such — every future entry is a bug, not a feature. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New subcommand: agentihooks doctor [--debug-hook] [--json].
Without --debug-hook: alias for `agentihooks status` (same checks).
With --debug-hook: spawns a fresh `python -m hooks` process per event
(SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop), feeds a
synthetic payload on stdin, and asserts the documented Claude Code
protocol invariants:
1. Exit code is 0 or 2. Exit 1 (or any unexpected code) flagged.
2. SessionStart / UserPromptSubmit accept plain-text stdout, but it
must stay under 10,000 chars. ≥10K triggers Claude Code's
tempfile-substitution path — the model receives a filepath instead
of body content (silent semantic loss).
3. PreToolUse / PostToolUse / Stop must emit valid hookSpecificOutput
JSON or empty stdout; plain-text on those events is undocumented
and may be ignored by some CC versions.
4. additionalContext, when present, is a string under 10,000 chars.
This is the missing visibility layer for upstream issue #34713 + #19432
class bugs: silent additionalContext drops on malformed JSON, oversized
payloads, exit code drift. Run it after any hook-stack change.
Adds check_hook_injection() + format_hook_injection() to status_checker
so the same logic is reusable from skills / MCP tooling.
Tests: 5 new in TestHookInjectionProbe. Full suite 1047 passed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…low-up) The Anton CI manifesto is ~36 KB. Claude Code injects hook stdout AND additionalContext into context up to a 10,000-char hard cap; beyond that the harness writes to a temp file and the model receives a filepath instead of body content. Until this fix, every SessionStart silently lost the entire manifesto — the model thought it loaded but actually saw a path reference. Bundle CLAUDE.md's "If the manifesto is not visible, alert operator" rule never fired because the model believed the inject succeeded. `agentihooks doctor --debug-hook` flagged this on its first run. Fix: _build_injection() now caps at 7500 bytes (header + body + footer) to leave headroom for other on_session_start contributors (TOKEN CONTROL, thinking guidance, brain inject). When the manifesto exceeds budget, the body is truncated and a footer points the agent at the canonical file path: [TRUNCATED — the full doctrine is at: /path/to/MANIFESTO.md] Read that path with the Read tool when you need section details. Doctor probe now shows SessionStart at 8526 bytes — under cap, all green. Tests: 3 new in TestInjectionBudget. Full suite 1050 passed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Operator preference: keep injections wide-open by default so agents get
the full enhanced brain / manifesto / broadcast context. Truncation
stays available as opt-in via env var when an operator decides they
need envelope headroom under Claude Code's 10K hook output limit.
Defaults flipped to "no cap":
BROADCAST_MAX_BYTES_PRETOOL: 4096 → 0
BROADCAST_MAX_BYTES_PROMPT: 8192 → 0
New env var (default 0 = no cap):
CI_MANIFESTO_MAX_BYTES — caps the CI manifesto inject when set;
when truncated, body is followed by a
"Read full doctrine at <path>" footer
Behavior:
- 0 (default): full content reaches the model. The doctor probe
will warn when total stdout exceeds 10,000 chars (Claude Code's
documented hard cap → silent filepath substitution upstream).
- >0: truncate to the configured byte budget, preserve trailing
marker so agents can self-discover the truncation.
Doctor probe still works the same — it reports current event sizes
and flags >10K as failures so operators can decide whether to dial
in a cap or let the upstream truncation happen.
Documented in README under "Hook output and the 10K cap" section,
and in .env.example with recommended values when opting in.
Tests updated to patch caps explicitly when verifying truncation.
Full suite: 1053 passed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…emory channel The CI manifesto (~36 KB) was being emitted on every SessionStart via hookSpecificOutput.additionalContext stdout. Claude Code's hook output is capped at ~2 KB before the harness substitutes the body with a filepath preview — so the model never saw the doctrine. Bundle's "if manifesto not visible, alert operator" rule never fired because the substitution is invisible to the agent. Fix: agentihooks init now appends the manifesto to ~/.claude/CLAUDE.md between BEGIN/END CI MANIFESTO markers. CLAUDE.md is loaded through the memory channel which has no 2 KB hook cap — the full doctrine permanently in context for every session, zero per-session API cost. Changes: * scripts/install.py — new _append_ci_manifesto_to_claude_md() helper, idempotent block replacement via marker comments, called after the profile-chain CLAUDE.md write. * hooks/context/ci_manifesto.py — inject_on_session_start() and maybe_refresh() now gated on CI_MANIFESTO_RUNTIME_INJECT (default false). Manifesto in CLAUDE.md is the new default; runtime inject is the legacy escape hatch. * hooks/config.py — new CI_MANIFESTO_RUNTIME_INJECT env var, default false. Doctor probe before: SessionStart 38,950 bytes → silently truncated. Doctor probe after: SessionStart 671 bytes → all green. Tests: 117 passed (config, broadcast, ci_manifesto, install). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Match the agentibrain-kernel / agentibridge / agenticore pattern: every page is owned by a top-level section, no floating standalones. - Create docs/pillars/index.md as the 'The Four Pillars' parent (was orphan — six pillar pages had parent: 'The Four Pillars' but no parent page existed). - Reset section nav_orders for clean user-journey ordering: Home(1) → Getting Started(2) → The Four Pillars(3) → Hook System(4) → MCP Tools(5) → Reference(6). - Route four standalone docs under sections: - bundles.md → Getting Started - connectors.md, extending.md → Hook System - cost-management.md → Reference - Backfill missing parent: on 12 leaf docs that were implicitly relying on directory structure (just-the-docs needs explicit parent).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Hardens the agentihooks injection layer against documented Claude Code
hook-protocol foot-guns. Does not claim to fix the upstream silent-stop
bug class (issues #50727 / #47903 / #53328) — that lives in CC's
streaming pipeline. This branch makes our hook stack stop being the
amplifier.
The first run of
agentihooks doctor --debug-hook(added in thisbranch) caught a real bug: SessionStart was emitting 38,950 chars of
plain-text stdout, which Claude Code silently truncates to a filepath at
≥10,000 chars. The CI manifesto + brain markers were never reaching the
model — the bundle's "if manifesto not visible, alert operator" rule
never fired because the harness substitution is invisible to the agent.
Now SessionStart inject is 8,526 bytes — under cap, all green.
Changes (7 commits)
706f96a— P0.1 per-injection size budget (4KB PreToolUse, 8KBUserPromptSubmit) + P0.2 empty-body suppression
(
[ALERT] No active signals.is the #34713 contamination shape) +A.1 per-call content-hash dedup + A.3 severity display-label map.
681cfa5— P1.1 default-offBROADCAST_CRITICAL_ON_PRETOOL,raise
BROADCAST_PRETOOL_MIN_SEVERITYtocritical. Operator~/.agentihooks/.envoverrides preserved viaos.environ.setdefault.671d481— P1.2 stablecontent_hashfield on every broadcastentry + diff-based clear in
brain_adapter._publish_entries. Brainticks no longer mint fresh UUIDs for unchanged content; per-(session,
message_id) delivery state finally matches.
852f024— P1.3 halt-phrase scrub on brain content(
"do you understand"/"elaborate to me"/"stop all tool calls"/
"wait for confirmation"rewritten before publish — these collidewith
operator-behavior.mdhalt rules). P1.4 empty-signal severitydowngrade. A.2 framing prefix on natural-language broadcasts. Severity
vocabulary expanded so
severity: warningno longer silentlyclobbered to
alert.8fb26aa— P0.3tests/test_import_hygiene.py— 75 hook modulesverified clean stdout on import (any module-level print silently
drops
additionalContext).afb53e2— P0.4agentihooks doctor [--debug-hook] [--json].Spawns each hook event with synthetic payload, asserts exit code +
stdout JSON validity + 10K cap.
d74b802— fix: cap CI manifesto SessionStart inject at 7,500bytes (header + body + footer). Truncation footer points at the
canonical file path so the agent can
Readthe full doctrine ondemand.
Research basis
additionalContext/systemMessage/ plain stdouterode model confidence (the
[ALERT]framing of empty payloads isthe documented contamination shape)
additionalContextis received but not injected into model context anthropics/claude-code#19432 —additionalContextnot alwaysinjected
Test plan
uv run python -m pytest— 1050 passed, 1 skipped, 0 failuresagentihooks doctor --debug-hook— all 5 hook events green,SessionStart now 8,526 bytes (was 38,950 → silently truncated)
~/.agentihooks/.envoverrides take precedenceover new defaults (verified
os.environ.setdefaultbehavior unchangedat
config.py:38)~/.agentihooks/logs/hooks.logfor one round of work; assert noBROADCAST [ALERT] ... No active signals.linesOut of scope (separate work)
exists:
BRAIN_REFRESH_INTERVAL)🤖 Generated with Claude Code