← Community
bugfixed

The Approval Airlock's own approve/execute endpoints require no session token -- any local caller can self-approve

ShwetaShweta#137d ago · 63 views
affected: station-v0.74fixed in: station-v0.83

The Approval Airlock's own approve/execute endpoints require
no session token -- any local caller can self-approve and fire any
registered write command with zero proof a human was involved

The Approval Airlock's stated contract, repeated across dozens of files in
this codebase, is that a write action requires an explicit human decision
before it can execute -- "hard-floor require_human," "never
auto-approved," "the operator approves the blast radius." The pipeline
that actually mints that human decision has no check that a human, or
even an authenticated Studio session, was involved at all.

Every write-capable route in the same dispatch file requires a session
token first (/api/build, /api/flow/run, /api/flow/automap, every
cap-off connector's stage/approve pair). The four routes that make up the
Approval Airlock's own command pipeline do not:

/api/commands/validate -> validate_command(command_id, inputs, claimed, intent)
/api/commands/preview -> preview_command(command_id, inputs, intent)
/api/commands/approve -> approve_command(command_id, inputs, method, intent)
/api/commands/execute -> execute_command(command_id, inputs, intent)

None of these four functions receive the request handler at all, so none
of them can check a session token even in principle. approve_command()'s
own docstring is "Bind a human approval to the EXACT payload" -- but the
method parameter it takes ("ui_click" or "terminal_confirm") is a bare
string the caller supplies; nothing verifies the claim. Binding an
approval is just:

airlock.bind_approval(command_id, inputs, method, stamp)
-> {"method": method, "timestamp": stamp,
"approved_payload_hash": payload_hash(command_id, inputs)}

No identity, no session, no channel -- just a hash of the payload the
caller itself provided.

This matters because the platform elsewhere goes out of its way to
distinguish a human channel from an automated one specifically for
approval purposes: primitives/operator_identity.py's HUMAN_CHANNELS
excludes the scheduler and MCP channels on purpose ("the scheduler
channel can NEVER carry a human actor... would manufacture accountability
for a decision nobody made"), and routes/team.py's session gate refuses
the MCP channel outright for team actions ("the ceremony belongs to
humans"). The command-execution pipeline -- covering every registered
write command in the catalog, including real money-moving actions like a
Stripe refund -- makes no such distinction. Anything that can reach the
loopback port with a POST carrying a loopback Origin or Referer header
(satisfied by any local process setting its own headers, not just a real
browser tab -- the MCP sidecar, a CLI script, a compromised local process)
can call approve then execute for any command, with nothing standing in
for "a human clicked this."

Reproduction steps:

  1. Extract a clean station-v0.74 tarball, sys.path.insert(0, "workbench"),

boot studio_server against a scratch workspace.

  1. Build a stub handler object with only a _send() method -- no

_require_session() at all, so if the dispatch path ever tried to call
it, this would raise AttributeError instead of silently succeeding.

  1. Call routes.dispatch_cap_off_wave3.try_dispatch() directly for

"/api/commands/preview", then "/api/commands/approve"
(method="ui_click"), then "/api/commands/execute" -- the exact
functions the real HTTP routes call -- for a real registered write
command (local.file_write).

Expected: approve and/or execute refuse without a valid session token,
the way every other write-capable route in the same file does.

Actual: preview returns requires_approval=True; approve returns
ok=True, status="approved_not_executed" with a bound approval; execute
consumes that approval and actually runs the write -- the receipt shows
result_status="executed", execution_class="executed", and the target file
is genuinely written to disk with the exact requested content. All three
calls succeeded through a handler object that structurally could not have
passed a session check, because it was never asked for one.

Root cause: routes/dispatch_cap_off_wave3.py's try_dispatch() dispatches
"/api/commands/validate", "/api/commands/preview", "/api/commands/approve",
and "/api/commands/execute" without a handler._require_session() call --
unlike every other branch in the same function. approve_command() and
execute_command() (studio_server.py / routes/commands.py) take no handler
argument and have no other identity/session parameter, so the omission
cannot be patched anywhere except the dispatch layer.

Suggested fix: require handler._require_session() (or an equivalent
explicit human-channel check, consistent with how team.py and
operator_identity.py already distinguish human from automated channels)
before /api/commands/approve and /api/commands/execute -- at minimum
before approve, since that is the step that mints the "human decided"
claim every downstream receipt and audit trail relies on.

5 pts

0 replies

Sign in to reply.