Reproduction steps:
- Configure a recipient allowlist for a write command, e.g. WS/allowlists.json:
{"acme.send_alert": {"to": ["+15550000001"]}}
(the command's to field is its allowlisted recipient, per _ALLOWLIST_FIELD_MAP).
- Honest path — preview the send to a NON-allowlisted number:
preview_command("acme.send_alert", {"to": "+19998887777"})
-> status "allowlist_denied" (correctly blocked; no airlock card, no handler)
- Bypass — skip preview and call the two other command endpoints directly:
approve_command("acme.send_alert", {"to": "+19998887777"}, "ui_click")
-> status "approved_not_executed" (approval bound; allowlist NOT checked)
execute_command("acme.send_alert", {"to": "+19998887777"})
-> status "executed" (handler sends to +19998887777; allowlist NOT checked)
- Run repro_recipient_allowlist_preview_only_bypass_v099.py against a clean v0.99
extraction (drives the REAL preview_command / approve_command / execute_command).
Expected:
The recipient allowlist caps WHERE an approved write may go — its own docstring
says it "rejects ... BEFORE an airlock card is even shown so a payload aimed at a
non-allowlisted recipient never gets to the human's eyes with an approve button."
That guarantee must hold at the ENFORCEMENT point (execute), not only at preview:
an approved write to a non-allowlisted recipient must never fire.
Actual:
_allowlist_check runs ONLY in routes/commands.preview_command (line 85).
approve_command (studio_server.py) and execute_command (routes/commands.py) never
call it, and /api/commands/{preview,approve,execute} are three INDEPENDENT
endpoints (wired in routes/dispatch_cap_off_wave3.py; the REQUESTS-queue path in
routes/requests.py likewise does approve_command + execute_command). approve_command
binds the approval with p.setdefault(idem, ...) — it does NOT require a pending
record produced by a passing preview, so nothing upstream enforces the allowlist
either. A caller (any authenticated Studio session — including a CSRF hit on the
local server, or a sandboxed module that reads WS/session_token off disk and calls
the API) therefore skips preview, calls approve then execute, and delivers an
approved write to an arbitrary, non-allowlisted recipient. The repro shows preview
-> allowlist_denied while approve+execute -> executed, sending to the blocked
number. The method in ("ui_click","terminal_confirm") check on approve is a
request field and is trivially supplied, so it is not a barrier.
Suggested fix:
Enforce the allowlist at the sink, not only in the preview. Call _allowlist_check
inside execute_command BEFORE invoking the handler (and inside approve_command
before binding an approval), returning blocked_by_policy on a miss, exactly as
preview_command does:
# execute_command, after resolve_status == available_write_requires_approval:
al_ok, al_field, al_value, al_allowed = _allowlist_check(cmd_id, inputs)
if not al_ok:
rc = airlock.make_receipt(cmd, inputs, intent, "blocked_by_policy", stamp,
note=f"recipient allowlist blocked: {al_field}={al_value!r} not in {al_allowed!r}")
rc["receipt_id"] = _persist_receipt(rc)
return rc
A recipient restriction that lives only in the preview is advisory; the airlock's
own invariant ("every mutation that leaves the station goes through
execute_command") means execute_command is where it must be checked