cl-exec-sandbox is a policy-driven process sandboxing library for Common
Lisp. Its policy model provides:
- read, write, and deny filesystem rules
- literal paths, deny globs, and portable special roots
- more-specific nested policy overrides
- read-only host and workspace-write presets
- protected project metadata such as
.git - full, isolated, and managed proxy-only network modes
- process, user, IPC, UTS, and network namespace isolation on Linux
- fresh
/procand minimal/devmounts - child timeouts, cancellation, bounded raw-byte capture and retained files
- explicit capability discovery
One policy is translated by a per-host backend. A policy a backend cannot
enforce signals sandbox-unavailable naming the missing capability. Query
sandbox-capabilities or sandbox-supported-p before relying on a capability.
The policy surface and Linux enforcement model were checked against OpenAI
Codex commit 2e1607ee2fa8099a233df7437adee5f16a741905. Codex is a reference.
The Linux backend targets x86-64 and SBCL. It uses the system bwrap binary
for filesystem and namespace isolation, plus a private helper for
no_new_privs, seccomp, and managed proxy routing. The helper lives beside
the Lisp sources.
The macOS backend runs commands under Seatbelt through /usr/bin/sandbox-exec,
generating a profile from the same policy. Resolved rules are emitted from the
broadest to the most specific, so Seatbelt’s last-match-wins resolution
reproduces nested overrides and protected metadata. Whole-root rules are
emitted before device access, so a read-only root still permits /dev. A
rule naming a single file becomes a literal filter.
On macOS:
:enabledand:isolatednetworking worksandbox-capabilitiesreports:process-namespacesas false- process separation is an accidental-damage boundary
Deny globs need rg on either host. Seatbelt installations outside /usr/bin
can set CL_EXEC_SANDBOX_SEATBELT to an absolute executable path.
The experimental Windows backend combines a fresh AppContainer identity with
an invocation-specific restricted-token SID for deny rules and a kill-on-close
Job Object for descendants. Run it as a standard Windows user. Build
scripts/build-windows-helper.ps1 from a Visual Studio SDK developer shell
with Clang installed, then ship
build/cl-exec-sandbox-windows.exe beside the Lisp sources. Alternatively, set
CL_EXEC_SANDBOX_WINDOWS_HELPER to its absolute path outside writable scopes.
Use appcontainer-sandbox-policy with explicit existing :read-roots and
:workspace-roots. Include the command’s binaries and dependencies in the read
scopes. Windows also supplies its normal AppContainer system-resource access
and private profile storage. Networking is isolated, including loopback.
(let ((policy
(cl-exec-sandbox:appcontainer-sandbox-policy
:workspace-roots (list #P"C:/work/project/")
:read-roots (list #P"C:/tools/"))))
(cl-exec-sandbox:run-sandboxed
"C:/tools/tool.exe" '("--version")
:policy policy :working-directory #P"C:/work/project/" :timeout 30))The backend rejects whole-host policies, network modes other than :isolated,
proc mounts, PID namespaces, globs, drive roots, UNC/device paths, reparse
points, hard-linked files, and paths of 240 characters
or more. The default protected metadata names are .git, .agents, and .codex.
This backend is not policy-equivalent to the Linux whole-host presets.
Filesystem grants are temporary SID-specific ACL entries. Native execution
and ACL changes are serialized within a Windows session. Serialize calls with
overlapping roots as well, including plan construction and cleanup of missing
metadata directories. Keep the trees and ACLs free of concurrent changes by
other host processes during a run.
Normal completion and run-sandboxed cancellation remove the grants and profile;
cleanup failures are reported. Terminating both the Lisp supervisor and helper
can leave stale SID entries and a profile. This prototype has no persistent
crash-recovery journal. When launching a plan yourself, call
sandbox-plan-cleanup after terminating and waiting for its helper.
Run (asdf:test-system :cl-exec-sandbox/windows-tests) after compiling
tests/windows-child.c to build/windows-child.exe. CI runs these enforcement
tests under a disposable non-administrator account.
(let ((policy
(cl-exec-sandbox:workspace-write-sandbox-policy
:workspace-roots (list #P"/work/project/")
:network :isolated)))
(cl-exec-sandbox:run-sandboxed
"/bin/sh"
'("-c" "git status --short")
:policy policy
:working-directory #P"/work/project/"
:output-limit 65536
:error-output-limit 65536))When a limit is exceeded, the returned prefix is available through
sandbox-result-output or sandbox-result-error-output. Inspect
sandbox-result-output-truncated-p and
sandbox-result-error-output-truncated-p before presenting captured output.
run-sandboxed drains binary stdout/stderr pipes into private files. Set
:capture-directory to an existing caller-owned private directory, or set
:retain-output-p t to retain generated temporary files. The caller owns retained
files and their deletion. Without either option, capture files are removed after
the final callback. The per-stream :capture-byte-limit defaults to 67,108,864
bytes and must be a finite non-negative integer. Excess bytes are drained and
counted without increasing disk usage.
(let* ((result (cl-exec-sandbox:run-sandboxed
"/bin/sh" '("-c" "make")
:policy (cl-exec-sandbox:unrestricted-sandbox-policy)
:capture-directory #P"/private/command-logs/"
:capture-byte-limit (* 32 1024 1024)
:output-limit 0 :error-output-limit 0))
(capture (cl-exec-sandbox:sandbox-result-error-capture result)))
(multiple-value-bind (head tail omitted)
(cl-exec-sandbox:sample-capture capture :head-bytes 1024 :tail-bytes 4096)
(values (cl-exec-sandbox:decode-capture-bytes head)
(cl-exec-sandbox:decode-capture-bytes tail)
omitted)))Inspect sandbox-result-output-capture and sandbox-result-error-capture.
With :merge-output-p t, stdout and stderr share the output capture and the
error capture is NIL. Separate streams have independent ordering. Capture
metadata includes sandbox-capture-path, sandbox-capture-byte-count (actual
closed file size), sandbox-capture-observed-byte-count (bytes drained),
sandbox-capture-complete-p, sandbox-capture-truncated-p and
sandbox-capture-status. Status is one of :complete, :limit, :write-error,
:interrupted, :timeout, :cancelled or :launch-failed. Completeness requires
ordinary command completion, EOF and successful storage of all drained bytes;
a nonzero command exit can have a complete capture. Storage limits/failures
retain the bytes successfully written and continue draining the child.
sandbox-capture-retained-p identifies caller-owned files; transient metadata
records the captured bytes even though cleanup removes the file.
sample-capture returns three values: raw head octets, non-overlapping tail
octets, and omitted retained bytes. It seeks directly to the tail and allocates
only the requested samples. decode-capture-bytes renders UTF-8 with replacement
characters for malformed input; the retained file preserves raw bytes.
The legacy :output-limit and :error-output-limit count rendered characters.
Retained modes default omitted text limits to zero, so result construction
uses bounded memory. Transient legacy capture defaults to complete rendered
text; pass explicit NIL for that behavior in retained mode, or a finite limit.
:cancel-function is a zero-argument predicate polled during execution.
sandbox-result-status distinguishes :exited, :timeout, :cancelled,
:launch-failed and :interrupted; timeout and cancellation also have boolean
result accessors. :capture-created-function receives initial result metadata
synchronously after capture allocation and before native launch, allowing the
caller to durably record paths before command side effects. Initial byte counts
are zero, exit code is NIL and status is :interrupted with completeness false.
An error in this callback prevents launch.
:capture-function receives final result metadata on normal completion and
every executor unwind, after termination, pipe shutdown and file closure.
Launch failures signal sandbox-execution-error with the final result through
sandbox-execution-error-result. Input/policy validation occurs before capture
setup. Callback failures cannot skip process cleanup. Pipes held open by
surviving descendants have bounded reader shutdown and an incomplete capture.
Asynchronous interrupts are deferred through ordered cleanup and the final
callback. Keep metadata callbacks brief.
On POSIX, generated files have mode 0600; on Windows they inherit the private
capture directory’s ACL. Allocate a directory accessible only to its owner.
Build the host’s private helper and run the complete supported-host test suite:
./checkPortable execution, retained capture and Seatbelt profile translation cases run on POSIX hosts. Linux-only enforcement cases are explicitly skipped elsewhere. Run the separate native Windows test system for AppContainer enforcement.
Applications which vendor this system should run scripts/build-helper during
their build and ship build/cl-exec-sandbox-process-group beside the Lisp
sources. Linux applications using restricted networking must also ship
build/cl-exec-sandbox-helper. Set CL_EXEC_SANDBOX_PROCESS_GROUP_HELPER or
CL_EXEC_SANDBOX_HELPER to alternate absolute helper paths when the installed
layout differs. Packaged Bubblewrap installations outside /usr/bin and /bin
can set CL_EXEC_SANDBOX_BWRAP to its absolute executable path.
Part of the Lambda Symbolics library shelf.