diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 2d98273ae0..2c556fc35d 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -8,6 +8,7 @@ Workflow scripts are **model-written** — the same trust level as the model's e - **The host never blocks**: `start()` returns without running any script code on the host; a synchronous spin anywhere in the script occupies the worker's loop, not the harness's. - **Termination is real**: a script that outlives its post-cancel grace is `worker.terminate()`d — nothing of it survives `dispose()`, where an in-process engine could only abandon the spin on its own loop. +- **No ambient credentials**: the worker spawns with an EMPTY environment (`env: {}` plus hermetic `execArgv`, the same stance as `dsh-code-runtime-worker`), so an escapee reading `process.env` finds no harness secrets — ambient-channel hardening only; the process-wide privileges above (fs and the rest) remain, so a genuine sandbox is still the engine swap. - **Serialization by construction**: everything crossing the thread is structured-clone data, and plain JSON before that — the `materializeFromRealm` walk rejects loud what JSON cannot carry, which is also what makes every postMessage hop total. What the seam guarantees regardless, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection, values JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing (containing what an escaped script may touch) remains an isolated-vm/separate-process engine swap behind the seam, still deferred. diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 28db34f097..9f39cd80e4 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -56,20 +56,30 @@ import type { ChildStartRequest, WorkerInit } from './types.ts' * vitest (vite transforms in-process, not via a node loader), and passing * execArgv explicitly also shields the worker from any loader flags the * parent was started with. Built (`lib/index.js`), the entry is the sibling - * bundle the package tsdown config emits and no loader is needed. + * bundle the package tsdown config emits and no loader is needed (execArgv + * pinned empty — hermetic, like the environment). + * + * Both shapes spawn with an EMPTY environment (`env: {}`): the documented vm + * escape reaches `process`, and the harness's ambient credentials + * (`DEEPSEEK_API_KEY` et al.) must not ride along — the same stance as + * `dsh-code-runtime-worker`, stronger than the scrubbed env the + * defensive-patterns rule requires for spawned commands (a shell needs PATH; + * this worker needs nothing). This closes the AMBIENT channel only — an + * escapee still holds process-wide privileges like fs access (the README's + * trust premise stands). * @param init - the run payload, passed as `workerData`. * @returns the entry URL and the Worker options to spawn it with. */ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOptions } { /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */ if (!import.meta.url.endsWith('.ts')) { - return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init } } + return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init, env: {}, execArgv: [] } } } // Lazy tsx resolution: only the unbuilt shape needs it, so the built // bundle never requires tsx to be installed. return { entry: new URL('./worker.ts', import.meta.url), - options: { workerData: init, execArgv: ['--import', fileURLToPath(import.meta.resolve('tsx'))] }, + options: { workerData: init, env: {}, execArgv: ['--import', fileURLToPath(import.meta.resolve('tsx'))] }, } } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index eb50ec1121..32f63a6c97 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -254,6 +254,24 @@ describe('dsh-workflow-workerthread', () => { expect(result.stopReason).toBe('completed') expect(result.value).toBe('fine') }) + + it('the worker spawns with an EMPTY environment: an escaped script finds no ambient credentials', async () => { + const { ctx, parent } = await setup() + // A canary in the HARNESS process's env: with an inherited environment + // the escape below would read it back (exactly how DEEPSEEK_API_KEY + // would leak); env: {} in the spawn options is what keeps it out. + process.env.WORKFLOW_ENV_CANARY = 'leak me' + try { + const result = await run(ctx, parent, scripted(` + const proc = ${ESCAPE} + return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).length } + `)) + expect(result.stopReason).toBe('completed') + expect(result.value).toEqual({ canary: null, keys: 0 }) + } finally { + delete process.env.WORKFLOW_ENV_CANARY + } + }) }) describe('lifecycle: parse errors, cancellation, termination, disposal', () => {