Fix #2876: bound built-in CC tool output via PreToolUse predictive cap - #2877
Conversation
Built-in tools (Read/Grep/Edit/Bash) run inside the Claude Code CLI, so egg can't wrap their output the way it caps its own MCP @tool payloads (#2805). A tool result above the Agent SDK's 1 MB JSON buffer kills the agent with exit 255 (#2804); #2810 made that a clean fail-fast but does not prevent it. PostToolUse can't suppress an oversized built-in payload either — it fires after the payload has crossed the channel that crashes the reader (#2810 dropped that approach). This adds the remaining lever: a PreToolUse hook that fires *before* the tool runs and denies calls likely to overflow, with a reason telling the agent how to narrow the call. This is the layer that unblocked the #2777 slice-1 coder, which crashed reading the 1.1 MB, 24k-line orchestrator/routes/pipelines.py whole. - New shared/egg_agent/tool_output_cap.py with predictive heuristics: - Read: no `limit` and file > EGG_READ_CAP_BYTES (default 256 KiB) -> deny, point at offset/limit. - Grep: output_mode=content with no head_limit and no path/glob scope (whole-repo content dump) -> deny, point at head_limit / files_with_matches. Stays narrow to avoid denying common small greps. - Wired as always-on PreToolUse hooks in client.py (mirrors the _deny_web_tools pattern); the overflow hits every route including first-party Opus. Kill switch EGG_TOOL_OUTPUT_CAP=false. - Tests for the predictive-deny path (Read/Grep) and hook registration. - Document the layer in docs/reference/agent-recovery.md.
This comment has been minimized.
This comment has been minimized.
Check Failure: Integration Tests / Integration TestsWhat's failing: Root cause: This is a CNI-bootstrap flake on the ephemeral k3s runner, not a code problem. Earlier in the same job, the Cilium install never converged — the When the Cilium agent never goes Ready, the This PR's diff is Python-only ( What needs to be done:
Suggestion: If this Cilium-bootstrap flake recurs, consider hardening — Authored by egg |
Autofix tracking{"Test/Integration Tests / Integration Tests": 1} |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review: PreToolUse predictive output cap (#2876)
I traced this end-to-end, including disassembling the bundled claude CLI to verify the overflow premise. The core design is sound and the feature genuinely works in egg's execution environment. One blocking observability gap and several non-blocking heuristic-quality notes below.
What I verified (the premise holds)
The PR's central claim — that an unbounded Read of a large file overflows the SDK's 1 MB JSON buffer in egg's path — is correct, and I confirmed why it differs from interactive Claude Code:
- The bundled CLI's interactive
Readdefault size cap is$X8 = 262144(256 KiB) — exactly the PR's_DEFAULT_READ_CAP_BYTES. Good calibration. - But egg runs via the SDK tool-dispatch path, which invokes tools with
fileReadingLimits:{maxTokens:1/0, maxSizeBytes:268435456}— i.e. infinite token cap, 256 MB byte cap. So in egg's pathReadreads the whole file (the CLI even documents "OMIT to read the whole file"), and any file between ~1 MB and 256 MB overflows the SDK message buffer. The byte-size heuristic is the right shape for this path. - The always-on claim holds: hooks fire even for the role-less string-prompt path.
Query.wait_for_result_and_end_input()keeps stdin open whileself.hooksis truthy, so the bidirectional control protocol delivers the PreToolUse callback. This is not a silent no-op for non-pipeline routes. client.py:198is the onlyClaudeAgentOptionsconstructor andclient.py:427the onlyquery()call — the cap sits at the single chokepoint, so coverage is complete.- The deny mechanism mirrors
_deny_web_toolsat the execution-model level (PreToolUse →permissionDecision: deny), and the SDK honors empty{}as "no opinion → allow" (_convert_hook_output_for_cli({}) == {}). Hook registration ordering vs. the DDG block atclient.py:329+does not clobber. - Tests exercise the real production path (real temp files, real
stat, real check functions) — no self-seeding goldens, no fixtures bypassing the code under test.
Blocking
1. EGG_READ_CAP_BYTES misconfiguration is silently swallowed (tool_output_cap.py:54-64).
def _read_cap_bytes() -> int:
raw = os.environ.get("EGG_READ_CAP_BYTES", "").strip()
if raw:
try:
value = int(raw)
if value > 0:
return value
except ValueError:
pass # <- silent
return _DEFAULT_READ_CAP_BYTES # <- no-op default branchThis is an operator-facing tuning knob, and the function matches two of the named anti-patterns: a silent except ValueError: pass and a no-op fallback branch. An operator who sets EGG_READ_CAP_BYTES=2_000_000 (underscores), EGG_READ_CAP_BYTES=2mb, a typo, 0, or a negative value gets the 256 KiB default with no signal that their deliberately-set value was ignored. If they intended a larger cap they get more false-positive denials than they asked for; if smaller, fewer — either way their intent is dropped silently.
The fix is a one-liner: logger.warning(...) (or raise) when raw is non-empty but doesn't parse to a positive int, before falling back. The default-when-unset path should stay silent — only the set-but-invalid case needs to be loud. There's also no test covering the 0/negative branch; add one alongside the existing test_invalid_env_falls_back_to_default.
Non-blocking
2. Read deny is mis-targeted for non-text files (check_read_output_risk). The heuristic keys purely on byte size, so a >256 KiB image/PDF/notebook is denied with "Re-run Read with 'offset' and 'limit' … (e.g. offset=1, limit=2000)" — advice that does nothing for a binary read, and the agent has no per-call escape (only the global kill switch). Denying is arguably correct (a large image base64-encodes past 1 MB too), so this is a message-quality issue, not a functional break. Consider exempting known binary extensions, or softening the remedy text for non-source files.
3. Read false-negative: a large explicit limit bypasses the cap. tool_input.get("limit") is not None treats any limit as "bounded," so Read(path, limit=10_000_000) on a 1.1 MB file is allowed and still overflows. Since the deny reason actively tells the agent to "use limit," a model could comply with an oversized value. The #2810 fail-fast is the backstop, but consider gating on offset+limit×(est. bytes/line) instead of mere presence.
4. The Grep cap is largely redundant. I confirmed the bundled CLI's Grep documents head_limit "Defaults to 250 when unspecified," and the SDK dispatch override only touches globLimits/fileReadingLimits, not grep — so a whole-repo output_mode=content grep is already capped at ~250 entries (~25 KB of normal code, safe). The genuine overflow risk for Grep is 250 very long lines (minified JS, etc.), which this heuristic doesn't target, while it does deny the common broad-but-safe content grep. It's also trivially bypassed (path="." or glob="**/*"). Harmless and conservative, but the value/false-positive ratio is lower than the Read cap — worth knowing the real risk profile is long lines, not match count.
5. Hook uses captured resolved_cwd rather than input_data["cwd"] (client.py:303). PreToolUseHookInput carries the live cwd; using it would be marginally more robust if the agent's cwd ever diverges from the launch cwd. In practice Read requires absolute paths, so this almost never bites.
6. Minor test isolation. test_read_and_grep_matchers_registered_by_default uses clear=False and relies on EGG_TOOL_OUTPUT_CAP being unset in the ambient env; an env that sets it false would make the test fail spuriously. Pin it explicitly.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- _read_cap_bytes: log a warning when EGG_READ_CAP_BYTES is set but unparseable or non-positive, instead of silently swallowing the operator's intent and using the default (review blocking item). - check_read_output_risk: gate on an estimated payload (limit × ~bytes/line) rather than the mere presence of a limit, so an oversized limit (e.g. limit=10_000_000) no longer bypasses the cap; tailor the deny remedy for binary files (pages for PDFs, no line-paging advice for images/notebooks, which Read returns whole). - client.py hook: prefer the live PreToolUse cwd, falling back to the launch cwd. - tests: cover the 0/negative/unparseable/unset env branches, oversized and binary limit cases, and pin EGG_TOOL_OUTPUT_CAP so ambient env can't cause spurious failures. - docs: update the predictive-cap table for the new Read semantics.
|
Thanks for the thorough end-to-end trace — the disassembly-backed confirmation of the 256 KiB calibration and the always-on string-prompt path was especially useful. Per-item disposition below. All code fixes are in commit 1. 2. Read deny mis-targeted for non-text files — 3. Read false-negative: large explicit 4. The Grep cap is largely redundant — 5. Hook uses captured 6. Minor test isolation —
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: PreToolUse predictive output cap (#2876) — commit 8ca2a84
I re-read the full delta since my last review (f56ee387..8ca2a84), traced the new check_read_output_risk logic by hand, and ran the targeted suites (test_tool_output_cap.py 28 passed, TestBuiltinOutputCapHook 5 passed).
Prior feedback — all addressed
- (1, blocking)
EGG_READ_CAP_BYTESsilent swallow — fixed._read_cap_bytes()now warns loudly on a set-but-invalid value (non-integer and non-positive each get a distinctlogger.warning) and stays silent when unset. Verified the four new branch tests cover it. - (3) oversized
limitbypass — fixed correctly. The check now gates onmin(size, limit × ~128 B/line) ≤ cap, solimit=10_000_000is denied while a normal paginglimitpasses. The suggestedlimit(cap // 128= 2048 at the default) sits exactly at the allow boundary, so a follow-up Read with the suggested value is honored — no off-by-one. - (5) live
cwd— fixed.input_data.get("cwd") or resolved_cwdis correct; I confirmedcwdis a required field onBaseHookInput, so the live value is populated and the fallback only fires if it's ever absent. - (6) test isolation — fixed.
EGG_TOOL_OUTPUT_CAPis now pinned in the cap-on tests. - (4) Grep redundancy — reasonable disagreement; keeping the narrow conservative backstop is fine.
(Side note: except TypeError, ValueError: in _coerce_positive_int is valid here — py3.14 / PEP 758, and it matches the existing codebase style in client.py / egg_config. Not an issue.)
Blocking
The PDF remedy is a dead-end — the hook tells the agent to use pages, then denies the pages-bounded read identically (check_read_output_risk + _read_remedy).
This was introduced by item (2)'s binary special-casing. For a .pdf over the cap, is_binary is true, so the limit/bounding branch is skipped and the call is denied on disk size alone — but the remedy says "Re-run Read with the 'pages' parameter to read a bounded page range (e.g. pages='1-5')." The hook never inspects pages, so the agent that follows the advice is denied again. Verified directly:
Read(big.pdf) -> DENY (remedy: "use pages='1-5'")
Read(big.pdf, pages='1-5') -> DENY (identical — size unchanged)
pages is precisely the mechanism that bounds a PDF read (the analogue of limit for text), and for large multi-page PDFs it's the only way to read the file through Read at all. As written, the hook makes an oversized PDF unreadable via Read (only the global kill switch escapes) and the remedy loops. This is the same class of self-contradiction the limit fix (3) just removed for text — it should be removed for PDFs too.
Fix: honor pages as bounding for PDFs, mirroring the text limit path — e.g. allow when a non-empty pages is present (or, if you want to keep it predictive, treat pages like limit and estimate). At minimum a pages-scoped PDF read must not be denied while the remedy points at pages. Add a test_pdf_with_pages_is_allowed alongside test_pdf_deny_points_at_pages so the remedy and the gate can't drift apart again.
Note this also makes the unbounded PDF deny suspect: a 300 KB PDF extracts to far less than 1 MB of text, so disk-size is a weak proxy for PDF output size — but the actionable defect is the pages dead-end, so that's the one I'm blocking on.
Non-blocking
_read_cap_byteswarns on every call. WhenEGG_READ_CAP_BYTESis misconfigured, the warning fires once perRead— hundreds of identical lines in a long session. A warn-once (module-level flag) would be cleaner. The current behavior satisfies item (1) as requested, so this is just polish..ipynbremedy is weak. Pointing a notebook atfile/statfor "metadata" isn't very useful (you'd wantjq/cell inspection). The image case is honest (genuinely no bounded read), but the notebook advice is thin. Message-quality only.- Long-line false negative persists (acknowledged). The 128 B/line estimate lets a small
limitthrough on a minified-JS/JSON file with multi-KB lines, which can still overflow. This is inherent to a line-count estimate and #2810's fail-fast is the backstop — fine as-is, noting it for the record.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Fix the blocking PDF remedy dead-end: an oversized PDF was denied on disk size alone while the deny reason told the agent to use `pages`, so a pages-scoped re-read was denied identically. check_read_output_risk now treats a non-empty `pages` range as bounding for PDFs (mirroring the text `limit` path; the Read tool caps a pages request at 20 pages), closing the loop. Also make the invalid-EGG_READ_CAP_BYTES warning fire once per distinct value instead of on every Read, and give notebooks a jq-oriented remedy instead of the generic file/stat advice.
|
Thanks for the re-review — the Blocking — PDF Non-blocking — Non-blocking — Non-blocking — long-line false negative persists —
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: PreToolUse predictive output cap (#2876) — commit f697ebb
I re-read the full delta since my last review (8ca2a84..f697ebb, 3 files: tool_output_cap.py, its test, and the recovery doc), traced the new PDF branch by hand, confirmed the module parses under Python 3.14, and ran the targeted suite (test_tool_output_cap.py — 32 passed).
Prior blocking issue — resolved
The PDF pages dead-end is genuinely closed, not papered over. check_read_output_risk now special-cases .pdf: an oversized PDF with a non-empty pages range returns None (allowed), so following the deny remedy (pages='1-5') is honored instead of looping (tool_output_cap.py:205-212). I verified the symmetry argument holds — unlike text limit (where limit=10_000_000 is still unbounded and needs the byte estimate), the Read tool itself caps a pages request at 20 pages ("Maximum 20 pages per request"), so there's no absurd-value escape hatch to guard against; allowing any non-empty pages unconditionally is correct. Empty/whitespace pages (str(pages).strip() falsy) still denies. test_pdf_with_pages_is_allowed and test_pdf_with_empty_pages_still_denied lock the gate and the remedy together so they can't drift apart again.
Prior non-blocking items — addressed
- Warn-once on bad cap env — fixed.
_warn_invalid_capis backed by a module-level_warned_cap_valuesset keyed by the raw value, so a steady misconfiguration logs once but a fixed-then-re-broken knob still warns on the new value. The autouse_reset_cap_warning_cachefixture clears it between tests, andtest_invalid_env_warns_only_once_across_readsexercises the real path (5 calls,call_count == 1). The set is bounded by distinct bad values and the hook is synchronous, so no leak or concurrency concern. - Notebook remedy — fixed.
.ipynbnow gets ajqcell-inspection remedy ahead of the generic binary branch (_read_remedy:154-159), whilecheck_read_output_riskstill judges notebooks on size alone (they're in_NON_PAGEABLE_BINARY_EXTENSIONS, returned whole).test_notebook_deny_suggests_jqconfirms the message containsjqand notoffset/limit. - Long-line false negative — acknowledged as inherent to a line-count estimate, with #2810's fail-fast as the backstop. Fine as-is.
Verification notes
- Tests exercise the production path: real temp files via
_write, realstatthroughcheck_read_output_risk, real check functions — no self-seeding goldens, no hand-built fixtures bypassing the code under test, no name-vs-behavior contradictions. - The
except TypeError, ValueError:in_coerce_positive_intis valid (PEP 758;requires-python = ">=3.14", runtime is 3.14.5). Module parses cleanly. - Doc table (
agent-recovery.md:146-148) matches the code: text / PDF / image-notebook rows are accurate.
No new issues found. The blocking dead-end is fixed correctly and the two polish items landed cleanly.
— Authored by egg
|
egg review completed. View run logs 9 previous review(s) hidden. |
…s [doc-updater] (#2882) Update documentation to reflect changes from bb71f4d: - STRUCTURE.md: add tool_output_cap.py to the egg_agent/ module listing - agent-tools.md: add cross-reference from the MCP output-size cap section to the new PreToolUse predictive cap for built-in CC tools Triggered by: #2877 Authored-by: egg Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
#2877) * Fix #2876: bound built-in CC tool output via PreToolUse predictive cap Built-in tools (Read/Grep/Edit/Bash) run inside the Claude Code CLI, so egg can't wrap their output the way it caps its own MCP @tool payloads (#2805). A tool result above the Agent SDK's 1 MB JSON buffer kills the agent with exit 255 (#2804); #2810 made that a clean fail-fast but does not prevent it. PostToolUse can't suppress an oversized built-in payload either — it fires after the payload has crossed the channel that crashes the reader (#2810 dropped that approach). This adds the remaining lever: a PreToolUse hook that fires *before* the tool runs and denies calls likely to overflow, with a reason telling the agent how to narrow the call. This is the layer that unblocked the #2777 slice-1 coder, which crashed reading the 1.1 MB, 24k-line orchestrator/routes/pipelines.py whole. - New shared/egg_agent/tool_output_cap.py with predictive heuristics: - Read: no `limit` and file > EGG_READ_CAP_BYTES (default 256 KiB) -> deny, point at offset/limit. - Grep: output_mode=content with no head_limit and no path/glob scope (whole-repo content dump) -> deny, point at head_limit / files_with_matches. Stays narrow to avoid denying common small greps. - Wired as always-on PreToolUse hooks in client.py (mirrors the _deny_web_tools pattern); the overflow hits every route including first-party Opus. Kill switch EGG_TOOL_OUTPUT_CAP=false. - Tests for the predictive-deny path (Read/Grep) and hook registration. - Document the layer in docs/reference/agent-recovery.md. * Address #2876 review: warn on invalid cap env, harden Read heuristics - _read_cap_bytes: log a warning when EGG_READ_CAP_BYTES is set but unparseable or non-positive, instead of silently swallowing the operator's intent and using the default (review blocking item). - check_read_output_risk: gate on an estimated payload (limit × ~bytes/line) rather than the mere presence of a limit, so an oversized limit (e.g. limit=10_000_000) no longer bypasses the cap; tailor the deny remedy for binary files (pages for PDFs, no line-paging advice for images/notebooks, which Read returns whole). - client.py hook: prefer the live PreToolUse cwd, falling back to the launch cwd. - tests: cover the 0/negative/unparseable/unset env branches, oversized and binary limit cases, and pin EGG_TOOL_OUTPUT_CAP so ambient env can't cause spurious failures. - docs: update the predictive-cap table for the new Read semantics. * Address #2876 re-review: honor PDF pages, warn-once on bad cap env Fix the blocking PDF remedy dead-end: an oversized PDF was denied on disk size alone while the deny reason told the agent to use `pages`, so a pages-scoped re-read was denied identically. check_read_output_risk now treats a non-empty `pages` range as bounding for PDFs (mirroring the text `limit` path; the Read tool caps a pages request at 20 pages), closing the loop. Also make the invalid-EGG_READ_CAP_BYTES warning fire once per distinct value instead of on every Read, and give notebooks a jq-oriented remedy instead of the generic file/stat advice. --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
…s [doc-updater] (#2882) Update documentation to reflect changes from bb71f4d: - STRUCTURE.md: add tool_output_cap.py to the egg_agent/ module listing - agent-tools.md: add cross-reference from the MCP output-size cap section to the new PreToolUse predictive cap for built-in CC tools Triggered by: #2877 Authored-by: egg Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
…sole-runtime Resolved conflicts: - gateway/contract_api.py: comment-only conflict. HEAD referenced checkpoint_handler.py (deleted by base #2993), base referenced docker-compose service name (removed by this PR). Updated to point at commit_registry_client.py, which still exists post-merge and shares the same orchestrator URL constant. - orchestrator/routes/pipelines.py: refactor-vs-additive in two regions (lines ~9307-10702 and ~10781-11034). HEAD did not intentionally touch these functions (verified via merge-base..HEAD diff: PR's pipelines.py changes are confined to Docker->Kubernetes imports, type annotations, and exception classes). Base's #2777 refactor wins: deleted stale helpers (_pr_metadata_from_plan_draft, _build_github_staging_manual_step, _maybe_open_base_pr_for_plan_to_implement, _resolve_slice_1_context_branch_from_contract), kept new _persist_context_pr_number. Also extended PR's Docker->Kubernetes intent to base's new _sync_worktree_reconciling_divergence by updating its spawner: "ContainerSpawner" forward-ref to "KubernetesSpawner". Lint: ruff check + format pass. Mypy reports two pre-existing errors in shared/egg_agent/client.py:387-388 (introduced by base's #2877 PreToolUse cap hook). File is byte-identical to base's version - errors are not introduced by this merge. Local make build skipped: full build/test takes 10+ min and would exceed session budget. CI will run the full check suite on the pushed result.
Summary
Fixes #2876. Built-in Claude Code tools (
Read,Grep,Edit,Bash) run inside the CLI, so egg can't wrap their output the way it caps its own MCP@toolpayloads (#2805). A tool result above the Agent SDK's 1 MB JSON buffer kills the agent with exit 255 (#2804). #2810 made that a clean fail-fast but doesn't prevent it, and PostToolUse can't suppress an oversized built-in payload — it fires after the payload has crossed the channel that crashes the reader (#2810 dropped that approach).This adds the remaining lever: a PreToolUse hook that fires before the tool runs and denies calls likely to overflow, with a reason telling the agent exactly how to narrow the call. This is the layer that blocked the #2777 slice-1 coder, which crashed reading the 1.1 MB, 24k-line
orchestrator/routes/pipelines.pywhole.What changed
shared/egg_agent/tool_output_cap.py(new) — predictive heuristics:Read: nolimitand file >EGG_READ_CAP_BYTES(default 256 KiB) → deny, point atoffset/limit.Grep:output_mode=contentwith nohead_limitand nopath/globscope (whole-repo content dump) → deny, point athead_limit/files_with_matches. Stays narrow to avoid denying the common small content grep.shared/egg_agent/client.py— wired as always-on PreToolUse hooks (mirrors the existing_deny_web_toolspattern); the overflow hits every route including first-party Opus. Kill switchEGG_TOOL_OUTPUT_CAP=false; threshold tunable viaEGG_READ_CAP_BYTES.Read/Grepon oversized inputs, kill switch, env-threshold, and hook registration.docs/reference/agent-recovery.md— documents the cap as the prevention layer ahead of Fix #2804: bound tool result size to prevent SDK buffer-overflow crashes #2810's fail-fast.These are predictive (the hook can't see the result), so expect some false positives/negatives; #2810's fail-fast remains the backstop when a prediction misses.
Acceptance
offset/limitbefore it overflows the 1 MB buffer.offset/limit/head_limit/files_with_matches).Read/Grepon oversized inputs.Test plan
pytest tests/shared/egg_agent/test_tool_output_cap.py tests/shared/egg_agent/test_client.py— 79 passed.ruff check/ruff format --check/mypyclean on changed files (one pre-existing unrelatedegg_agent_toolsimport-untyped note).Notes
Edit/Writeare not denied: modern CC returns a bounded snippet around the edit, and denying them outright would break editing the very large files #2777 needs.Bashstdout is already truncated by CC. Left as no-ops; revisit if they prove to overflow.Related
@toolcaps (sibling).