diff --git a/AGENTS.md b/AGENTS.md index 9a837b2..e151f7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ vault (standalone) sandbox (standalone) ### Builtin Tools Located in `packages/core/src/tools/builtin/`: - `read.ts`, `write.ts`, `edit.ts` -- File operations -- `bash.ts` -- Shell command execution +- `bash.ts` -- Shell command execution; `createBashTool(options?)` factory accepts `allowedCommandPaths` for advisory command validation (warns when a binary is not under an allowed directory; Landlock is the real enforcement) - `web-fetch.ts` -- HTTP fetching - `web-search.ts` -- Web search via Brave Search API (conditionally included when `brave_api_key` exists in vault) - `process.ts` -- Background process management (start/status/log/kill/list) @@ -84,6 +84,7 @@ Each tool declares `requiredCapabilities` and implements a `ToolHandler` interfa ### Sandbox - `packages/sandbox/src/sandbox.ts` -- Spawns child process with `unshare` + native helper +- `packages/sandbox/src/policy-builder.ts` -- `PolicyBuilder` class with fluent API; `PolicyBuilder.forDevelopment(cwd, options?)` creates a development-ready policy with allowlisted system paths, compiler toolchains (JVM, GCC), an expanded ~120 syscall allowlist, and support for `extraExecutePaths`/`extraReadWritePaths` via `DevelopmentPolicyOptions` - `native/src/main.c` -- C helper binary that applies: Landlock filesystem rules, seccomp-BPF syscall filtering, capability dropping, `PR_SET_NO_NEW_PRIVS` - Policy sent to helper via fd 3 as JSON diff --git a/README.md b/README.md index 5b27f3c..ccfe2b4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ After install, run `safeclaw onboard` for first-time setup. ### Security - Zero-trust security model with mandatory OS-level sandboxing (Landlock + seccomp-BPF + Linux namespaces) +- Development-ready sandbox policy via `PolicyBuilder.forDevelopment()` — allows compilers (GCC, JVM), package managers, and standard dev tools while enforcing kernel-level access control - AES-256-GCM encrypted secrets vault with OS keyring or passphrase-derived keys - Ed25519-signed skill manifests with capability declarations and runtime enforcement - Capability-based access control with path/host/executable constraints @@ -36,6 +37,7 @@ After install, run `safeclaw onboard` for first-time setup. ### Tools - Built-in tools: file read/write/edit, bash execution, web fetch, web search, background process management, multi-file patch application — all capability-gated +- Advisory command validation in bash tool warns when binaries are outside allowed paths (Landlock enforces the real boundary) - Web search via Brave Search API (conditionally included when API key is in vault) - Background process management with ring buffer output capture (1MB max, 8 concurrent, 1h auto-cleanup) - Multi-file patch tool with unified diff parsing, fuzzy hunk matching, and atomic writes @@ -63,7 +65,7 @@ Planned features in implementation order: | # | Feature | Plan | Priority | |---|---------|------|----------| -| 1 | Sandbox command execution & CWD permissions | [plan](docs/plans/2026-03-05-sandbox-permissions.md) | High | +| 1 | Sandbox command execution & CWD permissions | [plan](docs/plans/2026-03-05-sandbox-permissions.md) | **Done** | | 2 | Automatic context compaction | [plan](docs/plans/2026-03-05-context-compaction.md) | High | | 3 | Streaming UX (Phase 1 — readline) | [plan](docs/plans/2026-03-05-streaming-ux.md) | High | | 4 | Better CLI/TUI (Ink-based) | [plan](docs/plans/2026-03-05-tui.md) | High | @@ -89,7 +91,7 @@ Planned features in implementation order: Monorepo structure: - `@safeclaw/vault` — Encrypted secrets storage -- `@safeclaw/sandbox` — OS-level process sandboxing +- `@safeclaw/sandbox` — OS-level process sandboxing with `PolicyBuilder` for development-ready policies - `@safeclaw/core` — Capabilities, agent runtime, sessions, tools, skills, model providers, copilot client - `@safeclaw/gateway` — HTTP server with auth and rate limiting - `@safeclaw/cli` — Command-line interface diff --git a/docs/architecture.md b/docs/architecture.md index 6957dc1..565885e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,6 +73,7 @@ No dependencies on other SafeClaw packages. OS-level process isolation using Linux kernel features. - `SandboxPolicy` / `DEFAULT_POLICY`: policy types with maximally restrictive defaults +- `PolicyBuilder`: fluent API for constructing sandbox policies; `PolicyBuilder.forDevelopment(cwd)` creates a ready-made policy for software development with allowlisted system paths, compiler toolchains, and an expanded syscall set - `detectKernelCapabilities`: probes `/proc` for Landlock, seccomp, namespace support - `assertSandboxSupported`: throws if required kernel features are missing - `Sandbox` class: executes commands under policy (stub in v1; types and policies are real) @@ -114,6 +115,7 @@ Central package containing the agent runtime and security infrastructure. - `SimpleToolRegistry`: in-memory tool handler storage - `AuditLog`: records tool executions (request + result + timestamp) - Built-in tools: `read`, `write`, `edit`, `bash`, `web_fetch`, `apply_patch`, `process` (plus optional `web_search` when `brave_api_key` is in vault) +- `createBashTool(options?)`: factory function for the bash tool; accepts `allowedCommandPaths` for advisory command validation that warns when a binary is not under an allowed directory (Landlock is the real enforcement layer) - `ProcessManager`: tracks spawned child processes by UUID with ring buffer output capture (1MB max per process), automatic cleanup after 1 hour, and maximum 8 concurrent processes - `PatchParser` / `PatchApplier`: unified diff parser and hunk applier with fuzzy line-offset matching; used by the `apply_patch` tool for atomic multi-file patching diff --git a/docs/security-model.md b/docs/security-model.md index 1caacf9..420b236 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -19,7 +19,9 @@ Landlock (kernel >= 5.13) restricts filesystem access at the kernel level. The s ### seccomp-BPF -seccomp-BPF filters system calls. The default policy uses `defaultDeny: true` and allows only a minimal set of syscalls required for basic process operation: +seccomp-BPF filters system calls. All policies use `defaultDeny: true` and allow only explicitly listed syscalls. Any syscall not in the allow list is blocked. + +**DEFAULT_POLICY** allows a minimal set for basic process operation (~28 syscalls): ``` read, write, exit, exit_group, brk, mmap, close, fstat, mprotect, @@ -28,7 +30,20 @@ execve, wait4, uname, fcntl, getcwd, arch_prctl, set_tid_address, set_robust_list, rseq, prlimit64, getrandom ``` -Any syscall not in the allow list is blocked. +**Development policy** (`PolicyBuilder.forDevelopment()`) expands this to ~120 syscalls organized by category: + +- **Core I/O**: read, write, open, openat, close, lseek, pread64, pwrite64, readv, writev, sendfile +- **Memory**: mmap, mprotect, munmap, brk, mremap, madvise, msync +- **File metadata**: stat, fstat, lstat, newfstatat, fstatfs, statfs, statx +- **Directory**: getdents64, mkdir, rmdir, chdir, fchdir, getcwd +- **File operations**: rename, renameat2, unlink, unlinkat, link, linkat, symlink, symlinkat, readlink, readlinkat +- **File descriptors**: dup, dup2, dup3, fcntl, pipe, pipe2, eventfd2, epoll_create1, epoll_ctl, epoll_wait, epoll_pwait, select, pselect6, poll, ppoll +- **Permissions**: chmod, fchmod, fchmodat, chown, fchown, access, faccessat, faccessat2, umask +- **Process**: fork, vfork, clone, clone3, execve, execveat, wait4, waitid, exit, exit_group, getpid, getppid, gettid, getuid, getgid, geteuid, getegid, getgroups, setsid, setpgid, getpgid, getpgrp +- **Signals**: rt_sigaction, rt_sigprocmask, rt_sigreturn, kill, tgkill, rt_sigsuspend, sigaltstack +- **Time/clock**: clock_gettime, clock_getres, gettimeofday, nanosleep, clock_nanosleep, timer_create, timer_settime, timer_delete, timerfd_create, timerfd_settime, timerfd_gettime +- **Networking**: socket, connect, bind, listen, accept, accept4, recvfrom, sendto, recvmsg, sendmsg, shutdown, setsockopt, getsockopt, getpeername, getsockname, socketpair +- **Misc**: ioctl, prctl, arch_prctl, set_tid_address, set_robust_list, futex, sched_yield, sched_getaffinity, uname, prlimit64, getrandom, rseq, memfd_create, copy_file_range, fadvise64, fallocate, ftruncate, truncate, mlock, munlock, mincore ### Linux namespaces @@ -57,6 +72,24 @@ const DEFAULT_POLICY: SandboxPolicy = { Skills that need filesystem or network access must declare capabilities, and those capabilities must be granted before the tool orchestrator will allow execution. +### Development policy (PolicyBuilder) + +For practical software development, `PolicyBuilder.forDevelopment(cwd)` creates a policy that allows compilers, package managers, and standard dev tools to run while maintaining security: + +```typescript +const policy = PolicyBuilder.forDevelopment(process.cwd()); +const sandbox = new Sandbox(policy); +``` + +The development policy grants: +- **Execute access**: `/usr/bin`, `/usr/local/bin`, `/bin`, `/usr/sbin`, `/sbin`, `/usr/lib/jvm`, `/usr/lib/gcc`, `/usr/libexec`, plus the Node.js install prefix +- **Read access**: `/usr/include`, `/usr/share`, shared library paths (`/lib`, `/usr/lib`, `/lib64`, `/usr/lib64`), `/etc`, `/proc`, `/dev/null`, `/dev/urandom`, `/dev/zero`, `/dev/random` +- **Read-write access**: CWD, `/tmp`, `~/.safeclaw` +- **Extra paths**: `DevelopmentPolicyOptions` supports `extraExecutePaths` (e.g., `~/.cargo/bin`, `~/.rustup`) and `extraReadWritePaths` for user-local toolchains +- **Expanded syscalls**: ~120 syscalls (see seccomp-BPF section above) +- **Network**: `"none"` (unchanged from default) +- **Namespaces**: all enabled (PID, net, mount, user) + For a detailed explanation of enforcement layers, the helper binary architecture, policy format, and security guarantees, see [Sandboxing Deep Dive](sandboxing.md). ## Capability system diff --git a/native/src/landlock.c b/native/src/landlock.c index 5c91a9a..b9bc769 100644 --- a/native/src/landlock.c +++ b/native/src/landlock.c @@ -130,7 +130,8 @@ static uint64_t policy_access_to_landlock(int access_level, int abi) if (access_level == ACCESS_EXECUTE) { rights |= LANDLOCK_ACCESS_FS_EXECUTE | - LANDLOCK_ACCESS_FS_READ_FILE; + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR; } return rights; diff --git a/packages/cli/src/commands/bootstrap.test.ts b/packages/cli/src/commands/bootstrap.test.ts index a0d1a51..fa423cf 100644 --- a/packages/cli/src/commands/bootstrap.test.ts +++ b/packages/cli/src/commands/bootstrap.test.ts @@ -7,11 +7,23 @@ import { SessionManager, } from "@safeclaw/core"; const MockSandbox = vi.fn(); +const mockForDevelopment = vi.fn().mockReturnValue({ + filesystem: { + allow: [ + { path: "/bin", access: "execute" }, + { path: "/usr/bin", access: "execute" }, + ], + deny: [], + }, + syscalls: { allow: ["read", "write"], defaultDeny: true }, + network: "none", + namespaces: { pid: true, net: true, mnt: true, user: true }, + timeoutMs: 30_000, +}); vi.mock("@safeclaw/sandbox", () => ({ Sandbox: MockSandbox, - DEFAULT_POLICY: { - namespaces: { pid: true, net: true, mnt: true, user: true }, - timeoutMs: 30_000, + PolicyBuilder: { + forDevelopment: mockForDevelopment, }, })); @@ -72,6 +84,20 @@ function createMockDeps( describe("bootstrapAgent", () => { beforeEach(() => { MockSandbox.mockReset(); + mockForDevelopment.mockClear(); + mockForDevelopment.mockReturnValue({ + filesystem: { + allow: [ + { path: "/bin", access: "execute" }, + { path: "/usr/bin", access: "execute" }, + ], + deny: [], + }, + syscalls: { allow: ["read", "write"], defaultDeny: true }, + network: "none", + namespaces: { pid: true, net: true, mnt: true, user: true }, + timeoutMs: 30_000, + }); }); it("returns agent and sessionManager when vault exists with passphrase key", async () => { @@ -253,15 +279,15 @@ describe("bootstrapAgent", () => { ); }); - it("constructs Sandbox with DEFAULT_POLICY", async () => { + it("constructs Sandbox with PolicyBuilder.forDevelopment()", async () => { const deps = createMockDeps(); await bootstrapAgent(deps); - expect(MockSandbox).toHaveBeenCalledWith({ - namespaces: { pid: true, net: true, mnt: true, user: true }, - timeoutMs: 30_000, - }); + expect(mockForDevelopment).toHaveBeenCalledWith(process.cwd()); + expect(MockSandbox).toHaveBeenCalledWith( + mockForDevelopment.mock.results[0]!.value, + ); }); it("falls back gracefully when Sandbox constructor throws", async () => { diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index 03cd42d..06d360a 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -32,7 +32,7 @@ import { KeyringProvider as DefaultKeyringProvider, deriveKeyFromPassphrase as defaultDeriveKey, } from "@safeclaw/vault"; -import { Sandbox, DEFAULT_POLICY } from "@safeclaw/sandbox"; +import { Sandbox, PolicyBuilder } from "@safeclaw/sandbox"; import { readPassphrase as defaultReadPassphrase } from "../readPassphrase.js"; export interface BootstrapDeps { @@ -169,16 +169,22 @@ export async function bootstrapAgent( } const enforcer = new CapabilityEnforcer(capabilityRegistry); + // Build sandbox policy first — we extract allowed paths for bash tool validation + const sandboxPolicy = PolicyBuilder.forDevelopment(process.cwd()); + const allowedCommandPaths = sandboxPolicy.filesystem.allow + .filter((r) => r.access === "execute" || r.access === "readwrite") + .map((r) => r.path); + const braveApiKey = vault.get("brave_api_key"); const processManager = new ProcessManager(); const toolRegistry = new SimpleToolRegistry(); - for (const tool of createBuiltinTools({ braveApiKey, processManager })) { + for (const tool of createBuiltinTools({ braveApiKey, processManager, allowedCommandPaths })) { toolRegistry.register(tool); } let sandbox: Sandbox | undefined; try { - sandbox = new Sandbox(DEFAULT_POLICY); + sandbox = new Sandbox(sandboxPolicy); } catch (err: unknown) { // Sandbox not supported on this system — fall back to unsandboxed const detail = err instanceof Error ? err.message : String(err); diff --git a/packages/core/src/tools/builtin/bash.test.ts b/packages/core/src/tools/builtin/bash.test.ts index 7279cb9..74de0ac 100644 --- a/packages/core/src/tools/builtin/bash.test.ts +++ b/packages/core/src/tools/builtin/bash.test.ts @@ -167,4 +167,186 @@ describe("bashTool", () => { expect(result).toContain("spawn failed"); }); + + describe("command validation", () => { + it("warns when command binary is outside allowed paths", async () => { + // Mock which to return a path outside allowed dirs + vi.mocked(execFile).mockImplementation( + (cmd: unknown, args: unknown, opts: unknown, cb: unknown) => { + const cmdStr = cmd as string; + const argsArr = args as string[]; + if (cmdStr === "which" || (cmdStr === "/bin/bash" && argsArr[1] === "which suspicious-binary")) { + (cb as (err: null, stdout: string, stderr: string) => void)( + null, + "/opt/sketchy/suspicious-binary\n", + "", + ); + } else { + (cb as (err: null, stdout: string, stderr: string) => void)( + null, + "done\n", + "", + ); + } + return undefined as never; + }, + ); + + const result = await bashTool.execute({ + command: "suspicious-binary --flag", + allowedPaths: ["/bin", "/usr/bin", "/usr/local/bin"], + }); + + expect(result).toContain("Warning"); + expect(result).toContain("suspicious-binary"); + expect(result).toContain("not on the allowed"); + }); + + it("does not warn when command binary is in allowed paths", async () => { + vi.mocked(execFile).mockImplementation( + (cmd: unknown, args: unknown, _opts: unknown, cb: unknown) => { + const cmdStr = cmd as string; + const argsArr = args as string[]; + if (cmdStr === "/bin/bash" && argsArr[1] === "which ls") { + (cb as (err: null, stdout: string, stderr: string) => void)( + null, + "/usr/bin/ls\n", + "", + ); + } else { + (cb as (err: null, stdout: string, stderr: string) => void)( + null, + "output\n", + "", + ); + } + return undefined as never; + }, + ); + + const result = await bashTool.execute({ + command: "ls -la", + allowedPaths: ["/bin", "/usr/bin", "/usr/local/bin"], + }); + + expect(result).not.toContain("Warning"); + expect(result).toContain("output"); + }); + + it("skips validation when allowedPaths is not provided", async () => { + vi.mocked(execFile).mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { + (cb as (err: null, stdout: string, stderr: string) => void)( + null, + "output\n", + "", + ); + return undefined as never; + }, + ); + + const result = await bashTool.execute({ command: "some-cmd" }); + + expect(result).not.toContain("Warning"); + // which should not have been called — only one execFile call for the actual command + expect(execFile).toHaveBeenCalledTimes(1); + }); + + it("skips validation for shell builtins (cd, echo, etc.)", async () => { + vi.mocked(execFile).mockImplementation( + (cmd: unknown, args: unknown, _opts: unknown, cb: unknown) => { + const cmdStr = cmd as string; + const argsArr = args as string[]; + if (cmdStr === "/bin/bash" && argsArr[1]?.startsWith("which ")) { + // which would fail for builtins + const err = new Error("not found") as Error & { + code: number; + stdout: string; + stderr: string; + }; + err.code = 1; + err.stdout = ""; + err.stderr = ""; + (cb as (err: Error) => void)(err); + } else { + (cb as (err: null, stdout: string, stderr: string) => void)( + null, + "output\n", + "", + ); + } + return undefined as never; + }, + ); + + const result = await bashTool.execute({ + command: "echo hello", + allowedPaths: ["/bin", "/usr/bin"], + }); + + // Should not warn for builtins — just proceed + expect(result).not.toContain("Warning"); + }); + + it("warns for compiler outside allowed paths", async () => { + vi.mocked(execFile).mockImplementation( + (cmd: unknown, args: unknown, _opts: unknown, cb: unknown) => { + const cmdStr = cmd as string; + const argsArr = args as string[]; + if (cmdStr === "/bin/bash" && argsArr[1] === "which rustc") { + (cb as (err: null, stdout: string, stderr: string) => void)( + null, + "/home/user/.cargo/bin/rustc\n", + "", + ); + } else { + (cb as (err: null, stdout: string, stderr: string) => void)( + null, + "compiled\n", + "", + ); + } + return undefined as never; + }, + ); + + const result = await bashTool.execute({ + command: "rustc main.rs", + allowedPaths: ["/bin", "/usr/bin", "/usr/local/bin"], + }); + + expect(result).toContain("Warning"); + expect(result).toContain("rustc"); + }); + + it("does not warn for compiler inside allowed paths", async () => { + vi.mocked(execFile).mockImplementation( + (cmd: unknown, args: unknown, _opts: unknown, cb: unknown) => { + const cmdStr = cmd as string; + const argsArr = args as string[]; + if (cmdStr === "/bin/bash" && argsArr[1] === "which gcc") { + (cb as (err: null, stdout: string, stderr: string) => void)( + null, + "/usr/bin/gcc\n", + "", + ); + } else { + (cb as (err: null, stdout: string, stderr: string) => void)( + null, + "compiled\n", + "", + ); + } + return undefined as never; + }, + ); + + const result = await bashTool.execute({ + command: "gcc -o main main.c", + allowedPaths: ["/bin", "/usr/bin", "/usr/local/bin"], + }); + + expect(result).not.toContain("Warning"); + }); + }); }); diff --git a/packages/core/src/tools/builtin/bash.ts b/packages/core/src/tools/builtin/bash.ts index e8b574f..c4a9226 100644 --- a/packages/core/src/tools/builtin/bash.ts +++ b/packages/core/src/tools/builtin/bash.ts @@ -3,79 +3,194 @@ import type { ToolHandler } from "../types.js"; const DEFAULT_TIMEOUT = 120_000; -export const bashTool: ToolHandler = { - name: "bash", - description: "Execute a shell command via /bin/bash", - requiredCapabilities: ["process:spawn"], - - parameters: { - type: "object", - properties: { - command: { - type: "string", - description: "Shell command to execute via /bin/bash", - }, - timeout: { - type: "integer", - description: "Timeout in milliseconds", - default: 120000, - minimum: 1, +/** Shell builtins that `which` cannot resolve — skip validation for these */ +const SHELL_BUILTINS = new Set([ + "alias", "bg", "bind", "break", "builtin", "caller", "case", "cd", + "command", "compgen", "complete", "compopt", "continue", "coproc", + "declare", "dirs", "disown", "do", "done", "echo", "elif", "else", + "enable", "esac", "eval", "exec", "exit", "export", "false", "fc", + "fg", "fi", "for", "function", "getopts", "hash", "help", "history", + "if", "in", "jobs", "let", "local", "logout", "mapfile", "popd", + "printf", "pushd", "pwd", "read", "readarray", "readonly", "return", + "select", "set", "shift", "shopt", "source", "suspend", "test", + "then", "time", "times", "trap", "true", "type", "typeset", "ulimit", + "umask", "unalias", "unset", "until", "wait", "while", +]); + +/** + * Extract the first command binary name from a shell command string. + * Handles: "gcc -o main main.c", "FOO=1 gcc ...", "cd /tmp && ls", etc. + */ +function extractBinaryName(command: string): string | undefined { + // Strip leading env assignments (VAR=value) + const tokens = command.trim().split(/\s+/); + for (const token of tokens) { + if (token.includes("=") && !token.startsWith("-")) continue; + // Return the first non-assignment token (the binary name) + return token; + } + return undefined; +} + +/** + * Resolve a command binary path using `which` and check if it's in the allowed paths. + * Returns a warning string if the binary is outside allowed paths, or undefined if OK. + */ +function validateCommand( + binary: string, + allowedPaths: string[], +): Promise { + return new Promise((resolve) => { + if (SHELL_BUILTINS.has(binary)) { + resolve(undefined); + return; + } + + execFile( + "/bin/bash", + ["-c", `which ${binary}`], + { timeout: 5_000 }, + (err: Error | null, stdout: string) => { + if (err) { + // `which` failed — binary not found or is a builtin. Don't warn. + resolve(undefined); + return; + } + + const resolvedPath = stdout.trim(); + if (!resolvedPath) { + resolve(undefined); + return; + } + + // Check if resolved path is under any allowed path + const isAllowed = allowedPaths.some( + (allowed) => + resolvedPath === allowed || + resolvedPath.startsWith(allowed + "/"), + ); + + if (!isAllowed) { + resolve( + `Warning: Command '${binary}' resolves to '${resolvedPath}' which is not on the allowed paths list.\n` + + `Allowed paths: ${allowedPaths.join(", ")}\n` + + `The sandbox's Landlock rules will still enforce filesystem restrictions.\n`, + ); + } else { + resolve(undefined); + } }, - workdir: { - type: "string", - description: "Working directory for command execution", + ); + }); +} + +export interface BashToolOptions { + /** Allowed filesystem paths for command validation (advisory check) */ + allowedPaths?: string[]; +} + +/** + * Creates a bash tool handler with optional command validation. + * When `allowedPaths` is provided, the tool will warn (not block) if the + * command binary resolves to a path outside the allowed list. + */ +export function createBashTool(options?: BashToolOptions): ToolHandler { + const configuredAllowedPaths = options?.allowedPaths; + + return { + name: "bash", + description: "Execute a shell command via /bin/bash", + requiredCapabilities: ["process:spawn"], + + parameters: { + type: "object", + properties: { + command: { + type: "string", + description: "Shell command to execute via /bin/bash", + }, + timeout: { + type: "integer", + description: "Timeout in milliseconds", + default: 120000, + minimum: 1, + }, + workdir: { + type: "string", + description: "Working directory for command execution", + }, }, + required: ["command"], + additionalProperties: false, }, - required: ["command"], - additionalProperties: false, - }, - - async execute(args: Record): Promise { - const command = args["command"]; - if (typeof command !== "string") { - throw new Error("Required argument 'command' must be a string"); - } - const timeout = - args["timeout"] !== undefined ? Number(args["timeout"]) : DEFAULT_TIMEOUT; - if (Number.isNaN(timeout)) { - throw new Error("'timeout' must be a number"); - } + async execute(args: Record): Promise { + const command = args["command"]; + if (typeof command !== "string") { + throw new Error("Required argument 'command' must be a string"); + } - const workdir = - args["workdir"] !== undefined ? String(args["workdir"]) : undefined; - - return new Promise((resolve, _reject) => { - execFile( - "/bin/bash", - ["-c", command], - { - timeout, - cwd: workdir, - maxBuffer: 10 * 1024 * 1024, - }, - (err: Error | null, stdout: string, stderr: string) => { - if (err) { - // Non-zero exit: still return output rather than rejecting, - // since the LLM needs to see error messages - const errWithOutput = err as Error & { - stdout?: string; - stderr?: string; - code?: number; - }; + const timeout = + args["timeout"] !== undefined ? Number(args["timeout"]) : DEFAULT_TIMEOUT; + if (Number.isNaN(timeout)) { + throw new Error("'timeout' must be a number"); + } + + const workdir = + args["workdir"] !== undefined ? String(args["workdir"]) : undefined; + + // Advisory command validation — check if binary is in allowed paths + // Use runtime args override if provided, otherwise use configured paths + const allowedPaths = + (args["allowedPaths"] as string[] | undefined) ?? configuredAllowedPaths; + let warning: string | undefined; + if (allowedPaths && allowedPaths.length > 0) { + const binary = extractBinaryName(command); + if (binary) { + warning = await validateCommand(binary, allowedPaths); + } + } + + const output = await new Promise((resolve) => { + execFile( + "/bin/bash", + ["-c", command], + { + timeout, + cwd: workdir, + maxBuffer: 10 * 1024 * 1024, + }, + (err: Error | null, stdout: string, stderr: string) => { + if (err) { + // Non-zero exit: still return output rather than rejecting, + // since the LLM needs to see error messages + const errWithOutput = err as Error & { + stdout?: string; + stderr?: string; + code?: number; + }; + const parts: string[] = []; + if (errWithOutput.stdout) parts.push(errWithOutput.stdout); + if (errWithOutput.stderr) parts.push(errWithOutput.stderr); + if (parts.length === 0) parts.push(err.message); + resolve(parts.join("\n")); + return; + } const parts: string[] = []; - if (errWithOutput.stdout) parts.push(errWithOutput.stdout); - if (errWithOutput.stderr) parts.push(errWithOutput.stderr); - if (parts.length === 0) parts.push(err.message); + if (stdout) parts.push(stdout); + if (stderr) parts.push(stderr); resolve(parts.join("\n")); - return; - } - const parts: string[] = []; - if (stdout) parts.push(stdout); - if (stderr) parts.push(stderr); - resolve(parts.join("\n")); - }, - ); - }); - }, -}; + }, + ); + }); + + if (warning) { + return warning + output; + } + return output; + }, + }; +} + +/** Default bash tool (no command validation) for backwards compatibility */ +export const bashTool: ToolHandler = createBashTool(); diff --git a/packages/core/src/tools/builtin/index.ts b/packages/core/src/tools/builtin/index.ts index 00e9131..0ec3537 100644 --- a/packages/core/src/tools/builtin/index.ts +++ b/packages/core/src/tools/builtin/index.ts @@ -3,24 +3,30 @@ import type { ProcessManager } from "../process-manager.js"; import { readTool } from "./read.js"; import { writeTool } from "./write.js"; import { editTool } from "./edit.js"; -import { bashTool } from "./bash.js"; +import { bashTool, createBashTool } from "./bash.js"; import { webFetchTool } from "./web-fetch.js"; import { createWebSearchTool } from "./web-search.js"; import { createProcessTool } from "./process.js"; import { applyPatchTool } from "./apply-patch.js"; -export { readTool, writeTool, editTool, bashTool, webFetchTool, applyPatchTool }; +export { readTool, writeTool, editTool, bashTool, createBashTool, webFetchTool, applyPatchTool }; export { createWebSearchTool } from "./web-search.js"; export { createProcessTool } from "./process.js"; export interface BuiltinToolsOptions { braveApiKey?: string | undefined; processManager?: ProcessManager | undefined; + /** Allowed filesystem paths for bash command validation (advisory) */ + allowedCommandPaths?: string[] | undefined; } /** Creates an array of all built-in tool handlers. */ export function createBuiltinTools(options?: BuiltinToolsOptions): ToolHandler[] { - const tools: ToolHandler[] = [readTool, writeTool, editTool, bashTool, webFetchTool, applyPatchTool]; + const bash = options?.allowedCommandPaths + ? createBashTool({ allowedPaths: options.allowedCommandPaths }) + : bashTool; + + const tools: ToolHandler[] = [readTool, writeTool, editTool, bash, webFetchTool, applyPatchTool]; if (options?.braveApiKey) { tools.push(createWebSearchTool(options.braveApiKey)); diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 1b48ef2..c2b6fc8 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -12,6 +12,7 @@ export { writeTool, editTool, bashTool, + createBashTool, webFetchTool, applyPatchTool, createWebSearchTool, diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index d9fd7a1..26e1743 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -12,3 +12,5 @@ export type { EnforcementLayers, } from "./types.js"; export { findHelper } from "./helper.js"; +export { PolicyBuilder } from "./policy-builder.js"; +export type { DevelopmentPolicyOptions } from "./policy-builder.js"; diff --git a/packages/sandbox/src/policy-builder.test.ts b/packages/sandbox/src/policy-builder.test.ts new file mode 100644 index 0000000..394881c --- /dev/null +++ b/packages/sandbox/src/policy-builder.test.ts @@ -0,0 +1,331 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { PolicyBuilder } from "./policy-builder.js"; +import type { SandboxPolicy, PathRule } from "./types.js"; + +describe("PolicyBuilder", () => { + describe("addReadExecute()", () => { + it("adds a path with read access", () => { + const policy = new PolicyBuilder().addReadExecute("/usr/bin").build(); + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === "/usr/bin", + ); + expect(rule).toBeDefined(); + expect(rule!.access).toBe("execute"); + }); + }); + + describe("addReadWrite()", () => { + it("adds a path with readwrite access", () => { + const policy = new PolicyBuilder().addReadWrite("/tmp").build(); + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === "/tmp", + ); + expect(rule).toBeDefined(); + expect(rule!.access).toBe("readwrite"); + }); + }); + + describe("addReadOnly()", () => { + it("adds a path with read access", () => { + const policy = new PolicyBuilder().addReadOnly("/etc").build(); + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === "/etc", + ); + expect(rule).toBeDefined(); + expect(rule!.access).toBe("read"); + }); + }); + + describe("build()", () => { + it("returns a valid SandboxPolicy", () => { + const policy = new PolicyBuilder().build(); + expect(policy.filesystem).toBeDefined(); + expect(policy.filesystem.allow).toBeInstanceOf(Array); + expect(policy.filesystem.deny).toBeInstanceOf(Array); + expect(policy.syscalls.defaultDeny).toBe(true); + expect(policy.network).toBe("none"); + expect(policy.namespaces).toEqual({ + pid: true, + net: true, + mnt: true, + user: true, + }); + }); + + it("starts with an empty policy", () => { + const policy = new PolicyBuilder().build(); + expect(policy.filesystem.allow).toHaveLength(0); + expect(policy.syscalls.allow).toHaveLength(0); + }); + + it("chains builder methods", () => { + const builder = new PolicyBuilder(); + const result = builder + .addReadExecute("/bin") + .addReadWrite("/tmp") + .addReadOnly("/etc"); + expect(result).toBe(builder); + }); + }); + + describe("forDevelopment()", () => { + let policy: SandboxPolicy; + + beforeEach(() => { + policy = PolicyBuilder.forDevelopment("/home/dev/project"); + }); + + it("includes CWD as readwrite", () => { + const cwd = policy.filesystem.allow.find( + (r: PathRule) => r.path === "/home/dev/project", + ); + expect(cwd).toBeDefined(); + expect(cwd!.access).toBe("readwrite"); + }); + + it("includes /tmp as readwrite", () => { + const tmp = policy.filesystem.allow.find( + (r: PathRule) => r.path === "/tmp", + ); + expect(tmp).toBeDefined(); + expect(tmp!.access).toBe("readwrite"); + }); + + it("includes standard command paths as execute", () => { + const expectedPaths = [ + "/bin", + "/usr/bin", + "/usr/local/bin", + "/sbin", + "/usr/sbin", + ]; + for (const p of expectedPaths) { + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === p, + ); + expect(rule, `expected ${p} to be in allow list`).toBeDefined(); + expect(rule!.access).toBe("execute"); + } + }); + + it("includes shared library paths as execute", () => { + const expectedPaths = [ + "/usr/lib", + "/usr/lib64", + "/usr/local/lib", + "/usr/local/lib64", + "/lib", + "/lib64", + ]; + for (const p of expectedPaths) { + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === p, + ); + expect(rule, `expected ${p} to be in allow list`).toBeDefined(); + expect(rule!.access).toBe("execute"); + } + }); + + it("includes /etc as read-only", () => { + const etc = policy.filesystem.allow.find( + (r: PathRule) => r.path === "/etc", + ); + expect(etc).toBeDefined(); + expect(etc!.access).toBe("read"); + }); + + it("includes /proc/self as read-only", () => { + const proc = policy.filesystem.allow.find( + (r: PathRule) => r.path === "/proc/self", + ); + expect(proc).toBeDefined(); + expect(proc!.access).toBe("read"); + }); + + it("includes device nodes as read-only", () => { + const devices = ["/dev/null", "/dev/urandom", "/dev/zero"]; + for (const d of devices) { + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === d, + ); + expect(rule, `expected ${d} to be in allow list`).toBeDefined(); + expect(rule!.access).toBe("read"); + } + }); + + it("includes Node.js install path as execute", () => { + // Node.js install path is derived from process.execPath + // It should be something like /usr/local or /home/user/.nvm/versions/... + const nodeRules = policy.filesystem.allow.filter( + (r: PathRule) => r.access === "execute", + ); + // The node install dir should be in the allow list + const nodeInstallDir = process.execPath + .split("/") + .slice(0, -2) + .join("/"); + const found = nodeRules.some( + (r: PathRule) => r.path === nodeInstallDir, + ); + // Node install might already be under /usr/local/bin or /usr/bin + // so it might not be a separate entry. Just verify it's accessible. + const coveredByStandard = [ + "/bin", + "/usr/bin", + "/usr/local/bin", + "/usr/lib", + "/lib", + "/lib64", + ].some((p) => nodeInstallDir.startsWith(p) || nodeInstallDir === p); + expect( + found || coveredByStandard, + `Node.js install dir ${nodeInstallDir} should be accessible`, + ).toBe(true); + }); + + it("includes ~/.safeclaw as readwrite", () => { + const safeclaw = policy.filesystem.allow.find((r: PathRule) => + r.path.endsWith("/.safeclaw"), + ); + expect(safeclaw).toBeDefined(); + expect(safeclaw!.access).toBe("readwrite"); + }); + + it("has network set to none", () => { + expect(policy.network).toBe("none"); + }); + + it("has all namespaces enabled", () => { + expect(policy.namespaces).toEqual({ + pid: true, + net: true, + mnt: true, + user: true, + }); + }); + + it("has an expanded syscall allowlist", () => { + // Should have significantly more than the DEFAULT_POLICY's 26 syscalls + expect(policy.syscalls.allow.length).toBeGreaterThan(50); + expect(policy.syscalls.defaultDeny).toBe(true); + }); + + it("includes essential syscalls for development tools", () => { + const essentialSyscalls = [ + "openat", + "stat", + "readlink", + "getdents64", + "pipe2", + "clone", + "execve", + "futex", + "clock_gettime", + "newfstatat", + "clone3", + "mkdir", + "unlink", + "rename", + ]; + for (const sc of essentialSyscalls) { + expect( + policy.syscalls.allow, + `expected syscall "${sc}" to be allowed`, + ).toContain(sc); + } + }); + + it("has a 30-second timeout", () => { + expect(policy.timeoutMs).toBe(30_000); + }); + + it("does not duplicate paths", () => { + const paths = policy.filesystem.allow.map((r: PathRule) => r.path); + const unique = new Set(paths); + expect(paths.length).toBe(unique.size); + }); + + it("includes compiler and toolchain paths as execute", () => { + // JDK, GCC libs, Go toolchain — compilers need their internal libs + const compilerPaths = [ + "/usr/lib/jvm", + "/usr/lib/gcc", + "/usr/libexec", + "/usr/local/libexec", + ]; + for (const p of compilerPaths) { + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === p, + ); + expect(rule, `expected ${p} to be in allow list`).toBeDefined(); + expect(rule!.access).toBe("execute"); + } + }); + + it("includes /usr/include and /usr/local/include as read-only for C/C++ headers", () => { + for (const p of ["/usr/include", "/usr/local/include"]) { + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === p, + ); + expect(rule, `expected ${p} to be in allow list`).toBeDefined(); + expect(rule!.access).toBe("read"); + } + }); + + it("includes /usr/share and /usr/local/share as read-only for compiler support files", () => { + for (const p of ["/usr/share", "/usr/local/share"]) { + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === p, + ); + expect(rule, `expected ${p} to be in allow list`).toBeDefined(); + expect(rule!.access).toBe("read"); + } + }); + }); + + describe("forDevelopment() with user toolchains", () => { + it("includes ~/.cargo as execute when it exists", () => { + // forDevelopment detects user-local toolchains + const policy = PolicyBuilder.forDevelopment("/home/dev/project", { + extraExecutePaths: ["/home/dev/.cargo"], + }); + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === "/home/dev/.cargo", + ); + expect(rule).toBeDefined(); + expect(rule!.access).toBe("execute"); + }); + + it("includes ~/.rustup as execute when provided", () => { + const policy = PolicyBuilder.forDevelopment("/home/dev/project", { + extraExecutePaths: ["/home/dev/.rustup"], + }); + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === "/home/dev/.rustup", + ); + expect(rule).toBeDefined(); + expect(rule!.access).toBe("execute"); + }); + + it("includes extra readwrite paths", () => { + const policy = PolicyBuilder.forDevelopment("/home/dev/project", { + extraReadWritePaths: ["/home/dev/.cache"], + }); + const rule = policy.filesystem.allow.find( + (r: PathRule) => r.path === "/home/dev/.cache", + ); + expect(rule).toBeDefined(); + expect(rule!.access).toBe("readwrite"); + }); + + it("deduplicates extra paths against standard paths", () => { + const policy = PolicyBuilder.forDevelopment("/home/dev/project", { + extraExecutePaths: ["/usr/bin"], // already a standard path + }); + const matches = policy.filesystem.allow.filter( + (r: PathRule) => r.path === "/usr/bin", + ); + expect(matches).toHaveLength(1); + }); + }); +}); diff --git a/packages/sandbox/src/policy-builder.ts b/packages/sandbox/src/policy-builder.ts new file mode 100644 index 0000000..5c0be93 --- /dev/null +++ b/packages/sandbox/src/policy-builder.ts @@ -0,0 +1,326 @@ +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { SandboxPolicy, PathRule } from "./types.js"; + +/** Options for customizing the development policy */ +export interface DevelopmentPolicyOptions { + /** Additional paths that need execute access (e.g. ~/.cargo, ~/.rustup) */ + extraExecutePaths?: string[]; + /** Additional paths that need readwrite access (e.g. ~/.cache) */ + extraReadWritePaths?: string[]; +} + +/** + * Builds a SandboxPolicy with a fluent API. + * + * Use `PolicyBuilder.forDevelopment(cwd)` to get a ready-made policy + * for software development work (compilers, package managers, etc.). + */ +export class PolicyBuilder { + private readonly allowRules: PathRule[] = []; + private readonly denyRules: PathRule[] = []; + private readonly syscalls: string[] = []; + private readonly seenPaths = new Set(); + + addReadExecute(path: string): this { + this.addRule(path, "execute"); + return this; + } + + addReadWrite(path: string): this { + this.addRule(path, "readwrite"); + return this; + } + + addReadOnly(path: string): this { + this.addRule(path, "read"); + return this; + } + + private addRule(path: string, access: PathRule["access"]): void { + if (this.seenPaths.has(path)) return; + this.seenPaths.add(path); + this.allowRules.push({ path, access }); + } + + build(): SandboxPolicy { + return { + filesystem: { + allow: [...this.allowRules], + deny: [...this.denyRules], + }, + syscalls: { + allow: [...this.syscalls], + defaultDeny: true as const, + }, + network: "none", + namespaces: { pid: true, net: true, mnt: true, user: true }, + timeoutMs: 30_000, + }; + } + + /** + * Creates a policy suitable for software development. + * + * Grants: + * - Readwrite access to CWD, /tmp, and ~/.safeclaw + * - Execute access to standard command/library paths, compiler toolchains + * - Read access to /etc, /proc/self, device nodes, headers, and support files + * - Expanded syscall allowlist for common dev tools + * - No network access (tools needing network run unsandboxed) + */ + static forDevelopment( + cwd: string, + options?: DevelopmentPolicyOptions, + ): SandboxPolicy { + const builder = new PolicyBuilder(); + + // ── Readwrite paths ────────────────────────────────────────────── + builder.addReadWrite(cwd); + builder.addReadWrite("/tmp"); + builder.addReadWrite(join(homedir(), ".safeclaw")); + + // ── Standard command locations (execute) ───────────────────────── + builder.addReadExecute("/bin"); + builder.addReadExecute("/usr/bin"); + builder.addReadExecute("/usr/local/bin"); + builder.addReadExecute("/sbin"); + builder.addReadExecute("/usr/sbin"); + + // ── Shared libraries (execute) ─────────────────────────────────── + builder.addReadExecute("/usr/lib"); + builder.addReadExecute("/usr/lib64"); + builder.addReadExecute("/usr/local/lib"); + builder.addReadExecute("/usr/local/lib64"); + builder.addReadExecute("/lib"); + builder.addReadExecute("/lib64"); + + // ── Compiler and toolchain paths (execute) ─────────────────────── + // JDK installations (javac, java, jar, etc.) + builder.addReadExecute("/usr/lib/jvm"); + // GCC internal libraries, specs, and cc1/cc1plus + builder.addReadExecute("/usr/lib/gcc"); + // Compiler/linker helper binaries (e.g. ld, as wrappers) + builder.addReadExecute("/usr/libexec"); + builder.addReadExecute("/usr/local/libexec"); + + // ── Node.js install path ───────────────────────────────────────── + // process.execPath is e.g. /home/user/.nvm/versions/node/v22.0.0/bin/node + // We need the grandparent directory for the full installation + const nodeInstallDir = dirname(dirname(process.execPath)); + builder.addReadExecute(nodeInstallDir); + + // ── Read-only paths ────────────────────────────────────────────── + builder.addReadOnly("/etc"); + builder.addReadOnly("/proc/self"); + + // C/C++ system headers + builder.addReadOnly("/usr/include"); + builder.addReadOnly("/usr/local/include"); + + // Compiler support files, man pages, locale data + builder.addReadOnly("/usr/share"); + builder.addReadOnly("/usr/local/share"); + + // Device nodes + builder.addReadOnly("/dev/null"); + builder.addReadOnly("/dev/urandom"); + builder.addReadOnly("/dev/zero"); + + // ── Extra paths from options ───────────────────────────────────── + if (options?.extraExecutePaths) { + for (const p of options.extraExecutePaths) { + builder.addReadExecute(p); + } + } + if (options?.extraReadWritePaths) { + for (const p of options.extraReadWritePaths) { + builder.addReadWrite(p); + } + } + + // ── Expanded syscall allowlist ─────────────────────────────────── + // Includes all DEFAULT_POLICY syscalls plus what common dev tools need + for (const sc of DEVELOPMENT_SYSCALLS) { + builder.syscalls.push(sc); + } + + return builder.build(); + } +} + +/** + * Syscalls needed for typical development tool execution. + * This is the DEFAULT_POLICY set plus everything needed by: + * Node.js, git, pnpm, gcc, javac, rustc, go, make, and common CLI tools. + */ +const DEVELOPMENT_SYSCALLS: readonly string[] = [ + // ── Process lifecycle ────────────────────────────────────────────── + "exit", + "exit_group", + "clone", + "clone3", + "execve", + "execveat", + "wait4", + "waitid", + "kill", + "tgkill", + "getpid", + "getppid", + "getuid", + "getgid", + "geteuid", + "getegid", + "getgroups", + "prctl", + "arch_prctl", + "set_tid_address", + "set_robust_list", + + // ── Memory management ────────────────────────────────────────────── + "brk", + "mmap", + "mprotect", + "munmap", + "mremap", + "madvise", + "memfd_create", + + // ── File operations ──────────────────────────────────────────────── + "read", + "write", + "openat", + "close", + "fstat", + "stat", + "lstat", + "newfstatat", + "statx", + "access", + "readlink", + "readlinkat", + "getdents64", + "lseek", + "pread64", + "pwrite64", + "readv", + "writev", + "fcntl", + "ioctl", + "truncate", + "ftruncate", + + // ── File modification ────────────────────────────────────────────── + "rename", + "renameat", + "renameat2", + "mkdir", + "mkdirat", + "rmdir", + "unlink", + "unlinkat", + "symlink", + "symlinkat", + "chmod", + "fchmod", + "fchmodat", + "chown", + "fchown", + "lchown", + "umask", + "utimensat", + "fallocate", + + // ── Directory navigation ─────────────────────────────────────────── + "getcwd", + "chdir", + "fchdir", + + // ── Pipes and IPC ────────────────────────────────────────────────── + "pipe", + "pipe2", + "dup", + "dup2", + "close_range", + "copy_file_range", + "sendfile", + "splice", + "tee", + + // ── Signals ──────────────────────────────────────────────────────── + "rt_sigaction", + "rt_sigprocmask", + "rt_sigreturn", + "rt_sigtimedwait", + "rt_sigqueueinfo", + "sigaltstack", + + // ── Socket operations (for IPC, not network — Landlock controls net) ── + "socket", + "connect", + "sendto", + "recvfrom", + "sendmsg", + "recvmsg", + "bind", + "listen", + "accept4", + "socketpair", + "getsockname", + "getpeername", + "setsockopt", + "getsockopt", + "shutdown", + + // ── Polling and events ───────────────────────────────────────────── + "poll", + "ppoll", + "select", + "pselect6", + "epoll_create1", + "epoll_ctl", + "epoll_wait", + "eventfd2", + "inotify_init1", + "inotify_add_watch", + "inotify_rm_watch", + "timerfd_create", + "timerfd_settime", + "timerfd_gettime", + "signalfd4", + + // ── Time ─────────────────────────────────────────────────────────── + "clock_gettime", + "gettimeofday", + "nanosleep", + "alarm", + "setitimer", + "getitimer", + "times", + + // ── System info ──────────────────────────────────────────────────── + "uname", + "sysinfo", + "statfs", + "fstatfs", + "getrlimit", + "setrlimit", + "prlimit64", + "sched_getaffinity", + "sched_yield", + "getrandom", + "rseq", + + // ── io_uring (used by modern Node.js and tools) ──────────────────── + "io_uring_setup", + "io_uring_enter", + "io_uring_register", + + // ── Threading / futex ────────────────────────────────────────────── + "futex", + + // ── Misc (needed by Node.js, compilers, linkers) ─────────────────── + "openat2", + "mknod", +];