OS-level sandboxing for process execution. A zero-dependency Node.js library backed by a statically compiled Go binary.
On macOS the Go binary generates Seatbelt profiles and runs commands through sandbox-exec. On Linux it uses namespace isolation, bind mounts, pivot_root, seccomp-bpf filters, Landlock network confinement, and cgroup v2 resource quotas.
npm install @cdxgen/safer-execThe CLI provides terminal access to all sandbox features.
# Run with a built-in policy
safer-exec --policy=npm -- npm install
# Resource limits
safer-exec --max-memory=512 --max-cpu=1.0 -- npm run build
# Disable network, enable auditing
safer-exec --disable-network --audit -- cat package.json
# Filesystem diffing
safer-exec --diff --write-path=/tmp -- sh -c "echo hello > /tmp/out.txt"
# Learning mode
safer-exec --learn --learn-output=policy.json -- npm install
# Fork and exec control
safer-exec --allow-exec=node --allow-exec=npx -- npm run build
safer-exec --block-exec=sh -- npm install
safer-exec --block-fork -- npm install
safer-exec --trace-exec -- npm installFull help: safer-exec --help.
import { SaferExec } from "@cdxgen/safer-exec";
const result = await new SaferExec()
.allowHosts("registry.npmjs.org", "api.github.com")
.readPaths("/usr", "/etc/ssl/certs")
.writePaths(process.cwd() + "/node_modules")
.env("NODE_ENV", "production")
.maxMemory(512)
.disableNetwork()
.run("npm", ["install"]);
console.log(result.exitCode, result.stdout);Every configuration method returns this for chaining. The .run() method returns a promise that resolves to an ExecResult object containing stdout, stderr, exitCode, and optional auditLog, fsDiff, or learnedPolicy fields depending on which features are enabled.
new SaferExec(options?)
| Option | Type | Default | Description |
|---|---|---|---|
allowHosts |
string[] |
[] |
Hostnames to allow network access to |
readPaths |
string[] |
[] |
Filesystem paths to read from |
writePaths |
string[] |
[] |
Filesystem paths to write to |
env |
Object |
{} |
Environment variables to set |
disableNetwork |
boolean |
false |
Cut all network access |
maxMemoryMB |
number |
0 |
Memory limit in megabytes |
maxCPUCores |
number |
0 |
CPU limit as fractional cores |
maxProcesses |
number |
0 |
Max child processes (anti-fork bomb) |
timeoutMs |
number |
0 |
Hard kill timeout in milliseconds |
workingDir |
string |
process.cwd() |
Working directory |
binaryPath |
string |
auto-resolved | Override Go binary path |
enableAudit |
boolean |
false |
Enable violation auditing |
allowPorts |
number[] |
[] |
TCP ports to allow |
enableDiff |
boolean |
false |
Enable filesystem mutation diffing |
enableLearn |
boolean |
false |
Enable behavioral auto-profiling |
allowExec |
string[] |
[] |
Executables the command is allowed to run |
blockExec |
string[] |
[] |
Executables to block from running |
blockFork |
boolean |
false |
Prevent forking new processes |
traceExec |
boolean |
false |
Log every child process spawned |
strict |
boolean |
false |
Treat sandbox setup warnings as errors |
allowCrypto |
boolean |
true |
Permit cryptographic library/device access |
blockCrypto |
boolean |
false |
Block system crypto libraries access |
blockCryptoEntropy |
boolean |
false |
Block entropy (/dev/random) device access |
detectFIPS |
boolean |
false |
Enable FIPS compliance checks/logging |
strictFIPS |
boolean |
false |
Force strict FIPS validation |
allowGPU |
boolean |
false |
Permit process to utilize host GPU nodes |
blockTPM |
boolean |
false |
Restrict hardware access to TPM device |
spoofAntiVM |
boolean |
false |
Intercept debugger & virtualization checks |
traceLibraries |
boolean |
false |
Track dynamic library loading (opt-in) |
All methods return this for chaining except .run().
| Method | Description |
|---|---|
.applyPolicy(name) |
Apply a pre-defined policy. Throws if unknown. |
.allowHosts(...hosts) |
Add hostnames to the network allow list |
.readPaths(...paths) |
Add filesystem read paths |
.writePaths(...paths) |
Add filesystem write paths |
.env(key, value) |
Set an environment variable |
.disableNetwork() |
Disable all network access |
.maxMemory(mb) |
Set memory limit in megabytes |
.maxCPUCores(cores) |
Set CPU limit as fractional cores (e.g. 0.5) |
.maxProcesses(count) |
Set maximum child process count |
.timeout(ms) |
Set hard kill timeout in milliseconds |
.binaryPath(path) |
Override the Go binary path |
.workingDir(dir) |
Set the working directory |
.enableAudit() |
Enable sandbox violation auditing |
.allowPorts(...ports) |
Set allowed TCP ports |
.enableDiff() |
Enable filesystem mutation diffing |
.enableLearn() |
Enable behavioral auto-profiling |
.allowExec(...cmds) |
Restrict which executables can run |
.blockExec(...cmds) |
Block specific executables from running |
.blockFork() |
Prevent the command from forking new processes |
.traceExec() |
Log every child process spawned |
.strict() |
Treat sandbox setup warnings as hard errors |
.resolveSymlinks() |
Resolve target command symlink in PATH |
.allowCrypto(allow) |
Allow/disallow cryptographic operations |
.blockCrypto() |
Restrict system cryptographic libraries |
.blockCryptoEntropy() |
Restrict entropy devices (/dev/random) |
.detectFIPS() |
Log and watch for FIPS lookups |
.strictFIPS() |
Restrict runtime to strict FIPS compliant mode |
.allowGPU(allow) |
Allow/disallow access to host GPU nodes |
.blockTPM() |
Restrict hardware access to TPM device |
.spoofAntiVM() |
Intercept debugger & virtualization checks |
.traceLibraries() |
Track dynamic library loading (LD_AUDIT on Linux, audit events on macOS) |
Execute the sandboxed command. Returns Promise<ExecResult>:
interface Entry {
path: string;
size: number;
}
interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
timedOut?: boolean;
auditLog?: Array<{ type: string; target: string; detail?: string }>;
fsDiff?: { added: Entry[]; modified: Entry[]; deleted: Entry[] };
learnedPolicy?: {
readPaths: string[];
writePaths: string[];
allowIPs: string[];
allowPorts: number[];
envVars: string[];
allowCrypto?: boolean;
blockCrypto?: boolean;
blockCryptoEntropy?: boolean;
detectFIPS?: boolean;
strictFIPS?: boolean;
fipsDetected?: boolean;
cmd: string;
args: string[];
};
}The Node.js layer handles policy resolution, DNS lookups, and config serialization. It pipes a JSON ExecConfig to the Go binary over stdin. The Go binary reads the config and delegates to a platform-specific engine.
macOS path:
- Generate a Seatbelt profile from the config
- Apply RLIMIT quotas (memory via
RLIMIT_AS, CPU viaRLIMIT_CPU, process count viaRLIMIT_NPROC) - Execute
sandbox-exec -f <profile> <cmd> <args...>
Linux path (full isolation):
- Probe for user namespace availability; fall back to reduced mode if restricted
- Fork self with
--initflag and config inSAFER_EXEC_CONFIGenv var - Unshare namespaces (user, mount, PID, UTS, network)
- Map UID/GID to root inside the user namespace for mount privileges
- Create cgroup v2 hierarchy for resource quotas
- Mount tmpfs root, bind-mount read/write paths, mount proc and sysfs
- Apply Landlock v2 network confinement rules
- Apply seccomp-bpf filter blocking ptrace, kcmp, unshare, mount, pivot_root
pivot_rootto the new filesystem treeexecvethe target command
Linux path (reduced isolation — user namespaces unavailable):
- Fork self with
--init-reducedflag (no unshare) - Create cgroup v2 hierarchy for resource quotas
- Apply Landlock v2 network confinement rules
- Apply seccomp-bpf syscall filter
execvethe target command (host filesystem fully visible)
Communication between layers uses marker-prefixed JSON on stdout:
FSDIFF:prefix for filesystem diff reportsLEARNED:prefix for learned policy output- Audit entries are written as JSON lines to stderr
For environments without Node.js, or when you want to execute sandboxed commands directly using a standalone binary, pre-built Single Executable Application (SEA) binaries are published to GitHub Releases.
These binaries are fully self-contained, wrapping Node.js and the statically compiled Go engine into a single executable.
- macOS Intel (
darwin-amd64) - macOS Apple Silicon (
darwin-arm64) - Linux GNU (
linux-amd64,linux-arm64) - Linux Musl (
linux-amd64-musl,linux-arm64-musl)
You can download and verify the integrity of the standalone binaries using curl and sha256:
# Set parameters
OS="linux" # or "darwin"
ARCH="amd64" # or "arm64"
LIBC="" # or "-musl" for alpine/musl distributions
VERSION="0.5.0"
# Download binary and checksum files
curl -L -O "https://github.com/cdxgen/safer-exec/releases/download/v${VERSION}/safer-exec-${OS}-${ARCH}${LIBC}"
curl -L -O "https://github.com/cdxgen/safer-exec/releases/download/v${VERSION}/safer-exec-${OS}-${ARCH}${LIBC}.sha256"
# Verify checksum
if [[ "$OS" == "darwin" ]]; then
shasum -a 256 -c "safer-exec-${OS}-${ARCH}${LIBC}.sha256"
else
sha256sum -c "safer-exec-${OS}-${ARCH}${LIBC}.sha256"
fi
# Make binary executable and run
chmod +x "safer-exec-${OS}-${ARCH}${LIBC}"
./safer-exec-${OS}-${ARCH}${LIBC} --versionmacOS: Works out of the box using the built-in sandbox-exec.
Linux:
- Learning Mode requires
straceto be installed (sudo apt install strace). - On most distributions (Debian, Fedora, Arch, Alpine/Musl, Ubuntu ≤ 23.10) safer-exec works out of the box with full namespace isolation.
- On Ubuntu 24.04+ user namespace creation is restricted by AppArmor by default. safer-exec automatically detects this and falls back to reduced isolation mode (seccomp-bpf + Landlock only; no filesystem, PID, or network namespace isolation). A warning is printed. See Full Isolation on Ubuntu 24.04+ below to restore full isolation with an AppArmor profile.
Linux Resource Limits (Cgroup v2):
By default, systemd-based distributions do not allow unprivileged users to apply CPU, Memory, or PID limits. If you want to use .maxMemory(), .maxCPUCores(), or .maxProcesses() on systemd distributions without running as root, you must enable systemd user delegation on your machine:
# Enable CPU, Memory, and PID delegation for user sessions
sudo mkdir -p /etc/systemd/system/user@.service.d
sudo sh -c 'echo -e "[Service]\nDelegate=cpu memory pids" > /etc/systemd/system/user@.service.d/delegate.conf'
sudo systemctl daemon-reload
# You may need to log out and log back in for changes to take effect.Note on Alpine Linux (OpenRC): Alpine Linux uses OpenRC as the init system instead of systemd. OpenRC mounts cgroup v2 automatically at /sys/fs/cgroup. The systemd user delegation configuration above is not required; resource limits will be applied directly to the cgroup hierarchy.
Note: If cgroup v2 delegation is not configured or available (e.g. inside restricted Docker containers), safer-exec will gracefully skip the resource limits and print a warning, but will still enforce all other sandbox constraints (filesystem, network, syscalls).
safer-exec runs in one of two modes on Linux, chosen automatically at startup:
| Mode | Filesystem isolation | PID namespace | Network namespace | Seccomp | Landlock | Cgroup limits |
|---|---|---|---|---|---|---|
| Full (default) | ✓ bind-mount + pivot_root | ✓ | ✓ (if --disable-network) |
✓ | ✓ | ✓ |
| Reduced (fallback) | ✗ | ✗ | ✗ | ✓ | ✓ | ✓ |
Full mode requires the ability to create unprivileged user namespaces (unshare -U). Reduced mode is used automatically when this is unavailable, and a warning is printed to stderr:
safer-exec: warning: user namespaces unavailable — running with reduced isolation (seccomp + landlock only; no filesystem, PID, or network namespace isolation). Install the safer-exec AppArmor profile for full isolation.
In reduced mode, seccomp-bpf syscall filtering and Landlock network confinement still apply, so fork/exec blocking, syscall restrictions, and per-host network allow-lists remain effective. Filesystem isolation (restricting visible paths via bind mounts) and --diff are not available.
Ubuntu 24.04 (and later) restricts unprivileged user namespace creation by default via AppArmor (kernel.apparmor_restrict_unprivileged_userns=1). The restriction is per-binary: you can grant safer-exec permission without changing the system-wide setting.
sudo tee /etc/apparmor.d/safer-exec > /dev/null << 'EOF'
# AppArmor profile for safer-exec — grants permission to create
# unprivileged user namespaces required for full sandbox isolation.
abi <abi/4.0>,
include <tunables/global>
profile safer-exec /usr/local/bin/safer-exec flags=(unconfined) {
userns,
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/safer-execAdjust the path (/usr/local/bin/safer-exec) to wherever the binary is installed. When using the npm package, the binary lives inside node_modules/@cdxgen/safer-exec-linux-*/bin/safer-exec — you can use a glob pattern:
sudo tee /etc/apparmor.d/safer-exec > /dev/null << 'EOF'
abi <abi/4.0>,
include <tunables/global>
profile safer-exec /** {
userns,
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/safer-execThe profile takes effect immediately (no reboot required). Verify with:
# Should show the profile loaded
sudo aa-status | grep safer-execIf installing an AppArmor profile is not an option (e.g., in ephemeral CI environments), you can disable the restriction globally:
# Temporary (lost on reboot)
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
# Permanent
echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-userns.conf
sudo sysctl -p /etc/sysctl.d/99-userns.confThis weakens a system-wide security policy. Prefer the AppArmor profile for production systems.
Apply a hardened profile for common package managers. User-defined settings take precedence over policy defaults when both are present.
const result = await new SaferExec().applyPolicy("npm").run("npm", ["install"]);Available policies: npm, pnpm, yarn, pypi, maven, cargo, rubygems, composer, deno, gomod, bun.
Each policy is platform-aware. Paths are resolved at runtime based on the operating system. For example, the npm policy detects the Node binary directory, resolves SSL certificate paths for macOS versus Linux, and sets registry host allow lists for npm, Yarn, and JS CDN endpoints.
Policies that cover JavaScript package managers include blockFork: true and blockExec: ['*'] by default to prevent postinstall scripts from spawning subprocesses.
Restrict which executables the sandboxed process can run, block specific binaries, prevent forking, or trace all child processes.
// Allow only specific executables
const result = await new SaferExec()
.allowExec("node", "npx", "corepack")
.run("npm", ["install"]);
// Block specific executables (takes precedence over allow list)
const result = await new SaferExec()
.blockExec("sh", "bash")
.run("npm", ["install"]);
// Prevent all forking
const result = await new SaferExec().blockFork().run("npm", ["install"]);
// Log every child process spawned
const result = await new SaferExec().traceExec().run("npm", ["install"]);
console.log(result.auditLog); // process-exec entries with command lines and PIDsOn macOS these map to Seatbelt process-exec and process-fork rules. On Linux they add seccomp-bpf filters for clone, fork, vfork, and execve syscalls.
Track exactly which files a command creates, modifies, or deletes.
const result = await new SaferExec()
.writePaths(process.cwd())
.enableDiff()
.run("npm", ["install"]);
console.log(result.fsDiff.added); // newly created files
console.log(result.fsDiff.modified); // changed files
console.log(result.fsDiff.deleted); // removed filesOn Linux this uses OverlayFS to capture writes in a temporary upper directory. On macOS it compares pre and post execution snapshots of the write paths using SHA-256 content hashes.
Run a command in permissive mode and get back a strict minimal policy based on observed behavior.
const result = await new SaferExec().enableLearn().run("npm", ["install"]);
console.log(result.learnedPolicy);
// { readPaths: ["/usr", "/etc"], writePaths: ["./node_modules"],
// allowIPs: ["93.184.216.34"], allowPorts: [443] }On Linux the learner uses strace to capture file opens, stat calls, and network connects. If strace is not available it falls back to pre/post filesystem snapshots and /proc/net/tcp scanning. On macOS it uses Seatbelt trace rules.
Capture sandbox violations and resource accesses as structured log entries.
const result = await new SaferExec()
.allowHosts("api.github.com")
.readPaths("/usr", "/etc/ssl/certs")
.writePaths("/tmp/output")
.maxMemory(256)
.enableAudit()
.run("curl", ["https://api.github.com"]);
console.log(result.auditLog);Each audit entry contains a type (file-read, file-write, network-connect, syscall, process-exec), the target resource, and optional details.
Enable opt-in dynamic library load tracking to observe which shared libraries a sandboxed process loads at runtime.
const result = await new SaferExec()
.traceLibraries()
.enableAudit()
.run("node", ["myapp.js"]);
// On Linux: auditLog contains {"type":"lib-load","target":"/lib/x86_64-linux-gnu/libc.so.6"} entries
// On macOS: stderr contains trace-libraries diagnostic; .dylib loads appear as file-read audit events
console.log(result.auditLog.filter((e) => e.type === "lib-load"));Platform behavior:
| Platform | Mechanism | Scope |
|---|---|---|
| Linux | LD_AUDIT via rtld-audit (la_objopen hook compiled at runtime) |
All ELF shared libraries loaded by ld.so, including transitive dependencies and dlopen calls |
| macOS | Seatbelt audit (file-read events for .dylib / .framework paths) |
Library paths as captured by Seatbelt trace; DYLD_INSERT_LIBRARIES is blocked by macOS hardened runtime |
On Linux, safer-exec compiles a small C audit helper at runtime using gcc (or cc as fallback) and injects it via LD_AUDIT. Each loaded library emits a {"type":"lib-load","target":"<path>"} JSON entry to stderr, which runner.js parses into result.auditLog.
CLI:
# Trace library loads on Linux (requires gcc)
safer-exec --trace-libraries -- node myapp.js
# Combine with audit output
safer-exec --trace-libraries --audit -- python3 script.pyNote
--trace-libraries requires gcc or cc to be installed on Linux for runtime compilation of the audit helper. On macOS, no compiler is required as the mechanism uses the existing Seatbelt audit infrastructure.
The sandbox automatically injects the following environment variable into the sandboxed process environment:
RUNNING_IN_SAFER_EXEC_SANDBOX=true: Indication that the command is running inside a secure sandbox (useful for downstream tools to detect the sandboxed environment and suppress warnings or adjust path lookups).
Prior to executing a command, safer-exec scans the environment variables mapping (env) case-insensitively for keys containing potentially sensitive strings (such as TOKEN, PASSWORD, SECRET, API_KEY, CLIENT_SECRET, SESSION, COOKIE, AUTH, and KEY).
If any sensitive keys are detected, a consolidated warning is logged to standard error (stderr):
safer-exec: warning: sensitive environment variables detected: GITHUB_TOKEN, MY_SECRET
safer-exec supports auditing and enforcing FIPS (Federal Information Processing Standards) compliance:
- Linux: When
detectFIPSorstrictFIPSis enabled, the sandbox checks the host state/proc/sys/crypto/fips_enabledand mirrors this virtualized file inside the container. IfstrictFIPSis configured and the host lacks FIPS compliance, execution is blocked and afips-violationis audited. - macOS: On macOS, FIPS state is verified by reading Apple's security preference plist (
FIPSMode). IfstrictFIPSis active and FIPSMode is disabled on the host, a validation failure is triggered. - Auto-Discovery: During learn mode, if a process attempts to query the FIPS status or dynamic FIPS module providers (e.g.
fips.soorfips.dylib), the generated policy automatically setsfipsDetected: true.
cd go
go build -o bin/safer-exec ./cmd/safer-exec/npm run test:unit # Unit tests
npm run test:integration # Integration tests
npm run test:security # Security boundary tests
npm run test:benchmark # Performance benchmarksMIT