Skip to content

feat(agentihooks): injection robustness pass — size budget, content-hash dedup, doctor probe#48

Merged
nestorcolt merged 10 commits intomainfrom
dev
May 5, 2026
Merged

feat(agentihooks): injection robustness pass — size budget, content-hash dedup, doctor probe#48
nestorcolt merged 10 commits intomainfrom
dev

Conversation

@nestorcolt
Copy link
Copy Markdown
Contributor

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 this
branch) 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, 8KB
    UserPromptSubmit) + 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-off BROADCAST_CRITICAL_ON_PRETOOL,
    raise BROADCAST_PRETOOL_MIN_SEVERITY to critical. Operator
    ~/.agentihooks/.env overrides preserved via
    os.environ.setdefault.
  • 671d481 — P1.2 stable content_hash field on every broadcast
    entry + diff-based clear in brain_adapter._publish_entries. Brain
    ticks 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 collide
    with operator-behavior.md halt rules). P1.4 empty-signal severity
    downgrade. A.2 framing prefix on natural-language broadcasts. Severity
    vocabulary expanded so severity: warning no longer silently
    clobbered to alert.
  • 8fb26aa — P0.3 tests/test_import_hygiene.py — 75 hook modules
    verified clean stdout on import (any module-level print silently
    drops additionalContext).
  • afb53e2 — P0.4 agentihooks 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,500
    bytes (header + body + footer). Truncation footer points at the
    canonical file path so the agent can Read the full doctrine on
    demand.

Research basis

Test plan

  • uv run python -m pytest1050 passed, 1 skipped, 0 failures
  • agentihooks doctor --debug-hook — all 5 hook events green,
    SessionStart now 8,526 bytes (was 38,950 → silently truncated)
  • Existing user ~/.agentihooks/.env overrides take precedence
    over new defaults (verified os.environ.setdefault behavior unchanged
    at config.py:38)
  • Live-fire smoke in a fresh CC session: tail
    ~/.agentihooks/logs/hooks.log for one round of work; assert no
    BROADCAST [ALERT] ... No active signals. lines

Out of scope (separate work)

  • Pinning a Claude Code version floor / recommending Opus 4.6
  • SSE watchdog / token-rate monitor
  • Reducing brain refresh interval (operator-tunable knob already
    exists: BRAIN_REFRESH_INTERVAL)

🤖 Generated with Claude Code

nestorcolt and others added 10 commits May 6, 2026 00:06
…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).
@nestorcolt nestorcolt merged commit 4f4a5df into main May 5, 2026
1 check failed
@nestorcolt nestorcolt deleted the dev branch May 5, 2026 22:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant