feat(manifest)!: enforce cwd-manifest trust boundary (PRD 0011)
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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user