diff --git a/.gitignore b/.gitignore index a7ac670..8f7813b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ .DS_Store Thumbs.db +# User's local manifest — may contain real secrets. The repo ships +# claude-bottle.example.json and claude-bottle.demo.json for reference. +claude-bottle.json + # Claude Code local state — agent memory, scheduler lock, etc. .claude/ diff --git a/claude-bottle.demo.json b/claude-bottle.demo.json new file mode 100644 index 0000000..376ded5 --- /dev/null +++ b/claude-bottle.demo.json @@ -0,0 +1,25 @@ +{ + "bottles": { + "demo": { + "env": { + "FAKE_TOKEN": "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ" + }, + "git": [ + { + "Name": "foo", + "Upstream": "ssh://git@upstream.invalid/path.git", + "IdentityFile": "~/.cache/claude-bottle-demo/fake-key", + "KnownHostKey": "ssh-ed25519 AAAAEXAMPLE" + } + ] + } + }, + + "agents": { + "demo": { + "bottle": "demo", + "skills": [], + "prompt": "" + } + } +} diff --git a/docs/demo.gif b/docs/demo.gif index b846506..b66eace 100644 Binary files a/docs/demo.gif and b/docs/demo.gif differ diff --git a/docs/demo.tape b/docs/demo.tape index 2295799..2ef3a4e 100644 --- a/docs/demo.tape +++ b/docs/demo.tape @@ -1,38 +1,71 @@ -# VHS tape — produces docs/demo.gif from scripts/demo.sh. +# VHS tape — drives `./cli.py start demo` interactively and runs four +# bash probes via claude's `!` prefix. Setup (manifest + dummy SSH key +# + image pre-warm) and teardown happen outside the tape; record via +# `bash scripts/demo-record.sh`, which wraps both. # -# 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. +# Re-record when the probe results, manifest, or cli.py preflight +# rendering change. Output docs/demo.gif Set Shell "bash" -Set FontSize 14 -Set Width 1100 +Set FontSize 13 +Set Width 1180 Set Height 780 Set Padding 20 Set Theme "Catppuccin Mocha" -Set TypingSpeed 60ms -Set PlaybackSpeed 1.0 +Set TypingSpeed 40ms Hide Type "clear" Enter Show -Type "bash scripts/demo.sh" -Sleep 500ms +# Real cli.py invocation — what a user with claude-bottle.json in cwd +# would type. The bottle declares one allowlist (only baked-in +# defaults), one git upstream (unreachable on purpose so gitleaks runs +# before the gate would forward), and a FAKE_TOKEN env var shaped like +# a GitHub PAT. +Type "./cli.py start demo" +Enter +Sleep 8s + +# Confirm the y/N preflight. cli.py reads from /dev/tty. +Type "y" 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 +# Wait for the bottle to launch: networks created, pipelock + git-gate +# sidecars started, agent container started, claude boots. +Sleep 22s + +# Probe 1 — allowlisted HTTPS reaches an allowlisted host via the +# bumped TLS tunnel. Baseline: the proxy isn't just blocking everything. +Type `! curl --proxy "$HTTPS_PROXY" -sw 'status=%{http_code}\n' -o /dev/null https://raw.githubusercontent.com/git/git/master/README.md` +Enter +Sleep 5s + +# Probe 2 — non-allowlisted host. Pipelock's host filter refuses to +# forward; DLP doesn't even get a chance to run. +Type `! curl --proxy "$HTTPS_PROXY" -sw 'status=%{http_code}\n' -o /dev/null http://example.com/` +Enter +Sleep 5s + +# Probe 3 — allowlisted host BUT body carries a credential pattern. +# api.anthropic.com is on the baked-in allowlist, so the host check +# passes; the DLP body scanner has to catch the ghp_ pattern. +Type `! curl --proxy "$HTTPS_PROXY" -sw 'status=%{http_code}\n' -o /dev/null --data "token=$FAKE_TOKEN" http://api.anthropic.com/dlp-probe` +Enter +Sleep 5s + +# Probe 4 — git push of a file containing an AKIA-shaped key. The +# bottle's ~/.gitconfig rewrites the upstream URL to the git-gate via +# `insteadOf`, so this push hits the gate, gitleaks runs in the +# pre-receive hook, and rejects the ref before the gate would forward. +Type `! cd /tmp && rm -rf r && git init -qb main r && cd r && git config user.email demo@x && git config user.name demo && echo AKIAQRJHK7N5ZPM2VXTL > leak.txt && git add . && git commit -qm leak && git push ssh://git@upstream.invalid/path.git main` +Enter +Sleep 10s + +# Leave claude. The launcher tears down the container, sidecars, and +# networks on session end. +Ctrl+D +Sleep 4s diff --git a/scripts/demo-record.sh b/scripts/demo-record.sh new file mode 100755 index 0000000..e725a0a --- /dev/null +++ b/scripts/demo-record.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Record docs/demo.gif via VHS. Runs setup, invokes `vhs docs/demo.tape`, +# always tears down. Requires `vhs` (brew install vhs). + +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! command -v vhs >/dev/null 2>&1; then + echo "demo-record: vhs not found on PATH (brew install vhs)" >&2 + exit 1 +fi + +if [ -z "${CLAUDE_BOTTLE_OAUTH_TOKEN:-}" ]; then + echo "demo-record: CLAUDE_BOTTLE_OAUTH_TOKEN is unset; claude inside the bottle will not auth" >&2 + exit 1 +fi + +bash scripts/demo-setup.sh +trap 'bash scripts/demo-teardown.sh' EXIT + +vhs docs/demo.tape diff --git a/scripts/demo-setup.sh b/scripts/demo-setup.sh new file mode 100755 index 0000000..5ce96ed --- /dev/null +++ b/scripts/demo-setup.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Prepare the working directory to run the recorded demo via cli.py: +# - back up any existing claude-bottle.json so the user's real config +# isn't clobbered +# - install claude-bottle.demo.json as claude-bottle.json +# - create a dummy SSH identity at the path the demo manifest expects +# - pre-warm the bottle + git-gate images quietly so the recording +# doesn't spend its first 30s in BuildKit output + +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! docker info >/dev/null 2>&1; then + echo "demo-setup: docker daemon not reachable" >&2 + exit 1 +fi + +# Back up an existing local manifest (untouched if absent). Stored +# alongside the manifest with a deterministic name so teardown can +# find it without state files. +if [ -f claude-bottle.json ]; then + cp claude-bottle.json claude-bottle.json.demo-backup +fi +cp claude-bottle.demo.json claude-bottle.json + +# Dummy SSH identity — the git-gate validator wants a readable file at +# the IdentityFile path. Contents don't matter for the demo: the +# unreachable upstream means the gate never actually uses the key. +fake_key_dir="$HOME/.cache/claude-bottle-demo" +mkdir -p "$fake_key_dir" +chmod 700 "$fake_key_dir" +printf 'not-a-real-key\n' > "$fake_key_dir/fake-key" +chmod 600 "$fake_key_dir/fake-key" + +# Build the image graph quietly so the recorded run shows only the +# bottle launch and the four `!` probes, not BuildKit progress. +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 diff --git a/scripts/demo-teardown.sh b/scripts/demo-teardown.sh new file mode 100755 index 0000000..1879a3f --- /dev/null +++ b/scripts/demo-teardown.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Undo what demo-setup.sh did. Restores any pre-existing +# claude-bottle.json, removes the dummy SSH identity. Idempotent. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +rm -f claude-bottle.json +if [ -f claude-bottle.json.demo-backup ]; then + mv claude-bottle.json.demo-backup claude-bottle.json +fi + +rm -rf "$HOME/.cache/claude-bottle-demo" diff --git a/scripts/demo.sh b/scripts/demo.sh index c284a7d..ec93b22 100755 --- a/scripts/demo.sh +++ b/scripts/demo.sh @@ -1,56 +1,29 @@ #!/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. +# Human-runnable demo wrapper. Stages the demo manifest and dummy +# identity (see scripts/demo-setup.sh), launches `./cli.py start demo` +# interactively, then restores prior state. The recorded GIF +# (docs/demo.gif) goes through the same flow via docs/demo.tape. # -# Usage: -# bash scripts/demo.sh # run live -# vhs docs/demo.tape # record to docs/demo.gif +# Once attached to claude inside the bottle, use the `!` prefix to run +# bash directly — e.g. +# ! curl --proxy "$HTTPS_PROXY" -sw 'status=%{http_code}\n' \ +# -o /dev/null http://example.com/ +# returns 403 because example.com is not on the bottle's allowlist. set -euo pipefail cd "$(dirname "$0")/.." -verbose=0 -for arg in "$@"; do - case "$arg" in - -v|--verbose) verbose=1 ;; - -h|--help) - cat <&2 +demo: CLAUDE_BOTTLE_OAUTH_TOKEN is unset. The bottle launches claude, +which needs the token to authenticate. Set it in your shell env (e.g. +~/.zshrc) — see README §Auth — then re-run. 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 +bash scripts/demo-setup.sh +trap 'bash scripts/demo-teardown.sh' EXIT -# 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 +./cli.py start demo diff --git a/scripts/demo_harness.py b/scripts/demo_harness.py deleted file mode 100644 index da6f03f..0000000 --- a/scripts/demo_harness.py +++ /dev/null @@ -1,248 +0,0 @@ -"""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())