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,
|
||||
|
||||
Reference in New Issue
Block a user