docs: add end-to-end demo with recorded GIF
test / unit (push) Successful in 12s
test / integration (push) Successful in 19s

scripts/demo.sh + scripts/demo_harness.py drive a real bottle through
four probes (pipelock allow, host-allowlist block, DLP body-scan
block, git-gate gitleaks rejection). docs/demo.tape is the VHS source
that renders docs/demo.gif, embedded at the top of the README as a
working proof of the security model the prose describes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 00:55:58 -04:00
parent 3d9103d5b5
commit 4ef1cc58df
5 changed files with 351 additions and 0 deletions
+9
View File
@@ -8,6 +8,15 @@
Run multiple Claude Code agents on your own machine, each scoped to its own secrets, skills, and egress allowlist.
![pipelock and git-gate blocking exfil attempts against a live bottle](docs/demo.gif)
Four probes against a real bottle, end-to-end:
pipelock forwards a clean HTTPS GET to an allowlisted host,
blocks a GET to a non-allowlisted host,
blocks a POST whose body carries a credential pattern;
git-gate rejects a push containing a leaked key.
Run it yourself with `bash scripts/demo.sh`.
## Why "claude-bottle"?
Each container is a bottle; Claude is the genie inside. The genie's
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

+38
View File
@@ -0,0 +1,38 @@
# VHS tape — produces docs/demo.gif from scripts/demo.sh.
#
# Usage:
# brew install vhs # if you don't have it
# vhs docs/demo.tape # ~60-90s; writes docs/demo.gif
#
# Re-record on changes:
# - new scenarios → tweak Height below
# - faster overall → shorten the Sleep after the trailing summary
#
# The harness paces itself with its own time.sleep() calls so each
# scenario block has time to be read; VHS only needs to capture the
# whole run end-to-end.
Output docs/demo.gif
Set Shell "bash"
Set FontSize 14
Set Width 1100
Set Height 780
Set Padding 20
Set Theme "Catppuccin Mocha"
Set TypingSpeed 60ms
Set PlaybackSpeed 1.0
Hide
Type "clear"
Enter
Show
Type "bash scripts/demo.sh"
Sleep 500ms
Enter
# Warm-cache run takes ~14s; first-time runs that build images will be
# longer, but the wrapper pre-warms quietly so the recording sees a
# warm path. Pad a few seconds so the trailing PASS summary holds.
Sleep 20s
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Demo runner: builds the image graph if needed, then runs the four-scenario
# harness against a real bottle. Designed to produce screen-recordable
# output — paced banners, color, no Python tracebacks unless something
# actually breaks.
#
# Usage:
# bash scripts/demo.sh # run live
# vhs docs/demo.tape # record to docs/demo.gif
set -euo pipefail
cd "$(dirname "$0")/.."
verbose=0
for arg in "$@"; do
case "$arg" in
-v|--verbose) verbose=1 ;;
-h|--help)
cat <<EOF
Usage: bash scripts/demo.sh [--verbose]
Runs four pipelock + git-gate probes against a real bottle and prints
PASS/BLOCK verdicts. Without --verbose, Docker build chatter and
backend log lines are suppressed so the output is recordable.
EOF
exit 0 ;;
esac
done
if ! command -v docker >/dev/null 2>&1; then
echo "docker not found on PATH — install Docker Desktop or equivalent first" >&2
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "docker daemon not reachable — start Docker and re-run" >&2
exit 1
fi
# Pre-warm the image graph quietly so the recorded run shows only the
# four scenario blocks, not BuildKit progress. The backend rebuilds
# (cache-hit) on launch regardless; doing it once up front keeps the
# launch-time chatter short.
if [ "$verbose" = 0 ]; then
docker build -q -t claude-bottle:latest . >/dev/null 2>&1 || true
docker build -q -f Dockerfile.git-gate -t claude-bottle-git-gate:latest . >/dev/null 2>&1 || true
fi
if [ "$verbose" = 1 ]; then
exec python3 -u scripts/demo_harness.py
else
# Stderr carries backend info() lines and BuildKit chatter; drop it.
# The harness writes all scenario output (banners, results) to stdout.
exec python3 -u scripts/demo_harness.py 2>/dev/null
fi
+248
View File
@@ -0,0 +1,248 @@
"""End-to-end demo: spin up one bottle and show pipelock + git-gate
in action across four scenarios. Mirrors the integration tests in
tests/integration/ but with paced output suitable for screen
recording. Run with `bash scripts/demo.sh` for a banner-wrapped
session; this file is also runnable directly as `python -u
scripts/demo_harness.py`.
The bottle declares one `git` upstream (unreachable on purpose: the
gitleaks pre-receive hook runs before the gate would try to forward
to the real upstream) and one `FAKE_TOKEN` env var whose value has
the shape of a GitHub PAT so pipelock's DLP layer recognizes it.
All four probes run inside the same bottle so the demo proves the
same agent that can reach `api.anthropic.com` is the one that gets
blocked from reaching `example.com` and from posting credentials
anywhere.
"""
from __future__ import annotations
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from claude_bottle.backend import BottleSpec, get_bottle_backend # noqa: E402
from claude_bottle.manifest import Manifest # noqa: E402
FAKE_TOKEN = "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ"
FAKE_AWS_KEY = "AKIAQRJHK7N5ZPM2VXTL"
ALLOW_TARGET = "https://raw.githubusercontent.com/git/git/master/README.md"
BLOCK_HOST = "example.com"
DLP_TARGET_HOST = "api.anthropic.com"
def banner(n: int, total: int, title: str) -> None:
bar = "" * 60
print(f"\n\033[1;36m{bar}\033[0m")
print(f"\033[1;36mScenario {n}/{total}\033[0m \033[1m{title}\033[0m")
print(f"\033[1;36m{bar}\033[0m")
sys.stdout.flush()
def cmd(s: str) -> None:
print(f"\033[2m$ {s}\033[0m")
sys.stdout.flush()
def verdict(passed: bool, msg: str) -> None:
if passed:
print(f"\033[1;32m✓ {msg}\033[0m")
else:
print(f"\033[1;31m✗ {msg}\033[0m")
sys.stdout.flush()
def pause(seconds: float = 1.2) -> None:
time.sleep(seconds)
def scenario_allow(bottle) -> bool:
banner(1, 4, f"Allowlisted HTTPS GET → {ALLOW_TARGET.split('/')[2]}")
cmd(f'curl --proxy "$HTTPS_PROXY" {ALLOW_TARGET}')
script = (
"set -eu\n"
'curl --proxy "$HTTPS_PROXY" -s --max-time 10 '
"-w 'status=%{http_code} ' -o /tmp/body.txt "
f"{ALLOW_TARGET}\n"
'echo "len=$(wc -c < /tmp/body.txt)"\n'
)
result = bottle.exec(script)
print(result.stdout.rstrip())
ok = "status=200" in result.stdout and "len=0" not in result.stdout
verdict(ok, "forwarded — the proxy isn't just blocking everything")
return ok
_HTTP_PROBE = r"""
const http = require('http');
const proxy = new URL(process.env.HTTPS_PROXY);
const target = process.env.PROBE_TARGET; // absolute http URL
const body = process.env.PROBE_BODY || '';
const method = process.env.PROBE_METHOD || 'GET';
const url = new URL(target);
const opts = {
host: proxy.hostname, port: proxy.port, method,
path: target,
headers: { Host: url.host },
};
if (body) {
opts.headers['Content-Type'] = 'application/x-www-form-urlencoded';
opts.headers['Content-Length'] = Buffer.byteLength(body);
}
const req = http.request(opts, (res) => {
res.resume();
res.on('end', () => { console.log('status=' + res.statusCode); process.exit(0); });
});
req.on('error', (e) => { console.log('error=' + (e.code||'') + ' ' + e.message); process.exit(0); });
req.setTimeout(5000, () => { console.log('timeout'); req.destroy(); });
if (body) req.write(body);
req.end();
"""
def _node_probe(bottle, *, target: str, method: str = "GET", body: str = ""):
script = (
"set -e\n"
"cat > /tmp/probe.js <<'PROBE_EOF'\n"
f"{_HTTP_PROBE}\n"
"PROBE_EOF\n"
f"PROBE_TARGET={target!r} PROBE_METHOD={method!r} "
f"PROBE_BODY={body!r} node /tmp/probe.js\n"
)
return bottle.exec(script)
def scenario_block_host(bottle) -> bool:
banner(2, 4, f"Non-allowlisted host → GET http://{BLOCK_HOST}/")
cmd(f"node probe.js # GET http://{BLOCK_HOST}/")
result = _node_probe(bottle, target=f"http://{BLOCK_HOST}/")
print(result.stdout.rstrip())
ok = "status=200" not in result.stdout
verdict(ok, f"blocked at the host allowlist — DLP never had to run")
return ok
def scenario_block_dlp(bottle) -> bool:
banner(
3, 4,
f"Allowlisted host + secret in body → POST http://{DLP_TARGET_HOST}/dlp-probe",
)
cmd(f"node probe.js # POST token=ghp_… to http://{DLP_TARGET_HOST}/dlp-probe")
result = _node_probe(
bottle,
target=f"http://{DLP_TARGET_HOST}/dlp-probe",
method="POST",
body=f"token={FAKE_TOKEN}",
)
print(result.stdout.rstrip())
ok = "status=403" in result.stdout
verdict(
ok,
"blocked by pipelock DLP — host was allowed, body matched a credential pattern",
)
return ok
def scenario_block_git_push(bottle) -> bool:
banner(4, 4, "git push of a file containing AKIA… → git-gate")
cmd("echo AKIA… > leak.txt && git commit -m leak && git push")
push_script = (
"set -e\n"
"cd /tmp\n"
"rm -rf repo && git init -q -b main repo && cd repo\n"
"git config user.email demo@example.com\n"
"git config user.name demo\n"
f"echo '{FAKE_AWS_KEY}' > leak.txt\n"
"git add leak.txt\n"
"git commit -q -m 'oops: hardcoded key'\n"
# `~/.gitconfig`'s insteadOf rewrites this to git://<gate>/foo.git.
# Retry briefly because git-daemon takes a moment to bind after
# the gate container starts.
"for i in $(seq 1 15); do\n"
" out=$(git push ssh://git@upstream.invalid/path.git main 2>&1) && echo \"$out\" && exit 0\n"
" case \"$out\" in *'gitleaks'*|*'leaks found'*|*'rejected'*) echo \"$out\"; exit 1;; esac\n"
" sleep 1\n"
"done\n"
"echo TIMEOUT_WAITING_FOR_GATE; exit 2\n"
)
result = bottle.exec(push_script)
out = (result.stdout + result.stderr).rstrip()
# Only print the gitleaks-relevant tail; raw git stderr is noisy.
tail = "\n".join(out.splitlines()[-8:])
print(tail)
ok = result.returncode != 0 and (
"gitleaks rejected" in out or "leaks found" in out
)
verdict(
ok,
"rejected by git-gate pre-receive hook — upstream never saw the ref",
)
return ok
def main() -> int:
stage_dir = Path(tempfile.mkdtemp(prefix="cb-demo-stage."))
fake_key = stage_dir / "fake-key"
fake_key.write_text("not-a-real-key\n")
manifest = Manifest.from_json_obj({
"bottles": {
"demo": {
"env": {"FAKE_TOKEN": FAKE_TOKEN},
"git": [{
"Name": "foo",
"Upstream": "ssh://git@upstream.invalid/path.git",
"IdentityFile": str(fake_key),
"KnownHostKey": "ssh-ed25519 AAAAEXAMPLE",
}],
},
},
"agents": {
"demo": {"skills": [], "prompt": "", "bottle": "demo"},
},
})
print("\033[1mclaude-bottle demo: pipelock + git-gate, four probes, one bottle\033[0m")
print(
"\033[2mbuilding image graph, starting bottle and sidecars…\033[0m"
)
sys.stdout.flush()
backend = get_bottle_backend()
spec = BottleSpec(
manifest=manifest,
agent_name="demo",
copy_cwd=False,
user_cwd=str(stage_dir),
forward_oauth_token=False,
)
plan = backend.prepare(spec, stage_dir=stage_dir)
results: list[bool] = []
with backend.launch(plan) as bottle:
pause(0.6)
results.append(scenario_allow(bottle))
pause(2.0)
results.append(scenario_block_host(bottle))
pause(2.0)
results.append(scenario_block_dlp(bottle))
pause(2.0)
results.append(scenario_block_git_push(bottle))
pause(1.0)
bar = "" * 60
print(f"\n\033[1;36m{bar}\033[0m")
passed = sum(1 for r in results if r)
color = "32" if passed == len(results) else "31"
print(f"\033[1;{color}m{passed}/{len(results)} scenarios passed\033[0m")
print(f"\033[1;36m{bar}\033[0m")
return 0 if passed == len(results) else 1
if __name__ == "__main__":
sys.exit(main())