@cdxgen/safer-exec is a zero-dependency Node.js library that provides OS-level sandboxing for process execution. It consists of:
- Go engine (
go/) — a statically compiled binary that enforces native sandboxing - Node.js wrapper (
npm/src/) — resolves policies, performs DNS lookups, and orchestrates the Go binary - CLI (
npm/src/cli.js) — terminal interface to all sandbox features - Tests (
tests/andnpm/src/*.test.js) — unit, integration, security, and exhaustion tests
Node.js (policy + DNS) --[JSON on stdin]--> Go binary --[sandbox]--> target command
|
stdout/stderr back to Node.js
The Go engine binary is named safer-exec-rt (runtime). The name safer-exec (without -rt) is reserved for the standalone caxa-bundled executable distributed on GitHub Releases.
macOS: Generates Seatbelt profiles → applies RLIMIT quotas → runs via sandbox-exec
Linux: Forks self with --init → unshares namespaces → sets MS_SLAVE on root → mounts tmpfs → creates cgroup v2 → bind-mounts paths (fd-based with TOCTTOU check) → enforces submount read-only flags → double pivot_root → sets up /proc (hidepid=2 + dangerous entry hardening) → sets up minimal /dev → applies Landlock network rules → applies Landlock filesystem rules → applies seccomp-bpf (base + stackable filters) → acquires file locks → forks PID 1 reaper → child closes leaked fds → child sets PR_SET_PDEATHSIG / setsid → child execves target
go/ — Go engine (platform-specific sandboxing)
cmd/safer-exec/ — Entry point + platform engines
main.go — Reads JSON config from stdin, dispatches
engine_darwin.go — macOS: Seatbelt + RLIMIT engine
engine_linux.go — Linux: namespaces + seccomp + Landlock (net + fs) + cgroup
engine_openbsd.go — OpenBSD: unveil(2) + pledge(2) engine
engine_linux_amd64.go — x86_64 syscall numbers
engine_linux_arm64.go — arm64 syscall numbers
engine_linux_*_syscall.go — Architecture-specific syscall constants
engine_darwin_test.go — macOS engine tests
engine_linux_test.go — Linux engine tests
internal/
config/ — ExecConfig JSON contract (shared by all layers)
learner/ — Linux strace-based behavioral learner
learnermac/ — macOS Seatbelt trace parser (learning mode)
fsdiff/ — Filesystem snapshot + SHA-256 diff utilities
httptrace/ — eBPF HTTP URL + crypto tracing (Linux-only)
bpf/ — BPF C source + pre-compiled objects
cipher.go — Cipher name parser + IANA registry mapping
cipher_test.go — Cipher decomposition tests
apparmor/ — AppArmor profile for unprivileged user namespace creation
safer-exec — Bundled AppArmor profile
npm/
package.json — npm package definition (@cdxgen/safer-exec)
src/
index.js — SaferExec class (fluent API, public interface)
cli.js — CLI entry point (shebang, argument parsing)
runner.js — Go binary spawner, I/O piping, output parsing
net.js — DNS resolution (hostname → IP)
policies/ — Pre-built hardened policies per ecosystem
npm.js, pnpm.js, yarn.js, pypi.js, maven.js, cargo.js,
rubygems.js, composer.js, deno.js, gomod.js, bun.js
sslhelper.js — Platform-specific SSL cert paths
tests/
integration.test.js — Full pipeline tests (policy → DNS → Go → sandbox)
security.test.js — Boundary tests (isolation, env leakage, limits)
exhaustion.test.js — Resource exhaustion tests (memory bombs, fork bombs)
benchmark.js — Performance benchmarks vs child_process.exec
- Go 1.22+ (for building the engine)
- Node.js 20+ (for running the wrapper and tests)
cd go
go build -trimpath -ldflags="-s -w" -o bin/safer-exec-rt ./cmd/safer-exec/Cross-compile for other platforms:
GOOS=darwin GOARCH=arm64 go build -o bin/safer-exec-rt-darwin-arm64 ./cmd/safer-exec/
GOOS=linux GOARCH=amd64 go build -o bin/safer-exec-rt-linux-amd64 ./cmd/safer-exec/# From the npm/ directory (requires Go binary built first)
npm run test:unit # Node.js unit tests (npm/src/*.test.js)
npm run test:integration # Integration tests (tests/integration.test.js)
npm run test:security # Security boundary tests (tests/security.test.js)
npm run test:exhaustion # Resource exhaustion tests (tests/exhaustion.test.js)
npm run test:benchmark # Performance benchmarks (tests/benchmark.js)
npm run test:all # All tests
# Go tests (run from go/)
cd go
go test -v -race ./...node npm/src/cli.js --help
node npm/src/cli.js --policy=npm -- npm install
node npm/src/cli.js --max-memory=512 -- npm run build
node npm/src/cli.js --diff --write-path=/tmp -- npm install
node npm/src/cli.js --learn -- npm install
node npm/src/cli.js --trace-crypto --cbom-output=cbom.json -- npm install
node npm/src/cli.js diagnosticsThe ExecConfig struct is the canonical JSON contract between Node.js and Go. All configuration flows through this struct. When adding a new feature:
- Add the field to
ExecConfiginconfig.go - Update the Node.js
SaferExecclass inindex.js - Update both platform engines (
engine_darwin.goandengine_linux.go) - Update the CLI argument parser in
cli.js - Add tests for both platforms
- Update documentation (update README.md to describe the new configuration settings, CLI flags, and API methods)
- macOS uses Go build tags (
//go:build darwin) — Seatbelt profiles + RLIMIT - Linux uses Go build tags (
//go:build linux) — namespaces + seccomp + Landlock (net + fs) + cgroup v2 - OpenBSD uses Go build tags (
//go:build openbsd) — unveil(2) + pledge(2) - Architecture-specific syscall numbers use build tags (
linux && amd64,linux && arm64) - The
run()function signature is the same across platforms — the implementation differs
The Linux engine has been hardened with multiple layered defenses:
- Mount propagation control:
MS_SLAVEis set on root to prevent sandbox mounts from propagating to the host. - FD-based bind mounting: Source paths are opened via
O_PATHand mounted through/proc/self/fd/N. Post-mount,fstat(fd)is compared againstlstat(target)to detect TOCTTOU races. - Submount read-only enforcement: After each read-only bind mount,
/proc/self/mountinfois parsed to discover submounts, which are individually remounted read-only. Closes the kernelMS_RECloophole. /prochardening: Dangerous writable entries (/proc/sys,/proc/sysrq-trigger,/proc/irq,/proc/bus) are covered with read-only bind mounts.- Minimal
/devsetup: A fresh tmpfs is mounted on/devwith only essential device nodes (null,zero,full,random,urandom,tty), stdio symlinks, and/dev/shm. - PID 1 reaper: A guardian process runs as PID 1 in the PID namespace, reaping orphaned zombies and correctly propagating exit codes.
- Exit code propagation:
initMain()andinitReducedMain()now check forExitErrorand use its code instead of always exiting with1.
The Go binary communicates structured data back to Node.js via marker-prefixed JSON on stdout:
FSDIFF:prefix — filesystem diff reportLEARNED:prefix — learned policy outputCRYPTO:prefix — cryptographic observations (ciphers, libraries)- Audit entries — JSON lines on stderr
The Node.js runner.js parses these markers and separates them from regular stdout.
A JSON-lines status protocol is available for external lifecycle monitoring. When JsonStatusFd is configured (set by the Node.js runner via an O_CLOEXEC pipe fd), the engine writes:
{"child-pid": 12345, "type": "sandbox-start"}
{"exit-code": 0, "type": "sandbox-exit"}Policies are plain JavaScript functions that return config objects. They are platform-aware (detecting OS for SSL paths, Node binary paths, etc.). When adding a new policy:
- Create
npm/src/policies/<name>.js - Export a function that returns
{ allowHosts, readPaths, writePaths, env } - Register in
POLICIESmap inindex.js - Add test in
npm/src/policies.test.js
The following features were added as hardening measures. Some are now enabled by default; all fall back gracefully on platforms or kernels that lack the required support.
| Feature | Config Field | Description |
|---|---|---|
| ProtectSystem | protectSystem |
Auto-make /usr, /boot, /etc, /lib read-only. Modes: "strict", "full", "off". Mirrors systemd's ProtectSystem. Opt-in. |
| ProtectHome | protectHome |
Isolate $HOME. Modes: "read-only" (bind-mount ro), "tmpfs" (blank tmpfs), "off". |
| PrivateTmp | privateTmp |
Replace /tmp and /var/tmp with fresh tmpfs mounts. Prevents temp file leakage. |
| Cross-ns FD Binding | bindFds |
Bind-mount pre-opened file descriptors into the sandbox. Enables privileged parent → sandbox FD handoff. |
| Exclusive File Locks | lockFiles (enhanced) |
LockFileSpec now supports exclusive: true for LOCK_EX semantics. lockFilesExclusive() convenience method added. |
| setUpDev | setUpDev |
Minimal /dev setup inside sandbox (null, zero, random, urandom, tty). Enabled by default. |
| dieWithParent | dieWithParent |
Kill sandboxed process when parent exits (PR_SET_PDEATHSIG). Enabled by default. |
| newSession | newSession |
Disconnect from controlling terminal (setsid). Enabled by default. |
| bindUseFd | bindUseFd |
FD-based bind mounts with TOCTTOU safety checks. Opt-in. |
| Feature | Config Field | Description |
|---|---|---|
| Enhanced Blocklist | N/A (always on) | Added 18 syscalls to default blocklist: personality, lookupdcookie, fanotify_init, inotify, iouring, processvm*, delete_module, init_module, finit_module, quotactl, swapoff, swapon, request_key. |
| Kafel Policy Language | seccompFilters[].policy |
Compile seccomp filters from a simple policy language: "ALLOW syscall1, syscall2; DEFAULT KILL". |
| Feature | Config Field | Description |
|---|---|---|
| Cgroup v1 Fallback | N/A (automatic) | Falls back to cgroup v1 controllers when /sys/fs/cgroup/cgroup.controllers is absent. Supports memory, cpu, pids, blkio. |
| Feature | Description |
|---|---|
| IOCTL Control (ABI v4) | Restrict ioctl operations on devices via LANDLOCK_ACCESS_FS_IOCTL_DEV |
| UDP Rules (ABI v5) | Add LANDLOCK_ACCESS_NET_BIND_UDP and LANDLOCK_ACCESS_NET_CONNECT_UDP |
| Scoped Rules (ABI v5) | IPC restrictions: prevent signaling/ptrace outside the sandbox |
| Feature | Config Field | Description |
|---|---|---|
| Multi-UID Mapping | mapToTargetUid |
Map UID 0 inside namespace to caller's real UID. Reduces root-in-namespace attack surface. |
- Use
//go:buildtags for platform-specific files - Engine functions follow the pattern:
run(cfg),runInit(cfg),runLearn(cfg) - Syscall numbers should be defined in architecture-specific files, not in the main engine
- Use
fmt.Fprintf(os.Stderr, "safer-exec: ...")for error messages - Tests use Go's
testingpackage witht.TempDir()for temp directories - Post-fork child processes must only use raw syscalls — no Go runtime functions
- ES modules only (
"type": "module"in package.json) - JSDoc annotations on all exported functions and classes
- Use
node:testandnode:assert/strictfor testing (no external test frameworks) - Fluent API: all config methods return
thisfor chaining (except.run()which returnsPromise<ExecResult>) - Error messages prefixed with
safer-exec:for identification
The resolveBinaryPath() function in runner.js locates the Go engine binary. It searches in this priority order:
go/bin/safer-exec-rt-{platform}-{arch}(platform-specific local build)go/bin/safer-exec-rt(generic local build)- Platform-specific npm optional dependencies in
node_modules /usr/local/bin/safer-exec-rt(system-wide install; auto-bootstraps from node_modules if running as root)- Bare string
'safer-exec-rt'(PATH resolution fallback)
- Unit tests live alongside source files (
src/*.test.js) - Integration and security tests live in
tests/ - Go tests live alongside engine files (
engine_*_test.go) - Tests should verify actual sandbox behavior, not just config serialization
- Exhaustion tests should use
.timeout()to prevent hanging - NEVER use
sudoto run tests or execute command verification. If a test requires special capabilities, configure appropriate setcap or file permissions instead of using superuser privileges.
- Add field to
ExecConfigingo/internal/config/config.go - Implement in
engine_darwin.go(Seatbelt rule or RLIMIT) - Implement in
engine_linux.go(namespace, seccomp, Landlock, or cgroup) - Add method to
SaferExecclass innpm/src/index.js - If the feature has user-facing config, add the field to
PolicyFileinconfig.goand theapplyPolicyFilemethod inindex.js - Add CLI flag in
npm/src/cli.js - Add test fixtures — Create unit tests in
npm/src/and integration tests intests/. - Add tests to both platform test files and Node.js tests
- Update documentation (update README.md to describe the new configuration settings, CLI flags, and API methods)
- Create
npm/src/policies/<ecosystem>.js - Export
<ecosystem>Policy()function returning policy config - Import and register in
POLICIESmap innpm/src/index.js - Add test in
npm/src/policies.test.js
The --trace-crypto feature captures TLS cipher suites and cryptographic library
identities using eBPF uretprobes on OpenSSL/GnuTLS cipher negotiation functions.
How it works:
-
Library detection:
attachLibraryProbes()inhttptrace_linux.goparses library paths (libssl.so.3→ OpenSSL 3.x) and extracts versions. Detected libraries are returned viaTracer.DetectedLibraries(). -
Cipher detection: eBPF uretprobes on
SSL_get_current_cipher,SSL_CIPHER_get_name,SSL_CIPHER_get_bits,SSL_get_version(OpenSSL), andgnutls_cipher_suite_get_name(GnuTLS) capture negotiated cipher suites per TLS connection. Cipher info is correlated with HTTP requests via the SSL* pointer (ConnID). -
CBOM output:
writeCBOMFile()inengine_linux.goproduces a minimal CycloneDX JSON document withcryptographic-assetcomponents for each detected library and cipher suite. The document uses CycloneDX 1.7 specification. -
Cipher name parsing:
DecomposeCipherName()incipher.goparses OpenSSL-style names into constituent algorithms (key exchange, authentication, encryption, hash, mode) for CBOM metadata.
Adding or maintaining a BPF probe:
- Modify/add probes in
go/internal/httptrace/bpf/ssl_trace.c. When capturing parameters, avoid reading registers directly in exituretprobereturn handlers (which is blocked by the verifier on stripped/older runner kernels). Instead, implement a dual hook:- Save function parameters in a BPF hash map keyed by
tgid_tid(bpf_get_current_pid_tgid()) during the entryuprobe. - Retrieve the arguments from the map and read the return value (
PT_REGS_RC(ctx)) during the exituretprobe.
- Save function parameters in a BPF hash map keyed by
- Add the corresponding
*ebpf.Programfield tobpfObjectsinhttptrace_linux.go. - Register the program loading and attach it under
EnableCryptoTracing()orAttachStaticOpenSSL(). - Handle the events correctly in
readCipherLoop(). - Rebuild BPF objects: Run
cd go/internal/httptrace/bpf && bash compile.shto compile BPF bytecode targeting multiple architectures inside Docker containers. Because the Docker mount points to the host directory, make sure you compile BPF changes locally without container virtualization directory translation issues, and stage the updated.obinary objects (ssl_trace_linux_amd64.oandssl_trace_linux_arm64.o) into git.
- Run with
--auditto see resource accesses - Run with
--learnto discover what a command actually needs - Check stderr for
safer-exec:prefixed messages - On macOS, inspect generated Seatbelt profiles (temp
.sbfiles) - On Linux, check
/proc/self/ns/for namespace state - Run
safer-exec diagnosticsorSaferExec.diagnostics()to verify platform readiness and detect sandbox configuration issues - On Ubuntu 24.04+, the binary at
/usr/local/bin/safer-exec-rtmay need an AppArmor profile to create user namespaces; usesafer-exec bootstrap-apparmor
AI agents working with safer-exec can leverage policy files (--policy-file) to dynamically build and refine permissions:
- Discover: Execute commands with
--learnand--learn-output=policy.jsonto observe necessary file and network access paths. - Refine: Inspect the output policy JSON. Prune unnecessary paths or narrow broad directories to specific sub-paths (avoid blanket rules).
- Iterate: Run subsequent workflow steps with
--learn --policy-file=policy.jsonto merge new operations into the existing policy. - Deploy: Enforce the generated policy in production using
--policy-file=policy.json(CLI) orapplyPolicyFile(path)(Node.js API).
See THREAT-MODEL.md for the complete threat model covering sandbox guarantees, attack surfaces, and failure modes.
Key guarantees:
- macOS: Seatbelt
(deny default)+system.sbimport - Linux: User namespace + mount namespace (MS_SLAVE propagation control) + seccomp-bpf blocking escape syscalls + Landlock network/filesystem confinement + submount read-only enforcement +
/procdangerous entry hardening + minimal/devsetup + fd-based TOCTTOU-safe bind mounts + PID 1 reaper with zombie reaping - Both platforms enforce resource quotas (memory, CPU, process count, I/O)