Sandboxing User Projects¶
Doable runs untrusted code on the host: the AI agent writes files into a per-project tree, npm install runs, a Vite dev server boots, and tool calls (shell, fetch, install) touch the filesystem and the network. The sandbox is the chain of layers that ensures no project can damage the host or read another workspace's data.
This page is the threat-coverage view. The matching implementation-detail view lives at Architecture: Sandboxing.
Defense in depth (eight layers)¶
Each layer mitigates a different attack class. Removing any single layer should not silently disable the others.
- Policy layer (
packages/docore/src/policy/): allow-lists for tools, MCP servers, shell commands, URL fetches; per-workspace overrides. Defaults inpolicy/defaults.ts:DEFAULT_SAFE_COMMANDS,DEFAULT_DANGEROUS_COMMANDS,DEFAULT_TRAVERSAL_PATTERNS,DEFAULT_URL_ALLOWLIST. - Permission handler (
packages/docore/src/sandbox.ts): gates every tool call before the Copilot SDK executes it; returnsallow/deny/ask; writes oneSandboxAuditEntryper decision. - Process isolator (
packages/docore/src/isolator.ts): spawns each AI session worker in a separate OS process. Backends:NsjailBackend,SystemdBackend,JobObjectBackend,DirectBackend. - Per-project Linux UID (
services/api/src/runtime/dev-uid-allocator.ts): allocates a UID from the 10001 to 65000 pool (~55,000 slots).setpriv --reuiddrops to that UID before exec'ing the Vite dev server, install, or build. UIDs survivetsx watchrestarts because the allocator hydrates from the on-disk owner of the project tree. Pool exhaustion fails closed (acquireDevUidreturns null; caller refuses to spawn as root). - bubblewrap (bwrap) runtime jail (
packages/dovault/src/backends/bubblewrap-v2.ts): unprivileged Linux namespaces. The project root binds rw at/workinside the jail; the rest of the filesystem is masked or read-only; PID and network namespaces are unshared; thesandbox-spawnsetuid wrapper (/opt/doable/bin/sandbox-spawn) lets the unprivileged API process drop to the project UID. - seccomp + capability drop (
packages/dovault/src/composers/seccomp-bpf.tsandcgroup-cap.ts): BPF syscall filter and capability surface declared by theSandboxProfile.syscallsblock (packages/dovault/src/profile.ts). - Egress firewall (
packages/dovault/src/composers/nft-egress.tsplus a Squid HTTP allowlist): nftables drops outbound traffic from the sandbox-UID range except loopback by default; the Squid proxy enforces the workspace's URL allow-list at the HTTP layer for traffic that escapes nftables. The full design is in Egress Firewall. - OS containment: non-root API user, all services bound to
127.0.0.1, UFW deny-all (except22), Cloudflare Tunnel for external traffic, fail2ban watching SSH.
For the layered defaults vs. workspace overrides contract, see services/api/src/sandbox/workspace-rules.ts (workspace rules can only tighten, never loosen, and network floor entries from sandbox_system_rules are always reapplied).
Path C sandbox¶
"Path C" is the hardened layout shipped in the May 2026 security sprint. It combines:
- Per-project UID drop (
dev-uid-allocator) so the Vite preview process cannot read any other project's files even if the AI escapes the policy gate. bwrap --uid <sandbox_uid>so the inside-jail UID matches the on-disk owner; without this match, Vite hits EACCES in/work.sandbox-spawnsetuid helper invoked viasudo -nso the API runs unprivileged and only the helper holds thechown+setprivcapabilities.- nftables rule blocking egress for
skuid 10001-65000except loopback. - Squid proxy with workspace-configurable allowlist for any HTTPS that has to leave the box.
Path C is the production default across our production and development environments. Path A and Path B were earlier iterations; this site documents only the live posture.
Threat to mitigation map¶
| Threat | Layer that mitigates it |
|---|---|
| AI deletes critical files | Policy denies rm; bwrap binds /work rw, masks everything else |
| AI writes outside the project | bwrap mount namespace + Node Permission Model in dovault |
AI exfiltrates source via fetch() |
Squid URL allow-list + nftables egress drop |
| AI runs a CPU or memory bomb | systemd cgroups (MemoryMax, CPUQuota, TasksMax) or V8 heap cap |
Malicious postinstall script in npm install |
bwrap + per-project UID; install runs as a non-root, non-API uid |
| AI worker crash takes down the API | Worker runs in its own process; ProcessIsolator restarts it |
| One workspace reads another workspace's project | Each project has its own UID; bwrap only binds that project's path; queries are workspace-scoped via RLS |
| Compromised AI provider returns a malicious payload | Provider response is parsed safely; any tool calls still pass through the policy gate and audit log |
| Sandbox escape via a syscall not used by Vite | seccomp BPF allow-list narrows the available syscalls |
| Sandbox UID pool exhaustion | Allocator returns null and the caller refuses to spawn rather than fall back to root |
Workspace allowlists and deny lists¶
Workspace owners and admins configure per-workspace allow and deny rules for tool calls (workspace_sandbox_rules rows with rule_type='tool'|'bash'|'read') and network destinations (rule_type='network'). Rules are evaluated in priority order (lower number wins) against the workspace's tool_default_action / network_default_action. Workspace rules can only TIGHTEN profiles; the network floor in sandbox_system_rules is always reapplied even if a workspace tries to widen it. See services/api/src/sandbox/workspace-rules.ts and Egress Firewall for the full design.
What the sandbox does NOT cover¶
- Quota abuse: a determined user can still burn AI credits. Set per-workspace credit caps in
/admin/plan-limits. - Native Node addons in the user's
node_modules: can bypass the Node Permission Model. Blockchild_processandworker_threadsindovault.permissionsif you accept untrusted code from public users. - Browser-side attacks in published sites: Doable does not sandbox the HTML / JS a user ships in their own published app. That is the published app's responsibility.
- Side channels (CPU pinning, timing, /proc visibility) below the seccomp + bwrap envelope: the
proc-maskandetc-synthcomposers narrow this, but a determined attacker with code execution inside the jail can still measure timing.
Audit log¶
Every tool call (allow, deny, error) is recorded:
- In memory on the session (
SandboxAuditEntry). - In the
activity_eventstable. - Visible at Workspace Settings, Audit.
For SIEM integration, tail activity_events or pipe journalctl -u doable -f | grep '\[audit\]'.
See also¶
- Architecture: Sandboxing: implementation detail, per-layer code paths.
- Egress Firewall: nftables + Squid allowlist design.
- Security Model: the trust boundary and threat actors.
- Hardening Checklist: multi-tenant SaaS posture.
@doable/dovaultreference.@doable/docorereference.