feat(cmux-cua): refuse screen and control tools on a joined machine without the person's consent
Order: lands with manaflow-ai/cmux#27398, once reviewed at its head. The acpmux bridge half (manaflow-ai/cmux#27216) is in feat-cmux-next; the cmux patch PR (manaflow-ai/cmux#27393) carries this exact head.
Leo's ask this serves: "just have this sort of thing for, I guess, general onboarding of adding remote machines and whatnot and whatever, which includes, I guess, screen sharing potentially." The spec for it: a joined machine gets no screen and no control until the person at it grants them locally, and a revoke takes effect at once.
The gap
acpmux enforces machine-join consent for its own Computer Use bridge, and stops adding cmux-cua to newly generated agent config on a joined machine without consent. A cmux-cua mcp server an agent connected before that keeps running, and its calls never go through acpmux. So the check has to live where cmux-cua runs tools.
The change
ToolRegistry::invoke is the one place every tool call goes through: the stdio MCP server, the serve daemon (and the proxies that forward to it), the HTTP transport, and cmux-cua call. It now runs the consent check on every call, before the target guard, recording, fronting or dispatch.
-
Joined means acpmux's
machine-credential.jsonexists under$ACPMUX_HOME, else<home>/.acpmux. With no$HOME, the home comes from the password database; with no home at all, the answer is unknown. A join record counts as valid only if it is a private file (see below) with acpmux'sMachineCredentialshape. The credential value is type-checked and dropped, never kept, logged or returned. A record that is present but invalid, unreadable, or reached through a dangling$ACPMUX_HOMEor~/.acpmuxlink means joined with nothing granted. -
Consent is read from acpmux's
consent.jsonon every call, so a grant or revoke applies to the next call of a server that is already running. -
A private file, on Unix, is opened with
O_NOFOLLOW, is a regular file, is owned by this uid, and is not group- or world-writable (the same test as acpmux'sprivate_state_file). On Windows, the check runs on the opened handle (FILE_FLAG_OPEN_REPARSE_POINT, so reparse points are refused). The owner must be this user, SYSTEM or Administrators, and no other trustee may hold a write-like right (the same rule as acpmux'sowner_only.rs). A null DACL, an unknown ACE type, or a failed API call refuses. Records over 64 KiB are refused. -
What needs what:
screen: tools that read the screen (get_window_state,get_accessibility_tree,get_desktop_state,get_app_state,zoom,screenshot,list_windows,list_apps,get_cursor_position,get_screen_size,debug_window_info,start_recording/stop_recording).control: tools that act (pointer, keyboard,set_value,select_text,perform_secondary_action,launch_app/kill_app/bring_to_front, the agent cursor setters,set_config,install_ffmpeg,replay_trajectory).- Both:
page, and any tool the table doesn't know, so a new tool stays gated until someone classifies it. A Linux test checks the livetools/listagainst the table. - Neither: driver metadata.
-
Side-effect captures. Every capture site re-reads the
screengrant right before it touches pixels, never a value read before an await:- the recording's before capture;
- its after capture (also on the preflight-error path);
- the PiP frame;
- the serve daemon's activity-log after frame.
An action allowed by
controlalone still runs, but none of these capture anything. Ascreenrevoke ends a running recording, video included. It happens on the next call, a revoke during an action ends it when that action finishes, and between calls a watcher stops it within 0.5 s. The watcher is one thread per registry holding only a weak reference to the recording. Dropping the registry wakes it and joins it. If the watcher can't start,start_recordingstops the recording and fails (consent_watch_unavailable). -
Audited and left alone:
- macOS
check_permissionstakes a probe capture but returns only a boolean; - the cursor overlay and cursor feed carry pointer positions only;
- the journal records argument key names and the outcome only;
replay_trajectoryre-enters the gatedinvokefor every step;- the Codex-compat tools call native tools inside an outer call that is already gated.
- macOS
-
A refusal is an MCP tool error,
isError: truewithstructuredContent: {"code": "consent_required", "surface": "screen"|"control", "tool": ...}. It also writesconsent-refusal.json(0600, atomic, in acpmux's format) so the app's consent sheet can show it. The refusal text carries no path or credential. -
A machine that is not joined costs one failed
lstat, plus a few for its ancestors, per call. It behaves exactly as before and nothing is written. -
Also in this PR:
cursor_sampler's macOS externs now carry#[link]for CoreGraphics and CoreFoundation. The driver binary already links both through platform-macos. Without this, this crate's own test binary fails to link once a test starts a recording.
One consent source, and the cmux.app build
Every gate and every capture site above asks one ConsentSource (machine_consent.rs) right before it acts. default_source() is the only place that picks it.
Actions need screen and control. This matches the rule acpmux's Computer Use bridge enforces, so the two can't drift. Observation needs screen only.
Cargo feature cmux-host-consent. Only cmux's own build of the helper inside cmux.app turns it on. Released and standalone builds (Linux cloud VMs included) leave it off and keep the file reader, which is advisory because $ACPMUX_HOME is chosen by whoever starts the process.
With the feature on, consent comes only from the verified cmux app (host_consent.rs):
- The app holds one
machine_consent_hostconnection to theservedaemon. - The daemon admits it only when three things hold:
- the request carries the configured host token;
- the peer's code, checked by audit token, has a valid Apple-anchored signature of cmux's team;
- the peer's signing identifier is
com.cmuxterm.appor one of its.channel/.tagforms.
- On every check the daemon asks that connection, and the app answers from its own acpmux daemon (
_acpmux/machine_consent_get). - Each of these grants nothing:
- no host;
- no reply within 1.5 s;
- an error or a malformed answer;
- a replaced or departed host (its open questions fail at once);
- a full queue;
- a host that stops reading.
- Waits hand off the tokio worker, and a current-thread runtime never blocks on the host.
- No file or environment variable decides consent, and the registry's consent setters do not exist in this build.
Already-running processes
This build gates its own calls from the first call after a join. A cmux-cua process started from an older binary keeps its old behavior until it exits. Nothing here kills or restarts anything. The acpmux side handles the warning and the status.
Tests (red/green)
cmux-cua-core (machine_consent.rs tests, and machine_consent_registry_tests in tool.rs, which use counting tools that prove a refused tool never runs):
- a joined machine with no consent refuses both screen and control tools, and writes an owner-only refusal record;
- screen-only consent runs screenshots and refuses clicks;
- a revoke between two calls refuses the second, and joining while running gates the next call;
- a machine that is not joined runs every tool and gets no files;
- bad consent records grant nothing: malformed, partial, wrong case, too large, a symlink, or group-writable. Neither do bad join records (malformed, wrong shape, a link, a directory, dangling) or a dangling
$ACPMUX_HOMEor~/.acpmux; - with no home at all, everything but metadata refuses; the
$HOMEfallback is covered; - recording: a revoke ends the recording on the next call (the action still runs, no turn is captured), within 0.5 s with no call, and during the action (no after capture);
- PiP: a revoke during the action pushes no frame;
- a failed watcher start stops the recording; dropping the registry joins the watcher and frees the recording;
- the Windows DACL predicate on every platform, plus an
icaclstest on Windows CI that gives Everyone write access to each record; - the deprecated
type_text_charsalias is gated astype_text.
cmux-cua/tests/machine_consent_test.rs (Linux) runs one real cmux-cua mcp stdio process with ACPMUX_HOME in a temp dir. It goes through not-joined, then joined with no consent, then screen only, then both granted, then control revoked, then a malformed record, each step between calls of the same running server. It also checks every tool in the live tools/list against the table.
Before this change, the joined, revoke and capture cases fail: the tools run and capture. The unjoined case passes before and after.
š¤ Generated with Claude Code
Agent session: agent session