From 8774e94f91ec3c0d3d6d6b49e18c2f73148370dc Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 27 Jul 2026 22:03:48 -0400 Subject: [PATCH] fix: repair the demo harness for the current manifest format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo has been unrunnable since eafd1c1f deleted bot-bottle.demo.json as a pre-YAML-migration artifact. demo-setup.sh still copied that file under `set -euo pipefail`, so demo.sh and demo-record.sh both exited non-zero before launching anything. Rebuild the fixtures in the current per-file Markdown format. Bottles are only read from $HOME/.bot-bottle/bottles/ — manifest/index.py ignores a bottles/ dir in CWD by design (PRD 0011) — so setup now installs into real config and pairs every write with a .demo-backup that teardown restores. Teardown compares content before removing, so a second run cannot delete the user's restored original. Point the DLP probe at the bottle's own example.org route rather than api.anthropic.com/dlp-probe. The provider-injected anthropic route defaults to `redact`, which would forward a scrubbed request instead of refusing; the declared route sets outbound_on_match: block so probe 3 stays the hard 403 the demo is showing off. Left at the default `supervise` it would instead hold the request open for up to 300s awaiting operator approval and stall the recording. Also drop the stale pipelock naming from the tape (egress is the gateway's own scanner now), fix demo.sh's ./cli.py invocation, and pre-warm Dockerfile.gateway instead of the removed Dockerfile.git-gate. Refs #540 Co-Authored-By: Claude Opus 5 --- docs/demo.tape | 46 +++++++++++++++++----------- scripts/demo-setup.sh | 66 +++++++++++++++++++++++++++++----------- scripts/demo-teardown.sh | 39 +++++++++++++++++++----- scripts/demo.sh | 10 ++++-- scripts/demo/agent.md | 27 ++++++++++++++++ scripts/demo/bottle.md | 64 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+), 44 deletions(-) create mode 100644 scripts/demo/agent.md create mode 100644 scripts/demo/bottle.md 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.