Status: needs Aether 0.878.0 or later, the first release with
std.guest. All three guests run on the real calls,sandbox.spawn_socketandstd.ipc's frames (Aether 0.873.0, through aether#3510), and all three hosts drive their guest with Aether'sstd.guest; the Aether workers (RexxAE's andjs-worker) are built onstd.guest.child, and the Python guest speaks the same protocol throughpython/aether_guest.py. The Rexx worker is RexxAE's (onstd.guest.childsince its 0.878.0 pin); the JavaScript worker uses mquickjs-ae'smain. Discussion and decisions: aether#3503.
An Aether host runs a guest language in a separate, sandboxed child
process and talks to it both ways over std.ipc. The guest gets one
socket and nothing else, and asks the host for what it needs. A file comes
back as an open descriptor, not a path, so the guest never needs the
right to open anything.
There are three showcases with the same conversation, line for line:
| Showcase | The guest | What crosses into the child |
|---|---|---|
python/ |
stock python3, standard library only |
nothing: no bridge, no libpython ABI to match |
rexx/ |
RexxAE, run by RexxAE's own worker process | nothing: the worker is one binary with the engine compiled in |
js/ |
JavaScript on mquickjs-ae (the Aether port of Bellard's MicroQuickJS), run by js-worker |
nothing: the worker is one binary with the engine compiled in |
The JavaScript one is sae's case teased out. sae, the family's page
browser, runs pages' JavaScript, which nobody vetted, and today does it in
its own process on contrib.quickjs (quickjs-ng) with a time limit on each
entry into the page (sae began on mquickjs-ae and moved for modern
JavaScript). js/ is the out-of-process shape for it: untrusted JavaScript
in a child that locks itself down before the first line runs, reaches the
host only through functions the host mounted, and is cancelled from inside
the engine when a deadline passes.
Both hosts start their guest with sandbox.spawn_socket, which fails
closed: on macOS and Windows, or on Linux without Landlock, it starts
nothing. Running a guest uncontained is asked for by name
(make ... UNCONTAINED=1, which calls sandbox.spawn_socket_uncontained).
Coded, and observed working on CachyOS (Linux 7.3, Landlock in lsm=,
Python 3.14) with an Aether tree that had std.guest (the 0.878.0 release has it):
sandbox.spawn_socketfor both guests, failing closed. The Python guest getsgrant_runtime("python3")(its library, asked of python3 itself, no platform paths) and read access to its script; the Rexx worker gets no grants at all.- The kernel layer under it: Landlock and the wider seccomp filter. The Python raw-syscall probe is refused.
std.ipc's length-prefixed frames (ipc.write_frame,ipc.read_frame_timeout), a 4-byte big-endian length with a 64 KiB cap; the Python guest reads and writes the same header.- A granted file handed over as an open descriptor on the byte after a
GRANTframe, taken with one receive (ipc.recv, or Python'ssocket.recv_fds): noTAKEstep. The host end refuses descriptors sent up by a guest (std.ipc's rule). - A deadline on every exchange, from
std.guest: when it passes the host sendsCANCEL, and a guest that does not answer within the grace is killed. RexxAE's worker stops the script withrexxae_cancel, so a script waiting inHOST_HEARfor a message that never comes ends withcancelled by host(make rexxruns one). The Python guest's reader thread signals the main thread, which raisesCancelledwherever the program is, atime.sleep(3600)included (make pythonruns one). - RexxAE's generic worker (
../RexxAE/worker): any script, any mounted functions, onstd.guest.child, locking itself down withsandbox.enforceand no grants before its first step. - The protocol in Aether's standard library.
std.guest(the host's side) andstd.guest.child(an Aether worker's) were RexxAE'srexxworkerand the shared half of the two workers; the protocol is documented once, in Aether'sstd/guest/README.md. The Python guest speaks it withpython/aether_guest.py, standard library only, so all three hosts are the same program with other names. js-worker(js/worker.ae): mquickjs-ae's engine compiled in, onstd.guest.child, sojs/host.aedrives it withstd.guestasrexx/host.aedrives RexxAE's. It locks itself down the same way (child.run), before the first JavaScript runs, and is spawned with no grants. What is left inworker.aeis engine glue: the ROM's host functions, the mounts as JavaScript, and the interrupt hook.- Its standard library is baked at build time, as MicroQuickJS does
it:
js/romruns mquickjs-ae's ROM generator (itsgen/genengine, used as a library) on the core-only spec, the language plusconsole.log,printandperformance.now, with one global added,__host_call. A host's mounts are JavaScript wrappers round it that check their arity and pass their arguments as JSON; it refuses a name the host did not mount. - Cancel is in the engine. MicroQuickJS runs a script to the end in
one
JS_Eval, so there are no slices to step; instead it calls an interrupt handler every 10000 calls and backward jumps (JS_SetInterruptHandler), and the worker's looks for aCANCELon the socket without waiting. On one it returns 1 and the engine throws an error nocatchcan take. A script waiting on the host gets theCANCELas its reply.make jsruns awhile (true)that catches everything, cancelled at the deadline. make js-test(js/test_worker.ae) holds all of this, contained on Linux. With the interrupt handler not installed, its busy-loop case fails as it should:KILLED no answer to CANCELinstead of the cancel.
- Its standard library is baked at build time, as MicroQuickJS does
it:
- The host's policy (grant or refuse each request by name), and one
audit log (
build/audit.log) for spawns, GRANTs, DENYs, timeouts, cancels, kills and what the Rexx worker's own sandbox refused. - Everything a guest sends is escaped before it is printed or logged.
Left to do:
- macOS and Windows containment. Until then
spawn_socketrefuses there, and only the uncontained run, by name, works. Python has no cancel.Done:python/aether_guest.pytakes the host'sCANCELon a reader thread and interrupts the program, so a Python guest stops politely, and is killed only if it does not answer.The host module's home.Done:std.guestandstd.guest.childin Aether's standard library; nothing here reaches into RexxAE for it.- sae itself runs pages in its own process. Moving them into a
worker like
js-workerwould be on quickjs-ng (contrib.quickjs, whose time limit is the same hook), not on mquickjs-ae.
You need Aether 0.878.0 or later (the first release with std.guest), a RexxAE checkout next to
this one (../RexxAE) and python3 (3.9 or newer). For make js, also an
mquickjs-ae checkout next to this one (../mquickjs-ae, which js/mqjs
points at) and aeb (v0.333 or later), which builds js-worker as it
builds mquickjs-ae, with the engine compiled by the same Aether tree.
make python
make rexx
make js
make js-testTo use an Aether tree other than the one on PATH, pass both
AE=/path/to/aether/build/ae and AETHER_HOME=/path/to/aether.
make python runs the conversation, then a guest that goes quiet
(cancelled at the deadline, 5 s, in the middle of a time.sleep). make rexx builds RexxAE's worker, runs the
conversation, then a script stuck in HOST_HEAR (cancelled at the
deadline). make js builds js-worker, runs the conversation, then a
script in while (true) (cancelled at the deadline), a worker that never
answers (killed after the CANCEL's grace) and a frame over 64 KiB
(refused). On macOS all of them refuse to start a guest:
spawn refused: sandbox.spawn_socket: cannot contain a child on macOS, so nothing was started (sandbox.spawn_socket_uncontained runs one with no sandbox)
and make python UNCONTAINED=1 (or rexx) runs them with no process
sandbox, saying so on every run.
Tested contained on CachyOS (x86_64, Linux 7.3, Python 3.14) and, on
std.guest, on Ubuntu 22.04 (x86_64, Linux 5.15, Python 3.10), and
uncontained on macOS (arm64, Python 3.9).
js/mqjs is a relative symlink to ../../mquickjs-ae, with js/ae and
js/gen beside it (the engine's own imports and its source list resolve
from js/, where aeb runs); re-point js/mqjs to build against another
checkout. Nothing in mquickjs-ae changes.
Either side can start an exchange. The host asks the guest a question; the guest asks the host for files, and is given one and refused another. Then the guest tries to help itself. As observed on CachyOS, contained:
host -> python HELLO from the Aether host
python -> host HELLO from Python 3.14.9
host -> python ASK multiply 6 7
python -> host ANSWER 42
python -> host REQUEST config
host -> python GRANT config (then data/config.txt as an open descriptor)
python -> host SAY config says: greeting=hello from a file on the host's disk
python -> host REQUEST /etc/passwd
host -> python DENY /etc/passwd: not in this guest's policy
python -> host SAY the host said no: /etc/passwd: not in this guest's policy
python -> host PROBE open('/etc/passwd'): refused (Permission denied)
python -> host PROBE dlopen libc by path: refused (/usr/lib/libc.so.6)
python -> host PROBE raw syscall openat('/etc/passwd'): refused (Permission denied)
python -> host PROBE connect 1.1.1.1:443: refused (Operation not permitted)
python -> host DONE
host -> python BYE
python -> host BYE
The RexxAE transcript is the same apart from its probes, which go to the audit log, and the guests that stall:
rexx -> host PROBE ADDRESS SYSTEM cat /etc/passwd: RC 1
...
host -> python-stall ASK multiply 6 7
worker: ERROR cancelled by host: deadline of 5000 ms passed
...
host -> rexx-stuck HELLO from the Aether host
rexx-stuck -> host SAY waiting for a second message, which never comes
worker: ERROR cancelled by host: deadline of 5000 ms passed
The JavaScript transcript, contained on CachyOS:
host -> js HELLO from the Aether host
js -> host HELLO from MicroQuickJS (mquickjs-ae)
host -> js ASK multiply 6 7
js -> host ANSWER 42
js -> host REQUEST config
host -> js GRANT config (then data/config.txt as an open descriptor)
js -> host SAY config says: greeting=hello from a file on the host's disk
js -> host REQUEST /etc/passwd
host -> js DENY /etc/passwd: not in this guest's policy
js -> host SAY the host said no: not in this guest's policy
js -> host PROBE load('/etc/passwd'): refused (ReferenceError: variable 'load' is not defined)
js -> host PROBE require('fs'): refused (ReferenceError: variable 'require' is not defined)
js -> host PROBE std.loadFile / os.open (QuickJS's modules): refused (ReferenceError: variable 'std' is not defined)
js -> host PROBE new XMLHttpRequest(): refused (ReferenceError: variable 'XMLHttpRequest' is not defined)
js -> host PROBE fetch('https://1.1.1.1/'): refused (ReferenceError: variable 'fetch' is not defined)
js -> host PROBE __host_call('readFile', ...) past the mounts: refused (Error: readFile is not a function the host mounted)
js -> host DONE
host -> js BYE
js -> host BYE
worker: done, exit 0
...
host -> js-stuck HELLO from the Aether host
js-stuck -> host SAY spinning in while (true), catching everything
worker: ERROR cancelled by host: deadline of 5000 ms passed
...
host -> js-deaf HELLO from the Aether host
worker: KILLED no answer to CANCEL within 1000 ms
...
host -> js-big HELLO from the Aether host
worker: KILLED frame refused: ipc.read_frame: frame longer than max
And build/audit.log after the Python and Rexx runs, the host's decisions
and the Rexx worker's reports together:
--- session: python
SPAWN python3 (sandbox.spawn_socket)
python GRANT config -> data/config.txt
python DENY /etc/passwd: not in policy
--- session: python-stall
SPAWN python3 (sandbox.spawn_socket)
python-stall TIMEOUT no frame within 5000 ms; CANCEL sent
python-stall ERROR cancelled by host: deadline of 5000 ms passed
--- session: rexx
rexx SPAWN ../RexxAE/build/worker/rexxae-worker (sandbox.spawn_socket)
rexx reports: rexxae-worker locked itself down before the first step: no grants
rexx reports: probe worker fs.read('/etc/passwd'): refused (/etc/passwd: Permission denied)
rexx reports: probe worker tcp.connect 1.1.1.1:443: refused (connect failed)
rexx reports: rexxae-worker sandbox refused fs_read /etc/passwd
rexx reports: rexxae-worker sandbox refused tcp 1.1.1.1:443
rexx GRANT config -> data/config.txt
rexx DENY /etc/passwd: not in this guest's policy
rexx reports: rexxae-worker refused ADDRESS system: no environment is mounted
--- session: rexx-stuck
rexx-stuck SPAWN ../RexxAE/build/worker/rexxae-worker (sandbox.spawn_socket)
rexx-stuck reports: rexxae-worker locked itself down before the first step: no grants
rexx-stuck TIMEOUT no frame within 5000 ms; CANCEL sent
rexx-stuck ERROR cancelled by host: deadline of 5000 ms passed
and after the JavaScript runs:
--- session: js
js SPAWN js/target/build/bin/js-worker (sandbox.spawn_socket)
js reports: js-worker locked itself down before the first step: no grants
js reports: probe worker fs.read('/etc/passwd'): refused (/etc/passwd: Permission denied)
js reports: probe worker tcp.connect 1.1.1.1:443: refused (connect failed)
js reports: probe worker raw syscall instruction openat('/etc/passwd'), round std: refused (Permission denied)
js reports: js-worker sandbox refused fs_read /etc/passwd
js reports: js-worker sandbox refused tcp 1.1.1.1:443
js GRANT config -> data/config.txt
js DENY /etc/passwd: not in this guest's policy
--- session: js-stuck
js-stuck SPAWN js/target/build/bin/js-worker (sandbox.spawn_socket)
js-stuck reports: js-worker locked itself down before the first step: no grants
js-stuck TIMEOUT no frame within 5000 ms; CANCEL sent
js-stuck ERROR cancelled by host: deadline of 5000 ms passed
--- session: js-deaf
js-deaf SPAWN ../RexxAE/build/worker/silent (sandbox.spawn_socket)
js-deaf TIMEOUT no frame within 5000 ms; CANCEL sent
js-deaf KILLED no answer to CANCEL within 1000 ms
--- session: js-big
js-big SPAWN js/target/build/bin/js-worker (sandbox.spawn_socket)
js-big reports: js-worker locked itself down before the first step: no grants
js-big KILLED frame refused: ipc.read_frame: frame longer than max
The worker that never answers is RexxAE's stand-in (tests/worker_silent),
which reads every frame and replies to none. js-worker itself cannot be
made to ignore a CANCEL from JavaScript: the interrupt handler is the
engine's, and its error cannot be caught. What the kill is for is a worker
that has stopped answering for another reason, a bug or a hang in native
code.
All three hosts are the same program with other names: they drive their
guest with Aether's std.guest.
The Python host (python/host.ae):
perms = sandbox.new("python-guest") {
grant_runtime("python3") // asked of python3 itself
grant_fs_read("${os.getcwd()}/python/guest.py")
grant_fs_read("${os.getcwd()}/python/aether_guest.py")
}
policy = |name: string| { // what the guest may ask for
if name == "config" { return "data/config.txt" }
return ""
}
w, err = guest.launch(perms, "python3", argv, "python", childbox.AUDIT_LOG) // -I python/guest.py
guest.mount(&w, "host_hear", 0, 0) // and host_tell, host_request
guest.start(&w, "chat")
e = guest.next(&w) // SAY, CALL, DONE, ERROR, KILLED
There are two kinds of permission, and they are kept apart on purpose:
- Grants say what the guest may do on its own: the least it needs to start. The process sandbox enforces them, down to the kernel.
- The policy says what the guest may ask for. The host decides each request, and hands over a descriptor rather than a path.
Both kinds end up in the one audit log.
The Rexx host (rexx/host.ae) drives RexxAE's worker:
perms = sandbox.new("rexx-guest") {} // the worker needs nothing
w, err = guest.launch(perms, prog, null, "rexx", childbox.AUDIT_LOG)
guest.mount(&w, "HOST_HEAR", 0, 0) // and HOST_TELL, HOST_REQUEST
guest.start(&w, source)
e = guest.next(&w) // SAY, CALL, DONE, ERROR, KILLED
The JavaScript host (js/host.ae) is the Rexx host with other names:
perms = sandbox.new("js-guest") {} // the worker needs nothing
w, err = guest.launch(perms, "js/target/build/bin/js-worker", null, "js", childbox.AUDIT_LOG)
guest.mount(&w, "hostHear", 0, 0) // and hostTell, hostRequest
guest.start(&w, source)
e = guest.next(&w) // SAY, CALL, DONE, ERROR, KILLED
The guests, in their own languages:
- Python (
python/guest.py):aether_guest.open("python-guest").run(main), and the three mounted functions as Python callables:g.function("host_request")("config")returns{"granted": True, "text": ...}or{"granted": False, "why": ...}.python/aether_guest.pyis the Python twin ofstd.guest.child, standard library only: frames on the descriptor named inAETHER_IPC_FD,socket.recv_fdsfor a granted file, and a reader thread forCANCEL. - Rexx (
rexx/chat.rexx): RexxAE has no system API of its own. The host mounts three functions in the worker,HOST_HEAR(),HOST_TELL(line)andHOST_REQUEST(name), and each is a frame on the socket. NoADDRESSenvironment is mounted. The worker runs the script inside its ownsandbox.enforcewith no grants (RexxAE README, "Worker process"). - JavaScript (
js/chat.js): the same, withhostHear(),hostTell(line)andhostRequest(name).hostRequestreturns an object,{granted: true, text: ...}or{granted: false, why: ...}.printandconsole.logareSAYlines.
The shared part is Aether's std.guest (the spawn, the frames with
their deadline, cancel and kill, the descriptor hand-over, escaping, the
audit log's lines) and, for the showcase, lib/childbox: what the host
says in the conversation, which is the same for every guest.
sandbox.spawn_socket(perms: sandbox.Grants, prog: string, argv: ptr, env: ptr) -> (int, int, string)
os.run_socket's channel (fd 3, AETHER_IPC_KIND=socket) with
spawn_sandboxed's containment and a kernel layer, held to perms. It
fails closed: where it cannot contain the child it refuses to start it.
sandbox.spawn_socket_uncontained is the explicit alternative.
What each probe reports. "Contained" is observed on CachyOS (Linux 7.3, Landlock and seccomp); "Preload only" is what the LD_PRELOAD layer alone would allow, from its code:
| Probe | Uncontained (macOS) | Preload only | Contained (Linux, observed) |
|---|---|---|---|
Python open('/etc/passwd') |
ALLOWED | refused | refused (Permission denied) |
Python ctypes.CDLL of libc by its absolute path |
skipped (Linux only) | refused | refused (the preload) |
Python raw syscall(openat) via ctypes, libc's syscall found in its symbol table in memory |
skipped (Linux only) | ALLOWED | refused (Permission denied: Landlock) |
Python connect 1.1.1.1:443 |
ALLOWED | refused | refused (Operation not permitted: seccomp) |
Python guest silent after HELLO (in time.sleep) |
cancelled by host at the deadline |
cancelled by host |
cancelled by host at the deadline (5 s) |
Rexx ADDRESS SYSTEM |
RC 1 | RC 1 | RC 1 (the engine's own wall) |
Rexx worker fs.read / tcp.connect |
refused | refused | refused (the worker's own enforce) |
Rexx script waiting in HOST_HEAR |
cancelled by host at the deadline |
cancelled by host |
cancelled by host at the deadline (5 s) |
JS load, require, std/os, XMLHttpRequest, fetch |
ReferenceError | ReferenceError | ReferenceError (the engine's own wall: not in its ROM) |
| JS FFI, native modules, bytecode loading | none in MicroQuickJS | none | none: there is nothing to probe |
JS __host_call with a name the host did not mount |
refused | refused | refused (the worker) |
JS worker fs.read / tcp.connect |
refused | refused | refused (the worker's own enforce) |
JS worker open round std: on Linux the raw openat instruction, round libc and the preload; on macOS libc's open |
ALLOWED (enforce alone is std's) |
ALLOWED | refused (Permission denied: Landlock) |
JS while (true) catching every exception |
cancelled by host at the deadline |
cancelled by host |
cancelled by host at the deadline (5 s) |
| JS worker that never answers (the stand-in) | killed after the CANCEL's grace |
killed | killed (1 s after the CANCEL) |
JS print of 70000 bytes |
frame refused, worker killed | refused | frame refused, worker killed |
The raw-syscall row is the reason for a kernel layer. LD_PRELOAD only sees
calls that go through libc, and any interpreter with an FFI (Python's
ctypes, LuaJIT's FFI, Ruby's fiddle) can go round it. The preload
refuses a handle on libc however it is named (by name, by path, through a
symlink), so the probe reads libc's dynamic symbol table in memory, finds
syscall there, and calls it at that address. Landlock and the seccomp filter cannot be gone round this
way, and neither needs root or user namespaces.
The questions this README first asked were answered by Adil on aether#3503. In short:
-
Name and home:
sandbox.spawn_socket(perms, ...). Containment is the point, so it lives instd.sandbox; an optionalpermsonos.run_socketwould make a forgotten one silent. -
One receive returning
(data, fd or -1, err), which removes theTAKEstep. With it, a direction rule: the host refuses descriptors sent up by a guest. -
Framing: length-prefixed with a maximum frame size, not lines, written once in
std.ipc. Adil will write a chunked fuzz target against it. (The showcase usesstd.ipc's frames now.) -
Start-up grants, two answers for two kinds of guest:
- an Aether worker locks itself down after it starts, with
sandbox.enforcebefore the first step (done here); - a foreign interpreter gets a preset such as
grant_runtime("python3"). Not one API for both.
- an Aether worker locks itself down after it starts, with
-
The kernel layer, in order:
- Landlock first, which on its own refuses the raw
openatprobe; - then a seccomp filter wider than today's process-creation fence (at
least socket, connect, execve,
io_uring_setup, ptrace); - a seccomp user-notify broker last, if ever, since it mostly repeats the policy the protocol already has.
Landlock's network rules need Linux 6.7, so older kernels still need seccomp to refuse connect.
- Landlock first, which on its own refuses the raw
-
Policy shape: keep declarative grants and a closure for requests, with every GRANT and DENY in the audit log (done here).
-
macOS and Windows: fail closed, as
spawn_sandboxeddoes on macOS today. Uncontained only on an explicit request from the host (done here, by name). -
A generic RexxAE worker: yes, enforcing on itself, with a deadline on each exchange and a cancel in the engine. Done: RexxAE's
worker/, used byrexx/host.ae.
send,reply,spawnandmessageare reserved words (actors), and so istry, so the natural names for protocol code fail to compile. Here they aretell,hear,answerandstart_guest.- The preload's
dlopencheck refused only the bare nameslibc.so.6andlibc.so; libc by its path loaded. Fixed in 0.873.0 (aether#3510): libc and the loader are matched by basename, by where a path leads, and by the handle the loader returns. The preload alone is still not a wall against an FFI (the raw-syscall row), which is what Landlock is for. ae build --lib Xreplaces the defaultlib/search path, so a program that uses a compiled library and a local module needs--lib lib --lib X(aether#3504).- A closure passed as
fnhas no known result type, so the caller writesstring path = policy(name). - Passing a struct where another module's function takes a pointer to it is not caught by Aether's checker; the C compiler stops it instead.
- RexxAE ends a
WHEN ... THEN DOblock at the nextWHENorOTHERWISE, not at its ownEND, and a classicTHEN DO ... ENDfollowed byOTHERWISEfails to parse.rexx/chat.rexxusesIFblocks instead. This needs checking against RexxJS. - A RexxAE library built by a different Aether than the program refuses to link, with a clear message naming both versions. Hence the note under "Run it".
- The guest-supplied request name was printed unescaped (Adil's catch);
everything from a guest now goes through
guest.printable. - The preload replaces libc's
syscall()and refuses it outright when sandboxed, so a raw-syscall probe written in C has to use the instruction itself (js/rom.c), as the Python one findssyscallin libc's symbol table. Uncontained on Linux that probe opens/etc/passwd: the worker's ownenforceis std's checks, not a kernel wall. - MicroQuickJS names its host functions as C symbols in a ROM generated at
build time, so a worker cannot add a global from C at run time. One
fixed native function and JavaScript wrappers for each mount do it
instead, and keep the protocol's
MOUNTworking unchanged. - MicroQuickJS refuses two
catch (e)with the same name at the top level of one script ("catch variable already exists"). Upstream does the same (Bellard's7ea5399), so the port is faithful; a test script uses two names.