left4me/docs/superpowers/plans/2026-05-15-deployment-responsibility.md

45 KiB

Deployment Responsibility 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: Make left4me/deploy/ the single source of truth for static application-shape deployment artifacts (hardening drop-ins, sudoers, sysctl, helpers). ckn-bw delivers them via target-side symlinks into the (root-owned) /opt/left4me/src/deploy/... checkout.

Architecture: Five independent landable migration steps. Sysctl consolidation is the canary that validates the symlink mechanism end-to-end before higher-stakes artifacts (hardening, sudoers, scripts) follow. Base systemd unit bodies + slice CPU pinning stay bw-managed; only static drop-ins and stand-alone files move. Each step touches both repos (left4me + ckn-bw) and is verified on ovh.left4me via bw apply + on-host inspection.

Tech Stack: bundlewrap (bw apply, symlinks{}/files{} item types), systemd (systemctl daemon-reload, drop-ins), sysctl (sysctl --system), Python (pytest, ckn-bw items.py + metadata.py), git.

Spec: docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md. Reread it before starting if anything below is ambiguous.


Preflight

  • Step 0.1: Confirm prereq landed.
ssh root@ovh.left4me 'stat -c "%U:%G %a %n" /opt/left4me/src /var/lib/left4me/.venv /var/lib/left4me/steam'

Expected: /opt/left4me/src is root:root 755; .venv and steam are left4me:left4me. If not, stop — the runtime-state relocation (2026-05-15-runtime-state-relocation-design.md) hasn't landed and target-side symlinks won't be safe.

  • Step 0.2: Capture baseline state. Save the output to /tmp/preflight-baseline.txt for cross-checking later.
ssh root@ovh.left4me '
  echo "=== sysctl ==="
  sysctl kernel.yama.ptrace_scope net.core.rmem_max net.ipv4.tcp_congestion_control
  echo "=== left4me-web hardening (selected) ==="
  systemctl show -p ProtectSystem,ProtectHome,PrivateTmp,ProtectProc,SystemCallArchitectures,SystemCallFilter left4me-web.service
  echo "=== left4me-server@ hardening (selected, instance 1) ==="
  systemctl show -p ProtectSystem,PrivateUsers,PrivatePIDs,NoNewPrivileges,CapabilityBoundingSet,SystemCallArchitectures left4me-server@1.service 2>/dev/null || echo "(no instance 1 running; use any active instance or the unit-file value via systemctl cat)"
  echo "=== sudo for left4me ==="
  sudo -l -U left4me 2>&1 | head -30
'

Save the output; you will diff against these values after each migration step.


Goal: Move kernel.yama.ptrace_scope from ckn-bw metadata into the existing sysctl drop-in in left4me; replace the verbatim mirror in ckn-bw with a symlink item. Smallest possible change that validates the full mechanism (file content in left4me, symlink in ckn-bw, content propagates on bw apply, on-host kernel state matches).

Files:

  • Modify: ~/Projekte/left4me/deploy/files/etc/sysctl.d/99-left4me.conf
  • Modify: ~/Projekte/left4me/deploy/tests/test_example_units.py:182-198
  • Modify: ~/Projekte/ckn-bw/bundles/left4me/metadata.py:86-96
  • Modify: ~/Projekte/ckn-bw/bundles/left4me/items.py (replace files entry, add symlinks block)
  • Delete: ~/Projekte/ckn-bw/bundles/left4me/files/etc/sysctl.d/99-left4me.conf

Steps

  • Step 1.1: Append ptrace_scope to the sysctl drop-in in left4me.

Edit deploy/files/etc/sysctl.d/99-left4me.conf, append (preserving the trailing newline):

# Block ptrace except from CAP_SYS_PTRACE holders. Belt-and-braces with
# SystemCallFilter=~@debug + PrivateUsers=true in the gameserver unit.
# See docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md.
kernel.yama.ptrace_scope = 2
  • Step 1.2: Update the left4me test to assert the new line.

In deploy/tests/test_example_units.py:182-198, add one entry to the tuple in test_sysctl_conf_present_with_perf_settings:

def test_sysctl_conf_present_with_perf_settings():
    assert SYSCTL_CONF.is_file()
    text = SYSCTL_CONF.read_text()
    for line in (
        "net.core.rmem_max = 8388608",
        "net.core.wmem_max = 8388608",
        "net.core.rmem_default = 524288",
        "net.core.wmem_default = 524288",
        "net.core.netdev_max_backlog = 5000",
        "net.core.netdev_budget = 600",
        "vm.swappiness = 10",
        "net.ipv4.udp_rmem_min = 16384",
        "net.ipv4.udp_wmem_min = 16384",
        "net.core.default_qdisc = fq_codel",
        "net.ipv4.tcp_congestion_control = bbr",
        "kernel.yama.ptrace_scope = 2",
    ):
        assert line in text, f"missing {line!r} in 99-left4me.conf"
  • Step 1.3: Run the test and watch it pass.
cd ~/Projekte/left4me
pytest deploy/tests/test_example_units.py::test_sysctl_conf_present_with_perf_settings -v

Expected: PASS.

  • Step 1.4: Commit the left4me side.
cd ~/Projekte/left4me
git add deploy/files/etc/sysctl.d/99-left4me.conf deploy/tests/test_example_units.py
git commit -m "deploy/sysctl: absorb kernel.yama.ptrace_scope into the drop-in

Single source of truth for left4me sysctl tuning. The metadata entry
in ckn-bw (sysctl/kernel/yama/ptrace_scope) is removed in lockstep;
the live value is unchanged.

Part of 2026-05-15-deployment-responsibility-design.md migration step 1
(canary)."
git push
  • Step 1.5: Remove the metadata entry in ckn-bw.

In ~/Projekte/ckn-bw/bundles/left4me/metadata.py, delete the 'sysctl' block from defaults (lines ~86-96):

# DELETE THIS BLOCK:
    'sysctl': {
        # 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',
            },
        },
    },
  • Step 1.6: Replace the bw files{} entry with a symlinks{} entry in ckn-bw.

In ~/Projekte/ckn-bw/bundles/left4me/items.py:

(a) Find the files dict and remove the '/etc/sysctl.d/99-left4me.conf' entry:

# DELETE FROM `files = { ... }`:
    '/etc/sysctl.d/99-left4me.conf': {
        'source': 'etc/sysctl.d/99-left4me.conf',
        'mode': '0644',
        'owner': 'root',
        'group': 'root',
        'triggers': [
            'action:left4me_sysctl_reload',
        ],
    },

(b) Add a new symlinks block (if one doesn't exist yet) near the files dict:

symlinks = {
    '/etc/sysctl.d/99-left4me.conf': {
        'target': '/opt/left4me/src/deploy/files/etc/sysctl.d/99-left4me.conf',
        'owner': 'root',
        'group': 'root',
        'needs': [
            'git_deploy:/opt/left4me/src',
        ],
        'triggers': [
            'action:left4me_sysctl_reload',
        ],
    },
}

The needs: git_deploy:/opt/left4me/src is critical — without it bw may try to create the symlink before the checkout exists on a fresh host.

  • Step 1.7: Delete the verbatim mirror.
cd ~/Projekte/ckn-bw
rm bundles/left4me/files/etc/sysctl.d/99-left4me.conf
rmdir bundles/left4me/files/etc/sysctl.d 2>/dev/null || true  # if empty
  • Step 1.8: Run bw test to catch config errors locally.
cd ~/Projekte/ckn-bw
bw test

Expected: no errors for the left4me bundle.

  • Step 1.9: Apply to the production host.
cd ~/Projekte/ckn-bw
bw apply ovh.left4me

Expected output: shows the file→symlink replacement (bw removes the file, creates the symlink), fires left4me_sysctl_reload. The sysctl bundle no longer emits a separate ptrace_scope file (the metadata entry is gone), so that file goes away too. Net new state: one symlink at /etc/sysctl.d/99-left4me.conf plus the kernel parameter set.

  • Step 1.10: Verify on the host.
ssh root@ovh.left4me '
  echo "=== symlink ==="
  ls -la /etc/sysctl.d/99-left4me.conf
  echo "=== kernel value ==="
  sysctl kernel.yama.ptrace_scope
  echo "=== perf values still set ==="
  sysctl net.core.rmem_max net.ipv4.tcp_congestion_control
  echo "=== bw-generated ptrace_scope file is gone ==="
  ls /etc/sysctl.d/ | grep -i yama || echo "(no yama files — good)"
'

Expected:

  • /etc/sysctl.d/99-left4me.conf/opt/left4me/src/deploy/files/etc/sysctl.d/99-left4me.conf symlink

  • kernel.yama.ptrace_scope = 2

  • net.core.rmem_max = 8388608 and net.ipv4.tcp_congestion_control = bbr

  • No separate ptrace-related file in /etc/sysctl.d/

  • Step 1.11: Re-apply to confirm idempotent.

cd ~/Projekte/ckn-bw
bw apply ovh.left4me

Expected: 0 fixed, 0 failed. No actions re-fire.

  • Step 1.12: Commit ckn-bw side.
cd ~/Projekte/ckn-bw
git add bundles/left4me/metadata.py bundles/left4me/items.py
git add bundles/left4me/files/etc/sysctl.d/  # captures the deletion
git commit -m "left4me: symlink /etc/sysctl.d/99-left4me.conf to the checkout

Sysctl drop-in lives in left4me/deploy/files/etc/sysctl.d/99-left4me.conf
(absorbed kernel.yama.ptrace_scope from the metadata entry). Deliver
via target-side symlink instead of a verbatim copy.

Canary for the deployment-responsibility reshape (left4me design doc
2026-05-15-deployment-responsibility-design.md, step 1). Validated
end-to-end on ovh.left4me: symlink resolves to the checkout,
sysctl --system fires on apply, kernel value matches, idempotent."

Goal: Extract HARDENING_COMMON / HARDENING_SERVER / HARDENING_WEB from ckn-bw's Python source into two .conf drop-in files in left4me. Reactor stops splatting hardening into the unit body. ckn-bw deploys the drop-ins via target-side symlinks. Reference units in deploy/files/ lose their inline hardening (which moves into the drop-in files).

This is the biggest task. It runs systemctl daemon-reload on apply and changes the live unit-level enforcement state of left4me-web.service and left4me-server@.service. Restart both after applying to ensure the new effective directives are in force.

Files:

  • Create: ~/Projekte/left4me/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf
  • Create: ~/Projekte/left4me/deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf
  • Modify: ~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service (strip hardening directives + comments; keep base-unit content)
  • Modify: ~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service (same)
  • Modify: ~/Projekte/left4me/deploy/tests/test_example_units.py (move hardening assertions to a new test for the drop-ins; remove from the unit-body tests)
  • Modify: ~/Projekte/ckn-bw/bundles/left4me/metadata.py (delete HARDENING_* constants; remove **HARDENING_WEB / **HARDENING_SERVER splats from the reactor)
  • Modify: ~/Projekte/ckn-bw/bundles/left4me/items.py (add directory items for the .service.d/ dirs; add symlinks for the drop-ins)

Steps

  • Step 2.1: Create the web drop-in in left4me.

Move the existing hardening directives + their per-directive comments out of deploy/files/usr/local/lib/systemd/system/left4me-web.service into a new file at deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf. The new file's [Service] section contains every directive currently between the per-directive comment block in the reference unit. Concretely:

# Hardening drop-in for left4me-web.service.
#
# Source of truth: this file (in left4me/deploy/files/). ckn-bw deploys
# it to /etc/systemd/system/left4me-web.service.d/10-hardening.conf via a
# target-side symlink into the checkout.
#
# See docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md
# and 2026-05-15-hardening-test-plan.md for the threat model and the
# verification matrix.
#
# This unit is the web app; some sudo-incompatible directives are
# intentionally absent:
#   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
# All of those are unconditional on the gameserver unit (no sudo there).
[Service]
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectProc=invisible
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectClock=true
ProtectControlGroups=true
ProtectHostname=true
LockPersonality=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
RestrictRealtime=true
RemoveIPC=true
KeyringMode=private
UMask=0027

(Confirm the exact directive list matches HARDENING_COMMON + the web-specific additions in ckn-bw's metadata.py:131-210. Any directive in HARDENING_WEB belongs here.)

  • Step 2.2: Create the gameserver drop-in in left4me.

deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf:

# Hardening drop-in for left4me-server@.service.
#
# Source of truth: this file (in left4me/deploy/files/). ckn-bw deploys
# it to /etc/systemd/system/left4me-server@.service.d/10-hardening.conf
# via a target-side symlink into the checkout.
#
# Gameserver unit: full hardening profile. No sudo path inside; no
# sudo-incompatibility carve-outs.
[Service]
NoNewPrivileges=true
RestrictSUIDSGID=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
SystemCallFilter=~@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
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
ProtectHome=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
PrivateTmp=true
PrivateDevices=true
PrivateIPC=true
RestrictNamespaces=true
RestrictRealtime=true
ProtectProc=invisible
ProcSubset=pid
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectClock=true
ProtectControlGroups=true
ProtectHostname=true
LockPersonality=true
RemoveIPC=true
KeyringMode=private
UMask=0027
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Lock srcds bindable sockets to the game port range. Hard-coded range
# because systemd directive variable substitution is uneven for
# SocketBindAllow.
SocketBindAllow=udp:27000-27999
SocketBindAllow=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.

(Same cross-check: the directive list must equal HARDENING_SERVER in ckn-bw metadata.py.)

  • Step 2.3: Strip hardening from the reference unit files in left4me.

Edit deploy/files/usr/local/lib/systemd/system/left4me-web.service and left4me-server@.service. Remove every line that is now in the corresponding drop-in. Keep:

  • [Unit] section as-is
  • [Service] base-unit directives: Type, User, Group, WorkingDirectory, Environment=, EnvironmentFile=, ExecStartPre=, ExecStart=, ExecStopPost=, Restart=, RestartSec=, ReadWritePaths=, Slice=, Nice=, IOSchedulingClass=, IOSchedulingPriority=, OOMScoreAdjust=, MemoryHigh=, MemoryMax=, TasksMax=, LimitNOFILE=, KillSignal=, TimeoutStopSec=, LogRateLimitIntervalSec=
  • [Install] section as-is
  • The per-directive comment block at the top describing the sudo-incompatibility carve-outs is fine to drop (it's redundant with the drop-in file); leave one short pointer comment in the unit body if useful.

The resulting reference unit is the "base" unit: what the reactor emits before the drop-in adds the hardening profile.

  • Step 2.4: Update the left4me test to reflect the split.

In deploy/tests/test_example_units.py:

(a) Add path constants near the existing ones:

WEB_HARDENING_DROPIN = DEPLOY / "files/etc/systemd/system/left4me-web.service.d/10-hardening.conf"
SERVER_HARDENING_DROPIN = DEPLOY / "files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf"

(b) Remove hardening assertions from test_web_unit_contains_required_runtime_contract. The unit-body assertions that stay are the base-unit ones (User, Group, WorkingDirectory, PATH, EnvironmentFile, ExecStart, --workers/--threads, NoNewPrivileges-NOT-set, ReadWritePaths, MountFlags-NOT-set). The assertions that move to a new test (test_web_hardening_dropin) are the hardening directives (PrivateTmp=true, ProtectSystem=full|strict, etc. — note: ProtectSystem=full assertion in the existing test was already incorrect drift; the new assertion should be ProtectSystem=strict matching HARDENING_COMMON).

(c) Same surgery for test_server_unit_contains_required_runtime_contract → split base-unit assertions from hardening assertions.

(d) New tests:

def test_web_hardening_dropin_present_with_directives():
    assert WEB_HARDENING_DROPIN.is_file()
    text = WEB_HARDENING_DROPIN.read_text()
    assert "[Service]" in text
    # COMMON
    for d in (
        "ProtectProc=invisible",
        "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",
    ):
        assert d in text, f"missing {d!r} in web hardening drop-in"
    # WEB-specific
    assert "SystemCallArchitectures=native" in text
    assert "SystemCallFilter=@system-service" in text
    assert "SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete" in text
    # WEB must NOT include the sudo-incompatible directives.
    assert "NoNewPrivileges=" not in text
    assert "PrivateUsers=" not in text
    assert "RestrictSUIDSGID=" not in text
    assert "CapabilityBoundingSet=" not in text
    assert "~@privileged" not in text


def test_server_hardening_dropin_present_with_directives():
    assert SERVER_HARDENING_DROPIN.is_file()
    text = SERVER_HARDENING_DROPIN.read_text()
    assert "[Service]" in text
    # Server adds sudo-incompatible flags on top of COMMON.
    for d in (
        "NoNewPrivileges=true",
        "RestrictSUIDSGID=true",
        "PrivateUsers=true",
        "PrivatePIDs=true",
        "PrivateIPC=true",
        "PrivateDevices=true",
        "CapabilityBoundingSet=",
        "AmbientCapabilities=",
        "SystemCallArchitectures=native x86",
        "ProcSubset=pid",
        "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",
        "BindPaths=/var/lib/left4me/runtime/%i",
        "SocketBindAllow=udp:27000-27999",
        "SocketBindAllow=tcp:27000-27999",
    ):
        assert d in text, f"missing {d!r} in server hardening drop-in"
    assert "SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged" in text
    # MemoryDenyWriteExecute must remain absent (Source engine compat).
    assert "MemoryDenyWriteExecute" not in text
  • Step 2.5: Run all left4me tests.
cd ~/Projekte/left4me
pytest deploy/tests/test_example_units.py -v

Expected: all PASS (including the two new drop-in tests and the slimmed-down unit-body tests).

  • Step 2.6: Commit left4me side.
cd ~/Projekte/left4me
git add deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf
git add deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf
git add deploy/files/usr/local/lib/systemd/system/left4me-web.service
git add deploy/files/usr/local/lib/systemd/system/left4me-server@.service
git add deploy/tests/test_example_units.py
git commit -m "deploy: extract hardening into drop-in files alongside the units

Hardening directives leave the base unit body and live in:
  deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf
  deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf

Reference units now describe just the base operational shape (exec,
env, restart, resources). Tests split: base-unit content and hardening
profile are asserted separately.

Part of 2026-05-15-deployment-responsibility-design.md migration
step 2. ckn-bw lands the matching reactor surgery + symlink delivery."
git push
  • Step 2.7: Drop the HARDENING constants + splats from ckn-bw.

In ~/Projekte/ckn-bw/bundles/left4me/metadata.py:

(a) Delete HARDENING_COMMON, HARDENING_SERVER, HARDENING_WEB (lines ~122-210). All three constants and their comment block go away.

(b) Remove **HARDENING_WEB from the left4me-web.service Service dict (in the systemd_units reactor).

(c) Remove **HARDENING_SERVER from the left4me-server@.service Service dict.

(d) Update the # Hardening profile — see HARDENING_WEB ... comments above each splat to point to the drop-in file path instead (e.g., # Hardening profile delivered via /etc/systemd/system/left4me-web.service.d/10-hardening.conf (symlink into the checkout).).

  • Step 2.8: Add directory + symlink items for the drop-ins.

In ~/Projekte/ckn-bw/bundles/left4me/items.py:

(a) Add to directories:

    '/etc/systemd/system/left4me-web.service.d': {
        'owner': 'root', 'group': 'root', 'mode': '0755',
    },
    '/etc/systemd/system/left4me-server@.service.d': {
        'owner': 'root', 'group': 'root', 'mode': '0755',
    },

(b) Add one triggered action that runs systemctl daemon-reload. It will fire on either the symlink itself changing OR on git_deploy updating the source content (the symlink path doesn't change in that case, so we need both wirings):

actions['left4me_daemon_reload'] = {
    'command': 'systemctl daemon-reload',
    'triggered': True,
    'cascade_skip': False,
}

(c) Add to symlinks (which now has the sysctl entry from Task 1):

    '/etc/systemd/system/left4me-web.service.d/10-hardening.conf': {
        'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf',
        'owner': 'root', 'group': 'root',
        'needs': [
            'directory:/etc/systemd/system/left4me-web.service.d',
            'git_deploy:/opt/left4me/src',
        ],
        'triggers': [
            'action:left4me_daemon_reload',
        ],
    },
    '/etc/systemd/system/left4me-server@.service.d/10-hardening.conf': {
        'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf',
        'owner': 'root', 'group': 'root',
        'needs': [
            'directory:/etc/systemd/system/left4me-server@.service.d',
            'git_deploy:/opt/left4me/src',
        ],
        'triggers': [
            'action:left4me_daemon_reload',
        ],
    },

(d) Wire left4me_daemon_reload into the git_deploy:/opt/left4me/src triggers list (in addition to whatever's already there — left4me_alembic_upgrade, left4me_pip_install, etc.):

git_deploy = {
    '/opt/left4me/src': {
        'repo': node.metadata.get('left4me/git_url'),
        'rev': node.metadata.get('left4me/git_branch'),
        'triggers': [
            'action:left4me_alembic_upgrade',
            'action:left4me_pip_install',
            'action:left4me_daemon_reload',     # NEW
            # …whatever else is already in this list
        ],
    },
}

This handles the case where the source content of a hardening drop-in changes (via git_deploy) but the symlink itself doesn't.

  • Step 2.9: bw test in ckn-bw.
cd ~/Projekte/ckn-bw
bw test

Expected: no errors.

  • Step 2.10: Apply to the production host (interactive).

The web service will get its hardening overlay reloaded on this apply. Expect bw to: remove old reactor-emitted hardening from the unit file, create the new drop-in directories and symlinks, systemctl daemon-reload. The service is not restarted automatically by bw on a drop-in change. You must restart it explicitly to pick up the new effective directives:

cd ~/Projekte/ckn-bw
bw apply ovh.left4me
ssh root@ovh.left4me 'systemctl restart left4me-web.service'

Gameserver instances are template-instantiated on demand by the web app. To pick up the new hardening drop-in, any currently running instance must be restarted via the web UI (or directly: systemctl restart left4me-server@<instance>). New instances started after daemon-reload will get the new hardening automatically.

  • Step 2.11: Verify on host.
ssh root@ovh.left4me '
  echo "=== drop-ins symlinked ==="
  ls -la /etc/systemd/system/left4me-web.service.d/10-hardening.conf
  ls -la /etc/systemd/system/left4me-server@.service.d/10-hardening.conf
  echo "=== web unit, effective directives ==="
  systemctl show -p ProtectSystem,ProtectHome,PrivateTmp,ProtectProc,SystemCallArchitectures,LockPersonality left4me-web.service
  echo "=== server unit (file-level, since instances spawn on demand) ==="
  systemctl cat left4me-server@.service | grep -E "^Drop-In|10-hardening"
  echo "=== systemctl daemon-reload fired ==="
  systemctl status left4me-web.service | head -5
'

Expected:

  • Both drop-in symlinks point into /opt/left4me/src/deploy/files/etc/systemd/system/...

  • systemctl show for left4me-web reports the directives from the drop-in (ProtectSystem=strict, PrivateTmp=yes, etc. — matches Step 0.2's baseline)

  • systemctl cat left4me-server@.service shows # Drop-In: /etc/systemd/system/left4me-server@.service.d/ and references 10-hardening.conf

  • Step 2.12: Run the relevant hardening test plan checks.

From docs/superpowers/specs/2026-05-15-hardening-test-plan.md, pick a small subset that exercises both units in the live state (e.g., Test 1 ProtectSystem, Test 4 PrivateUsers on the server unit). Run them. Expected: pass identically to before.

  • Step 2.13: Re-apply for idempotency.
cd ~/Projekte/ckn-bw
bw apply ovh.left4me

Expected: 0 fixed, 0 failed. Daemon-reload does not fire on a no-op apply.

  • Step 2.14: Commit ckn-bw side.
cd ~/Projekte/ckn-bw
git add bundles/left4me/metadata.py bundles/left4me/items.py
git commit -m "left4me: hardening lives in drop-ins owned by left4me; deliver via symlink

Reactor stops emitting hardening directives in the unit bodies. The
HARDENING_COMMON / HARDENING_SERVER / HARDENING_WEB constants are gone.
Effective hardening on the live units now comes from drop-in files
shipped by left4me at:
  /etc/systemd/system/left4me-web.service.d/10-hardening.conf
  /etc/systemd/system/left4me-server@.service.d/10-hardening.conf
Both are target-side symlinks into /opt/left4me/src/deploy/files/...
(safe because /opt/left4me/src is root-owned post-relocation refactor).

Verified on ovh.left4me: systemctl show reports the same directives as
the pre-refactor baseline; relevant hardening test-plan checks pass."

Goal: /etc/sudoers.d/left4me becomes a symlink into the checkout instead of a file copy from ckn-bw's bundle. Delete the bundle's mirror file.

Files:

  • Modify: ~/Projekte/ckn-bw/bundles/left4me/items.py (move sudoers from filessymlinks)
  • Delete: ~/Projekte/ckn-bw/bundles/left4me/files/etc/sudoers.d/left4me
  • (Optional) Create: ~/Projekte/left4me/deploy/tests/test_sudoers.py

Steps

  • Step 3.1: (Optional) Add a left4me-side syntax test.

The current bw files{} entry uses 'test_with': 'visudo -cf {}', which won't run for a symlinks{} entry. Replace that gate with a left4me-side pytest:

# deploy/tests/test_sudoers.py
"""Syntax-check the sudoers drop-in via visudo before it leaves the repo."""
import shutil
import subprocess
from pathlib import Path

import pytest

SUDOERS = Path(__file__).resolve().parents[2] / "deploy/files/etc/sudoers.d/left4me"


@pytest.mark.skipif(shutil.which("visudo") is None, reason="visudo not installed")
def test_sudoers_parses():
    result = subprocess.run(
        ["visudo", "-cf", str(SUDOERS)],
        capture_output=True, text=True,
    )
    assert result.returncode == 0, f"visudo -cf failed: {result.stdout}{result.stderr}"

Run it:

cd ~/Projekte/left4me
pytest deploy/tests/test_sudoers.py -v

Expected: PASS (or SKIP on systems without visudo — fine for CI on hosts that have it).

  • Step 3.2: Commit the left4me-side test if added.
cd ~/Projekte/left4me
git add deploy/tests/test_sudoers.py
git commit -m "deploy/tests: add visudo syntax test for the sudoers drop-in

Pre-deploy syntax guard; replaces ckn-bw's per-item test_with which
won't apply to a symlink-delivered file (see deployment-responsibility
migration step 3)."
git push
  • Step 3.3: Move the sudoers entry from files to symlinks in ckn-bw.

In ~/Projekte/ckn-bw/bundles/left4me/items.py:

(a) Remove from files:

# DELETE:
    '/etc/sudoers.d/left4me': {
        'source': 'etc/sudoers.d/left4me',
        'mode': '0440',
        'owner': 'root',
        'group': 'root',
        'test_with': 'visudo -cf {}',
    },

(b) Add to symlinks:

    '/etc/sudoers.d/left4me': {
        'target': '/opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
        'owner': 'root', 'group': 'root',
        'needs': [
            'git_deploy:/opt/left4me/src',
        ],
        # sudo follows symlinks; with the target file at root:root 0440
        # in a root-owned source tree, sudo accepts it. No daemon-reload
        # equivalent — sudo re-reads /etc/sudoers.d/ on each invocation.
    },
  • Step 3.4: Delete the verbatim mirror.
cd ~/Projekte/ckn-bw
rm bundles/left4me/files/etc/sudoers.d/left4me
rmdir bundles/left4me/files/etc/sudoers.d 2>/dev/null || true
  • Step 3.5: Confirm the source file has the right mode (must be 0440 root:root) in the live checkout.

The on-target permissions of /opt/left4me/src/deploy/files/etc/sudoers.d/left4me matter (sudo checks the resolved file). git_deploy extracts as root, so the file inherits the source file's mode from git (default 0644). sudo requires 0440 / 0400. The simplest fix: a small ckn-bw action that runs chmod 0440 /opt/left4me/src/deploy/files/etc/sudoers.d/left4me after git_deploy. Add to items.py:

actions['left4me_chmod_sudoers'] = {
    'command': 'chmod 0440 /opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
    'unless': 'test "$(stat -c %a /opt/left4me/src/deploy/files/etc/sudoers.d/left4me)" = "440"',
    'cascade_skip': False,
    'needs': [
        'git_deploy:/opt/left4me/src',
    ],
    # Required so the symlink resolves to a sudo-acceptable file.
}

And add 'action:left4me_chmod_sudoers' to the symlinks['/etc/sudoers.d/left4me'] needs list.

Alternative: set the in-repo file's git mode to 0440 with chmod 0440 deploy/files/etc/sudoers.d/left4me && git add --chmod=u-w … so git tracks the mode. Either works; the action approach is more robust against future repo-mode regressions.

  • Step 3.6: bw test + apply.
cd ~/Projekte/ckn-bw
bw test
bw apply ovh.left4me

Expected: file removed, action fires the chmod, symlink created.

  • Step 3.7: Verify sudo still works.
ssh root@ovh.left4me '
  echo "=== symlink ==="
  ls -la /etc/sudoers.d/left4me
  stat -c "%U:%G %a" /opt/left4me/src/deploy/files/etc/sudoers.d/left4me
  echo "=== sudoers content via sudo -l ==="
  sudo -l -U left4me 2>&1 | head -30
'

Expected:

  • Symlink resolves into the checkout

  • Target file is root:root 440

  • sudo -l -U left4me lists the same commands as Step 0.2 baseline

  • Step 3.8: Functional check — run a privileged helper via sudo.

Trigger one of the sudoers-allowed commands the way the web app does, e.g. a status check via the systemctl helper:

ssh root@ovh.left4me 'sudo -u left4me /usr/bin/sudo /usr/local/libexec/left4me/left4me-systemctl status left4me-web.service | head -5'

Expected: command runs successfully (returns status output, not "Sorry, user … is not allowed").

  • Step 3.9: Idempotent re-apply + commit.
cd ~/Projekte/ckn-bw
bw apply ovh.left4me   # expect 0 fixed
git add bundles/left4me/items.py bundles/left4me/files/etc/sudoers.d/
git commit -m "left4me: symlink /etc/sudoers.d/left4me to the checkout

Sudoers drop-in lives in left4me/deploy/files/etc/sudoers.d/left4me
(single source of truth). Deleted the verbatim mirror in this bundle's
files/ tree. Added an idempotent chmod action so the in-checkout file
is 0440 root:root — required for sudo to accept it through the symlink.

Syntax check on the source file is now a left4me-side pytest
(deploy/tests/test_sudoers.py) running visudo -cf.

Part of 2026-05-15-deployment-responsibility-design.md migration step 3."

Goal: Move left4me/scripts/{libexec,sbin}/ into left4me/deploy/scripts/{libexec,sbin}/ for layout consistency (everything ckn-bw deploys lives under deploy/). Replace the install_left4me_scripts copy-action with bw symlinks{} items, one per script.

Files:

  • git mv in left4me: scripts/deploy/scripts/
  • Modify: ~/Projekte/left4me/AGENTS.md, any docs referencing the old path (grep first)
  • Modify: ~/Projekte/ckn-bw/bundles/left4me/items.py (drop install_left4me_scripts action; add symlink items; update git_deploy triggers)

Steps

  • Step 4.1: Find every reference to the old path in left4me.
cd ~/Projekte/left4me
grep -rn "scripts/libexec\|scripts/sbin\|/opt/left4me/src/scripts" \
  --include='*.py' --include='*.md' --include='*.sh' --include='*.toml' --include='*.cfg' \
  . 2>/dev/null | grep -v worktrees

Save the file list — every match needs updating.

  • Step 4.2: git mv the scripts directory in left4me.
cd ~/Projekte/left4me
git mv scripts deploy/scripts
  • Step 4.3: Update all references in left4me to the new path.

For each file found in 4.1, replace scripts/libexec/deploy/scripts/libexec/, scripts/sbin/deploy/scripts/sbin/, /opt/left4me/src/scripts//opt/left4me/src/deploy/scripts/. Use sed or your editor of choice. Be careful: don't touch worktree files or vendored dependencies.

  • Step 4.4: Run the full left4me test suite.
cd ~/Projekte/left4me
pytest -v

Expected: PASS. If any test fails on a path reference you missed, fix and re-run.

  • Step 4.5: Commit the left4me relocation.
cd ~/Projekte/left4me
git add -A
git commit -m "deploy: move scripts/{libexec,sbin}/ into deploy/scripts/

Layout consistency: everything ckn-bw deploys to the host now lives
under deploy/. ckn-bw's install_left4me_scripts copy-action goes away
in lockstep with this commit and is replaced by target-side symlinks.

Part of 2026-05-15-deployment-responsibility-design.md migration step 4."
git push
  • Step 4.6: Replace install_left4me_scripts with symlinks in ckn-bw.

In ~/Projekte/ckn-bw/bundles/left4me/items.py:

(a) Delete actions['install_left4me_scripts'].

(b) Remove 'action:install_left4me_scripts' from git_deploy:/opt/left4me/src's triggers list.

(c) Replace directory:/usr/local/libexec/left4me (already exists) and add symlink items, one per script (the current four — see ls /opt/left4me/src/scripts/libexec/ scripts/sbin/ on host for the canonical list):

# Per-script symlinks. Source content lives in left4me/deploy/scripts/.
# git_deploy:/opt/left4me/src is the prerequisite for each — without it
# the symlink target wouldn't exist on a fresh apply.
LEFT4ME_LIBEXEC_SCRIPTS = (
    'left4me-overlay',
    'left4me-systemctl',
    'left4me-journalctl',
    'left4me-script-sandbox',
)
LEFT4ME_SBIN_SCRIPTS = (
    'left4me',
)

for _script in LEFT4ME_LIBEXEC_SCRIPTS:
    symlinks[f'/usr/local/libexec/left4me/{_script}'] = {
        'target': f'/opt/left4me/src/deploy/scripts/libexec/{_script}',
        'owner': 'root', 'group': 'root',
        'needs': [
            'directory:/usr/local/libexec/left4me',
            'git_deploy:/opt/left4me/src',
        ],
    }

for _script in LEFT4ME_SBIN_SCRIPTS:
    symlinks[f'/usr/local/sbin/{_script}'] = {
        'target': f'/opt/left4me/src/deploy/scripts/sbin/{_script}',
        'owner': 'root', 'group': 'root',
        'needs': [
            'git_deploy:/opt/left4me/src',
        ],
    }

(d) Confirm the source scripts have the executable bit set in the checkout. Add an idempotent chmod action if needed:

actions['left4me_chmod_scripts'] = {
    'command': (
        'chmod 0755 '
        '/opt/left4me/src/deploy/scripts/libexec/* '
        '/opt/left4me/src/deploy/scripts/sbin/*'
    ),
    'unless': (
        '! find /opt/left4me/src/deploy/scripts -type f \\! -perm 755 -print -quit | grep -q .'
    ),
    'cascade_skip': False,
    'needs': [
        'git_deploy:/opt/left4me/src',
    ],
}

Add 'action:left4me_chmod_scripts' to each script symlink's needs:.

  • Step 4.7: bw test + apply.
cd ~/Projekte/ckn-bw
bw test
bw apply ovh.left4me

Expected: install_left4me_scripts action removed from the graph; symlinks created; existing root-owned copies in /usr/local/{libexec,sbin}/ are replaced by symlinks.

  • Step 4.8: Verify the helpers still work.
ssh root@ovh.left4me '
  echo "=== symlinks ==="
  ls -la /usr/local/libexec/left4me/ /usr/local/sbin/left4me
  echo "=== executable check ==="
  /usr/local/libexec/left4me/left4me-systemctl --help 2>&1 | head -3 || true
  echo "=== sudo invocation (the way the web app uses them) ==="
  sudo -u left4me /usr/bin/sudo /usr/local/libexec/left4me/left4me-systemctl status left4me-web.service | head -5
'

Expected: every entry is a symlink into /opt/left4me/src/deploy/scripts/; the help/status outputs look normal; sudo invocation still succeeds.

  • Step 4.9: Live gameserver round-trip.

Start a fresh instance via the web app (or sudo /usr/local/libexec/left4me/left4me-systemctl start left4me-server@test), confirm srcds_run starts, stop it. The left4me-overlay helper (now a symlink into the checkout) gets exercised as part of the ExecStartPre/ExecStopPost.

  • Step 4.10: Idempotent re-apply + commit.
cd ~/Projekte/ckn-bw
bw apply ovh.left4me   # expect 0 fixed
git add bundles/left4me/items.py
git commit -m "left4me: symlink privileged helpers to the checkout

/usr/local/libexec/left4me/* and /usr/local/sbin/left4me are now
target-side symlinks into /opt/left4me/src/deploy/scripts/...
Replaces the install_left4me_scripts copy-action.

Part of 2026-05-15-deployment-responsibility-design.md migration
step 4. Verified on ovh.left4me: helpers run via sudo from the web
app context; gameserver mount/unmount round-trip succeeds."

Task 5: Cleanup + docs

Goal: Prune now-dead metadata, update both repos' READMEs to describe the new model, and run the end-to-end verification matrix one final time.

Files:

  • Modify: ~/Projekte/left4me/deploy/README.md
  • Modify: ~/Projekte/ckn-bw/bundles/left4me/README.md
  • (Possibly) Modify: ~/Projekte/left4me/AGENTS.md

Steps

  • Step 5.1: Audit ckn-bw bundles/left4me/ for any dead references.
cd ~/Projekte/ckn-bw
grep -nE "HARDENING_|install_left4me_scripts|left4me_chown_src" bundles/left4me/*.py

Expected: no matches. If there are, they're leftovers from earlier steps — clean them up.

  • Step 5.2: Update ~/Projekte/left4me/deploy/README.md.

Describe the new model: deploy/files/ and deploy/scripts/ are the canonical source for everything ckn-bw deploys via target-side symlinks; base unit bodies in deploy/files/usr/local/lib/systemd/system/ remain reference fixtures that match the reactor-emitted live form. Hardening profile lives in the drop-in files alongside the unit it hardens.

  • Step 5.3: Update ~/Projekte/ckn-bw/bundles/left4me/README.md.

Replace the "drops privileged helpers" and "git_deploy then pip_install -e" descriptions with the current model: target-side symlinks for static artifacts (hardening drop-ins, sudoers, sysctl drop-in, helpers); reactor emits per-host unit bodies and slice CPU pinning. Point at the design doc for the rationale.

  • Step 5.4: Update ~/Projekte/left4me/AGENTS.md if it describes the deployment layout.

Likely just a path update from scripts/deploy/scripts/. Grep first.

  • Step 5.5: Full end-to-end verification matrix.

Re-run the Preflight baseline capture and compare against the post-migration state:

ssh root@ovh.left4me '
  echo "=== sysctl ==="
  sysctl kernel.yama.ptrace_scope net.core.rmem_max net.ipv4.tcp_congestion_control
  echo "=== left4me-web hardening (selected) ==="
  systemctl show -p ProtectSystem,ProtectHome,PrivateTmp,ProtectProc,SystemCallArchitectures left4me-web.service
  echo "=== left4me-server@ hardening (template-level via systemctl cat) ==="
  systemctl cat left4me-server@.service | grep -E "ProtectSystem|PrivateUsers|PrivatePIDs|NoNewPrivileges|SocketBindAllow"
  echo "=== sudo for left4me ==="
  sudo -l -U left4me 2>&1 | head -30
  echo "=== symlinks ==="
  ls -la /etc/sudoers.d/left4me /etc/sysctl.d/99-left4me.conf \
         /etc/systemd/system/left4me-web.service.d/10-hardening.conf \
         /etc/systemd/system/left4me-server@.service.d/10-hardening.conf \
         /usr/local/libexec/left4me/left4me-overlay \
         /usr/local/sbin/left4me
  echo "=== bw idempotent ==="
'
cd ~/Projekte/ckn-bw && bw apply ovh.left4me

Expected:

  • All sysctl values match baseline

  • All hardening directives reported by systemctl match baseline

  • sudo -l output matches baseline

  • All listed paths are symlinks into /opt/left4me/src/deploy/

  • bw apply reports 0 fixed, 0 failed

  • Step 5.6: Gameserver round-trip in production mode.

Through the web UI: start an instance, confirm it appears in the live-state panel, run a cvar inspect, stop it. End-to-end confirms the symlinked helpers + hardened gameserver unit all work together.

  • Step 5.7: Commit docs.
cd ~/Projekte/left4me
git add deploy/README.md AGENTS.md 2>/dev/null
git commit -m "deploy/docs: describe the new symlink-based delivery model" || true
git push

cd ~/Projekte/ckn-bw
git add bundles/left4me/README.md
git commit -m "left4me/README: describe symlink delivery + reactor scope after the reshape"
  • Step 5.8: Mark the design doc as shipped.

In ~/Projekte/left4me/docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md, add a status block at the top:

## Status

**Shipped 2026-05-15.** All five migration steps landed and verified on
ovh.left4me. Implementation plan executed:
`docs/superpowers/plans/2026-05-15-deployment-responsibility.md`.

Commit:

cd ~/Projekte/left4me
git add docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md
git commit -m "spec(deployment-responsibility): mark shipped after step-5 verification"
git push

What this plan does NOT cover

  • The build-overlay-unit refactor (2026-05-15-build-overlay-unit-design.md) — separate work, lands on top of this. Its hardening profile should ship as a drop-in inline with the dispatcher using the pattern established here.
  • AppArmor profiles (deferred from the defenses survey).
  • Moving base unit bodies / slice CPU pinning into left4me — explicitly out of scope per the design doc.

Sequencing notes

  • Steps within a task are sequential. Tasks 1-4 can in principle be parallelized across multiple sessions, but they all touch bw apply ovh.left4me, and interleaving applies makes failure attribution harder. Land them one at a time.
  • Each task ends with both repos committed and the live host idempotent. That's the natural "checkpoint" — safe to stop between tasks.
  • If a verification step fails: stop, do not paper over. Most likely cause is a wiring mismatch (wrong path, missing needs, missing daemon-reload trigger). Use bw verify and systemctl status/journalctl -xe on the host to localize.