fix: validate and re-cap all inbound worker-port traffic (Codex round 1)

The host's message listener trusted the compile-time WorkerToHost shape on
traffic from a peer that runs model code: postMessage(null) threw in the
listener and crashed the host process; forged log/done messages bypassed
maxLogBytes/maxValueBytes (the worker-side LogBuffer and prepareValue cap
only honest flows); and the error-reply renegotiation re-echoed a forged
non-cloneable call id, throwing outside any catch.

Every inbound message now passes a runtime shape gate that validates and
REBUILDS it field by field (junk drops without a throw; call ids must be
numbers, so replies are always clone-plain; forged extra fields never ride
along). One host-side ledger bounds everything landing in logs — honest
port entries, forged ones, and stray pipe bytes — at the single documented
maxLogBytes, with the shared in-band truncation marker emitted host-side
when the ledger trips first; the completion value is re-capped host-side
through the same prepareValue (with exactly the truncation suffix as slack
so honest worker-capped values pass unchanged), and done error text is
bounded. Also folds the stray-capture budget into that shared ledger
(round-1 finding B: it was a second maxLogBytes on top of the documented
shared cap).
This commit is contained in:
Tianyi Cui
2026-07-08 11:42:59 +08:00
parent 466a052159
commit aa2a7f9a8a
6 changed files with 192 additions and 13 deletions
@@ -12,6 +12,7 @@
import { inspect } from 'node:util'
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
import { logTruncationMarker } from './protocol.ts'
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
/** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
@@ -62,7 +63,7 @@ export class LogBuffer {
const cost = Buffer.byteLength(entry.text, 'utf8')
if (cost > this.remaining) {
this.truncated = true
this.sink({ source: 'stderr', text: `[dsh-code-runtime-worker] log capture truncated at ${this.maxBytes} bytes` })
this.sink({ source: 'stderr', text: logTruncationMarker(this.maxBytes) })
return
}
this.remaining -= cost
@@ -18,6 +18,8 @@ import { Context } from 'cordis'
import z from 'schemastery'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingFunction, CodeLogEntry, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { prepareValue } from './bootstrap.ts'
import { logTruncationMarker } from './protocol.ts'
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
export type { BootstrapPort, PatchableStream } from './bootstrap.ts'
@@ -108,6 +110,64 @@ function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** The log sources / console levels the seam vocabulary admits, as runtime sets for inbound-message validation. */
const LOG_SOURCES = new Set<string>(['console', 'stdout', 'stderr'])
const LOG_LEVELS = new Set<string>(['log', 'info', 'warn', 'error', 'debug'])
/**
* Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
* can post anything — `null`, primitives, objects with poisoned fields — so
* the compile-time `WorkerToHost` type means nothing here: everything is
* re-validated and REBUILT field by field (a forged extra field never rides
* along; a non-number call id can never be echoed into a reply). Junk returns
* `undefined` and is dropped — a throw in the host's `message` listener would
* crash the host process.
*/
function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
if (typeof raw !== 'object' || raw === null) return undefined
const m = raw as Record<string, unknown>
switch (m.type) {
case 'call': {
if (typeof m.id !== 'number' || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined
return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args }
}
case 'log': {
const entry = m.entry
if (typeof entry !== 'object' || entry === null) return undefined
const e = entry as Record<string, unknown>
if (typeof e.text !== 'string') return undefined
if (typeof e.source !== 'string' || !LOG_SOURCES.has(e.source)) return undefined
if (e.level !== undefined && (typeof e.level !== 'string' || !LOG_LEVELS.has(e.level))) return undefined
return {
type: 'log',
entry: {
source: e.source as CodeLogEntry['source'],
...e.level !== undefined ? { level: e.level as Exclude<CodeLogEntry['level'], undefined> } : {},
text: e.text,
},
}
}
case 'done': {
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} }
const error = m.error
if (typeof error !== 'object' || error === null) return undefined
const message = (error as Record<string, unknown>).message
if (typeof message !== 'string') return undefined
return { type: 'done', ...m.value !== undefined ? { value: m.value } : {}, error: { message } }
}
default: return undefined
}
}
/**
* Headroom the host's value re-cap grants over `maxValueBytes`: exactly the
* truncation suffix {@link prepareValue} appends, so a value the WORKER
* already capped passes through unchanged instead of being marked twice.
* (A multibyte rendering the worker sliced by characters can still exceed
* this and pick up a second marker — bounded and harmless.)
*/
const VALUE_RENDER_SLACK = Buffer.byteLength('… [truncated]', 'utf8')
/**
* The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as
* the `codeRuntime` service; every cap comes from validated config. See the
@@ -234,13 +294,32 @@ export class WorkerCodeRuntime extends CodeRuntime {
const answered = new Set<number>()
const logs: CodeLogEntry[] = []
const strayLogs: CodeLogEntry[] = []
let strayBudget = this.config.maxLogBytes
// ONE host-side ledger for everything that lands in `logs`/`strayLogs`,
// whatever the path: honest port entries, FORGED port entries (model
// code posting `log` messages directly, bypassing the worker-side
// LogBuffer), and stray pipe bytes. On the first overflow it emits the
// same in-band marker the worker's LogBuffer would and drops the rest,
// so the documented cap is one shared `maxLogBytes` however it is hit.
let logBudget = this.config.maxLogBytes
let logsTruncated = false
const admit = (entry: CodeLogEntry, sink: CodeLogEntry[]): void => {
if (logsTruncated) return
const cost = Buffer.byteLength(entry.text, 'utf8')
if (cost > logBudget) {
logsTruncated = true
sink.push({ source: 'stderr', text: logTruncationMarker(this.config.maxLogBytes) })
return
}
logBudget -= cost
sink.push(entry)
}
// No settled guard: `finish` snapshots the arrays when it resolves, so
// a chunk flushing after settlement mutates only the discarded buffers,
// and the ledger bounds that growth until the pipes close.
const captureStray = (source: 'stdout' | 'stderr') => (chunk: Buffer) => {
if (settled || strayBudget <= 0) return
const text = chunk.toString('utf8').slice(0, strayBudget)
strayBudget -= Buffer.byteLength(text, 'utf8')
strayLogs.push({ source, text })
admit({ source, text: chunk.toString('utf8') }, strayLogs)
}
worker.stdout.on('data', captureStray('stdout'))
worker.stderr.on('data', captureStray('stderr'))
@@ -267,9 +346,14 @@ export class WorkerCodeRuntime extends CodeRuntime {
const onDone = (message: WorkerToHost): void => {
if (message.type !== 'done') return
// Re-cap the completion value HOST-side: the honest path already
// capped it in the worker (prepareValue there), but a forged done
// message bypasses the bootstrap entirely — without this, model code
// could flood the host past maxValueBytes. Honest values pass
// unchanged (see VALUE_RENDER_SLACK); the error text is bounded too.
finish({
...message.value !== undefined ? { value: message.value } : {},
...message.error ? { error: { kind: 'exception' as const, message: message.error.message } } : {},
...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK),
...message.error ? { error: { kind: 'exception' as const, message: message.error.message.slice(0, this.config.maxValueBytes) } } : {},
})
}
@@ -308,8 +392,12 @@ export class WorkerCodeRuntime extends CodeRuntime {
})()
}
worker.on('message', (message: WorkerToHost) => {
if (message.type === 'log' && !settled) logs.push(message.entry)
worker.on('message', (raw: unknown) => {
// Parse before touching: the peer can post ANY shape, and a throw in
// this listener would crash the host process. Junk drops silently.
const message = parseWorkerMessage(raw)
if (!message) return
if (message.type === 'log' && !settled) admit(message.entry, logs)
onCall(message)
onDone(message)
})
@@ -63,3 +63,16 @@ export type WorkerToHost = CallMessage | LogMessage | DoneMessage
export type ReplyMessage =
| { type: 'reply'; id: number; ok: true; value: unknown }
| { type: 'reply'; id: number; ok: false; message: string }
/**
* The in-band marker entry text announcing that log capture stopped at the
* byte budget. Shared wire vocabulary: the worker's LogBuffer emits it when
* ITS budget exhausts, and the host emits the identical text when its own
* ledger drops an entry first (forged port traffic, stray pipe bytes) — so
* a truncated run reads the same however the cap was hit.
* @param maxBytes - the configured `maxLogBytes` the marker names.
* @returns the marker line.
*/
export function logTruncationMarker(maxBytes: number): string {
return `[dsh-code-runtime-worker] log capture truncated at ${maxBytes} bytes`
}