Sandboxing User Projects¶
When the AI generates code, that code may execute on your server: npm install runs, the Vite dev server boots, and tools the AI calls (shell, fetch) may touch the filesystem. Doable is built so no user project can damage the host or read other users' data, no matter what the model is asked to do.
This page is the implementation-detail view (developer audience). The matching threat-coverage view lives at Security: Sandboxing.
Layered defense (where each layer lives)¶
┌────────────────────────────────────────────────────────────────────┐
│ 1. Policy layer │
│ packages/docore/src/policy/{defaults,store,merge,admin}.ts │
│ Allow-lists for tools, MCP servers, shell commands, URLs; │
│ per-workspace overrides; file or DB persistence. │
├────────────────────────────────────────────────────────────────────┤
│ 2. Permission handler │
│ packages/docore/src/sandbox.ts │
│ Wraps the Copilot SDK's PermissionHandler; gates each tool │
│ call against the policy; emits SandboxAuditEntry per decision. │
├────────────────────────────────────────────────────────────────────┤
│ 3. Process isolator │
│ packages/docore/src/isolator.ts + backends/{nsjail,systemd, │
│ jobobject,direct,unshare}.ts │
│ Runs each AI session in a child process under the strongest │
│ available backend. │
├────────────────────────────────────────────────────────────────────┤
│ 4. Per-project Linux UID │
│ services/api/src/runtime/dev-uid-allocator.ts │
│ Allocates a UID from 10001-65000 pool; survives tsx-watch │
│ restarts via on-disk owner reclaim; fails closed on exhaustion. │
├────────────────────────────────────────────────────────────────────┤
│ 5. dovault: runtime jail for spawned processes │
│ packages/dovault/src/{vault,process-jail,config-guard, │
│ resource-limiter}.ts + composers/* + backends/* │
│ SandboxProfile + backend + composers per spawn. │
├────────────────────────────────────────────────────────────────────┤
│ 6. Workspace rules (tighten only) │
│ services/api/src/sandbox/{workspace-rules,system-rules, │
│ rule-matcher,orchestrator}.ts │
│ Workspace allow/deny lists narrow the profile; never widen it. │
├────────────────────────────────────────────────────────────────────┤
│ 7. Egress firewall │
│ packages/dovault/src/composers/nft-egress.ts + Squid proxy. │
│ nft drops uid-range egress; Squid enforces URL allowlist. │
├────────────────────────────────────────────────────────────────────┤
│ 8. OS containment │
│ Non-root API user, 127.0.0.1 binds, UFW deny-all (except 22), │
│ Cloudflare Tunnel for ingress, fail2ban on sshd. │
└────────────────────────────────────────────────────────────────────┘
Layers 1 to 7 ship as code in this repo. Layer 8 is your host posture and is configured automatically by deployment/server-setup.sh and deployment/docker/setup.sh.
Layer 1: Policy¶
Lives in packages/docore/src/policy/.
A policy answers:
- Which tool names is the AI allowed to call?
- Which MCP servers are connected to this workspace?
- Which shell commands are safe (
ls,git status) vs dangerous (rm -rf,curl | sh)? - Which URLs may the AI
fetch()?
Defaults are in policy/defaults.ts: DEFAULT_SAFE_COMMANDS, DEFAULT_DANGEROUS_COMMANDS, DEFAULT_TRAVERSAL_PATTERNS, DEFAULT_URL_ALLOWLIST. Workspace overrides flow through PolicyAdmin (policy/admin.ts) into PolicyStore (policy/store.ts), persisted via FilePersistence or MemoryPersistence (policy/persistence.ts). The merge logic (policy/merge.ts) ensures admin defaults always win over a user attempt to widen.
Layer 2: Sandbox handler¶
packages/docore/src/sandbox.ts builds a permission handler that the Copilot SDK calls before executing each tool invocation. It:
- Looks up the matching policy via the resolved
PolicyMap. - Returns
allow/deny/ask. - Writes a
SandboxAuditEntryfor every decision.
The audit stream is consumed by the doable UI (Workspace Settings, Audit) so operators can see exactly what the agent tried to do.
Layer 3: Process isolator¶
packages/docore/src/isolator.ts decides how to spawn each AI session worker. Backends:
| Backend | Where it runs | What it adds |
|---|---|---|
NsjailBackend (backends/nsjail.ts) |
Linux with nsjail installed | Mount namespace + seccomp jail |
SystemdBackend (backends/systemd.ts) |
Linux with systemd | Per-process cgroups (memory, CPU, tasks) |
JobObjectBackend (backends/jobobject.ts) |
Windows | Win32 Job Objects with kill-on-close + memory limit |
DirectBackend (backends/direct.ts) |
Anywhere | No isolation; fallback for dev / macOS |
unshare (backends/unshare.ts) |
Linux | Plain unshare(2) fallback when nsjail is missing |
The engine picks the strongest available backend at startup; operators pin it explicitly via DoCoreEngineOptions.isolationBackend.
Layer 4: Per-project Linux UID¶
services/api/src/runtime/dev-uid-allocator.ts.
Allocates a Linux UID from 10001 to 65000 (~55,000 slots) for setpriv --reuid before exec'ing the dev server, install, or build. Key behaviours:
- The first 1,000 UIDs are pre-created as named users
doable-dev-1..1000bydeployment/server-setup.shsopsoutput is readable. Higher UIDs are numeric-only; the kernel does not requireuseraddforsetprivorchownto work. - The allocator hydrates from the on-disk owner when a project re-binds (
statSync(projectPath).uid), sotsx watchrestarts do not break already-chowned projects with EACCES at install or spawn time. - Two host-side prerequisites are detected at module load:
/opt/doable/bin/sandbox-spawn(the setuid helper) must exist ANDsudo -nl <wrapper>must return 0. If neither path is open and the API is not running as root, the allocator fails closed (returns null; caller refuses to spawn). - Pool exhaustion is fatal:
acquireDevUidreturns null rather than reuse a UID.
Layer 5: dovault¶
packages/dovault is the per-spawn jail. Three things compose:
- A
SandboxProfile(packages/dovault/src/profile.ts): a pure, serializable description of the world the process should see. Filesystem binds and masks, namespace toggles (pid,net,uts,ipc,user), UID + GID, seccomp surface,procOverlay,etcSynth. - A
SandboxBackend(packages/dovault/src/backends/sandbox-backend.ts): an adapter that translates the profile into native flags. Shipsbubblewrap-v2.ts(Linux bwrap),systemd-v2.ts(cgroups),sandbox-exec-v2.ts(macOS),psroot-v2.ts(Windows),apple-container.ts,gvisor.ts,win-heap.ts,direct.ts. Each declaresDeclaredLayersso composers know what gaps to fill. - Composers (
packages/dovault/src/composers/): plug gaps the backend does not natively provide. The current composers areseccomp-bpf.ts,landlock.ts,mount-helper.ts,mac-profile.ts,proc-mask.ts,etc-synth.ts,nft-egress.ts,cgroup-cap.ts.
The orchestrator in services/api/src/sandbox/orchestrator.ts resolves the profile (profile-resolver.ts), resolves the backend (backend-resolver.ts), picks composers (pickComposers), runs preflight steps, spawns the child, and runs teardown in reverse. In prod or staging hardening, the orchestrator refuses to spawn when the chosen backend declares fs: "none".
The legacy Vault class (packages/dovault/src/vault.ts) still exists and is exported for compatibility; new code uses the SandboxBackend + Composer model.
Layer 6: Workspace rules¶
services/api/src/sandbox/workspace-rules.ts loads workspace_sandbox_settings (mig 072 in the sandbox subsystem) and workspace_sandbox_rules (mig 073). Rules carry a rule_type of tool, bash, read, or network; a pattern; an action; and a priority (lower wins).
The contract is tighten only: workspace rules narrow a profile but never widen it. Network floor entries from sandbox_system_rules are reapplied after the workspace pass so an operator-set floor cannot be overridden by a workspace owner. When the profile id is not in allowed_profile_keys, resolution fails closed.
rule-matcher.ts evaluates the rules. system-rules.ts loads the host-wide floor. audit.ts records every spawn for forensics.
Layer 7: Egress firewall¶
Two layers in series:
- nftables (
packages/dovault/src/composers/nft-egress.tsanddeployment/server-setup.sh): drops outbound packets whose source uid is in the10001-65000sandbox pool except loopback. This is the hard floor. - Squid HTTP proxy: terminates the workspace's URL allowlist at the HTTP layer for traffic that has to leave the box. Workspace rules of
rule_type='network'populate the Squid config.
The combined design is documented in Egress Firewall.
Layer 8: OS containment¶
Defaults from the bundled installers:
- All app processes run as the unprivileged
doableuser (notroot). - All ports bind to
127.0.0.1only; see Network Binding. - UFW: deny incoming except 22 (SSH).
- Cloudflare Tunnel terminates external HTTPS and routes through
127.0.0.1to Caddy. - fail2ban watches sshd.
What can still go wrong¶
- An attacker with arbitrary tool access can exhaust your AI provider quota; set per-workspace credit caps.
- A misconfigured policy allowing arbitrary
fetch()to attacker-controlled URLs can exfiltrate code. KeepDEFAULT_URL_ALLOWLISTstrict. - Native Node addons can bypass the Node Permission Model. Disable
child_processandworker_threadsindovault.permissionsfor untrusted code.
For multi-tenant SaaS deployments, see the Hardening Checklist.
See also¶
- Security: Sandboxing: threat coverage and the "Path C" production posture.
- Egress Firewall.
- Network Binding.
@doable/dovaultreference.@doable/docorereference.