Sandbox mode
Sandbox mode denies every native call that opens a real filesystem, network, or
subprocess resource -- for running untrusted or merely-not-yet-trusted d6 code
without letting it touch anything outside the process itself. Ordinary computation,
puts/print, and everything else keep working normally; a sandboxed program still
runs to completion and can report a result.
d6 --sandbox untrusted.d6
# or, equivalently:
DIAMOND_SANDBOX=1 d6 untrusted.d6
Any value except 0 enables it, including an empty one, so a wrapper script that expands
an unset variable keeps the sandbox on. d6 --sandbox is a thin convenience that sets
the same environment variable to 1 before running normally, overriding any 0; both forms, and DIAMOND_SANDBOX=1 ./program against
a d6 build-produced standalone binary, behave identically.
What's denied
Every one of these raises SandboxError (a rescuable StandardError) instead of doing
anything:
File.open,File.publish,File.rename,File.sync,File.chmod,File.delete,File.exist?,File.directory?,File.expand_pathDir.entriesProgramBuilder#expand_source(it resolvesrequireby reading files and returns the text)TCPSocket.connect,TCPServer.listen/listen_nonblockingUDPSocket.bind/.openTLSSocket.connect,TLSServer.listenSQLite3.open,PostgreSQL.open,MySQL.openProcess.run,Process.spawnSignal.trap
File.dirname/.basename/.extname/.absolute?/.join are not denied -- they're
pure string manipulation with no real syscall, unlike .directory? (stat) and
.expand_path (path resolution against the real filesystem).
begin
File.open("secrets.txt", "r")
rescue error: SandboxError
puts(error.message()) # => "sandbox denies File.open"
end
Per-capability granularity
DIAMOND_SANDBOX=1 alone still denies everything above, unchanged. DIAMOND_SANDBOX_ ALLOW (only consulted once DIAMOND_SANDBOX is already set) names categories to let
through instead:
DIAMOND_SANDBOX=1 DIAMOND_SANDBOX_ALLOW=network d6 app.d6 # filesystem/database/subprocess still denied
Five categories, comma-separated when combining more than one:
filesystem--File.open/.publish/.sync/.delete/.directory?/.expand_path,Dir.entriesnetwork--TCPSocket.connect,TCPServer.listen/listen_nonblocking,UDPSocket.bind/.open,TLSSocket.connect,TLSServer.listendatabase--SQLite3.open,PostgreSQL.open,MySQL.open(kept separate fromfilesystem/networkeven though SQLite3 technically opens a file and Postgres/MySQL are technically network -- "allow DB access but not raw sockets/files" is a real, distinct policy shape)subprocess--Process.run,Process.spawnsignals--Signal.trap. A handler is a process-wide side effect: it can redirect whatSIGINTorSIGTERMdoes to the whole process, which is why it is not on by default under--sandbox. A program that has to run a shutdown handler needsDIAMOND_SANDBOX_ALLOW=signals.
Deliberately an allow-list, not a deny-list. An unrecognized or misspelled category
name in DIAMOND_SANDBOX_ALLOW (a typo, a name from some future category that doesn't
exist yet) simply never matches anything -- that capability stays denied, the same
fail-safe direction as leaving DIAMOND_SANDBOX_ALLOW unset entirely. A deny-list design
would fail the other way: the same typo would silently deny less than intended.
begin
TCPServer.listen(8080)
server.close()
rescue error: SandboxError
# not reached with DIAMOND_SANDBOX_ALLOW=network
end
File.open("secrets.txt", "r") # still denied -- filesystem wasn't in the allow-list
Not covered: allow-listing specific paths or hosts (e.g. "deny filesystem except
under /tmp," "deny network except to one host") -- a real, separate design problem
(path traversal, symlink following, host-prefix-matching bypasses) this pass
deliberately doesn't attempt. Category matching here is a small, fixed, hand-verified
set of literal strings with no pattern-matching surface to get wrong; path/host
matching would be a materially different, higher-stakes feature. Revisit only with a
concrete driving need.
Resource limits
Independent of --sandbox/DIAMOND_SANDBOX -- these bound how much a program can
do, not what kind of thing it can do, so they're useful on their own (e.g. safely
executing an untrusted or fuzzer-mutated program that never touches the filesystem or
network at all) and combine naturally with --sandbox for defense in depth (see below for what they do not
guarantee against code that rescues them):
DIAMOND_MAX_INSTRUCTIONS=1000000 d6 untrusted.d6
DIAMOND_MAX_WALL_MILLISECONDS=5000 d6 untrusted.d6
DIAMOND_MAX_MEMORY_BYTES=100000000 d6 untrusted.d6
DIAMOND_MAX_INSTRUCTIONS-- opcode dispatch count.DIAMOND_MAX_WALL_MILLISECONDS-- wall-clock budget.DIAMOND_MAX_MEMORY_BYTES-- live (post-collection) memory budget.
All three budgets are enforced by the interpreter between instructions, so a VM
that has any of them set (or is past a tripped one) never runs JIT-compiled code,
even with DIAMOND_JIT=1. The memory budget counts too, because a loop that only
grows one array allocates nothing new, and the interpreter catches that between
instructions.
All three raise a rescuable ResourceLimitError once exceeded -- the same way as any
other catchable error, including for the memory budget specifically: a genuine host
allocator failure is deliberately not catchable in d6 (nothing can safely
recover from real memory exhaustion), but hitting your own configured cap, with
plenty of real memory still available, is a different, safe-to-catch situation.
begin
index = 0
while true
index = index + 1
end
rescue error: ResourceLimitError
puts(error.message()) # => "resource limit exceeded"
end
Each of the three is independently opt-in (unset = unlimited); all can be combined.
DIAMOND_MAX_LIVE_THREADS=N lowers the cap on live spawned threads (Thread.new and
Supervisor#add_child together) below the built-in 64: past it, Thread.new raises
ThreadError ("too many concurrently active threads") and add_child raises the same.
A value above 64, zero or unparseable keeps 64; the limit can only tighten it. It counts
live threads process-wide, so it bounds what a sandboxed program can spawn, but it is not a
memory budget for them.
A budget fires in two steps. Exceeding it raises the rescuable ResourceLimitError
once, and switches the instruction and wall-clock budgets off for the rest of that
VM's run -- otherwise they would re-trip on the very next instruction, including the ones
the rescue/ensure clause needs to run. The same moment starts a grace allowance, sized
for cleanup and not for carrying on:
| budget | grace after it trips (for each VM) |
|---|---|
DIAMOND_MAX_INSTRUCTIONS | 1,000,000 more instructions |
DIAMOND_MAX_WALL_MILLISECONDS | 1,000 ms past the budget's deadline |
DIAMOND_MAX_MEMORY_BYTES | a ceiling a quarter above the budget, at least 4 MiB above it |
Each allowance has a default and an override, read once per VM at start-up like the budgets themselves. An unset, unparseable or zero value keeps the default.
| budget | override |
|---|---|
DIAMOND_MAX_INSTRUCTIONS | DIAMOND_GRACE_INSTRUCTIONS=N -- N more instructions |
DIAMOND_MAX_WALL_MILLISECONDS | DIAMOND_GRACE_WALL_MILLISECONDS=N -- N ms past the deadline |
DIAMOND_MAX_MEMORY_BYTES | DIAMOND_GRACE_MEMORY_BYTES=N -- a ceiling N bytes above the budget (replaces the quarter-of-budget rule and its 4 MiB floor) |
A smaller value gives a rescue clause less room; a larger one is the caller's choice and keeps a rescued program alive for correspondingly longer, so tighten rather than loosen anything that runs code you do not trust.
A program still running past its allowance is stopped for good. The VM unwinds with an
"... budget exhausted" runtime error that no rescue clause can match (exit status 70 for
the main program; join raises ThreadError for a thread). The allowance is measured from
the budget, not from when the error was rescued, so rescuing late buys no extra time.
begin
loop do # fires at DIAMOND_MAX_INSTRUCTIONS
end
rescue error: ResourceLimitError
nil # up to 1,000,000 more instructions are allowed here
end
50_000_000.times() do # stopped: "instruction budget exhausted"
end
These budgets are not a security boundary on their own. Use them to cap an honest
program that loops or allocates by mistake. To contain code you do not trust, also bound it
from outside the process (a timeout, a cgroup, or killing it), and keep it under --sandbox.
The budgets are shared by every VM in the process; see below.
What's not covered:
The budgets are shared by every VM in the process. The top-level program, each
Thread.new,Supervisorchild andProgramBuilder#runadd to one set of counters, so spawning more of them does not buy more allowance:- Instructions are one process-wide total. Each VM adds its count and checks it every 4,096 instructions, and a VM that finishes adds its remainder, so a swarm of short-lived threads is still counted. A budget is therefore enforced to within 4,096 instructions per VM, not exactly.
- Wall-clock time runs from the first budgeted VM of the process, so a thread started late inherits what is left.
- Memory is the sum of every live VM's bytes. A thread that has finished stops counting (its heap stays allocated until its handle is collected, but nothing runs in it), and garbage another thread has not collected yet still counts until it does.
When the total is exceeded, each VM that is still doing work raises its own
ResourceLimitErrorat its next check, then gets its own grace allowance. A parent that only waits to join a thread, and so runs fewer than 4,096 instructions after the total went over, is not told. A supervised child that is hard-stopped is restarted like any other crash, in a fresh VM that draws on the same totals.The wall-clock budget is checked, not exact. It is read every few thousand instructions, not every single one, since reading the clock is comparatively expensive, so the overshoot past a configured millisecond budget is bounded but nonzero.
A blocking wait is bounded, a blocking read is not.
IO.polland channelsend/receive/selectsleep in the kernel between instructions, so they clamp their wait to the budget's remaining time and raiseResourceLimitError(or hard-stop, past the grace allowance) when it runs out. A read on a file, pipe or socket is not interrupted, andThread#joinwaits for the thread, which has its own budget check. Under--sandboxthe I/O opcodes are denied before they can block.Memory is what the VM's own accounting counts. Objects and the storage of
ArrayandHash(including growth bypush, which allocates no new object, checked at the next instruction) are counted; buffers owned by native libraries behind a socket, TLS, SQLite, PostgreSQL or MySQL handle are not.
What's not covered
Stated honestly, matching this project's own convention for every other feature's known limitations:
- No path/host allow-listing, only whole-category granularity -- see "Per-capability granularity" above for exactly what's covered and why finer-grained matching is out of scope.
- Spawning threads is bounded, not denied.
DIAMOND_MAX_LIVE_THREADSlowers the live thread cap (see "Resource limits"); a sandboxed program can still spawn up to that many, and each spawned thread's own code is still fully subject to every restriction above (see Concurrency internals -- sandbox mode checks the real process environment directly at each gated opcode, so it applies identically to the top-level program, everyThread/Supervisorchild, and anything run viaProgramBuilder#run, with nothing to propagate from parent to child). - The program's own inputs are not restricted.
ENV(aHashof the real process environment, which is where deployment secrets usually live),ARGVand the clock are readable from sandboxed code. Sandbox mode stops the program touching the outside world; it does not hide what the process was started with, so do not put secrets in the environment of a sandboxed process that you would not hand to the code inside it. - The compile-time
requiregraph is not restricted. Sandbox mode restricts runtime behavior; the entry script itself (and whatever itrequires) is assumed to already be source the caller chose to run. Arequirethe program triggers at run time throughProgramBuilder#expand_sourceis a different thing: it is a file read, so it is denied with thefilesystemcategory.
Revisit any of these only with a real driving need, not speculatively -- the same bar the roadmap already holds every other research direction to.