diff --git a/README.md b/README.md index 75032b5..9aa2938 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/parenting-parent.el b/parenting-parent.el index 9bddbfd..65b2695 100644 --- a/parenting-parent.el +++ b/parenting-parent.el @@ -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 () @@ -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. @@ -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 @@ -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")) diff --git a/test/parenting-test.el b/test/parenting-test.el index 0fffc43..1511416 100644 --- a/test/parenting-test.el +++ b/test/parenting-test.el @@ -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