refactor(manifest): drop bottle.egress field, egress_proxy is the only allowlist
Goal: one allowlist surface (egress_proxy.routes), no second
free-form `egress:` knob. Anything that used to live there now
goes in `egress_proxy.routes` as a bare-pass entry
(`- host: <name>`).
Removed:
- `BottleEgress` dataclass + DLP_ACTIONS constant + bottle.egress
field on `Bottle`.
- `pipelock_bottle_allowlist` helper.
- `pipelock_allowlist_summary` helper (the compact preflight
summary stopped using it after PR #31).
- `allowlist_summary` field on `DockerBottlePlan`.
- `bottle.egress.allowlist` folding in
`egress_proxy_routes_for_bottle` — only DEFAULT_ALLOWLIST
auto-folds now.
- The two-branch logic in `pipelock_effective_allowlist`
(egress-proxy-present vs not) — pipelock now just mirrors
`egress_proxy_routes_for_bottle` unconditionally.
Hard-coded:
- `request_body_scanning.action = "block"` in
`pipelock_build_config` (was driven by
`bottle.egress.dlp_action`). The previous default was already
"block" — the knob to switch to "warn" was a foot-gun in a
sandboxed agent context, so it's gone.
Tests:
- `test_pipelock_allowlist.py` rewritten to assert the
mirrored-from-egress-proxy semantics directly.
- `test_manifest_md_load.py`, `test_pipelock_yaml.py`,
`test_egress_proxy.py` fixtures migrated to put hosts in
`egress_proxy.routes` instead of `egress.allowlist`.
Local bottle migrated too: `~/.claude-bottle/bottles/dev.md`
loses the `egress: { allowlist: [example.com] }` block, picks up
a bare-pass `- host: example.com` route.
409 unit + integration tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,6 @@ class DockerBottlePlan(BottlePlan):
|
||||
# None when bottle.supervise is False. PRD 0013 supervise sidecar
|
||||
# is opt-in via the manifest's bottle.supervise field.
|
||||
supervise_plan: SupervisePlan | None
|
||||
allowlist_summary: str
|
||||
use_runsc: bool
|
||||
|
||||
def print(self, *, remote_control: bool) -> None:
|
||||
|
||||
@@ -14,7 +14,6 @@ import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ... import pipelock
|
||||
from ...env import ResolvedEnv, resolve_env
|
||||
from ...log import die
|
||||
from .. import BottleSpec
|
||||
@@ -192,7 +191,6 @@ def resolve_plan(
|
||||
_write_env_file(resolved, env_file)
|
||||
prompt_file.write_text(agent.prompt)
|
||||
|
||||
allowlist_summary = pipelock.pipelock_allowlist_summary(bottle)
|
||||
use_runsc = docker_mod.runsc_available()
|
||||
|
||||
return DockerBottlePlan(
|
||||
@@ -212,7 +210,6 @@ def resolve_plan(
|
||||
git_gate_plan=git_gate_plan,
|
||||
egress_proxy_plan=egress_proxy_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
allowlist_summary=allowlist_summary,
|
||||
use_runsc=use_runsc,
|
||||
)
|
||||
|
||||
|
||||
@@ -190,23 +190,24 @@ def egress_proxy_routes_for_bottle(
|
||||
bottle: Bottle,
|
||||
) -> tuple[EgressProxyRoute, ...]:
|
||||
"""Effective egress-proxy routes: manifest routes followed by
|
||||
bare-pass entries for DEFAULT_ALLOWLIST hosts and
|
||||
`bottle.egress.allowlist` hosts. This is what gets rendered into
|
||||
routes.yaml + what the addon enforces.
|
||||
bare-pass entries for DEFAULT_ALLOWLIST hosts. This is what
|
||||
gets rendered into routes.yaml + what the addon enforces.
|
||||
|
||||
Manifest routes win over defaults on host collision (manifest
|
||||
routes carry more specific config — auth, path filter, role
|
||||
markers). Hostname comparison is case-insensitive."""
|
||||
markers). Hostname comparison is case-insensitive.
|
||||
|
||||
Operators that want to allow an arbitrary host that isn't in
|
||||
DEFAULT_ALLOWLIST declare it directly in
|
||||
`bottle.egress_proxy.routes` as a bare-pass entry
|
||||
(`- host: <name>`). The legacy `bottle.egress.allowlist`
|
||||
folding is gone — egress_proxy is the single allowlist surface."""
|
||||
out: list[EgressProxyRoute] = list(egress_proxy_manifest_routes(bottle))
|
||||
claimed: set[str] = {r.host.lower() for r in out}
|
||||
for host in DEFAULT_ALLOWLIST:
|
||||
if host.lower() not in claimed:
|
||||
out.append(EgressProxyRoute(host=host))
|
||||
claimed.add(host.lower())
|
||||
for host in bottle.egress.allowlist:
|
||||
if host and host.lower() not in claimed:
|
||||
out.append(EgressProxyRoute(host=host))
|
||||
claimed.add(host.lower())
|
||||
return tuple(out)
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ Bottle schema (frontmatter):
|
||||
env: { <NAME>: <env-entry>, ... }
|
||||
git: [ <git-entry>, ... ]
|
||||
egress_proxy: { routes: [ <egress-route>, ... ] }
|
||||
egress: { allowlist: [ <hostname>, ... ] }
|
||||
|
||||
Agent schema (frontmatter):
|
||||
bottle: <bottle-name> # required
|
||||
@@ -341,63 +340,11 @@ class EgressProxyConfig:
|
||||
return cls(routes=routes)
|
||||
|
||||
|
||||
DLP_ACTIONS = ("block", "warn")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BottleEgress:
|
||||
allowlist: tuple[str, ...] = ()
|
||||
# Action pipelock takes when its DLP layer matches a credential
|
||||
# pattern in a request body. "block" → 403 from the proxy, the
|
||||
# request never leaves the egress network. "warn" → forward the
|
||||
# request and emit a log line. Default is "block": detect-only
|
||||
# would let real secrets escape under the agent's compromised
|
||||
# tooling, which is the threat model claude-bottle was built for.
|
||||
dlp_action: str = "block"
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "BottleEgress":
|
||||
d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
|
||||
allow = d.get("allowlist")
|
||||
items: list[str] = []
|
||||
if allow is not None:
|
||||
if not isinstance(allow, list):
|
||||
die(
|
||||
f"bottle '{bottle_name}' egress.allowlist must be an array "
|
||||
f"(was {type(allow).__name__})"
|
||||
)
|
||||
allow_list = cast(list[object], allow)
|
||||
for i, host in enumerate(allow_list):
|
||||
if not isinstance(host, str):
|
||||
die(
|
||||
f"bottle '{bottle_name}' egress.allowlist[{i}] must be a string "
|
||||
f"(was {type(host).__name__})"
|
||||
)
|
||||
items.append(host)
|
||||
dlp_action_raw = d.get("dlp_action")
|
||||
if dlp_action_raw is None:
|
||||
dlp_action = "block"
|
||||
elif isinstance(dlp_action_raw, str):
|
||||
if dlp_action_raw not in DLP_ACTIONS:
|
||||
die(
|
||||
f"bottle '{bottle_name}' egress.dlp_action must be one of "
|
||||
f"{', '.join(DLP_ACTIONS)} (was {dlp_action_raw!r})"
|
||||
)
|
||||
dlp_action = dlp_action_raw
|
||||
else:
|
||||
die(
|
||||
f"bottle '{bottle_name}' egress.dlp_action must be a string "
|
||||
f"(was {type(dlp_action_raw).__name__})"
|
||||
)
|
||||
return cls(allowlist=tuple(items), dlp_action=dlp_action)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bottle:
|
||||
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||
git: tuple[GitEntry, ...] = ()
|
||||
egress_proxy: EgressProxyConfig = field(default_factory=EgressProxyConfig)
|
||||
egress: BottleEgress = field(default_factory=BottleEgress)
|
||||
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
|
||||
# the launch step brings up a supervise sidecar that exposes three
|
||||
# MCP tools to the agent (cred-proxy-block, pipelock-block,
|
||||
@@ -481,13 +428,6 @@ class Bottle:
|
||||
else EgressProxyConfig()
|
||||
)
|
||||
|
||||
egress_raw = d.get("egress")
|
||||
egress = (
|
||||
BottleEgress.from_dict(name, egress_raw)
|
||||
if egress_raw is not None
|
||||
else BottleEgress()
|
||||
)
|
||||
|
||||
supervise_raw = d.get("supervise", False)
|
||||
if not isinstance(supervise_raw, bool):
|
||||
die(
|
||||
@@ -496,7 +436,7 @@ class Bottle:
|
||||
)
|
||||
|
||||
return cls(
|
||||
env=env, git=git, egress_proxy=egress_proxy, egress=egress,
|
||||
env=env, git=git, egress_proxy=egress_proxy,
|
||||
supervise=supervise_raw,
|
||||
)
|
||||
|
||||
@@ -832,7 +772,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||
# sets dies with a "did you mean" pointer — typos shouldn't silently
|
||||
# ghost into an empty config.
|
||||
_BOTTLE_KEYS = frozenset(
|
||||
{"env", "git", "egress_proxy", "egress", "supervise"}
|
||||
{"env", "git", "egress_proxy", "supervise"}
|
||||
)
|
||||
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
||||
_AGENT_KEYS_OPTIONAL = frozenset({"skills"})
|
||||
|
||||
+11
-60
@@ -50,38 +50,15 @@ DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = (
|
||||
# --- Allowlist resolution --------------------------------------------------
|
||||
|
||||
|
||||
def pipelock_bottle_allowlist(bottle: Bottle) -> list[str]:
|
||||
"""Hostnames in bottle.egress.allowlist."""
|
||||
return list(bottle.egress.allowlist)
|
||||
|
||||
|
||||
def pipelock_route_hosts(bottle: Bottle) -> list[str]:
|
||||
"""Hostnames declared in `bottle.egress_proxy.routes`. Returned
|
||||
sorted + deduped. Used by the no-egress-proxy fallback path
|
||||
below; bottles that DO use egress-proxy include the same hosts
|
||||
via `egress_proxy_routes_for_bottle`."""
|
||||
hosts = {r.Host for r in bottle.egress_proxy.routes if r.Host}
|
||||
return sorted(hosts)
|
||||
|
||||
|
||||
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
||||
"""Hostnames pipelock allows. Sorted for stability.
|
||||
|
||||
Two paths, depending on whether the bottle uses egress-proxy:
|
||||
|
||||
- Bottle declares `egress_proxy.routes[]` → agent's HTTPS_PROXY
|
||||
points at egress-proxy. Egress-proxy is the bottle's primary
|
||||
allowlist gate (DEFAULT_ALLOWLIST + bottle.egress.allowlist +
|
||||
manifest routes all live there as bare-pass or full routes,
|
||||
folded in by `egress_proxy_routes_for_bottle`). Pipelock's
|
||||
allowlist is then a MIRROR of egress-proxy's hosts — same
|
||||
set, just serving as the defense-in-depth hostname gate +
|
||||
DLP scanner on the upstream leg.
|
||||
|
||||
- Bottle has no `egress_proxy.routes[]` → agent talks straight
|
||||
to pipelock. Pipelock keeps its previous behavior: bake in
|
||||
DEFAULT_ALLOWLIST + bottle.egress.allowlist for claude-code
|
||||
defaults.
|
||||
Always mirrors `egress_proxy_routes_for_bottle(bottle)` — the
|
||||
egress-proxy is the single allowlist surface; pipelock's
|
||||
allowlist is the downstream copy for defense-in-depth + DLP
|
||||
body scanning. For bottles without any `egress_proxy.routes[]`
|
||||
declared, this is just the baked DEFAULT_ALLOWLIST that
|
||||
egress_proxy_routes_for_bottle always folds in.
|
||||
|
||||
The supervise sidecar's hostname is auto-added when supervise
|
||||
is enabled (sibling-sidecar traffic that flows through pipelock
|
||||
@@ -89,19 +66,9 @@ def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
||||
`bottle.git` do NOT contribute here — git traffic flows
|
||||
through git-gate (PRD 0008), not pipelock."""
|
||||
seen: dict[str, None] = {}
|
||||
if bottle.egress_proxy.routes:
|
||||
# Mirror egress-proxy's effective host set — same defaults
|
||||
# and bottle.egress.allowlist entries are already folded in
|
||||
# at the egress-proxy layer; we don't add them twice.
|
||||
for r in egress_proxy_routes_for_bottle(bottle):
|
||||
if r.host:
|
||||
seen.setdefault(r.host, None)
|
||||
else:
|
||||
for h in DEFAULT_ALLOWLIST:
|
||||
seen.setdefault(h, None)
|
||||
for h in pipelock_bottle_allowlist(bottle):
|
||||
if h:
|
||||
seen.setdefault(h, None)
|
||||
for r in egress_proxy_routes_for_bottle(bottle):
|
||||
if r.host:
|
||||
seen.setdefault(r.host, None)
|
||||
if bottle.supervise:
|
||||
seen.setdefault(SUPERVISE_HOSTNAME, None)
|
||||
return sorted(seen.keys())
|
||||
@@ -160,22 +127,6 @@ def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]:
|
||||
return sorted(DEFAULT_TLS_PASSTHROUGH)
|
||||
|
||||
|
||||
def pipelock_allowlist_summary(bottle: Bottle) -> str:
|
||||
"""One-line summary for the y/N preflight display:
|
||||
"<N> hosts allowed (host1, host2, host3, +M more)"."""
|
||||
hosts = pipelock_effective_allowlist(bottle)
|
||||
count = len(hosts)
|
||||
if count == 0:
|
||||
return "0 hosts allowed (none)"
|
||||
show = count
|
||||
more = 0
|
||||
if count > 5:
|
||||
show = 3
|
||||
more = count - show
|
||||
joined = ", ".join(hosts[:show])
|
||||
if more > 0:
|
||||
return f"{count} hosts allowed ({joined}, +{more} more)"
|
||||
return f"{count} hosts allowed ({joined})"
|
||||
|
||||
|
||||
|
||||
@@ -226,9 +177,9 @@ def pipelock_build_config(
|
||||
# Body-scan enforcement is a separate pipelock section (each DLP
|
||||
# "surface" — body, MCP, response — has its own action). Pipelock's
|
||||
# built-in default for request_body_scanning is "warn" (forward
|
||||
# with a log line); claude-bottle's default is "block" so a hit
|
||||
# with a log line); claude-bottle hard-codes "block" so a hit
|
||||
# actually stops the request from leaving the egress network.
|
||||
cfg["request_body_scanning"] = {"action": bottle.egress.dlp_action}
|
||||
cfg["request_body_scanning"] = {"action": "block"}
|
||||
if ca_cert_path or ca_key_path:
|
||||
if not (ca_cert_path and ca_key_path):
|
||||
raise ValueError(
|
||||
|
||||
Reference in New Issue
Block a user