left4me/docs/superpowers/plans/2026-05-15-hardening-refactor.md
mwiegand b1293f9952
plan(hardening-refactor): implementation plan against the proven composition
12 tasks across left4me + ckn-bw: emitter verification, three Python
constants in the systemd_units reactor, spread into both managed units,
sysctl drop-in, annotated reference units, four spec bug fixes, mark
uid-split spec superseded, cross-repo push, bw apply + verify on host,
apt-remove test tooling. Each task has bite-sized steps with exact
commands and expected output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:25:25 +02:00

48 KiB

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:

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:

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:

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

## 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 <date> against
  bundles/systemd at <commit>.
- `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

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) <noreply@anthropic.com>
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:

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:

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

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:

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
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) <noreply@anthropic.com>
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:

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:

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

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:

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
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) <noreply@anthropic.com>
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:

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
'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 fullstrict).

  • Step 3: Verify the file still parses and bw test passes
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
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) <noreply@anthropic.com>
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:

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:

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

'/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
cd ~/Projekte/ckn-bw
bw test ovh.left4me 2>&1 | head -50
  • Step 4: Commit
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) <noreply@anthropic.com>
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:

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:

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

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

cat ~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service
  • Step 2: Replace with the new annotated content
# 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
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) <noreply@anthropic.com>
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
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:

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:

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

PID2=$(pgrep -f 'srcds_linux.*\@2' | head -1)

Replace with:

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:

**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
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) <noreply@anthropic.com>
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

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:

**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 `<refactor 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 <refactor commit> with the actual hash once the commits land — Task 10 will sweep this.)

  • Step 3: Commit
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) <noreply@anthropic.com>
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:

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
cd ~/Projekte/left4me && git push origin master
cd ~/Projekte/ckn-bw && git push origin master
  • Step 4: Sweep the placeholder <refactor commit> 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 <refactor commit> in the uid-split spec's superseded header.

cd ~/Projekte/left4me
REFACTOR_HASH=$(git log --oneline -1 --format='%h')
sed -i.bak "s/<refactor commit>/$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 <refactor commit> 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
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:

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

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
sudo apt remove --purge -y gdb libseccomp-dev seccomp
sudo apt autoremove -y
  • Step 3: Verify clean
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
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.