Create docs/specs/001-workspace-isolation-linux-users.md
b9f6b2c64c05 jacobcole 2026-04-23 1 file
new file mode 100644
index 0000000..dc5cc17
@@ -0,0 +1,85 @@
+---
+visibility: public
+---
+
+# Spec 001 — Workspace isolation via Linux users
+
+**Status:** Draft
+**Related:** [PRD FR-5..FR-8](../prd/001-picortex-v1.md#workspace-isolation), [ADR-0002](../adrs/0002-linux-users-over-docker.md)
+
+## 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 SQLite `chats.id`.
+- **Chat user** — Linux username `chat-<n>` where `<n>` is the first 8 hex chars of `sha256(chat_id)`. Collisions detected at creation; collisions get `chat-<n>-1`, `-2` etc.
+- **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:
+
+```bash
+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
+
+1. Backend can read any chat's home (runs as root *via sudoers*, not ambient). Chat users cannot read other chat users' homes (`0700`).
+2. Chat users have no sudo, no passwordless privilege escalation.
+3. Chat users' `PATH` contains `/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 restrictive `bash --restricted` profile when using the web terminal.
+4. `/tmp` is per-user (via `pam_namespace`).
+5. Network egress is firewalled; see [Spec 008](008-observability.md) for allowlist.
+
+## Testing
+
+- **Unit:** provisioning pure functions (user-name derivation, path composition).
+- **Integration:** spin up, spin down, assert `0700`; assert chat-A can't `cat` chat-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 `bubblewrap` in 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.
\ No newline at end of file