3be70eb07a
Reshape the allowlist topology so the egress-proxy is the bottle's
single allowlist surface, and replace the agent-side
routes/allowlist file mounts with a live MCP tool.
Policy change (move defaults to egress-proxy):
- `egress_proxy_routes_for_bottle(bottle)` now folds in
DEFAULT_ALLOWLIST (the claude-code defaults) and
`bottle.egress.allowlist` (user adds) as bare-pass routes (no
auth, no path filter), on top of the bottle's
`egress_proxy.routes`. Manifest routes win on host collision.
- `pipelock_effective_allowlist(bottle)` mirrors egress-proxy's
effective host set when egress-proxy is in use. Pipelock is
no longer the bottle's primary allowlist authority; it
enforces a downstream copy as defense-in-depth + does DLP body
scanning.
- Split out `egress_proxy_manifest_routes(bottle)` for callers
that want just the manifest entries (tests, internal use).
- DEFAULT_ALLOWLIST moves from `pipelock.py` to `egress_proxy.py`
(pipelock re-imports for the no-egress-proxy fallback path).
- Dropped the `egress-proxy` auto-allow on pipelock's allowlist
— the agent never dials egress-proxy via the proxy mechanism;
pipelock only sees upstream hostnames from egress-proxy's
CONNECTs.
Introspection endpoint (existing mitmproxy feature):
- Egress-proxy addon recognises requests to the magic host
`_egress-proxy.local` and synthesizes responses via
`flow.response = http.Response.make(...)` — no upstream
connection, no allowlist enforcement on the magic host.
- `GET /allowlist` returns the in-memory route table as JSON
(host + path_allowlist + auth_scheme + token_env per route;
no token VALUES).
- Smoke-tested end-to-end against a real egress-proxy container.
MCP tool (existing supervise plumbing):
- New `list-egress-proxy-routes` tool (no inputs, no operator
approval). Handler fetches via egress-proxy's introspection
endpoint using urllib's ProxyHandler against
`EGRESS_PROXY_FORWARD_PROXY`. Returns the JSON payload as the
tool's text content; `isError: true` if the proxy is
unreachable.
- `egress-proxy-block` description now points the agent at
`list-egress-proxy-routes` instead of a staged file path.
- `pipelock-block` description acknowledges the mirror — agents
should prefer `egress-proxy-block` to add hosts; pipelock-block
stays for the rare divergence case.
Drop agent-side file mounts:
- Supervise's `current-config` dir staging no longer writes
routes.yaml / allowlist. Only `Dockerfile` remains
(capability-block still reads it from
`/etc/claude-bottle/current-config/Dockerfile`).
- `prepare.py` stops passing `routes_content` /
`allowlist_content` to `supervise.prepare`.
- `Supervise.prepare` signature simplified to one
`dockerfile_content` kwarg.
Tests: 400 unit + integration pass. Added coverage for
defaults-folding (`TestRoutesForBottleFoldsDefaults`), the new
tool definition + handler, and the updated supervise.prepare
shape.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
264 lines
9.0 KiB
Python
264 lines
9.0 KiB
Python
"""Per-bottle persistent state (PRD 0016).
|
|
|
|
Holds the per-bottle Dockerfile override that capability-block
|
|
remediation writes, the transcript snapshot the state-preservation
|
|
helper saves before teardown, and the launch metadata that lets
|
|
`cli.py resume <identity>` reconstruct a bottle's spec. State
|
|
lives at:
|
|
|
|
~/.claude-bottle/state/<identity>/
|
|
metadata.json — agent_name + cwd + started_at (for resume)
|
|
Dockerfile — per-bottle override (absent → use repo's)
|
|
transcript/ — last snapshotted agent state (best-effort)
|
|
|
|
When the per-bottle Dockerfile is present, the launch step builds
|
|
the agent image with a per-bottle tag (claude-bottle-rebuilt-<id>)
|
|
from this file rather than the repo's. The build context is still
|
|
the repo root so the Dockerfile can COPY claude_bottle source files
|
|
the same way the original does.
|
|
|
|
Identity model:
|
|
- Every `cli.py start <agent>` mints a fresh identity via
|
|
`bottle_identity(agent_name)`: slug-prefix for readability plus a
|
|
5-char random suffix for parallel-safe uniqueness. The metadata
|
|
written at launch time pins (agent_name, cwd) to that identity.
|
|
- `cli.py resume <identity>` reads the metadata and re-launches a
|
|
bottle pinned to the same identity, picking up any per-bottle
|
|
Dockerfile and transcript snapshot.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
import secrets
|
|
import string
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from ... import supervise as _supervise
|
|
from . import util as docker_mod
|
|
|
|
|
|
# Directory layout: ~/.claude-bottle/state/<identity>/...
|
|
_STATE_SUBDIR = "state"
|
|
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
|
_TRANSCRIPT_SUBDIR = "transcript"
|
|
_METADATA_NAME = "metadata.json"
|
|
# Live-config dir bind-mounted into the supervise sidecar (read-only).
|
|
# Host's apply paths keep these files fresh so supervise's
|
|
# `list-pipelock-allowlist` / `list-egress-proxy-routes` MCP tools
|
|
# return the current state — not a snapshot from launch time.
|
|
_LIVE_CONFIG_SUBDIR = "live-config"
|
|
LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
|
|
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
|
|
# Empty marker file. capability_apply writes it before teardown so
|
|
# cli.py's session-end cleanup knows to preserve the state dir for
|
|
# `cli.py resume <identity>`. Absent = clean up.
|
|
_PRESERVE_MARKER = ".preserve"
|
|
|
|
# 5 chars of base36 alphabet ≈ 60M combinations. Plenty for human
|
|
# operators starting bottles by hand; collision-free in practice.
|
|
_RANDOM_SUFFIX_LEN = 5
|
|
_SUFFIX_ALPHABET = string.ascii_lowercase + string.digits
|
|
|
|
|
|
def bottle_identity(agent_name: str) -> str:
|
|
"""Mint a fresh per-launch bottle identity. The slug-prefix is
|
|
`slugify(agent_name)` for readability; the suffix is 5 random
|
|
base36 chars so two simultaneous `start <agent>` invocations
|
|
don't collide on container/network names.
|
|
|
|
Every call produces a different identity (non-deterministic).
|
|
To continue an existing bottle's state, use the recorded
|
|
identity from BottleMetadata via `cli.py resume <identity>`,
|
|
not this function."""
|
|
slug = docker_mod.slugify(agent_name)
|
|
suffix = "".join(secrets.choice(_SUFFIX_ALPHABET) for _ in range(_RANDOM_SUFFIX_LEN))
|
|
return f"{slug}-{suffix}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BottleMetadata:
|
|
"""Persistent record of how a bottle was launched, written at
|
|
start time and read by `cli.py resume`. Lives at
|
|
~/.claude-bottle/state/<identity>/metadata.json."""
|
|
|
|
identity: str
|
|
agent_name: str
|
|
cwd: str # empty string when --cwd was not passed
|
|
copy_cwd: bool
|
|
started_at: str # ISO 8601 UTC
|
|
|
|
|
|
def metadata_path(identity: str) -> Path:
|
|
return bottle_state_dir(identity) / _METADATA_NAME
|
|
|
|
|
|
def write_metadata(metadata: BottleMetadata) -> Path:
|
|
"""Persist `metadata` to ~/.claude-bottle/state/<identity>/metadata.json.
|
|
Mode 0o644 — no secrets, just (agent_name, cwd, timestamp)."""
|
|
path = metadata_path(metadata.identity)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(dataclasses.asdict(metadata), indent=2) + "\n")
|
|
path.chmod(0o644)
|
|
return path
|
|
|
|
|
|
def read_metadata(identity: str) -> BottleMetadata | None:
|
|
"""Return the metadata for `identity`, or None if no state has
|
|
been recorded for it. Used by `cli.py resume` to reconstruct
|
|
the launch spec."""
|
|
path = metadata_path(identity)
|
|
if not path.is_file():
|
|
return None
|
|
raw = json.loads(path.read_text())
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
return BottleMetadata(
|
|
identity=str(raw.get("identity", identity)),
|
|
agent_name=str(raw.get("agent_name", "")),
|
|
cwd=str(raw.get("cwd", "")),
|
|
copy_cwd=bool(raw.get("copy_cwd", False)),
|
|
started_at=str(raw.get("started_at", "")),
|
|
)
|
|
|
|
|
|
def bottle_state_dir(identity: str) -> Path:
|
|
"""Per-bottle state directory on the host. Created lazily by the
|
|
write helpers; readers tolerate its absence."""
|
|
return _supervise.claude_bottle_root() / _STATE_SUBDIR / identity
|
|
|
|
|
|
def per_bottle_dockerfile_path(identity: str) -> Path:
|
|
return bottle_state_dir(identity) / _PER_BOTTLE_DOCKERFILE_NAME
|
|
|
|
|
|
def per_bottle_dockerfile(identity: str) -> str | None:
|
|
"""Return the per-bottle Dockerfile content if present, else
|
|
None. None means: use the repo's Dockerfile (the original
|
|
pre-capability-block behavior)."""
|
|
p = per_bottle_dockerfile_path(identity)
|
|
if p.is_file():
|
|
return p.read_text()
|
|
return None
|
|
|
|
|
|
def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
|
|
p = per_bottle_dockerfile_path(identity)
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(content)
|
|
p.chmod(0o644)
|
|
return p
|
|
|
|
|
|
def per_bottle_image_tag(identity: str) -> str:
|
|
"""Image tag for a rebuilt bottle. Distinct from the base
|
|
claude-bottle:latest so per-bottle rebuilds don't collide in
|
|
the docker image cache."""
|
|
return f"claude-bottle-rebuilt-{identity}:latest"
|
|
|
|
|
|
def live_config_dir(identity: str) -> Path:
|
|
"""Per-bottle live-config dir. Bind-mounted read-only into the
|
|
supervise sidecar; the host's apply paths refresh the files on
|
|
every operator approval so the agent's `list-*` MCP tools always
|
|
return current state."""
|
|
return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR
|
|
|
|
|
|
def live_routes_path(identity: str) -> Path:
|
|
return live_config_dir(identity) / LIVE_CONFIG_ROUTES_NAME
|
|
|
|
|
|
def live_allowlist_path(identity: str) -> Path:
|
|
return live_config_dir(identity) / LIVE_CONFIG_ALLOWLIST_NAME
|
|
|
|
|
|
def write_live_config(
|
|
identity: str, *, routes: str = "", allowlist: str = "",
|
|
) -> Path:
|
|
"""Initialise (or refresh) the live-config dir. Empty-string args
|
|
leave the existing file alone (caller passes only what it knows).
|
|
Returns the live-config dir path."""
|
|
d = live_config_dir(identity)
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
if routes:
|
|
p = live_routes_path(identity)
|
|
p.write_text(routes)
|
|
p.chmod(0o644)
|
|
if allowlist:
|
|
p = live_allowlist_path(identity)
|
|
p.write_text(allowlist)
|
|
p.chmod(0o644)
|
|
return d
|
|
|
|
|
|
def transcript_snapshot_dir(identity: str) -> Path:
|
|
"""Where capability_apply stashes the agent's transcript before
|
|
teardown, so the next `cli.py start <agent>` can offer to
|
|
resume from it."""
|
|
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
|
|
|
|
|
|
# --- Preserve-on-close marker ----------------------------------------------
|
|
|
|
|
|
def preserve_marker_path(identity: str) -> Path:
|
|
return bottle_state_dir(identity) / _PRESERVE_MARKER
|
|
|
|
|
|
def mark_preserved(identity: str) -> Path:
|
|
"""Mark this bottle's state for preservation across session
|
|
teardown. Written by capability_apply.apply_capability_change so
|
|
cli.py's session-end cleanup leaves the state dir intact for a
|
|
subsequent `cli.py resume`."""
|
|
path = preserve_marker_path(identity)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.touch()
|
|
return path
|
|
|
|
|
|
def is_preserved(identity: str) -> bool:
|
|
return preserve_marker_path(identity).exists()
|
|
|
|
|
|
def clear_preserve_marker(identity: str) -> None:
|
|
"""Idempotent removal. Called at fresh launch (start or resume)
|
|
so a marker left from a prior capability-block doesn't keep
|
|
state alive past the next normal session-end."""
|
|
try:
|
|
preserve_marker_path(identity).unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def cleanup_state(identity: str) -> None:
|
|
"""Remove the per-bottle state dir entirely. Called by cli.py
|
|
when a bottle session ends and is_preserved(identity) is False.
|
|
Idempotent — missing dir is success."""
|
|
import shutil
|
|
state_dir = bottle_state_dir(identity)
|
|
if state_dir.is_dir():
|
|
shutil.rmtree(state_dir, ignore_errors=True)
|
|
|
|
|
|
__all__ = [
|
|
"BottleMetadata",
|
|
"bottle_identity",
|
|
"bottle_state_dir",
|
|
"cleanup_state",
|
|
"clear_preserve_marker",
|
|
"is_preserved",
|
|
"mark_preserved",
|
|
"metadata_path",
|
|
"per_bottle_dockerfile",
|
|
"per_bottle_dockerfile_path",
|
|
"per_bottle_image_tag",
|
|
"preserve_marker_path",
|
|
"read_metadata",
|
|
"transcript_snapshot_dir",
|
|
"write_metadata",
|
|
"write_per_bottle_dockerfile",
|
|
]
|