acpmux: Full access approves what the harness still asks (Claude Code's own guards in bypass), logged as "Approved: … (Full access)"

Merged
TE
12d ago
+749-20

The ask

hb-acpmux asked Leo, through claude-sr2: "[irreversible] should Full access also make acpmux approve the requests Claude Code still raises in bypass (like 'dangerous rm on an unresolvable target')? Yes means those deletions run unasked."

Leo, word for word: "i think it should... idk like if it is on bypass permissions, that's not auto"

claude-sr2's spec: "when a chat is in bypassPermissions / Full access, the harness's own safety guards are approved too, for example Claude Code's "dangerous rm on an unresolvable target" (classifier_approvable:false). Bypass means no prompts; it is not auto mode. Ask-style modes keep prompting. Scope it to the Full access family only (bypassPermissions, agent-full-access and the other entries in full-access-modes.json), and log each auto-approved guard in the chat as a quiet line ("Approved: rm -rf … (Full access)") so it can be seen afterwards. Add a test. Codex xhigh review, then land."

Why it asked

Claude Code 2.1.307 in bypassPermissions still sends can_use_tool for its own guards. cosmic-gray claude-sr2/whats-new-video, event 243: "decision_reason": "Dangerous rm operation on statically-unresolvable target: /private/tmp/claude-501/…/scratchpad/f/*", "classifier_approvable": false, for cd ~/scratch/whats-new-video/reel && rm f/*; ffmpeg …. That chat runs in bypassPermissions (acpmux set it, event 11) with policy null, the daemon default ask, so hub/permissions.rs had no answer (PermissionPolicy::Ask => None) and put a card in front of the person (event 245).

The rule

A chat whose current mode is a Full access mode that cmux set answers each plain tool ask its harness raises with that ask's one-time allow, without asking, whatever the acpmux policy, unless deny-all or a rule decided it. Every such answer is a permission_auto record with fullAccess: <mode>, and the agent pane shows it as one muted line: "Approved: rm -rf … (Full access)".

  • Which modes: the shared rule, spec/full-access-modes.json (start_mode::is_full_access; no new list): Claude Code bypassPermissions, Codex agent-full-access, full-access, yolo, anything with a marker. The mode must be the one cmux set (mode_set_by_cmux, the same check trust_wait.rs full_access_by_cmux uses): the person's pick (through session/set_mode or the harness's mode option), the start mode, an inherited start, a fork of such a chat. A mode the harness reported by itself (the agent controls that) is not enough, and neither is an argv-pinned --dangerously-skip-permissions. While sessions.allowFullAccess is off nothing is approved this way.
  • Current mode, not the start mode: the mode is read under the permission lock when the request arrives. A chat switched out of Full access mid-turn prompts again from its next request (tested with a FIFO-gated ask).
  • Which requests: guards and plain tool approvals alike (bypass means no prompts). Never a question (Claude Code's AskUserQuestion, a Codex requestUserInput, anything with _meta.acpmux.question or a questions input), a plan approval (ExitPlanMode, Grok's and Cursor's plan asks, switch_mode), the folder-trust card, or any request marked interactive: those are for the person and still ask. MCP elicitation forms never reach this path (claude_stdio declines them, the Codex bridge declines every form that is not its own MCP tool approval), so none is auto-answered.
  • Which option: the request's allow_once only, never an "always" option (it writes a rule). An ask that offers no one-time allow still asks.
  • What still wins: deny-all (the person's explicit choice) and deny rules deny; an ask rule or an approve rule decides as before; a Web turn is never answered by itself (remote floor). Ask-style modes (default, plan, acceptEdits, auto, Codex's asking modes) are unchanged.
  • Codex: agent-full-access is approval never, so Codex raises no command, file or permission approval there; if a future Codex does, it gets the same rule (covered with the stand-in's full-access mode).

What changed

  • cmux-tui/crates/acpmux/src/hub/permissions.rs: full_access_mode and full_access_allow; handle_permission_for answers with them after deny and before the chat allowance and the policy, and records fullAccess on the permission_auto record (acpmux session tail and _acpmux/events show it). One tracing::info! per approval.
  • cmux-tui/crates/acpmux/src/hub/permission_groups.rs: plain_tool_ask (shares eligible's interactive checks).
  • hub/web_control.rs write_mode_state: any write that moves the mode away from the mark takes the mark, under the same meta lock (so a later write cannot bring the mode back with it). hub/turns.rs: set_mode marks the mode only once the chat runs it, and a mode picked through the mode config option is marked the same way. hub/fork.rs: a fork keeps its chat's mark; hub/lifecycle.rs: a replayed mode option restores the mark as a replayed mode does (its agent gets it back only once it runs that mode, restore_mode_mark).
  • webviews/src/agent-session/acpmux/direct.ts, permissions/record.ts, Localizable.xcstrings: a permission_auto with fullAccess draws the existing receipt row (.acpmux-permission-record, muted, one line) with the new string permission.receipt.fullAccess = "Approved: {action} (Full access)". A policy's or rule's permission_auto still draws nothing.

Tests (red first on base)

tests/hub_integration/full_access_guards.rs (stand-in Claude Code modes, policy ask):

  • Full access + guard → approved with the one-time allow (the lasting one is listed first), logged with fullAccess, no permission_request; a plain ask: in bypass is approved too.
  • default / acceptEdits + the same guard → still asks.
  • deny rule in Full access → denied (mode stays bypassPermissions); deny-all → denied.
  • mode switched out of Full access while the turn waits on a FIFO → the next request asks.
  • the harness drifting out of, or into, Full access by itself → asks.
  • AskUserQuestion, _meta.acpmux.question, a lasting-only allow → still ask.
  • another family's Full access mode (full-access) → approved; its asking mode asks.
  • a chat that starts in Full access (start level fullAccess, no pick) → approved.
  • a fork of a Full access chat → its first guard approved.
  • a harness whose mode is only its mode option: Full access picked through the option → approved; the harness moving the option away and back by itself (across turns, or inside one turn) → asks; after an agent restart and on a fork → still approved.
    permission_groups.rs unit test only_a_tool_approval_is_a_plain_tool_ask.
    webviews/test/agent-pane-permission-receipt.test.ts: a Full access permission_auto leaves "Approved: rm -rf f/* (Full access)"; a policy's leaves none.

Remote fleet (fast-checks):

  • Tests only on base (c5ced87): full_access_guards 5 passed, 2 failed (the two "approves" tests; the turn waits on a card). Webviews receipt test: 3 pass, 1 fail ([] instead of ["Approved: rm -rf f/* (Full access)"]).
  • Round-2 tests only (7bd883d, on the round-1 fix): 8 passed, 2 failed (fork, mode option).
  • Round-3 tests only (57e9d3b): 11 passed, 2 failed (restart, fork of a mode-option chat).
  • Head 9fa0444: cargo fmt --check, cargo clippy -p acpmux --all-targets -D warnings clean; cargo test -p acpmux --no-fail-fast: 94 suites pass; two failures that also fail on base ecd13fb: daemon_chat_open a_long_chat_opens_on_its_newest_page_and_scrolls_to_its_first_message (fails on base every run) and session_pool pooled_sessions_end_with_the_daemon_and_after_a_crash (flaky: 1 of 3 fails on base, 3 of 3 pass at head). Webviews: receipt test and record.test.ts 7/7; fmt and lint 0 errors.

Capture

The agent pane on a Claude Code chat in Bypass permissions whose harness raised its own guard for rm f/* (the shape of whats-new-video's event 243), a stand-in daemon with the recorded events, captured with Playwright on the fleet (--setup playwright).

Before (base)After
Darkbefore darkafter dark
Lightbefore lightafter light

Before: the turn stops on an Allow/Deny card. After: no card; the work goes on and the transcript keeps one muted line, "Approved: cd reel && rm f/*; ffmpeg … (Full access)", in the existing receipt row (.acpmux-permission-record). One version: the receipt row and its words already exist for a card's answer; this only adds the "(Full access)" wording.

Review

Codex gpt-6-astra xhigh (hb-bypass-guards-review, reading the bare mirror, no checkout).

  • Round 1 at 69f963e: BLOCK, 4 findings: a mode-option round trip kept a stale mark; Full access picked through the mode option was not counted; a fork lost its mark; the stand-in did not run Claude Code's modes. Fixed with tests in 7bd883d / 20fd856.
  • Round 2 at 20fd856: BLOCK, 2 findings: the mark was dropped outside the lock that wrote the mode (a concurrent write could bring the mode back with it); a replayed mode option did not restore the mark after restart or fork. Fixed with tests in 57e9d3b / 9fa0444.
  • Round 3: SHIP, "Reviewed head: 9fa044410e636f130411b41cac30cbcccbcd6cc1".

Takes back #24957: a fork kept no mode_set_by_cmux, so a fork of a Full access chat ran in bypass but asked for every guard (Codex review finding 3). The fork now keeps its chat's mark only when it is the chat's current mode, fresh_agent moves it to replay_mark, and restore_mode_mark gives it back only once the fork's own agent runs that mode; a mode the harness only reported never had a mark to copy, so #24957's rule (only a mode cmux set lends trust) holds.

Stop conditions

No new timer, observer or loop.

Agent session: big-red
🤖 Generated with Claude Code