diff --git a/README.md b/README.md
index 01066ad..564e639 100644
--- a/README.md
+++ b/README.md
@@ -86,8 +86,6 @@ detects the stack (**Node, Python, Deno, Ruby/Rails, Rust, Go, Java, PHP, .NET,
Dart** - anything else runs via the generic base), scaffolds the config, and on confirm
builds + runs it.
-
![a config-less node project: bare sluice finds no config, detects the stack (node/npm), scaffolds sluice.config.sh and previews the knobs it chose (ports, run command, an empty allowlist pointing at sluice learn), asks [Y/n], then builds and runs it sandboxed - ending on the egress receipt with registry.npmjs.org reached in green and a first-run nudge to sluice learn and sluice lock](assets/onboard-demo.gif)
-
When a run exits, sluice prints an **egress receipt**: the hosts it reached and any it
tried but the firewall blocked. `sluice learn` then turns the blocked list into your
allowlist with a **per-host review** - allow / skip / collapse to a `.domain` wildcard -
@@ -100,9 +98,8 @@ The firewall is not just a claim - an allowlisted host gets through, an exfil PO
raw-IP bypass are blocked, and the run's audit log is **tamper-evident** (`egress --verify`
flips from green to red if a record is altered).
-When a host you actually need is blocked, `sluice learn` allows it from the receipt, live:
-
-
+When a host you actually need is blocked, `sluice learn` allows it from the receipt, live -
+see [examples/](examples/README.md) for the full walkthrough.
```bash
sluice # build (if needed) + run SLUICE_RUN_CMD in the sandbox
diff --git a/assets/demos/hard-cap-demo.config.sh b/assets/demos/hard-cap-demo.config.sh
index 2a72fd3..4146006 100644
--- a/assets/demos/hard-cap-demo.config.sh
+++ b/assets/demos/hard-cap-demo.config.sh
@@ -7,4 +7,4 @@
SLUICE_DESC="hard-cap: preventive per-boot egress byte ceiling"
SLUICE_ALLOW_DOMAINS="httpbin.org"
SLUICE_EGRESS_HARD_CAP_BYTES=1258291
-SLUICE_RUN_CMD='printf ">> httpbin.org is allowlisted - the box really can reach it:\n"; curl -sS --max-time 8 -o /dev/null -w " warm GET ok (http %{http_code})\n" https://httpbin.org/get; printf ">> now launder 4 MiB OUT to that same allowed host (the honest caveat):\n"; head -c 4194304 /dev/urandom | curl -sS --max-time 8 -o /dev/null -w " uploaded %{size_upload} of 4194304 bytes before the wire went dead\n" -T - https://httpbin.org/anything; printf " curl exit=%s (non-zero: killed mid-flight by the cap)\n" "$?"; sleep 1'
+SLUICE_RUN_CMD='sh demo.sh'
diff --git a/assets/demos/hard-cap-demo.tape b/assets/demos/hard-cap-demo.tape
index e308d31..b952af7 100644
--- a/assets/demos/hard-cap-demo.tape
+++ b/assets/demos/hard-cap-demo.tape
@@ -15,7 +15,8 @@
#
# Reproduce (from the repo root; .demos/ is gitignored recording scratch):
# d=.demos/hard-cap; rm -rf "$d"; mkdir -p "$d" .demos/render
-# cp assets/demos/hard-cap-demo.config.sh "$d/sluice.config.sh"
+# cp assets/demos/hard-cap-demo.config.sh "$d/sluice.config.sh"
+# cp assets/demos/hard-cap-demo.workload.sh "$d/demo.sh" # SLUICE_RUN_CMD='sh demo.sh'
# printf 'export PATH="$PWD/bin:$PATH"\nexport SLUICE_NO_BANNER=1 SLUICE_NO_UPDATE_CHECK=1 DOCKER_CLI_HINTS=false\nexport PS1="$ "\ncd .demos/hard-cap\n' > .demos/hard-cap-setup.sh
# ( cd "$d" && git init -q ) # own .git => no git-common-dir warning
# ( cd "$d" && PATH="$PWD/../../bin:$PATH" SLUICE_NO_BANNER=1 SLUICE_YES=1 sluice build ) # build off-camera
@@ -63,12 +64,15 @@ Enter
Sleep 200ms
Show
-# beat 2: run it. warm GET proves httpbin.org is reachable; the 4 MiB launder-out dies mid-upload -
-# curl exits non-zero with a truncated body. The at-exit receipt bounds httpbin.org's bytes.
+# beat 2: run it. The warm GET proves httpbin.org is genuinely reachable; the launder-out then runs
+# until the wire dies; and the RE-RUN of that same warm GET fails, which is what makes it conclusive
+# (curl's exit 28 is a timeout, and a slow upstream would produce that too - only a box-wide egress
+# death distinguishes the cap). The workload derives every verdict; nothing is narrated.
+# Timing: warm GET ~1s + upload to --max-time 15 + re-check to --max-time 8 => ~26s of run.
Type "sluice"
Sleep 600ms
Enter
-Sleep 10500ms
+Sleep 30000ms
Hide
Type "clear"
diff --git a/assets/demos/hard-cap-demo.workload.sh b/assets/demos/hard-cap-demo.workload.sh
new file mode 100644
index 0000000..330852f
--- /dev/null
+++ b/assets/demos/hard-cap-demo.workload.sh
@@ -0,0 +1,62 @@
+#!/bin/sh
+# Hard-cap demo workload. Copied into the fixture as demo.sh; the config runs it via
+# SLUICE_RUN_CMD='sh demo.sh' so `cat sluice.config.sh` shows POSTURE (the allowlist + the cap),
+# not 500 characters of curl.
+#
+# Every verdict below is DERIVED from the observed result. The previous version printed
+# "non-zero: killed mid-flight by the cap" as a literal string next to an interpolated "$?" - so a
+# 503 from the upstream, a server-truncated upload and curl exiting 0 all still rendered as the cap
+# working. A demo that cannot fail proves nothing; this one exits non-zero and says WHY.
+set -u
+
+CAP=1258291 # keep in sync with SLUICE_EGRESS_HARD_CAP_BYTES in sluice.config.sh
+WANT=4194304 # 4 MiB - comfortably past the cap
+
+printf '>> httpbin.org is allowlisted - prove the box really can reach it:\n'
+# NB: capture without `|| echo 000` - curl's -w already emits 000 on failure, and appending another
+# renders "000000". Normalise an empty capture instead.
+code="$(curl -sS --max-time 8 -o /dev/null -w '%{http_code}' https://httpbin.org/get 2>/dev/null)"
+code="${code:-000}"
+case "$code" in
+ 2*) printf ' warm GET ok (http %s)\n' "$code" ;;
+ *) printf ' ABORT: warm GET returned http %s - upstream is unhealthy, not a cap result.\n' "$code"
+ printf ' (this run proves nothing about the cap; re-record when the host is up)\n'
+ exit 1 ;;
+esac
+
+printf '>> now launder data OUT to that same allowed host (the honest caveat):\n'
+# One upload cannot exhaust the cap: httpbin truncates a chunked PUT server-side well below it. So
+# push repeatedly - every rejected body still crosses the wire and debits the in-box quota - until
+# the cap severs the flow. The loop is bounded so a healthy upstream cannot hang the recording.
+total=0; fired=0; i=0
+while [ "$i" -lt 12 ]; do
+ i=$((i + 1))
+ sent="$(head -c "$WANT" /dev/urandom \
+ | curl -sS --max-time 15 -o /dev/null -w '%{size_upload}' -T - https://httpbin.org/anything 2>/dev/null)"
+ rc=$?
+ total=$((total + ${sent:-0}))
+ printf ' attempt %-2s %8s bytes on the wire (running total %s)\n' "$i" "${sent:-0}" "$total"
+ # the cap severs the connection: curl dies non-zero having sent less than it was asked to.
+ if [ "$rc" -ne 0 ] && [ "${sent:-0}" -lt "$WANT" ]; then fired=1; break; fi
+done
+
+if [ "$fired" -eq 1 ]; then
+ printf ' => wire went dead after %s bytes (cap %s, curl exit=%s)\n' "$total" "$CAP" "$rc"
+ # curl exit 28 is a TIMEOUT - which a merely slow upstream also produces. Distinguish them: the cap
+ # is per-boot and severs ALL proxied egress, so the warm GET that worked seconds ago must now fail
+ # too. A slow upstream would still serve it. This is what makes the run conclusive.
+ printf '>> re-run the warm GET that succeeded above - the cap severs ALL egress, not one flow:\n'
+ code2="$(curl -sS --max-time 8 -o /dev/null -w '%{http_code}' https://httpbin.org/get)"
+ code2="${code2:-000}"
+ case "$code2" in
+ 2*) printf ' INCONCLUSIVE: http %s - egress still works, so the stall was upstream, not the cap.\n' "$code2"
+ exit 1 ;;
+ *) printf ' http %s - egress is dead box-wide. CONFIRMED: the cap fired.\n' "$code2"
+ exit 0 ;;
+ esac
+fi
+
+printf ' => NOT capped: %s bytes crossed the wire over %s attempts without the cap severing.\n' \
+ "$total" "$i"
+printf ' (this run does NOT demonstrate the cap - do not ship it)\n'
+exit 1
diff --git a/assets/demos/laundering-cover.config.sh b/assets/demos/laundering-cover.config.sh
new file mode 100644
index 0000000..f90b124
--- /dev/null
+++ b/assets/demos/laundering-cover.config.sh
@@ -0,0 +1,9 @@
+# Laundering-cover demo: a REAL agent posture, not a contrived one. The claude preset must allow
+# api.anthropic.com for the agent to work at all - and that host is POST-capable, so an attacker
+# inside the box can write data OUT through it. sluice splices, never decrypts, so the request body
+# is not inspected. Allowlisting cannot close this; the gate names it instead.
+SLUICE_DESC="Claude Code (Anthropic)"
+SLUICE_ALLOW_DOMAINS="api.anthropic.com"
+SLUICE_MASK=".env*"
+SLUICE_ENV="ANTHROPIC_API_KEY"
+SLUICE_RUN_CMD="true"
diff --git a/assets/demos/laundering-cover.tape b/assets/demos/laundering-cover.tape
new file mode 100644
index 0000000..26d90fa
--- /dev/null
+++ b/assets/demos/laundering-cover.tape
@@ -0,0 +1,82 @@
+# LAUNDERING-COVER demo: the limit sluice cannot close by allowlisting, and the knob that turns it
+# into a refusal. The fixture is a REAL agent posture - the claude preset allows api.anthropic.com
+# because the agent needs it. That host is POST-capable, so data can be written OUT through it; we
+# splice, never decrypt, so the request body is never inspected (THREAT_MODEL "allowed-host
+# laundering"). Beat 2 runs and the box WORKS, with a yellow note naming the risk. Beat 3 sets
+# SLUICE_STRICT_LAUNDERING=1 and the same command REFUSES, exit 1.
+#
+# Why the exit code is the whole point: `resolve_engine` runs ~725 lines BEFORE warn_laundering, and
+# `die` also exits 1 - so a tape that renders `|| echo "refused (exit $?)"` on a host with no engine
+# prints its success line for the WRONG reason and proves nothing. This tape shows the refusal text
+# itself (which only warn_laundering emits) and requires a working engine, verified by beat 2 having
+# just built and run the same box. Verified 2026-07-21 on docker-desktop: strict exit=1, plain exit=0.
+#
+# Honest limits: SLUICE_LAUNDERING_OK=1 silences the note (it does not add a control); the matcher is
+# a curated host LIST (src/10-egress-helpers.sh laundering_host) - an allowed host it does not know
+# is not flagged; and strict mode refuses the RUN, it does not inspect traffic. The guarantee is
+# "you are told, and can hard-fail", not "laundering is prevented".
+#
+# Reproduce (from the repo root; .demos/ is gitignored recording scratch):
+# d=.demos/laundering; rm -rf "$d"; mkdir -p "$d" .demos/render
+# cp assets/demos/laundering-cover.config.sh "$d/sluice.config.sh"
+# ( cd "$d" && git init -q ) # own .git => no git-common-dir warning
+# printf 'export PATH="$PWD/bin:$PATH"\nexport SLUICE_NO_BANNER=1 SLUICE_NO_UPDATE_CHECK=1 DOCKER_CLI_HINTS=false\nexport PS1="$ "\ncd .demos/laundering\n' > .demos/laundering-setup.sh
+# ( cd "$d" && PATH="$PWD/../../bin:$PATH" SLUICE_NO_BANNER=1 SLUICE_YES=1 sluice build ) # build off-camera
+# vhs assets/demos/laundering-cover.tape # writes .demos/render/laundering-cover.gif
+# gifsicle -O3 --colors 128 .demos/render/laundering-cover.gif -o assets/laundering-cover.gif
+
+Output ".demos/render/laundering-cover.gif"
+Set Shell "bash"
+Set FontSize 18
+Set Width 1400
+Set Height 620
+Set Padding 28
+Set Theme "Dracula"
+Set TypingSpeed 0ms
+
+Hide
+Type "source .demos/laundering-setup.sh"
+Enter
+Sleep 1000ms
+Type "SLUICE_YES=1 sluice build >/dev/null 2>&1"
+Enter
+Sleep 8000ms
+Type "clear"
+Enter
+Sleep 1000ms
+Show
+
+# beat 1: the fixture - a real agent posture. ONE allowed host, and it is the one the agent needs.
+Sleep 500ms
+Type "cat sluice.config.sh"
+Sleep 600ms
+Enter
+Sleep 4000ms
+
+# clear between beats (off camera) so each starts fresh - no scroll accumulation (bloats the gif).
+Hide
+Type "clear"
+Enter
+Sleep 200ms
+Show
+
+# beat 2: it RUNS (exit 0). The yellow note names the risk: an allowlisted host an attacker can also
+# write to. Allowlisting cannot close this - we splice, not decrypt, so the body is never inspected.
+Type "sluice run true; echo exit=$?"
+Sleep 600ms
+Enter
+# the box boots on camera here - exit=0 only lands at ~5.5s, so dwell long enough to READ it.
+Sleep 9000ms
+
+Hide
+Type "clear"
+Enter
+Sleep 200ms
+Show
+
+# beat 3: same command, strict mode. Now it REFUSES - red, exit 1, before the box ever boots. The
+# refusal TEXT is the proof: only warn_laundering emits it, so this cannot pass for a die() elsewhere.
+Type "SLUICE_STRICT_LAUNDERING=1 sluice run true; echo exit=$?"
+Sleep 600ms
+Enter
+Sleep 6000ms
diff --git a/assets/fleet-audit.gif b/assets/fleet-audit.gif
deleted file mode 100644
index 78b47ae..0000000
Binary files a/assets/fleet-audit.gif and /dev/null differ
diff --git a/assets/hard-cap-demo.gif b/assets/hard-cap-demo.gif
index 03eb8c9..6f34e67 100644
Binary files a/assets/hard-cap-demo.gif and b/assets/hard-cap-demo.gif differ
diff --git a/assets/learn-demo.gif b/assets/learn-demo.gif
deleted file mode 100644
index c1dfb85..0000000
Binary files a/assets/learn-demo.gif and /dev/null differ
diff --git a/assets/lock-demo.gif b/assets/lock-demo.gif
deleted file mode 100644
index f0df27b..0000000
Binary files a/assets/lock-demo.gif and /dev/null differ
diff --git a/assets/onboard-demo.gif b/assets/onboard-demo.gif
deleted file mode 100644
index 0c39ac6..0000000
Binary files a/assets/onboard-demo.gif and /dev/null differ
diff --git a/assets/overlay-demo.gif b/assets/overlay-demo.gif
deleted file mode 100644
index 970f187..0000000
Binary files a/assets/overlay-demo.gif and /dev/null differ
diff --git a/assets/policy-refusal.gif b/assets/policy-refusal.gif
deleted file mode 100644
index 5df91bd..0000000
Binary files a/assets/policy-refusal.gif and /dev/null differ
diff --git a/docs/hardening.md b/docs/hardening.md
index 6f59bf8..2740f08 100644
--- a/docs/hardening.md
+++ b/docs/hardening.md
@@ -65,8 +65,6 @@ After a session sluice prints the changeset counts as a nudge. Cost: the git com
mounted (an rw mount would bypass the protection), so a git worktree can't resolve refs in the
box; seeding a large repo costs startup time.
-![with SLUICE_WORKSPACE=overlay the box edits a throwaway copy: on the host notes.txt still reads 'original' and created.txt does not exist; sluice diff shows the box's changes (notes.txt modified, created.txt added); sluice apply prompts [y/N] and on 'y' writes them back - applied 1 added, 1 modified, 0 deleted](../assets/overlay-demo.gif)
-
## `SLUICE_MASK`: in-repo secret masking
Protects against: the agent reading secrets that live inside the repo - the project mount is
@@ -114,7 +112,7 @@ Protects against: a runaway agent or build exhausting the host
The default posture is default-drop with an allowlist; these knobs gate or bound what an allowed
host can still carry ([residual risk](../THREAT_MODEL.md#residual-risk-one-line)).
-
+
- `SLUICE_EGRESS_MAX_BYTES` - a **detective** budget on bytes sent to reached hosts. Over the cap,
`sluice egress` exits non-zero (a CI gate) and `sluice learn` warns. Bounds laundering volume.
diff --git a/docs/operations.md b/docs/operations.md
index 05f9736..52243ce 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -59,8 +59,6 @@ sourced, `--all` is chain-integrity only: per-box egress knobs (host budgets, by
it, and `-b ` is rejected for `--all` (it spans every box by definition). An unreadable log reports
`verified:false, reason:"unreadable"` - never a silent pass.
-
-
## Machine-readable contracts
Every JSON surface carries a `schema` id so a consumer (a dashboard, a CI gate, the future control plane)
diff --git a/docs/policy.md b/docs/policy.md
index 0e45713..e6d684c 100644
--- a/docs/policy.md
+++ b/docs/policy.md
@@ -5,7 +5,18 @@ it can **add** and **deny** allowlist hosts and **refuse to run** when local con
Inert unless a policy is configured. Signing is planned (v2.1); today integrity rests on the source
(see [the managed-mode boundary](#managed-mode-the-honest-boundary)).
-
+A local config that opens a direct-IP **supernet** containing a denied address is refused
+**pre-build** - no box is ever started - and the violated directive is named:
+
+```
+$ sluice run true # local config sets SLUICE_ALLOW_IPS="169.254.169.0/24:80"
+[sluice] policy denies SLUICE_ALLOW_IPS '169.254.169.0/24:80' (overlaps deny-ip 169.254.169.254/32)
+$ echo $?
+1
+```
+
+The overlap check is what closes the bypass: `169.254.169.0/24` contains the denied
+cloud-metadata `/32`, so a base-address-only comparison would have let it through.
## Sources and precedence
diff --git a/docs/supply-chain.md b/docs/supply-chain.md
index 6399137..f4417e0 100644
--- a/docs/supply-chain.md
+++ b/docs/supply-chain.md
@@ -23,8 +23,6 @@ sluice update # rebuild --no-cache, then refresh the lock
All three report forms take `--json`.
-
-
### Reproducibility, in tiers (be honest)
Wolfi apk is a rolling repo, so the same config builds different versions on different days. sluice
diff --git a/examples/README.md b/examples/README.md
index 84d6e19..4dba62a 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -18,9 +18,7 @@ everyday task it stands in for. Bread-and-butter first; skim down for the securi
The [overlay](overlay.config.sh) preset is the human gate in motion - the box edits a
throwaway copy, you review with `sluice diff`, and only `sluice apply` (with a `[y/N]`
-confirm) touches your real files:
-
-![with SLUICE_WORKSPACE=overlay the box edits a throwaway copy: on the host notes.txt still reads 'original' and created.txt does not exist; sluice diff shows the box's changes (notes.txt modified, created.txt added); sluice apply prompts [y/N] and on 'y' writes them back - applied 1 added, 1 modified, 0 deleted](../assets/overlay-demo.gif)
+confirm) touches your real files.
## Your own stack
@@ -28,8 +26,6 @@ No preset needed: `sluice init` detects 11 stacks (list in the [main README](../
anything else runs via the generic base) and scaffolds the config, then `sluice learn` (below)
fills the egress allowlist from what the app actually tried to reach.
-![bare sluice in a config-less node project finds no config, detects the stack (node/npm), scaffolds sluice.config.sh and previews the knobs it chose, asks [Y/n], then builds and runs it sandboxed - ending on the egress receipt with registry.npmjs.org reached in green and a first-run nudge to sluice learn / sluice lock](../assets/onboard-demo.gif)
-
## Stronger isolation (Linux)
Any preset above - or your own repo - runs under an own-kernel micro-VM with
@@ -59,8 +55,6 @@ $ sluice learn
[sluice] reloaded the running box (squid reconfigure) - live now, no rebuild.
```
-
-
Picks are written to the config **and** hot-loaded into the running box - no rebuild.
`[d]omain` allows the whole `.parent` wildcard, and when 2+ blocked hosts share a parent,
learn offers the collapse up front. Flags:
diff --git a/test/verify-security-hardcap.bats b/test/verify-security-hardcap.bats
index 8547e3a..4596481 100644
--- a/test/verify-security-hardcap.bats
+++ b/test/verify-security-hardcap.bats
@@ -1,14 +1,22 @@
#!/usr/bin/env bats
# SLUICE_EGRESS_HARD_CAP_BYTES: a PREVENTIVE in-box byte ceiling on proxied egress, enforced with an
# xt_quota rule on squid's uid followed by a DROP - so exhaustion severs even established flows. This
-# suite asserts the rule SHAPE (never the byte value - `iptables -S` prints the counting-down remaining
-# quota) and the ordering (the pair precedes the ESTABLISHED accept), plus the host-side <1MiB refusal.
+# suite asserts the rule SHAPE, the ordering (the pair precedes the ESTABLISHED accept), the host-side
+# <1MiB refusal, and - in the final test - that the cap ACTUALLY FIRES against live traffic.
+#
+# Do NOT assert on the byte value printed by `iptables -S`: it is the CONFIGURED quota, not the
+# remaining one. Verified on Docker Desktop's LinuxKit kernel - after 123K of real proxied egress the
+# printed value was still the full 1258291. Enforcement is observable through the RULE COUNTERS
+# (`iptables -L -v`) instead: bytes on the quota ACCEPT, packets on the following DROP once exhausted.
# xt_quota is absent on some kernels; the box then fails closed (refuses to boot), so the enforcement
# tests skip on such a runner rather than false-fail. Needs Docker (engine lane).
load test_helper/common
setup_file() {
- make_box hardcap hc 'SLUICE_EGRESS_HARD_CAP_BYTES="2097152"' 'SLUICE_RUN_CMD="bash"'
+ # cap at the 1 MiB floor + one allowlisted host: the exhaustion test drives real bytes through
+ # squid, and the floor keeps the traffic it must generate to the minimum.
+ make_box hardcap hc 'SLUICE_EGRESS_HARD_CAP_BYTES="1048576"' 'SLUICE_ALLOW_DOMAINS="example.com"' \
+ 'SLUICE_RUN_CMD="bash"'
"${SLUICE_ENGINE:-docker}" logs sluice-sectest-hardcap > "$WORK/hclog" 2>&1 || true
}
teardown_file() { destroy_box hardcap hc; }
@@ -70,3 +78,48 @@ _skip_if_no_xtquota() {
run bash -c "cd '$WORK/hc' && '$SLUICE' egress"
assert_success
}
+
+# _uid999_rule : the live counters for squid's quota ACCEPT ("accept") or the DROP that follows
+# it ("drop"), as " ". Reads `iptables -L -v` (counters), not `-S` (configured value).
+_uid999_rule() {
+ local want="$1" line
+ line="$("$ENG" exec --user root sluice-sectest-hardcap iptables -L OUTPUT -v -n 2>/dev/null \
+ | grep -E "owner UID match 999" | grep -vE "dpt:3128")"
+ case "$want" in
+ accept) printf '%s\n' "$line" | grep -F "quota:" | awk '{print $1, $2}' ;;
+ drop) printf '%s\n' "$line" | grep -v -F "quota:" | awk '{print $1, $2}' ;;
+ esac
+}
+
+# The behavioural end of the suite: every test above proves the rule is INSTALLED; this one proves it
+# BITES. Without it the only evidence the cap ever stopped a byte was a demo GIF whose "killed
+# mid-flight by the cap" line was a hardcoded string that printed even when curl exited 0.
+#
+# Not gated on the upstream's cooperation: example.com 405s a PUT, but the rejected body still
+# crosses the wire through squid and debits the quota, so the DROP fires regardless of status code.
+@test "hardcap: the cap FIRES - egress past the ceiling is dropped, not merely rule-shaped" {
+ _skip_if_no_xtquota
+ "$ENG" ps --filter "name=sluice-sectest-hardcap" --filter status=running --format '{{.Names}}' \
+ | grep -qx sluice-sectest-hardcap || skip "box not up"
+
+ local before_drop; before_drop="$(_uid999_rule drop | awk '{print $1}')"
+ [ -n "$before_drop" ] || skip "no uid-999 DROP rule found (cap not installed on this runner)"
+
+ # drive > the 1 MiB cap through the proxy; each PUT is refused at ~170 KB, so several are needed.
+ "$ENG" exec sluice-sectest-hardcap sh -c '
+ i=0; while [ $i -lt 12 ]; do
+ head -c 1048576 /dev/urandom \
+ | curl -sS --max-time 15 -o /dev/null -T - https://example.com/ >/dev/null 2>&1
+ i=$((i+1))
+ done' >/dev/null 2>&1 || true
+
+ local after_drop after_bytes
+ after_drop="$(_uid999_rule drop | awk '{print $1}')"
+ after_bytes="$(_uid999_rule accept | awk '{print $2}')"
+
+ # the ACCEPT must have carried real traffic (guards against a vacuous pass when egress never left)
+ [ "$after_bytes" != "0" ] || skip "no proxied egress recorded - runner has no outbound network"
+
+ # and past the ceiling, packets must land on the DROP
+ [ "$after_drop" -gt "$before_drop" ]
+}