d6 docs

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_path
  • Dir.entries
  • ProgramBuilder#expand_source (it resolves require by reading files and returns the text)
  • TCPSocket.connect, TCPServer.listen/listen_nonblocking
  • UDPSocket.bind/.open
  • TLSSocket.connect, TLSServer.listen
  • SQLite3.open, PostgreSQL.open, MySQL.open
  • Process.run, Process.spawn
  • Signal.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.entries
  • network -- TCPSocket.connect, TCPServer.listen/listen_nonblocking, UDPSocket.bind/.open, TLSSocket.connect, TLSServer.listen
  • database -- SQLite3.open, PostgreSQL.open, MySQL.open (kept separate from filesystem/network even 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.spawn
  • signals -- Signal.trap. A handler is a process-wide side effect: it can redirect what SIGINT or SIGTERM does to the whole process, which is why it is not on by default under --sandbox. A program that has to run a shutdown handler needs DIAMOND_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:

budgetgrace after it trips (for each VM)
DIAMOND_MAX_INSTRUCTIONS1,000,000 more instructions
DIAMOND_MAX_WALL_MILLISECONDS1,000 ms past the budget's deadline
DIAMOND_MAX_MEMORY_BYTESa 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.

budgetoverride
DIAMOND_MAX_INSTRUCTIONSDIAMOND_GRACE_INSTRUCTIONS=N -- N more instructions
DIAMOND_MAX_WALL_MILLISECONDSDIAMOND_GRACE_WALL_MILLISECONDS=N -- N ms past the deadline
DIAMOND_MAX_MEMORY_BYTESDIAMOND_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, Supervisor child and ProgramBuilder#run add 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 ResourceLimitError at 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.poll and channel send/receive/select sleep in the kernel between instructions, so they clamp their wait to the budget's remaining time and raise ResourceLimitError (or hard-stop, past the grace allowance) when it runs out. A read on a file, pipe or socket is not interrupted, and Thread#join waits for the thread, which has its own budget check. Under --sandbox the I/O opcodes are denied before they can block.

  • Memory is what the VM's own accounting counts. Objects and the storage of Array and Hash (including growth by push, 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_THREADS lowers 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, every Thread/Supervisor child, and anything run via ProgramBuilder#run, with nothing to propagate from parent to child).
  • The program's own inputs are not restricted. ENV (a Hash of the real process environment, which is where deployment secrets usually live), ARGV and 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 require graph is not restricted. Sandbox mode restricts runtime behavior; the entry script itself (and whatever it requires) is assumed to already be source the caller chose to run. A require the program triggers at run time through ProgramBuilder#expand_source is a different thing: it is a file read, so it is denied with the filesystem category.

Revisit any of these only with a real driving need, not speculatively -- the same bar the roadmap already holds every other research direction to.

Edit this page on GitHub