Spec 001 — Workspace isolation via Linux users
Status: Draft Related: PRD FR-5..FR-8, ADR-0002
Goal
Each chat (1:1 or group) has its own Unix user, home directory, and filesystem. No chat can read another chat's files. The picortex backend runs as a dedicated service user and enters each chat's context via runuser/sudo -u.
Identifiers
- Chat ID — from Linq's
chat.id(stable for the lifetime of the chat). Stored in SQLitechats.id. - Chat user — Linux username
chat-<n>where<n>is the first 8 hex chars ofsha256(chat_id). Collisions detected at creation; collisions getchat-<n>-1,-2etc. - Home directory —
$CHAT_WORKSPACE_ROOT/<chat_id>(default/srv/picortex/chats/<chat_id>). Symlinked from/home/chat-<n>for standard shell behavior.
Provisioning
Triggered on first inbound message to an unknown chat. Backend (as root via sudoers) runs:
useradd --create-home \
--home-dir "$CHAT_WORKSPACE_ROOT/$CHAT_ID" \
--shell /bin/bash \
--user-group \
"chat-$HEX"
chmod 0700 "$CHAT_WORKSPACE_ROOT/$CHAT_ID"
chown "chat-$HEX:chat-$HEX" "$CHAT_WORKSPACE_ROOT/$CHAT_ID"
# Seed files
sudo -u "chat-$HEX" -H bash -c '
mkdir -p .picortex/prompts
cp /usr/local/share/picortex/discriminator.default.md .picortex/prompts/discriminator.md
git init -q
git add -A && git commit -q -m "init"
'
# Per-chat cgroup (cgroups v2)
mkdir -p "/sys/fs/cgroup/picortex/$CHAT_ID"
echo "256M" > "/sys/fs/cgroup/picortex/$CHAT_ID/memory.max"
echo "200" > "/sys/fs/cgroup/picortex/$CHAT_ID/pids.max"
All steps idempotent (check existence first). Failures roll back via a single userdel -rf.
Teardown
Soft: tmux kill-session -t picortex:$CHAT_ID + archive home dir to $CHAT_WORKSPACE_ROOT/_archive/$CHAT_ID.tar.zst + userdel -r chat-$HEX.
Hard: scripts/destroy-chat.sh <CHAT_ID> removes user, home, archive, cgroup. Logged to SQLite events table.
Sudoers
A single drop-in at /etc/sudoers.d/picortex:
picortex ALL=(%picortex-chats) NOPASSWD: /usr/bin/tmux, /usr/bin/runuser, /usr/bin/bash
picortex ALL=(root) NOPASSWD: /usr/sbin/useradd, /usr/sbin/userdel, /usr/bin/chown, /usr/bin/chmod, /bin/mkdir
The picortex-chats group auto-includes every chat-* user (added at useradd time).
Security invariants
- Backend can read any chat's home (runs as root via sudoers, not ambient). Chat users cannot read other chat users' homes (
0700). - Chat users have no sudo, no passwordless privilege escalation.
- Chat users'
PATHcontains/usr/local/bin/picortex-shims+ system defaults — the shims directory holds allowlisted wrappers (e.g.claude,git,rg,jq) and denies everything else via a restrictivebash --restrictedprofile when using the web terminal. /tmpis per-user (viapam_namespace).- Network egress is firewalled; see Spec 008 for allowlist.
Testing
- Unit: provisioning pure functions (user-name derivation, path composition).
- Integration: spin up, spin down, assert
0700; assert chat-A can'tcatchat-B's~/.picortex/prompts/discriminator.md. - Stress: 50 concurrent provisions; no collisions, no orphan users.
- Chaos: kill the backend mid-provision; assert cleanup on next start.
Open questions
- OQ1: Should we tighten with
bubblewrapin v1 or wait for D2? — Leaning wait. - OQ2: How to handle Linq chat-name changes? (Chat ID is stable, so probably ignore.)
- OQ3: Per-chat cgroups require cgroups v2; does our deploy target support? — Hetzner does; HMA macOS does not. D1 question.