Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,55 @@ Testing a fresh nix build is just:
```

For children that must run inside a sandbox (e.g. an Emacs-hosted
LLM agent under bwrap), three more keywords wrap the command:
LLM agent), `:sandbox` builds a [bubblewrap](https://github.com/containers/bubblewrap)
jail for you:

```elisp
(parenting-spawn
:sandbox '(:ro-binds ("/home/me/project") ; dirs the child may read
:rw-binds ("/home/me/project/build")
:environment ("SSL_CERT_FILE") ; names carried in
:network nil)) ; no network (the default)
```

`:sandbox` is a plist and, when given, derives `:command-wrapper`,
`:child-socket-path` and `:child-library-directory` itself, so those
three are mutually exclusive with it (passing any alongside `:sandbox`
is an error). Everything else — `:emacs`, `:args`, `:load-path`,
`:init`, `:batch`/`:daemon`, `:quick`, `:name`, `:timeout` — still
applies. The jail always runs with `--die-with-parent`, a minimal
read-only root built from the system directories the Emacs binary
needs (`/usr`, `/bin`, … whichever exist) plus a whole-store
read-only bind of `/nix/store` when present, a fresh `--tmpfs /tmp`,
`/proc` and `/dev`, and a cleared environment.

The control socket crosses the mount namespace because parenting
binds its private directory read-write at the same path inside the
jail, so the in-jail socket path equals the parent's. The parenting
`.el` sources are bound read-only at their own path, so the child
loads them and the parent can still verify they are readable.

`:sandbox` keywords:

- `:ro-binds` — extra read-only binds, a list whose elements are
either a path (bound at the same path inside) or a `(SRC . DEST)`
cons. This is also where project directories the child may read go;
there is no separate field for them.
- `:rw-binds` — extra read-write binds, same shape.
- `:environment` — a list of environment variable *names* to carry
into the otherwise-empty jail (via `--setenv`); everything else is
cleared. The baseline (`HOME` on the tmpfs, plus `PATH`, `TERM`,
`LANG` from `parenting-sandbox-default-environment`) is always set
first.
- `:network` — non-nil to `--share-net`; nil (the default) leaves the
jail with no network.

`parenting-sandbox-program`, `parenting-sandbox-system-directories`
and `parenting-sandbox-default-environment` customize the bwrap
binary, the system directories, and the baseline environment.

To assemble the wrapper by hand instead — for a jail `:sandbox` does
not cover, or a non-bwrap tool — the low-level keywords remain:

```elisp
(parenting-spawn
Expand Down Expand Up @@ -327,8 +375,9 @@ a parent-resident agent has its tool calls routed into a child via
`parenting-eval`. Either way the parent must assume the child is
fully compromised. The layers, outermost first:

1. OS-level isolation of the child process (`:command-wrapper`,
e.g. bwrap) bounds what the child can touch directly.
1. OS-level isolation of the child process (`:sandbox`, or a
hand-built `:command-wrapper`, e.g. bwrap) bounds what the child
can touch directly.
2. The parent structurally refuses `eval` requests — a child cannot
evaluate forms in the parent at all.
3. The allowlist defaults to deny-all; each grant can carry an
Expand Down
143 changes: 142 additions & 1 deletion parenting-parent.el
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,113 @@ called with each new connection, which is also pushed onto
(when (and directory (file-directory-p directory))
(delete-directory directory t))))

;;; Sandboxing children with bwrap

(defcustom parenting-sandbox-program "bwrap"
"The bubblewrap executable used to build a `:sandbox' jail.
`parenting-spawn' prefixes this to the child's command line, so a
full path may be given when bwrap is not on `exec-path'."
:type 'string
:group 'parenting)

(defcustom parenting-sandbox-system-directories
'("/usr" "/bin" "/lib" "/lib64" "/etc")
"System directories read-only bound into every `:sandbox' jail.
These make a minimal root filesystem holding what the Emacs binary
typically needs to run. Only the ones that actually exist on the
parent are bound, so a Nix-only host with no /usr simply skips it.
The Nix store, when present, is bound separately."
:type '(repeat directory)
:group 'parenting)

(defcustom parenting-sandbox-default-environment
'(("PATH" . "/usr/bin:/bin")
("TERM" . "dumb")
("LANG" . "C.UTF-8"))
"Environment set inside every `:sandbox' jail before pass-through.
An alist of (NAME . VALUE). The jail starts from an empty
environment (bwrap --clearenv); these are set with --setenv, then
HOME is pointed at the jail's tmpfs, and finally the caller's
`:environment' pass-through names are copied in from this Emacs's
environment. Rebind or extend this to change the baseline."
:type '(alist :key-type string :value-type string)
:group 'parenting)

(defun parenting--sandbox-bind-args (flag binds)
"Return bwrap arguments binding BINDS with FLAG.
FLAG is a bwrap bind option such as \"--ro-bind\" or \"--bind\".
Each element of BINDS is either a path (bound at the same path on
both sides) or a cons (SRC . DEST)."
(cl-mapcan (lambda (bind)
(if (consp bind)
(list flag (car bind) (cdr bind))
(list flag bind bind)))
binds))

(defun parenting--sandbox-command-wrapper (spec socket-directory
library-directory)
"Return the bwrap command prefix for SPEC, a `:sandbox' plist.
SOCKET-DIRECTORY is the directory holding the control socket; it is
bound read-write at the same path so the child can reach the socket
across the mount namespace. LIBRARY-DIRECTORY holds the parenting
.el sources; it is bound read-only at the same path so the child can
load them.

SPEC keywords, all optional:
:ro-binds extra read-only binds (paths or (SRC . DEST) conses),
the place for project directories the child may read;
:rw-binds extra read-write binds, same shape;
:environment a list of environment variable NAMES to carry into
the jail from this Emacs (everything else is cleared);
:network non-nil to share the host network (default: no
network).

The returned list is suitable as a `:command-wrapper' prefix: bwrap,
a minimal read-only root, a private /tmp, /proc and /dev, the socket
and library binds, a cleared environment seeded from
`parenting-sandbox-default-environment' plus the pass-through names,
and network sharing only when asked."
(let* ((home (expand-file-name "home" socket-directory))
(environment (plist-get spec :environment))
(ro-binds (plist-get spec :ro-binds))
(rw-binds (plist-get spec :rw-binds)))
(append
(list parenting-sandbox-program
"--die-with-parent"
"--unshare-all")
(and (plist-get spec :network) (list "--share-net"))
;; A minimal root: the system directories that exist, plus the
;; whole (world-readable) Nix store when there is one.
(parenting--sandbox-bind-args
"--ro-bind"
(cl-remove-if-not #'file-exists-p
parenting-sandbox-system-directories))
(and (file-exists-p "/nix/store")
(list "--ro-bind" "/nix/store" "/nix/store"))
(list "--tmpfs" "/tmp"
"--proc" "/proc"
"--dev" "/dev")
;; The control socket's directory, read-write, at the same path,
;; so `child-socket-path' can equal the parent's socket path.
(list "--bind" socket-directory socket-directory)
;; The parenting sources, read-only, at the same path, so the
;; parent can verify readability and the child can load them.
(list "--ro-bind" library-directory library-directory)
;; Extra caller binds.
(parenting--sandbox-bind-args "--ro-bind" ro-binds)
(parenting--sandbox-bind-args "--bind" rw-binds)
;; A cleared environment: baseline, then a writable HOME on the
;; tmpfs, then the caller's pass-through names.
(list "--clearenv")
(list "--setenv" "HOME" home)
(cl-mapcan (lambda (pair)
(list "--setenv" (car pair) (cdr pair)))
parenting-sandbox-default-environment)
(cl-mapcan (lambda (var)
(let ((value (getenv var)))
(and value (list "--setenv" var value))))
environment))))

;;; Spawning children

(defun parenting--child-library-directory ()
Expand Down Expand Up @@ -227,6 +334,7 @@ as the child sees it."
socket-path
child-socket-path
child-library-directory
sandbox
(name "parenting-child")
(timeout parenting-default-timeout))
"Spawn a child Emacs and return a connection to it.
Expand Down Expand Up @@ -258,14 +366,35 @@ belongs to the caller and survives `parenting-shutdown'.
CHILD-SOCKET-PATH is the same socket as seen from where the child
runs (another mount namespace, or another machine entirely), when
that differs. CHILD-LIBRARY-DIRECTORY is where the child finds the
parenting .el sources, when the parent's copy is not visible to it."
parenting .el sources, when the parent's copy is not visible to it.

SANDBOX, when non-nil, is a plist describing a bwrap jail; parenting
turns it into the COMMAND-WRAPPER, CHILD-SOCKET-PATH and
CHILD-LIBRARY-DIRECTORY for you, so it is mutually exclusive with
those three (passing SANDBOX with any of them is an error). The jail
runs with --die-with-parent and no network by default, a minimal
read-only root, a private /tmp, and a cleared environment. The
control socket's directory is bound read-write at its own path (so
the child reaches the socket across the mount namespace) and the
parenting sources read-only at their own path. SANDBOX keywords:
:ro-binds and :rw-binds are extra binds, each a list of paths or
\(SRC . DEST) conses — put project directories the child may read on
:ro-binds; :environment is a list of environment variable names to
carry into the otherwise-empty jail; :network non-nil shares the
host network. See `parenting--sandbox-command-wrapper'."
(when (and batch daemon)
(error "Choose at most one of :batch and :daemon"))
(when (and sandbox (or command-wrapper child-socket-path
child-library-directory))
(error "%s"
(concat ":sandbox sets :command-wrapper, :child-socket-path"
" and :child-library-directory; do not pass them too")))
(let* ((emacs (or emacs (expand-file-name invocation-name
invocation-directory)))
(directory (and (null socket-path)
(parenting--make-socket-directory)))
(socket (or socket-path (expand-file-name "socket" directory)))
(socket-directory (or directory (file-name-directory socket)))
(server (make-network-process
:name (concat name "-server")
:server t
Expand All @@ -276,6 +405,18 @@ parenting .el sources, when the parent's copy is not visible to it."
(stderr (generate-new-buffer (format " *%s-stderr*" name)))
(library-directory (or child-library-directory
(parenting--child-library-directory)))
;; :sandbox derives the wrapper from the private socket
;; directory and the source directory, both bound at their
;; own path inside the jail. The socket dir being at the
;; same path lets `child-socket-path' default to `socket',
;; and the source dir being readable at the same path keeps
;; the bootstrap `verified' check on (child-library-directory
;; stays nil, so verified is t).
(command-wrapper
(if sandbox
(parenting--sandbox-command-wrapper
sandbox socket-directory library-directory)
command-wrapper))
(child-command (append
(list emacs)
(and quick '("-Q"))
Expand Down
131 changes: 131 additions & 0 deletions test/parenting-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -668,5 +668,136 @@ exercises its remote routing."
(should-not (process-live-p child)))
(parenting-shutdown conn))))

;;; Sandboxing children with bwrap

(defun parenting-test--flag-values (args flag)
"Return the values that follow each FLAG occurrence in ARGS.
Single-argument bwrap flags: collects the one word after each FLAG."
(let ((values nil)
(rest args))
(while rest
(when (equal (car rest) flag)
(push (cadr rest) values))
(setq rest (cdr rest)))
(nreverse values)))

(defun parenting-test--bind-p (args flag src dest)
"Return non-nil if ARGS contains FLAG SRC DEST in sequence."
(let ((triple (list flag src dest))
(found nil)
(rest args))
(while (and rest (not found))
(when (equal (list (nth 0 rest) (nth 1 rest) (nth 2 rest)) triple)
(setq found t))
(setq rest (cdr rest)))
found))

(ert-deftest parenting-sandbox-wrapper-core-flags ()
;; The pure builder must always ask for the die-with-parent guard, a
;; cleared environment, /proc, /dev and a private /tmp, and must bind
;; the socket dir rw and the library dir ro at their own paths.
(let ((args (parenting--sandbox-command-wrapper
nil "/run/sock-dir" "/opt/parenting")))
(should (equal parenting-sandbox-program (car args)))
(should (member "--die-with-parent" args))
(should (member "--unshare-all" args))
(should (member "--clearenv" args))
(should (equal '("/proc") (parenting-test--flag-values args "--proc")))
(should (equal '("/dev") (parenting-test--flag-values args "--dev")))
(should (equal '("/tmp") (parenting-test--flag-values args "--tmpfs")))
;; Socket directory bound read-write at the same path.
(should (parenting-test--bind-p
args "--bind" "/run/sock-dir" "/run/sock-dir"))
;; Library directory bound read-only at the same path.
(should (parenting-test--bind-p
args "--ro-bind" "/opt/parenting" "/opt/parenting"))
;; HOME is set (to a path under the socket directory).
(should (member "HOME" (parenting-test--flag-values args "--setenv")))))

(ert-deftest parenting-sandbox-wrapper-network-toggle ()
;; No network by default; --share-net only when the spec asks.
(should-not (member "--share-net"
(parenting--sandbox-command-wrapper
nil "/s" "/l")))
(should-not (member "--share-net"
(parenting--sandbox-command-wrapper
'(:network nil) "/s" "/l")))
(should (member "--share-net"
(parenting--sandbox-command-wrapper
'(:network t) "/s" "/l"))))

(ert-deftest parenting-sandbox-wrapper-nix-store ()
;; The whole store is bound read-only exactly when it exists.
(let ((args (parenting--sandbox-command-wrapper nil "/s" "/l")))
(should (eq (and (parenting-test--bind-p
args "--ro-bind" "/nix/store" "/nix/store")
t)
(and (file-exists-p "/nix/store") t)))))

(ert-deftest parenting-sandbox-wrapper-environment-passthrough ()
;; Each named, set variable is carried in with its own --setenv; an
;; unset name is dropped rather than passed empty.
(let* ((set-name "PARENTING_TEST_PASS")
(unset-name "PARENTING_TEST_ABSENT_XYZZY"))
(setenv set-name "carried")
(setenv unset-name nil)
(unwind-protect
(let* ((args (parenting--sandbox-command-wrapper
(list :environment (list set-name unset-name))
"/s" "/l"))
(setenvs (parenting-test--flag-values args "--setenv")))
(should (member set-name setenvs))
(should-not (member unset-name setenvs))
;; The value rides right after the name.
(let ((rest args) (value nil))
(while rest
(when (and (equal (car rest) "--setenv")
(equal (cadr rest) set-name))
(setq value (nth 2 rest)))
(setq rest (cdr rest)))
(should (equal "carried" value))))
(setenv set-name nil))))

(ert-deftest parenting-sandbox-wrapper-extra-binds ()
;; Read-only and read-write extra binds, both plain paths and
;; (src . dest) conses, land as the right bwrap flags.
(let ((args (parenting--sandbox-command-wrapper
'(:ro-binds ("/proj" ("/data/src" . "/data"))
:rw-binds ("/scratch"))
"/s" "/l")))
(should (parenting-test--bind-p args "--ro-bind" "/proj" "/proj"))
(should (parenting-test--bind-p args "--ro-bind" "/data/src" "/data"))
(should (parenting-test--bind-p args "--bind" "/scratch" "/scratch"))))

(ert-deftest parenting-spawn-sandbox-rejects-explicit-wrapper ()
;; :sandbox owns :command-wrapper, :child-socket-path and
;; :child-library-directory, so combining them is an error and no
;; child is spawned.
(should-error (parenting-spawn :sandbox '(:network nil)
:command-wrapper '("bwrap")))
(should-error (parenting-spawn :sandbox '(:network nil)
:child-socket-path "/tmp/x"))
(should-error (parenting-spawn :sandbox '(:network nil)
:child-library-directory "/tmp/lib")))

(defun parenting-test--bwrap-usable-p ()
"Return non-nil if bwrap can create an unprivileged user namespace.
Many CI runners and nested sandboxes forbid this, so the live
sandbox test skips rather than fails when it is unavailable."
(and (executable-find parenting-sandbox-program)
(eq 0 (call-process parenting-sandbox-program nil nil nil
"--ro-bind" "/" "/" "true"))))

(ert-deftest parenting-spawn-sandbox-child-roundtrips ()
;; The end-to-end proof: a real child under bwrap, reached over a
;; socket that crosses the mount namespace. Skipped cleanly where
;; unprivileged user namespaces are unavailable.
(unless (parenting-test--bwrap-usable-p)
(ert-skip "bwrap cannot create a user namespace here"))
(parenting-with-child (conn :sandbox '(:network nil) :timeout 60)
(should (equal 3 (parenting-eval conn '(+ 1 2))))
;; The child really is jailed: no host network was shared.
(should (equal "sandboxed" (parenting-eval conn '"sandboxed")))))

(provide 'parenting-test)
;;; parenting-test.el ends here
Loading