feat(mcp-gateway): hold tool-result image blocks to the inline-image budget (#4010) - #4025
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/imaging.py:146 -- function-local False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSAdvisory premise-level review of First-Principles-Verdict: PASS A reported permanent-session-wedge (#4010) fixed at the deepest seam Kiro Crew owns, with every rider declared and traced to the same wedge class. What this change shipsIntent: stop one oversized MCP tool-result image from permanently wedging a session (issue #4010) — a FIX.
The true root cause (kiro-cli replaying unbounded history images) is out of scope and explicitly tracked upstream (kirodotdev/Kiro#9707); this sits at the deepest reachable mechanism. No existing mechanism does this job — the spill layer ( [FIRST-PRINCIPLES-REVIEWED] 7ba19a7 |
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Right seam, right budget: reuses the tested prompt-path machinery at the one tool-result hop Kiro Crew owns, fail-closed where the wedge is, with honestly-scoped limits. Suggestions
[DESIGN-REVIEWED] 7ba19a7 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates are self-rated low-confidence and describe the same class of concern: bounded, contained CPU/latency effects on purpose-built dedicated thread pools — not a crash, data loss, corruption, security hole, or removed guard. Candidate 1 (per-frame decode budget under-counts shrink re-decodes): I confirmed Candidate 2 (large-frame JSON parse on the maintenance pool): I confirmed No new grounded finding emerged from reading the two new modules, the pump integration, or the executor pool. No findings. [OPUS-REVIEWED] 7ba19a7 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
3bbe34b to
4dbf4e6
Compare
|
Review round 1 dispositions (new head GPT 5.6 BLOCKING — header-only pass-through admits truncated images: FIXED. The within-cap branch in First Principles CONCERNS — prompt path skips the new source-pixel ceiling: SUBTRACTION TAKEN. Full suite re-verified at the new head: 55253 passed, remaining failures are the documented pre-existing environment baseline. |
4dbf4e6 to
540c719
Compare
|
Review round 2 disposition (new head GPT 5.6 BLOCKING — fallback re-encode keeps the source mime (TIFF bytes labeled after re-encode as PNG): FIXED, class-wide. Per this pipeline's second-finding rule, the whole mislabel class was audited rather than point-patched:
Tests: Full suite re-verified at the new head: 55257 passed, remaining failures are the documented pre-existing environment baseline. |
540c719 to
25f072f
Compare
|
Review round 3 dispositions (new head GPT 5.6 BLOCKING — per-frame decode work unbounded: FIXED. New GPT 5.6 FINDING (advisory) — function-local PIL import violates top-level-imports: REBUTTED, keeping as-is. This import is deliberately lazy and documented at the site and in the module docstring. The pre-push Opus lane BLOCKING finding on an earlier revision measured the cost of eager loading in this exact module: the gateway daemon ( |
25f072f to
cffc6d9
Compare
|
Review round 4 dispositions (new head Design CONCERNS 1 + First Principles Watch — "moved verbatim" is inaccurate and the prompt-path hardening is undeclared: ACCEPTED. The PR description now says "relocated … and hardened in the move" and carries a dedicated Declared behavior change on the prompt path section naming all three changes (truncated files fail closed, >64MP sources fail closed, emitted mimeType tracks detected bytes) so a human reviews them as behavior changes. Design CONCERNS 2 — probe's \u half routes CJK text frames through the 2-worker image pool: FIXED. The hook is now two-stage: a cheap parse-confirm ( Design CONCERNS 3 — block-count cap truncates legitimate tiny-image batches: FIXED by redesign. First Principles Subtraction — zero-consumer Design Suggestion — "Closes #4010" vs the upstream residual: keeping Full suite re-verified at the new head: 55255+ passed, remaining failures are the documented pre-existing environment baseline. |
cffc6d9 to
223fad9
Compare
|
Review round 5 dispositions (new head First Principles Subtraction — zero-consumer Watch: per-frame pixel budget undeclared in the description: ACCEPTED. The Watch: "byte-identical" claim contradicted by the mislabel fix: ACCEPTED, claim tightened (not the code). Now reads: correctly-labeled compliant images ride through byte-identical; a compliant image whose mimeType lies keeps its bytes but gets the header-detected label. Watch: "Closes #4010" half-fixed: standing disposition from round 4 (kept deliberately; maintainer can strike the line at merge). |
a6f99d8 to
c5324be
Compare
|
Review round 8 disposition (new head GPT 5.6 BLOCKING — wrapped base64 can bypass the encoded-size ceiling: FIXED. The ceiling is measured on the canonical encoding, so canonical is now what goes on the wire: a block passes through untouched only when its Running tally for maintainers: 6 GPT blocking findings across 8 review rounds, every one a genuine defect in progressively deeper territory (truncation integrity → mime labeling → per-frame resource bounds → whitespace DoS → budget starvation → encoding canonicality). All fixed with regression tests; GPT, Opus, and Design have each been ✅/PASS on at least one prior head of this same design, so the architecture itself has been stable since round 4 — the churn is boundary-hardening of the new module, which is exactly what an EXTERNAL-CONTENT seam should get. |
c5324be to
7c2e451
Compare
|
Review round 9 disposition (new head GPT 5.6 BLOCKING — mass rejected blocks amplify the frame past the relay limit: FIXED, with a different mechanism than suggested. The defect is real (280k empty image blocks × ~250-byte omission notes ≈ 70 MiB out of an 8 MiB input → stub drops the frame), but the suggested fix — charging rejected blocks against the pixel budget — would reintroduce the round-7 starvation defect: rejected blocks cost a header read, not a decode, and charging phantom pixels lets junk starve legitimate images again. The actual defect is OUTPUT amplification, so the fix bounds output: at most 4 explanatory omission notes per response ( GPT 5.6 FINDING (advisory) — lazy PIL import: standing round-3 rebuttal, third occurrence, not re-litigating. |
…budget (#4010) Images arriving as MCP tool-result content blocks bypassed the prompt-path downscale entirely: the gateway relay forwarded them raw into kiro-cli's conversation history, where one image over the backend's per-image limits (2000 px longest edge for many-image requests, 5 MiB base64) is replayed to the model on every later turn and permanently wedges the session. - add kiro_crew/imaging.py: a LEAF module owning the budget constants and Pillow machinery (moved from acp/prompt_blocks.py, which now re-exports them), plus the public downscale_image_block() entry point. Leaf placement keeps the gateway daemon from importing the ACP package (import cycle + boot cost); Pillow loads lazily on the first image. A new MAX_IMAGE_SOURCE_PIXELS ceiling (64M px) refuses adversarially compressed huge-area rasters on the header read, before any decode - add mcp_gateway/image_budget.py: rewrite every image content block in a relayed tools/call response to the same budget the prompt path enforces; compliant images ride through byte-identical, oversized ones are downscaled in place, and a block whose compliance cannot be established (undecodable, bomb refusal, over the source ceiling, Pillow absent) is replaced by a text block -- fail closed per block. Base64 is decoded whitespace-tolerantly so 76-column-wrapped producers are not regressed. The pump pre-filter is escape-aware: evading the literal probe requires a JSON \u escape, which the second probe half matches, so a negative answer is provable - hook it into the backend stdout pump before the spill step, on a dedicated bounded image_executor() pool so seconds-long Pillow work can never occupy the maintenance workers the orphan sweeps need Out of scope, documented: images from kiro-cli's own built-in tools never transit Kiro Crew (upstream cap needed), and MCP embedded resource blobs are not rewritten (no brokered path renders them). Closes #4010
7c2e451 to
7ba19a7
Compare
|
Review round 10 disposition (new head GPT 5.6 BLOCKING — within-cap unsupported formats pass through unconverted: FIXED. The byte-identical fast path now requires BOTH conditions: dimensions within cap AND detected format in the known-good table. A within-cap TIFF (or any format outside the table) falls through to the existing re-encode machinery as a format CONVERSION at original dimensions — no resize — emerging as PNG with a matching label. Regression test: 300x200 TIFF converts to a 300x200 PNG. This closes the last cell of the 2x2 matrix (dimensions x format) whose other three cells earlier rounds covered. GPT 5.6 FINDING (advisory) — lazy PIL import: standing round-3 rebuttal, fourth occurrence. Process note for maintainers: this session's autonomous fix budget (10 iterations) is now exhausted. Ten review rounds produced 7 genuine GPT blocking findings, each fixed with a regression test, and GPT/Opus/Design/First-Principles have each PASSed at least one head of this design. If the next round produces another blocking finding, this session escalates to a human decision rather than continuing to iterate. |
…budget (kirodotdev#4010) (kirodotdev#4025) Images arriving as MCP tool-result content blocks bypassed the prompt-path downscale entirely: the gateway relay forwarded them raw into kiro-cli's conversation history, where one image over the backend's per-image limits (2000 px longest edge for many-image requests, 5 MiB base64) is replayed to the model on every later turn and permanently wedges the session. - add kiro_crew/imaging.py: a LEAF module owning the budget constants and Pillow machinery (moved from acp/prompt_blocks.py, which now re-exports them), plus the public downscale_image_block() entry point. Leaf placement keeps the gateway daemon from importing the ACP package (import cycle + boot cost); Pillow loads lazily on the first image. A new MAX_IMAGE_SOURCE_PIXELS ceiling (64M px) refuses adversarially compressed huge-area rasters on the header read, before any decode - add mcp_gateway/image_budget.py: rewrite every image content block in a relayed tools/call response to the same budget the prompt path enforces; compliant images ride through byte-identical, oversized ones are downscaled in place, and a block whose compliance cannot be established (undecodable, bomb refusal, over the source ceiling, Pillow absent) is replaced by a text block -- fail closed per block. Base64 is decoded whitespace-tolerantly so 76-column-wrapped producers are not regressed. The pump pre-filter is escape-aware: evading the literal probe requires a JSON \u escape, which the second probe half matches, so a negative answer is provable - hook it into the backend stdout pump before the spill step, on a dedicated bounded image_executor() pool so seconds-long Pillow work can never occupy the maintenance workers the orphan sweeps need Out of scope, documented: images from kiro-cli's own built-in tools never transit Kiro Crew (upstream cap needed), and MCP embedded resource blobs are not rewritten (no brokered path renders them). Closes kirodotdev#4010
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Summary
Images arriving as MCP tool-result content blocks bypass the prompt-path image downscale (
acp/prompt_blocks.py) entirely: the gateway relay forwards them raw into kiro-cli's conversation history. Because kiro-cli replays the full history to the model every turn, a single image over the backend's per-image limits (2000 px longest edge for many-image requests, 5 MiB base64) permanently wedges the session — the offending block sits at a fixed history index and nothing can evict it (issue #4010's Bedrock error).This PR applies the same, already-tested budget at the one tool-result seam Kiro Crew owns: the MCP gateway's response relay.
What changed
kiro_crew/imaging.py(new) — the budget constants and Pillow machinery, relocated fromacp/prompt_blocks.py(which re-exports the constants) and hardened in the move, plus the publicdownscale_image_block()entry point. A leaf module on purpose: the gateway daemon (gatewayd) imports it viamcp_gateway.backend, and importing it fromacp.prompt_blockswould pull the whole ACP client into the broker and close an import cycle back intomcp_gateway. Pillow loads lazily on the first image. NewMAX_IMAGE_SOURCE_PIXELSceiling (64M px) refuses adversarially compressed small-bytes/huge-area rasters on the header read, before any decode — Pillow's own bomb guard only hard-errors at ~357M px (~1.4 GiB of pixels).mcp_gateway/image_budget.py(new) — rewrites every{"type":"image"}block in a relayedtools/callresponse: correctly-labeled, canonically-encoded compliant images ride through byte-identical (a compliant image with a lyingmimeTypeor wrapped base64 keeps its bytes but gets the header-detected label / canonical encoding, since the byte ceiling is measured on the canonical form); oversized ones are downscaled in place; a block whose compliance cannot be established (undecodable, bomb refusal, over the source ceiling, still over the byte ceiling at the 256 px floor, or Pillow absent) is replaced by a text block — fail closed per block. Base64 decodes whitespace-tolerantly sobase64.encodebytes-style 76-column producers are not regressed. Cumulative decode work per response is bounded by a source-pixel budget (MAX_FRAME_SOURCE_PIXELS, 4x the per-image ceiling, charged from header reads): blocks past it are replaced by a distinct "too much image processing" text block without ever being decoded, so a legitimate many-small-image result passes whole while an adversarial frame is bounded at four maximum-size decodes. Structural failures (non-JSON, noresult.content) forward the original line, mirroring the sibling spill layer.mcp_gateway/backend.py— the stdout-pump hook, before the spill step. The pre-filter is escape-aware and its negative answer is provable: evading the literal"image"probe requires writing a letter as a JSON\uescape (the only escape yielding a letter), and the\ubytes are the probe's second half. Pillow work runs on a dedicated pool.executors.py— new boundedimage_executor()(2 workers,mc-image), following thediscovery_executorprecedent: externally-paced, seconds-long Pillow work queues among itself and can never occupy the maintenance workers the orphan sweeps need.mcp-gateway-oversize-response.mdgains a "Layer 3" section;acp-client.mdpoints at the relocated machinery.test/test_mcp_gateway_image_budget.py(new, 24 tests): downscale-in-place, byte-identical pass-through, all five fail-closed paths, escaped-typerewrite, whitespace-wrapped base64, probe soundness, entry-point budget checks. Two pump-wiring tests intest_mcp_gateway_backend_coverage.py.Declared behavior change on the prompt path
The relocated machinery is NOT byte-identical to what
prompt_blocks.pyhad, and the prompt path (dashboard/Slack/Discord attachments) inherits the hardening: (1) a truncated-but-header-readable file that previously inlined byte-identical now fails closed to a path reference (img.verify()on the pass-through branch); (2) a source over 64M pixels now fails closed before any decode (MAX_IMAGE_SOURCE_PIXELS); (3) the emittedmimeTypenow tracks the header-detected format rather than the file extension, so a mislabeled attachment reaches the backend with the label its bytes actually are. All three close the same wedge class on the prompt path that this PR closes on the tool-result path; they are deliberate, tested, and called out here so they get reviewed as behavior changes, not riders.Scope, disclosed honestly
The issue's primary reproducer — kiro-cli's own built-in Read tool in image mode — never transits Kiro Crew (Kiro Crew advertises
fs.readTextFile: false; kiro-cli reads files and stores tool results itself). That path must be capped upstream in kiro-cli and is tracked there (kirodotdev/Kiro#9707). What Kiro Crew can and now does enforce is every brokered MCP server's tool result (browser/Playwright captures and any third-party server) when the MCP gateway is enabled — the interception point the issue asks for ("KiroCrew's own tools... will produce the same problem until KiroCrew enforces the cap"). MCP embedded-resource blobs are deliberately not rewritten (no brokered path renders them to the model today); documented next to the built-in-tools carve-out.Pre-push review (dual model-pinned lanes)
MAX_IMAGE_SOURCE_PIXELSheader-read ceiling).acpimport cycle intogatewayd(fixed: leafimaging.py), Pillow work on the maintenance pool (fixed: dedicatedimage_executor). 4 advisory — strict base64 regression (fixed: whitespace-tolerant), fail-open at debug level (fixed: warning + server name, scheduling vs rewrite failures now distinguishable), embedded-resource scope (documented carve-out), no-Pillow fail-open (fixed: gateway seam fails closed).Testing
isort/flake8/mypy(981 files) clean; brand, harness-parity, docs-lint, scrub-lint gates green.import kiro_crew.mcp_gateway.backendloads zerokiro_crew.acpmodules and noPIL; Pillow loads on the first image block.Closes #4010