The three-entry cordis.yml (dsh-sandbox-local + dsh-bash-sandbox at a
read-only default + dsh-approval) served over ACP: the first live approval
composition. Recorded snapshot scenarios pin the wire end to end —
config-options advertisement, the mode-switching arc as the suite pinned
header (both switches, the prompt-section delta, one changed-by-the-user
notice per knob, a confined write landing under the switched mode), and
both escalation branches over scripted permissionAnswers (a grant runs
confined under workspace-write; a rejection executes nothing and pins the
fail-closed text). The with-key escalation e2e drives a real model +
real runner + the real bridge answerer, world-verified; ci.yml snapshot
lane and e2e.yml install bubblewrap so the confined replays actually
execute.
Both RFCs move to implemented/ (Decision/Consequences form, deferred
phases tracked in their own sections), with every cross-reference flipped.
The default-relative ladder conflicts with per-session overrides: schemas
are registry-global while the effective mode is switchable, so a session
overridden NARROWER than the default loses exactly the lever it needs — a
workspace-write default with a read-only override could only request
danger-full-access (forced over-escalation), and a danger-full-access
default with a read-only override advertised no fields at all (confined,
no escalation path). The enum now pins the closed target vocabulary
(workspace-write / danger-full-access) whenever the executor confines;
strict widening is enforced per call against the session effective mode
(override ?? default), failing closed without prompting anyone. The
default-relative ladder and per-session dynamic schemas move to
Alternatives; the harmless Risks claim is corrected to name the runtime
check as the safety boundary.
The proposed forms claimed examples/sandbox-acp-agent, its recorded
scenarios, and the approval servicing of ask as already existing; a reader
of this docs-only change would look for a composition and goldens that are
not in the tree. Usage walkthroughs now name the arc the staged scenarios
are to record, both Testing sections open with the plan-time banner, and
the grounding bullet states todays degrade honestly.
The sandbox execute wrapper JSON round-tripped the return and blindly cast it
to ToolExecuteReturn. A JSON-valid but wrong-shape return — a bare string,
{ content: 'ok' }, blocks without a type tag — sailed through: the registry
spreads result.content, so { content: 'ok' } became ['o','k'], passed the
session log's isJsonValue gate, and the DeepSeek serializer then flattened it
to '(no output)' — silent corruption of the next model request and every
replay, instead of a contained tool error.
The round-tripped value is now shape-checked against the two ToolExecuteReturn
forms (array of content blocks, or { content: blocks, meta? }); block checks
are structural only (plain object + string type tag) because the ContentBlock
union is merge-extensible. A wrong shape — and the formerly cryptic
forgot-return/bare-string cases — fails that one call with a teaching error
echoing a truncated preview of what was returned and the two valid forms.
New specs pin the object-form pass-through (meta included), six rejection
shapes, and the preview truncation; per-file 100% coverage holds.
P1 review finding: extractMeta timed only the literal's vm evaluation;
materializing the RESULT then read properties ordinarily on the HOST
stack, so a meta literal smuggling a getter (get name() { while(true){} })
could wedge the host outside any timeout — defeating the exact spin
isolation the worker thread exists for.
Rather than harden the evaluator (descriptor walks, AST validation),
delete the mechanism: the workflow's identity now reaches the seam as a
plain JSON field (WorkflowStartRequest.meta), carried by the tool as a
schema-validated `meta` object parameter the model fills directly. The
engine only shape-validates data (validateMeta, every violation named)
and pre-parses the body; the scanner, the vm evaluation, and the
host-side materialization are gone, and with them the hole. A body
still opening with a Claude Code-style `export const meta` statement
gets a pointed SCRIPT_PARSE message (the likeliest authoring slip; a
CC script's body stays drop-in, only its meta header moves into the
parameter). syncTimeoutMs now governs exactly one thing: the initial
synchronous slice inside the worker.
The RFC's decision section is rewritten in place (implemented-RFC
rule); the embedded-meta format moves to alternatives-considered with
the hole as the reason. Tool description, presentation (title now reads
meta.name directly — the textual sniff is gone), seam vocabulary docs,
and catalogs follow.
Pure mechanical rename now that the package's internals are the
worker-thread engine: directory, package name, spec/e2e filenames,
module tags and logger prefixes, tsconfig/knip/run-gates/AGENTS.md
references, example cordis.yml plugin ids, doc links; catalogs
regenerated and the lockfile refreshed.
The outer ring catches up with the engine swap (the package's own
README/JSDoc rode the port commit):
- Seam module doc and README name the worker-thread engine as THE
implementation, with isolated-vm/separate-process sandboxing as the
deferred hardening; the seam service doc states the holder-owned-runs
contract (engine-fiber disposal deliberately leaves live runs to
their holders).
- Seam contract precision: agentsStarted documents the termination-path
degradation to the host-observed count; the events section scopes the
agent-start/agent-end pair to calls that STARTED a child run;
WorkflowRun wording drops the vm-era abandonment language.
- The dynamic-workflows RFC is rewritten in place to the shipped
mechanism (implemented-RFC rule): why worker threads, the thread's
concrete buys, the in-process node:vm first cut recorded under
alternatives considered; the tool section describes the usage policy
as the tool's own prompt section.
- gen-doc-graphs: six workflow/* DYNAMIC_EVENT_DISPATCHERS entries (the
catalog no longer claims nothing dispatches them) and the seam-note
wording; core-data-structures gains its workflow.md index row;
packages/README + AGENTS.md layout line + example cordis.yml comments
say worker-thread; catalogs regenerated.
The sandbox docs overclaimed a containment contract the design never makes:
"capability access is routed through cordis services, never Node built-ins,
so everything a mounted plugin does stays inspectable and disposable". The
host-realm helpers on the sandbox global (harness, console, btoa) are
reachable functions, so mount code that goes looking can reach the host realm
through one of them — accepted under the trust stance, because the ctx a
mount ultimately receives is fully privileged anyway. Reword the sandbox
module doc, the README trust stance, and the RFC sandbox-semantics section to
say exactly that: the traps and small global surface STEER honest code onto
the cordis services; they are not a security boundary.
Two review findings (#220) on the sandbox context façade:
- Undeclared services were reachable: the façade resolved any live global via
ctx.get(name), so ctx.bash worked without inject: ['bash']. A cross-mount
consumer could then depend on a provider cordis never saw — unmounting the
provider would neither park the consumer nor unwind its registered tools,
leaving a model-visible tool that fails only at execution. The façade now
reads ctx.fiber.inject and refuses any service the mount did not declare
(with a teaching error naming the inject fix), so the dependency is always
visible to cordis and its activation/unload semantics bind.
- ctx.tools.get returned the live ToolDefinition, including execute — mount
code could call another tool directly and bypass ToolRegistry.execute and
its pre/post-execute hooks and accounting. get now returns the same
read-only name/description/parameters view as schemas(), never an invocable.
Adds inject-gate and schema-view regression cases to sandbox-context.spec.ts
(undeclared property/get denied, declared allowed, the cross-mount zombie-tool
scenario refused at call time, get exposes no execute). Package stays at
per-file 100% coverage. RFC, mount description, and tool-catalog updated.
Review finding (#220): the guarded proxy only special-cased ctx.tools, so
mount code could reach an UNGUARDED context through ctx.root, ctx.extend(), or
a service instance's .ctx, then ctx.root.tools.register({…}) to bypass the
marker check and host-realm normalization — a raw vm-realm result would later
error a real agent turn at the session-log plainness check.
The sandbox ctx is now a whitelist façade, not a pass-through proxy: it exposes
only what a mount needs — tools.register (marker-guarded), on/once, provide, the
timer helpers, and injected services resolved through a guarded get — and denies
every framework-plumbing member (root, parent, fiber, reflect, registry, extend,
isolate, intercept, plugin, set, mixin, …) with a teaching error. Injected
services are wrapped so a method returning a Context is rejected on the way back
(the .ctx escape), closing the one indirect leak. There is no context-valued
member left to reach; cross-mount provide/inject is untouched (the plugin's own
inject and the fiber's pending/active gating are unchanged). ctx.plugin (child
plugins) and ctx.set are denied by design; ctx.effect is deferred (FIXME).
Adds tests/sandbox-context.spec.ts covering the escape class (root/extend/fiber/
plugin/set/… denied, the classic root.tools.register bypass, the .ctx escape,
read-only writes) plus the async-service and symbol/in-operator paths for 100%
coverage. RFC/README/tool-catalog/config-catalog updated; api-catalog.ts
regenerated (also picks up the codeRuntime service that entered on the master
merge and was left stale).
The tree SHAPE was the wrong surface for the model: what it needs from
cordis_inspect is what services, plugins, and capabilities are loaded, not the
fiber hierarchy. The plugins section is now a flat name + lifecycle-state list
from ctx.registry (deterministically sorted, one line per instance); the ASCII
tree renderer, the parent→child rebuild, and the dyn-id tree annotation are
deleted — dynamic mounts keep their own richer dynamic section (id, state,
provides, waits). Net -49 lines; RFC and READMEs state the flat-list contract.
Field sessions showed models writing tool schemas in the JSON-Schema dialect
by strong prior — type: 'integer', required: false, then the full
{ type:'object', properties, required: [...] } wrapper — and the rejection
text itself pushed a nearly-correct DSL attempt BACK to raw JSON Schema: one
stats tool cost three consecutive schema errors before mounting. The boundary
now normalizes wherever the input has exactly one meaning (wrapper unwrapped
with the required array becoming per-property flags at any nesting level,
integer → number, required: false → optional, all rebuilt as fresh host-realm
objects) and rejects only genuinely meaningless input, enumerating the valid
vocabulary in the error. Re-running the failing session mounts first-try.
The mount description documents both accepted forms.
The design record for tool-cordis: the three-tool contract, the vm sandbox
trust stance and boundary mechanisms, the dynamic-group lifecycle, cross-mount
provide/inject composition, the generated runtime API catalog, and the
alternatives weighed (per-capability registration tools, hand-maintained API
tables, a mount provenance event, a hardened sandbox).
Both demo:code-mode UIs now share one mechanism: the base example plus a
same-shaped code-mode.cordis.yml include overlay (insert the worker
runtime, flip tools.mode). Previously the REPL side was a hand-forked
example (examples/code-agent) that had also silently diverged — it
dropped compaction and the subagent stack — so the demo's UI argument
switched agents, not just surfaces. The fork is retired: coding-agent
gains the overlay, a Code Mode README section absorbing code-agent's,
and both of its tests (the keyless boot guard, retargeted at the
overlay; the with-key RFC proof, which hand-mounts its own harness and
moves untouched). The RFC's composed-surface and e2e-tier lines, the
examples index, the AGENTS.md smoke table, and the dsh-tools README
link now describe the overlay shape.
Verified live: overlay keyless smoke, with-key code-mode e2e from its
new home, demo:code-mode banner + EOF exit, and the acp handshake.
Code Mode is the point; the UI is just the surface it happens to wear.
demo:code and demo:acp-code collapse into one dispatcher
(scripts/demo-code-mode.mjs): `pnpm run demo:code-mode [repl|acp]` —
repl (default) boots the stdio REPL over examples/code-agent, acp
serves examples/acp-agent's code-mode overlay; each UI runs the exact
node invocation its standalone script ran, and an unknown argument
fails loud with usage. All nine references across READMEs, the RFC,
the overlay header, and the keyless-smoke comment renamed. Smoked all
three paths: usage exit 2, ACP initialize handshake, REPL boot + EOF.
The seam shipped without its own RFC — the reconstructable-requests RFC
was amended with the mechanics, but the decision record (why a
compose-once frozen prefix, and what the per-request before/after shape,
a system-prompt section, a durable history opener, per-turn composition,
and a dedicated session event each lost to) had no home. Implemented
lifecycle, feature class, dated to the first commit of the work.
ds-review-bot critical (follow-up): on the first step of a resumed or
seeded/forked instance, auto-compaction ran before runStep composed
this instance's prefix, so the gate read the PREVIOUS instance's logged
prefix from the header fold — a contributor that grew across
resume/fork (skills added, AGENTS.md grown: exactly the
environment-dependent case) could under-gate and ship an over-window
first request.
The loop now composes agent/session-prefix before the instance's first
agent/pre-step (still once per instance; runStep just reads the cache),
and agent/pre-step carries the composed prefix to its listeners.
CompactService.compactIfNeeded gains the sessionPrefix parameter;
BasicCompactService.estimatePressure gates on the handed value — the
header-fold read is gone, so the estimate is exact at every step
including a resumed/forked instance's first. Composition moving before
the boundary snapshot also means a composing listener's session append
now joins the CURRENT request (documented on the seam).
New coverage: composition precedes pre-step and the seam receives the
composed prefix; cancel and disposal landing inside the composition
window drop the step cleanly; the compact gate test hands the prefix
directly.
Brings in the refreshed base (master merged through the stack after #203
and #205 landed), including the acp-snapshot extraction (#204), and
re-ports this PR's snapshot-suite extensions onto the extracted package:
- dsh-acp-snapshot's Scenario gains headerClass and configPath; the suite
factory pins the request header PER CLASS (construction rejects a
missing or duplicated class pin), forwards a scenario's configPath to
the harness (RunOptions.configPath overrides AgentUnderTest.configPath),
and a new fixtures meta-test asserts every pinning fixture carries
exactly one request/header and no deltas.
- The acp-agent example's thin scenario table re-registers code-mode-turn
and both-mode-turn with their overlay configs and per-class pins; the
committed fixtures replay unchanged.
- The package's synthetic suites cover the new surface (explicit
headerClass on one suite, the default on the other, a configPath
override through the fake bin, and the two construction throws).