# L4D2 Script Sandbox v3 Implementation Plan

> **Approval status:** User-approved 2026-05-08; implemented and pushed in `7e66936`. This plan is recorded retrospectively for symmetry with the v1 / v2 plans.

**Goal:** Restrict the sandbox to public-internet egress per `docs/superpowers/specs/2026-05-08-l4d2-script-sandbox-v3-egress-filter.md`. Bind a static public-resolver `resolv.conf` into the sandbox.

---

## Locked Decisions (see spec for rationale)

- `IPAddressDeny=` only; no `IPAddressAllow=any`.
- Explicit CIDRs (no `localhost` / `link-local` shorthand keywords — `systemd-run -p` parser rejects them).
- Static `nameserver 1.1.1.1` + `nameserver 8.8.8.8` in a sandbox-only resolv.conf.
- `AF_UNIX` left enabled.

---

## Current Gap (at start of this iteration)

- `deploy/files/usr/local/libexec/left4me/left4me-script-sandbox` (v2) shares the host network namespace with no egress filter.
- The helper bind-mounts `/etc/resolv.conf` from the host into the sandbox (which points at private-IP DNS).
- `deploy/deploy-test-server.sh` does not install a sandbox-only resolv.conf.
- No deploy-artifact tests for `IPAddressDeny=` or for the resolv.conf shape.

---

## Task 1: Add `IPAddressDeny=`, swap resolv.conf bind, ship the static file

**Files:**

- Create: `deploy/files/etc/left4me/sandbox-resolv.conf` — two `nameserver` lines + a header comment.
- Modify: `deploy/files/usr/local/libexec/left4me/left4me-script-sandbox` — add `-p IPAddressDeny="..."` directive (11 explicit CIDRs); replace the `/etc/resolv.conf:/etc/resolv.conf` token in `BindReadOnlyPaths=` with `/etc/left4me/sandbox-resolv.conf:/etc/resolv.conf`.
- Modify: `deploy/deploy-test-server.sh` — add an `install -m 0644 -o root -g root .../sandbox-resolv.conf /etc/left4me/sandbox-resolv.conf` line near the existing `host.env` install.
- Modify: `deploy/tests/test_deploy_artifacts.py` — extend `test_script_sandbox_helper_invokes_systemd_run_with_hardening` to assert each CIDR is present and that `IPAddressAllow=any` is **absent** (regression guard); update the BindReadOnlyPaths assertion to expect the sandbox-resolv.conf bind; add `test_sandbox_resolv_conf_exists` and `test_deploy_script_installs_sandbox_resolv_conf`.

Test plan (RED-first not used here; the work was driven by smoke-test feedback against a live host):

1. `test_script_sandbox_helper_invokes_systemd_run_with_hardening` — `IPAddressDeny=` present with all 11 CIDRs; no `IPAddressAllow=any`; resolv.conf bind path is `/etc/left4me/sandbox-resolv.conf:/etc/resolv.conf`.
2. `test_sandbox_resolv_conf_exists` — file present, ≥2 nameservers, all in non-private space.
3. `test_deploy_script_installs_sandbox_resolv_conf` — deploy script references both source path under `deploy/files/etc/left4me/sandbox-resolv.conf` and target path `/etc/left4me/sandbox-resolv.conf`.

**Verification:**

```
sh -n deploy/deploy-test-server.sh
bash -n deploy/files/usr/local/libexec/left4me/left4me-script-sandbox
python3 -m pytest deploy/tests/ -q
```

**Commit:** `feat(deploy): restrict script-sandbox egress to public internet only`

---

## Task 2: Deploy + smoke-test on `ckn@10.0.4.128`

**Files:** none.

Run `deploy/deploy-test-server.sh ckn@10.0.4.128`. Then on the host, invoke the helper with a probe script that opens TCP connections to:

- `1.1.1.1:443` — must connect (public)
- `127.0.0.1:8000` — must block (web app on loopback)
- `127.0.0.1:22` — must block (sshd on loopback)
- `10.0.4.128:22` — must block (host's external SSH on private LAN)
- `10.0.0.1:53` — must block (LAN DNS resolver)

Plus `curl -m 5 https://steamcommunity.com/` end-to-end (DNS + HTTPS) → 200.

Inside the sandbox, `cat /etc/resolv.conf` must show the two public resolvers.

If any of the localhost / private targets connects, the deny is being silently overridden — see spec §Locked Decisions point 1.

**Commit:** none — operational verification.

---

## Lessons surfaced during execution

These belong in the spec but are repeated here as the "things the next person should not have to rediscover":

- **`IPAddressAllow=any` silently overrides every `IPAddressDeny=` rule** on this systemd 257 / kernel 6.12 combo, despite documentation stating "more specific rule wins". The negative test (`IPAddressAllow=any not in text`) locks this in.
- **systemd-run's `-p` parser rejects the `localhost` / `link-local` / `multicast` shorthand keywords** even though they parse fine in unit files. Use explicit CIDRs.
- **`/var/lib/left4me/.../left4me.db` is mode 0644 by default** — writing this file from the web app left it world-readable. Tightening to 0640 root:left4me happens in v2's deploy-script change; v3 does not re-touch it.
- **bpftool ships separately on Debian.** It's not needed for runtime, but `apt-get install bpftool` is useful for inspecting `sd_fw_egress` attach state when debugging filter behaviour.

---

## Rollback

`git revert 7e66936` and redeploy. The change is purely in deploy artifacts; no app code, no DB migration. Reverting reopens the previous v2 reachability.
