ADR-0002: Linux users over Docker for per-chat isolation
Status: Accepted (provisional — revisit after D2 isolation report) Date: 2026-04-23 Deciders: Jacob
Context
Each chat (1:1 or group) needs an isolated workspace so compromise of chat A cannot read chat B's files, exfiltrate secrets, or influence chat B's Claude Code sessions. Cortex uses Docker containers per workspace. picortex explicitly rejects that model. Alternatives:
| Option | Spin-up cost | Memory per idle | Security | Complexity |
|---|---|---|---|---|
| Docker container per chat | seconds + image pull | 100-500 MB | strong | high (daemon, network, volumes) |
| Linux user + home dir + POSIX perms | milliseconds | ~ a few MB (tmux+bash) | moderate | low |
| firejail / bubblewrap namespace | milliseconds | ~ a few MB | strong | medium (seccomp policy tuning) |
| nsjail | milliseconds | ~ a few MB | strong | medium |
| Landlock (6.1+ kernel) | ~ nothing | ~ a few MB | strong | medium (new-ish API) |
| VM per chat (microVM, Firecracker) | 100s ms | 128+ MB | very strong | high |
Jacob's context:
- Single user (Jacob). Threat model is "sloppy group-chat prompt injection" not "adversarial tenant."
- Small machine. Target is a 4 GB VPS or Mac Mini.
- Iteration speed matters more than defense-in-depth at v0.1.
- Prior research in
~/memory/research/openclaw-group-chat-security.mdandopenclaw-security-audit-2026-02-20.mddocuments the prompt-injection threat model in depth.
Decision
v1: per-chat Unix user + home dir with POSIX permissions (0700) + runuser/sudo -u.
This will be hardened in D2 by potentially wrapping the tmux session entry point in bubblewrap or Landlock — those are cheap composable additions to the user-based base, not replacements for it.
Consequences
Positive
- Sub-second chat provisioning (
useradd,mkdir,chown,tmux new-session). - Idle cost: one tmux + one bash ≈ a few MB RAM.
- No Docker daemon, no image registry, no volume orchestration.
- POSIX is boring and well-understood; less novel surface area.
- Trivially composes with bubblewrap / Landlock later without re-architecting.
Negative
- Shared kernel. A kernel-level exploit affects everyone.
- Shared
/tmp, shared networking by default. Needs mitigation. - No easy "rm -rf the container" if a chat goes bad — teardown means
userdel -r+ cleanup. - Cap'ed by
useradduid range on some systems; unlikely to matter at Jacob's scale.
Mitigations
/tmpper user: setpam_namespaceso/tmpis per-user-isolated.- Network: Claude Code processes run without
NET_BIND_SERVICEand behind a firewall that blocks outbound exceptapi.anthropic.comand a known allowlist. Egress filter viaiptablesowner-match. - FS allowlist: chat user can't write outside its home. Enforced by owner-only permissions on the home dir and read-only bind mounts for shared tools (
/usr/local/bin/picortex-shims). - Resource limits:
cgroupsv2 per chat user — CPU share, memory cap (256 MB default), pids.max. - Seccomp / Landlock: added in D2 if threat model changes.
- Teardown path:
userdel -rf chat-<hex>+ remove sudoers drop-in. Scripted inscripts/destroy-chat.sh.
Trigger for revisit
- D2 report finds Linux-user perms insufficient for the threat model.
- First isolation incident.
- Jacob invites a second user to picortex.
- picortex opens to multi-tenant.
Alternatives considered in detail
- Docker containers per chat: rejected (see top of file). Would add ~50 MB image-cache baseline per chat and >1 s spin-up; operationally heavy for a personal tool.
- OpenClaw sandbox: rejected per explicit user directive ("no OpenClaw"). Also: sandbox model is whole-agent, not per-chat.
- Firejail: considered; keep as a possible D2 addition. Its default profiles are friendlier than bubblewrap for interactive shells.
- bubblewrap (
bwrap): the strongest "cheap" hardening option; composes well with Linux users. Primary D2 candidate. - nsjail: more flexible than bwrap but more config; hold for D2.
- Microsandbox / gVisor / Firecracker: overkill for v1.
References
~/memory/research/openclaw-group-chat-security.md~/memory/research/openclaw-security-audit-2026-02-20.md- Cortex R2.1 — "each chat = own filesystem" invariant