Skip to content

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 SandboxAuditEntry for 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..1000 by deployment/server-setup.sh so ps output is readable. Higher UIDs are numeric-only; the kernel does not require useradd for setpriv or chown to work.
  • The allocator hydrates from the on-disk owner when a project re-binds (statSync(projectPath).uid), so tsx watch restarts 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 AND sudo -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: acquireDevUid returns null rather than reuse a UID.

Layer 5: dovault

packages/dovault is the per-spawn jail. Three things compose:

  1. 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.
  2. A SandboxBackend (packages/dovault/src/backends/sandbox-backend.ts): an adapter that translates the profile into native flags. Ships bubblewrap-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 declares DeclaredLayers so composers know what gaps to fill.
  3. Composers (packages/dovault/src/composers/): plug gaps the backend does not natively provide. The current composers are seccomp-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.ts and deployment/server-setup.sh): drops outbound packets whose source uid is in the 10001-65000 sandbox 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 doable user (not root).
  • All ports bind to 127.0.0.1 only; see Network Binding.
  • UFW: deny incoming except 22 (SSH).
  • Cloudflare Tunnel terminates external HTTPS and routes through 127.0.0.1 to 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. Keep DEFAULT_URL_ALLOWLIST strict.
  • Native Node addons can bypass the Node Permission Model. Disable child_process and worker_threads in dovault.permissions for untrusted code.

For multi-tenant SaaS deployments, see the Hardening Checklist.

See also