feat(manifest)!: enforce cwd-manifest trust boundary (PRD 0011)
test / unit (pull_request) Successful in 13s
test / integration (pull_request) Successful in 22s

Splits `Manifest.resolve` into a two-phase load:

1. $HOME/claude-bottle.json parses under the full schema as today.
   This file owns bottle infrastructure (cred_proxy.routes, git,
   env, egress).
2. $CWD/claude-bottle.json parses under the new CwdExtension schema
   — agents-only. Any `bottles:` section dies at parse with a
   pointer at the home file. Each cwd agent's `bottle:` must
   resolve against a home-defined bottle name.

When CWD == HOME (running from $HOME directly) the resolver
short-circuits to home-only — no false trust-boundary error from
parsing the same file twice.

Closes the exfil vector documented in PRD 0011: a cloned repo's
claude-bottle.json can no longer redefine cred-proxy routes, and
therefore can't redirect $CLAUDE_BOTTLE_OAUTH_TOKEN /
$GITHUB_TOKEN / etc. to an attacker-named upstream on first
launch.

Preflight surfaces the boundary positively: print() shows
`bottle: <name>  (from $HOME/claude-bottle.json)`, and to_dict
emits `"bottle_source": "home"`. README + the existing dry-run
integration test pick that up.

BREAKING: existing cwd manifests that define bottles now fail.
The error message names the file path, the offending field, and
the fix ("move bottles section to $HOME/claude-bottle.json").

Tests:
- tests/unit/test_manifest_trust_boundary.py — 10 cases covering
  PRD 0011's success criteria (bottles rejected, agents allowed,
  cwd overrides agent fields, no silent fallback, home-only
  unchanged, etc.).
- tests/integration/test_dry_run_plan.py picks up the
  bottle_source assertion.
This commit is contained in:
2026-05-24 15:22:58 -04:00
parent 579a9dae3e
commit ccfdb141dd
5 changed files with 438 additions and 35 deletions
+11 -1
View File
@@ -7,6 +7,7 @@ further resolution; show_plan-style rendering is the `print` method.
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
@@ -95,7 +96,12 @@ class DockerBottlePlan(BottlePlan):
info("env (names only): " + (", ".join(v.env_names) if v.env_names else "(none)"))
info("skills : " + (" ".join(v.agent.skills) if v.agent.skills else "(none)"))
info(f"docker runtime : {runtime_label}")
info(f"bottle : {v.agent.bottle}")
# PRD 0011: the bottle definition always comes from $HOME — the
# cwd manifest can't define bottles. The label is a positive
# signal to the operator that no infrastructure was supplied by
# the cwd.
home_path = f"{os.environ['HOME']}/claude-bottle.json"
info(f"bottle : {v.agent.bottle} (from {home_path})")
if v.git_names:
info(f" git remotes : {', '.join(v.git_names)}")
git_lines = [
@@ -129,6 +135,10 @@ class DockerBottlePlan(BottlePlan):
return {
"agent": self.spec.agent_name,
"bottle": v.agent.bottle,
# PRD 0011: bottles can only come from $HOME. This field is
# a positive audit signal for machine consumers (CI, dry-run
# parsers) that the bottle wasn't supplied from cwd.
"bottle_source": "home",
"container_name": self.container_name,
"image": self.image,
"derived_image": self.derived_image,
+121 -28
View File
@@ -1,7 +1,11 @@
"""Manifest dataclasses. Read claude-bottle.json (cwd + $HOME, deep-merged)
into a frozen, validated Manifest tree.
"""Manifest dataclasses. `Manifest.resolve` does a two-phase load
(PRD 0011 trust boundary): `$HOME/claude-bottle.json` parses under
the full schema (bottles + agents); `$CWD/claude-bottle.json` parses
under the narrower CwdExtension schema (agents only, referencing
home-defined bottles). The cwd file may not define bottles — any
`bottles:` section there dies with the trust-boundary message.
Schema (see CLAUDE.md "Intended design"):
Home schema:
{
"bottles": {
"<bottle-name>": {
@@ -20,12 +24,17 @@ Schema (see CLAUDE.md "Intended design"):
}
}
Bottles group shared infrastructure (git upstreams + their gate credentials,
egress allowlist) that multiple agents can reference. Every agent must
reference a bottle.
Cwd schema (PRD 0011): only the `agents:` section above. The
`bottle:` field on each cwd agent must resolve against a name in
the home manifest's `bottles:` set.
Validation runs once at construction (Manifest.from_json_obj) so getters
can trust the shape.
Bottles group shared infrastructure (git upstreams + their gate
credentials, egress allowlist, cred-proxy routes) that multiple
agents can reference. Every agent must reference a bottle.
Validation runs once at construction (Manifest.from_json_obj for
home, CwdExtension.from_json_obj for cwd) so getters can trust the
shape.
"""
from __future__ import annotations
@@ -436,6 +445,54 @@ class Agent:
return cls(bottle=bottle, skills=skills, prompt=prompt)
@dataclass(frozen=True)
class CwdExtension:
"""The parsed cwd manifest, after PRD 0011's trust-boundary check.
Carries only agents — bottles cannot come from the cwd file.
Each agent's `bottle:` has already been validated against the
home manifest's bottle names at parse time, so callers can treat
`agents` as a drop-in replacement-or-addition to the home
manifest's agent dict (cwd entries override home entries on
name collision)."""
agents: dict[str, Agent]
@classmethod
def from_json_obj(
cls,
obj: object,
home: "Manifest",
*,
cwd_file: Path,
) -> "CwdExtension":
"""Parse the cwd file under the narrower schema. Dies if the
document contains a `bottles:` section; the cwd manifest can
only declare agents that reference home-defined bottles."""
d = _as_json_object(obj, f"manifest at {cwd_file}")
if "bottles" in d:
die(
f"manifest at {cwd_file} defines bottles. Bottle "
f"infrastructure (cred_proxy.routes, git, env, egress) "
f"must live in {os.environ['HOME']}/claude-bottle.json "
f"only — the cwd file can declare agents that reference "
f"home-defined bottles, but cannot define or modify the "
f"bottles themselves. Move the bottles section to "
f"{os.environ['HOME']}/claude-bottle.json, then keep "
f"only the agents section in this file. "
f"See docs/prds/0011-cwd-manifest-trust-boundary.md."
)
raw_agents = _section_dict(
d.get("agents"), f"manifest at {cwd_file} 'agents'"
)
bottle_names = set(home.bottles.keys())
agents: dict[str, Agent] = {
n: Agent.from_dict(n, a, bottle_names) for n, a in raw_agents.items()
}
return cls(agents=agents)
@dataclass(frozen=True)
class Manifest:
bottles: Mapping[str, Bottle]
@@ -443,35 +500,64 @@ class Manifest:
@classmethod
def resolve(cls, cwd: str) -> "Manifest":
"""Look for claude-bottle.json in <cwd> and in $HOME, deep-merge
them (cwd entries override home entries on key conflict for both
bottles and agents), then validate. Dies if neither file is
found, either is invalid JSON, or the merged shape violates the
schema."""
"""Two-phase load (PRD 0011):
1. `$HOME/claude-bottle.json` parses under the full schema
(bottles + agents). This is the trusted, operator-owned
file — it defines bottle infrastructure (cred_proxy.routes,
git, env, egress) and any home-resident agents.
2. `$CWD/claude-bottle.json` parses under the cwd schema
(`CwdExtension`): agents-only; presence of a `bottles:`
section dies with the trust-boundary message. Each cwd
agent's `bottle:` must resolve against home-defined names.
Cwd agents merge into home agents, overriding on name
collision.
Dies if neither file is found, either is invalid JSON, or
either side fails validation."""
cwd_file = Path(cwd) / "claude-bottle.json"
home_file = Path(os.environ["HOME"]) / "claude-bottle.json"
cwd_doc = _load_json_or_die(cwd_file) if cwd_file.is_file() else None
home_doc = _load_json_or_die(home_file) if home_file.is_file() else None
# When the user runs claude-bottle from inside $HOME the two
# paths resolve to the same file. Parse it once as the home
# manifest and skip the cwd phase — the trust boundary is
# there to protect against a *different* manifest at cwd.
same_file = (
cwd_file.is_file()
and home_file.is_file()
and cwd_file.resolve() == home_file.resolve()
)
if cwd_doc is None and home_doc is None:
home_doc = _load_json_or_die(home_file) if home_file.is_file() else None
cwd_doc = (
_load_json_or_die(cwd_file)
if cwd_file.is_file() and not same_file
else None
)
if home_doc is None and cwd_doc is None:
die(f"no claude-bottle.json found in {cwd} or {os.environ['HOME']}")
h: dict[str, object] = home_doc if home_doc is not None else {}
c: dict[str, object] = cwd_doc if cwd_doc is not None else {}
h_bottles = _section_dict(h.get("bottles"), "bottles")
c_bottles = _section_dict(c.get("bottles"), "bottles")
h_agents = _section_dict(h.get("agents"), "agents")
c_agents = _section_dict(c.get("agents"), "agents")
merged: dict[str, object] = {
"bottles": {**h_bottles, **c_bottles},
"agents": {**h_agents, **c_agents},
}
return cls.from_json_obj(merged)
home = (
cls.from_json_obj(home_doc)
if home_doc is not None
else cls(bottles={}, agents={})
)
if cwd_doc is None:
return home
ext = CwdExtension.from_json_obj(cwd_doc, home, cwd_file=cwd_file)
return home._extend(ext)
@classmethod
def from_json_obj(cls, obj: object) -> "Manifest":
"""Validate and build a Manifest from a raw JSON-like dict."""
"""Validate and build a Manifest from a raw JSON-like dict.
This is the full-schema parser — used for the home file and
for tests that build a Manifest directly. The cwd file goes
through `CwdExtension.from_json_obj` and a narrower schema;
see `Manifest.resolve`."""
d = _as_json_object(obj, "manifest")
raw_bottles = _section_dict(d.get("bottles"), "manifest 'bottles'")
raw_agents = _section_dict(d.get("agents"), "manifest 'agents'")
@@ -485,6 +571,13 @@ class Manifest:
}
return cls(bottles=bottles, agents=agents)
def _extend(self, ext: "CwdExtension") -> "Manifest":
"""Merge a CwdExtension into this (home) manifest. Cwd agents
override home agents on name collision; bottles are unchanged
(the trust boundary forbids cwd-defined bottles)."""
merged_agents: dict[str, Agent] = {**self.agents, **ext.agents}
return Manifest(bottles=self.bottles, agents=merged_agents)
def has_agent(self, name: str) -> bool:
return name in self.agents