fix(probe): Codex rules allow only self-limiting session commands, not socket-request

Merged
KJ
8d ago
+2051-303

The flaw

#2019 writes $CODEX_HOME/rules/agent-relay.rules, which allowed <probe> relay socket-request --socket <sock> with no prompt. Codex execpolicy rules match a command prefix, so everything after --socket <sock> was unconstrained. A relayed (untrusted) peer message could steer Codex into relay socket-request --socket <sock> --method PATCH --path /settings --body … or --method POST --path /register, and those ran outside the sandbox with no prompt.

Fix

New self-limiting probe subcommands. Each one fixes its own method and route, validates every other argument, and percent-encodes it into the query:

CommandRequest
relay send --socket S (--to N | --address a@m | --channel C) [--reply-to ID] [--thread ID] [--body T]POST /send
relay agents --socket SGET /agents
relay whoami --socket SGET /whoami
relay read --socket S <ref> (existing; refuses --dev)GET /messages?ref=
relay fetch-attachment --socket S <file_id>POST /attachments/fetch?file_id=
relay connect-send --socket S [--to N] [--body T]POST /connect/send
relay subscribe-channel --socket S <channel> [--remove]POST / DELETE /channels/subscribe?channel=
  • Codex rules (SandboxRules) allow exactly those seven prefixes on this core's socket. There is no socket-request rule.
  • Every hint uses the new commands: Codex/OpenCode/Grok macOS reply and agents lines, attachment fetch, channel-thread reply, Relay Connect reply, spawn report-back (no more printf | pipe), the channel stop line, the Windows preamble, and the Claude SessionStart preamble and joining text, which named curl and so asked in manual mode. The core's GET /setup/status now reports probe (the space-free link when it is in use), and the hook writes the commands through it, exactly as the rules name them; every_preamble_command_runs_under_claudes_and_codexs_rules checks each against both rule sets.
  • Claude permission rules now allow the same subcommands (Bash(<probe> relay send --socket <s> *), …) instead of pinned socket-request paths. The stale rules an older core recorded are removed on upgrade (new test).
  • socket-request stays for people and debugging, with no rule, so it always asks. Its allowlist now also takes /channels/subscribe?channel=.
  • Validation: names and addresses take one word, with no whitespace, control characters or URL/quote characters. An address has exactly one @. Ids are plain tokens. --thread needs --channel, and --reply-to can't be combined with --channel. A body on a command that doesn't take one is refused. clap refuses a repeated --socket. A hint whose sender name would fail validation (for example one with a newline) gets no probe command, so peer text can't add lines to the hint.
  • Upgrade: write_rules rewrites Agent Relay's own rules file and removes any other header-bearing agent-relay*.rules (an older build's), so no socket-request rule is left behind. Turn-off and uninstall still restore ~/.codex byte for byte (extended test).

Route enumeration (session socket: Outbound::handle + mobile_handoff_route)

"Before" means reachable with no prompt through the #2019 socket-request rule, as limited by the client allowlist. "After" means reachable through any allowed rule prefix.

RouteBeforeAfter
POST /sendyesyes (relay send)
GET /agentsyesyes (relay agents)
GET /whoamiyesyes (relay whoami)
GET /messagesyesyes (relay read)
POST /attachments/fetchyesyes (relay fetch-attachment)
POST /connect/sendyesyes (relay connect-send)
PATCH /settingsyesno
GET /settingsyesno
POST /registeryesno
DELETE /registeryesno
POST /integrations/subscribeyesno
DELETE /integrations/subscribeyesno
POST /connect/leaveyesno
GET /connect/statusyesno
GET /setup/statusyesno
POST /connect/joinnono
POST/DELETE /channels/subscribenoyes (relay subscribe-channel; joins or leaves one channel as this session)
POST /codex/stop-hooknono
POST /setup/sign-in, /setup/sharing, /setup/auto-activate, /setup/direct-deliverynono
POST/DELETE /webhooksnono
POST /claude/mobile-handoff/{poll,ack,result,permission}nono

(The old client allowlist also took DELETE /connect/join, which the router doesn't serve; it got a 404.)

Tests

  • outbound::tests::session_routes_list_every_route_the_router_serves: reads the router's source (include_str!) and fails unless SESSION_ROUTES lists exactly the routes it serves, so a new route can't skip the enumeration.
  • sandbox_rule_reach_tests::codex_rules_reach_only_their_own_routes (probe bin): for every rule prefix, parses about 37k suffixes through the real Cli: every 1–3-word combination of 33 hostile words (--method PATCH, --path /settings, --socket /other, --dev, a?x=1, ../register, --, …) plus a targeted attack per SESSION_ROUTE. Each suffix goes through the same session_command + request() path that run uses. It asserts the socket is the rule's, the route is that rule's own, and the query keys are allowed, and that each route in SESSION_ROUTES is reached iff it is intended. A mutation check (making agents send PATCH /settings) fails it.
  • an_allowed_command_keeps_the_socket_its_rule_names: a repeated --socket and read --socket … --dev are refused.
  • stop_hook::tests::codex_itself_allows_only_the_self_limiting_commands: runs the real codex execpolicy check (skipped when Codex isn't installed; it ran here with Codex 0.163). The seven commands are allow. socket-request on every route, another socket, relay settings and relay subscribe match nothing.
  • an_older_cores_rules_are_replaced_on_upgrade (Codex, including stale -2/-3 copies, then byte-for-byte turn-off), an_older_cores_socket_request_rules_are_replaced_on_upgrade (Claude), session_cli validation and injection tests, plus the updated hint tests.
  • cargo test --locked (default features) and --no-default-features --features production-binaries both pass locally, except two tests/history_flush.rs wall-clock deadline tests that fail on this machine (slow external volume). They're unrelated to this change; CI is the judge.

Live proof (isolated macOS env, real Codex 0.163 in its default sandbox)

I ran a variant of scripts/try-relay-init.sh:

  • HOME, AGENT_RELAY_HOME, INSTALL_DIR, CODEX_HOME and the XDG dirs were under /tmp/crx, with AGENT_RELAY_CORE_SOCKET=$HOME/.agentworkforce/desktop/core.sock.
  • The project was ~/crx-project. Only ~/.codex/auth.json was copied. Inherited RELAY_*/AGENT_RELAY_* variables were unset.
  • AGENT_RELAY_PROBE_PATH pointed at this branch's probe (0.1.0-dev+1193cb7fb95e), with npx -y agent-relay@next.
  1. DM injected, Codex listed agents and replied with no approval prompt. ✓ codex: DM injected and the reply reached alice, with no approval prompt. The pane shows • Ran <probe>… └ {"data":{"agents":[… and then • Ran <probe>… └ {"data":{"conversation_id":…,"from":"codex-crx-project-reply-ready"…. Only the new subcommands have rules, and step 2 shows anything else can't reach the socket from the sandbox.
  2. socket-request … --method PATCH --path /settings from Codex is sandboxed. Codex ran it in the sandbox: Failed (exit 1) … Agent Relay's session socket is unavailable. codex execpolicy check against the written rules gives {"matchedRules":[]} for PATCH /settings and POST /register, and "decision":"allow" for relay send and relay agents.
  3. Uninstall leaves ~/.codex clean. No hooks.json, backup, rules/ or trust entries remain; config.toml holds only the project trust the harness wrote and Codex's own TUI keys. /tmp/crx and ~/crx-project were removed, and only processes under /tmp/crx were stopped.

Coordination with #2029

#2029 adds a Codex session-start hint naming relay socket-request … --method GET --path /agents, … POST --path '/send?to=<name>' --body … and … GET --path /whoami. With this PR those prompt, so its text should name relay agents --socket <s>, relay send --socket <s> --to <name> --body '…' and relay whoami --socket <s>. I added relay whoami for that purpose. Whichever lands second updates its text. outbound::probe_session_command(socket, &SessionCommand::…, body) builds them.

🤖 Generated with Claude Code


Agent Relay sessions

  • claude session 8ab900cc · opened via gh pr create · last active 2026-10-31