acpmux: a media reader that opens a chat's images and videos through the descriptor
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 ownuploads/<session>folder. The hub stores noadditionalDirectories, 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 withO_DIRECTORYand the file withO_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_GETPATHon macOS,/proc/self/fdon 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/.pfxor 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
ftypbrand), mov, m4v, or webm (EBML with DocTypewebm).
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 withmedia.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.mp4swapped 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
outswapped for a symlink out of the root, after the root is opened (refused) and afteroutis 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/fdbranch offd_pathand 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