diff --git a/README.md b/README.md index 6fc9238..a9d8778 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,27 @@ Testing a fresh nix build is just: ``` For children that must run inside a sandbox (e.g. an Emacs-hosted -LLM agent), `:sandbox` builds a [bubblewrap](https://github.com/containers/bubblewrap) -jail for you: +LLM agent), `:sandbox` builds a jail for you. The spec says what the +child may reach; `parenting-sandbox-backend` picks what enforces it: + +- `bwrap` (the default on GNU/Linux): + [bubblewrap](https://github.com/containers/bubblewrap), a mount + namespace holding only the granted paths, so everything else is + absent. Needs unprivileged user namespaces. +- `landlock`: [Landlock](https://docs.kernel.org/userspace-api/landlock.html) + through the [landrun](https://github.com/Zouuup/landrun) launcher + (`parenting-sandbox-landlock-program`). No namespaces, so it works + where user namespaces are disabled. The host filesystem stays in + place: an ungranted path can still be probed for existence (`stat`, + `access`) but not read, listed or written. Landlock restricts TCP, + not UDP. +- `sandbox-exec` (the default on macOS): a deny-by-default Seatbelt + profile run with `sandbox-exec`, with the same visibility as + Landlock. This backend is tested as generated text only and has not + yet been run on macOS; `parenting-sandbox-exec-extra-rules` adds + rules a child turns out to need. +- a function, called with the spec, the socket directory and the + library directory, returning the command prefix. ```elisp (parenting-spawn @@ -145,6 +164,11 @@ jail for you: :network nil)) ; no network (the default) ``` +The rest of this section describes the bwrap backend; the others take +the same spec, grant paths in place (so a `(SRC . DEST)` remapping is +bwrap only), and give the child a HOME and a TMPDIR under the socket +directory instead of a private `/tmp`. + `: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` diff --git a/parenting-parent.el b/parenting-parent.el index 77e2164..f5f199d 100644 --- a/parenting-parent.el +++ b/parenting-parent.el @@ -180,7 +180,34 @@ 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 +;;; Sandboxing children + +;; A `:sandbox' spec (paths, environment, network) says what the child +;; may reach; a backend turns it into the command prefix that enforces +;; it. The spec is the same for every backend. Backends differ in one +;; way worth knowing: bwrap builds a mount namespace holding only the +;; granted paths, so everything else is absent, while Landlock and +;; Seatbelt leave the host filesystem in place and refuse access, so +;; an ungranted path can still be probed for existence (`stat', +;; `access') but not read, listed or written. + +(defcustom parenting-sandbox-backend + (if (eq system-type 'darwin) 'sandbox-exec 'bwrap) + "How `parenting-spawn' builds a `:sandbox' jail. +`bwrap' (the default on GNU/Linux): bubblewrap, a mount namespace +holding only the granted paths. Needs unprivileged user namespaces. +`landlock': Landlock through the landrun launcher +\(`parenting-sandbox-landlock-program'). No namespaces, so it works +where user namespaces are disabled; needs Landlock ABI 4 or later for +the network restriction. +`sandbox-exec' (the default on macOS): a Seatbelt profile run with +`parenting-sandbox-exec-program'. +A function is also accepted: it is called with the spec, the socket +directory and the library directory, as the built-in backends are, +and returns the command prefix." + :type '(choice (const bwrap) (const landlock) (const sandbox-exec) + function) + :group 'parenting) (defcustom parenting-sandbox-program "bwrap" "The bubblewrap executable used to build a `:sandbox' jail. @@ -226,6 +253,40 @@ both sides) or a cons (SRC . DEST)." (defun parenting--sandbox-command-wrapper (spec socket-directory library-directory) + "Return the command prefix enforcing SPEC, a `:sandbox' plist. +Dispatches on `parenting-sandbox-backend'; SOCKET-DIRECTORY and +LIBRARY-DIRECTORY are as for `parenting--sandbox-bwrap'." + (let ((backend parenting-sandbox-backend)) + (funcall (pcase backend + ('bwrap #'parenting--sandbox-bwrap) + ('landlock #'parenting--sandbox-landlock) + ('sandbox-exec #'parenting--sandbox-seatbelt) + ((pred functionp) backend) + (_ (error "Unknown `parenting-sandbox-backend': %S" backend))) + spec socket-directory library-directory))) + +(defun parenting--sandbox-same-path-binds (binds keyword) + "Return BINDS, signaling if any is a (SRC . DEST) cons. +KEYWORD names the spec field for the error. Only bwrap can mount a +path at a different location; the other backends grant paths where +they are." + (dolist (bind binds binds) + (when (consp bind) + (error "%s: the %S backend grants paths in place; %S needs bwrap" + keyword parenting-sandbox-backend bind)))) + +(defun parenting--sandbox-environment (spec home) + "Return the jail environment for SPEC as an alist of (NAME . VALUE). +HOME first, then `parenting-sandbox-default-environment', then each +`:environment' name that is set in this Emacs, in that order." + (append (list (cons "HOME" home)) + parenting-sandbox-default-environment + (delq nil (mapcar (lambda (var) + (let ((value (getenv var))) + (and value (cons var value)))) + (plist-get spec :environment))))) + +(defun parenting--sandbox-bwrap (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 @@ -298,6 +359,170 @@ and network sharing only when asked." (and value (list "--setenv" var value)))) environment)))) +(defcustom parenting-sandbox-landlock-program "landrun" + "The landrun executable used by the `landlock' sandbox backend. +landrun applies Landlock rules to the process it runs and then execs +it; see https://github.com/Zouuup/landrun." + :type 'string + :group 'parenting) + +(defcustom parenting-sandbox-device-files + '(("/dev/null" . rw) ("/dev/zero" . ro) ("/dev/urandom" . ro) + ("/dev/random" . ro) ("/dev/tty" . rw)) + "Device files granted by the Landlock and Seatbelt backends. +An alist of (PATH . ACCESS) with ACCESS `ro' or `rw'; files that do +not exist on the parent are skipped. bwrap gets its own /dev." + :type '(alist :key-type file :value-type (choice (const ro) (const rw))) + :group 'parenting) + +(defun parenting--sandbox-landlock (spec socket-directory library-directory) + "Return the landrun command prefix for SPEC, a `:sandbox' plist. +SOCKET-DIRECTORY and LIBRARY-DIRECTORY are as for +`parenting--sandbox-bwrap', granted read-write and read-only at +their own paths. Landlock grants paths where they are, so the spec's +binds must be plain paths. The child gets read and execute access to +the system directories that exist and the Nix store, the device files +in `parenting-sandbox-device-files', a HOME and a TMPDIR under the +socket directory (created by `parenting-spawn'), the environment of +`parenting--sandbox-environment' and nothing else, and no TCP unless +the spec's :network is non-nil. /proc is not granted. Landlock +restricts TCP only; UDP and other socket families stay open." + (let* ((home (expand-file-name "home" socket-directory)) + (tmp (expand-file-name "tmp" socket-directory)) + (ro-binds (parenting--sandbox-same-path-binds + (plist-get spec :ro-binds) :ro-binds)) + (rw-binds (parenting--sandbox-same-path-binds + (plist-get spec :rw-binds) :rw-binds)) + (flags (lambda (flag paths) + (cl-mapcan (lambda (path) (list flag path)) paths)))) + (append + (list parenting-sandbox-landlock-program) + (and (plist-get spec :network) (list "--unrestricted-network")) + (funcall flags "--rox" + (append (cl-remove-if-not + #'file-exists-p parenting-sandbox-system-directories) + (and (file-exists-p "/nix/store") '("/nix/store")))) + (funcall flags "--ro" (list library-directory)) + (funcall flags "--rw" (list socket-directory)) + (cl-mapcan (lambda (device) + (and (file-exists-p (car device)) + (list (if (eq (cdr device) 'rw) "--rw" "--ro") + (car device)))) + parenting-sandbox-device-files) + (funcall flags "--ro" ro-binds) + (funcall flags "--rw" rw-binds) + (cl-mapcan (lambda (pair) + (list "--env" (concat (car pair) "=" (cdr pair)))) + (append (parenting--sandbox-environment spec home) + (list (cons "TMPDIR" tmp)))) + (list "--")))) + +(defcustom parenting-sandbox-exec-program "sandbox-exec" + "The sandbox-exec executable used by the `sandbox-exec' backend." + :type 'string + :group 'parenting) + +(defcustom parenting-sandbox-exec-system-directories + '("/usr" "/bin" "/System" "/Library" "/private/etc" + "/private/var/db/timezone" "/opt/homebrew" "/Applications") + "Directories the `sandbox-exec' backend lets the child read. +The macOS counterpart of `parenting-sandbox-system-directories'; +the ones that exist on the parent are granted, plus the Nix store." + :type '(repeat directory) + :group 'parenting) + +(defcustom parenting-sandbox-exec-extra-rules nil + "Extra Seatbelt rules appended to the `sandbox-exec' profile. +A list of strings, each one complete rule such as +\"(allow mach-lookup (global-name \\\"com.apple.foo\\\"))\"." + :type '(repeat string) + :group 'parenting) + +(defun parenting--seatbelt-string (string) + "Return STRING as a quoted Seatbelt profile string literal." + (concat "\"" + (replace-regexp-in-string "[\"\\]" "\\\\\\&" string) + "\"")) + +(defun parenting--sandbox-seatbelt-profile (spec socket-directory + library-directory) + "Return the Seatbelt profile text enforcing SPEC. +Deny by default; allow process creation within the sandbox, reading +metadata everywhere (so path lookups work; see the note on visibility +above), reading the macOS system directories, the Nix store, +LIBRARY-DIRECTORY and the spec's :ro-binds, reading and writing +SOCKET-DIRECTORY, the device files and the spec's :rw-binds, and the +network only when the spec's :network is non-nil (the control socket, +a Unix socket under SOCKET-DIRECTORY, is always reachable)." + (let* ((ro-binds (parenting--sandbox-same-path-binds + (plist-get spec :ro-binds) :ro-binds)) + (rw-binds (parenting--sandbox-same-path-binds + (plist-get spec :rw-binds) :rw-binds)) + (subpaths (lambda (paths) + (mapconcat (lambda (path) + (format " (subpath %s)" + (parenting--seatbelt-string + (directory-file-name path)))) + paths ""))) + (devices (lambda (access) + (mapconcat (lambda (device) + (if (and (eq (cdr device) access) + (file-exists-p (car device))) + (format " (literal %s)" + (parenting--seatbelt-string + (car device))) + "")) + parenting-sandbox-device-files "")))) + (mapconcat + #'identity + (append + (list "(version 1)" + "(deny default)" + "(allow process-fork)" + "(allow process-exec)" + "(allow signal (target same-sandbox))" + "(allow sysctl-read)" + "(allow file-read-metadata)" + (format "(allow file-read*%s%s%s)" + (funcall subpaths + (append + (cl-remove-if-not + #'file-exists-p + parenting-sandbox-exec-system-directories) + (and (file-exists-p "/nix/store") + '("/nix/store")) + (list library-directory) + ro-binds)) + (funcall devices 'ro) + (funcall devices 'rw)) + (format "(allow file-read* file-write*%s%s)" + (funcall subpaths (cons socket-directory rw-binds)) + (funcall devices 'rw))) + (if (plist-get spec :network) + (list "(allow network*)") + (list (format "(allow network-outbound (remote unix-socket%s))" + (funcall subpaths (list socket-directory))))) + parenting-sandbox-exec-extra-rules) + "\n"))) + +(defun parenting--sandbox-seatbelt (spec socket-directory library-directory) + "Return the sandbox-exec command prefix for SPEC, a `:sandbox' plist. +The profile is `parenting--sandbox-seatbelt-profile'; the environment +is cleared with env -i and set as for the Landlock backend, with HOME +and TMPDIR under SOCKET-DIRECTORY. LIBRARY-DIRECTORY is readable at +its own path. This backend is covered by tests of the generated +command only; it has not yet been run on macOS." + (let ((home (expand-file-name "home" socket-directory)) + (tmp (expand-file-name "tmp" socket-directory))) + (append + (list parenting-sandbox-exec-program + "-p" (parenting--sandbox-seatbelt-profile + spec socket-directory library-directory) + "/usr/bin/env" "-i") + (mapcar (lambda (pair) (concat (car pair) "=" (cdr pair))) + (append (parenting--sandbox-environment spec home) + (list (cons "TMPDIR" tmp))))))) + ;;; Spawning children (defun parenting--child-library-directory () @@ -382,20 +607,21 @@ 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. -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'." +SANDBOX, when non-nil, is a plist describing a jail, which the backend +in `parenting-sandbox-backend' (bwrap, Landlock or macOS Seatbelt) +enforces; 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 has no network by default, a minimal +read-only view of the system, a private HOME and TMPDIR, and a +cleared environment. The control socket's directory is read-write at +its own path and the parenting sources read-only at their own path. +SANDBOX keywords: :ro-binds and :rw-binds are extra paths, each a +list of paths, or with bwrap also (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 allows the 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 @@ -428,8 +654,17 @@ host network. See `parenting--sandbox-command-wrapper'." ;; stays nil, so verified is t). (command-wrapper (if sandbox - (parenting--sandbox-command-wrapper - sandbox socket-directory library-directory) + (progn + ;; HOME and TMPDIR for the jail, on the host side of the + ;; read-write socket-directory grant: the Landlock and + ;; Seatbelt backends have no mount namespace to create + ;; them in, and the --dir of the bwrap backend finds + ;; them already there. Both go with the socket + ;; directory at shutdown. + (dolist (sub '("home" "tmp")) + (make-directory (expand-file-name sub socket-directory) t)) + (parenting--sandbox-command-wrapper + sandbox socket-directory library-directory)) command-wrapper)) (child-command (append (list emacs) diff --git a/test/parenting-test.el b/test/parenting-test.el index a13174f..b26cfc3 100644 --- a/test/parenting-test.el +++ b/test/parenting-test.el @@ -844,5 +844,136 @@ sandbox test skips rather than fails when it is unavailable." (should (parenting-eval conn '(file-directory-p (getenv "HOME")))) (should (parenting-eval conn '(file-writable-p (getenv "HOME")))))) +;;; Sandbox backends + +(ert-deftest parenting-sandbox-backend-dispatch () + ;; A function backend receives the spec and both directories and its + ;; return value is the prefix; an unknown symbol is an error. + (let ((parenting-sandbox-backend + (lambda (spec socket-directory library-directory) + (list "custom" (format "%S" spec) socket-directory + library-directory)))) + (should (equal '("custom" "(:network t)" "/s" "/l") + (parenting--sandbox-command-wrapper + '(:network t) "/s" "/l")))) + (let ((parenting-sandbox-backend 'no-such-backend)) + (should-error (parenting--sandbox-command-wrapper nil "/s" "/l")))) + +(ert-deftest parenting-sandbox-landlock-wrapper () + ;; landrun first and "--" last; the socket directory read-write, the + ;; library read-only, the store read-and-execute when present; HOME + ;; and TMPDIR under the socket directory; TCP only when asked. + (let* ((parenting-sandbox-backend 'landlock) + (args (parenting--sandbox-command-wrapper + '(:ro-binds ("/proj") :rw-binds ("/scratch")) + "/run/sock-dir" "/opt/parenting")) + (envs (parenting-test--flag-values args "--env"))) + (should (equal parenting-sandbox-landlock-program (car args))) + (should (equal "--" (car (last args)))) + (should (member "/run/sock-dir" (parenting-test--flag-values args "--rw"))) + (should (member "/scratch" (parenting-test--flag-values args "--rw"))) + (should (member "/opt/parenting" (parenting-test--flag-values args "--ro"))) + (should (member "/proj" (parenting-test--flag-values args "--ro"))) + (should (eq (and (member "/nix/store" + (parenting-test--flag-values args "--rox")) + t) + (and (file-exists-p "/nix/store") t))) + (should (member "HOME=/run/sock-dir/home" envs)) + (should (member "TMPDIR=/run/sock-dir/tmp" envs)) + (should-not (member "--unrestricted-network" args)) + (should (member "--unrestricted-network" + (parenting--sandbox-command-wrapper + '(:network t) "/s" "/l"))) + ;; Landlock grants paths in place: a remapping bind is an error. + (should-error (parenting--sandbox-command-wrapper + '(:ro-binds (("/src" . "/dest"))) "/s" "/l")))) + +(ert-deftest parenting-sandbox-landlock-environment () + ;; Only the named, set variables are carried in, after the baseline. + (let ((parenting-sandbox-backend 'landlock)) + (setenv "PARENTING_TEST_PASS" "carried") + (setenv "PARENTING_TEST_ABSENT_XYZZY" nil) + (unwind-protect + (let ((envs (parenting-test--flag-values + (parenting--sandbox-command-wrapper + '(:environment ("PARENTING_TEST_PASS" + "PARENTING_TEST_ABSENT_XYZZY")) + "/s" "/l") + "--env"))) + (should (member "PARENTING_TEST_PASS=carried" envs)) + (should-not (cl-find-if (lambda (e) + (string-prefix-p + "PARENTING_TEST_ABSENT_XYZZY" e)) + envs))) + (setenv "PARENTING_TEST_PASS" nil)))) + +(ert-deftest parenting-sandbox-seatbelt-wrapper () + ;; sandbox-exec with a deny-by-default profile, then env -i with the + ;; jail environment; the network only when asked, the control socket + ;; always. + (let* ((parenting-sandbox-backend 'sandbox-exec) + (args (parenting--sandbox-command-wrapper + '(:ro-binds ("/proj") :rw-binds ("/scratch")) + "/run/sock-dir" "/opt/parenting")) + (profile (nth 2 args))) + (should (equal (list parenting-sandbox-exec-program "-p") + (cl-subseq args 0 2))) + (should (equal '("/usr/bin/env" "-i") (cl-subseq args 3 5))) + (should (member "HOME=/run/sock-dir/home" args)) + (should (member "TMPDIR=/run/sock-dir/tmp" args)) + (should (string-match-p "^(deny default)$" profile)) + (should (string-match-p + "^(allow file-read\\* file-write\\* (subpath \"/run/sock-dir\") (subpath \"/scratch\")" + profile)) + (should (string-match-p "(subpath \"/opt/parenting\")" profile)) + (should (string-match-p "(subpath \"/proj\")" profile)) + (should (string-match-p + "^(allow network-outbound (remote unix-socket (subpath \"/run/sock-dir\")))$" + profile)) + (should-not (string-match-p "(allow network\\*)" profile)) + (should (string-match-p "^(allow network\\*)$" + (nth 2 (parenting--sandbox-command-wrapper + '(:network t) "/s" "/l")))))) + +(ert-deftest parenting-sandbox-seatbelt-quoting () + ;; Quotes and backslashes in a path cannot end the string literal. + (should (equal "\"/a\\\"b\\\\c\"" + (parenting--seatbelt-string "/a\"b\\c")))) + +(defun parenting-test--landrun-usable-p () + "Return non-nil if landrun can apply a Landlock ruleset here. +Kernels without Landlock, or without the network restriction the +backend asks for, make it fail; the live test then skips." + (and (executable-find parenting-sandbox-landlock-program) + (eq 0 (call-process parenting-sandbox-landlock-program nil nil nil + "--rox" "/" "--" "true")))) + +(ert-deftest parenting-spawn-landlock-child-roundtrips () + ;; A real child under Landlock: it answers over the socket, has a + ;; writable HOME, sees no variable it was not given, and cannot read + ;; a file outside its grants that this Emacs can. + (unless (parenting-test--landrun-usable-p) + (ert-skip "landrun cannot apply Landlock here")) + (let ((parenting-sandbox-backend 'landlock) + (secret (make-temp-file "parenting-landlock-secret"))) + (unwind-protect + (progn + (with-temp-file secret (insert "secret")) + (setenv "PARENTING_TEST_NOT_PASSED" "leak") + (parenting-with-child (conn :sandbox '(:network nil) :timeout 60) + (should (equal 3 (parenting-eval conn '(+ 1 2)))) + (should (parenting-eval conn '(file-writable-p (getenv "HOME")))) + (should-not (parenting-eval + conn '(getenv "PARENTING_TEST_NOT_PASSED"))) + (should (eq 'refused + (parenting-eval + conn `(condition-case nil + (with-temp-buffer + (insert-file-contents ,secret) + 'read) + (file-error 'refused))))))) + (setenv "PARENTING_TEST_NOT_PASSED" nil) + (delete-file secret)))) + (provide 'parenting-test) ;;; parenting-test.el ends here