Skip to content

feat: preserve opaque inbound attachments - #3754

Merged
bolichen97 merged 1 commit into
kirodotdev:mainfrom
Jos-HJD:feat/3515-arbitrary-attachments
Sep 9, 2026
Merged

feat: preserve opaque inbound attachments#3754
bolichen97 merged 1 commit into
kirodotdev:mainfrom
Jos-HJD:feat/3515-arbitrary-attachments

Conversation

@Jos-HJD

@Jos-HJD Jos-HJD commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

An inbound attachment in a format the gateway does not recognise never reaches the
agent as a file. messaging/attachments.py classified video/* and everything
unrecognised as VIDEO / OTHER and rejected both before downloading, so the
turn carried only a note:

[Attached file: archive.zip (application/zip, 5000 bytes) — unsupported type]

The bytes were available — Slack exposes them via url_private_download, Discord and
Telegram via their own CDNs — but the agent got a sentence about a file it could not
open. Sending a zip, an MP4, a binary payload, or an SVG produced a session that knew
something arrived and could do nothing with it.

Why it matters

The formats that are "unsupported" for model inlining are often exactly the ones a
tool-using agent is best at: unpack the archive, read the manifest inside it, hash the
binary, run a converter. Rejecting them before download decides on the model's behalf
that a file is useless, when the useful path was never the model's eyes — it was the
agent's file tools. Every channel inherited the same dead end, because the rejection
lives in the shared ingestion layer rather than in any one adapter.

What changed (motivation → approach → change)

Symptom → root cause. The rejection sat before _fetch(), so no policy change
downstream could recover the bytes. The classifier's job — "can the model inline
this?" — had been overloaded into "should we keep this at all?". Those are different
questions: an MP4 is not inlineable, but it is perfectly storable.

Change. VIDEO and OTHER now flow through the same bounded download path the
other classes use, and land in a new IngestResult.file_paths:

  • a new IngestLimits.max_opaque_bytes (50 MB, matching the Dashboard upload ceiling),
    checked against channel metadata before download and against the actual bytes
    after, like every other class
  • the file is preserved byte-for-byte in a randomized tempfile.mkstemp path; nothing
    parses, extracts, or executes it
  • the agent gets the local path plus a metadata block naming the original file, its
    declared type and its real size
  • temp_paths now includes file_paths, so the existing per-turn cleanup owns them —
    no new lifecycle
  • the fix is in the shared layer, so Slack, Discord, Telegram, Webex, WeCom, Teams and
    Weixin all get it

Slack's adapter returns image_paths + file_paths and keeps the historical
(paths, text_blocks) shape; its queue kwarg keeps the name image_temp_paths so no
existing caller or test has to move.

One hole found in review, and closed

A sender supplies filename and mimetype independently (Telegram sendDocument,
Discord filename / content_type), so an opaque file can arrive named photo.png
while declaring application/octet-stream. safe_suffix is traversal-safe but keeps
the extension, so the temp path ended .png — and acp/prompt_blocks.py selects
prompt paths and their mimeType by suffix alone, with no comparison against
content. That path would be emitted as image/png on the strength of a sender-supplied
name, without the sniff_image_mime check the IMAGE branch enforces for exactly this
reason.

Scope, stated precisely: _fit_encoded_budget does call Pillow Image.open, so
arbitrary non-image bytes normally fail closed there. The residual exposure is a
decodable raster at a mismatched suffix travelling with untruthful wire metadata,
plus the _HAS_PIL == False fallback which passes bytes through unparsed.

Closed by stripping an inlineable image suffix before ownership transfers, reusing the
os.replace retype the IMAGE branch already performs — and failing closed: a
rename that does not happen drops the attachment (audited, with a visible reason)
instead of emitting the original path. A contract test pins the suffix set against
IMAGE_MEDIA_TYPES so the two cannot drift.

Tests

Added to the existing files — no new test file:

  • byte-identical preservation for MP4 / ZIP / octet-stream, asserting the metadata
    block carries name, declared type and actual size
  • max_opaque_bytes enforced on downloaded bytes when metadata lies (size=0)
  • temp_paths covers image + audio + opaque, and cleanup removes all three
  • a video/webm voice memo still transcribes under the audio override, while other
    video types are preserved as opaque files
  • an opaque file never keeps an inlineable image suffix (parametrized over png,
    PNG, jpg, jpeg, gif, webp, bmp), asserting .bin as a hard-coded
    expectation rather than against the production constant
  • a rename that fails drops the attachment, reports it, and leaves no temp file
  • a contract test pinning the suffix set to acp.prompt_blocks.IMAGE_MEDIA_TYPES
  • IngestResult's new field is appended last, pinned by a positional-construction test
  • Slack: byte-identical ZIP download, pre-download 50 MB rejection, SVG preserved as an
    opaque file instead of rejected
  • Telegram: complete video bytes in file_paths, then caller cleanup
  • Discord: download happens, the prompt carries [Attached file: archive.bin] and one
    .bin path, and the path is gone after the turn

Every new assertion was mutation-verified: with the guard removed each one fails. That
caught a real defect in the first draft of this PR's own tests — the suffix assertion
was written against _INLINEABLE_IMAGE_SUFFIXES itself and passed vacuously when that
set was emptied.

Manual verification

N/A — covered by unit tests plus the mutation pass above. Not exercised: delivery
through a live Slack/Discord/Telegram workspace, which needs credentials and a real
inbound file.

Screenshots

N/A — backend only, no UI surface changes.

Pre-existing issues this change makes larger

Local review raised two more defects. Both are real and both predate this change. One has
since been fixed upstream and this branch is rebased onto that fix; the other is only
widened here and is disclosed rather than folded in.

  1. Synchronous writes during download. RealSlackClient.download_file does
    open(dest, "wb") and f.write(chunk) on the event-loop thread (8 KB at a time,
    with a network await between chunks). That file is untouched here; the same path
    already served IMAGE, TEXT and DOCUMENT. What changes is the largest accepted
    payload on it: 20 MiB → 50 MiB.
  2. Queue-discard temp-file leak — now fixed upstream, no longer applicable. This was
    reported as fix: unlink queued attachment temp files when entries are discarded #3768 and fixed by fix: unlink temp files when queued entries are discarded (#3768) #3776 (717b0c91), which this branch is rebased onto.
    session.unlink_queued_temp_paths() unlinks every path in a discarded entry's
    image_temp_paths, on cancel_queued, clear_queue, dequeue's cancelled-skip, and
    the _pending_queue drops in _handle_message_deleted and the !stop handler.
    Because this PR routes image, audio and opaque paths through that same kwarg, opaque
    files are covered by that fix without further change here. The remaining known gap is
    upstream's own: session-teardown paths (restart/remove/destroy/idle sweep) drop
    session.queue without unlinking.

Two further notes worth your judgement: retained opaque bytes are bounded only by
max_attachments × max_opaque_bytes with no aggregate budget, and Weixin's
fetch_cdn_bytes buffers a whole object in RAM before writing, which this change makes
newly reachable for video and unrecognised items (bounded by its own 32 MB
MAX_CDN_BYTES).

Notes

  • docs/system-specs/modules/messaging.md and slack-gateway.md are updated in this
    commit, per AGENTS.md. Those are the only two module specs that document inbound
    attachments.
  • Local gates: 479 targeted tests, isort, flake8, mypy src/kiro_crew/, plus the
    frontend tsc -b + build, vitest (18128 passed) and the Electron suite (922 passed).
    mypy reports 3 pre-existing os.*xattr errors in hooks.py that only appear on
    macOS.

Closes #3515

@Jos-HJD
Jos-HJD requested a review from a team as a code owner August 15, 2026 05:55
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention labels Aug 15, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator

👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated.

Missing sections:

  • ## Problem / Motivation
  • ## What changed

Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle.

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 15, 2026
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review (fork) — ✅ no blocking findings

Reviewed af79162351bfaf4c82be6afd229b042d23c4d543 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] af79162

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5, fork) — 🟡 CONCERNS

Design-level review of af79162351bfaf4c82be6afd229b042d23c4d543 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound root-cause fix in the right shared layer; the one risk left open is unbudgeted disk exposure through a lifecycle with a known leak.

Watch

Per-message opaque exposure is max_attachments (10) × max_opaque_bytes (50 MB) = 500 MB transient per message, held alive across queued turns, and the disclosed session-teardown paths ("drop session.queue without unlinking") now leak 50 MB files instead of 10 MB images — an allow-listed sender on an unattended small host can exhaust disk with no in-product ceiling.
Clears when: an aggregate per-message (or per-queue) opaque byte budget lands, or a human explicitly accepts the 500 MB/message bound and the teardown leak is tracked as a follow-up issue.

Suggestions

  • Neutralize the suffix at mkstemp time instead of renaming after download: kind is known before _fetch chooses the suffix, so mapping inlineable suffixes to bin for VIDEO/OTHER there deletes the os.replace, the fail-closed drop branch, and the rename-failure test entirely — the whole "could not be stored safely" case stops existing.

[DESIGN-REVIEWED] af79162

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5, fork) — ✅ PASS

Premise-level review of af79162351bfaf4c82be6afd229b042d23c4d543 via the fork AI-review pipeline — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All claims verified against the base tree. Everything substantive in this change checks out: the encoder's suffix-only typing (prompt_blocks.py:177-178), the 50 MB dashboard ceiling (dashboard/handlers/files.py:971), the shared-layer reach (5 channel adapters call ingest_attachments), the counted consumers of the new field, and the author's stated reason for the test-pinned suffix copy (prompt_blocks.py:36 imports the heavy kiro_crew.hooks module, so importing IMAGE_MEDIA_TYPES into the ingestion layer has a real cost the contract test avoids). The deleted rejection tests pinned a reason ("models do not accept video") that concerned inlining only, and the new behavior still never inlines video, so no prior decision is contradicted.

First-Principles-Verdict: PASS

Verify the default-on exposure a human should own: 10 × 50 MB per message to gateway disk, Slack still writing synchronously on the event loop.

What this change ships

Intent: give the agent the actual bytes of video/zip/binary/SVG attachments as local files its tools can act on, instead of an "unsupported type" note — ADDITION.

Inventory (8 items)
  1. A video, archive, binary or SVG sent on any channel now reaches the agent as a local file — justified
  2. The "unsupported type" / "video is not supported" replies are gone — justified
  3. A name/type/actual-size note accompanies each preserved file — justified
  4. New 50 MB per-file cap, checked before and after download — justified
  5. An opaque file named photo.png lands as .bin, never an image suffix — justified
  6. A file whose suffix cannot be neutralized is dropped with a visible reason — justified
  7. Opaque files ride the existing per-turn cleanup, including the queued-turn path — justified
  8. Two module specs updated in the same commit — justified

The zero option on item 1 leaves users sending files the agent is told about but cannot open, and the consumption path is already recorded in base (prompt_blocks.py:147-150: a path left as text "a tool-capable agent can open"). IngestResult.file_paths has 3 counted production consumers (temp_paths, append_attachment_context, slack/files.py). The suffix strip is verified against prompt_blocks.py:178, which types by suffix alone; it reuses the IMAGE branch's existing retype mechanism, and the deeper encoder-side fix is out of scope and disclosed as such.

[FIRST-PRINCIPLES-REVIEWED] af79162

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review (fork) — ✅ no blocking findings

Reviewed af79162351bfaf4c82be6afd229b042d23c4d543 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] af79162

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 15, 2026
@Jos-HJD

Jos-HJD commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Review dispositions for cc8ad8e12d8aeefc40cb411035474e5d116e98d8

  1. Template comment — fixed. The PR body now uses the exact required headings ## Problem / Motivation and ## What changed (motivation → approach → change). The code SHA is unchanged.

  2. GPT 5.6 blocker — rebutted, with follow-up tracking. The queue-discard mechanism is real, but it predates this patch: image paths already travel in the same historical image_temp_paths kwarg, while cancel_queued / clear_queue discard entries without unlinking them. This PR widens the accepted per-file ceiling from 20 MiB to 50 MiB; it does not introduce the missing ownership release. Reverting opaque queuing would remove the feature requested by Preserve arbitrary inbound attachments as opaque session files #3515 rather than fix the lifecycle owner. The root-cause fix is tracked in fix: unlink queued attachment temp files when entries are discarded #3768. A repository writer can record the required false-positive judgment for this exact SHA with:

    /ai-review override gpt cc8ad8e12d8aeefc40cb411035474e5d116e98d8: Queue-discard cleanup predates this PR and is tracked in #3768; reverting opaque queuing would remove #3515 rather than fix the lifecycle owner.

  3. Design Review CONCERNS — accepted and deferred. The increased retained-byte ceiling and lack of an aggregate queued-byte budget are valid widened risks. Queue ownership cleanup plus regression coverage and aggregate-budget consideration are tracked in fix: unlink queued attachment temp files when entries are discarded #3768. The suggested create-as-.bin simplification is reasonable locally, but it would still preserve a producer-side suffix table; the sink-level hardening in security: derive ACP image MIME from decoded content #3769 is the broader subtraction. The current implementation remains fail-closed and mutation-tested until that lands.

  4. First Principles CONCERNS — accepted and deferred. Deriving ACP image MIME from decoded content is the right cross-producer hardening and would eventually allow redundant producer guards/tables to be removed. It affects every image producer and the no-Pillow fallback, so it is intentionally separated from opaque preservation and tracked in security: derive ACP image MIME from decoded content #3769. For this PR, the producer boundary remains a tested fail-closed guard.

No code was changed or re-pushed for these dispositions.

@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Aug 15, 2026
@Jos-HJD
Jos-HJD force-pushed the feat/3515-arbitrary-attachments branch from cc8ad8e to 987f9d1 Compare August 15, 2026 16:56
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 15, 2026
@Jos-HJD

Jos-HJD commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Backend Tests (3.12, 1) failure on 987f9d10 is an unrelated timing flake — a re-run is all it needs

Rebased onto 4843d25a to clear the merge conflict. One job then failed:

FAILED test/test_app_manager.py::TestCopyAppTree::test_install_off_loop_does_not_block_event_loop
  - assert 1.3077502660000846 < 1.0
1 failed, 13417 passed, 31 skipped in 965.52s

Evidence that this is runner contention rather than a regression from this PR:

  • The same shard passed on the other interpreter in the same run. Backend Tests (3.10, 1) — identical shard split, identical commit — reported 13418 passed, 31 skipped in 462.59s. The 3.12 shard took 965s for the same work, i.e. roughly 2× slower, and the assertion that failed is a wall-clock one: the test copies 2000 files via asyncio.to_thread(install_app, src) and requires the heartbeat loop's max(gaps) < 1.0s.
  • This PR does not touch that code path. app_manager.py and test/test_app_manager.py are not in the diff (9 files: shared attachment ingestion, Slack files/events, four test files, two module specs), and neither file was modified by the 37 upstream commits this branch was rebased over.
  • Coverage Gate is downstream of it. Its log shows backend-test=failure -- failing closed.; it has no independent finding.
  • Screenshot Evidence shows cancelled from Canceling since a higher priority waiting request for screenshot-evidence-3754 exists — ordinary concurrency supersession, and it is green on the latest attempt.

I deliberately did not push a no-op commit to retrigger, and did not touch the timing threshold in a test this PR has no business changing. gh run rerun is admin-only, so a re-run of that one job by a maintainer should clear it. Everything else on 987f9d10 is green: all other backend shards (3.10 ×4, 3.12 ×2/3/4, Windows ×4, namespace sandbox), frontend, E2E, lint/type, SAST, and the AI-review lanes.

Locally on the rebased commit: 479 targeted tests pass (the 6 attachment/queue/Slack-event files), isort and flake8 clean, mypy src/kiro_crew/ clean apart from 3 pre-existing macOS-only os.*xattr errors in hooks.py.

@Jos-HJD

Jos-HJD commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: that flake also blocks the AI-review round from starting

Worth flagging because it makes the re-run load-bearing rather than cosmetic. On 987f9d10 the CI workflow run (31896884702) concluded failure solely because of the test_install_off_loop_does_not_block_event_loop timing assertion described above.

The Stage-2 fork review lanes gate on that conclusion:

# .github/workflows/fork-gpt-review.yml
workflow_run:
  workflows: ["CI"]
  types: [completed]
...
if: >-
  github.event.workflow_run.event == 'pull_request'
  && github.event.workflow_run.conclusion == 'success'

So GPT 5.6 Review, Opus 4.8 Review, Design Review, First Principles Review and UX Review are all sitting at skipped for this commit — not "late", never triggered. They cannot produce a verdict, and PR Readiness cannot go green, until CI completes successfully for this SHA.

That leaves a re-run of run 31896884702 (or just its Backend Tests (3.12, 1) job) as the only way forward, and gh run rerun reports Must have admin rights to Repository for me. I would rather ask for that one click than push a no-op commit to retrigger, since a fresh empty revision would also discard the already-green results for every other lane on this SHA.

For completeness, the previous revision cc8ad8e1 did complete a full review round: Opus 4.8, Design Review, First Principles Review and UX Review all passed there, and the only blocking finding was GPT 5.6's queue-discard leak — which is exactly what #3776 has since fixed upstream and what this rebase incorporates.

@Jos-HJD

Jos-HJD commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Ask: could a maintainer re-run CI on 987f9d10?

One click is all this needs — re-run run 31896884702, or just its Backend Tests (3.12, 1) job.

Why it needs a maintainer rather than me: gh run rerun answers Must have admin rights to Repository for a fork contributor, and the failure is a wall-clock timing assertion in test_app_manager.py that this PR does not touch — the same shard passed on 3.10 in the same run (462s) while the 3.12 runner took 965s for identical work. Evidence is in the two comments above.

Why it is load-bearing rather than cosmetic: the Stage-2 fork review lanes gate on workflow_run.conclusion == 'success', so while CI is red the GPT 5.6, Opus 4.8, Design, First Principles and UX checks stay at skipped and never produce a verdict. PR Readiness cannot go green until CI succeeds for this SHA.

I have deliberately not pushed a no-op commit to retrigger, since a new revision would discard the results every other lane already produced on this SHA. If you would rather I refresh the branch than have someone re-run, say so and I will.

Happy to do anything else that helps — the branch is one commit on 4843d25a and green locally (479 targeted tests, isort, flake8, mypy src/kiro_crew/).

@Jos-HJD
Jos-HJD force-pushed the feat/3515-arbitrary-attachments branch from 987f9d1 to 71f432d Compare August 16, 2026 02:58
@Jos-HJD

Jos-HJD commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto fa30219b — this also retriggers the round the flake blocked

71f432de, still one commit, still the same 9 files. No conflicts across the 50 upstream commits since 4843d25a, and no content change from 987f9d10 — this is a base refresh, not a revision of the work.

I am doing it rather than continuing to wait on the re-run I asked for above, for two reasons. A fresh SHA runs CI again without needing admin rights, and if it completes green the five Stage-2 review lanes finally trigger instead of sitting at skipped. And the green results I was protecting on 987f9d10 had aged onto a base 50 commits behind, so preserving them stopped being worth the stall.

If Backend Tests (3.12, 1) fails again on the same wall-clock assertion in test_app_manager.py, that is the load-sensitive flake documented above and not this PR — the file is untouched by these 9 files and by the upstream commits in between.

Local gates on 71f432de: 479 targeted tests, isort, flake8, mypy src/kiro_crew/ (3 pre-existing macOS-only os.*xattr errors in hooks.py).

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 16, 2026
@github-actions github-actions Bot added the readiness: action required A blocking check or review needs attention label Aug 16, 2026
@Jos-HJD

Jos-HJD commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

GPT 5.6 on 71f432de — rebutted, with a second model reaching the same conclusion on the same commit

First, the good news this revision brought: the rebase cleared the flake, CI completed, and the review round that was stuck at skipped actually ran. Opus 4.8 passed, Design, First Principles and UX passed, unresolved threads 0. The one remaining red is GPT 5.6, and it has moved to a different finding than last round — the queue-discard leak it blocked on before is gone, because upstream #3776 fixed it and this branch is rebased onto that.

The new finding is the synchronous-write one, with Fix: Restore VIDEO/OTHER rejection until Slack file writes are offloaded.

Why I am not taking it

The write it names is not in this diff. The f.write is in RealSlackClient.download_file at slack/client.py:734:

with open(dest, "wb") as f:
    async for chunk in resp.content.iter_chunked(8192):
        f.write(chunk)

git diff --name-only <merge-base>..HEAD | grep -c slack/client.py returns 0. That file is untouched by these 9 files, and this loop already served IMAGE, TEXT, DOCUMENT and AUDIO before this PR. What this PR changes is the largest payload accepted onto it: 20 MiB → 50 MiB. That is a widening of an existing exposure, disclosed in the PR body, not a new mechanism.

Opus 4.8 independently evaluated the same candidate on this exact SHA and killed it. Quoting its published reasoning, not paraphrasing:

Candidate 1 mechanics are confirmed: slack/client.py:download_file streams iter_chunked(8192) with no running byte cap … But this "download-then-getsize" shape is identical to the pre-existing IMAGE/TEXT/DOCUMENT/AUDIO branches — the module's docstring/comment explicitly documents the pre-download check as "advisory only" and post-download getsize as authoritative. The diff extends this documented, accepted pattern to two more mimetypes; it does not introduce the unbounded-transient-write capability (an authorized sender could already do this via a size-lying image). The trust boundary is a single authorized OS user, and the minimal fix (streaming cap) lives in untouched download callbacks. Below 80; killed.

Two models now disagree on this commit, and the one that ruled it out did so by naming where the minimal fix actually lives — in the untouched download callbacks, not here.

The suggested fix deletes the feature. Restoring the pre-download VIDEO/OTHER rejection is precisely the behaviour #3515 asks to remove: it is what makes a zip, an MP4 or a binary reach the agent as a sentence instead of a file. I would rather leave the PR blocked than close it by reverting its purpose.

What I would do instead, if you want it addressed here

The honest fix for the underlying issue is to offload the write in download_file (asyncio.to_thread around the chunk loop, or an aiofiles-style writer) and/or add a streaming byte cap so the cap no longer depends on a post-download getsize. That is a change to a file this PR does not touch and it affects every attachment class, so I kept it out — but say the word and I will send it as its own PR, or fold it in here if you would rather see one change.

Local gates on 71f432de: 479 targeted tests, isort, flake8, mypy src/kiro_crew/ (3 pre-existing macOS-only os.*xattr errors in hooks.py).

adiarora06 added a commit to adiarora06/KiroCrew that referenced this pull request Aug 17, 2026
…rodotdev#3769)

`acp/prompt_blocks.py` chose an image's wire `mimeType` from its path suffix.
The suffix is a claim made by whoever named the file -- an upload handler that
kept a client-supplied name, a channel that renamed an attachment, a screenshot
tool with its own convention -- and every producer feeding this sink therefore
needed its own guard to keep that claim honest.

Two concrete failures came out of it:

- A renamed image shipped mislabelled. An image already inside the dimension cap
  rides through `_downscale_within_limits` byte-identical, so a JPEG named
  `.png` reached the model as JPEG bytes declared `image/png`.
- A non-image named like one was inlined anyway. `notes.png` became an image
  block the backend cannot decode. That is not a one-turn error: kiro-cli
  replays the full message history every turn, so a rejected block sits at a
  fixed history index and wedges the session from then on -- the same
  consequence the dimension and encoded-size caps already fail closed to avoid.

The suffix now decides only which paths are CANDIDATES (it is what the regex
matched on, and re-checking it keeps a `.txt` from costing a stat). What the
file IS is decided from its bytes:

- `_sniff_media_type` prefers Pillow's reported `format`, which comes from a
  real decode and is header-only, so it costs no decompression.
- A magic-byte signature table is the no-Pillow fallback. A hand-stripped
  install would otherwise degrade to trusting the suffix, which is the property
  this sink exists to remove. `RIFF` is matched together with its form field so
  a WAV does not read as a WEBP.
- An undecodable or unsupported candidate is left as a path, not inlined.
- A content/suffix MISMATCH is not a refusal: the image is real, so it still
  travels, labelled by its content. Producers rename attachments routinely, and
  dropping a good image to placate a filename trades a capability for nothing.
  The mismatch is logged.

This also fixes the re-encode, not just the label: `_downscale_within_limits`
picks its Pillow save format from the mime, so a wrong mime produced a wrongly
encoded payload as well as a wrong declaration.

Scoped to the sink. The issue's follow-on -- removing now-redundant
producer-side suffix tables -- is deliberately not here: those are defense in
depth, and removing them is the part that needs the broader producer
compatibility coverage kirodotdev#3754 flagged.

- `test_acp_prompt_blocks.py`: new `TestMediaTypeFromContent` (14 tests) covers
  content-over-suffix, the renamed-but-real image, the fail-closed non-image /
  SVG-behind-a-raster-name / truncated cases, the signature table agreeing with
  Pillow on every supported format and refusing a RIFF that is not WEBP, and
  the re-encode following content. 16 tests fail on main.
- `test_every_supported_suffix_maps_to_its_mime` now writes REAL bytes of each
  format; it previously wrote a PNG behind every suffix, which is why it could
  pass while the mime was read off the filename.
- Full ACP/image suites: 2132 passed.
adiarora06 added a commit to adiarora06/KiroCrew that referenced this pull request Aug 18, 2026
…rodotdev#3769)

`acp/prompt_blocks.py` chose an image's wire `mimeType` from its path suffix.
The suffix is a claim made by whoever named the file -- an upload handler that
kept a client-supplied name, a channel that renamed an attachment, a screenshot
tool with its own convention -- and every producer feeding this sink therefore
needed its own guard to keep that claim honest.

Two concrete failures came out of it:

- A renamed image shipped mislabelled. An image already inside the dimension cap
  rides through `_downscale_within_limits` byte-identical, so a JPEG named
  `.png` reached the model as JPEG bytes declared `image/png`.
- A non-image named like one was inlined anyway. `notes.png` became an image
  block the backend cannot decode. That is not a one-turn error: kiro-cli
  replays the full message history every turn, so a rejected block sits at a
  fixed history index and wedges the session from then on -- the same
  consequence the dimension and encoded-size caps already fail closed to avoid.

The suffix now decides only which paths are CANDIDATES (it is what the regex
matched on, and re-checking it keeps a `.txt` from costing a stat). What the
file IS is decided from its bytes:

- `_sniff_media_type` prefers Pillow's reported `format`, which comes from a
  real decode and is header-only, so it costs no decompression.
- A magic-byte signature table is the no-Pillow fallback. A hand-stripped
  install would otherwise degrade to trusting the suffix, which is the property
  this sink exists to remove. `RIFF` is matched together with its form field so
  a WAV does not read as a WEBP.
- An undecodable or unsupported candidate is left as a path, not inlined.
- A content/suffix MISMATCH is not a refusal: the image is real, so it still
  travels, labelled by its content. Producers rename attachments routinely, and
  dropping a good image to placate a filename trades a capability for nothing.
  The mismatch is logged.

This also fixes the re-encode, not just the label: `_downscale_within_limits`
picks its Pillow save format from the mime, so a wrong mime produced a wrongly
encoded payload as well as a wrong declaration.

Scoped to the sink. The issue's follow-on -- removing now-redundant
producer-side suffix tables -- is deliberately not here: those are defense in
depth, and removing them is the part that needs the broader producer
compatibility coverage kirodotdev#3754 flagged.

- `test_acp_prompt_blocks.py`: new `TestMediaTypeFromContent` (14 tests) covers
  content-over-suffix, the renamed-but-real image, the fail-closed non-image /
  SVG-behind-a-raster-name / truncated cases, the signature table agreeing with
  Pillow on every supported format and refusing a RIFF that is not WEBP, and
  the re-encode following content. 16 tests fail on main.
- `test_every_supported_suffix_maps_to_its_mime` now writes REAL bytes of each
  format; it previously wrote a PNG behind every suffix, which is why it could
  pass while the mime was read off the filename.
- Full ACP/image suites: 2132 passed.
@iamwhatever iamwhatever added the needs-pr-triage PR scanner: awaiting automated triage label Aug 21, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator

🤖 Kiro Crew [operator: bolichen97]: This PR was flagged for drive-to-green triage. I assessed the blockers, and they require a human decision rather than a mechanical fix.

Current state on 71f432de: the only red is GPT 5.6 Review (and PR Readiness, which reflects it). Opus 4.8, Design, First Principles and UX all pass, 0 unresolved threads.

Why this is not auto-resolvable: GPT 5.6's blocking fix is "Restore VIDEO/OTHER rejection until Slack file writes are offloaded." The only two ways to satisfy that verdict are:

  • Revert the opaque-attachment feature — which is exactly what Preserve arbitrary inbound attachments as opaque session files #3515 asks for, so the pipeline will not undo it, or
  • Offload the synchronous f.write in RealSlackClient.download_file (slack/client.py:734) — pre-existing code this PR does not touch (the loop already served IMAGE/TEXT/DOCUMENT/AUDIO); folding it in expands scope beyond the PR's intent.

You (and a second model, Opus 4.8) have rebutted the finding as pre-existing: this PR only raises the accepted ceiling on that path from 20 MiB to 50 MiB, it does not introduce the sync write.

Decision needed from a maintainer:

  • Accept the disclosed pre-existing risk and override GPT 5.6's blocking verdict (4 other review lanes pass), or
  • Ask that the slack/client.py sync-write offload be folded into this PR (an explicit scope expansion) before merge.

I have not merged, approved, or pushed anything. Once a maintainer resolves the GPT lane (override or scope guidance), the pipeline will re-assess on its next cycle. Add pr-no-autofix to opt out of future automation.

@bolichen97 bolichen97 added needs-author-decision PR blocked on author input and removed needs-pr-triage PR scanner: awaiting automated triage labels Aug 21, 2026
@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Aug 23, 2026
adiarora06 added a commit to adiarora06/KiroCrew that referenced this pull request Aug 24, 2026
…rodotdev#3769)

`acp/prompt_blocks.py` chose an image's wire `mimeType` from its path suffix.
The suffix is a claim made by whoever named the file -- an upload handler that
kept a client-supplied name, a channel that renamed an attachment, a screenshot
tool with its own convention -- and every producer feeding this sink therefore
needed its own guard to keep that claim honest.

Two concrete failures came out of it:

- A renamed image shipped mislabelled. An image already inside the dimension cap
  rides through `_downscale_within_limits` byte-identical, so a JPEG named
  `.png` reached the model as JPEG bytes declared `image/png`.
- A non-image named like one was inlined anyway. `notes.png` became an image
  block the backend cannot decode. That is not a one-turn error: kiro-cli
  replays the full message history every turn, so a rejected block sits at a
  fixed history index and wedges the session from then on -- the same
  consequence the dimension and encoded-size caps already fail closed to avoid.

The suffix now decides only which paths are CANDIDATES (it is what the regex
matched on, and re-checking it keeps a `.txt` from costing a stat). What the
file IS is decided from its bytes:

- `_sniff_media_type` prefers Pillow's reported `format`, which comes from a
  real decode and is header-only, so it costs no decompression.
- A magic-byte signature table is the no-Pillow fallback. A hand-stripped
  install would otherwise degrade to trusting the suffix, which is the property
  this sink exists to remove. `RIFF` is matched together with its form field so
  a WAV does not read as a WEBP.
- An undecodable or unsupported candidate is left as a path, not inlined.
- A content/suffix MISMATCH is not a refusal: the image is real, so it still
  travels, labelled by its content. Producers rename attachments routinely, and
  dropping a good image to placate a filename trades a capability for nothing.
  The mismatch is logged.

This also fixes the re-encode, not just the label: `_downscale_within_limits`
picks its Pillow save format from the mime, so a wrong mime produced a wrongly
encoded payload as well as a wrong declaration.

Scoped to the sink. The issue's follow-on -- removing now-redundant
producer-side suffix tables -- is deliberately not here: those are defense in
depth, and removing them is the part that needs the broader producer
compatibility coverage kirodotdev#3754 flagged.

- `test_acp_prompt_blocks.py`: new `TestMediaTypeFromContent` (14 tests) covers
  content-over-suffix, the renamed-but-real image, the fail-closed non-image /
  SVG-behind-a-raster-name / truncated cases, the signature table agreeing with
  Pillow on every supported format and refusing a RIFF that is not WEBP, and
  the re-encode following content. 16 tests fail on main.
- `test_every_supported_suffix_maps_to_its_mime` now writes REAL bytes of each
  format; it previously wrote a PNG behind every suffix, which is why it could
  pass while the mime was read off the filename.
- Full ACP/image suites: 2132 passed.
@bolichen97
bolichen97 enabled auto-merge (squash) August 24, 2026 07:00
adiarora06 added a commit to adiarora06/KiroCrew that referenced this pull request Aug 29, 2026
…rodotdev#3769)

`acp/prompt_blocks.py` chose an image's wire `mimeType` from its path suffix.
The suffix is a claim made by whoever named the file -- an upload handler that
kept a client-supplied name, a channel that renamed an attachment, a screenshot
tool with its own convention -- and every producer feeding this sink therefore
needed its own guard to keep that claim honest.

Two concrete failures came out of it:

- A renamed image shipped mislabelled. An image already inside the dimension cap
  rides through `_downscale_within_limits` byte-identical, so a JPEG named
  `.png` reached the model as JPEG bytes declared `image/png`.
- A non-image named like one was inlined anyway. `notes.png` became an image
  block the backend cannot decode. That is not a one-turn error: kiro-cli
  replays the full message history every turn, so a rejected block sits at a
  fixed history index and wedges the session from then on -- the same
  consequence the dimension and encoded-size caps already fail closed to avoid.

The suffix now decides only which paths are CANDIDATES (it is what the regex
matched on, and re-checking it keeps a `.txt` from costing a stat). What the
file IS is decided from its bytes:

- `_sniff_media_type` prefers Pillow's reported `format`, which comes from a
  real decode and is header-only, so it costs no decompression.
- A magic-byte signature table is the no-Pillow fallback. A hand-stripped
  install would otherwise degrade to trusting the suffix, which is the property
  this sink exists to remove. `RIFF` is matched together with its form field so
  a WAV does not read as a WEBP.
- An undecodable or unsupported candidate is left as a path, not inlined.
- A content/suffix MISMATCH is not a refusal: the image is real, so it still
  travels, labelled by its content. Producers rename attachments routinely, and
  dropping a good image to placate a filename trades a capability for nothing.
  The mismatch is logged.

This also fixes the re-encode, not just the label: `_downscale_within_limits`
picks its Pillow save format from the mime, so a wrong mime produced a wrongly
encoded payload as well as a wrong declaration.

Scoped to the sink. The issue's follow-on -- removing now-redundant
producer-side suffix tables -- is deliberately not here: those are defense in
depth, and removing them is the part that needs the broader producer
compatibility coverage kirodotdev#3754 flagged.

- `test_acp_prompt_blocks.py`: new `TestMediaTypeFromContent` (14 tests) covers
  content-over-suffix, the renamed-but-real image, the fail-closed non-image /
  SVG-behind-a-raster-name / truncated cases, the signature table agreeing with
  Pillow on every supported format and refusing a RIFF that is not WEBP, and
  the re-encode following content. 16 tests fail on main.
- `test_every_supported_suffix_maps_to_its_mime` now writes REAL bytes of each
  format; it previously wrote a PNG behind every suffix, which is why it could
  pass while the mime was read off the filename.
- Full ACP/image suites: 2132 passed.
adiarora06 added a commit to adiarora06/KiroCrew that referenced this pull request Aug 30, 2026
…rodotdev#3769)

`acp/prompt_blocks.py` chose an image's wire `mimeType` from its path suffix.
The suffix is a claim made by whoever named the file -- an upload handler that
kept a client-supplied name, a channel that renamed an attachment, a screenshot
tool with its own convention -- and every producer feeding this sink therefore
needed its own guard to keep that claim honest.

Two concrete failures came out of it:

- A renamed image shipped mislabelled. An image already inside the dimension cap
  rides through `_downscale_within_limits` byte-identical, so a JPEG named
  `.png` reached the model as JPEG bytes declared `image/png`.
- A non-image named like one was inlined anyway. `notes.png` became an image
  block the backend cannot decode. That is not a one-turn error: kiro-cli
  replays the full message history every turn, so a rejected block sits at a
  fixed history index and wedges the session from then on -- the same
  consequence the dimension and encoded-size caps already fail closed to avoid.

The suffix now decides only which paths are CANDIDATES (it is what the regex
matched on, and re-checking it keeps a `.txt` from costing a stat). What the
file IS is decided from its bytes:

- `_sniff_media_type` prefers Pillow's reported `format`, which comes from a
  real decode and is header-only, so it costs no decompression.
- A magic-byte signature table is the no-Pillow fallback. A hand-stripped
  install would otherwise degrade to trusting the suffix, which is the property
  this sink exists to remove. `RIFF` is matched together with its form field so
  a WAV does not read as a WEBP.
- An undecodable or unsupported candidate is left as a path, not inlined.
- A content/suffix MISMATCH is not a refusal: the image is real, so it still
  travels, labelled by its content. Producers rename attachments routinely, and
  dropping a good image to placate a filename trades a capability for nothing.
  The mismatch is logged.

This also fixes the re-encode, not just the label: `_downscale_within_limits`
picks its Pillow save format from the mime, so a wrong mime produced a wrongly
encoded payload as well as a wrong declaration.

Scoped to the sink. The issue's follow-on -- removing now-redundant
producer-side suffix tables -- is deliberately not here: those are defense in
depth, and removing them is the part that needs the broader producer
compatibility coverage kirodotdev#3754 flagged.

- `test_acp_prompt_blocks.py`: new `TestMediaTypeFromContent` (14 tests) covers
  content-over-suffix, the renamed-but-real image, the fail-closed non-image /
  SVG-behind-a-raster-name / truncated cases, the signature table agreeing with
  Pillow on every supported format and refusing a RIFF that is not WEBP, and
  the re-encode following content. 16 tests fail on main.
- `test_every_supported_suffix_maps_to_its_mime` now writes REAL bytes of each
  format; it previously wrote a PNG behind every suffix, which is why it could
  pass while the mime was read off the filename.
- Full ACP/image suites: 2132 passed.
@bolichen97

Copy link
Copy Markdown
Collaborator

Open PR relationship audit

This 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

  • PR #8228 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #8228: KEEP. Complementary halves of the same module's guarantee; 3754 gives no rejection path to a files_inbound=False channel, so it cannot cover 8228. Files: src/kiro_crew/messaging/attachments.py, test/test_messaging_attachments.py.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

Video and unrecognized formats were rejected before download, so an inbound
file the agent could have acted on arrived as a note instead of bytes. Keep
them as byte-identical temporary files under a 50 MB cap, hand the agent the
local path plus the original name, type and size, and reuse the existing
per-turn cleanup ownership. Opaque bytes are never parsed or executed
automatically.

A sender picks filename and mimetype independently, so an opaque file could
arrive named "photo.png" while declaring application/octet-stream. The ACP
encoder types a prompt path by suffix alone, so such a path would reach the
image sink without the content-signature check the IMAGE branch enforces --
emitted as image/png on the strength of a sender-supplied name. An inlineable
image suffix is therefore stripped from the temporary path before ownership
transfers, mirroring the retype the IMAGE branch already performs, and a
rename that fails drops the attachment rather than emitting the original path.
A contract test pins the suffix set against the encoder's own table.
@bolichen97
bolichen97 force-pushed the feat/3515-arbitrary-attachments branch from 71f432d to af79162 Compare September 8, 2026 21:24
@bolichen97

Copy link
Copy Markdown
Collaborator

Rebased onto main 6fa5519c by a maintainer as part of the 2026-09-08 open-PR audit. New head af7916235.

Conflicts:

  • messaging/attachments.py: main wrapped the ingest loop in try/except BaseException (new cleanup_offloaded) and added a ValueError downloader-refusal branch, re-indenting everything here. Took main's structure and re-applied this PR's intent: dropped the VIDEO/OTHER rejection branches, added both to cap as max_opaque_bytes, moved the elif kind in (VIDEO, OTHER) branch to main's indentation.
  • slack/events.py: three enqueue sites where main added from_trusted_bot= and this PR renamed the temp-path variable. Kept both.

Gates: isort and flake8 clean. test_messaging_attachments.py failed black already on the old head, so it is now formatted; events.py and test_telegram_attachments.py fail black exactly as on main and are baselined. pytest on the 4 touched test files plus test_slack_events_coverage.py and test_acp_prompt_blocks.py: 627 passed, 1 skipped.

Please review the resolution. A maintainer push makes the maintainer the last pusher, so a second approver is needed under the repo's last-push rule. Reply if anything looks wrong.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: action required A blocking check or review needs attention merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: checking Automated validation is still running labels Sep 8, 2026
@bolichen97
bolichen97 merged commit 90574a0 into kirodotdev:main Sep 9, 2026
69 checks passed
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor) needs-author-decision PR blocked on author input

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Preserve arbitrary inbound attachments as opaque session files

3 participants