diff --git a/docs/superpowers/plans/2026-05-15-hardening-refactor.md b/docs/superpowers/plans/2026-05-15-hardening-refactor.md new file mode 100644 index 0000000..f60d10a --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-hardening-refactor.md @@ -0,0 +1,1321 @@ +# Hardening refactor — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the proven Test 7 hardening composition into ckn-bw's `systemd/units` reactor for `left4me-server@.service` and `left4me-web.service`, ship the `kernel.yama.ptrace_scope=2` sysctl via the bundle, mirror the reference units with per-directive educational comments, fix four spec bugs in the test plan, mark the uid-split spec superseded, and verify the deploy on `left4.me`. + +**Architecture:** Three shared Python dicts in ckn-bw's `metadata.py` (`HARDENING_COMMON`, `HARDENING_SERVER`, `HARDENING_WEB`) spread via `**` into the existing `systemd/units` reactor entries. Multi-value directives encoded as Python tuples (existing precedent: `EnvironmentFile` at `metadata.py:201-204`). Reference units in `left4me/deploy/files/usr/local/lib/systemd/system/` hand-synced to match the emission, with per-directive comments explaining each threat addressed. Broader configmgmt responsibility reshape (drop-ins owned by left4me) deliberately deferred. + +**Tech Stack:** Python 3, bundlewrap (ckn-bw), systemd 257 (Debian 13 Trixie on `left4.me`), pytest (for `deploy/tests/`), bash (for verification on host). + +**Repos:** This plan touches two repos. Each task header notes which: +- `~/Projekte/left4me` (project repo — this file lives here) +- `~/Projekte/ckn-bw` (config-management bundle repo) + +--- + +## Task 1: Verify ckn-bw systemd-bundle emitter handles tuples and empty values + +**Repo:** `~/Projekte/ckn-bw` + +**Why:** The factoring depends on two emitter behaviors: +1. A tuple of strings → emitted as repeated `Key=Value` lines (needed for `SystemCallFilter`, `BindReadOnlyPaths`, `SocketBindAllow`). +2. An empty string → emitted as `Key=` with no value (needed for `CapabilityBoundingSet=`, `AmbientCapabilities=`). +If either doesn't work, the factoring needs to change shape (inline-join strings, or use a sentinel). + +**Files:** +- Read: `~/Projekte/ckn-bw/bundles/systemd/` (the upstream bundlewrap systemd bundle; locate where unit emission happens) +- Read: `~/Projekte/ckn-bw/bundles/left4me/metadata.py:201-204` (existing tuple-emission precedent for `EnvironmentFile`) + +- [ ] **Step 1: Inspect the systemd bundle's emitter for multi-value handling** + +Run: +```bash +cd ~/Projekte/ckn-bw +find bundles/systemd -name '*.py' -type f +# Expected: items.py, possibly templates/ +grep -rn 'tuple\|list\|EnvironmentFile' bundles/systemd/ 2>/dev/null +# Look for how multi-value emission works +``` + +Then read the emitter source. Expected behavior: iterates the dict; if the value is a tuple/list, emits one `Key=Value` line per element; if the value is a string, emits one line. + +- [ ] **Step 2: Inspect handling of empty values** + +Run: +```bash +grep -rn "''\\|None\\|if value" bundles/systemd/ 2>/dev/null +``` + +Read the emitter to see if it strips empty strings or emits `Key=`. If unclear from reading, write a tiny test: + +```python +# Save as /tmp/test-emitter.py — adapt to actual emitter import path +from bundles.systemd.items import render_unit # or whatever the real entry point is + +unit = { + 'Service': { + 'Type': 'simple', + 'CapabilityBoundingSet': '', + 'SystemCallFilter': ('@system-service', '~@debug @mount'), + } +} +print(render_unit('test.service', unit)) +``` + +Run it (adapting paths to ckn-bw's actual layout). Expected: a unit body containing `CapabilityBoundingSet=` (empty value) and two `SystemCallFilter=` lines. + +- [ ] **Step 3: Record findings in the design doc's "Open items resolved in implementation"** + +If both behaviors work as expected: + +Edit `~/Projekte/left4me/docs/superpowers/specs/2026-05-15-hardening-refactor-design.md`. Replace the "Open items resolved in implementation, not design" section with: + +```markdown +## Implementation notes (resolved during plan execution) + +- The ckn-bw systemd-bundle emitter renders Python tuples as repeated + `Key=Value` lines and renders empty strings as `Key=` with no + value. The factoring uses both. Verified on against + bundles/systemd at . +- `SocketBindAllow=` value: hard-coded port range `27000-27999` + (matches the `LEFT4ME_PORT_RANGE_*` metadata values). Variable + substitution in this directive is not supported. +``` + +If either behavior is broken, document the fallback and adjust later tasks accordingly. The most common fallbacks: +- Tuple not supported → inline-join with space for `SystemCallFilter` (it accepts space-separated within a line, though semantics differ — read systemd.exec(5) carefully). +- Empty value not supported → patch the emitter or open a ckn-bw issue. + +- [ ] **Step 4: Commit the design-doc update** + +```bash +cd ~/Projekte/left4me +git add docs/superpowers/specs/2026-05-15-hardening-refactor-design.md +git commit -m "$(cat <<'EOF' +spec(hardening-refactor): resolve emitter open items + +Verified during plan execution that the ckn-bw systemd-bundle emitter +handles tuples and empty values as expected. SocketBindAllow port +range hard-coded since systemd directive variable substitution is not +universal. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Add `HARDENING_COMMON`, `HARDENING_SERVER`, `HARDENING_WEB` constants + +**Repo:** `~/Projekte/ckn-bw` + +**Files:** +- Modify: `~/Projekte/ckn-bw/bundles/left4me/metadata.py` (add the three constants near the top of the file, after imports, before the first `@metadata_reactor.provides` decorator) + +- [ ] **Step 1: Read the current file head to find insertion point** + +Run: +```bash +head -30 ~/Projekte/ckn-bw/bundles/left4me/metadata.py +``` + +Identify where the imports end and the first `@metadata_reactor.provides` starts. The new constants go between. + +- [ ] **Step 2: Add the three constants** + +Insert into `~/Projekte/ckn-bw/bundles/left4me/metadata.py` between imports and the first reactor: + +```python +# Hardening composition — proven via the hardening test plan (left4me +# commit 461b8d0). See: +# docs/superpowers/specs/2026-05-15-hardening-threat-model.md +# docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md +# docs/superpowers/specs/2026-05-15-hardening-test-plan.md +# docs/superpowers/specs/2026-05-15-hardening-refactor-design.md +# (paths in the left4me repo) + +# Directives both managed units take verbatim. +HARDENING_COMMON = { + 'ProtectProc': 'invisible', + 'ProcSubset': 'pid', + 'ProtectKernelTunables': 'true', + 'ProtectKernelModules': 'true', + 'ProtectKernelLogs': 'true', + 'ProtectClock': 'true', + 'ProtectControlGroups': 'true', + 'ProtectHostname': 'true', + 'LockPersonality': 'true', + 'ProtectSystem': 'strict', + 'ProtectHome': 'true', + 'PrivateTmp': 'true', + 'RestrictNamespaces': 'true', + 'RestrictRealtime': 'true', + 'RemoveIPC': 'true', + 'KeyringMode': 'private', + 'UMask': '0027', + 'RestrictAddressFamilies': 'AF_INET AF_INET6 AF_UNIX', +} + +# Gameserver unit: COMMON + sudo-incompatible flags + filesystem +# virtualization + i386 amendment + per-instance PID namespace + bound +# socket binds. +HARDENING_SERVER = { + **HARDENING_COMMON, + 'NoNewPrivileges': 'true', + 'RestrictSUIDSGID': 'true', + 'PrivateUsers': 'true', + # PrivatePIDs is the test-plan amendment that closes D2.b: same-uid + # ProtectProc=invisible cannot hide gunicorn from srcds (both run + # as uid 980); a private PID namespace does. + 'PrivatePIDs': 'true', + 'PrivateIPC': 'true', + 'PrivateDevices': 'true', + 'CapabilityBoundingSet': '', + 'AmbientCapabilities': '', + # srcds_linux is i386 (Source 2007 engine). Bare 'native' kills + # every 32-bit syscall and traps srcds_run in a respawn loop. + 'SystemCallArchitectures': 'native x86', + 'SystemCallFilter': ( + '@system-service', + '~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged', + ), + 'TemporaryFileSystem': '/var/lib /etc /opt /home /root /srv /mnt /media', + 'BindReadOnlyPaths': ( + '/var/lib/left4me/installation', + '/var/lib/left4me/overlays', + '/etc/left4me/host.env', + '/etc/ssl', + '/etc/ca-certificates', + '/etc/resolv.conf', + '/etc/nsswitch.conf', + '/etc/alternatives', + ), + 'BindPaths': '/var/lib/left4me/runtime/%i', + # Lock srcds bindable sockets to the game port range. Hard-coded + # range because systemd directive variable substitution is uneven. + 'SocketBindAllow': ( + 'udp:27000-27999', + 'tcp:27000-27999', + ), + # MemoryDenyWriteExecute=true permanently excluded — Source engine + # i386 .so files have text relocations that need mprotect(W+X) + # during the dynamic linker's relocation pass. +} + +# Web unit: COMMON + sudo-compatible additions. EXCLUDES +# NoNewPrivileges, PrivateUsers, RestrictSUIDSGID, empty +# CapabilityBoundingSet, and ~@privileged in the syscall filter — all +# sudo-incompatible until a future refactor replaces sudo with +# systemctl-managed transient units. +HARDENING_WEB = { + **HARDENING_COMMON, + 'SystemCallArchitectures': 'native', + 'SystemCallFilter': ( + '@system-service', + '~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete', + ), +} +``` + +- [ ] **Step 3: Verify the file still parses** + +Run: +```bash +cd ~/Projekte/ckn-bw +python3 -c "import ast; ast.parse(open('bundles/left4me/metadata.py').read())" +# Expected: no output (no syntax error) +``` + +- [ ] **Step 4: Run `bw test` if available** + +Run: +```bash +cd ~/Projekte/ckn-bw +bw test ovh.left4me 2>&1 | head -50 +# Expected: passes, or fails only on already-known issues unrelated +# to these constants +``` + +- [ ] **Step 5: Commit** + +```bash +cd ~/Projekte/ckn-bw +git add bundles/left4me/metadata.py +git commit -m "$(cat <<'EOF' +bundles/left4me: add HARDENING_{COMMON,SERVER,WEB} constants + +Three shared dicts capturing the proven hardening composition from the +left4me hardening test plan (left4me commit 461b8d0). HARDENING_COMMON +is the directive set both managed units take verbatim; HARDENING_SERVER +adds the sudo-incompatible flags + filesystem virtualization + i386 +amendment + PrivatePIDs + SocketBindAllow; HARDENING_WEB adds the +sudo-compatible syscall filter. + +Not yet spread into the unit emission — that's the next commit. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Spread `HARDENING_SERVER` into `left4me-server@.service` reactor entry + +**Repo:** `~/Projekte/ckn-bw` + +**Files:** +- Modify: `~/Projekte/ckn-bw/bundles/left4me/metadata.py` (the `left4me-server@.service` entry inside the `systemd/units` reactor, currently at ~line 221+) + +- [ ] **Step 1: Read the current emission** + +Run: +```bash +sed -n '221,276p' ~/Projekte/ckn-bw/bundles/left4me/metadata.py +``` + +Identify the `Service` dict inside the `left4me-server@.service` entry. Note any existing hardening keys (`NoNewPrivileges`, `PrivateTmp`, `ProtectSystem`, `ReadOnlyPaths`, `ReadWritePaths`, etc.) — they will be removed in favor of the spread. + +- [ ] **Step 2: Edit the entry to spread `HARDENING_SERVER` and remove duplicated keys** + +Modify the `Service` dict to look like: + +```python +'left4me-server@.service': { + 'Unit': { + 'Description': 'left4me server instance %i', + 'After': 'network-online.target', + 'Wants': 'network-online.target', + 'StartLimitBurst': '5', + 'StartLimitIntervalSec': '60s', + }, + 'Service': { + 'Type': 'simple', + 'User': 'left4me', + 'Group': 'left4me', + 'EnvironmentFile': ( + '/etc/left4me/host.env', + '/var/lib/left4me/instances/%i/instance.env', + ), + 'WorkingDirectory': '-/var/lib/left4me/runtime/%i/merged/left4dead2', + 'ExecStartPre': '+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay mount %i', + 'ExecStart': '/var/lib/left4me/runtime/%i/merged/srcds_run -game left4dead2 +hostport ${L4D2_PORT} $L4D2_ARGS', + 'ExecStopPost': '+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay umount %i', + 'Restart': 'on-failure', + 'RestartSec': '5', + + # Resource control (baseline from prior performance work). + 'Slice': 'l4d2-game.slice', + 'Nice': '-5', + 'IOSchedulingClass': 'best-effort', + 'IOSchedulingPriority': '4', + 'OOMScoreAdjust': '-200', + 'MemoryHigh': '1.5G', + 'MemoryMax': '2G', + 'TasksMax': '256', + 'LimitNOFILE': '65536', + 'KillSignal': 'SIGINT', + 'TimeoutStopSec': '15s', + 'LogRateLimitIntervalSec': '0', + + # Hardening profile — see HARDENING_SERVER constant near top + # of this file for per-directive rationale. + **HARDENING_SERVER, + }, + 'Install': { + 'WantedBy': {'multi-user.target'}, + }, +}, +``` + +Removed from the prior emission (now provided by `HARDENING_SERVER`): +`NoNewPrivileges`, `PrivateTmp`, `PrivateDevices`, `ProtectHome`, +`ProtectSystem`, `RestrictSUIDSGID`, `LockPersonality`, `ReadOnlyPaths`, +`ReadWritePaths`. + +(`ReadOnlyPaths` and `ReadWritePaths` are superseded by +`TemporaryFileSystem` + `BindReadOnlyPaths` + `BindPaths` in +`HARDENING_SERVER`.) + +- [ ] **Step 3: Verify the file still parses and `bw test` passes** + +Run: +```bash +cd ~/Projekte/ckn-bw +python3 -c "import ast; ast.parse(open('bundles/left4me/metadata.py').read())" +bw test ovh.left4me 2>&1 | head -50 +``` + +- [ ] **Step 4: Render the unit to a temp file and visually diff against the proven Test 7 composition** + +If `bw` has a render-without-apply mode (`bw apply --interactive` or `bw items` etc.), use it. Otherwise: + +```bash +cd ~/Projekte/ckn-bw +bw items ovh.left4me 'svc_systemd:left4me-server@.service' 2>/dev/null || \ + bw debug -n ovh.left4me -c "from bundlewrap.utils import get_file_contents; print(repo.get_node('ovh.left4me').metadata.get('systemd/units')['left4me-server@.service'])" +``` + +Adapt to whatever inspection command ckn-bw supports. Compare visually +to the Test 7 drop-in (recorded inline in left4me's +`docs/superpowers/specs/2026-05-15-hardening-test-plan.md` under +"## Test 7" section): every directive that was in the proven Test 7 +drop-in should now be present in the rendered unit. + +Note specifically: `PrivatePIDs=true` and `SystemCallArchitectures=native x86` +must appear (the test amendments). `MemoryDenyWriteExecute=true` must +NOT appear (permanently excluded). + +- [ ] **Step 5: Commit** + +```bash +cd ~/Projekte/ckn-bw +git add bundles/left4me/metadata.py +git commit -m "$(cat <<'EOF' +bundles/left4me: spread HARDENING_SERVER into left4me-server@.service + +Replaces the inline hardening directives on the gameserver unit with +the shared HARDENING_SERVER dict. Removes legacy ReadOnlyPaths / +ReadWritePaths (superseded by TemporaryFileSystem + BindReadOnlyPaths ++ BindPaths in the dict). Brings the unit to the proven Test 7 +composition with the i386 amendment (SystemCallArchitectures=native x86) +and PrivatePIDs=true. + +Not deployed until bw apply. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Spread `HARDENING_WEB` into `left4me-web.service` reactor entry + +**Repo:** `~/Projekte/ckn-bw` + +**Files:** +- Modify: `~/Projekte/ckn-bw/bundles/left4me/metadata.py` (the `left4me-web.service` entry, ~line 186) + +- [ ] **Step 1: Read the current emission** + +Run: +```bash +sed -n '186,220p' ~/Projekte/ckn-bw/bundles/left4me/metadata.py +``` + +The current entry has `ProtectSystem='full'` and `PrivateTmp='true'` and a comment about `NoNewPrivileges` intentionally not being set. + +- [ ] **Step 2: Edit the entry to spread `HARDENING_WEB`** + +```python +'left4me-web.service': { + 'Unit': { + 'Description': 'left4me web application', + 'After': 'network-online.target', + 'Wants': 'network-online.target', + }, + 'Service': { + 'Type': 'simple', + 'User': 'left4me', + 'Group': 'left4me', + 'WorkingDirectory': '/opt/left4me/src', + 'Environment': { + 'HOME=/var/lib/left4me', + 'PATH=/opt/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }, + 'EnvironmentFile': ( + '/etc/left4me/host.env', + '/etc/left4me/web.env', + ), + 'ExecStart': ( + '/opt/left4me/.venv/bin/gunicorn ' + f'--workers {workers} --threads {threads} ' + "--bind 127.0.0.1:8000 'l4d2web.app:create_app()'" + ), + 'Restart': 'on-failure', + 'RestartSec': '3', + + # Web app writes broadly under /var/lib/left4me. Kept inline + # because it's web-specific (server@ uses BindPaths to bind + # only its instance dir). + 'ReadWritePaths': '/var/lib/left4me', + + # Hardening profile — see HARDENING_WEB constant near top of + # this file. NoNewPrivileges intentionally NOT set: workers + # sudo to the helpers. PrivateUsers and RestrictSUIDSGID also + # absent for the same reason. ProtectSystem tightens from + # 'full' to 'strict' via HARDENING_COMMON. + **HARDENING_WEB, + }, + 'Install': { + 'WantedBy': {'multi-user.target'}, + }, +}, +``` + +Removed from the prior emission (now provided by `HARDENING_WEB`): +`PrivateTmp`, `ProtectSystem` (tightened from `full` → `strict`). + +- [ ] **Step 3: Verify the file still parses and `bw test` passes** + +```bash +cd ~/Projekte/ckn-bw +python3 -c "import ast; ast.parse(open('bundles/left4me/metadata.py').read())" +bw test ovh.left4me 2>&1 | head -50 +``` + +- [ ] **Step 4: Commit** + +```bash +cd ~/Projekte/ckn-bw +git add bundles/left4me/metadata.py +git commit -m "$(cat <<'EOF' +bundles/left4me: spread HARDENING_WEB into left4me-web.service + +Adds the sudo-compatible hardening subset to the web unit. Tightens +ProtectSystem=full → strict. NoNewPrivileges, PrivateUsers, +RestrictSUIDSGID, empty CapabilityBoundingSet, and ~@privileged in the +syscall filter intentionally absent (sudo-incompatible until a future +refactor replaces the helper sudo with systemctl-managed transient +units). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Manage `kernel.yama.ptrace_scope=2` sysctl via the bundle + +**Repo:** `~/Projekte/ckn-bw` + +**Files:** +- Locate or create: a sysctl drop-in. Path depends on the convention discovered in Step 1; candidates: + - `~/Projekte/ckn-bw/bundles/left4me/files/etc/sysctl.d/99-left4me-ptrace.conf` for the file-based pattern + - A `sysctl/options` reactor entry inline in `bundles/left4me/metadata.py` for the reactor-based pattern + +- [ ] **Step 1: Find the existing sysctl pattern in ckn-bw** + +Run: +```bash +cd ~/Projekte/ckn-bw +grep -rn 'sysctl\.d\|kernel\.' bundles/ 2>/dev/null | grep -v '^bundles/left4me/' | head -10 +``` + +Look for an existing `pkg_files:` or `files:` entry that targets `/etc/sysctl.d/*`, or a `metadata.get('sysctl/...')` reactor pattern. Read the relevant bundle's `items.py` or `metadata.py` to understand the convention. + +If ckn-bw has a `sysctl` bundle with a reactor for `sysctl/*` keys, use that. Otherwise, fall back to a direct `files:` entry in the left4me bundle. + +- [ ] **Step 2: Add the sysctl drop-in** + +If ckn-bw has a sysctl reactor: + +Add to `~/Projekte/ckn-bw/bundles/left4me/metadata.py`, in a new +`@metadata_reactor.provides('sysctl/...')` reactor or by appending to an existing one: + +```python +@metadata_reactor.provides('sysctl/options') +def sysctl_left4me(metadata): + return { + 'sysctl': { + 'options': { + # Block ptrace except from CAP_SYS_PTRACE holders. Belt-and- + # braces with SystemCallFilter=~@debug + PrivateUsers=true + # in the gameserver unit. See: + # left4me docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md + 'kernel.yama.ptrace_scope': '2', + }, + }, + } +``` + +If ckn-bw uses a direct `files:` entry pattern: + +Create `~/Projekte/ckn-bw/bundles/left4me/files/etc/sysctl.d/99-left4me-ptrace.conf` with: + +``` +# Block ptrace except from CAP_SYS_PTRACE holders. Belt-and-braces with +# SystemCallFilter=~@debug + PrivateUsers=true in the gameserver unit. +# See left4me docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md +kernel.yama.ptrace_scope = 2 +``` + +And add to `~/Projekte/ckn-bw/bundles/left4me/items.py` in the `files = {...}` dict: + +```python +'/etc/sysctl.d/99-left4me-ptrace.conf': { + 'mode': '0644', + 'owner': 'root', + 'group': 'root', + 'triggers': { + 'action:sysctl_reload', # adapt to whatever the sysctl reload action is called in ckn-bw + }, +}, +``` + +- [ ] **Step 3: Verify with `bw test`** + +```bash +cd ~/Projekte/ckn-bw +bw test ovh.left4me 2>&1 | head -50 +``` + +- [ ] **Step 4: Commit** + +```bash +cd ~/Projekte/ckn-bw +git add bundles/left4me/ +git commit -m "$(cat <<'EOF' +bundles/left4me: ship kernel.yama.ptrace_scope=2 sysctl drop-in + +Belt-and-braces with the gameserver unit's SystemCallFilter=~@debug + +PrivateUsers=true. Currently applied by hand on left4.me (left over +from the hardening test plan's Test 9); landing in the bundle so it +survives bw apply and is reproducible on any future host. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Update reference unit `deploy/files/usr/local/lib/systemd/system/left4me-server@.service` with per-directive comments + +**Repo:** `~/Projekte/left4me` + +**Files:** +- Modify: `~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service` + +- [ ] **Step 1: Read the current reference** + +Run: +```bash +cat ~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service +``` + +- [ ] **Step 2: Replace with the new annotated content** + +Overwrite the file with: + +```ini +# left4me gameserver — system unit, one instance per gameserver. +# +# This is the REFERENCE COPY of the deployed unit. The live source is +# the systemd/units reactor at ~/Projekte/ckn-bw/bundles/left4me/metadata.py +# (look for 'left4me-server@.service'). Hardening directives live in +# the HARDENING_SERVER constant near the top of the same file. +# This file is hand-synced; edit both together. +# +# Threat model: docs/superpowers/specs/2026-05-15-hardening-threat-model.md +# Defenses survey: docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md +# Test plan + results: docs/superpowers/specs/2026-05-15-hardening-test-plan.md + +[Unit] +Description=left4me server instance %i +After=network-online.target +Wants=network-online.target +# Bound the restart loop. Without these, a persistent ExecStartPre or +# ExecStart failure spins indefinitely. +StartLimitBurst=5 +StartLimitIntervalSec=60s + +[Service] +Type=simple +User=left4me +Group=left4me +EnvironmentFile=/etc/left4me/host.env +EnvironmentFile=/var/lib/left4me/instances/%i/instance.env +# `-` prefix: chdir failure is non-fatal. The merged dir only exists +# once ExecStartPre's overlay mount succeeds. +WorkingDirectory=-/var/lib/left4me/runtime/%i/merged/left4dead2 +# `+` prefix runs the helper as PID 1 (root, all caps, host +# namespaces) — required because the unit has NoNewPrivileges=true +# AND PrivateUsers=true; both block sudo's setuid path. nsenter into +# PID 1's mount namespace ensures the umount in ExecStopPost succeeds +# without EBUSY from the unit's own slave-mount tree. +ExecStartPre=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay mount %i +# Run from the merged overlay, NOT installation/. srcds_run cds to its +# own dirname before exec'ing srcds_linux; the binary's path determines +# gameinfo + addons lookup. +ExecStart=/var/lib/left4me/runtime/%i/merged/srcds_run -game left4dead2 +hostport ${L4D2_PORT} $L4D2_ARGS +ExecStopPost=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay umount %i +Restart=on-failure +RestartSec=5 + +# === Resource control baseline === +# See docs/superpowers/specs/2026-05-09-l4d2-server-host-perf-baseline-design.md +Slice=l4d2-game.slice +Nice=-5 +IOSchedulingClass=best-effort +IOSchedulingPriority=4 +OOMScoreAdjust=-200 +MemoryHigh=1.5G +MemoryMax=2G +TasksMax=256 +LimitNOFILE=65536 +KillSignal=SIGINT +TimeoutStopSec=15s +LogRateLimitIntervalSec=0 + +# === Identity / privilege drop === +NoNewPrivileges=true # block setuid escalation (defense: D3) +RestrictSUIDSGID=true # block setuid()/setgid() syscalls +CapabilityBoundingSet= # drop all caps — no privilege to escalate +AmbientCapabilities= + +# === Filesystem virtualization === +# Mask /var/lib, /etc, /opt, etc. with empty tmpfs; bind back only +# what srcds needs. The DB (/var/lib/left4me/left4me.db) and web.env +# (/etc/left4me/web.env) are intentionally not bound — they don't +# exist in this unit's filesystem view (defenses: D1.a, D1.b). +TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media +BindReadOnlyPaths=/var/lib/left4me/installation +BindReadOnlyPaths=/var/lib/left4me/overlays +BindReadOnlyPaths=/etc/left4me/host.env +BindReadOnlyPaths=/etc/ssl +BindReadOnlyPaths=/etc/ca-certificates +BindReadOnlyPaths=/etc/resolv.conf +BindReadOnlyPaths=/etc/nsswitch.conf +BindReadOnlyPaths=/etc/alternatives +BindPaths=/var/lib/left4me/runtime/%i +ProtectSystem=strict # belt-and-braces with TemporaryFileSystem +ProtectHome=true + +# === Process namespacing === +PrivateUsers=true # own user namespace; cross-uid ptrace blocked (D2) +PrivatePIDs=true # own PID namespace; hides peer-srcds + gunicorn (D2.b, D5) +PrivateTmp=true +PrivateDevices=true +PrivateIPC=true +RestrictNamespaces=true # block unshare()/clone(CLONE_NEW*) + +# === /proc and /sys === +ProtectProc=invisible # foreign-uid /proc hidden (paired with PrivatePIDs for full hide) +ProcSubset=pid # /proc shows only PID dirs, no kallsyms/cpuinfo +ProtectKernelTunables=true # /proc/sys, /sys read-only +ProtectKernelModules=true # no module load/unload +ProtectKernelLogs=true # no /dev/kmsg or syslog() +ProtectClock=true # no settimeofday() +ProtectControlGroups=true # /sys/fs/cgroup read-only +ProtectHostname=true # no sethostname() +LockPersonality=true # no personality() switches + +# === Syscall filter === +# srcds_linux is i386 (Source 2007 engine). 'native x86' allows both +# x86_64 (from srcds_run + the dynamic linker) and i386 (from srcds_linux). +# Bare 'native' traps srcds_run in a respawn loop. +SystemCallArchitectures=native x86 +SystemCallFilter=@system-service +SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged +# ~@debug is the load-bearing block for D2.a: drops ptrace(), process_vm_readv/writev(). +# ~@privileged blocks anything requiring CAP_*, redundant with empty bounding set. +# MemoryDenyWriteExecute=true is NOT set — Source engine i386 .so files +# have text relocations that need mprotect(W+X) during dynamic-linker pass. + +# === Network === +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX # AF_UNIX needed for journald +# Lock srcds bindable sockets to the game port range. +SocketBindAllow=udp:27000-27999 +SocketBindAllow=tcp:27000-27999 + +# === Misc hygiene === +RestrictRealtime=true # no real-time scheduling +RemoveIPC=true # clean up SysV IPC on unit stop +KeyringMode=private # private kernel keyring +UMask=0027 + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 3: Verify content visually** + +Run: +```bash +diff <(cat ~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service | grep -vE '^#|^\s*$') \ + <(echo "expected directive set" | tr ' ' '\n' | sort) +``` + +(Or just read the file and confirm each directive matches the reactor's emission.) + +- [ ] **Step 4: Commit (deferred — commit with Task 7's reference for one logical change)** + +Don't commit yet; the next task updates the web reference. Commit both together at the end of Task 7. + +--- + +## Task 7: Update reference unit `deploy/files/usr/local/lib/systemd/system/left4me-web.service` with per-directive comments + +**Repo:** `~/Projekte/left4me` + +**Files:** +- Modify: `~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service` + +- [ ] **Step 1: Read the current reference** + +```bash +cat ~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service +``` + +- [ ] **Step 2: Replace with the new annotated content** + +```ini +# left4me web application — system unit. +# +# This is the REFERENCE COPY of the deployed unit. The live source is +# the systemd/units reactor at ~/Projekte/ckn-bw/bundles/left4me/metadata.py +# (look for 'left4me-web.service'). Hardening directives live in +# the HARDENING_WEB constant near the top of the same file. +# This file is hand-synced; edit both together. +# +# Several directives that the gameserver uses are intentionally absent +# from this unit: +# NoNewPrivileges — blocks sudo's setuid escalation +# PrivateUsers — breaks sudo's host-root mapping +# RestrictSUIDSGID — blocks setuid()/setgid() +# CapabilityBoundingSet= — empty value would deny sudo's caps +# ~@privileged in SystemCallFilter — blocks sudo's setuid syscall +# The web app invokes privileged helpers (left4me-systemctl, +# left4me-overlay, left4me-script-sandbox) via sudo, so these +# directives can't be applied here. A future refactor replacing sudo +# with systemctl-managed transient units would unlock them. +# +# Threat model + defenses + tests: see docs/superpowers/specs/2026-05-15-hardening-* + +[Unit] +Description=left4me web application +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=left4me +Group=left4me +WorkingDirectory=/opt/left4me/src +Environment=HOME=/var/lib/left4me PATH=/opt/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +EnvironmentFile=/etc/left4me/host.env +EnvironmentFile=/etc/left4me/web.env +ExecStart=/opt/left4me/.venv/bin/gunicorn --workers 4 --threads 4 --bind 127.0.0.1:8000 'l4d2web.app:create_app()' +Restart=on-failure +RestartSec=3 + +# Web writes broadly under /var/lib/left4me (DB, instance configs, +# overlays, runtime). Kept inline because it's web-specific +# (server@ uses BindPaths to bind only its instance dir). +ReadWritePaths=/var/lib/left4me + +# === Filesystem === +ProtectSystem=strict # tightened from prior 'full'; via HARDENING_COMMON +ProtectHome=true +PrivateTmp=true + +# === /proc + kernel === +ProtectProc=invisible # foreign-uid /proc hidden (defense: D4) +ProcSubset=pid +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectClock=true +ProtectControlGroups=true +ProtectHostname=true +LockPersonality=true + +# === Syscall filter (sudo-compatible — note absence of ~@privileged) === +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete +# ~@debug blocks ptrace + process_vm_readv/writev (D4). +# ~@privileged intentionally omitted — sudo needs setuid(). + +# === Network === +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +# === Misc hygiene === +RestrictNamespaces=true +RestrictRealtime=true +RemoveIPC=true +KeyringMode=private +UMask=0027 + +[Install] +WantedBy=multi-user.target +``` + +(Note: the `--workers 4 --threads 4` values are placeholders; the live emission uses metadata-derived values. The reference doc can use a fixed example — operator note in commit message will clarify.) + +- [ ] **Step 3: Commit both reference units together** + +```bash +cd ~/Projekte/left4me +git add deploy/files/usr/local/lib/systemd/system/left4me-server@.service \ + deploy/files/usr/local/lib/systemd/system/left4me-web.service +git commit -m "$(cat <<'EOF' +deploy/files: annotate reference units with per-directive hardening comments + +Update the educational reference copies of left4me-server@.service and +left4me-web.service to match the new hardening composition from the +ckn-bw reactor (HARDENING_COMMON + HARDENING_SERVER / HARDENING_WEB). +Per-directive comments explain each defense's purpose and the threat +it addresses, so a cold reader of this repo can understand the threat +model from the unit file alone. + +Top-of-file note in each reference points at the ckn-bw reactor as +the live source; reference is hand-synced. + +gunicorn ExecStart in the web reference uses placeholder +'--workers 4 --threads 4' values; live emission interpolates from +metadata. This is the documented divergence between the reference +and the deployed unit. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Fix four spec bugs in the hardening test plan + +**Repo:** `~/Projekte/left4me` + +**Files:** +- Modify: `~/Projekte/left4me/docs/superpowers/specs/2026-05-15-hardening-test-plan.md` + +The four bugs (per the test plan's existing "Spec bugs surfaced" / "Output" section, recorded by the test executor in commit `461b8d0`): + +A. **PID-lookup race.** `pgrep -f 'srcds_linux.*left4dead2' | head -1` picks whichever instance's PID is lowest — often `@2` not `@1`. Replace with `systemctl show -p MainPID --value left4me-server@1.service`. + +B. **gdb-from-host ptrace verification flaw.** `sudo nsenter --target $PID --mount -- gdb -p $TARGET` runs gdb as root with full caps in only the mount namespace; the unit's SECCOMP filter doesn't apply. Replace with a probe that runs *inside the same hardening profile* via `systemd-run` with the same directives, or inspect the compiled `SystemCallFilter` directly. + +C. **D5 pgrep pattern won't match.** `pgrep -f 'srcds_linux.*\@2'` doesn't match because the `@N` lives in the systemd unit name, not in argv. Use `systemctl show -p MainPID --value left4me-server@2.service` or look up the instance by game port (`27021` for @2 in current deployments). + +D. **`scmp_sys_resolver` package name.** It's in `seccomp` on Debian 13, not `libseccomp-dev` as the spec said. + +- [ ] **Step 1: Locate the four references in the test plan** + +```bash +grep -n "pgrep -f 'srcds_linux" ~/Projekte/left4me/docs/superpowers/specs/2026-05-15-hardening-test-plan.md +grep -n "nsenter.*gdb\|gdb.*--batch -p" ~/Projekte/left4me/docs/superpowers/specs/2026-05-15-hardening-test-plan.md +grep -n "libseccomp-dev" ~/Projekte/left4me/docs/superpowers/specs/2026-05-15-hardening-test-plan.md +``` + +Each occurrence is a fix site. + +- [ ] **Step 2: Apply fix A — PID-lookup race** + +Find and replace, across the test plan: + +``` +PID=$(pgrep -f 'srcds_linux.*left4dead2' | head -1) +``` + +With: + +``` +PID=$(systemctl show -p MainPID --value left4me-server@1.service) +``` + +(For tests targeting `@2`, use `@2.service` instead.) + +- [ ] **Step 3: Apply fix B — gdb verification flaw** + +Find every block like: + +```bash +sudo nsenter --target $PID --mount -- /usr/bin/gdb --batch -p $GUNICORN_PID 2>&1 | tail -3 +# Expect: ptrace: Operation not permitted +``` + +Replace with a note that this verification is unreliable, and offer two replacement approaches: + +```bash +# This nsenter-based gdb runs as root with full caps in only the unit's +# mount namespace; the SECCOMP filter doesn't apply, so the result is +# not meaningful. Use one of these instead: +# +# Option A: probe inside the same hardening profile. +sudo systemd-run --pty --uid=left4me --gid=left4me \ + -p NoNewPrivileges=true \ + -p PrivateUsers=true \ + -p CapabilityBoundingSet= \ + -p AmbientCapabilities= \ + -p SystemCallArchitectures='native x86' \ + -p SystemCallFilter='@system-service' \ + -p SystemCallFilter='~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged' \ + -- /usr/bin/gdb --batch -p $GUNICORN_PID 2>&1 | tail -3 +# Expect: ptrace: Operation not permitted (or seccomp-related kill) +# +# Option B: inspect the compiled SystemCallFilter directly. +sudo systemd-analyze syscall-filter left4me-server@1.service 2>&1 | grep -E '^(ptrace|process_vm)' +# Expect: empty (these syscalls are not in the allow list) +``` + +(Note: `systemd-analyze syscall-filter` may not accept a unit name; if not, parse the unit's `SystemCallFilter=` lines and resolve via `systemd-analyze syscall-filter @system-service`.) + +- [ ] **Step 4: Apply fix C — D5 pgrep pattern** + +Find Test 8's D5 verification: + +```bash +PID2=$(pgrep -f 'srcds_linux.*\@2' | head -1) +``` + +Replace with: + +```bash +PID2=$(systemctl show -p MainPID --value left4me-server@2.service) +``` + +- [ ] **Step 5: Apply fix D — package name** + +Find and replace: + +``` +scmp_sys_resolver (in libseccomp-dev; install on demand for Test 3/4 if filters need analysis) +``` + +With: + +``` +scmp_sys_resolver (in `seccomp` package on Debian 13; install on demand for Test 3/4 if filters need analysis) +``` + +- [ ] **Step 6: Mark the "Spec bugs surfaced" subsection as resolved** + +If the test plan has a section titled "Spec bugs surfaced" or "Output", append a resolution line: + +```markdown +**Resolved 2026-05-15 via the hardening-refactor plan** (commit +will be filled in after step 7 of Task 8). The four bugs are +fixed in-place in the test commands above. +``` + +- [ ] **Step 7: Commit** + +```bash +cd ~/Projekte/left4me +git add docs/superpowers/specs/2026-05-15-hardening-test-plan.md +git commit -m "$(cat <<'EOF' +spec(hardening-test-plan): fix four bugs surfaced by executor + +Four corrections noted by the test plan's executor in commit 461b8d0: + +- PID-lookup race: pgrep+head can pick the wrong instance. Replace + with systemctl show -p MainPID --value left4me-server@N.service. +- gdb-from-host ptrace check: nsenter into only the mount namespace + with root caps bypasses the SECCOMP filter, so the test is a false + positive. Replace with systemd-run-with-same-directives probe, or + syscall-filter inspection. +- D5 pgrep pattern: 'srcds_linux.*\@2' doesn't match because @N is + in the unit name, not argv. Use systemctl show -p MainPID. +- scmp_sys_resolver is in the seccomp package on Debian 13, not + libseccomp-dev. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: Mark `2026-05-15-user-uid-split-design.md` superseded + +**Repo:** `~/Projekte/left4me` + +**Files:** +- Modify: `~/Projekte/left4me/docs/superpowers/specs/2026-05-15-user-uid-split-design.md` (front-matter) + +- [ ] **Step 1: Read the current top of the file** + +```bash +head -10 ~/Projekte/left4me/docs/superpowers/specs/2026-05-15-user-uid-split-design.md +``` + +- [ ] **Step 2: Add a "Status: superseded" header** + +Insert at the top of the file, immediately after the H1 title line: + +```markdown +**Status: SUPERSEDED 2026-05-15 by the hardening refactor.** + +The original question — should left4me have 1, 2, or 3 system users — is +now answered: **2 users (current state) is correct.** The +defenses that motivated a 3-user split (DB readability from srcds, +cross-server ptrace, same-uid /proc visibility, web-side reach into +gameserver state) are closed by the systemd hardening composition +landed in commit ``: +- `PrivateUsers=true` blocks cross-uid ptrace at the kernel level. +- `PrivatePIDs=true` hides peer processes even when uids match. +- `TemporaryFileSystem=` + minimal binds hide the DB and web.env from + srcds entirely. +- `SystemCallFilter=~@debug` + empty `CapabilityBoundingSet=` block + ptrace at the syscall layer. + +The residual filesystem-ACL surface (DB at `0640 root:left4me`, +web.env same) is a separate concern: a uid split would close it via +kernel ACLs, but for the current deployment shape it's covered by the +systemd-imposed FS view. If the deployment shape changes (multi-tenant +host, shell logins as the service uids, additional services running +as `left4me` outside these units) the uid split should be revisited. + +The original content of this spec is preserved below for context. + +--- +``` + +(Replace `` with the actual hash once the commits land — Task 10 will sweep this.) + +- [ ] **Step 3: Commit** + +```bash +cd ~/Projekte/left4me +git add docs/superpowers/specs/2026-05-15-user-uid-split-design.md +git commit -m "$(cat <<'EOF' +spec(user-uid-split): mark superseded by the hardening refactor + +The 1/2/3-user question is answered: stay at 2 (left4me + l4d2-sandbox). +The defenses that motivated a 3-user split (cross-uid ptrace, +cross-server contamination, web-side reach into gameserver state, +DB/env exposure to srcds) are closed by the systemd hardening +composition: PrivateUsers + PrivatePIDs + TemporaryFileSystem + +SystemCallFilter=~@debug + empty CapabilityBoundingSet. + +The residual filesystem-ACL surface (mode 0640 root:left4me on DB and +web.env) is noted as a separate concern — covered for the current +deployment shape, revisit if shape changes. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Cross-repo push (operator-gated) + +**Repos:** both + +**Pre-condition:** all prior tasks committed locally in both repos. + +- [ ] **Step 1: Verify clean state in both repos** + +Run: +```bash +cd ~/Projekte/left4me && git status && git log --oneline origin/master..HEAD +echo "---" +cd ~/Projekte/ckn-bw && git status && git log --oneline origin/master..HEAD +``` + +Expected: both repos clean working trees; left4me has 4 new commits (Tasks 7, 8, 9, + possibly the Task 1 emitter-doc update), ckn-bw has 4 new commits (Tasks 2, 3, 4, 5). + +- [ ] **Step 2: Confirm with operator before push** + +Pause and confirm with the operator that pushing is the right move. Don't push autonomously — the operator's pattern in earlier sessions has been to commit locally and push manually. + +- [ ] **Step 3: If approved, push both repos** + +```bash +cd ~/Projekte/left4me && git push origin master +cd ~/Projekte/ckn-bw && git push origin master +``` + +- [ ] **Step 4: Sweep the placeholder `` in `2026-05-15-user-uid-split-design.md`** + +After the push, look up the actual refactor commit hash (the most recent left4me commit) and replace `` in the uid-split spec's superseded header. + +```bash +cd ~/Projekte/left4me +REFACTOR_HASH=$(git log --oneline -1 --format='%h') +sed -i.bak "s//$REFACTOR_HASH/" docs/superpowers/specs/2026-05-15-user-uid-split-design.md +rm docs/superpowers/specs/2026-05-15-user-uid-split-design.md.bak +git add docs/superpowers/specs/2026-05-15-user-uid-split-design.md +git commit --amend --no-edit +git push --force-with-lease origin master # only if not yet pulled elsewhere +``` + +(Force-push is OK here because the placeholder fill is part of the same commit; only acceptable if no other operator has pulled.) + +Alternative: leave `` as a follow-up sweep; the spec is human-readable either way. + +--- + +## Task 11: `bw apply ovh.left4me` and verify on the host + +**Repo:** `~/Projekte/ckn-bw` (executes against `left4.me`) + +**Pre-condition:** Task 10 completed. + +- [ ] **Step 1: Apply** + +```bash +cd ~/Projekte/ckn-bw +bw apply ovh.left4me 2>&1 | tee /tmp/bw-apply.log +``` + +Watch for errors. Expected outcome: the systemd units `left4me-server@1`, `left4me-server@2`, `left4me-web` are reloaded and restarted by `bw`. The sysctl drop-in is installed and reloaded. + +- [ ] **Step 2: Verify directives present on the host** + +SSH to `left4.me`: + +```bash +ssh root@left4.me # or whatever the operator's ssh alias is +sudo systemctl cat left4me-server@1.service +# Expect: contains every directive from HARDENING_SERVER. Specifically +# verify PrivatePIDs=true and SystemCallArchitectures=native x86 appear. +sudo systemctl cat left4me-web.service +# Expect: ProtectSystem=strict (not full), and the new hardening +# directives present (no NoNewPrivileges, no PrivateUsers). +sysctl kernel.yama.ptrace_scope +# Expect: kernel.yama.ptrace_scope = 2 +``` + +- [ ] **Step 3: Functional smoke** + +```bash +# All services active +sudo systemctl is-active left4me-server@1 left4me-server@2 left4me-web +# Expect: active active active + +# 30 seconds later, still active (catches respawn-loop regressions) +sleep 30 +sudo systemctl is-active left4me-server@1 left4me-server@2 left4me-web + +# systemd-analyze score +sudo systemd-analyze security left4me-server@1.service | tail -3 +# Expect: a low score (≤ 2.0); recall baseline was 7.5, Test 7 was 1.3 +sudo systemd-analyze security left4me-web.service | tail -3 +# Expect: ≤ 5.0; baseline 8.7, Test 10 was 4.1 +``` + +- [ ] **Step 4: Attack-vector re-verification (subset of Test 8, with the corrected probes)** + +```bash +PID1=$(systemctl show -p MainPID --value left4me-server@1.service) +GUNICORN_PID=$(systemctl show -p MainPID --value left4me-web.service) +PID2=$(systemctl show -p MainPID --value left4me-server@2.service) + +# D1.a — DB invisible to srcds +sudo nsenter --target $PID1 --mount -- cat /var/lib/left4me/left4me.db 2>&1 | head -1 +# Expect: No such file or directory + +# D1.b — web.env invisible +sudo nsenter --target $PID1 --mount -- cat /etc/left4me/web.env 2>&1 | head -1 +# Expect: No such file or directory + +# D2.b — gunicorn invisible via /proc +sudo nsenter --target $PID1 --mount --pid -- ls /proc/$GUNICORN_PID 2>&1 | head -1 +# Expect: No such file or directory (PrivatePIDs makes the host PID not exist in the namespace) + +# D5 — cross-instance: server@1 cannot see server@2's PID +sudo nsenter --target $PID1 --mount --pid -- ls /proc/$PID2 2>&1 | head -1 +# Expect: No such file or directory + +# Syscall filter compiled correctly (option B from Task 8/step 3 fix) +sudo systemd-analyze syscall-filter left4me-server@1.service 2>/dev/null \ + | grep -E '^\s*(ptrace|process_vm_)' || echo "blocked (not in allow list)" +# Expect: "blocked (not in allow list)" +``` + +- [ ] **Step 5: Smoke against the web UI** + +Manually: +- Open the web UI (whatever URL the operator uses for left4.me). +- Log in. +- Start/stop a server (exercises the sudo path). +- View live logs for a server. +- Trigger an overlay rebuild for a script overlay (exercises the sandbox). + +Confirm everything works. If anything breaks, the fix is to identify which directive caused it via journalctl, narrow the filter, and iterate. + +- [ ] **Step 6: Record on-host state** + +If something breaks: file a follow-up note in the test plan's Results section. If clean: just proceed. + +- [ ] **Step 7: No commit needed for this task** (host verification only) + +--- + +## Task 12: Apt-remove test tooling from `left4.me` + +**Repo:** none (host change) + +**Pre-condition:** Task 11 verified. + +- [ ] **Step 1: Confirm tooling is unused** + +SSH to `left4.me` and confirm no live processes need gdb/libseccomp/seccomp: + +```bash +which gdb +# Expect: /usr/bin/gdb (still installed) +sudo apt list --installed 2>/dev/null | grep -E '^(gdb|libseccomp-dev|seccomp)/' +# Expect: list of those packages +``` + +- [ ] **Step 2: Remove** + +```bash +sudo apt remove --purge -y gdb libseccomp-dev seccomp +sudo apt autoremove -y +``` + +- [ ] **Step 3: Verify clean** + +```bash +which gdb 2>&1 +# Expect: empty (gdb not in path) +sudo apt list --installed 2>/dev/null | grep -E '^(gdb|libseccomp-dev|seccomp)/' +# Expect: empty +``` + +- [ ] **Step 4: Verify services still happy** + +```bash +sudo systemctl is-active left4me-server@1 left4me-server@2 left4me-web +# Expect: active active active +``` + +- [ ] **Step 5: No commit needed** (host change only; if future operator wants reproducibility, add the removal to ckn-bw's `pkg_apt` exclusion list — but defer that decision) + +--- + +## Plan complete + +After Task 12, write a final session-handoff at +`docs/superpowers/specs/2026-05-15-session-handoff.md` (overwrite the +existing executor handoff) summarizing: + +- Refactor landed (commit hashes both repos) +- All attack vectors still blocked post-deploy +- uid-split spec marked superseded +- Next session: build-overlay-unit refactor or the deferred drop-in + reshape, operator's call + +Commit + push the handoff. + +## Self-review checklist + +After completing all tasks, verify against the design spec: + +- [ ] All directives from HARDENING_SERVER appear in the deployed + `left4me-server@.service` (per Task 11/Step 2). +- [ ] All directives from HARDENING_WEB appear in the deployed + `left4me-web.service`. +- [ ] PrivatePIDs=true and SystemCallArchitectures=native x86 present + on server@. (Test amendments.) +- [ ] MemoryDenyWriteExecute=true absent everywhere. +- [ ] SocketBindAllow=udp:27000-27999 + tcp:27000-27999 on server@. +- [ ] `kernel.yama.ptrace_scope=2` on the host. +- [ ] Reference units in deploy/files annotated with per-directive + comments. +- [ ] Test plan's four bugs fixed. +- [ ] uid-split spec marked superseded. +- [ ] gdb + seccomp + libseccomp-dev removed from left4.me. +- [ ] All three units active and stable. +- [ ] Test 8 D1.a, D1.b, D2.b, D5 vectors all blocked.