Skip to content

Sandboxed Execution

chant build --fold folds a subset of source files with zero module execution (TypeScript as Data). Everything outside that subset — most source files today — falls back to importing and running the file for real. Run in-process, that execution has the same filesystem, network, environment, and process-spawn access as the chant CLI itself.

chant build --sandbox (chant #1045 Phase 2) moves that execution off the CLI’s own process: every run-fallback file for a build runs together, as one bundled module graph, inside one child process with Node’s Permission Model locked down. This is the isolation the fold epic (#1019) was building toward — coverage of the fold subset stops being the security-relevant number, because the remainder no longer runs unrestricted.

A file that folds executes zero of its own code either way. The files that mattered for isolation were always the run-fallback ones, and partial fold coverage did nothing for them: an attacker’s payload just needs to land in any file the folder doesn’t recognize (an arrow function, a cross-file reference, an ordinary composite call), which is unremarkable, idiomatic chant. --sandbox isolates that remainder directly, so the property holds regardless of what fraction of files happen to fold today.

Folding a file was never quite “zero execution” of anything, though — which is what chant #1093 closed. To fold export const web = WebApp({...}), the folder had to resolve WebApp and call it for real; likewise for new Bucket({...})’s constructor and a registered intrinsic tag. When those come from a lexicon package, that’s the same trusted code the CLI already loaded. When they come from a sibling project file, folding executed project code — that file’s whole module top level, plus the factory body — inside the CLI’s process, for a file reported as [fold:fold]. Under --sandbox the folder refuses any such import and the file falls back to the (sandboxed) run path instead. See The trust boundary, precisely.

chant #1023 removes the call itself for the composites it can: a factory defined in your own source as Composite((props) => …) whose body stays inside the interpretable subset is evaluated rather than invoked, so its module is never imported at all and the calling file folds under --sandbox instead of being demoted. A factory outside that subset still invokes, and is still refused under --sandbox — the fallback is exact, not partial.

For one build:

  1. The project’s chant.config.ts is evaluated in a sandboxed child and comes back as plain JSON (chant #1113). This happens first, before anything else, because the config is the first project-authored file the CLI reads — and it is project-authored code like any other. A chant.config.json project skips this: JSON is data, parsed in-process, with nothing to execute.
  2. chant build’s fold/run decision runs, with one difference from a plain --fold build: a file whose fold would require importing and invoking a project-owned factory, constructor or intrinsic falls back to run instead of folding (chant #1093). Files that fold are constructed by reducing their AST — chant #1022/#1023 — and stay in the CLI’s own process, because at that point nothing of the project’s has to execute for them.
  3. Every run-fallback file for the build is bundled together into ONE self-contained module with esbuild — not split into one bundle per file. This matters for correctness, not just efficiency: two files in the same build that both import a third all need to observe the SAME object instance for cross-file references to resolve, exactly as they would running unbundled in one process today. Bundling the whole set together, once, preserves that.
  4. The bundle runs in a child process started with Node’s --permission flag and a scrubbed environment.
  5. The child does its own entity collection and reference resolution over just that run-fallback set, then reports back a plain JSON entity set (chant #1045 Phase 1’s wire format) over the process’s IPC channel — never live objects, which can’t cross a process boundary anyway.
  6. The parent decodes that JSON, merges it with whatever folded in-process, and continues the build exactly as it would without --sandbox.
  7. If the project declares lint.policies, those checks run in a second sandboxed child once the build is merged and serialized (chant #1131). The encoded build result goes in, plain PostSynthDiagnostics come back, and no policy module is imported in the CLI’s process. See What a sandboxed policy sees.

Enable it with the CLI flag or project-wide:

chant.config.ts
export default {
build: {
fold: true,
sandbox: true,
},
};
Terminal window
chant build ./infra/ --fold --sandbox

--sandbox composes with --fold but doesn’t require it: without --fold, every discovered file is a run-fallback file, and --sandbox isolates all of them.

The two ways of enabling it are not equivalent for the config file itself, and chant build says so rather than leaving it implied. Reading build.sandbox out of chant.config.ts means evaluating that file, so a config-only opt-in cannot cover its own evaluation — by the time chant knows the project asked for sandboxing, the project’s config has already run in the CLI’s process. Only --sandbox on the command line, known before any config is touched, puts the config inside the boundary; a config-only opt-in still sandboxes everything from step 2 onward and emits a warning naming the file. (chant.config.json has no such limit — it is never executed, so build.sandbox: true there is fully honest.)

What is isolated (verified on Node v24.13.1)

Section titled “What is isolated (verified on Node v24.13.1)”
VectorMechanismResult
Filesystem read--allow-fs-read scoped to the bundle and the project directoryAny other path — readFileSync("/etc/hosts"), a sibling repo, $HOME — is denied
Filesystem writeNot granted at allAny write anywhere is denied
Spawning a processNot granted at allchild_process.execSync/spawn/fork are denied
Worker threadsNot granted at allnew Worker(...) is denied — meaningful because bundling removes the need for a TypeScript loader (tsx) inside the child, which itself needed worker threads and a writable temp directory
Ambient environmentThe child is spawned with a scrubbed env, not a permission flagprocess.env shows only PATH (plus CHANT_ENV in the config and policy children — see below); nothing else the parent process had is visible

A denial surfaces as a chant DiscoveryError naming the file and the operation (e.g. sandbox denied FileSystemRead (/etc/hosts): "…/evil.ts" attempted an operation outside the sandbox's allowlist) — never Node’s raw ERR_ACCESS_DENIED.

What is explicitly NOT isolated: network egress

Section titled “What is explicitly NOT isolated: network egress”

Node has no permission flag for network access. --permission does not gate fetch, http, https, net, dns, or any wrapping library — a run-fallback file can make outbound requests inside the sandbox exactly as it could outside it. This is not an oversight; there is nothing in Node to turn off. A bootstrap-time patch over fetch/http/net inside the child would raise the bar against accidental egress but is trivially defeatable by anything that imports its own copy of node:http or shells out through a still-open vector, and is not shipped here — claiming it as a boundary would be dishonest about what it actually stops.

If you need to block egress, do it outside Node, at the OS or container layer:

  • Container with no egress — run the sandboxed build inside a container whose network namespace has no route out (no default gateway, or an explicit deny-all egress NetworkPolicy/security group). This is the right shape for a service like behold that computes specs for repositories it does not own: the container is the actual trust boundary, and --sandbox is what makes the filesystem/process/environment portion of that boundary meaningful.
  • Network namespace locallyunshare --net (Linux) or an equivalent sandboxed network namespace with no interface configured.
  • sandbox-exec (macOS) — a seatbelt profile denying network sockets, layered on top of --sandbox’s filesystem/process/environment restrictions.

With chant build --sandbox, no file the project authored is executed in the CLI’s own process — not its source files, folded or not, not its chant.config.ts, and not its lint.policies modules. That covers the whole build: reading the configuration, discovering the resources, and enforcing the project’s own organizational policy over the result. The only code that runs there is chant’s own and the packages of the lexicons this build loaded.

That allowlist is enforced by resolution, not by what the source says. A specifier is trusted when it is one of the lexicon package names the build resolved (resolveProjectLexiconsloadPlugins, the same closed set chant #1063 established), or when it resolves to a path inside chant-core’s own executing tree. A specifier that merely looks chant-owned — @intentius/chant, @intentius/chant-lexicon-anything — is not enough: an untrusted repo controls both the text in its source and the contents of its own node_modules.

examples/sandbox-execution-boundary.test.ts asserts this over the whole corpus. Every one of the 101 example directories has its config loaded and is then built with { fold: true, sandbox: true }, while the three places project code can execute in this process are recorded — importModule (packages/core/src/discovery/import.ts), importConfigModule/requireConfigModule (packages/core/src/config-import.ts), and importPolicyModule (packages/core/src/lint/policy-import.ts). No path inside the project’s own source directory may appear in the first, and neither of the others may be called at all.

The policy half needs a differently-shaped test, and that is what let it survive two rounds of this work: lint.policies is loaded by buildCommand, not by build(), so a corpus loop that builds through build() never reaches it. A second section drives the real buildCommand over every corpus entry that declares policies, twice — plain, then --fold --sandbox — and asserts both that no policy module was imported here and that the [policy:…] diagnostics are identical.

One residual remains, pre-existing and not closed by this flag:

  • Lexicon packages are resolved from the project’s install. A repo that ships a poisoned @intentius/chant-lexicon-aws in its own node_modules gets it executed — but the CLI already imports every active lexicon package into its own process to obtain serializers and lint rules, long before discovery starts, so this is a property of loading lexicons at all rather than something fold or --sandbox introduces.

lint.plugins — declarative lint rules, the other project-authored module list in the config — is not part of this. chant build never loads it; only chant lint does, and chant lint has no --sandbox. It is a different command with a different (unsandboxed) trust model, not a hole in this one.

Only JSON crosses a process boundary, so under --sandbox a chant.config.ts must evaluate to data. Every field ChantConfig declares already is: string arrays (lexicons, capabilities, environments), strings, nested plain objects of strings and booleans (ownership, build, release, sbom, signing, vulnPolicy), arrays of plain objects (stacks), records of plain objects (buildParams). lint.plugins and lint.policies are file paths — chant loads those modules itself; they are not functions embedded in the config. Lexicon extensions ride on the schema’s passthrough (the temporal lexicon’s temporal: block), and those are data too.

A value that would not survive the round trip is named and refused, never dropped:

Cannot evaluate /p/chant.config.ts inside the --sandbox boundary: it holds values that are not data.
hooks.beforeBuild: a function

Functions, symbols, bigints, NaN/Infinity, Date/RegExp/Map/Set/class instances, circular references and undefined array elements all report their key path. The one thing dropped rather than reported is an undefined-valued object property, because { sourceDir: undefined } and {} are indistinguishable to every reader of ChantConfig.

One environment variable is forwarded into the config child: CHANT_ENV, the value of --env. The CLI sets it before loading the config precisely so a config can branch on the environment, and dropping it would silently produce a different configuration under --sandbox. It is a value the user typed on the command line, not an ambient secret. Nothing else from process.env is visible — a config reading process.env.AWS_SECRET_ACCESS_KEY gets undefined.

A lint.policies check is a callback over the finished build, so unlike the config it cannot be evaluated somewhere and carried back as a value — it has to run where it can see the resources. Under --sandbox that place is a second child, spawned after the parent has merged and serialized, handed the encoded build result. Only PostSynthDiagnostics come back, and they are validated as data inside the child before they cross.

Four of the five things a PostSynthContext gives a check are already data and cross unchanged: outputs, warnings, errors, sourceFileCount. (env too.) The fifth, entities, is a live map whose cross-entity references are object identity and WeakRefs, so it crosses through the same entity wire format the discovery child already uses. That is enough for every policy pattern in this repo and for every lexicon-shipped post-synth check except one, but it is not literally the same object graph, and the differences are:

  • An intrinsic (Sub, Ref, gitlab’s !reference, …) arrives as a marker-bearing wrapper exposing toJSON() and, where the original had it, toYAML(). A check that reads an intrinsic’s serialized form is unaffected; one that reaches into the intrinsic’s own fields or tests instanceof its class sees nothing.
  • A decoded entity is a marker-bearing plain object rather than an instance of the lexicon’s resource class. entity.props, entity.entityType, entity.lexicon, Object.keys(entity), isDeclarable(entity) and instanceof AttrRef all behave identically; instanceof SomeResourceClass does not.
  • nestedStack() has no wire form at all. A build that uses it and declares lint.policies fails under --sandbox, naming the entity, rather than running the policy over a silently incomplete set. (No example in this repo uses it.)
  • errors arrive as plain objects rather than Error instances. Never observable in practice: chant build runs policies only when the build produced no errors.

The first two are not new. A --sandbox build already merges decoded entities for every run-fallback file, so a policy on such a build was already looking at decoded entities for part of the set; this widens that to all of them and nothing else (the encoding is idempotent — asserted in packages/core/src/discovery/sandbox/policy-wire.test.ts).

What genuinely changes is what a policy is allowed to do. It gets the same permission profile as a run-fallback file — read the bundle and the project directory, nothing else; no writes, no spawning, no worker threads; an environment of PATH plus CHANT_ENV. A policy that writes a compliance report, shells out to a scanner, or reads ~/.aws/credentials therefore fails under --sandbox, naming the file and the operation. That is the flag working, not a bug, but it is a real cost and worth knowing before turning it on.

Its console.log/console.error output IS forwarded (chant #1148), the same as a run-fallback file’s or the config’s — see the next section.

Sandboxed output is forwarded, prefixed, never dropped

Section titled “Sandboxed output is forwarded, prefixed, never dropped”

A run-fallback file, chant.config.ts, and a lint.policies module all run in a child process, and all three can print — a stray debug console.log, a warning a policy wants a human to see, a stack trace from something that throws without going through chant’s own error classification. Diagnostics crossing the boundary as data (an entity set, a config, a PostSynthDiagnostic) was never meant to imply that this incidental output vanishes; a sandboxed build stays as loud as an unsandboxed one.

Each child’s stdout AND stderr are relayed, line-buffered, to the CLI’s own stderr, prefixed by which child produced them:

ChildPrefix
Run-fallback source (chant #1045 Phase 2)[sandbox:run]
chant.config.ts (chant #1113)[sandbox:config]
lint.policies (chant #1131)[policy:<module-basename>] (every declared module’s basename, joined, when a project declares more than one)

This is forwarding, not a second capture: a permission denial or an early exit is still classified into the same DiscoveryError/thrown message it always was — the raw stderr a child produced before dying still names the failure. Forwarding and classification read the same bytes; one relays them for a human to see as they arrive, the other keeps feeding the existing error path.

A diagnostic that is not data is named and refused, never silently mangled by JSON.stringify:

Cannot run lint.policies inside the --sandbox boundary: a policy check's return value holds values that are not data.
/p/policies/org.ts [0].fix: a function

The same applies to a diagnostic that is data but is not a diagnostic — a missing checkId, or a severity outside error/warning/info.

  • chant itself and the lexicon packages. The boundary is around executing project source — the untrusted input. chant’s own discovery/collection/resolution code (and the lexicon a project imports) is trusted, and runs the same whether or not --sandbox is on.
  • Folded files. A file that folds under --sandbox executes nothing of the project’s at all — that is what the #1093 refusal above guarantees — so there is nothing left to isolate.
  • typescript, resolved and loaded directly. Several lexicons’ lint rules do AST-based analysis with the typescript compiler package, which every one of those lexicons re-exports eagerly — bundling it runs into a well-known esbuild limitation (its own internal require("fs")-style calls don’t survive being bundled into an ESM module). It’s left unbundled and resolved to its real, fixed location on disk instead, which the sandboxed child is also granted read access to. This is a fixed, chant-shipped dependency; project source never controls what’s installed there, so this doesn’t weaken the boundary around untrusted code.

As of this measurement, 76 of 102 example projects in the corpus fold completely — every file in them reduces to a value with zero module execution. The remaining 26 have at least one file that falls back to running.

--sandbox isolates the rest, so raising fold coverage is an optimization on top of it, not a precondition.

Sandboxing costs 2 more run-fallback files than plain --fold, and no whole entries — it folds the same set. The reason is narrow: folding a call into project-owned code would have to run that code, so under --sandbox those files run in the child instead. Most projects import their constructors and composites from lexicon packages and never hit it.

just fold-differential and just sandbox-differential report the current numbers.