# The audited rail: security design

Written 1 Oct 2026, before a line of the rail was built. The operator asked
for the rail and for this design first. The decisions below are taken; the
risks at the end are the operator's to weigh.

## What it is

Robinhood opened its trading platform to outside agents through an MCP
server at `agent.robinhood.com/mcp/trading`, and says in writing that it does
not audit the agents people connect. The rail is this house's own MCP server
at `harmonicagent.solutions/mcp/rail`. An agent's MCP client is pointed at it
instead of at Robinhood. It offers the same tools. Every order an agent
places through it is filed on the public record first, placed only as filed,
reported after, and graded a day on by the same scorebook that grades every
desk here. Reads pass through. Nothing else about the agent changes.

## The decision on custody

The CLI keeps a person's Robinhood session on their own machine and the desk
never sees it. The rail cannot work that way: an off-the-shelf MCP client
holds one bearer token for one server and cannot refresh a second one for
Robinhood. So the rail holds the Robinhood session, on this server, sealed.

That is a change in the trust model and it is taken knowingly:

- The session is sealed with AES-256-GCM under a key derived by HKDF from
  `WALLET_MASTER_SECRET`, the same secret that already guards every desk
  wallet, with its own salt and info string so the two keys are unrelated.
  No new Railway variable. Without the secret the rail refuses to connect
  anyone and says so.
- The sealed blob is written to the brain store and read back only by the
  one function that forwards a call to Robinhood. No desk, no route and no
  public shape can reach a token. The module exports nothing that returns one.
- A token never appears in a log line, an error, a tool result or a public
  shape. Errors to the agent are fixed sentences. A test reads the source for
  this.
- A connection unused for thirty days is erased, upstream tokens and all.
  A person can erase it sooner: the `rail_disconnect` tool, or the revoke
  endpoint, or by revoking the agent in the Robinhood app, after which the
  rail's next call fails and the connection is marked dead.

## The flow

Standard MCP authorization (OAuth 2.1), so a URL swap in any MCP client is
the whole setup.

1. The client POSTs to `/mcp/rail` without a token and is answered 401 with
   `WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp/rail"`.
2. It reads that metadata, then `/.well-known/oauth-authorization-server`,
   and registers itself at `/api/rail/register` (RFC 7591). Redirect URIs
   are limited to loopback (`http://localhost` or `http://127.0.0.1`, any
   port, any path) or `https://`. Nothing else is accepted.
3. It sends the person to `/api/rail/authorize` with PKCE (S256 only; plain
   is refused), a state, and optionally `resource`, which must equal the
   rail's URL or the request is refused (RFC 8707).
4. The person sees the rail's consent page: what it does, what it holds, what
   it never does, and the name and redirect host of the client asking. One
   button continues to Robinhood's own sign-in. The page carries a one-time
   nonce; the continue is a POST with it.
5. The rail is a dynamically registered public client of Robinhood, with one
   redirect: `/api/rail/callback`. It sends the person to Robinhood's
   authorization endpoint with its own PKCE verifier and a state that names
   the pending request, and sets a random bind as an HttpOnly, SameSite=Lax
   cookie on the callback path alone. Robinhood sends the code back to the
   callback.
6. The callback answers only the browser that pressed continue: the cookie
   must carry the bind the pending request holds, or the request is dropped
   and the code is never exchanged. Then the rail exchanges the code, seals
   the tokens, opens a connection, mints its own single-use authorization
   code bound to the client, the redirect URI, the code challenge and the
   connection, and sends the person back to the client's redirect URI with
   that code and the client's state.
7. The client exchanges the code at `/api/rail/token` with its verifier. It
   receives the rail's access token (one hour) and refresh token (thirty
   days, rotated on every use, and bound to the client id, which a refresh
   must carry). Both are random 256-bit values; only their SHA-256 is stored.
8. Every MCP call carries the rail's access token. The rail finds the
   connection, refreshes the Robinhood token when it is within a minute of
   expiry, and forwards.

## What the rail forwards and what it refuses

- `tools/list` is Robinhood's own list for that account, read live and kept
  ten minutes, with the rail's own tools added: `rail_how`, `rail_record`
  (the agent's own filings and grades) and `rail_disconnect`.
- Read tools (quotes, positions, account, order lists, pre-trade review)
  are forwarded unchanged.
- The equity order tool is chosen from the list by the same `pickTool` the
  CLI uses and is wrapped. A call to it runs, in order: the proposal is built
  from the arguments; it is filed on the Robinhood record under the
  connection's identity; the guard checks the arguments against the filing
  field by field; the order is forwarded; the fill is reported; the result
  comes back to the agent with the filing id appended. If the guard refuses,
  nothing is forwarded and the refusal is the tool result.
- Any other tool that places or submits an order (options, crypto, a second
  equity path) is refused with a plain sentence, and so is any tool that
  would move money (a transfer, a deposit, a withdrawal). The rail places
  nothing it cannot file and moves no money. Cancels are forwarded. A tool
  is read by its words, with underscores as spaces, so a name like
  place_crypto_order is caught by its name alone.
- Robinhood's own trade-approval setting still applies to everything the
  rail forwards. By Robinhood's support page it is on by default for the
  in-app agent and off by default for an MCP account, which is what the
  rail connects, so the consent page tells the person where to turn it on.

## Identity on the record

The connection id, hashed, is the identity on the Robinhood record, as the
terminal's session id is today. Each placement is also filed on the agent
registry under a terminal name derived from that id, so every connected agent
has a public rank once thirty of its placements are graded. No account
number, handle, name or wallet is kept or shown.

## Rate and size

Register: 3 a minute per IP. Authorize and token: 10 a minute per IP. MCP:
120 a minute per IP, as the registry's door. Five open proposals per identity
at a time, as today. Argument bodies are capped at the server's 8 KB limit.

## Threats considered

- Server compromise with the master secret: every connected session is
  exposed. Mitigations: sealing, short upstream token lifetimes with rotation,
  thirty-day purge, revocation, and Robinhood's own approval setting on the
  account once the person turns it on (off by default for an MCP account). This is the cost of the custody decision and it is not hidden.
- Token in a log or an error: refused by construction and by test.
- Authorization code injection or replay: codes are single use, five minutes,
  bound to client, redirect URI and PKCE challenge; the verifier must hash
  to the challenge. A code presented a second time ends the connection it
  opened and every token issued for it.
- A sign-in link handed to somebody else (a consent started in one browser,
  the Robinhood sign-in completed in another, so that the second person's
  session would be sealed into the first person's connection): the consent
  sets a bind cookie on the callback path and the callback refuses, before
  the exchange, any browser that does not carry it. The one who consented
  and the one who signed in must be the same browser.
- Open redirect: redirect URIs are matched exactly against the registration,
  with only the port free on loopback.
- CSRF on consent: a server-held nonce per pending request, POST only.
- Confused deputy at Robinhood: the pending request binds the client, and
  the consent page names it before the person continues.
- A placing tool disguised by its description: tools are classified by the
  words of their name, so a tool whose description says "returns the order
  status" is still a placing tool, and a tool the server marks destructive
  is refused unless it cancels.
- An argument the filing cannot name: the rail forwards the filing's own
  fields (symbol, side, quantity, type, limit, account, time in force) and
  nothing else, so a second instrument id or a stop price beside the filed
  symbol is refused before anything is sent.
- A forward that gets no answer: the filing is recorded as unanswered, never
  as expired or refused, since the order may be live, and the agent is told
  to read its open orders before sending again.
- Passthrough of a Robinhood token as the rail's own credential: not
  accepted. The rail accepts only tokens it minted.
- SSRF: the upstream URL is a constant; nothing in a request chooses it.

## What it will not do

Never places an order it did not file. Never alters a filed order. Never
places on its own, on a schedule, or for the house. Never holds a dollar or
a share. Never shows one person's record to another. The house's desks never
read or use a connected session. Nothing here touches the flywheel.

## For the operator

- No new Railway variable: the seal derives from `WALLET_MASTER_SECRET`.
- The rail is proved end to end against a mock of Robinhood's OAuth and MCP.
  Against the real one it is unproved until a real MCP account connects, the
  same gap the terminal has.
- Robinhood's terms for its MCP may or may not permit a third party to hold
  a customer's session and forward orders. That reading is the operator's.
- A compromise of this server with its secret would expose connected
  sessions. The CLI path remains, with the session on the person's machine,
  for anyone who prefers it.
