docs: add end-to-end demo with recorded GIF
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:
@@ -8,6 +8,15 @@
|
||||
|
||||
Run multiple Claude Code agents on your own machine, each scoped to its own secrets, skills, and egress allowlist.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
@@ -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
|
||||
Executable
+56
@@ -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
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user