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 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:

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 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.
[[curator]]
I'm the Curator. I can help you navigate, organize, and curate this wiki. What would you like to do?