LIVE RED TEAM - SyberLabs/RISE GitHub Wiki
Red team of the live Current
Status: an adversarial review of the live layer as merged in #319, on main at c4e31ff, and the fixes that followed it. Every confirmed defect is fixed. Each was first committed as a test asserting the invariant and marked as an expected failure. The mark was then removed, the test watched failing, and the code fixed until it passed. The tests are in src/live/red-team.test.js and e2e/live-red-team.spec.js. What still needs a real OpenAI session, a product MCP host, or a decision about a second origin is in Β§G and Β§H. Nothing here was tried against a real provider or host. Β§A to Β§F below are the review as it was written, before the fixes; the table that follows says how each finding was resolved.
Resolution
| Finding | Resolution |
|---|---|
| F-1 to F-4, runtime races | A run is the runtime's before its provider answers: it is registered as main or side before adapter.open, which is handed an AbortSignal. Stop aborts and closes a run that is still opening. A connection that arrives after Stop is closed unused. Surface re-checks Stop after each wait. A second Dive is refused at once, and the controls disable Dive while one is connecting. The signal reaches the WebRTC transport, which asks nothing of the site or OpenAI once it is aborted, and the OpenAI adapter never sends the question after it. Verified in the browser: Stop during connect asks nothing, shows nothing, and leaves no peer open. |
| F-5, F-6, queue overflow and event budget | The line parser gathers a passage's words and sends them in chunks of up to 1,000 characters. Only ended passages are shown, so nothing is delayed for the reader, and a whole Current is now at most about 114 events however the provider cuts it. |
| F-7, forged author | mcp-app.js sets the origin of everything that comes through the host (kind: 'model') for both the answer and a Dive. |
F-8, unbounded @ buffer |
An @ line longer than 512 characters is not a header. It is ignored like any other unknown directive and never held, and the result is the same whole or chunked. |
| F-9, check-then-copy | validateEvent copies the input's own enumerable data once, each property read once and nesting capped at eight levels, and validates the copy. Non-plain objects become a value every check refuses, so today's plain-object rules are unchanged. |
| F-10, blank chunk | What does not fit is dropped with the space before it, so a passage at its limit ends instead of sending a blank chunk. |
F-11, [ and letters |
A partial marker held back at the end of a line is sent as words of that line: a marker cannot continue past a line break. |
| F-12, ended metadata | state.set and evidence.add for an ended segment are refused (SEGMENT_CLOSED), and the events spec says so. No producer relied on the old behaviour. |
| R-1, any page can host | Not resolved; it needs a decision. Serving the embed from a separate origin needs a new domain. What code can do is done: the answer's attribution names the page that framed RISE ("MCP host at https://β¦"), taken from the frame's first ancestor or the referrer, or says the page is unidentified. The embed takes no key. |
| R-2, spend after RISE stops reading | The Worker's session sets max_output_tokens: 4096, the most OpenAI allows. When a limit means nothing more can be shown, the text adapter cancels the provider and completes the Current. The call id is still not kept: the browser closing the peer ends the call, and a server-side hangup is only for an unclean close. |
| R-3, stale voice callbacks | The runtime ignores a voice's callbacks once its run has closed. |
| R-4, atom ids | Deferred, as recommended: nothing keys on them yet. |
| Also fixed | The live page itself had the same race one layer up. A reader who leaves while it is still getting ready no longer has a runtime started, and an embedded page destroyed while its host says hello writes nothing. |
| U-1 to U-6 | Unchanged: they need a real provider, host, voice, or Cloudflare log (Β§G). |
The invariant under attack: models, hosts, plugins, and providers may propose information and events; they must never directly mutate authoritative playback or runtime state outside the bounded operations RISE explicitly permits.
A. Executive assessment
The protocol and reducer hold, but the runtime does not. Stop is not authoritative while an await is in flight, and the MCP host can claim a human author.
- The event boundary holds. Hostile JSON, reordering, duplicates, conflicting sequence numbers, far-future gaps, prototype keys, and events after a terminal event were all refused within budget. Across 400 seeded hostile deliveries the reducer never threw, ended text never changed, and every lowering was a prefix of the next. One gap is real:
state.setandevidence.addare still accepted for an ended segment, which the plan says is refused. - The runtime has four races, all the same bug.
start,dive,surface, andstopeach await a provider or Player and then act without re-checking whether the reader pressed Stop meanwhile. In the browser, with OpenAI's transport faked, Stop pressed during connect still sends the question to the provider (billed to the reader's key) and presents the answer. This must be fixed before OpenAI Live is switched on. - The MCP host can forge who wrote an answer. A Current or Dive answer from the host's model may carry
origin: { kind: 'human', name: 'Your teacher' }. RISE then shows "From Your teacher." and compiles the program withauthority: 'user'. Attribution is the one thing a reader may take as evidence, so this must be fixed before MCP is switched on. - The generic text stream parser has bounded-looking limits and several defects. A line that starts with
@is buffered without bound. A passage reaching 3,999 characters emits a blank chunk the reducer refuses. One large delta overflows the adapter's queue. One character per delta exhausts the 5,000-event cap inside every text limit. None of these affects the sealed or MCP paths. They matter for any line-format provider, OpenAI included. frame-ancestors *is a deliberate risk, not a defect. WithMCP_ENABLED, any website can frame/live?embed=mcp, act as its host, and read every question the reader types or speaks. The browser test proves this. It is what an MCP app needs today, but it runs on RISE's primary origin.- OpenAI Realtime matches the documentation where the two can be compared. The endpoint, the multipart shape, the event names, and the response statuses match. Three behaviours can only be settled by a real session, listed in Β§G.
Both flags are "false" in wrangler.production.jsonc, so none of this is reachable in production today.
B. Trust boundary
UNTRUSTED β ADAPTER (per provider) β RISE (authoritative)
β β
OpenAI Realtime ββ oai-events JSON βββββΊβ openai-wire: names, response id, β
(via Worker relay: key regex, β status β delta/done/error β
same-origin, SDP checks, no echo) β segment-parser: @lines β events β
β text-stream: writer + channel(64)β
Generic text provider ββ deltas ββββββββΊβ pushNow β€ 128, else OVERFLOW β
β β validateEvent (closed catalog,
MCP host (any parent frame) β JSON-RPC βΊβ mcp-port: source === parent ββββΊ bounds, forbidden keys)
tool input Current, sampling result β mcp-app: currentToEvents(current)β β (event is data from here)
β origin passed through ββ F-7 β βΌ
Reader speech / typing βββ text ββββββββΊβ closed grammar (Dive / Surface) β createCurrentStream (pure):
β β seq window 16, refusals 8,
β β 5,000 events, 16Γ4,000 chars
β β β ended segments only
β β βΌ
β β compileRiseCurrent β Session
β β β (authority from origin β F-7)
β β βΌ
β β runtime: start/dive/surface/stop
β β (awaits unguarded β F-1..F-4)
β β βΌ
β β Player.extend / Chamber (one)
Authority changes hands twice. The first handover is at validateEvent: what came from a provider becomes a plain, bounded, closed-catalog value. The second is at the runtime, where the reducer's committed segments become a Session only RISE can present. Everything left of the reducer may propose. It may not reach the Player except through ended segments.
The breaks found are at three points. The origin of an MCP answer crosses unchanged and becomes authority (F-7). The runtime's awaits let a closed run act (F-1 to F-4). And validateEvent reads some fields twice, which is safe only while every producer is JSON or structured clone (F-9).
C. Findings
Severity is the harm if the flag in question were switched on today: High means it breaks the invariant or the reader's trust in a way they would notice or be billed for; Medium means a correct answer fails or misleads; Low means a local defect with a bounded effect.
Confirmed defects
F-1 Β· High Β· CONFIRMED DEFECT: Stop during connect does not stop.
- Where:
src/live/runtime.jsstart():main = await openRun(...)(line 286) with nostoppedcheck after it.LiveHostshows Stop beforeawait runtime.start(). - Reproduce: unit test "Stop while the provider is still connecting"; browser test "DEFECT: Stop pressed while the OpenAI session is connecting".
- Expected: after Stop, no provider request, no Player, and the connection closed.
- Observed: status returns to
live, a Player is created and presented, and the connection is never closed. In the browser,response.createis sent after Stop (askedAfterStop: 1) and the answer is shown. The peer closes only when the answer finishes. - Scenario: the reader presses Stop because they asked the wrong question. The question is still sent, billed to their key, and the answer takes over the screen.
- Smallest fix: after each await in the runtime,
if (stopped) { await connection.close(); return; }. Pass anAbortSignalfromstop()intoadapter.open, so the WebRTC transport can abandon the offer before the data channel opens.
F-2 Β· High Β· CONFIRMED DEFECT: two Dives can open, and the first is orphaned.
- Where:
runtime.jsdive()checksif (side)(line 331) beforeside = await openRun(...)(line 349). The Dive button stays enabled until the status becomesdiving, which happens only after the open resolves (controls.jslines 208β209). - Reproduce: unit test "two Dives asked before the first has opened".
- Expected: a second Dive is refused while one is opening (
NESTED_DIVE). - Observed: both open. The first is overwritten and never closed, and it keeps generating on the reader's key.
- Smallest fix: set a pending-Dive marker synchronously before the await and clear it on failure. Same re-check as F-1.
F-3 Β· Medium Β· CONFIRMED DEFECT: Stop while a Dive is opening starts the Dive.
- Where:
runtime.jsdive(): nostoppedcheck afteropenRun. - Reproduce: unit test "Stop while a Dive is still opening".
- Observed: status becomes
divingafterstopped, and the side run starts and is never closed. - Smallest fix: the F-1 fix.
F-4 Β· Medium Β· CONFIRMED DEFECT: Stop during Surface re-presents a destroyed Player.
- Where:
runtime.jssurface():await closeRun(child)(line 370), then presents the main Player and setslivewithout checkingstopped. - Reproduce: unit test "Stop during Surface" (
presentedAfterStop: 1). - Observed: the host is handed a Player that
stop()already destroyed. - Smallest fix: the F-1 fix.
F-5 Β· Medium Β· CONFIRMED DEFECT: the text stream adapter's outcome depends on chunking.
- Where:
src/live/adapters/text-stream.jsline 79:pushNowfails past twice the channel capacity (128 queued events), and the adapter ends with non-recoverableOVERFLOW. - Reproduce: unit test "the same answer arriving in one delta overflows". A 200-line answer in one delta ends in phase
openwith nothing shown. The same answer line by line completes. - Expected: the same words produce the same Current however they are chunked. The parser corpus proves this holds below the queue.
- Scenario: a provider or proxy that buffers and flushes (common with HTTP streaming) loses a long answer.
- Smallest fix: push from the parser with back-pressure (
await channel.push) instead ofpushNow, or coalesce the parser's output per delta into onesegment.textper segment.
F-6 Β· LowβMedium Β· CONFIRMED DEFECT: one character per delta exhausts the event cap.
- Where: the parser emits one
segment.textper non-empty delta, andstream.jscaps a stream at 5,000 events. - Reproduce: unit test "an answer within every text limit fails TOO_MANY_EVENTS" (three passages under 10,000 characters).
- Smallest fix: coalesce text per microtask in the adapter. Do not raise the cap: it bounds a hostile provider, not an honest one.
F-7 Β· Medium Β· CONFIRMED DEFECT: the MCP host's model can present its words as a person's.
- Where:
src/live/adapters/mcp-app.jsacceptβcurrentToEvents(current)(line 120).current-events.jsline 22 copiescurrent.originunchanged.rise-current.jsline 197 derivesauthority: 'user'from a non-model origin.host/passage.jsline 90 renders "From {who}.". - Reproduce: unit test "a Dive written by the host's model can present itself as a human author"; browser test "DEFECT: the host's model can have its Dive shown to the reader as a person's words" (the passage reads "From Your teacher.").
- Expected: the origin of anything that arrives through the host says it came through the host. RISE cannot verify a claim of human authorship, so it must not display one.
- Scenario: a prompt-injected or careless host model attributes an answer to a teacher, a doctor, or the reader. RISE's own chrome then vouches for it, and the Session is marked as the user's rather than proposed.
- Smallest fix: in
mcp-app.js, replace the incomingoriginwith{ kind: 'model', name: 'Host model', provider: 'MCP host' }, asensureOpenalready does. Keep the host's claimed name only as quoted, unverified text if it is wanted at all.
F-8 Β· LowβMedium Β· CONFIRMED DEFECT: the line parser's @ buffer is unbounded and rescanned.
- Where:
src/live/adapters/segment-parser.jsline 192:if (!midLine && buffer.startsWith('@')) return;. The buffer waits for a newline with no limit and is searched again on every delta. - Reproduce: unit test "a line that begins with @ is buffered without any bound". Pushing 32 MB grew the heap by about 146 MB, against an expected bound under 4 MB. In a probe, 64 MB grew it by 775 MB, and 40,000 one-character pushes took 47 ms, against 4 ms for a normal line: superlinear.
- The hypothesis is proved. The Current's text limits do not apply, because nothing has become text yet. With OpenAI the buffer is bounded only by the model's own maximum output, since the Worker sets no
max_output_tokens(the documented default is"inf"). With a generic provider it is unbounded. - Smallest fix: a header longer than a small cap (about 200 characters) is not a header. Treat the line as words, or drop it and count a refusal.
F-9 Β· Low today, High with in-realm plugins Β· CONFIRMED DEFECT: validateEvent checks and copies separately.
- Where:
src/live/protocol.jsvalidateEventreadsvisual,evidence.kind,origin.kind, the interrupt reason, andtypemore than once. - Reproduce: unit tests "an accessor can return a visual outside the closed catalog after it was checked" (
webgpu:any-shader) and "an accessor can change an evidence kind between the check and the copy" (javascript:alert(1)). - Why Low today: every current producer is
JSON.parseoutput or a structured clone frompostMessage, and neither can carry getters. An in-page plugin passing objects directly would get past the closed catalog. - Smallest fix: snapshot the input once at entry (
structuredClone, which drops accessors, or a JSON round trip). Alternatively, read each field once into a local and validate the local.
F-10 Β· Low Β· CONFIRMED DEFECT: a passage reaching 3,999 characters sends a blank chunk.
- Where:
segment-parser.jssend(lines 110β122). With one character of room left, the piece can be a single space, and the reducer refuses a blanksegment.text(EVENT_TEXT). - Reproduce: unit test "a passage that reaches 3,999 characters sends a blank chunk". The hostile-corpus fuzz found it.
- Effect: it spends one of eight refusals. A provider that writes long passages can exhaust the refusal budget and fail the Current.
- Smallest fix: skip pieces that are blank after trimming.
F-11 Β· Low Β· CONFIRMED DEFECT: a trailing [ plus up to five letters is lost or glued at @end.
- Where:
segment-parser.jsheld(lines 84, 139β144, 169). A possible playback marker is held back. At@endit is dropped, or it is prefixed to the next passage's first line. - Reproduce: unit test "a line ending in "[" and up to five letters". The received text was "The index is xSee [noteand then more."
- Smallest fix: at
@end, or at any line end, flushheldas text into the segment it came from.
F-12 Β· Low Β· CONFIRMED DEFECT: an ended segment's condition and sources can still change.
- Where:
src/live/stream.js:state.setandevidence.addhave nosegment.endedcheck (comparesegment.textat line 157 anddive.attachat line 207).LIVE-CURRENT.mdΒ§3 says "An event for a finalised segment β¦ is refused and counted." - Reproduce: unit test "an ended segment's condition and sources can still be changed". The Chamber then adopts the changed visual program on the next extension.
- Effect: the words stay immutable (fuzzed), but the imagery and the sources shown for a passage the reader has already heard can change afterwards.
- Smallest fix: refuse both with
SEGMENT_CLOSED, assegment.textis refused. The events spec's "lowering-affecting metadata" already covers this.
Architectural risks
R-1 Β· High if MCP is enabled Β· frame-ancestors * with an unauthenticated parent.
- Where:
worker/mcp-server.mjsline 182 (for/live?embed=mcp).src/live/hosts/mcp-port.jsaccepts messages fromevent.source === parent, whatever that parent is. - Proof: browser test "risk: any origin can frame the page, act as its host, and read the question the reader asks". A page on another origin frames RISE with no relay and receives the typed Dive question in a sampling request. Speech-derived questions go the same way.
- Why it is a risk and not a defect: MCP app hosts render the app in a sandboxed frame whose origin is not known in advance, so the frame has to accept any ancestor. What any site gains is the ability to show validated, bounded text in RISE's chrome, and to read what the reader asks there.
- Smallest mitigation: serve the embed from a separate origin (for example
embed.or a sandbox domain) that has no first-party storage, no key, and no reading history. Show the host's origin, fromdocument.referrerorancestorOrigins, in the embed. Never store a key in embed mode.
R-2 Β· Medium Β· provider output keeps running after RISE stops reading it.
- The parser's limits and the reducer's caps drop text, but the provider keeps generating. The Worker's session sets no
max_output_tokens, and a cap that drops text does not sendresponse.cancel. The Worker also discards the call id in theLocationheader, so RISE has no server-side hangup if the browser closes uncleanly. - Mitigation: set
max_output_tokensfrom the Current's text budget. Cancel when the adapter ends for a local reason. Keep the call id for a hangup.
R-3 Β· Low Β· runtime trusts voice renderers to stop calling back.
runtime.jsattachVoicecallbacks do not checkrun.closed. The browser voice guards itself (item.utterance !== utterance), so there is no defect today. A new renderer that does not guard would write into a closed run.- Mitigation: one
if (run.closed) return;in the runtime, so renderers need not be trusted.
R-4 Β· Low Β· atom ids are regenerated on every lowering.
- Every field of every committed atom except
idis identical across lowerings (tested). The ids are fresh UUIDs each time (tested). Nothing keys on atom ids acrossPlayer.extendtoday. A future feature that did, such as bookmarks, notes, or evidence links, would break silently. - Mitigation: derive atom ids from segment id and index when the first such feature is built, not before.
Unverified assumptions (need a real provider or host)
- U-1: every OpenAI
errorevent ends the Current (openai-wire.js), while the documentation says "most errors are recoverable and the session will stay open." This is safe, since it fails closed, but it may end answers needlessly. - U-2: RISE sends
conversation.item.createandresponse.createas soon as the data channel opens, without waiting forsession.created. The documentation does not say whether that is allowed. - U-3: a text-only session (
output_modalities: ['text']) accepts the browser's receive-only audio line in the offer. - U-4: Cloudflare's
observability.enabled: truedoes not log the reader'sAuthorizationheader on/api/live/realtime. - U-5: product MCP hosts render the app in a sandbox that works with
frame-ancestors *, delegate the microphone, support sampling, accept protocol version2026-01-26, and do not rely on partial tool input. - U-6: a real
speechSynthesisand a real recognizer behave like the fakes the tests use, including boundary events and cancellation.
False alarms (attacked and held)
| Attack | Result |
|---|---|
Prototype keys, sparse arrays, null, valueOf objects, deep nesting, MAX_SAFE_INTEGER and fractional sequence numbers |
Refused; no pollution |
| 400 seeds of reordering, duplicates, conflicts, gaps, junk, and post-terminal events | Never threw; ended text immutable; lowerings are prefixes; pressure β€ 16; refusals β€ 8 |
| Duplicate storm | Bounded by the 5,000-event cap |
| Parser under arbitrary chunking (150 hostile seeds) | Identical output, zero refusals |
| Disconnect at every character boundary | Only whole passages survive, each equal to the uninterrupted run |
| Events after Stop | Never applied |
| Player extension changing an already-heard atom | Every field but id identical |
| Recompiling the whole Current on every segment end | Quadratic, but 1.4 ms at 16 segments and 20,000 characters, and 15 ms summed over a whole Current. Irrelevant at today's caps; revisit if the caps rise. |
postMessage(β¦, '*') from the embed to its parent |
The parent cannot be swapped without destroying the frame. The recipient is untrusted anyway (R-1). |
| Relay origin checks | Hold (existing browser tests) |
| Worker echoing upstream errors or the key | Never echoed (existing tests) |
| Unexpected provider audio | Never attached or played |
| OpenAI endpoint, multipart shape, delta and status names | Match the current documentation |
D. Streaming readiness
| Area | Ready? | Why |
|---|---|---|
| Protocol and reducer | Yes | Fuzzed and bounded; fix F-12 and snapshot input (F-9) before plugins |
| Generic text streaming | No | F-5, F-6, F-8, F-10, F-11: chunking changes the outcome |
| OpenAI Realtime | No | F-1 and F-2 bill the reader after Stop; U-1 to U-3 are untested |
| Speech sync | Partly | Correct with the fakes; real voices untested (U-6); R-3 |
| Interruption | Partly | Reducer and adapter correct; Stop races (F-1, F-3, F-4) |
| Dive and Surface | No | F-2, F-3, F-4 |
| Reconnect | Yes, locally | Replay and resume are tested with fakes; no real network drop tried |
| Long sessions | Yes, within caps | 20,000 characters and 16 segments bound everything measured. Repeated Dive and Surface memory was not measured. |
E. Plugin readiness
Not ready. The design can be made ready with a small contract.
Of the four possible outputs (sealed Currents, RISE events, typed proposals, and capability outputs), the right one for plugins is sealed Currents or RISE events, never objects in RISE's realm. Both are already validated end to end. Typed proposals and capability outputs would each need a new validator, and a new place where authority could leak.
The smallest capability model:
- A plugin is a provider. It implements the existing adapter contract (
openreturning events) and nothing else: no Player, no Chamber, no runtime handle. - It runs out of realm, in a worker or a sandboxed frame, so anything it produces reaches RISE by structured clone (no accessors; F-9 becomes moot). Snapshot the input anyway.
- RISE sets its origin. A plugin never supplies
origin. RISE writes{ kind: 'model' | 'plugin', name, provider }from the plugin's manifest (the F-7 lesson). - It declares capabilities, and RISE enforces them. The manifest lists which event types and visuals the plugin may emit, and the reducer is given that list. A plugin cannot widen it.
- Budgets are per plugin. Events, characters, refusals, wall time, and provider spend belong to the plugin's run, and it ends when they are spent.
- Composition is by precedence, not merge. The reader's controls come first (Stop, Surface, and Dive always win), then RISE's own lowering, then one active provider for each role (main or side). Two plugins never write into one Current. A plugin's contribution can only be a Current or a Dive.
F. Adversarial tests added
src/live/red-team.test.js: 30 tests. The review added 22: thirteen were expected failures, one per confirmed defect (F-9 has two), and nine held, including the 400-seed delivery fuzz, the 150-seed parser corpus, the disconnect sweep, and the atom-prefix check. The fixes turned the thirteen into ordinary tests and added eight: the abort signal reachingopen, the OpenAI adapter sending nothing after it, voice callbacks after Stop, a proxy and a hundred-thousand-level nesting, a long@line whole and chunked, cancelling a provider once nothing more can be shown, naming the framing page, and the host's main answer claiming a person. The file runs in the full unit suite, not in the pull-request fast set.e2e/live-red-team.spec.js: three browser tests in thefullproject. One is a passing proof of R-1: a cross-origin host still reads the question, which is the accepted exposure. The other two were expected failures and now pass. One checks that the attribution says the host's model wrote the Dive and names the framing page. The other checks that Stop during connect asks nothing, shows nothing, and leaves no peer open.- Beside them: the WebRTC transport's abort paths (
openai-webrtc.test.js), the controls during a connecting Dive (controls.test.js),framedByand the page left while it was getting ready (LiveHost.test.js), the reducer's refusals for ended segments (stream.test.js), and the Worker's output cap (worker/live-realtime.test.js).
Run them with npx vitest run src/live/red-team.test.js and npx playwright test e2e/live-red-team.spec.js --project=full.
G. Required real-world experiments
Each experiment is one real OpenAI Realtime session (a few cents on a test key) or one real host, run once and recorded.
| Experiment | Cheapest form | Evidence it produces |
|---|---|---|
U-2: send before session.created |
Open a call, send response.create on channel open, and log the event order |
Whether the first response is accepted, queued, or errors |
| U-3: receive-only audio with a text-only session | The Worker's exact offer | 201 with SDP, or a 4xx naming the audio line |
| U-1: recoverable errors | Send one malformed client event mid-answer | Whether the session stays open and the answer continues after error |
| F-1 on the real provider | Stop 200 ms after pressing Ask | Whether a response is billed (usage in the dashboard) |
| R-2: runaway output | Ask for a very long answer and let the parser drop it | Tokens billed after RISE stopped reading |
| U-4: key in logs | One call with a canary key, then search Cloudflare logs | Whether the header is recorded |
| U-5: product MCP host | Install the server in one product host with sampling | Whether the frame loads, sampling works, and the microphone is offered |
| U-6: real speech | One Current on Chrome and Safari with real voices | Whether boundary events arrive and cancellation is clean |
H. Prioritized actions
As written before the fixes. Everything in it is done except R-1's separate origin, R-4, and the experiments. What still stands between each flag and switching it on:
- OpenAI Live: experiments U-2, U-3 and U-4.
- MCP: R-1's origin decision and experiment U-5.
Block before enabling OpenAI Live (LIVE_REALTIME_ENABLED):
- F-1 to F-4: re-check
stoppedafter every await in the runtime, close orphaned connections, add a synchronous pending-Dive guard, and pass an abort signal intoadapter.open. - R-2: set
max_output_tokensfrom the text budget, and cancel on a local end. - Experiments U-2, U-3, and U-4.
Block before enabling MCP (MCP_ENABLED):
- F-7: RISE sets the origin of everything that comes through the host.
- R-1: serve the embed from an origin with no first-party state, and show the host's origin to the reader.
- Experiment U-5.
Amended 2026-10-08, MCP enabled: F-7 is done. R-1 is closed by the self-contained card (LIVE-010, LIVE-012): the card runs in the host's sandbox, an opaque origin holding no RISE state, and nothing frames /live?embed=mcp, which keeps frame-ancestors 'none'. U-5 is the owner's witness in ChatGPT and Claude; its microphone and sampling parts are outside Composer v1.
Fix soon (generic streaming): F-5 and F-6 (coalesce text and apply back-pressure), F-8 (cap the header line), F-10 and F-11 (parser edge cases), F-12 (refuse metadata on ended segments).
Architecture before plugins: F-9 (snapshot input once), R-3 (runtime ignores callbacks for closed runs), and the capability contract in Β§E. R-4 only when something first keys on atom ids.
Experiments: the table in Β§G. Run U-1 and U-6 before tuning error recovery or speech timing.
Checked against the operating principles
- Deleted rather than added: no production code changed, and every fix proposed is a guard or a cap on existing code. No new layer is proposed except the out-of-realm rule for plugins, which removes a class of attack rather than adding a validator.
- Verified by something that ran: every confirmed defect and every false alarm has a test that ran. Every OpenAI claim is labelled as either matching the documentation or needing a session. Nothing about a product MCP host or a real voice is claimed.
- Where this review is weak: it did not measure memory across repeated Dive and Surface, try a real network drop, or read a Cloudflare log.
Published from docs/plans/LIVE-RED-TEAM.md. Edits made here are overwritten on the next push to main.