docs(workflow): admit the node:vm escape concretely — absent globals are surface, not containment

The review's critical finding: the engine README and the tool
description read as if the missing filesystem/network/Node globals were
enforced, but a script can reach the host Function constructor via
globalThis.constructor.constructor and from it process and every Node
builtin. Per the trust premise this is ACCEPTED (model-written scripts,
bash-equivalent trust; genuine sandboxing is the deferred engine swap
already listed) — but the docs must say so instead of implying a wall.
The trust-premise sections (engine README, module doc, RFC) now name
the escape and its acceptance; the model-facing tool description says
the APIs are not PROVIDED rather than implying they are prevented.
This commit is contained in:
Tianyi Cui
2026-07-06 21:04:20 +08:00
parent 3987425547
commit 9688870da3
5 changed files with 11 additions and 8 deletions

View File

@@ -22,7 +22,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre
### The engine (dsh-workflow-vm): in-process node:vm
**Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) — host code MAY run script code while reading script values, and that is accepted, because a hostile script can already occupy the event loop forever with a synchronous spin past its first await; containing its error VALUES while conceding it the event loop would be cost without a threat model. Genuine hardening is an engine swap behind the seam (worker/isolated-vm gets value isolation by serialization for free), not incremental host-side defenses.
**Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) AND Node-API escape from the context — the vm context shares object machinery with the host, so a script can reach the host `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin; the absent globals are API surface, not containment. Host code MAY run script code while reading script values, and that is accepted, because a hostile script can already occupy the event loop forever with a synchronous spin past its first await; containing its error VALUES while conceding it the event loop would be cost without a threat model. Genuine hardening is an engine swap behind the seam (worker/isolated-vm gets value isolation by serialization for free), not incremental host-side defenses.
**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Under the trust premise, in-process is enough. Accepted, documented limitations: `start()` blocks the caller for the script's initial synchronous slice (bounded by the vm timeout); that timeout covers ONLY the initial slice, so a synchronous spin past it (an await continuation, a thenable's `then` invoked by promise resolution — a returned thenable resolves per JavaScript semantics, which is what makes an un-awaited `return agent('x')` work — or script code the host runs while rendering a thrown value) cannot be killed in-process; `dispose()` cancels, waits a bounded grace for the script to settle and its children to finish disposing, then abandons.

View File

@@ -293,7 +293,7 @@ Script-body hooks:
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.
Constraints: concurrency and total-agent caps apply; `Date.now()`, `Math.random()`, and argless `new Date()` throw (pass timestamps via `args`); no filesystem, network, timers, or Node.js APIs — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.
Constraints: concurrency and total-agent caps apply; `Date.now()`, `Math.random()`, and argless `new Date()` throw (pass timestamps via `args`); no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.
```json
{