diff --git a/README.md b/README.md index 51979a2..a9d7b3c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..b846506 Binary files /dev/null and b/docs/demo.gif differ diff --git a/docs/demo.tape b/docs/demo.tape new file mode 100644 index 0000000..2295799 --- /dev/null +++ b/docs/demo.tape @@ -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 diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100755 index 0000000..c284a7d --- /dev/null +++ b/scripts/demo.sh @@ -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 </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 diff --git a/scripts/demo_harness.py b/scripts/demo_harness.py new file mode 100644 index 0000000..da6f03f --- /dev/null +++ b/scripts/demo_harness.py @@ -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:///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())