workflow: spawn the worker with an empty environment
The documented vm escape reaches process, and the worker inherited the harness's env - so a buggy or prompt-injected script could read and exfiltrate ambient credentials (DEEPSEEK_API_KEY et al.) without touching a single file (ds-review-bot finding on #233). Spawn with env: {} and a hermetic execArgv on both runtime shapes, the same stance as dsh-code-runtime-worker and stronger than the scrubbed env the defensive-patterns rule requires for spawned commands (a shell needs PATH; this worker needs nothing). Ambient-channel hardening only: an escapee keeps the process-wide privileges the trust premise already admits - the genuine sandbox remains an engine swap.
This commit is contained in:
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
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.
|
||||||
|
|||||||
@@ -56,20 +56,30 @@ import type { ChildStartRequest, WorkerInit } from './types.ts'
|
|||||||
* vitest (vite transforms in-process, not via a node loader), and passing
|
* vitest (vite transforms in-process, not via a node loader), and passing
|
||||||
* execArgv explicitly also shields the worker from any loader flags the
|
* execArgv explicitly also shields the worker from any loader flags the
|
||||||
* parent was started with. Built (`lib/index.js`), the entry is the sibling
|
* 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`.
|
* @param init - the run payload, passed as `workerData`.
|
||||||
* @returns the entry URL and the Worker options to spawn it with.
|
* @returns the entry URL and the Worker options to spawn it with.
|
||||||
*/
|
*/
|
||||||
function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOptions } {
|
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 */
|
/* 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')) {
|
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
|
// Lazy tsx resolution: only the unbuilt shape needs it, so the built
|
||||||
// bundle never requires tsx to be installed.
|
// bundle never requires tsx to be installed.
|
||||||
return {
|
return {
|
||||||
entry: new URL('./worker.ts', import.meta.url),
|
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'))] },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,24 @@ describe('dsh-workflow-workerthread', () => {
|
|||||||
expect(result.stopReason).toBe('completed')
|
expect(result.stopReason).toBe('completed')
|
||||||
expect(result.value).toBe('fine')
|
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', () => {
|
describe('lifecycle: parse errors, cancellation, termination, disposal', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user