feat(attachments): deliver images to the model and share one ingestion layer - #974
Conversation
Arbiter — ✅ no blocking findingsArbiter found no unresolved long-term items that require action before merging Second-order review for Review detailsBoth line-level reviewers (Opus 5 and GPT 5.6) reported no findings, so the only sub-threshold material is the design reviewer's two items: the prose round-trip / regex path-scraper contract concern, and the duplicated 10 MB constant. Judging each against the narrow bar: Prose round-trip contract (design reviewer "Watch"). The channel→provider seam is an internal, in-process contract — not a persisted schema, wire format, or public API. Passing Duplicated 10 MB cap (design reviewer "Suggestion"). A one-line constant import; a future drift would cause an oversized image to fall back to a path reference (with a warning logged), not data loss or a crash. Reversible any time. Neither item is a one-way door or concrete harm caused by this diff. Arbiter-Verdict: PASS No sub-threshold finding meets the long-term-impact bar. Suggested follow-ups (open as issues — non-blocking)
[ARBITER-REVIEWED] e887882 False positive or not applicable? A repository writer can comment: For a broader accepted-risk deferral, apply |
81dc3ce to
8d274cf
Compare
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — 🟡 CONCERNSAdvisory design-level review of Design-Verdict: CONCERNS Sound seam and real fix, but images still round-trip through prose — the regex path-scraper stays the load-bearing contract, and it's a proven defect magnet. Watch
Suggestions
[DESIGN-REVIEWED] e887882 |
Opus 5 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
8d274cf to
b307572
Compare
Disposition for
|
b307572 to
96f9c3b
Compare
Finding 4 resolved in
|
96f9c3b to
8caba3c
Compare
Round 2 disposition for
|
| case | before | reviewer's fix | shipped |
|---|---|---|---|
url \n path |
✗ | ✓ | ✓ |
url + space + path |
✗ | ✗ | ✓ |
https://x.com/logo.png treated as a path |
✗ | ✗ | ✓ |
| filename containing spaces | ✓ | ✓ | ✓ |
| two paths, one line | ✓ | ✓ | ✓ |
 |
✓ | ✓ | ✓ |
Both properties of that regex are now documented in-place with the defect each one prevents, since both were learned from real failures rather than reasoned about up front.
Tests added (8): url+newline, url+space, multiple URLs then a path, two images after a URL, a newline never appearing inside a captured path, remote .png URL not matched, filenames with spaces preserved, markdown-image syntax.
Verification
21,533 passed; isort/flake8 clean; mypy clean across 565 files. One failure, test_sandbox_backend_cache.py::test_fail_closed_transient_message_advises_retry_not_optout, reproduced on a clean origin/main worktree — host cannot unshare, and this PR touches no sandbox code.
Round 3 disposition for
|
8caba3c to
832a5c9
Compare
8803882 to
bc98c48
Compare
…n layer
Problem
-------
Images sent from any chat surface never reached the model as vision input, and
most channels dropped attachments entirely.
`AcpProvider.start()` replaces `AcpClient` with `AcpSessionProvider`, and
`AcpSessionHandle.prompt()` hardcoded a single text block. The only code that
built an ACP image block lived on `AcpClient` — the path that had just been
replaced. So Slack downloaded an image to a temp file and passed the
*filesystem path as prose*; the dashboard did the same with ``.
The model could only see the picture by opening that path with a tool, and only
while the temp file survived.
Fix
---
`prompt_blocks.build_prompt_blocks()` is now the single builder used by BOTH
prompt paths, so they cannot drift apart again. `AcpSessionHandle.prompt()`
emits real image blocks, gated on `promptCapabilities.image` — which the
handshake previously parsed and discarded. When the agent does not advertise
image support the path is left in the text as a tool-openable reference rather
than dropped.
Two further defects surfaced while writing the tests:
* the legacy path regex was greedy with `\s` in its class, so
`/tmp/a.png and /tmp/b.png` matched as ONE span ending at the final `.png` —
not a file, so *every* image in a multi-image message was silently lost. This
was live on Slack, not theoretical. Fixed with a non-greedy quantifier.
* `.svg` sat in the media map while the regex omitted it, making the mapping
unreachable. Now excluded deliberately: it is scriptable XML, not a raster
format.
Shared ingestion layer
----------------------
`messaging/attachments.py` owns everything that happens after bytes land —
classification, caps, magic-byte validation, redaction, document extraction,
SEL audit, temp cleanup — behind a channel-supplied download callback. Host
allowlisting and auth stay channel-side, because only the channel knows them.
`slack/files.py` becomes a thin adapter on top of it, keeping its
`(image_paths, text_blocks)` contract so `events.py` and the busy-message queue
are untouched. Its existing test suite is the proof the migration is faithful.
Three holes closed relative to the original Slack implementation:
* size is re-checked on the DOWNLOADED bytes. Slack trusted the channel's
`size`, which defaults to 0 when absent, so a missing or dishonest value
bypassed the cap entirely.
* image content is validated by signature, and the TRUE type wins: a real JPEG
mislabelled `image/png` still works and its temp file is retyped so the
encoder emits truthful `mimeType`, while a script claiming to be a PNG is
rejected (CWE-434).
* a per-message attachment cap now exists.
Rejections are RETURNED rather than swallowed. Silently dropping an attachment
is the defect this work exists to fix — a user sends a file and the reply just
ignores it. Video is rejected with a visible reason: kiro-cli advertises
`promptCapabilities.image` only.
Tests
-----
- `test_acp_prompt_blocks.py` (17): block shapes, the capability gate's false
branch, over-cap fallback, multi-image, dedupe, every supported suffix, SVG
exclusion, bare filenames not probed as paths
- `test_messaging_attachments.py` (38): post-download size enforcement,
masquerading content rejected, mislabelled-but-valid retyped, redaction,
truncation, video/unsupported rejections, count cap, no-temp-leak on failure,
one bad attachment not losing the others
- `test_slack_files.py`: three assertions updated where silence became a
visible rejection, and the PNG fixtures now carry real 8-byte signatures
rather than the `\x89PNG` shorthand
Manual verification
-------------------
N/A — backend only, no user-visible surface. Verified the wire shape directly:
a real 1x1 PNG produces `{"type":"image","mimeType":"image/png"}` whose base64
round-trips to the original bytes, and the gated path preserves the reference.
Refs #923
bc98c48 to
e887882
Compare
Round 4 disposition for
|
…n layer (kirodotdev#974) Problem ------- Images sent from any chat surface never reached the model as vision input, and most channels dropped attachments entirely. `AcpProvider.start()` replaces `AcpClient` with `AcpSessionProvider`, and `AcpSessionHandle.prompt()` hardcoded a single text block. The only code that built an ACP image block lived on `AcpClient` — the path that had just been replaced. So Slack downloaded an image to a temp file and passed the *filesystem path as prose*; the dashboard did the same with ``. The model could only see the picture by opening that path with a tool, and only while the temp file survived. Fix --- `prompt_blocks.build_prompt_blocks()` is now the single builder used by BOTH prompt paths, so they cannot drift apart again. `AcpSessionHandle.prompt()` emits real image blocks, gated on `promptCapabilities.image` — which the handshake previously parsed and discarded. When the agent does not advertise image support the path is left in the text as a tool-openable reference rather than dropped. Two further defects surfaced while writing the tests: * the legacy path regex was greedy with `\s` in its class, so `/tmp/a.png and /tmp/b.png` matched as ONE span ending at the final `.png` — not a file, so *every* image in a multi-image message was silently lost. This was live on Slack, not theoretical. Fixed with a non-greedy quantifier. * `.svg` sat in the media map while the regex omitted it, making the mapping unreachable. Now excluded deliberately: it is scriptable XML, not a raster format. Shared ingestion layer ---------------------- `messaging/attachments.py` owns everything that happens after bytes land — classification, caps, magic-byte validation, redaction, document extraction, SEL audit, temp cleanup — behind a channel-supplied download callback. Host allowlisting and auth stay channel-side, because only the channel knows them. `slack/files.py` becomes a thin adapter on top of it, keeping its `(image_paths, text_blocks)` contract so `events.py` and the busy-message queue are untouched. Its existing test suite is the proof the migration is faithful. Three holes closed relative to the original Slack implementation: * size is re-checked on the DOWNLOADED bytes. Slack trusted the channel's `size`, which defaults to 0 when absent, so a missing or dishonest value bypassed the cap entirely. * image content is validated by signature, and the TRUE type wins: a real JPEG mislabelled `image/png` still works and its temp file is retyped so the encoder emits truthful `mimeType`, while a script claiming to be a PNG is rejected (CWE-434). * a per-message attachment cap now exists. Rejections are RETURNED rather than swallowed. Silently dropping an attachment is the defect this work exists to fix — a user sends a file and the reply just ignores it. Video is rejected with a visible reason: kiro-cli advertises `promptCapabilities.image` only. Tests ----- - `test_acp_prompt_blocks.py` (17): block shapes, the capability gate's false branch, over-cap fallback, multi-image, dedupe, every supported suffix, SVG exclusion, bare filenames not probed as paths - `test_messaging_attachments.py` (38): post-download size enforcement, masquerading content rejected, mislabelled-but-valid retyped, redaction, truncation, video/unsupported rejections, count cap, no-temp-leak on failure, one bad attachment not losing the others - `test_slack_files.py`: three assertions updated where silence became a visible rejection, and the PNG fixtures now carry real 8-byte signatures rather than the `\x89PNG` shorthand Manual verification ------------------- N/A — backend only, no user-visible surface. Verified the wire shape directly: a real 1x1 PNG produces `{"type":"image","mimeType":"image/png"}` whose base64 round-trips to the original bytes, and the gated path preserves the reference. Refs kirodotdev#923
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. |
Part 1 of #923. Discord's adapter follows in a second PR; this one makes images work everywhere and builds the seam that adapter plugs into.
Problem
Images sent from any surface never reached the model as vision input.
AcpProvider.start()replacesAcpClientwithAcpSessionProvider(providers/acp.py:624), andAcpSessionHandle.prompt()hardcoded a single text block (acp/session_handle.py:396). The only code that built an ACP image block lived onAcpClient— the path that had just been replaced (acp/client.py:3644).So Slack downloaded an image to a temp file and passed the filesystem path as prose. The dashboard did the same via
. The model could only see the picture by opening that path with a tool, and only while the temp file survived — Slack deletes it when the turn ends, so replaying history yields a dead path.Why it matters
Sending a screenshot is one of the most natural things a user does in a chat channel. It silently did nothing useful, with no error and no explanation.
Fix (symptom → root cause → change)
The symptom was "the model ignores my image". The root cause was two prompt paths where only the dead one could encode images. The change makes
prompt_blocks.build_prompt_blocks()the single builder used by both, so they cannot drift apart again.AcpSessionHandle.prompt()now emits real image blocks, gated onpromptCapabilities.image— which the handshake previously parsed and threw away (acp/runtime.pykept onlyloadSession). When the agent does not advertise image support, the path stays in the text as a tool-openable reference rather than being dropped, which is a strictly better fallback than silence.Two further defects surfaced while writing the tests:
\sin its character class, so/tmp/a.png and /tmp/b.pngmatched as ONE span ending at the final.png— not a file, so all images were skipped. This was live on Slack, not theoretical. Fixed with a non-greedy quantifier; filenames containing spaces still work..svgwas unreachable by accident. It sat in the media map while the regex omitted it. Now excluded deliberately and documented: scriptable XML, not a raster format.Shared ingestion layer
messaging/attachments.pyowns everything that happens after bytes land — classification, caps, magic-byte validation, redaction, document extraction viadoc_parser, SEL audit, temp cleanup — behind a channel-supplied download callback. Host allowlisting and auth deliberately stay channel-side, because only the channel knows them (Slack needs a bearer token; Discord needs none but signed CDN URLs).slack/files.pybecomes a thin adapter over it and keeps its(image_paths, text_blocks)contract, soevents.pyand the busy-message queue are untouched. Its existing test suite is the evidence the migration is faithful.Three holes closed relative to the original Slack implementation:
size, which defaults to0when absent — a missing or dishonest value bypassed the cap entirelyimage/pngstill works and its temp file is retyped so the encoder emits truthfulmimeType, while a script claiming to be a PNG is rejected (CWE-434)Rejections are returned, not swallowed. Silently dropping an attachment is the defect this work exists to fix. Video is rejected with a visible reason rather than ignored: kiro-cli advertises
promptCapabilities.imageonly (docs/kiro-cli/acp.md:135-146), and the models behind it do not accept video.The security-posture allowlist moved with the code:
messaging/attachments.pyis registered as inbound sanitisation, andslack/files.pywas removed because it no longer calls a redactor. The drift guard caught that staleness on its own, which is a good sign it works.Tests
test_acp_prompt_blocks.py(17) — block shapes and ordering, the capability gate's false branch, over-cap fallback, multi-image, same-path dedupe, every supported suffix → mime, SVG exclusion, bare filenames not probed as paths, directory-with-image-suffix not readtest_messaging_attachments.py(38) — post-download size enforcement with lying metadata, masquerading content rejected, mislabelled-but-valid retyped, RIFF/WAVE not mistaken for WebP, redaction before truncation, video/unsupported rejection wording, count cap, no temp-file leak on download failure, one bad attachment not losing the others,safe_suffixnever yielding a path componenttest_slack_files.py— three assertions updated where silence deliberately became a visible rejection (missing URL, too-large, download failure), and the PNG fixtures now carry real 8-byte signatures instead of the\x89PNGshorthand. The audio case stays silent on purpose: audio is transcribed on a separate upstream path.Full backend suite: 22,084 passed.
isort/flake8clean;mypyclean across 565 files.Manual verification
N/A for UI — backend only, no user-visible surface changed, so no screenshots.
The wire shape was verified directly rather than only through mocks: a real 1×1 PNG produces
{"type":"image","mimeType":"image/png"}whose base64 round-trips to the original bytes; the capability-gated path preserves the reference as text; over-cap and missing-file cases fall back without dropping the reference.Pre-existing failures (not from this PR)
Verified on a clean
origin/mainworktree, all in files this PR does not touch:test_sandbox_*,test_source_providers,test_issue_radar_gh_bin,test_beacon,test_clipidfd,test_deploy_round30_fixeshardlink — host capability (unshareunavailable, hardlinkEPERM) and host install-path assumptions.test_telegram.py::test_concurrent_queue_adds_share_one_receipt— genuinely flaky concurrency race; fails on cleanmaintoo (2/5 there, 4/5 here across isolated repeats). No Telegram or queue code is touched by this PR.Correction: an earlier revision of this description listed
test_slack_files.py::test_text_temp_file_cleanedas a pre-existing flake. That was wrong. It failed in CI withModuleNotFoundError: No module named 'kiro_crew.slack.files.tempfile'because this PR moved temp handling into the neutral layer, so the patch target stopped existing — the failure was mine. The same root cause explains the local symptom:patch("...slack.files.tempfile.mkstemp")reaches the globaltempfilemodule, so the "exactly 1 temp file" assertion was counting unrelated subsystems' temp files, including the SEL audit's. Now targets the neutral layer and scopes the count to the attachment under test. The file passes in full.Review round (all four findings were real)
Each was validated against the code with file:line evidence before any change; none was rebutted. Two were fixed with a different remedy than prescribed, for reasons stated in the disposition comment.
hooks.safe_read_file_bytes. The rawread_bytes()was inherited from the old encoder, but this PR changed that code from dead to live, so the gate is this PR's responsibility. Paths come from message text and are therefore user-influenced. Did not make the builderasync: the gate is synchronous and so was the read it replaces, so awaiting would churn both call sites while changing nothing about blocking.asyncio.to_thread. Traced with no thread boundary: Socket Mode →_route_message→ingest_attachments→extract_text, so a large PDF stalled every session. Deliberately notsubprocess_executor(), whose workers are documented as reserved for PTY teardown and orphan reaping.assert ['text'] == ['text', 'image']), becausetmp_pathyieldsC:\...there. Implemented platform-gated:\and:are legal POSIX filename characters, so one merged pattern would let merely-mentioned prose likeC:\docs\logo.pngmatch a real file in the CWD.video/webm— the repo's own pre-existing_AUDIO_MIMETYPESalready encoded that — so a transcribed memo also received an "unsupported video" note. Root cause was two sources of truth;SLACK_AUDIO_MIMETYPESis now defined once andclassify()accepts a channel-declared override. The override is opt-in and scoped:video/mp4is still rejected, with a test pinning it.Follow-ups (not in scope)
InboundMessage.attachmentsalready exists andDiscordInboundMessagealready inherits it, so no shared-dataclass change is needed.