acpmux: a media reader that opens a chat's images and videos through the descriptor

Merged
TE
13d ago
+883-0

Leo-ask: acpmux-media-safety

Leo, 2026-10-21: "we also want acpmux to be able to have images and videos (i think we already had it but... it just wasn't rendering for images. not sure if videos were safe) we want to make it prompt injection safe as well since images to remote is maybe dangerous to remote... we need to look into how t3 code does stuff"

Summary

This is the first half of build plan item 7 in the media-safety design (teamleaderleo/leo-workspace#874, rule R10): the reader that lets the hub that owns a chat read that chat's images and videos. A later PR has the app's pane ask that hub for the bytes, so the pane never looks for a same-named file on this Mac. This PR adds only the reader, in cmux-tui/crates/acpmux/src/hub/media_read.rs. No route calls it yet. The _acpmux/media_open and _acpmux/media_range methods, the mediaRead feature and their guards are in #24373, which lands after this one.

What the reader does:

  • Roots. A chat may read files under its own folder (cwd; relative paths start there) and under its own uploads/<session> folder. The hub stores no additionalDirectories, so those folders are not roots. An absolute path is matched against the roots as spelled or as resolved. Only the roots themselves are resolved, so a path the agent names inside a protected folder never makes the daemon look there, which would raise a macOS privacy prompt.

  • Open against the descriptor. The reader opens the root first. Then it opens every component below the root with openat(O_NOFOLLOW | O_CLOEXEC), folders with O_DIRECTORY and the file with O_NONBLOCK. No component inside the root can be a link.

  • Checks on the opened descriptor. The file must be a regular file with exactly one link (a hard link would be a second name outside the root). Its own path (F_GETPATH on macOS, /proc/self/fd on Linux) must be under the root's own path. No component below the root may be a dot name (.git, .ssh, .env), and the file may not be a .pem/.key/.p12/.pfx or sit under ~/Library.

  • Sniffed type. The type comes from the file's bytes, read through the descriptor:

    • images: png, jpeg, gif or webp;
    • videos: mp4 (by its ftyp brand), mov, m4v, or webm (EBML with DocType webm).

    SVG, PDF, HEIC, AVIF, Matroska and everything else are refused.

  • Caps.

    • an image at most 10 MiB, a video at most 200 MiB;
    • a range at most 6 MiB, which is 8 MiB of base64, under the 15 MiB RPC cap and an older peer's 16 MiB frame (a test checks the arithmetic);
    • at most 8 open handles, each dropped after 2 minutes idle.
  • One identity for the whole read. Each range is read from the opened descriptor. It compares (dev, ino, size, mtime) with the open's before the read and again after it. A file that changed in place is refused with media.changed. A file replaced at its path, or swapped for a link, keeps giving the bytes that were opened. Identity::token() is a keyed hash, keyed per daemon run, so the identity a reply carries names no inode.

Non-test code adds no unwrap, expect or panic. The two unsafe blocks (openat, fcntl(F_GETPATH)) each have a SAFETY comment.

Testing

The tests were committed before the reader (test(acpmux): media reader race and refusal tests), in src/hub/media_read_tests.rs. Race tests use a step hook inside the open (after the root, after each folder, after the file's checks):

  • out/clip.mp4 swapped for a symlink to a stand-in ~/.ssh/id_ed25519, before the open, between the folder's open and the file's, and after the checks: the first two are refused, and the third returns the original bytes;
  • the parent folder out swapped for a symlink out of the root, after the root is opened (refused) and after out is opened (the original bytes);
  • the file replaced by another inode between the open and the first range, and between two ranges: the original bytes or a refusal, never the new file's.

Each one asserts that no bytes from outside the root come back. Other tests cover the sniff table, ranges and an in-place change, every refusal reason (media.path, media.outside, media.denied, media.notFile, media.unreadable, media.type, media.tooLarge, media.linked), the handle table's caps, chat scoping and expiry, and the identity token.

Fleet run cmux-ci run --class isolated --script scripts/ci/cmux-tui-rust-check.sh --arg all --arg media_read at f75ae4f0e04e8cb2817f50a918b1cb264fc7de74 (step c71cce9a15f09e42f1bfcf6a, macOS): fmt pass, clippy -D warnings on the workspace pass, and the 9 hub::media_read::tests pass. (The run at the earlier head was red on two clippy cloned_ref_to_slice_refs hits in the test file, fixed in f75ae4f0.)

cargo fmt -p acpmux --check passes on this head. scripts/cmux-next/crash_ratchet.py counts acpmux at unwrap 179, expect 10, exit 5, panic_macro 2, the same on base 958f2e9c and on this head. The script exits 1 on both, for cmux-tui-core hits already on base. scripts/cmux-next/prepush-gate.py finds no new violations.

Observed / Not run

  • Observed: the fleet run above, and the fmt and ratchet runs on cmux16.
  • Not run: Linux. The /proc/self/fd branch of fd_path and the non-Unix stubs (which refuse every open) are compiled only by the hosted CI lanes. No route calls the reader in this PR, so nothing runs end to end. #24373 has the integration tests.

Changelog

none

Proof

No capture: daemon-only code with no UI and no route yet.

Agent session: cosmic-gray

🤖 Generated with Claude Code