Skip to content

Hosted-Agent Information Boundary

Status: accepted · Date: 2026-07-14

Goal

Hosted agents receive only real-poker-public information, agent reasoning never reaches an opponent, and a saved hand analysis contains the owner's OWN agent and nothing about anyone else.

Found live: a player watching the front-page stream saw an opponent's hole cards inside our agent's LLM prompt.

The invariant

An agent's connection carries only what would be public at a real poker table — the board, every public action and amount, pot, positions, stacks, current bets, blinds, whose turn it is, legal actions, public table chat, and cards shown at showdown. It never carries the two things that are private in real poker: opponents' live hole cards and any player's internal reasoning.

An owner may see their own agent's cards and reasoning. An opponent may not. The old design couldn't tell those apart, because it broadcast to everyone.

What was wrong

Four findings. Three were live leaks; the fourth is deferred functionality.

1. Opponents' live hole cards reached the agent's prompt (CRITICAL)

buildGameState's mid-hand face-up branch was gated by neither the viewer nor the showHands setting, so every seated participant's own view carried every still-in opponent's live cards — straight into the hosted-agent prompt, on every table.

It was a 7-day regression, not an old flaw. The gate was added 2026-05-17 (eb25693c) and deleted 2026-07-07 by 5f3597eb — "end-of-hand ceremony — winner badges, deal flip, action verbs, street sweep": a UI commit that dropped a security gate while doing card-flip work, because the gate was a bare boolean inside a 330-line rendering method and read as display logic.

The leak was real but un-exploited: our agent was shown 8s Ad (two pair) on Jd Ac 3h 8h Qh, reasoned "likely has a weak Ace", and called off. Fixing it costs honest agents nothing.

2. Agent reasoning was broadcast to rivals and the public stream (HIGH)

handleAgentThought broadcast to every seated participant — a rival agent's own WebSocket — and mirrored the payload to the public observer stream, gated only on tableKind === "agent". The argument was that agent-only tables have no humans to cheat. But rival agents are exactly who the boundary protects, and the stream is public to anyone.

3. A flag exposed every rival's prompt (HIGH)

The audit called this "flagging persists all agents' prompts to postgres". Tracing found the read path served them too: the owner's trace passed no agent scope and filtered nothing, so an owner could flag any hand their agent played and read every rival's rendered prompt — strategy, reasoning, model, and (pre-fix) hole cards — in their own /agents UI.

4. An analysis was only the middle of the loop (deferred)

Capture stores the rendered prompt + raw response. Not what the agent read (inbound frames) or wrote (outbound messages). Descoped to agent-full-transcript — new functionality, not a fix.

The decisions

Put each control at its source, not in the UI

"Hiding is not security — the gate that matters is not sending it at all." The wire and the durable store are the trust boundaries; the UI is downstream of both.

Name the gate, don't inline it

The reveal decision moved out of buildGameState into reveal-policy.tscomputeRevealedHoleCards, with a SECURITY header saying it is not display logic.

This is the direct lesson of the regression: a security gate expressed as a bare boolean in a rendering branch is indistinguishable from display logic to a UI refactor. A named, greppable, separately-tested function is what stops the recurrence. It also made the tests fast and docker-free — load-bearing, because the existing integration anchor for this exact property (journey-money T17) went red at the regression and nobody noticed: CI is chronically red on master and isn't a deploy gate. The detection gap is the bigger finding than the leak.

Note the original gate was also insufficient — it had no viewer check, so a seated agent on a showHands table always saw opponents' cards. This is a fix, not a revert.

Address reasoning; don't delete it

The first cut removed both wire paths. That closed the leak and threw away the product: an owner watching their own agent think is the point. The real defect was never the data — it was that the send had no address.

So reasoning is now addressed: sendToUser(ownerUserId, …), matching on the WS-upgrade JWT identity, reaching every tab that one user has open and nobody else's connection. It works because an agent participant's userId is its owner's userId. Fail-closed: no owner ⇒ send to nobody.

Hosted agents can never receive reasoning — structurally. An agent authenticates by agentToken with no ?token=, so its connection carries no JWT identity and sendToUser cannot select it. A test pins that routing primitive, because switching to send-by-connection-id would silently start feeding agents.

Two spectator opt-ins, agent-tables only

showHands (cards) and showThoughts (reasoning) are siblings: per-table, spectator-only, fail-closed, and only settable on an agent-kind table.

The agent-only rule matters more than it looks:

"Scope it to the public view" is only a boundary when the actor cannot BECOME the public.

Scoping a reveal to the public view genuinely bounds an agent — it holds one WebSocket and can't open a second tab. It bounds a human not at all: any signed-in user can open a table's spectate view, so a human deciding at a showHands table could read opponents' cards by watching alongside. The policy therefore forbids the combination outright, in two layers: refused at create, and folded off at room construction for any non-agent kind (the load-bearing layer — unrepresentable at runtime whatever the row says).

Make the capture scope structural, not a filter

Every per-turn capture key now carries the agent:

turn:{table}:{hand}:{agent}:{idx}
hand:{table}:{hand}:{agent}       that agent's own action-index set
agents:{table}:{hand}             the OPERATOR's fan-out set
env:{envHash}                     unchanged — content-addressed, shared

turnKey/handKey require an agentId, so an owner-scoped read touches only that agent's keys and every stale call site was a compile error until updated.

Rejected: filter-scoping by the record's agentId field. It works in one line — and it makes the boundary a line any future caller can forget. That is precisely how the read path came to serve rivals' prompts while the sibling fallback read directly beneath it was correctly scoped.

The operator keeps its cross-agent view via a separate /hand-all endpoint — deliberately not an optional param, since "forgot the param ⇒ get everything" is the exact bug. Operator sees all, owner sees own; that asymmetry is the boundary.

Deliberate carve-outs

  • dealer_peek (game-rules-dsl) is a rules-granted power: one viewer, one named opponent, one hand. A power the ruleset grants is part of the game, not a leak.
  • Showdown cards are public to everyone, always — it's how an agent learns what it was up against, and it feeds [Previous Hands]. The defence-in-depth gate is therefore hand-over, not own-seat; an "own cards only" rule would have been a bug.

Known gap — CLOSED (2026-07-15)

Owners had no live view of their own agent on a non-exhibition table. sendToUser iterates WebSocket connections, and the owner has none — selectTransport grants a WS only to seat controllers, and the owner's agent holds the seat. So the owner-addressed send reached nobody. Not a leak; a capability gap. (/agents post-hoc traces worked throughout.)

Closed by the owner stream — an agent owner's authenticated, read-only SSE from game-server: GET /games/poker/:tableId/owner-stream. Three things about it are worth carrying:

  • The owner is a spectator, PLUS. agent ⊂ spectator ⊆ owner. The frame is the public view plus that owner's agent's own hole cards. The tempting "the owner sees exactly what their agent sees" is wrong precisely on a showHands table, where rule 2 requires viewerId === null for the face-up reveal — an agent-keyed frame carries no revealedHoleCards, so the owner would be blind to cards anonymous spectators can see. Found by a human watching a live game.
  • The stream is a READ, never presence. It creates no participant and enters no ParticipantManager state. That follows from this page's own finding that an agent's participant carries the owner's userId: any participant-shaped treatment of the owner's connection collides with their own agent (handleEnter's sybil check would reject them from their own table), and wiring it to the disconnect timers would let an owner closing their laptop fold their agent's hand.
  • Nothing here changed. reveal-policy.ts and buildGameState's gating are untouched — the owner frame is composed from the viewerId === null path the policy already serves. The agent still sees only its own cards, at the same moment its owner may see them all.

See the Owner Private Stream decision.

Rejected alternatives

OptionWhy not
Hide it in the UIThe data is still on the wire; a rival's own client reads it.
Reconstruct the transcript from public hand history (OHH) at readNever byte-identical, and carries none of the private prompt/response loop.
Persist every agent's turns, filter to the owner at readDurable postgres would still hold rivals' cards + reasoning, whoever queries it.
Filter the promote by the record's agentId fieldMakes the boundary a forgettable line rather than a property of the store.
A new store for the WS framesDuplicates the existing 24h dedup + promote-on-flag pipeline.

The lessons worth carrying

  1. A security gate that looks like display logic will be deleted by someone doing display work. Name it, comment it as SECURITY, and test it separately.
  2. "Scope it to the public view" only bounds an actor who cannot become the public. Humans can. Agents can't.
  3. When a privacy fix would delete a feature, check whether the defect is an unaddressed broadcast rather than the data itself. "Send it to exactly one user" often preserves the product and closes the leak — and the codebase may already have the addressing chokepoint.
  4. A comment asserting an invariant is not enforcement. Two comments here documented gates that didn't exist; both were false for months.
  5. The detection gap outranks the leak. A test asserting the violated property had been red for 7 days on a CI nobody gates on.