Receiver Scripts and the Sandbox#
Status: decided on 2026-09-23 as milestones 7.4 and 7.5 (docs/BACKEND.md section 5.4); every slice but the
sandbox's own heap (slice 6) is in (section 9). A receiver script is lowered as one function of its module and run by the VM inside a sandbox: the
grant reaches the runtime (runtime/sandbox.c), a panic, Process.exit, a refused path and every limit stop the
script and never the host, and each stop carries the line of the script it happened on (compiler/src/vm/sandbox.trb,
compiler/tests/sandbox.test.trb). torb manifest evaluates a project.trb through it and torb manifest --check
compares the result with the static reader on every manifest of the repository, which is a gate of tier A. A program
run by torb run loads and applies scripts through std/sandbox (examples/config-dsl,
tests/conformance/vm-only/sandbox-load.trb), and a script from a path only known while the program runs is
checked and lowered into the running program by the torb that runs it (slice 7,
tests/conformance/vm-only/sandbox-runtime-path.trb). A native binary loads and applies scripts through the
front end and the VM it links (slice 8, section 1): the value crosses into that VM and back as text, every field of it
(section 5), and tests/conformance/sandbox-crossing.trb prints the same in both back ends. The toolchain reads the
evaluated manifest where a setting it uses is computed, refuses a computed static setting, and keeps what the
evaluation read in build/manifest-inputs.trb (slice 5, section 7). The sandbox's own heap (slice 6) is not built;
torb build --embed-vm builds a native binary that interprets a whole program instead (VM.md section 11).
A receiver script is a .trb file that is the body of a receiver closure instead of a module: project.trb against
Project, a config.trb against the ServerConfig of the program that loads it (CONCEPT.md, "Receiver Scripts and the
Sandbox"). The checker has checked such files for a long time (semantics/checker/receiver.trb); this record decides
how one is lowered, run and contained, and how the value it configured gets back to whoever asked.
project.trb / config.trb the host: torb (project.trb) or a program (Sandbox.load)
│ checked as the body of │
│ (var self: Receiver) => Void │ grants: modules, files, environment, limits
▼ ▼
one IR function `script` of the module ──► bytecode ──► the VM, inside a sandboxed call
│ steps and time: counters of the interpreter
│ memory, files, environment, exit, panics:
│ runtime/sandbox.c, consulted by the runtime
▼
Ok: the receiver, configured in place
Fail: SandboxError(line, message) - the host goes on- 1. A script runs in the VM, always
- 2. Checking — the receiver, the prelude, and imports against the grant
- 3. Lowering — one function per script module, emitted only where something loads it
- 4. The sandbox — capabilities, limits, and what
project.trbmay do - 5. How the host passes the value in and reads it out
- 6. Errors and their sites
- 7.
project.trbthrough the VM - 8. What this is not
- 9. Slices
- 10. Open
1. A script runs in the VM, always#
Decision: a receiver script is interpreted, by every host, and never compiled to native code. The three things that make a sandbox a sandbox - a step limit, a time limit and a panic that does not end the host - are properties of an interpreter: a native frame cannot be counted per instruction, and a panic in native code ends the process by design (CONCEPT.md, "A panic aborts the process"). So the one place where the language promises a recoverable panic is also the one construct defined to run in the VM.
- This is the one exception to "the VM is never the more permissive back end" (VM.md section 1). That rule is about programs, which both back ends must run alike. A script is not a program: it is data with control flow, and a native binary runs it in a VM of its own - the one of its script host, below.
- Every host has a VM:
torbitself (it evaluatesproject.trb), a programtorb runruns in the VM (Sandbox.load, slices 4 and 7), a native binarytorb build --embed-vmbuilt, which interprets the whole program (VM.md section 11), and a native binarytorb buildcompiled, which links the script host. - A native binary (slice 8, as built) links the script host:
compiler/src/vm/host.trb- the front end and the VM - with the toolchain's standard library as text, compiled once per toolchain, profile and C compiler into an object of its own belowbuild/script-host/beside the runtime (compiler/src/cli/host.trb). It is the size of the compiler, so no program waits for it twice; the key is a hash of the compiler's and the standard library's sources, the runtime's headers and thetorbthat emits the C, and two builds that compile it at once each write a directory of their own. The program carries the files its build read, the standard library aside (scriptSources), keyed below/torbinstead of the directory they share, so the binary names no path of the machine that built it.Sandbox.loadhands the host the path (hostLoadScript): it checks those files again with the script as one more module - one the program was compiled with, or a file of the disk it runs on - against the receiver, lowers the script next tocrossedInandcrossedOutofstd/sandbox, and answers the bytecode as text, which theScriptkeeps; a refusal is theSandboxErrorofload, with its line.Script.applyhands it back with the value as crossing text and the grant (hostRunScript): the host reads the value in, runs the script on it inside the sandbox and under its limits, which count the script alone, writes it out and gives back what it placed in its VM. Nothing is kept between two calls, so every load costs a check of the program - a fraction of a second at-O2- and a binary that loads scripts needscompiler/besidetorbwhere it is built, and nothing where it runs. The C of such a program declares the host's two functions itself; the VM, which runs its scripts in place, never reaches them. - A path only known while the program runs (slice 7, as built).
Sandbox.loadlooks a path up in the table of the program's scripts first; any other path goes to thetorbthat runs the program, through the instructionload.script: the program's files are checked again with the file as one more module whose receiver is the typescriptReceiver<Value>names (its module and name,ScriptReceiver.Declared), the script is lowered as itsscriptfunction, and its part is encoded as a continuation of the running program (emitBytecodewithafter), as a session oftorb replgrows. The continuation's constants go to the top of the registers, which the frames give up for them, because the constant pool below the frames cannot grow while they are in use; the image is laid out again with every old chunk where it was, and a loop takes the new one wherever a chunk comes out of a value (a closure, a witness table, a destructor).run.loadedthen runs the body asrun.scriptruns a compiled one. A file loaded again with the same text is the script loaded before; every other one costs a check of the program, a few seconds. A receiver with type arguments cannot be named this way and is aSandboxError.
2. Checking#
The file is checked as the body of (var self: Receiver) => Void with the file scope "the prelude and the receiver",
as semantics/checker/receiver.trb has done since the checker existed. What changes is imports.
Decision: a script may use a module, and the import is checked against the grant - at torb check time where the
grant is fixed, at load time where the host decides it. "Capabilities are checked at module import, before anything
runs" (BACKEND 5.4): the import list is the capability list, because there is no reflection, no eval and no dynamic
import.
project.trb's grant is the toolchain's and never changes (section 7), sotorb checkreports an import it does not allow, at theuse, with the list of what is allowed:error: A project file may import `std/fs`, `std/text` and `std/os/environment`, and not `std/process` --> project.trb:1:1 | 1 | use Process from "std/process" | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = A project file reads its own directory and the environment it was started in, nothing elseA script a program loads is granted modules at the call site, which the checker cannot evaluate, so its imports are checked by
Sandbox.loadwhen the script is loaded, and a module the grant does not name is theSandboxErrorof the load with the line of theuse(section 6).std/machineandstd/sandboxare never importable by a script, whatever the grant says, and the checker says so for every script: the first trusts its operands the way machine code does (VM.md section 6), and a script that loads scripts is a nesting the sandbox does not have (section 8).Nothing is transitive. The allowlist is about what the script names. A module of the standard library it may import can itself use anything, which is why files, the environment and
Process.exithave a second lock in the runtime (section 4) - the lock that does not depend on who asked.
3. Lowering#
Decision: a script module lowers to one IR function, script, whose single parameter is the var receiver and whose
body is the file. It is named after the module with the member component script, which no declaration can produce
and which never collides with the entry function of the same module.
- The receiver is parameter zero, a reference. The checker declares the receiver binding at
Span(0, 0)and records the file's receiver closure there (checkReceiverScript); the lowering maps that span to the parameter, which is exactly whatbindClosureParametersdoes for the implicit receiver of a closure. A property command, a member call and a bareselfof the script are then places through avarparameter like any other. - The statements are a body, not a top level. A
constof a script is a local of the function (the checker already checks it withcheckStatement), aforis a loop in it, and auseis no code at all. - A script is lowered only where something loads it.
project.trbwas excluded from every program so thattorb buildnever compiles a manifest (programModulesinir/lower/lower.trb), and it stays excluded: the toolchain's evaluation program asks for the script functions of the manifests it evaluates, and nothing else does. - A program carries the table of its scripts.
scriptBody<Value>(path)andscriptImports<Value>(path)ofstd/sandboxare natives whose bodies the lowering generates once per receiver type (ir/lower/script.trb): a chain of text comparisons of the path, relative to the package, against every file the checker checked againstValue, answering the script function as a closure and the modules itsuselines name with their lines. It is ordinary IR, and it is whatSandbox.load- TorbScript now - reads. The one native only the VM has isrunScript: the lowering refuses it for the C back end and hands it to the bytecode emitter as the instructionrun.scriptotherwise. - A loaded file is a script wherever it lies, also among the files of a package, which would make it a module:
the load decides (
addReceiverScriptsinsemantics/graph.trb).
4. The sandbox#
Decision: the capabilities are a closed list, granted by the host, enforced twice - once at import, once in the runtime - and every limit is exact about what it counts.
| Capability | How it is granted | The first lock (import) | The second lock (runtime) |
|---|---|---|---|
Other modules of std | modules "std/text"; inside std/os one module at a time, modules "std/os/system" | the script's use against the list: a package, or the module of std/os, and a grant of a package covers its modules | - |
| The file system | files readOnly: "./config", readWrite: "./out" | std/fs has to be in modules | every path a file function of the runtime is handed |
| The environment | environment "APP_*", "*" for all | std/os/environment in modules | Environment.get of a name no pattern matches answers None |
| Processes | modules "std/process" | the import | Process.exit inside a script stops the script, always |
| The network | modules "std/network" (or std/http) and network connect: "host:port", listen: "address:port" | the import | a resolve, connect or listen outside the grant stops the script, a UDP bind is a listen and a datagram a connect - the patterns and the rules are docs/design/NETWORK.md section 10 |
| The clock | modules "std/time" | the import | - |
| Output | the prelude's print and printError | - | - |
| Foreign functions | never | - | - |
- Files: a root is an absolute, normalized directory, and "inside" is PATH.md section 7's lexical rule plus a link
check. A relative path of the script is resolved against the base directory of the sandbox (the project
directory for
project.trb, the working directory for a program's script), normalized, and must start with a root of the right side: a read may use areadOnlyor areadWriteroot, a write only areadWriteone. Every component below the root that exists must not be a symbolic link (a junction or any other reparse point on Windows), because the lexical check alone is the gap PATH.md names.File.existsandFile.absolutePathare reads: whether a file exists outside the roots is information the grant did not give. A refusal stops the script (section 6); it is never anIoErrorthe script could catch and ignore. - The environment: a pattern is a name, or a prefix followed by one
*. A variable no pattern matches reads as unset. That is quiet on purpose and is the reason the loud lock is the module: a host that grants no environment grants nostd/os/environment, and the script fails to load with a message that names the module (PROJECT.md section 8, "A dependency does not get the environment"). Process.exitnever ends the host. Inside a sandbox it stops the script with the code in the message.- Output is allowed and goes to the host's standard output and standard error, unchanged. A script cannot read anything through it, and a configuration that says what it is doing is a configuration somebody can debug. (Section 10 lists this as a question of taste.)
The limits, and what each one counts:
| Limit | Default | What it counts |
|---|---|---|
steps | 1 000 000 | bytecode instructions the VM dispatches while the script runs, including the receiver's members and std code it calls |
time | 2 s | wall-clock time, read from the monotonic clock every 4096 steps, so a script overruns by at most that much work |
workers | 1 | the workers the script's tasks may run on at once; the VM runs them on the thread of the sandbox, so a script uses one whatever it is granted (docs/design/CONCURRENCY.md section 3) |
memory | 64 MB | bytes the script allocates while it runs, counted by torb_allocate and torb_raw_allocate |
- Memory is allocation, not residency, for now. The runtime's
freedoes not know a block's size, so what a script holds cannot be measured without the sandbox's own heap (slice 6). What it allocates can, and it is the conservative bound: a script can never hold more than it allocated. A configuration allocates kilobytes; a script that churns through 64 MB of garbage is stopped, which for a configuration file is the right answer anyway. - Limits are counters, not signals. Nothing interrupts the host: the interpreter checks the step counter per instruction and the clock every 4096 steps, the runtime checks the allocation budget at every allocation.
5. How the host passes the value in and reads it out#
Decision: inside one VM the value is passed by reference; across the boundary between a host that is native code and the VM, only text crosses, and the text is the receiver's own vocabulary.
- A program run by the VM (
torb run, slices 4 and 7) and the script it loads are one bytecode program: the receiver has one layout, andScript.apply(var value)hands the script the reference thevarparameter already is. Nothing is copied or converted, and the script configures the caller's value in place, as CONCEPT.md says. torbis native code, and itsManifestis not the VM'sProject. A record of the VM has the VM's own inline layout (VM.md section 2), so no word of aProjectmeans anything to the compiled toolchain. AStringis the one value whose representation both sides share, because it is the runtime'storb_text. So the evaluation program answers the configuredProjectprinted back as its settings - the literal command calls that would produce it, which is thesettingsblock of the locked manifest (PROJECT.md section 8) - and the toolchain reads that text with the static reader it already has. The kernel operationTextOutcopies a text of the VM into words the host decodes; it is an operation, not a native, so it needs no second commit (VM.md section 6).- Values in are the same the other way round: the host places a text with
Machine.placeText(the program's arguments already come in this way), and a receiver starts from its default value inside the VM. - A native binary (slice 8, as built) crosses with
std/encoding:crossedApplywrites the value withEncodedValue.of(value, privateFields: true)and the crossing text ofstd/sandbox/src/crossing.trb- one letter per part, a text with its length in bytes in front, a float as the text that reads back as the same bits - the host's VM reads it in withValueDecoder.of(..., privateFields: true)and the receiver'sDecode, the script configures it, and the way back is the same. Every field crosses: a derivedencodewrites aprivatefield with a default as well where its target asks for the whole value (Encoder.writesPrivateFields), and a deriveddecodereads it back where its source does (Decoder.readsPrivateFields), so a counter avar fnof the receiver keeps comes back as the script left it; a format answersfalseto both and writes a type's data as before (ENCODING.md section 3, "The whole value"). A receiver used from a native binary is thereforeEncode & Decode, which a receiver of plain settings is by derivation; one that is not - a field of a closure or of asharedtype - is an error of the program at theScript.applythe native build reaches (runCrossed, which the lowering turns intocrossedApplyfor the receiver), and a program the VM runs is not asked. Aprivatefield whose type is notEncodestays behind, at its default.
6. Errors and their sites#
SandboxError(line, message) is the only failure a script produces. line is a line of the script, 0 where the
failure is about no line of it.
| When | line | message |
|---|---|---|
| a module the grant does not name | the use | The script may not import `std/fs`: the caller grants it with `modules "std/fs"` |
| a path whose script the program was not compiled with | 0 | There is no script `./other.trb` in this program: a script is loaded by a path it was compiled with |
| the step limit | where it stopped | The script ran more than 1000000 steps |
| the time limit | where it stopped | The script ran longer than 2000 milliseconds |
| the memory limit | where it stopped | The script allocated more than 64000000 bytes |
| a panic | the panic's site, or the script's line that called into it | the panic's message; a site outside the script is appended as (at std/text/src/lib.trb:12:5) |
| a path outside the roots | the line that called into std/fs | The script may not read `../VERSION`: it is not inside `C:/work/shop` |
| a link below a root | the same | The script may not read `data/x`: `data` is a symbolic link |
Process.exit | the same | The script called Process.exit(3) |
a name no network connect pattern names | the line of the call; inside a task, the script's line that waited for it | The script may not resolve `api.example.test`: no `network connect` pattern names it |
an address and port no network connect pattern names, and no granted name resolved to | the same | The script may not connect to `10.0.0.1:80`: no `network connect` pattern names it (send to for a datagram) |
an address and port no network listen pattern names | the same | The script may not listen on `0.0.0.0:8080`: no `network listen` pattern names it (bind to for UDP) |
the system's name servers (systemNameServers, lookup without servers) | the same | The script may not read the system's name servers: a sandbox grants no resolver configuration |
| a network pattern outside the grammar | 0 | The `network connect` pattern `api.example.test` is malformed: a pattern is `host:port` |
"Where it stopped" is the innermost frame of the VM whose function belongs to the script's file, and the line is the location of the last instruction before its program counter that carries one. A limit that is reached inside the receiver's own member names the line of the script that called the member. A refusal inside a task the script started -
TcpStream.connectandresolverun in one - has no frame of the script below it: the stop waits for the operation that ran the scheduler (VM.md section 10), and the line is that one's, thetestthat waited for the task.A stop leaks what the script held. Nothing runs on the way out, as with every panic (gap 9): the frames of the script are abandoned and the destructors the kernel had queued are dropped without running. What leaked is bounded by the memory limit, and slice 6's own heap ends it.
For
project.trba stop is a diagnostic with the file and the line (PROJECT.md section 8, "Errors"):error: The script may not read `../../VERSION`: it is not inside `C:/work/shop/member` --> member/project.trb:2
7. project.trb through the VM#
Decision: the toolchain evaluates a manifest by running evaluated of std/project in the VM, with the manifest's
script function as its argument, under a fixed grant; the result is text that the static reader reads.
public fn evaluated(script: (var self: Project) => Void): String {
var project = Project()
script project
project.settings()
}- The grant is PROJECT.md section 8's: modules
std/fs,std/textandstd/os/environment; files read-only below the project's own directory, which is also the base a relative path is resolved against; the environment"*"; limits of 1 000 000 steps, 16 MB and 2 s. A dependency's manifest will get the same withoutstd/os/environmentonce dependencies are resolved (PROJECT.md section 7). settings()prints what differs from the vocabulary's defaults, in the order of the vocabulary, every argument a literal. A default is not printed because the static reader has defaults of its own, and a line that repeats one would only be a second place to keep in step with it.- One front end run evaluates every manifest below a path.
torb manifest --check .checks the workspace once, lowers every manifest's script function next toevaluatedinto one program, and runs each in its own sandbox. That is what makes the comparison with the static reader cheap enough to be a gate of tier A: the two must agree on everyManifestfield of everyproject.trbof the repository before anything reads the evaluated one (BACKEND row 7.5). - The static reader stays for the settings that decide the workspace -
name,prelude,dependencies,workspace, theprogramlines - because they are needed before anything can be checked, and "the static read comes first" (PROJECT.md section 8). One of them written as something that would have to run is refused at its value, with the file, the line and the column (manifestProblemsOf), and never evaluated. - The toolchain reads the evaluation (slice 5, as built) where a setting it uses is computed -
version, ortasks { ... }, or a line that is no setting at all, such as anif(needsEvaluationofproject/manifest.trb): the build,torb runandtorb testevaluate that one manifest and readversionandtasksfrom what it answers (manifestOfProjectofvm/manifest.trb). The sandbox records every file the script read and every variable it asked for (the grant'srecord, the kernel operationtake.inputs), andbuild/manifest-inputs.trbkeeps them with a hash each - a variable by the hash of its name and value, never the value - beside the settings the evaluation answered: the next build reads those instead of evaluating again unless one of the hashes changed. A manifest that computes only what the toolchain does not read, such asdescription, is never evaluated.
8. What this is not#
- Not a build script. A script configures a value. It does not run before a build, produce files or call the compiler (PROJECT.md section 13).
- Not nestable. A script cannot load scripts:
std/sandboxis never importable by one. A host that wants two layers loads both itself. - Not a security boundary against the operating system. The locks are the language's: a script cannot name a capability it was not granted, and the runtime refuses paths and variables outside the grant. A bug in the runtime, a race between the link check and the open (PATH.md section 7), and a hard link inside a root are outside it.
- Not a cache of programs. Which files and variables an evaluation of
project.trbread, and what it answered, is kept (build/manifest-inputs.trb, section 7); a script a program loads is checked again at every load.
9. Slices#
| # | Scope | State |
|---|---|---|
| 1 | This record | Done |
| 2 | The sandbox of the VM: runtime/sandbox.c (roots, links, patterns, the allocation budget, the stop), the kernel's recovery point around every operation while a sandbox is open, TextOut, the interpreter's step and time counters and the propagation of a stop, the line of a stop; a script module lowered as its script function; compiler/src/vm/sandbox.trb, the host's API; the checker's import rule for scripts | Done: compiler/tests/sandbox.test.trb pins every refusal with its exact text |
| 3 | project.trb through the VM: Project.settings() and evaluated in std/project, torb manifest [--check], the gate | Done: torb manifest --check agrees with the static reader on every manifest of the repository |
| 4 | Sandbox.load in a program the VM runs: SandboxCapabilities and Script as TorbScript over the generated table of the program's scripts (scriptBody, scriptImports) and the one operation only the VM has (runScript, the instruction run.script); relative roots and the base read against the working directory | Done: examples/config-dsl under torb run, tests/conformance/vm-only/sandbox-load.trb |
| 5 | The toolchain reads the evaluated manifest where a setting it uses is computed, and refuses a computed static setting (PROJECT.md section 12, slice 7); build/manifest-inputs.trb | Done: section 7; manifestProblemsOf, needsEvaluation, manifestOfProject, the grant's record and take.inputs; compiler/tests/project.test.trb |
| 6 | The sandbox's own heap: an exact memory limit, a teardown that frees what a stopped script held | Open |
| 7 | A path known only at run time: the front end inside the running torb checks and lowers the file into the program | Done: section 1; scriptReceiver, loadScript, runLoadedScript of std/sandbox, the instructions load.script and run.loaded, vm/run.trb's loader; tests/conformance/vm-only/sandbox-runtime-path.trb |
| 8 | A native binary that loads scripts: the front end and the VM embedded, the value encoded across (section 5) | Done: sections 1 and 5; the script host (vm/host.trb, cli/host.trb), crossedApply, crossedIn, crossedOut and the crossing text of std/sandbox, derived Encode/Decode over every field; tests/conformance/sandbox-crossing.trb in both back ends |
| 9 | A script held as text (docs/design/URI.md gap 13): Sandbox.loadText checks and lowers a String the program fetched through a Storage, in the VM and through the script host | Done: loadScriptText and hostLoadScriptText of std/sandbox, the instruction load.text; tests/conformance/vm-only/sandbox-storage.trb, and sandbox-crossing.trb in both back ends |
10. Open#
Questions of taste for the owner; everything technical above is decided.
- Output from a script. Allowed and unchanged today. The alternatives are to refuse
printwithout a grant (outputas a seventh capability) or to prefix what a script prints with its file. - The CLI.
torb manifestprints the evaluated settings of a project;torb lockwill write them intoproject.lock.trb(PROJECT.md section 8). Whether the first stays a command of its own or becomestorb lock --printis a question of the command's vocabulary. - The environment of
project.trb. PROJECT.md section 8 grants every variable to the invoked project and its members; the grant here follows it. Narrowing it to nothing, which would make a manifest a pure function of its directory, is the one direction PROJECT.md considered and rejected, and it stays the owner's to reopen.