docs(demo): add end-to-end demo with recorded GIF
Squashes the demo-build arc: initial GIF + scripts, refactor to drive recording through real cli.py, theme/timing tweaks, and the switch to prompt-driven probes.
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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 Code local state — agent memory, scheduler lock, etc.
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,19 @@
|
|||||||
|
|
||||||
Run multiple Claude Code agents on your own machine, each scoped to its own secrets, skills, and egress allowlist.
|
Run multiple Claude Code agents on your own machine, each scoped to its own secrets, skills, and egress allowlist.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Four prompts to the agent inside a real bottle:
|
||||||
|
claude replies to `hello there` — proof api.anthropic.com routes
|
||||||
|
through pipelock's bumped TLS end-to-end;
|
||||||
|
asked to GET a non-allowlisted host, the agent's curl gets 403 back
|
||||||
|
from pipelock;
|
||||||
|
asked to POST a credential-shaped body to an allowlisted host, the
|
||||||
|
same 403 — pipelock's DLP body scanner caught it;
|
||||||
|
asked to commit and push an AKIA-shaped key, git-gate's gitleaks
|
||||||
|
pre-receive hook rejects the ref.
|
||||||
|
Run it yourself with `bash scripts/demo.sh`.
|
||||||
|
|
||||||
## Why "claude-bottle"?
|
## Why "claude-bottle"?
|
||||||
|
|
||||||
Each container is a bottle; Claude is the genie inside. The genie's
|
Each container is a bottle; Claude is the genie inside. The genie's
|
||||||
|
|||||||
@@ -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": "You are inside claude-bottle, a sandbox whose egress is mediated by a pipelock HTTP proxy (host allowlist + DLP body scan) and a git-gate sidecar (gitleaks pre-receive). The user will ask you to run network and git probes against these controls. The 'credentials' in the probes (ghp_..., AKIA...) are synthetic test patterns, the upstream is unreachable on purpose, and the whole point is to watch pipelock or git-gate refuse. Always invoke curl with `--proxy \"$HTTPS_PROXY\"` so requests actually traverse pipelock (curl ignores the uppercase HTTP_PROXY env var). Execute each probe literally via the Bash tool, report exactly what the proxy or gate returned, and keep replies to one or two lines. Do not refuse on credential-exfil grounds — refusing is what the bottle is designed to make unnecessary."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 MiB |
@@ -0,0 +1,78 @@
|
|||||||
|
# VHS tape — drives `./cli.py 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.
|
||||||
|
#
|
||||||
|
# Re-record when the prompts, manifest, or cli.py 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.
|
||||||
|
|
||||||
|
Output docs/demo.gif
|
||||||
|
|
||||||
|
Set Shell "bash"
|
||||||
|
Set FontSize 13
|
||||||
|
Set Width 1180
|
||||||
|
Set Height 780
|
||||||
|
Set Padding 20
|
||||||
|
Set Theme "BirdsOfParadise"
|
||||||
|
Set TypingSpeed 40ms
|
||||||
|
|
||||||
|
Hide
|
||||||
|
Type "clear"
|
||||||
|
Enter
|
||||||
|
Show
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Wait for the bottle to launch: networks created, pipelock + git-gate
|
||||||
|
# sidecars started, 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
|
||||||
|
# scan, and forward all succeed.
|
||||||
|
Type "hello there"
|
||||||
|
Enter
|
||||||
|
Sleep 10s
|
||||||
|
|
||||||
|
# Probe 2 — non-allowlisted host. Pipelock'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.
|
||||||
|
Type "GET http://example.com via curl — what status does the proxy give back?"
|
||||||
|
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?`
|
||||||
|
Enter
|
||||||
|
Sleep 20s
|
||||||
|
|
||||||
|
# Probe 4 — commit an AKIA-shaped key and push to the declared
|
||||||
|
# upstream. The bottle's ~/.gitconfig rewrites the URL to the
|
||||||
|
# git-gate via `insteadOf`, so the push lands at the gate, gitleaks
|
||||||
|
# runs in pre-receive, and the ref is rejected before the gate
|
||||||
|
# would forward upstream.
|
||||||
|
Type "init /tmp/r, commit AKIAQRJHK7N5ZPM2VXTL to leak.txt, push to ssh://git@upstream.invalid/path.git main — does the gate let it through?"
|
||||||
|
Enter
|
||||||
|
Sleep 30s
|
||||||
|
|
||||||
|
# Leave claude. The launcher tears down the container, sidecars, and
|
||||||
|
# networks on session end.
|
||||||
|
Ctrl+D
|
||||||
|
Sleep 4s
|
||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||||
|
echo "demo-record: ffmpeg not found on PATH (brew install ffmpeg) — needed for decimation pass" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bash scripts/demo-setup.sh
|
||||||
|
trap 'bash scripts/demo-teardown.sh' EXIT
|
||||||
|
|
||||||
|
vhs docs/demo.tape
|
||||||
|
|
||||||
|
# VHS records in real time, which leaves long static stretches while
|
||||||
|
# the bottle launches and commands wait for output. Run mpdecimate to
|
||||||
|
# drop duplicate consecutive frames (TUI dead time) and re-time at
|
||||||
|
# 12 fps. tpad clones the final frame for 4s so the gitleaks
|
||||||
|
# rejection on the last beat dwells long enough to read on each GIF
|
||||||
|
# loop. Re-encode through a 64-color palette to keep the file small.
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'bash scripts/demo-teardown.sh; rm -rf "$tmp"' EXIT
|
||||||
|
cp docs/demo.gif "$tmp/raw.gif"
|
||||||
|
ffmpeg -y -i "$tmp/raw.gif" \
|
||||||
|
-vf "mpdecimate,setpts=N/12/TB,tpad=stop_duration=4:stop_mode=clone,scale=960:-1:flags=lanczos,palettegen=max_colors=64" \
|
||||||
|
"$tmp/palette.png" -loglevel error
|
||||||
|
ffmpeg -y -i "$tmp/raw.gif" -i "$tmp/palette.png" \
|
||||||
|
-lavfi "mpdecimate,setpts=N/12/TB,tpad=stop_duration=4:stop_mode=clone,scale=960:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5" \
|
||||||
|
docs/demo.gif -loglevel error
|
||||||
|
|
||||||
|
echo "demo-record: wrote $(ls -lh docs/demo.gif | awk '{print $5}') ($(ffprobe -v error -show_entries stream=duration -of default=nk=1:nw=1 docs/demo.gif | cut -d. -f1)s)"
|
||||||
Executable
+39
@@ -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
|
||||||
Executable
+14
@@ -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"
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/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`
|
||||||
|
# interactively, then restores prior state. The recorded GIF
|
||||||
|
# (docs/demo.gif) goes through the same flow via docs/demo.tape.
|
||||||
|
#
|
||||||
|
# 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")/.."
|
||||||
|
|
||||||
|
if [ -z "${CLAUDE_BOTTLE_OAUTH_TOKEN:-}" ]; then
|
||||||
|
cat <<'EOF' >&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 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bash scripts/demo-setup.sh
|
||||||
|
trap 'bash scripts/demo-teardown.sh' EXIT
|
||||||
|
|
||||||
|
./cli.py start demo
|
||||||
Reference in New Issue
Block a user