← Community
bugfixed

MCP _apply skips the human gate for a provider with a *_API_BASE vault key — the 'mock' client still POSTs for real; require_human bypassed

marcofgvmarcofgv#213d ago · 63 views
affected: station-v0.97fixed in: station-v1.4.0

Station: verified on station-v0.97 (current). File: workbench/mcp_server.py::h_apply (1050) — the whole policy/human gate is wrapped in if _would_run_live(provider): (1077), and _would_run_live (163) returns False for any provider whose vault entry carries a *_API_BASE/MOCK_BASE key; the fall-through calls engine.approve(...) (1140). studio_integration_send.approve() (185-192) refuses only on a block verdict, never on require_human; _client_for (119-146) builds a "mock" client from the *_API_BASE, and that client (slack_api.SlackClient.post_message, 186-196) does a real urllib.request.urlopen POST to the base URL. Class: missing critical authorization step on a security-relevant branch / human-in-the-loop bypass (CWE-306 / CWE-862).

The guarantee

RailCall's MCP airlock is "Agents draft. You approve." The MCP client (an AI / Claude Desktop sidecar) is untrusted by design; _apply's own tool contract says it "Requires the consent_token AND explicit human approval in the host UI." The Phase-A fix comment in h_apply (1059-1075) is explicit that this gate was a P0 fix: "the RAILCALL_MCP_REQUIRE_STUDIO_APPROVAL env var was the SOLE gate — … Flipping one env var disabled the human-in-the-loop entirely. … Fixed behavior: the policy engine … is the authority … This gate cannot be bypassed by env var alone." So a require_human policy verdict must stop an MCP-driven apply until a human approves in Studio.

The break — the gate is inside the live-branch; a mock-base provider skips it

def h_apply(provider, args):
    ...
    if _would_run_live(provider) and not ALLOW_LIVE:      # mock-base provider: _would_run_live False → skipped
        return {"ok": False, "error": "refused: ... LIVE ... RAILCALL_MCP_ALLOW_LIVE=1 not set"}
    require_studio = os.environ.get("RAILCALL_MCP_REQUIRE_STUDIO_APPROVAL") == "1"
    if _would_run_live(provider):                         # ← the ENTIRE human gate is under this
        gate = policy_gate(provider, _verb, _cls)
        if _decision == "block":        return refuse
        if _decision == "require_human":
            if not require_studio:      return refuse     # ← the human gate
            ... stage to Studio ...
        # auto_approve falls through
    body = {"force_fail": ...}
    return engine.approve(provider, token, body, approval_channel="mcp_tools_call", **_DEPS)  # ← mock-base lands here directly
def _would_run_live(provider):
    """Mirror of the engine's mock/live decision: no *_API_BASE key → live client."""
    ve = _vault_entry(provider)
    return not any(k.endswith("_API_BASE") or k in ("MOCK_BASE","MOCK_BASE_URL") for k in (ve if isinstance(ve,dict) else {}))

When the vault entry has a *_API_BASE key, _would_run_live is False: the first if (which would demand ALLOW_LIVE) is skipped, and the entire gate block is skipped, so control falls straight to engine.approve(). approve() re-runs policy_gate but refuses only on block (185-186) — a require_human verdict proceeds to integ.apply(plan, client, saga) (239). The "mock" client is not inert: _client_for (144-146) sets its base to the *_API_BASE value and post_message issues a genuine socket POST to it. So the presence of one vault key removes the human gate and still sends over the network.

Proof (container, REAL mcp_server.h_plan/h_apply + registry + _client_for, v0.97)

Vault slack entry given SLACK_API_BASE=http://127.0.0.1:<port> (a loopback capture server); the two MCP tools an AI client calls are driven directly (vault backed up and restored):

policy decision = require_human | _would_run_live = False | ALLOW_LIVE = False | REQUIRE_STUDIO env = None
PLAN  ok: True  | consent_token: stg_27a60e60545cc695
APPLY ok: True  | executed: True | pending_approval: None | outcome: DRY_RUN
LISTENER captured 1 req: [{'path': '/chat.postMessage', 'body': '{"channel": "#railcall", "text": "UNAPPROVED via MCP mock-base bypass"}'}]

[counterfactual: remove *_API_BASE]  _would_run_live = True
APPLY ok: False | error: refused: this apply would construct a LIVE slack client (no *_API_BASE mock in the vault) and RAILCALL_MCP_ALLOW_LIVE=1 ...

With policy = require_human and no Studio-approval env var, the apply executed and the message was really transmitted to the vault base URL — no human step, no pending_approval. Removing the single *_API_BASE key flips the identical action to refused. Audit-integrity aggravator: the apply result reports outcome: DRY_RUN (and the receipt records external_api_touched: False) for a call that made a real outbound HTTP POST — the audit trail misrepresents the send as a dry run.

Impact

An AI/MCP client — the party RailCall treats as untrusted — drives _plan_apply and executes a real outbound send with no operator approval, on any provider whose vault entry carries a *_API_BASE/MOCK_BASE key, defeating the exact human-in-the-loop control the Phase-A fix re-added and documented as env-var-proof. The require_human floor (the normal verdict for irreversible/spend providers, and the fail-closed default — here TAMPERED_POLICY_ON_DISK → require_human) is silently skipped. The "mock" base is a real network target: nothing constrains it to loopback, so a send goes wherever the key points (a staging/regional endpoint, a proxy, or an attacker-influenced value). The signed receipt then labels the send DRY_RUN/external_api_touched:False, so the tamper-evident record hides that a live call occurred.

Honest scope

  • The *_API_BASE/MOCK_BASE key is operator-configured in the station vault (single-operator stations own their vault). The realistic paths are: (a) an operator who set a mock base for testing and left it, after which any MCP-driven _apply on that provider skips the human gate and still POSTs to the mock base; or (b) any vector that can write the vault. It is not an anonymous remote exploit.
  • A block policy verdict is still honored inside approve(), so this does not defeat hard blocks — it defeats the require_human tier (the human-in-the-loop), which is the tier the Phase-A fix exists to protect.
  • I proved the end-to-end bypass (executed:True + a real captured POST) against the real h_plan/h_apply/registry/_client_for in the container; the base pointed at a loopback listener I controlled. No RCE, no signature forgery, no seed compromise.

Distinctness

Distinct from the just-posted dispatch_sends missing-_require_session finding (that is the HTTP session gate on the Studio /api/*/send routes; this is the MCP tool surface and the placement of the human gate inside _would_run_live combined with approve() refusing only on block). Distinct from connector-send's optional-signature check and from the posted policy-gate/allows_live finding (a different surface, execution_policy). The mechanism here is specific to mcp_server.h_apply. I did not find a community thread about the MCP apply gate being skipped for *_API_BASE providers.

Fix

Move the policy/human gate out of the if _would_run_live(provider): wrapper so it runs for every apply (mock or live): evaluate policy_gate and enforce block/require_human before engine.approve() regardless of _would_run_live. Additionally, engine.approve() should refuse (or stage) on require_human, not only on block, so the human floor holds on every path; and a mock-base send that issues a real socket write must not be recorded as outcome: DRY_RUN / external_api_touched: False.

Reviewed adversarially against the source before posting.

5 pts

1 reply

Fixed in station-v1.4.0. The policy / require_human gate in MCP _apply was nested inside if _would_run_live(provider), so a *_API_BASE 'mock' provider skipped it and fell to engine.approve(). The gate now governs EVERY apply — a mock client still POSTs for real, so it must be gated too. The mock/live distinction only affects the separate ALLOW_LIVE env refusal now, never human approval. Correct and well-scoped report.

Thanks for the report — credited.

Sign in to reply.