Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center"><img src="assets/onboard-demo.gif" width="680" alt="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"></p>

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

<p align="center"><img src="assets/learn-demo.gif" width="680" alt="a real run is blocked by the firewall: curl fails and the egress receipt shows pypi.org in red as 'blocked, not allowlisted'; sluice learn then reviews the host with an allow/skip/domain/quit prompt, 'a' allows it and the running box is reloaded live with no rebuild; the rerun returns HTTP 200 and the receipt flips to green 'reached pypi.org, all egress was allowlisted'"></p>
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
Expand Down
2 changes: 1 addition & 1 deletion assets/demos/hard-cap-demo.config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
12 changes: 8 additions & 4 deletions assets/demos/hard-cap-demo.tape
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
62 changes: 62 additions & 0 deletions assets/demos/hard-cap-demo.workload.sh
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions assets/demos/laundering-cover.config.sh
Original file line number Diff line number Diff line change
@@ -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"
82 changes: 82 additions & 0 deletions assets/demos/laundering-cover.tape
Original file line number Diff line number Diff line change
@@ -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
Binary file removed assets/fleet-audit.gif
Binary file not shown.
Binary file modified assets/hard-cap-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed assets/learn-demo.gif
Binary file not shown.
Binary file removed assets/lock-demo.gif
Binary file not shown.
Binary file removed assets/onboard-demo.gif
Binary file not shown.
Binary file removed assets/overlay-demo.gif
Binary file not shown.
Binary file removed assets/policy-refusal.gif
Binary file not shown.
4 changes: 1 addition & 3 deletions docs/hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center"><img src="../assets/overlay-demo.gif" width="720" alt="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"></p>

## `SLUICE_MASK`: in-repo secret masking

Protects against: the agent reading secrets that live inside the repo - the project mount is
Expand Down Expand Up @@ -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)).

<p align="center"><img src="../assets/hard-cap-demo.gif" width="720" alt="with SLUICE_EGRESS_HARD_CAP_BYTES set just above the 1 MiB floor, a warm GET proves httpbin.org is genuinely allowlisted, then a 4 MiB upload to that same allowed host is killed mid-flight by the in-box xt_quota - curl aborts non-zero after ~2 MB and the egress receipt shows the bytes bounded near the cap, not the full 4 MiB: even a host you permit can't be used to launder unlimited volume out"></p>
<p align="center"><img src="../assets/hard-cap-demo.gif" width="720" alt="with SLUICE_EGRESS_HARD_CAP_BYTES=1258291 a warm GET returns http 200, proving httpbin.org is genuinely allowlisted; an upload to that same allowed host then puts 1556480 bytes on the wire before it dies (curl exit 28); re-running the warm GET that had just succeeded now times out with http 000, showing egress is dead box-wide rather than one flow being slow - which is what confirms the cap fired; the receipt closes at 1 reached, 0 blocked, 1.4 MB"></p>

- `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.
Expand Down
2 changes: 0 additions & 2 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,6 @@ sourced, `--all` is chain-integrity only: per-box egress knobs (host budgets, by
it, and `-b <box>` is rejected for `--all` (it spans every box by definition). An unreadable log reports
`verified:false, reason:"unreadable"` - never a silent pass.

<p align="center"><img src="../assets/fleet-audit.gif" width="760" alt="with the container engine down, sluice egress --verify --all walks every box's append-only hash chain host-side and reports all 3 boxes intact; --verify --all --json emits the sluice.fleet-verify/v1 schema per box; --export --all concatenates every box's JSONL each record tagged with its box; then one dropped log line flips one box to TAMPERED with a non-zero exit - never a silent pass"></p>

## Machine-readable contracts

Every JSON surface carries a `schema` id so a consumer (a dashboard, a CI gate, the future control plane)
Expand Down
13 changes: 12 additions & 1 deletion docs/policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

<p align="center"><img src="../assets/policy-refusal.gif" width="680" alt="an org policy denies a host and sets deny-ip for the cloud-metadata IP 169.254.169.254/32; a developer's local config crosses both lines - it allowlists the denied host and opens a direct-IP supernet 169.254.169.0/24 that overlaps the denied /32 - so sluice refuses to run before any box is built, naming the overlapping deny-ip and exiting non-zero; swapping to a compliant config then runs clean under the same policy"></p>
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

Expand Down
2 changes: 0 additions & 2 deletions docs/supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ sluice update # rebuild --no-cache, then refresh the lock

All three report forms take `--json`.

<p align="center"><img src="../assets/lock-demo.gif" width="700" alt="sluice lock --check reports the inventory in sync; after a dependency is added and the box rebuilt, lock --check catches the drift (classified: + apk tree, exit 1); a CycloneDX SBOM then carries the new package with its purl and SHA-1 integrity hash; finally lock --scan --fail-on high runs that SBOM through a host grype and gates the build on the lodash CVEs (non-zero exit)"></p>

### Reproducibility, in tiers (be honest)

Wolfi apk is a rolling repo, so the same config builds different versions on different days. sluice
Expand Down
8 changes: 1 addition & 7 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,14 @@ 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:

<p align="center"><img src="../assets/overlay-demo.gif" width="720" alt="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"></p>
confirm) touches your real files.

## Your own stack

No preset needed: `sluice init` detects 11 stacks (list in the [main README](../README.md#use);
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.

<p align="center"><img src="../assets/onboard-demo.gif" width="680" alt="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"></p>

## Stronger isolation (Linux)

Any preset above - or your own repo - runs under an own-kernel micro-VM with
Expand Down Expand Up @@ -59,8 +55,6 @@ $ sluice learn
[sluice] reloaded the running box (squid reconfigure) - live now, no rebuild.
```

<p align="center"><img src="../assets/learn-demo.gif" width="680" alt="a real run is blocked by the firewall: curl fails and the egress receipt shows the host in red as 'blocked, not allowlisted'; sluice learn then reviews it with an allow/skip/domain/quit prompt, 'a' allows it and the running box is reloaded live with no rebuild; the rerun returns HTTP 200 and the receipt flips to green"></p>

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:
Expand Down
Loading