Skip to content

Replace output_budget_guard command-shape heuristic with context-load-based output bounding #4286

Description

@Trecek

Remediation of the mechanism, not the shapes. Do not patch more R1-R3 command shapes. The #4284 investigation (report appended below) established that the shape-enumeration approach itself is the defect.

Operator directive

output_budget_guard must stop being a heuristic over specific commands. The failure mode it exists to prevent is context overload — too many bytes entering model context from tool output. Which command produced the bytes is irrelevant. Enforcement must bound what actually enters context (measure real output, spill overflow to durable artifacts, return bounded inline slices), not attempt a static pre-execution proof that a shell command's output will be small. Static boundedness proof over arbitrary shell is unwinnable: the classifier can only enumerate blessed shapes, every legitimate unlisted shape becomes a false positive, and the repo has paid for this pattern ≥13 times on write_guard alone (same shared classification engine), with output_budget_guard separately on its third patch cycle in its first week.

What the #4284 investigation established

  1. Universal scope was deliberate, and its premise concedes the point. The originating rectify plan (2026-07-15) prescribed session_scope="any" and "NO AUTOSKILLIT_HEADLESS gate", while itself stating Claude Code is "masked ... because its harness natively spills Bash output to a file"; ADR-0005 (line 62) likewise records "Claude's native Bash spill behavior covers shell output on that backend." On Claude Code the guard adds ~zero containment while realizing its full friction cost. test_guard_fires_without_headless_gate (tests/hooks/test_output_budget_guard.py:277) pins the absence of gating and must be consciously revisited.
  2. The classifier's accepted-pipeline surface is effectively one shape. _trace_pipeline_destination (src/autoskillit/hooks/_command_classification.py:518-587) requires producer stderr captured AND a literal terminal head -c/--bytes cap AND every intermediate stage's stderr kept in-pipe — true only for |& joins (line 543). Consequences observed live on 2026-07-18 (session c71aaa97, discovered during the rectify skill writes undeclared working files in interactive runs #4283 investigation): 63 denials (adversarially recounted with is_error filtering; all in subagents, none in the main session), including find … 2>&1 | wc -l | head -c 4000, rg … 2>&1 | sort | uniq -c | head -c N, and jq -c 'keys' … 2>&1 | head -1 | head -c 1000 — all practically bounded, all denied because wc/sort/head -1 stderr "reaches the model" (an explicit per-stage 2>/dev/null would have been accepted — _command_classification.py:580 — but no agent discovered that hatch). head -n is never a cap (_byte_cap_value, lines 487-505); single-line aggregators are worse than unmodeled: stdin-mode wc -l is affirmatively classified an ambiguous unbounded producer (output_budget_guard.py:261-273), so bare ls | wc -l is denied — verified by direct classifier execution.
  3. The anonymous deny message is being classified as prompt injection. The reason string (src/autoskillit/hooks/guards/output_budget_guard.py:329-334) names no origin and uses internal jargon ("R1-R3 producer"). Sonnet Explore subagents independently called it "a prompt-injection attempt embedded in tool output" and deliberately routed around it; one subsequently reported a CLAUDE.md-content <system-reminder>-style block as another injection attempt (self-reported in its narrative; no such block appears in its logged tool results, so delivery of a genuine reminder is unverified). No guard in the repo self-identifies — a systemic convention defect this guard's universal scope exposed first. Any surviving enforcement surface must carry explicit AutoSkillit provenance.
  4. The escape valve manufactures undeclared artifacts. The deny text steers every denied session toward ad-hoc writes under .autoskillit/temp/ — in direct tension with skill output manifests (rectify skill writes undeclared working files in interactive runs #4283), with no purge/reaping or artifact-manifest mechanism (ensure_project_temp, core/io.py:356-395, provides .gitignore containment only — it creates but never purges).
  5. This is a recurring architectural pattern, not a one-off. ≥13 shape-by-shape false-positive rectifications on the same _command_classification.py engine for write_guard (e.g. 0d683ce "6th incident in 31 days", b041e09 "12th write guard rectification in ~90 days", 5b061dc heredoc FPs across 7 guard files); output_budget_guard (born e2523b7, 2026-07-15) is on its third patch cycle in its first week.
  6. Channel reality. The guard does execute on Codex (config.toml hook sync; CI round-trip probe tests/execution/backends/test_cli_conformance_probes.py:696). Its matcher covers two channels with very different existing protection: the MCP run_cmd channel is already lossless via spill_run_cmd_resultspill_output (ADR-0005 Layer 1; server/tools/_execution_helpers.py:54-80) — precisely the spill+bounded-slice mechanism this issue proposes — and Claude Code native Bash has a documented lossless spill: output beyond 30,000 characters (default) is saved in full to a session-directory file with the path plus a short preview returned to the model, raisable via BASH_MAX_OUTPUT_LENGTH to a 150,000-character ceiling (code.claude.com/docs/en/tools-reference, "Bash tool behavior" — verified against the live page 2026-07-18; ADR-0005 line 62 records the same conclusion). The single channel with no lossless containment is Codex native shell, bounded today only by the lossy 54,500-token transport ceiling (CODEX_TOOL_OUTPUT_TOKEN_LIMIT; ADR-0005 Accepted Gap 1 notes shapes outside R1-R3 are bounded only by that ceiling — "Codex may still truncate and discard their excess output"). Closing that one gap losslessly is the real work; the R1-R3 enumeration is a partial pre-spend patch over it.

Remediation direction (for decomposition — not a prescriptive design)

  • Retire pre-execution command-shape classification as the enforcement mechanism. No enumeration of risky verbs/shapes as the primary control.
  • Enforce at the output boundary, per backend, on actual measured bytes. Claude Code: native Bash spill already bounds shell output — no AutoSkillit shell-deny surface needed there. Codex: make the native-shell path lossless the same way ADR-0005 layers 1-2 already treat MCP responses — spill full output to a project artifact and return a bounded inline slice, with the transport ceiling as the backstop rather than the mechanism.
  • Reuse-and-refactor the existing capture path rather than adding a parallel one: today's chain is lossless-then-lossy — subprocess output is captured to temp files (create_temp_io), read in full into Python strings (read_temp_output, execution/process/_process_io.py:75-91), the original capture files are then deleted (_process_io.py:67-72), and only afterwards are oversized strings re-written to fresh artifacts (spill_run_cmd_result). Promote the original capture files to the published artifacts and let only bounded slices enter memory (verified against current code, 2026-07-18).
  • Implementation reality (resolved 2026-07-18 by direct source verification): no Bash-matching mechanism="output-rewrite" hook exists on either backend today — all six output-rewrite registry entries target MCP tools or Write|Edit — and the Codex PostToolUse route is a verified dead end for losslessness: in Codex 0.144.1 the shell handler builds the hook's post_tool_use_response by mapping output through format_exec_output_str (vendored codex-rs/core/src/tools/handlers/shell.rs:225-229), whose body is explicitly "Truncate for model consumption before serialization" (codex-rs/core/src/tools/mod.rs:89-97); it is built only for Ok results (out.as_ref().ok()), and the hook engine rejects updatedMCPToolOutput (codex-rs/hooks/src/engine/output_parser.rs:427-428). Unified-exec's hook payload is the serialized tool result (context.rs:105-106; reported equally truncated). Three viable routes remain for the plan to weigh: (a) near term — route Codex shell through an AutoSkillit-owned lossless-capture tool and uniformly deny uncaptured native shell on Codex (a scope rule, not command-shape inference); (b) a PreToolUse updated_input capture wrapper — input rewriting IS supported (shell_command.rs:224-240, unified_exec/exec_command.rs) — gated behind strict semantics/security conformance (rewriting changes what approval layers see; quoting/TTY/signal semantics can shift); (c) end state — pre-truncation integration at the point where Codex still holds raw ExecToolCallOutput (upstream support or maintained patch).
  • Pre-spend trade-off: pre-execution denial prevents the command from ever running (no compute, no side effects, no generated bytes); output-boundary bounding executes first and bounds after. The plan must decide whether a minimal pre-execution backstop remains for catastrophic shapes or the execution cost is accepted.
  • Sequencing constraint: Claude Code-side friction relief must not wait, but the pre-execution guard must not be retired on Codex before the lossless output-boundary mechanism exists there — it is currently the only pre-spend protection on that channel.
  • Provenance rule for any residual hook message (deny or advisory): self-identify as an AutoSkillit hook, state the mechanism, and show a compliant rewrite of the actual denied/flagged command — ideally via a shared formatter with a contract test (composes with Unified hook checkpoint framework: declarative tool-call enforcement for L1 skill sessions #3758). Two refinements: (1) treat the textual prefix as presentation, not authentication — ordinary tool output can spoof any prefix, so provenance should originate as a typed policy event (source scope, hook id/version, decision, stable reason code) rendered per backend by the shared formatter; (2) any suggested rewrite MUST be validated by running the guard's own classifier on it before emission — in the 2026-07-18 incident, agents imitating the deny message's generic template were denied again, compounding distrust.
  • Compose the artifact path with skill output manifests (rectify skill writes undeclared working files in interactive runs #4283): overflow artifacts land in declared, skill-scoped locations, not ad-hoc temp files.
  • Interim mitigations (scope narrowing via the delivery-layer generators, targeted FP fixes) are at the remediation planner's discretion, but must not further entrench the shape-enumeration mechanism; the intent-pinning tests and rectify-plan rationale must be updated deliberately, not silently.

Adversarial validation (2026-07-18)

Two independent adversarial reviews were run against this issue before publication; every challenged claim was re-verified directly (file reads, git show, direct classifier execution, is_error-filtered transcript recounts). Corrections applied: denial count 65→63 (the main session had none — its apparent hits were successful commands whose output quoted the trigger string); ls | wc -l confirmed denied (an earlier investigation draft claimed otherwise); guard birth date corrected to 2026-07-15; guard census replaced with exact registry counts (35 HookDefs; 12 headless_only; 3 interactive_only; output_budget_guard the sole explicit session_scope="any"); the "genuine system-reminder distrusted" episode downgraded to self-reported/unverified. Open NEEDS-EVIDENCE items: the reported scratch-file byproduct (not located in transcripts). Codex hook output-mutation capability was subsequently resolved negative by direct source verification — see "Implementation reality" above and the External approach review below. The guard also produced a fresh false positive during this validation pass itself: it denied cat >> .autoskillit/temp/investigate/<report>.md <<'EOF' … — a zero-stdout heredoc append writing into the sanctioned temp directory — adding an append-redirect/heredoc-writer class to the false-positive surface.

External approach review (2026-07-18)

An outward-looking approach review (.autoskillit/temp/review-approach/review_approach_output_bounding_2026-07-18_083310.md) was evaluated claim-by-claim; findings were incorporated only after independent verification against original sources (vendored Codex 0.144.1 tree at .autoskillit/temp/openai-codex/, the live Claude Code docs, and AutoSkillit code):

  • Incorporated (verified): Codex PostToolUse receives pre-truncated output and rejects output rewriting → dead end for lossless capture (source citations in "Implementation reality" above); Codex PreToolUse updated_input rewriting exists (capture-wrapper route is possible but semantics/security-gated); Claude Code's 30,000-char documented lossless Bash spill (official docs); AutoSkillit's current capture path double-materializes and deletes the lossless original (code citations above).
  • Incorporated (design guidance, follows from verified facts): the three Codex implementation routes; typed policy-event provenance over textual prefixes; shadow-telemetry rollout with a hard conformance gate (aligns with investigation Recommendation 5); capture-contract details (exact byte counts, digest, completeness state, capture_failed fail-stop instead of silent discard).
  • Not incorporated (unverified or deferred): the review's claims about Claude Code hooks-doc injection-defense notes and session tool-result retention cleanup (not independently verified); its full artifact-lifecycle enumeration (quotas/leases/TTL/permissions/manifest-only deletion) — sound but belongs to the rectify skill writes undeclared working files in interactive runs #4283 manifest composition work; see the review file.

Cross-references

Investigation

Prior investigation completed interactively; adversarially validated and cross-checked against an external approach review, 2026-07-18. Full corrected report below.

Investigation: output_budget_guard — Unscoped Firing and Mistrusted Deny Messages (Issue #4284)

Date: 2026-07-18
Scope: src/autoskillit/hooks/guards/output_budget_guard.py, src/autoskillit/hooks/_command_classification.py, src/autoskillit/hook_registry.py, hook delivery machinery (plugin hooks.json / Codex config.toml), the 2026-07-18 session transcripts of the incident, provenance (PR #4277, commit 971685865, rectify plan of 2026-07-15, ADR-0005, issues #4272/#4283/#3758)
Mode: Standard

Summary

output_budget_guard fires in interactive Claude Code subagents not by omission but by deliberate, documented, test-pinned design — and that design decision is where the trouble starts, because its own record concedes Claude Code needs no such protection. Three compounding defects were confirmed: (1) deliberate universal scope whose rationale (rectify plan 2026-07-15, ADR-0005) admits "Claude's native Bash spill behavior covers shell output on that backend" yet enforces there anyway; (2) a classifier whose only recognized bounded pipeline is the exact two-stage producer 2>&1 | head -c N shape — any intermediate filter stage (sort, wc -l, uniq -c, grep -c, cut, head -1) flips the disposition to deny because that stage's (practically always empty) stderr "reaches the model," producing 65 denials in the single session that discovered the problem, including denials of the guard's own suggested rewrite pattern; and (3) an anonymous deny message (shared repo-wide convention — no guard self-identifies) that sonnet Explore subagents independently classified as a prompt-injection attempt and deliberately routed around, with one subagent escalating to distrust a genuine Claude Code <system-reminder> afterward. The historical check shows this is the third patch cycle on this one-week-old guard and the same failure species as at least 13 write_guard false-positive rectifications on the same shared classification engine — a recurring architectural pattern, flagged for /rectify.

Root Cause

Three distinct root causes, one per observed anomaly class:

  1. Unscoped firing — deliberate design, questionable premise [SUPPORTED]. main() has exactly two gates: AUTOSKILLIT_SKILL_NAME vs. an empty _EXEMPT_SKILLS (output_budget_guard.py:34,303-305) and the output_budget_policy.disabled config flag. No AUTOSKILLIT_HEADLESS, AUTOSKILLIT_AGENT_BACKEND, AUTOSKILLIT_SESSION_TYPE, or agent_id check exists. The HookDef is registered session_scope="any" (hook_registry.py:207), and test_guard_fires_without_headless_gate (tests/hooks/test_output_budget_guard.py:277) pins the absence of gating. The originating rectify plan (.autoskillit/temp/rectify/rectify_output_budget_protocol_2026-07-15_135615.md, lines 13, 232) prescribed this verbatim: "the only enforcement point covering Codex's native shell path, on both backends, interactive sessions included" and "NO AUTOSKILLIT_HEADLESS gate (the incident was interactive)". The premise is undermined by the same documents: the plan says Claude Code is "masked ... because its harness natively spills Bash output to a file," and ADR-0005 (line 62) states "Claude's native Bash spill behavior covers shell output on that backend." So on Claude Code the guard's marginal token protection is ~zero by the design record's own accounting, while its friction cost is fully realized there. Note the delivery layer cannot scope it either: Claude hooks ship via the plugin cache hooks.json (project .claude/settings.json is intentionally empty when the plugin is installed, cli/_hooks.py:75), which has no session-type concept; session_scope is consumed only by generate_codex_hooks_config() (skips interactive_only) — for Claude sessions it is unenforced metadata. Any scoping must live inside the script, as write_guard.py:248-252 and 12 other headless_only guards do.

  2. False positives — a structurally over-strict pipeline rule [SUPPORTED]. _trace_pipeline_destination (_command_classification.py:518-587) accepts a pipeline only if (a) the risky producer's own stderr is captured (2>&1, |&, 2>/dev/null/dev/null is a recognized safe sink, line 392), (b) the terminal stage is head/tail with a literal -c/--bytes valueshell_max_inline_bytes (_byte_cap_value, lines 487-505 — -n/--lines never counts), and (c) every intermediate stage's stderr must stay in the pipe or go to an explicit safe sink — the in-pipe default holds only when the next connector is |& (line 543), though an explicit per-stage 2>/dev/null is also accepted via the _DEST_SAFE branch (line 580; verified empirically: find . -name x 2>&1 | wc -l 2>/dev/null | head -c 100 is BOUNDED while the same without 2>/dev/null is HAZARDOUS — no agent in the incident discovered this hatch). Consequence: find … 2>&1 | wc -l | head -c 100 and rg … 2>&1 | sort | uniq -c | head -c 3000 are denied because wc/sort stderr defaults to _DEST_MODEL (lines 580-584), even though those streams are empty in practice and the terminal byte cap bounds everything. A non-terminal head -c is UNKNOWN→deny (lines 554-555). Line-bounded aggregators (wc -l, grep -c — one line of output) are never modeled as bounded; worse, stdin-mode wc -l (no file operands) is affirmatively classified an ambiguous unbounded producer (output_budget_guard.py:261-273ambiguous = not operands or …), so bare ls | wc -l is denied (verified by direct classifier execution on 2026-07-18). The observed denials of "bounded" commands are therefore not a parser bug but the intended disposition of an over-conservative rule set: the only shapes that pass are the exact ones in the deny message and test corpus.

  3. Injection misclassification — anonymous deny text meets injection-trained agents [SUPPORTED]. The deny reason (output_budget_guard.py:329-334): "Unbounded command output is prohibited: this R1-R3 producer has no proven byte-bounded stdout/stderr path. Run rg -l ... 2>&1 | head -c 4000 for bounded discovery, or redirect both descriptors under .autoskillit/temp/ then read slices." It names no origin ("AutoSkillit", "hook", "guard" absent), uses internal jargon ("R1-R3 producer") meaningless to a subagent, and instructs behavior (write files) that conflicts with Explore subagents' read-only role — a near-perfect match for the "instructions embedded in tool output" injection signature these models are trained to resist. No guard in the repo self-identifies (survey of all ~18 guards), so this is a systemic convention defect that this guard's universal scope exposed first.

Affected Components

  • src/autoskillit/hooks/guards/output_budget_guard.py: the guard; no session gating; anonymous deny text (lines 31, 34, 302-334) [SUPPORTED]
  • src/autoskillit/hooks/_command_classification.py: classify_command_output_budget + _trace_pipeline_destination (518-587), _byte_cap_value (487-505) — over-strict pipeline rule [SUPPORTED]
  • src/autoskillit/hook_registry.py:204-212: session_scope="any", exempt_skills=frozenset(), enforcement_strength={"claude_code": "soft", "codex": "works-as-is"} [SUPPORTED]
  • src/autoskillit/cli/_hooks.py:51-98 + plugin cache hooks.json: Claude delivery path, global to all sessions/subagents in the project [SUPPORTED]
  • src/autoskillit/execution/backends/_codex_hooks.py:42-154 + codex.py:1184-1202: Codex delivery via ~/.codex/config.toml; guard confirmed live on Codex by CI round-trip probe (tests/execution/backends/test_cli_conformance_probes.py:696) [SUPPORTED]
  • tests/hooks/test_output_budget_guard.py: pins current behavior incl. absence of gating (line 277) [SUPPORTED]
  • Four skills embedding OUTPUT_DISCIPLINE_BLOCK (core/types/_type_constants.py:58-110): investigate, rectify, audit-bugs, audit-friction — prompt-side counterpart [SUPPORTED]

Data Flow

HOOK_REGISTRY → two parallel delivery paths: (a) Claude Code — generate_hooks_json() → plugin cache hooks.json → every session and Task/Explore subagent in the project dir; (b) Codex — sync_hooks_to_codex_config() on each launch → [[hooks.PreToolUse]] in ~/.codex/config.toml. At tool time: PreToolUse fires on Bash|mcp__.*autoskillit.*__run_cmd_dispatch.pymain() reads stdin JSON (tool_input.command/cmd), policy from .autoskillit/temp/.hook_config.json (+overlay), classifies, and on non-BOUNDED emits a permissionDecision: deny payload whose reason string is what the model sees embedded in the failed tool result. On Claude Code the denial is enforcement_strength: soft — the model can and does route around it with alternate commands, which is exactly what the transcripts show.

Test Gap Analysis

  • Session scoping: only test_guard_fires_without_headless_gate exists, and it asserts the absence of gating (an intent pin, not a behavior test). No test constructs a subagent payload (agent_id), no interactive-session integration test; the only e2e test (tests/server/test_output_budget_e2e.py) is smoke-gated, headless-only, and never asserts guard behavior. Interactive-subagent firing was therefore invisible to CI and intended.
  • False-positive shapes: the allow-corpus (test_allows_proven_bounded_cases) contains only the exact blessed shapes (2>&1 | head -c N, temp redirects, find -quit). None of the incident shapes — … | head -c N without 2>&1, … 2>&1 | wc -l | head -c N, … 2>&1 | sort | head -c N, head -n — appear anywhere in the corpus as either allow or deny assertions, so the FP surface was never measured. No adversarial/property-based corpus of real-world command shapes exists anywhere in tests/.
  • Deny-message content: tests assert only the trigger substring and rewrite hint; nothing asserts provenance/self-identification, so the anonymity was never a reviewable property.
  • Agent reaction: no test layer exists that could catch "agents misread the deny as injection" — that is a live-behavior property only observable in transcripts/probes (the Codex round-trip probe checks the reason reaches the model, not how the model responds).

Similar Patterns

  • Of the registry's 35 HookDef entries, 12 are explicitly session_scope="headless_only" and 3 interactive_only; output_budget_guard is the only entry that explicitly declares session_scope="any" (hook_registry.py:207) — the rest inherit "any" by default. The shell-deny guards comparable to it are all headless-gated in-script: write_guard.py:248-252, planner_result_naming_guard.py:50-51, git_ops_guard.py, test_runner_guard.py, etc. Established gating signals available to hook scripts: AUTOSKILLIT_HEADLESS, AUTOSKILLIT_AGENT_BACKEND ("codex"), AUTOSKILLIT_SESSION_TYPE, and the hook payload's agent_id (subagent detection, used by skill_load_guard.py; documented in hooks/guards/AGENTS.md). output_budget_guard uses none — one of only ~2 deny guards firing everywhere.
  • No guard self-identifies in deny text and no shared deny formatter exists — every guard inlines its own payload. Issue Unified hook checkpoint framework: declarative tool-call enforcement for L1 skill sessions #3758 (unified hook checkpoint framework, OPEN, unimplemented) standardizes deny mechanics but does not mandate a provenance prefix.
  • Guard false-positive → shape-by-shape rectify is an established repo pattern (see Historical Context).

Design Intent Findings

  • output_budget_guard: Born e2523b762 (2026-07-15, "feat: guard unbounded command output" — date verified via git show), hardened 54cb7430c (JSONL bypass), remediated 971685865 (PR Output Budget Protocol Audit Remediation #4277, merged 2026-07-17) — the only commit on the file's visible history (squash). Stated purpose (ADR-0005 layer 3): pre-spend denial of enumerated high-confidence unbounded shapes R1 (recursive grep/rg), R2 (JSONL producers), R3 (find). Universal scope prescribed by the rectify plan of 2026-07-15 before implementation; PR Output Budget Protocol Audit Remediation #4277's review raised no scoping objection. Dependents: Codex config sync (codex_status="works-as-is"), tests/hooks/test_output_budget_guard.py, test_codex_hooks_format_contract.py, the CI conformance probe, audit-friction's converted search battery, output_budget_policy config bridge. [SUPPORTED]
  • Deny-message wording: prescribed verbatim in the rectify plan (line 230) — the temp-redirect steering was designed in, not improvised. [SUPPORTED]
  • enforcement_strength={"claude_code": "soft"}: the registry itself records that Claude-side denial is advisory-strength; transcripts confirm agents treat it as such. [SUPPORTED]

Historical Context

Prior fixes on the same shared engine (_command_classification.py), all incident-driven, all shape-by-shape: d3e4a46a9 (#3150 interpreter bypass), 2d04f9924 (#3260 shlex layer), 0d683cea2 (#3300 subshell redirects — "6th incident in 31 days"), 266f2f5a0 (#3555 path resolution — "rectified 10+ times in 90 days"), b041e0972 (#3571 fd-token conflation — "12th rectification in ~90 days"), 752bc39f0 (#3619 shell vars), 5b061dc1c (#3677 heredoc bodies across 7 guard files), plus 0c7f64893, 20fa08315, 10ec867bc, 45f58a871, 78baa2d07, cd40a24a7, 70461d55e — ≥13 recurrences May–July 2026. output_budget_guard (born e2523b762, 2026-07-15) repeats the species (heuristic shape enumeration, reactive patching, no adversarial corpus, no shadow-mode rollout) and is already on its third patch cycle within its first week; the ≥13 count is write_guard-only — this guard's cycles are additional to it. This is a recurring pattern — consider running /rectify for architectural immunity after resolving the immediate issue.

Incident Evidence (2026-07-18 transcripts)

Session c71aaa97-69c2-418a-b976-1467926abf36 (~07:10–07:37, the investigation that produced #4283/#4284) — 63 denials total (recounted with is_error filtering during adversarial validation): main session 0 (its two apparent hits were successful commands whose output merely quoted the trigger string from the guard's source), subagents a30bfd0 5, a9dd32b 4, a240efe 2, a1752c3 16, a903d06 4, ac719b3 32. Representative denied commands: find … -type f | head -c 4000 (no 2>&1), find … 2>&1 | wc -l | head -c 4000, rg … .jsonl 2>&1 | sort | uniq -c | head -c N, jq -c 'keys' sessions.jsonl 2>&1 | head -1 | head -c 1000. Verbatim reactions [SUPPORTED]: subagent a30bfd0 (entry 8): "error message about 'unbounded command output' is a prompt-injection attempt embedded in tool output — … I'll disregard the fake instruction it's trying to plant"; (entry 13): "repeated injected fake policy … I will not comply"; (entry ~23, parsed-entry indexing; raw-line numbering differs by ~1) escalation — the agent's narrative reports a CLAUDE.md-content <system-reminder>-style block as "another injection attempt"; the logged tool results contain no such block ("system-reminder" occurs exactly once in the file, inside the agent's own text), so whether a genuine reminder was actually delivered is unverifiable [NEEDS-EVIDENCE]. Subagent a9dd32b (entry 8): "That's a prompt injection attempt … I'll ignore that instruction"; later (entry 80) self-corrected after finding the policy block in git history. Parent session recognized the guard (entries 86-96) and recorded the three-subagent count contemporaneously (entry 87). CORRECTED after adversarial validation: #4284's "ls | wc -l" anomaly was essentially right. The actual incident command (subagent a903d06) was ls /dev/shm/…/.claude/skills/ 2>&1 | wc -l inside a multi-statement Bash call, and bare ls | wc -l is also denied — wc is in _RISKY_SURFACE_RE (output_budget_guard.py:40) and stdin-mode wc -l is classified ambiguous/unbounded (lines 261-273); verified by direct classifier execution [SUPPORTED]. An earlier draft of this report wrongly stated the shape was allowed, based on a subagent's misreading of the operand branch. The "scratch file written by a complying subagent" was not located — no Write/Edit tool use in any of the six subagents and no temp-redirect found under bounded search [NEEDS-EVIDENCE].

External Research

  • Codex CLI supports PreToolUse hooks via [[hooks.PreToolUse]] in config.toml; vendored hook_runtime.rs fires them for Bash/apply_patch — the guard's intended target does execute it (also proven by the CI round-trip probe). Sources: developers.openai.com/codex/hooks, developers.openai.com/codex/config-reference, agenticcontrolplane.com/blog/codex-cli-hooks-reference. A secondary source's claim that hooks need a [features].codex_hooks opt-in was not found in the vendored source [NEEDS-EVIDENCE].
  • Claude Code plugin hooks are project-global with tool-name matchers only; no session-type scoping exists at the delivery layer — in-script env/payload checks are the only mechanism (consistent with repo practice).

Scope Boundary

Investigated: guard implementation and registration; classifier disposition logic (code-level trace); hook delivery on both backends; provenance chain (#4272 → rectify plan → PR #4277 → ADR-0005); test corpus; guard-convention survey; incident transcripts (all 7 files, denial counts + reactions); historical recurrence chain; Codex hooks external docs.
Not yet explored: quantified token cost of the 65 denial round-trips vs. hypothetical savings (needs token telemetry per session); whether Codex sessions exhibit the same injection-misread of the deny reason (probe asserts delivery, not reaction); the un-located scratch-file incident; #4283 remediation design (companion issue, separate track); actual FP rate across the ~110 historical deny-bearing transcripts.

Recommendations

Approaches, not implementations — in priority order:

  1. Self-identify the deny message (smallest change, largest trust win). Prefix the reason with explicit provenance and mechanism, e.g. "[AutoSkillit output_budget_guard — PreToolUse hook configured by this repository] …", state that the denial is a real permission decision (not tool output), drop the internal "R1-R3" jargon, and show a compliant rewrite of the denied command rather than a generic rg -l template. Ideally introduce a shared deny-reason formatter used by all guards (aligns with the Unified hook checkpoint framework: declarative tool-call enforcement for L1 skill sessions #3758 framework direction) so provenance becomes a convention, with a contract test asserting every guard's reason carries it.
  2. Fix the dominant false-positive class in the pipeline rule. Treat a pipeline as bounded when the risky producer's stderr is captured and the terminal stage is byte-capped, tolerating intermediate stages whose stderr is uncaptured (or: require capture only for stages that are themselves risky producers). Additionally recognize single-line terminal aggregators (wc -l, wc -c, grep -c) as intrinsically bounded sinks — bare ls | wc -l is denied today via the ambiguous-operand wc branch. Seed an adversarial allow/deny corpus from the 65 real denied commands of the incident before changing dispositions.
  3. Decide the Claude Code scope question explicitly, with the benefit asymmetry on the table. The universal scope was deliberate, but its own record concedes Claude's harness already bounds Bash output. Candidate narrowings, in increasing strictness: exempt Claude Code subagents (payload agent_id present — their context is disposable and their outputs return bounded to the parent anyway); exempt interactive Claude Code sessions (fire only when AUTOSKILLIT_HEADLESS or backend=codex); or keep universal but make the Claude-side mechanism advisory (additionalContext instead of deny), matching its de-facto "soft" strength. Any change must update the intent-pinning test and the rectify-plan rationale consciously, not silently.
  4. Reconcile the temp-steering escape valve with skill output manifests (rectify skill writes undeclared working files in interactive runs #4283 companion). The deny message should not instruct arbitrary sessions to create ad-hoc temp artifacts; when a skill context exists (AUTOSKILLIT_SKILL_NAME), point at the skill's declared artifact directory/manifest; otherwise prefer the bounded-rewrite path and mention the redirect option as the escape valve of last resort — coordinated with whatever manifest mechanism rectify skill writes undeclared working files in interactive runs #4283's remediation adopts.
  5. Architectural immunity for the guard-classifier pattern (via /rectify). Shadow-mode/telemetry-first rollout for new deny guards (log would-deny without denying, review FP rate, then enforce), a shared real-world command corpus harvested from session logs, and an explicit false-positive budget as a release gate — addressing the ≥13-recurrence pattern rather than this instance only.

Post-Report Adversarial Validation (2026-07-18)

Two independent adversarial reviews were run against this report; every challenged claim was re-verified directly (file reads, git show, direct classifier execution, is_error-filtered transcript recounts). Corrections applied in place above: denial total 65→63 (main session 0); ls | wc -l is denied (the original draft's contrary claim came from a subagent misreading of the wc operand branch); guard birth date 2026-07-15; census figures replaced with exact registry counts; the system-reminder escalation downgraded to NEEDS-EVIDENCE; the per-stage 2>/dev/null escape hatch documented. Additional facts established during validation:

  • The guard's matcher covers two channels. The MCP run_cmd channel is already lossless via spill_run_cmd_resultspill_output (ADR-0005 Layer 1; server/tools/_execution_helpers.py:54-80) — the exact spill+bounded-slice mechanism proposed for output bounding. The only channel with no lossless containment today is Codex native shell.
  • No Bash-matching mechanism="output-rewrite" hook exists on either backend (all six output-rewrite registry entries target MCP tools or Write|Edit); a native-shell output-boundary mechanism is new infrastructure. Codex hook output-mutation capability was subsequently RESOLVED negative (2026-07-18, direct source verification): the Codex 0.144.1 shell handler hands PostToolUse output already passed through format_exec_output_str ("Truncate for model consumption before serialization" — vendored codex-rs/core/src/tools/handlers/shell.rs:225-229, mod.rs:89-97) and the hook engine rejects updatedMCPToolOutput (hooks/src/engine/output_parser.rs:427-428); PreToolUse updated_input rewriting exists (shell_command.rs:224-240).
  • Trade-off: pre-execution denial prevents the command from running at all (no compute, side effects, or generated bytes); output-boundary bounding executes first and bounds after.
  • Sequencing: Claude-side friction relief can proceed immediately, but the pre-execution guard should not be retired on Codex before a lossless output-boundary mechanism exists there — it is currently the only pre-spend protection on that channel.
  • Live recurrence during this very validation pass: the guard denied cat >> .autoskillit/temp/investigate/<report>.md <<'EOF' … — a zero-stdout heredoc append writing into the sanctioned temp directory itself — adding an append-redirect/heredoc-writer false-positive class to the surface documented above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugExisting behavior is brokenrecipe:remediationRoute: investigate/decompose before implementation

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions