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,