Sandbox model of the chidori-js runtime
Two layers ship: the in-process capability-confinement sandbox, and an OS-level isolation mode that runs each agent in a confined child process — on by default for the
chidoriCLI on Unix, opt-in elsewhere (--isolate) — see OS-level isolation. Known limitations are documented in Current gaps. Engine: the pure-Rustchidori-jsengine — the only JS engine in the tree. Related:docs/os-isolation-plan.md,docs/captured-effects-vfs-crypto-timers.md,docs/conformance.md.
This document describes what kind of sandbox the chidori-js runtime is, what it
confines, what it deliberately does not confine, and which protections are
on by default. It is written for two readers: someone deciding whether it is safe
to run a given class of code on this engine, and someone hardening it further.
TL;DR
chidori-js is a capability-confinement sandbox built on Rust memory safety,
not an OS-level containment sandbox. The JavaScript it runs has no ambient
authority: the interpreter itself cannot touch the filesystem, network, clock,
or processes. Every capability is something the host explicitly injects. On top
of that, per-run resource limits (opcode budget, memory ceiling, optional
wall-clock deadline) bound runaway CPU and heap so a single agent cannot hang or
OOM the host.
What it is good at: confining the language — a buggy or hostile script cannot corrupt memory, escape into host code, or reach a capability it was not given.
What it is not (by default): a containment boundary for the powerful effects
the host does inject (http, workspace.*). Those are real capabilities;
whether granting them is "safe" depends entirely on whether the agent code is
trusted.
For that case there is an OS-level isolation mode — on by default for the
CLI on Unix, opt-in for embedders and elsewhere (--isolate /
CHIDORI_ISOLATE=process): each run executes in a disposable
child process that holds only the JS engine and brokers every effect back to
the trusted parent over a pipe. The child runs under a per-OS sandbox (Linux:
empty network namespace + Landlock read-only filesystem + seccomp syscall
denylist; macOS: a Seatbelt deny profile) plus a setrlimit floor and a
parent-side deadline-kill — so even a total compromise of the interpreter has no
ambient network, filesystem, or sibling-run to reach. See
OS-level isolation.
Threat model
The framework's primary model is trusted, developer-authored agent code. The
"sandbox" exists chiefly to make execution deterministic and replayable (route
all non-determinism through captured effects) and to provide defense-in-depth
against bugs and accidental resource exhaustion — see the product framing in
docs/conformance.md (language conformance measured bare,
with the security sandbox and deterministic replay layered on top).
This doc additionally evaluates the engine against a stricter model — untrusted agent code — because the architecture (no ambient authority + memory safety) is most of the way there, and it is useful to be precise about the remaining distance.
| Adversary capability | Confined? |
|---|---|
| Corrupt host memory / execute native code via an interpreter bug | ✅ Yes — pure safe Rust |
| Reach fs / net / clock / processes the host did not inject | ✅ Yes — no ambient authority |
Escape the virtual filesystem to real disk via node:fs | ✅ Yes — in-memory VFS, traversal-clamped |
| Hang the host with an infinite loop | ✅ Yes — opcode budget |
| OOM the host (string / heap growth) | ✅ Yes — string cap + memory ceiling |
| Crash the host with a panic | ✅ Yes — catch_unwind boundary |
Abuse an injected powerful effect (http, workspace) | ✅ On the server (deny-by-default unless the operator opts out); ⚠️ on the bare CLI only if the operator gates it — see gaps |
| Starve co-tenant agents / exceed a per-agent memory quota | ✅ Per-run meter (thread-attributed; small cross-thread drift) — see gaps |
| Break out of the process / OS | ✅ CLI on Unix (isolation on by default): confined child process — seccomp/Seatbelt + netns/Landlock + rlimits — see OS-level isolation. ⚠️ Embedders / --no-isolate / non-Unix: none (in-process) |
Architecture: capability injection, not ambient authority
The pure engine (crates/chidori-js) is a parser (oxc) → bytecode compiler →
stack VM with Rc<RefCell> reference counting. Two properties make it a sandbox:
-
Memory safety. There is zero
unsafein the engine crate (the word appears only in doc comments). The whole stack is safe Rust plusoxc. The worst an interpreter bug can do is panic or misbehave — it cannot corrupt memory or jump into host code. This is a categorical improvement over embedding a C/C++ engine (QuickJS, V8) in-process. -
No ambient authority. The engine contains no
std::fs,std::net,std::process,std::thread,Command, or HTTP client. A bareEngine::new()running arbitrary JS can compute and allocate — nothing else. I/O exists only because the host installs it:Engine::install_chidori_effects(crates/chidori-js/src/lib.rs) wires the asyncchidori.*effect surface (log,tool,prompt,input,memory,template,checkpoint,callAgent,workspace.*). Networking is not achidori.*method — it is brokered through the internalglobalThis.__chidori_httpglobal (forwarded as the"http"effect), reached only via the capturedfetch/node:httpsurface. TheexecJs/execPython/execWasmJS stubs are defined but inert — the host backend rejects the effect (… is not supported on the rust engine); there are no snippet sandboxes.Engine::install_sync_nativeswires the synchronous__chidori_*natives thenode:shims call (crypto hashing/HMAC, captured randomness, the VFS).
JS cannot name a capability it was not handed. Determinism follows from the same seam: every non-deterministic source flows through a host effect that is recorded in record mode and replayed in replay mode (
crates/chidori-js/src/host.rs).
The production wiring lives in src/runtime/rust_engine.rs::run_module, which
builds a fresh engine per run, installs the captured-effect natives + determinism
prelude, forwards chidori.* to the shared HostBindingBackend (the durable
host machinery — call log, replay, policy, MCP, OTEL), then runs the agent's
entrypoint.
Filesystem isolation (node:fs → VFS)
node:fs does not reach real disk. It maps to an in-memory, snapshot-resident
virtual filesystem on RuntimeContext (src/runtime/context.rs vfs_* →
src/runtime/vfs.rs). Key properties:
- In-memory / snapshot-resident. Writes ride the snapshot manifest and survive suspend→restore; a write never touches the host disk.
- Path-traversal clamped.
normalize()resolves./..and clamps..at the root, so/../../etc/passwdresolves inside the VFS, not to a real file. - Host-disk mode is unimplemented.
FsPolicy::Hostis explicitly rejected (src/runtime/rust_engine.rsfs_policy_guard); the durable default isFsPolicy::Captured.cryptoandtimersare likewise policy-gated (CryptoPolicy,TimerPolicy).
workspace.* is different: it performs real disk I/O, but is sanitized to a
workspace root with ../absolute-path rejection and symlink-traversal checks
(host side, src/runtime/workspace.rs). It is a deliberate capability, not an
isolated surface.
Resource limits (DoS protection)
The engine exposes the primitives; run_module wires them per run via an RAII
ExecutionGuard (src/runtime/rust_engine.rs). All are env-tunable so a
deployment can tighten them for untrusted code or relax them for trusted batch work.
Opcode budget — bounds CPU
vm.op_budget decrements once per executed opcode and throws an uncatchable
RangeError at zero (crates/chidori-js/src/exec.rs). It bounds pure-JS
compute and is latency-independent: time spent blocked in a synchronous host
effect (an LLM/tool/http call) does not consume it, so a legitimately slow agent
is unaffected while while (true) {} still terminates.
- Env:
CHIDORI_JS_OP_BUDGET(default5_000_000_000;0disables).
Memory ceiling — bounds heap
Two complementary layers:
-
Per-op string cap (always on, in-engine).
op_addandConcatStrings(template join) throwRangeErrorwhen a single concatenation would exceedMAX_STRING_LEN(2^28 = ~268M code units,crates/chidori-js/src/value.rs). This closes the exponentials += s/s = `${s}${s}`OOM and matches the caps onrepeat/padStart/padEndand on dense-array allocation (MAX_DENSE_ARRAY= 2^25 = ~33.5M elements). With these caps, no single opcode can allocate without bound. -
Per-run live-heap ceiling (watchdog). A
CountingAllocator(src/mem_guard.rs) is installed as the binary's#[global_allocator]. Each run registers a per-run meter on its execution thread (RunMeterGuard); every alloc/free performed on that thread is charged to that run, so under concurrent multi-agent execution one run's allocations are attributed to that run rather than to whichever co-tenant happens to be sampled (nestedcallAgentchildren meter themselves and hand accounting back to the parent). A background watchdog samples the meter and trips the VM's cooperative-cancellation flag (vm.interrupt, polled every 256 ops) when a run exceeds its cap — catching the vector a per-op cap cannot: accumulating many capped objects in a long-lived container (Map/Set/array). The VM unwinds withRangeError: execution interrupted.- Env:
CHIDORI_JS_MEM_CAP_MB(default4096;0disables) andCHIDORI_JS_MEM_POLL_MS(watchdog sampling interval, default10).
- Env:
Wall-clock deadline — optional, off by default
The same watchdog can enforce a wall-clock deadline, also via vm.interrupt.
- Env:
CHIDORI_JS_DEADLINE_MS(default0= off). - Caution: wall-clock time includes time blocked in synchronous host effects (LLM/tool/http run inline on the run thread), so a tight deadline can abort an agent merely waiting on a slow tool. It is off by default for that reason — prefer the opcode budget to bound compute. Enable the deadline only where host effects are known-fast (e.g. confining untrusted code under a short hard limit).
Stack & regex — always on
max_call_depth = 2000guards native-stack overflow from deep JS recursion (crates/chidori-js/src/vm.rs).REGEX_STEP_LIMIT = 100_000bounds catastrophic backtracking / ReDoS (crates/chidori-js/src/regexp.rs).
Panic containment
run_module wraps the engine call in std::panic::catch_unwind
(AssertUnwindSafe), so an interpreter panic surfaces as
Error: rust engine panicked: … instead of unwinding into the host/server.
Defaults summary
| Control | Env var | Default | Disable |
|---|---|---|---|
| Opcode budget | CHIDORI_JS_OP_BUDGET | 5_000_000_000 | 0 |
| Memory ceiling (MB, per-run meter) | CHIDORI_JS_MEM_CAP_MB | 4096 | 0 |
| Memory watchdog poll interval (ms) | CHIDORI_JS_MEM_POLL_MS | 10 | — |
| Wall-clock deadline (ms) | CHIDORI_JS_DEADLINE_MS | off | — |
| String length | (compile constant) | 2^28 (~268M) code units | — |
| Dense array length | (compile constant) | 1,000,000 | — |
| Call depth | (compile constant) | 2,000 | — |
| Regex steps | (compile constant) | 100,000 | — |
OS-level isolation (--isolate)
Everything above confines the language and bounds resources, but in-process
the VM shares its address space with the host: there is no OS boundary, so a
hypothetical interpreter RCE would land in the host process. Isolation adds
that boundary. It is on by default for the chidori CLI on Unix (see
Enabling it — embedders and non-Unix platforms keep the
historical opt-in) and additive — agent code, the SDKs, the durable call
log, and replay semantics are byte-for-byte unchanged (asserted by
rust_engine::tests::isolated_run_matches_in_process_byte_for_byte). The full
design lives in docs/os-isolation-plan.md; this
is the operator-facing summary. Code: crates/chidori/src/runtime/isolate/.
Process-per-run with brokered effects
Each run executes in a disposable child process (chidori __run-worker, a
hidden subcommand) that holds only the JavaScript engine. Every host op — each
chidori.* effect (log, prompt, tool, callAgent, http, memory,
template, checkpoint, input, workspace.*), every captured sync native
(VFS read/write, crypto hash/HMAC/random, DOM render), and every module load — is
RPC'd back to the parent over a length-prefixed JSON frame protocol on the
child's stdin/stdout (isolate/protocol.rs). The parent keeps doing all real I/O
and owns the durable call log, policy gate, MCP, providers, and OTEL; the child
only computes JavaScript.
This is cheap because the host-call boundary was already a single synchronous
(op, args) -> Result<Value, String> seam (route_host_op). Brokering swaps the
in-process InProcessHost for a BrokeredHost whose dispatch is a blocking pipe
round-trip — semantically identical to the VM, which already blocks on effects
inline. The cost is one IPC hop per effect, dwarfed by LLM/tool latency.
- Disposable, leak-free. The child exits after one run, so the
Rc<RefCell>cross-run cycle-leak concern (gap #6) does not apply to the isolated path, and the per-run heap meter becomes a clean per-process measure (no cross-tenant attribution drift — gaps #2/#3). - Pause/resume/replay for free. Pause = the child returns the pause sentinel and exits; resume/replay = the parent spawns a fresh worker and serves recorded effect results over the same pipe (the child cannot tell record from replay). Zero child-state serialization.
- Parent hardening. A
MAX_FRAME_BYTES(64 MB) ceiling stops a hostile child from OOMing the parent with a giant frame; EOF / decode error / child death all map to a run failure; reads never block past the deadline.
Resource floor (all platforms)
Before any agent code runs, the worker applies a setrlimit floor to itself
(isolate/limits.rs), shipped from the parent in the Init frame so the parent
owns the policy:
RLIMIT_CPU— hard CPU-seconds backstop to the in-engine opcode budget. Like the budget (and unlike a wall-clock deadline) it does not count time the child spends blocked on a brokered effect, so it bounds runaway compute without penalizing a legitimately slow agent. Opt-in (CHIDORI_ISOLATE_CPU_SECS).RLIMIT_FSIZE— max bytes writable to a regular file (the child has no filesystem, so this is belt-and-suspenders). Opt-in only, because a0cap also kills writes to a redirected regular-filestderr— file-write confinement is Landlock's job instead.RLIMIT_CORE = 0— no core dumps (a crash must not splatter memory to disk).RLIMIT_NOFILE— a small open-file ceiling (default 256).
Deliberately not set: RLIMIT_AS (address-space caps are too blunt — a
multi-threaded VM over-reserves virtual memory) and RLIMIT_NPROC (counts every
process of the real uid, fragile under shared-uid concurrency; blocking fork
belongs to seccomp). A hard per-run memory ceiling via cgroup v2 memory.max is
not yet wired; the in-process heap watchdog (a clean per-process measure under
isolation) is the stand-in.
On top of the floor the parent runs a wall-clock deadline-kill watchdog
(CHIDORI_ISOLATE_DEADLINE_MS): a thread that SIGKILLs a wedged child that has
stopped cooperating entirely. This is the hard backstop distinct from the
in-engine CHIDORI_JS_DEADLINE_MS.
Per-OS confinement (isolate/sandbox.rs)
Because brokering means the child needs no outward capability on any platform,
even a coarse per-OS sandbox is meaningfully strong — there is nothing wired for
it to abuse. Note the flip side: brokered http executes in the parent, with
the parent's network reach, so the OS sandbox alone is no defense against
server-side request forgery. That is the SSRF guard's job (runtime::ssrf):
the parent refuses http destinations that resolve to non-public addresses
(loopback, RFC 1918, the 169.254.169.254 cloud-metadata range, and their IPv6
equivalents), checked at DNS-resolution time and on every redirect hop, with
CHIDORI_HTTP_ALLOW_HOSTS as the deliberate allowlist.
Each layer is best-effort: a layer that cannot be applied (older
kernel, rootless container) logs a skip note and degrades rather than breaking
the run. Set CHIDORI_ISOLATE_REQUIRE_SANDBOX=1 to fail closed if the
platform's core layer (seccomp on Linux, Seatbelt on macOS) cannot be applied.
Linux (apply_linux), layered before seccomp so unshare/landlock_*
remain legal:
- Empty network namespace (
unshare(CLONE_NEWNET)) — no interfaces, so network egress is impossible at the OS even if a syscall slipped through. NeedsCAP_SYS_ADMIN; skipped rootless. - Landlock read-only filesystem (kernels ≥ 5.13) — denies every write-class
access while leaving reads for the C runtime, closing the
openat-write surface seccomp leaves open and sparing inherited fds like a redirected stderr. - seccomp-bpf denylist (via
seccompiler, safe Rust) —NO_NEW_PRIVS+KILL_PROCESSon a curated denylist: the whole socket family,exec*,ptrace/process_vm_*, namespace/mount, privilege-change, kernel-module/bpf/perf_event_open, and keyring syscalls.fork/cloneare not denied (the watchdog thread needs them, and a fork that cannotexecgains no code) — theexec*denial is what forecloses code execution. It is a denylist, not an allowlist, deliberately: it cannot false-positive-kill the engine and ships real confinement today; the near-empty allowlist remains the end goal.
macOS (apply_macos):
- Seatbelt via
sandbox_init(the deprecated-but-stable libSystem FFI Chromium's renderer uses) with an allow-default, targeted-deny SBPL profile ((deny network*)+(deny file-write*)) — the same posture as the Linux seccomp+Landlock pair. The feature'sunsafeFFI (Seatbelt,unshare,setrlimit,kill) stays in the host-sideisolate/module, out of the engine crate. (Type-checked on the Linux host but runtime-unverified — no macOS CI host yet; the best-effort design degrades a load failure to a logged skip.)
Guarantee matrix:
| Mechanism | Linux | macOS |
|---|---|---|
| Separate process + brokered effects | ✅ | ✅ |
| rlimits (CPU/FSIZE/CORE/NOFILE) | ✅ | ✅ |
| Network egress blocked at OS | ✅ empty netns | ✅ Seatbelt deny |
| Filesystem writes blocked at OS | ✅ Landlock + seccomp | ✅ Seatbelt deny |
| Syscall confinement | ✅ seccomp denylist | ⚠️ Seatbelt (coarser) |
| Hard memory ceiling | ⏳ cgroup memory.max (not yet wired) | ⏳ not yet wired |
Windows is not a shipped isolation target.
Failure mapping
The parent translates an OS kill into a precise host error (the child also writes
a structured error frame from its catch_unwind boundary before exiting):
| Child outcome | Host error |
|---|---|
seccomp kill (SIGSYS) | blocked syscall (seccomp/SIGSYS) |
RLIMIT_CPU exceeded | CPU limit exceeded |
| OOM / memory kill | memory limit exceeded |
| parent deadline kill | wall-clock deadline exceeded |
opcode budget (in-child RangeError) | JavaScript exception: … (unchanged) |
| panic → error frame, exit | rust engine panicked: … (unchanged shape) |
| pipe EOF / oversized frame | isolated run failed: worker terminated |
Enabling it
Isolation is on by default for the CLI on Unix (Linux and macOS get an OS
sandbox layer; other Unixes get process separation + rlimits): when
CHIDORI_ISOLATE is unset, chidori run/chidori serve isolate each run.
Opt out with --no-isolate or CHIDORI_ISOLATE=off; --isolate remains as an
explicit override of an ambient off. Embedders of the library keep the
historical opt-in behavior (unset means off) — only the chidori binary flips
the default. The worker child always has the env var explicitly set to off
so it never recursively re-isolates. The startup banner prints an Isolation:
line describing the active posture. Isolation (process sandbox) and
--untrusted (policy) are orthogonal but composable — running untrusted
without isolation prints a nudge rather than silently changing behavior.
| Env var | Default | Effect |
|---|---|---|
CHIDORI_ISOLATE | unset (on for the CLI on Unix; off for embedders) | process runs each agent in a confined child worker; off disables. Set by --isolate / --no-isolate. |
CHIDORI_ISOLATE_REQUIRE_SANDBOX | off | Fail the run closed if the platform's core confinement (seccomp/Seatbelt) can't be applied. |
CHIDORI_ISOLATE_DEADLINE_MS | off | Parent-side wall-clock SIGKILL of a wedged worker. |
CHIDORI_ISOLATE_CPU_SECS | off | Hard RLIMIT_CPU ceiling on worker compute. |
CHIDORI_ISOLATE_NOFILE | 256 | RLIMIT_NOFILE (clamped to the inherited hard limit). |
CHIDORI_ISOLATE_FSIZE_BYTES | off | RLIMIT_FSIZE (opt-in; off because a 0 cap also kills a redirected regular-file stderr). |
CHIDORI_ISOLATE_NO_CORE | on | Disable core dumps (RLIMIT_CORE=0). |
Current gaps
These are the known limitations as of this writing. None of them are memory-safety holes (the engine is safe Rust); they are confinement and resource-precision gaps.
-
The bare-CLI default is allow. The powerful effects —
http(real outbound network requests) andworkspace.*(real disk I/O within a sanitized root) — all pass through the policy enforcement gate (enforce_policy), and the surface where untrusted callers actually arrive is deny-by-default:chidori serveruns under theuntrustedprofile unless the operator explicitly configures policy (any validCHIDORI_POLICY*source, or the--trustedflag to opt back into the permissive default; malformed policy configuration fails closed to deny rather than falling back to allow-all). What is deliberately permissive ischidori runwithout flags: local CLI runs of developer-authored code get theAlwaysAllowfallback, with--untrusted/CHIDORI_POLICY_PROFILE=untrustedavailable when the code being run is not trusted. -
Per-run memory accounting is thread-attributed, not ownership-attributed. Each run registers a per-run meter on its execution thread (
src/mem_guard.rs::RunMeterGuard), so the cap measures that run's own allocations and concurrent runs do not trip each other's caps. The residual imprecision: bytes a host effect allocates on other threads (e.g. tokio workers buffering an HTTP response) are not charged until they reach the run thread, and a value allocated on the run thread but freed elsewhere stays charged (the meter clamps at zero in the other direction). For a single-threaded VM run this drift is small; only true ownership accounting (charge at string/object allocation, credit onDropinside the engine) would eliminate it. Under--isolatethis drift disappears: each run is its own process, so the meter is a clean per-process measure with no cross-tenant attribution. -
Memory enforcement granularity. The live-heap check is polled (watchdog every ~10 ms by default, tunable via
CHIDORI_JS_MEM_POLL_MS; the VM observes the trip every 256 ops). A run can therefore overshoot the cap briefly before unwinding. Bounded in practice because the per-op size caps mean no single opcode allocates more than ~16 MB, but it is not a hard instantaneous ceiling. A hard, kernel-enforced ceiling (cgroup v2memory.max) under--isolateis not yet wired — see gap #4. -
OS-level isolation is default-on only for the CLI on Unix. Embedders of the library,
--no-isolateruns, and non-Unix platforms run the engine in-process with the host — no seccomp, namespace, or separate-process boundary — so that posture is purely capability-confinement plus Rust memory safety. The--isolatemode provides the boundary (and thechidoribinary enables it by default on Unix); everywhere else the operator must enable it. Sub-gaps within the isolated path:- No hard memory ceiling. cgroup v2
memory.maxneeds delegation and is not yet wired;RLIMIT_ASis too blunt for a multi-threaded VM. The polled heap watchdog (cleaner per-process under isolation) is the stand-in. - seccomp is a denylist, not an allowlist. Real confinement today but the near-empty allowlist remains the stronger end state.
- Rootless net-ns and mount/pid namespaces are not yet wired (the empty
network namespace needs
CAP_SYS_ADMINand is skipped rootless; the socket seccomp block is the rootless backstop). - The macOS Seatbelt path is runtime-unverified (type-checked only — no macOS CI host yet); it degrades to a logged skip on failure.
- No hard memory ceiling. cgroup v2
-
Container element counts beyond arrays are uncapped. Arrays are bounded by
MAX_DENSE_ARRAY(2^25, ~33.5M), butMap/Set/object property counts are not individually capped. The memory ceiling (gap 2) is the backstop for the bytes they consume; there is no separate per-container element limit. -
Cycles are reclaimed only at run boundaries.
Rc<RefCell>cannot reclaim cycles mid-run.run_modulecallsVm::dispose()after every run (src/runtime/rust_engine.rs), which breaks the outgoing edges of every object the VM allocated — including cycles disconnected from the realm roots — so a long-lived server thread does not leak run-over-run. A refcount-accounting cycle collector also exists (Vm::collect_cycles,crates/chidori-js/src/gc.rs) but is not wired into the run loop, so within a run cycles accumulate until teardown. The per-run memory cap is the backstop for the bytes involved. The--isolatepath sidesteps this entirely: the child process exits after one run (spawn-per-run, no warm pool), so no state — leaked or otherwise — survives across runs. -
Engine maturity. The pure-Rust engine is at 99.08% Test262 (see
docs/conformance.md); spec deviations are not memory-unsafe but can produce surprising behavior or, in edge cases, perturb determinism/replay. This is a correctness-maturity caveat, not a containment risk.
The untrusted policy profile (deny-by-default)
The permission policy (src/policy.rs) gates the powerful host effects — http,
every workspace:* action (workspace:list / read / write / delete /
manifest), tool:<name> calls, and app_data:<action> — through
enforce_policy (src/runtime/typescript/bindings.rs). The fallback for an unmatched effect
depends on the surface: on chidori run (trusted, developer-authored code on the
developer's own machine) it is AlwaysAllow; on chidori serve — the surface
untrusted callers reach — it is deny-by-default unless the operator
explicitly configures policy.
The untrusted profile is a ready-made, deny-by-default policy you can select
by name — no hand-written JSON. It is what chidori serve runs under out of the
box; select it explicitly with a CLI flag (on run and serve) or an
environment variable:
chidori run --untrusted agent.ts # CLI flag
chidori serve --untrusted agent.ts # also on serve (already the default there)
CHIDORI_POLICY_PROFILE=untrusted chidori run agent.ts # env-var equivalentThe flag is the operator's last word: --untrusted takes precedence over all
CHIDORI_POLICY* env vars (including a permissive CHIDORI_POLICY_FILE /
CHIDORI_POLICY), so a wrapper script can guarantee confinement regardless of
ambient configuration.
Semantics:
- Fallback:
NeverAllow. Any gated effect with no matching allow-rule is refused withpolicy: `<target>` denied. - Allowed:
workspace:list,workspace:read,workspace:manifest— read-only introspection of the sanitized workspace root, which mutates nothing and cannot reach outside the root. - Denied:
http(network egress) andworkspace:write/workspace:delete(disk mutation within the root), plus anything else that reaches the gate.
The fallback governs exactly the powerful surface, because the pure effects
(log, template, memory, prompt, …) never call enforce_policy and so run
regardless of the profile — they have no ambient authority to abuse.
Selection order is the --untrusted flag first, then env-driven resolution:
CHIDORI_POLICY_FILE → CHIDORI_POLICY (inline JSON) → CHIDORI_POLICY_PROFILE
(a built-in name) → the surface default. The surface default differs:
chidori runfalls back to the permissive profile (AlwaysAllow, no rules) — the historical default for trusted local development.chidori servefalls back to theuntrustedprofile, with a denial reason that names the opt-outs. A malformedCHIDORI_POLICY*source fails closed to this default rather than silently serving allow-all. Pass--trustedto restore the permissivechidori runresolution (the startup banner'sPolicy:line reports the active posture either way).
To customize further, copy the profile's shape into your own CHIDORI_POLICY
JSON (rules + "default": "never_allow") — full schema below.
Writing a policy file (CHIDORI_POLICY_FILE / CHIDORI_POLICY)
The deployment checklist asks for a configured policy; this is the file's
complete shape (src/policy.rs::PolicyConfig):
{
"rules": [
// Tried in order; the FIRST rule whose target and match_args both
// match wins. No rule matches → the "default" decision applies.
{
"target": "http", // "http" | "workspace:<action>" | "tool:<name>" | "app_data:<action>" | "*"
// workspace actions: list | read | write | delete | manifest
"decision": "always_allow", // "always_allow" | "ask_before" | "never_allow"
"match_args": { // optional JSON subset matched against the call's args
"url_prefix": "http://ops.internal:9911/"
},
"reason": "ops API only" // optional; shown in the denial/approval message
},
{ "target": "workspace:write", "decision": "always_allow" },
{ "target": "workspace:read", "decision": "always_allow" },
{ "target": "workspace:list", "decision": "always_allow" }
],
"default": "never_allow", // fallback when no rule matches (default: "always_allow")
"default_reason": "this server allows only the ops API and workspace writes"
}match_args semantics:
- Objects match as subsets: every key in the pattern must exist in the call args and match recursively.
- Strings substring-match:
{"url": "/status/"}matches any URL containing it. Convenient for tool args — but never use a plain string to scopehttpby host: the host text appearing anywhere (e.g. inside a hostile URL's query string) would satisfy it. url_prefixanchors: the reserved key matches when the call'surlstarts with the given string — the right shape for "this agent talks to this host only" ({"url_prefix": "https://api.example.com/"}). Combine with the SSRF guard'sCHIDORI_HTTP_ALLOW_HOSTSfor internal hosts.- Other JSON values must be equal.
A malformed file fails closed: chidori serve logs a parse warning and
falls back to the deny-by-default untrusted profile, never to allow-all.
Every denied gated call is reported on the server's stderr (as well as in
the run journal), so a misconfigured policy is audible, not silent.
The supervised profile (ask-by-default)
The supervised profile is the approval-flow sibling of untrusted: the same
read-only workspace allowlist, but the fallback is AskBefore instead of
NeverAllow. Under the server, a gated call suspends the run — the session
transitions to awaitingapproval with a pending_approval carrying the
(target, args) being asked about — and POST /sessions/:id/approve with
{"decision": "allow"} or {"decision": "deny"} settles it. Approvals are
remembered per (target, args) for the rest of the session, so the agent does not
re-ask for an identical call. On the bare CLI (where nothing can answer the
prompt) the gated call errors instead, unless CHIDORI_POLICY_AUTO_APPROVE=1.
Select it anywhere a profile name is accepted: CHIDORI_POLICY_PROFILE=supervised
or a session's policy_profile field (below).
Per-session profiles over the HTTP API
A multi-tenant server can mix trusted and untrusted callers without restarting or
re-configuring: POST /sessions and POST /sessions/stream accept an optional
policy_profile field (alias policyProfile) naming a built-in profile.
curl -X POST localhost:8080/sessions \
-d '{"input": {}, "policy_profile": "untrusted"}'Semantics:
- Stricter-wins layering. The session profile is layered on the server
policy and, for every gated call, the stricter of the two decisions applies
(
never_allow>ask_before>always_allow). A caller-selected profile can therefore tighten what the operator's policy allows but can never relax it — selecting a profile is not an escalation path even on a hardened server. - Sticky for the session's lifetime. The profile name is persisted on the
session and re-applied on every re-run:
input()resume, approval replay, and/replay. It is reported back in the session JSON aspolicy_profile. - Validated up front. An unknown profile name is a
400at creation; a stored name that no longer resolves (e.g. after a version change) fails closed tountrustedrather than silently running under the looser server policy.
Both SDKs expose this: client.run(input, { policyProfile: "untrusted" }) in
TypeScript, client.run(input, policy_profile="untrusted") in Python.
How to harden for untrusted code
If you intend to run code you do not trust on this engine today:
- Select the deny-by-default policy:
chidori run --untrusted(orCHIDORI_POLICY_PROFILE=untrusted, above);chidori serveis already there by default. This denieshttpandworkspacemutations while leaving read-only workspace introspection available. - Lower
CHIDORI_JS_OP_BUDGETandCHIDORI_JS_MEM_CAP_MBto fit the workload (and tightenCHIDORI_JS_MEM_POLL_MSalongside a small cap), and enableCHIDORI_JS_DEADLINE_MS(acceptable because untrusted code should not be making slow trusted host calls). - Add OS isolation with
--isolate(chidori run --isolate,chidori serve --isolate, orCHIDORI_ISOLATE=process): each run executes in a confined child process and brokers its effects back over a pipe, so a breakout has no ambient process to land in. Layers are best-effort — setCHIDORI_ISOLATE_REQUIRE_SANDBOX=1to fail closed if the platform's core confinement can't be applied. See OS-level isolation for the full posture. (Running each agent in its own container is still complementary.) - Keep
node:fsonFsPolicy::Captured(the VFS) and avoidworkspace.*.
Verification
The protections above are exercised by:
crates/chidori-js/tests/smoke.rs::string_growth_is_bounded— string/template caps throwRangeErrorrather than OOM.src/runtime/rust_engine.rs::tests::run_agent_opcode_budget_terminates_infinite_loop— the opcode budget terminates a runaway loop.
OS isolation (--isolate) is exercised by:
src/runtime/rust_engine.rs::tests::isolated_run_matches_in_process_byte_for_byte— the worker's output and host-call log match the in-process path exactly.crates/chidori/tests/isolate_limits.rs(skip-aware where a layer is unavailable):isolated_run_succeeds_under_the_default_resource_floor(no false positives),seccomp_blocks_a_denied_syscall(a post-filtersocket()probe is killed),filesystem_writes_are_blocked_when_confined(Landlock/Seatbelt),parent_deadline_kills_a_wedged_worker,cpu_limit_terminates_a_busy_worker, andseatbelt_loads_and_enforces_on_macos.