Spec captures the v2 architecture (systemd-run service mode with full
hardening directives, no bwrap), the two surfaces in scope (helper
rewrite + bubblewrap dep removal + left4me.db mode tightening), and the
gotchas surfaced by smoke-testing the prototype on ckn@10.0.4.128:
- ProtectSystem=strict makes /var/lib/left4me visible (not invisible);
must add TemporaryFileSystem=/var/lib to mask it.
- Script bind via BindReadOnlyPaths uses ${SCRIPT}:/script.sh syntax.
- No PrivatePID= directive in systemd; host PIDs visible via /proc.
Information disclosure only — kernel UID-mismatch blocks signals.
Plan breaks the migration into 4 tasks (helper rewrite, deploy-script
deps + DB mode, host smoke-test, drift sweep) with explicit rollback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.9 KiB
L4D2 Script Sandbox v2 Implementation Plan
Approval status: User-approved 2026-05-08 after smoke-testing the v2 prototype on
ckn@10.0.4.128.
Goal: Replace the bwrap-based sandbox helper with a systemd-only one per docs/superpowers/specs/2026-05-08-l4d2-script-sandbox-v2-systemd.md. Drop the bubblewrap apt dep. Tighten left4me.db file mode to 0640 root:left4me. Update the deploy-artifact tests to assert the new helper shape.
Architecture: See spec. Helper invokes systemd-run --pipe --wait in service-unit mode with full hardening directives. No bwrap. Web-app side (ScriptBuilder, run_sandboxed_script, routes) is unchanged.
Locked Decisions
See spec §Locked Decisions for rationale. Implementation summary:
- Helper file at the same path (
deploy/files/usr/local/libexec/left4me/left4me-script-sandbox) is rewritten in place. - The sudoers rule is unchanged.
bubblewrapdropped fromapt-get install/dnf installlines.left4me.dbchmod 0640 added to deploy script as a post-init step.- Sandbox UID, system user, overlay-dir chown logic, and ScriptBuilder API stay the same.
Current Gap
deploy/files/usr/local/libexec/left4me/left4me-script-sandboxinvokessystemd-run --scope ... -- bwrap [namespace flags] /bin/bash /script.sh.deploy/deploy-test-server.shline ~84 installsbubblewrapvia apt/dnf.deploy/tests/test_deploy_artifacts.py::test_script_sandbox_helper_invokes_systemd_run_and_bwrapassertsbwrap,--unshare-pid,--uid=l4d2-sandbox, etc.deploy/tests/test_deploy_artifacts.py::test_deploy_script_installs_bubblewrapassertsbubblewrapis in apt/dnf install lines.left4me.dbis created at deploy time with the default 0644 permissions; any host user can read it.
Task 1: Rewrite the sandbox helper to be systemd-only
Files:
- Modify:
deploy/files/usr/local/libexec/left4me/left4me-script-sandbox— replace thesystemd-run --scope … bwrap …invocation withsystemd-run --service --pipe --wait …carrying the hardening directives.
Test plan:
bash -nsyntax check (already covered bytest_script_sandbox_helper_passes_shell_syntax_check).test_deploy_artifacts.py::test_script_sandbox_helper_invokes_systemd_run_and_bwrapis replaced by a new pin:test_script_sandbox_helper_invokes_systemd_run_with_hardening. Asserts:- No
bwrapreference remains. systemd-runis invoked with--pipe,--wait,--collect,--unit=(transient service unit form, no--scope).- All hardening directives present:
NoNewPrivileges=yes,ProtectSystem=strict,ProtectHome=yes,PrivateTmp=yes,PrivateDevices=yes,PrivateIPC=yes,ProtectKernelTunables=yes,ProtectKernelModules=yes,ProtectKernelLogs=yes,ProtectControlGroups=yes,RestrictNamespaces=yes,RestrictSUIDSGID=yes,LockPersonality=yes,MemoryDenyWriteExecute=yes,SystemCallFilter=,CapabilityBoundingSet=(empty),User=l4d2-sandbox,Group=l4d2-sandbox. TemporaryFileSystem=covers/etcand/var/lib.BindReadOnlyPaths=includes/etc/resolv.conf /etc/ssl /etc/ca-certificates /etc/nsswitch.conf /etc/alternativesand the script bind${SCRIPT}:/script.sh.BindPaths=carries the overlay bind.- Cgroup limits unchanged (
MemoryMax=4G,MemorySwapMax=0,TasksMax=512,CPUQuota=200%,RuntimeMaxSec=3600).
- No
- Existing
test_script_sandbox_helper_dry_run_modekeeps passing — the dry-run guard still short-circuits beforesystemd-run. - Existing
test_script_sandbox_helper_validates_overlay_idkeeps passing — argument validation is unchanged.
Implementation: helper body verbatim from the spec §Helper.
Verification:
python3 -m pytest deploy/tests/test_deploy_artifacts.py -q
bash -n deploy/files/usr/local/libexec/left4me/left4me-script-sandbox
Commit: refactor(deploy): rewrite left4me-script-sandbox to systemd-only — drop bwrap
Task 2: Drop bubblewrap apt/dnf dep + tighten left4me.db mode
Files:
- Modify:
deploy/deploy-test-server.sh— removebubblewrapfromapt-get install/dnf installpackage lists; add a post-init step that ensuresleft4me.dbis mode 0640 ownedroot:left4me. - Modify:
deploy/tests/test_deploy_artifacts.py— replacetest_deploy_script_installs_bubblewrapwithtest_deploy_script_does_not_install_bubblewrap; addtest_deploy_script_tightens_left4me_db_permissions.
Test plan:
test_deploy_script_does_not_install_bubblewrap— for eachapt-get install/dnf installline,bubblewrapis absent.test_deploy_script_tightens_left4me_db_permissions— script containschmod 0640 /var/lib/left4me/left4me.dbandchown root:left4me /var/lib/left4me/left4me.db(in either order).test_deploy_script_shell_syntaxkeeps passing (sh -n).
Implementation:
- Remove the bare
bubblewraptoken from the two install lines. - After the
alembic upgrade headstep (which creates the DB if missing), add:
Idempotent — re-runs are no-ops.$sudo_cmd chown root:left4me /var/lib/left4me/left4me.db $sudo_cmd chmod 0640 /var/lib/left4me/left4me.db
Verification:
python3 -m pytest deploy/tests/test_deploy_artifacts.py -q
sh -n deploy/deploy-test-server.sh
Commit: chore(deploy): drop bubblewrap apt dep + tighten left4me.db mode to 0640 root:left4me
Task 3: Deploy + smoke-test on the test host
Files: none.
This is an operational verification step, not a code change. Run deploy/deploy-test-server.sh ckn@10.0.4.128, then on the host re-run the same smoke battery used to validate the prototype:
- Identity / privileges:
idreturnsuid=996 gid=985;/proc/self/statusshowsNoNewPrivs: 1andCapBnd: 0000000000000000. - Filesystem isolation:
/etc/passwdabsent,/etc/alternatives/awkpresent,/var/lib/left4me/left4me.dbabsent,/homeinaccessible,/usrnot writable,/overlaywritable. - Tools + network:
awkresolves through/etc/alternatives;curl https://steamcommunity.com/returns 200. - Cgroup limits: while a 5s-sleep script runs,
cat /sys/fs/cgroup/.../memory.maxreturns4294967296;pids.max512;cpu.max200000 100000. - Memory cap: 5 GB Python alloc raises
MemoryError. - Wipe:
find /overlay -mindepth 1 -deleteempties the overlay dir. - Seccomp / restriction probes:
unshare -U,mount -t tmpfs,setarch -X,bpfsetsockopt all fail with EPERM/EINVAL. - Build via web UI: log in as admin, create a script overlay with
echo "hi" > foo, click Save, confirm job succeeds andfooappears in/var/lib/left4me/overlays/{id}/foo. - DB hardening:
stat -c "%a %U:%G" /var/lib/left4me/left4me.dbreturns640 root:left4me.
Mark this task complete only after every check passes on the live host.
Commit: none (operational verification — record results in conversation/PR description).
Task 4: Drift sweep + push
Files: as needed across the repo.
Run the full test suite for all three packages; chase any drift caused by the helper rewrite or deploy-script changes.
python3 -m pytest l4d2web/tests/ -q
python3 -m pytest l4d2host/tests/ -q
python3 -m pytest deploy/tests/ -q
Implementation: fix what breaks. Expected: nothing new should break, since the Python-side contract is unchanged. If something does, treat it as a sign of an unintended coupling and address.
Push the commits to origin/master.
Verification: all three suites green; git status clean; commits visible on git.sublimity.de/cronekorkn/left4me.
Commit: none unless drift fixes are needed.
Rollback plan
If Task 3 surfaces a blocker (a hardening directive breaks a real-world script class, seccomp filter is too narrow, BindPaths semantics differ on the host's systemd version), roll back via git revert of Tasks 1+2 and redeploy. Git history preserves both the v1 and v2 helper. The Python side never changed, so reverting only the deploy artifacts is sufficient — no DB migration to undo, no template change to roll back.