From f9e3b6addab02eda3a9d624d9fe3b0d407b1e937 Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 28 May 2026 20:58:00 -0400 Subject: [PATCH 1/3] docs(prd): add PRD 0027 agent-level git user identity Lift git.user (name/email) to the agent layer with a per-field overlay onto the referenced bottle, mirroring the extends: merge. git.remotes stays bottle-only. Includes identity provenance in preflight/info and an example collapse. Refs #94 Co-Authored-By: Claude Opus 4.8 --- docs/prds/0027-agent-git-user-identity.md | 226 ++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/prds/0027-agent-git-user-identity.md diff --git a/docs/prds/0027-agent-git-user-identity.md b/docs/prds/0027-agent-git-user-identity.md new file mode 100644 index 0000000..7798259 --- /dev/null +++ b/docs/prds/0027-agent-git-user-identity.md @@ -0,0 +1,226 @@ +# PRD 0027: Agent-level git user identity + +- **Status:** Draft +- **Author:** didericis +- **Created:** 2026-05-28 +- **Issue:** #94 + +## Summary + +Let an **agent** file declare `git.user` (name / email). At launch +the agent's `git.user` overlays the referenced bottle's `git.user` +per-field (agent wins on non-empty fields), mirroring the +`extends:` overlay from PRD 0025. `git.remotes` stays bottle-only. + +Solves the "I need a whole separate bottle just to change the commit +name" coupling: today commit attribution — a purpose/presentation +concern — can only be varied by authoring a new security boundary +(a bottle). After this change, two agents (e.g. `implementer` and +`reviewer`) can share one bottle and still commit under distinct +identities. + +## Problem + +`git.user.name` / `git.user.email` is a bottle-only field. Agent +frontmatter is validated against a strict allowlist (`_AGENT_KEYS` +in `manifest.py`); a `git:` block in an agent file dies at parse +with "unknown frontmatter key(s)". So the only way to give an agent +a distinct commit identity is to give it a distinct *bottle*. + +That couples identity to the trust boundary. Bottles are home-only +and define security (egress, credentials, provider). Forcing a new +bottle per commit-name means either (a) duplicating a bottle just to +flip one string, or (b) leaning on PRD 0025 `extends:` to make a +near-clone whose only delta is `git.user`. Both are heavier than the +concern deserves. + +## Why this is safe (trust analysis) + +Allowing agent files — which can live in `$CWD/.bot-bottle/agents/` +and therefore be supplied by a cloned repo — to set `git.user` does +**not** weaken the security model, because git author identity is +not a credential or a capability: + +- **Push auth is separate.** Authentication to a remote is the + bottle's `git.remotes` IdentityFile / token. `user.name` / + `user.email` grant zero access. +- **It's already forgeable.** The agent inside the bottle can run + `git config user.email ` or `git commit --author=...` + at runtime regardless of the manifest. The manifest field is only + a *default*; allowing agents to set it adds no capability that + isn't already reachable one layer down. +- **Authorship was never a trust anchor.** If attribution integrity + matters, that is a commit-*signing* concern (SSH/GPG), which a + name/email field cannot provide either way. + +The one residual is cosmetic impersonation (a cloned repo's agent +file could set an identity that reads like a real person's). Commits +still push under the bottle's credentials, and the author string was +never vouched-for, so this is presentation, not escalation. We +document that an agent identity is *claimed, not vouched*. + +`git.remotes` is explicitly **not** lifted to the agent layer — that +block carries credentials and host trust (IdentityFile, KnownHostKey) +and stays a bottle-only, home-only concern. + +## Goals / Success Criteria + +- Add an optional `git.user` block to the agent frontmatter schema + (name and/or email), reusing the existing `GitUser` validator. +- An agent's declared `git.user` overlays the referenced bottle's + `git.user` per-field; each non-empty agent field wins, empties + fall through to the bottle. Identical overlay semantics to the + bottle `extends:` merge (PRD 0025). +- `git.remotes` in an agent file is rejected at parse with a clear + "bottle-only" pointer. +- The overlay is applied at `Manifest.bottle_for()` — the single + point both backends call to resolve an agent's bottle — so the + docker and smolmachines provisioners need no changes. +- Existing agent files continue to parse identically — `git.user` + is opt-in. +- **Identity provenance is surfaced.** The y/N preflight (both + backends) and `cli.py info ` print the effective git + identity with a per-field `(agent)` / `(bottle)` annotation so the + operator can see which level each field came from. +- **The example collapses to demonstrate the pattern.** The bundled + example replaces the identity-only-bottle shape with one shared + bottle + per-agent `git.user`, showing the intended usage. + +## Non-goals + +- **No agent-level `git.remotes`.** Credentials and host trust stay + bottle-only and home-only. +- **No other agent-level bottle fields.** This PRD lifts `git.user` + and nothing else; agents do not gain `egress`, `env`, + `agent_provider`, etc. (that is the issue #88 design PRD 0025 + deliberately rejected). +- **No commit signing.** Attribution integrity via SSH/GPG is a + separate concern, out of scope here. +- **No CWD-vs-HOME identity gating.** Because the field is + non-enforcing (see trust analysis), CWD and HOME agents are + treated the same. If a future change makes identity load-bearing, + revisit. + +## Design + +### Schema + +A new optional `git:` block on agent files, carrying only `user`: + +```yaml +--- +bottle: claude-dev +git: + user: + name: claude-implementer + email: eric+claude-implementer@dideric.is +--- + +You are a feature-implementation agent ... +``` + +- `git.user` accepts `name` and/or `email` (string-or-die; at least + one non-empty), validated by the **existing** `GitUser.from_dict`. +- `git.remotes` (or any `git` key other than `user`) in an agent + file dies at parse: "git.remotes is bottle-only". +- `git` is added to `_AGENT_KEYS`; the agent md-loader threads the + raw `git` block into the dict `Agent.from_dict` consumes. + +### Merge rule + +When `Manifest.bottle_for(agent)` resolves the agent's bottle, it +returns the bottle with `git_user` overlaid by the agent's +`git_user`: + +| Field | Merge | +|-------------------|------------------------------------------------| +| `git_user.name` | agent's if non-empty, else bottle's | +| `git_user.email` | agent's if non-empty, else bottle's | + +This is the same per-field overlay `_merge_bottles` already applies +for `extends:` (`manifest.py:1212`). Empty string = "not set", the +same predicate the provisioner's `is_empty()` uses. All other bottle +fields are returned unchanged. + +### Where the overlay lives + +``` +def bottle_for(self, agent_name) -> Bottle: + agent = self.agents[agent_name] + bottle = self.bottles[agent.bottle] + if agent.git_user.is_empty(): + return bottle + merged = GitUser( + name=agent.git_user.name or bottle.git_user.name, + email=agent.git_user.email or bottle.git_user.email, + ) + return dataclasses.replace(bottle, git_user=merged) +``` + +Both `provision/git.py` paths (docker + smolmachines) already call +`bottle_for(agent_name)` and read `bottle.git_user`, so they pick up +the merged identity with no edits. The dashboard / `info` / preflight +surfaces that print bottle config go through the same resolution. + +### Provenance display + +A sibling `Manifest.git_identity_summary(agent_name) -> str | None` +returns the effective identity annotated per field, e.g.: + +``` +identity : name=claude-implementer (agent), email=eric+claude-implementer@dideric.is (bottle) +``` + +`(agent)` when the agent's `git.user` supplied that field, `(bottle)` +when it fell through to the referenced bottle. Returns `None` when no +identity is set at either level (callers omit the line). Both +`bottle_plan.py` preflights and `cli/info.py` print it; `info` today +prints `git remotes` but not the identity, so this adds the line. + +### `Agent` dataclass + +`Agent` gains `git_user: GitUser = GitUser()`. `Agent.from_dict` +parses the optional `git.user` and rejects non-`user` git keys. + +## Implementation chunks + +1. **PRD (this commit).** Sets the design. +2. **Schema + overlay + tests.** + - Add `"git"` to `_AGENT_KEYS`. + - Add `git_user: GitUser = GitUser()` to `Agent`; parse it in + `Agent.from_dict`, reusing `GitUser.from_dict`; reject any + `git` key other than `user` with a bottle-only message. + - Thread `fm.get("git")` through `_load_agents_from_dir`'s + `agent_dict`. + - Apply the per-field overlay in `Manifest.bottle_for()`. + - Add `Manifest.git_identity_summary()` and print it in both + `bottle_plan.py` preflights and `cli/info.py`. + - Unit tests: agent name+email overlay; agent name-only (email + falls through to bottle); agent email-only; agent identity with + a bottle that declares none; agent `git.remotes` dies; agent + with no `git` block unchanged; bottle-only behavior preserved; + provenance summary returns the right `(agent)`/`(bottle)` tags + and `None` when unset. +3. **Docs + example.** README manifest section: note `git.user` is + allowed on agents and overlays the bottle; `git.remotes` stays + bottle-only. Collapse the example — `examples/agents/implementer.md` + carries its own `git.user` against the shared `dev` bottle, + demonstrating per-agent identity without an identity-only bottle. + +## Testing strategy + +- **Unit (must):** the overlay matrix above, the parse-time reject + for agent `git.remotes`, and confirmation that `bottle_for` leaves + every non-`git_user` bottle field untouched. +- **No integration changes needed:** provisioners consume the + already-merged `Bottle` via `bottle_for`; existing docker / + smolmachines git-provisioning tests cover the consumption path. + +## Open questions + +- **Should a `git.user`-declaring agent be loadable as a Claude Code + subagent file too?** The CC-passthrough keys (`name`, `model`, …) + let one file double as `~/.claude/agents/*.md`; `git` is not a CC + key, so a file carrying it won't round-trip as a CC subagent. Not a + blocker (bot-bottle ignores unknown CC keys; CC ignores `git`), but + worth noting. Out of scope. -- 2.52.0 From 0708e99e4e740aae41eb43e4bf3620265e15c08d Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 28 May 2026 21:10:47 -0400 Subject: [PATCH 2/3] feat(manifest): lift git.user to the agent layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents may declare git.user (name/email); it overlays the referenced bottle's git.user per-field at Manifest.bottle_for (agent wins on non-empty), mirroring the extends: merge. git.remotes is rejected on agents — it carries credentials and host trust and stays bottle-only. The overlay lives at bottle_for, the single chokepoint both backends use, so the docker/smolmachines git provisioners are unchanged. Adds Manifest.git_identity_summary with per-field (agent)/(bottle) provenance, printed in both preflights and `info`. Refs #94 Co-Authored-By: Claude Opus 4.8 --- bot_bottle/backend/docker/bottle_plan.py | 4 + .../backend/smolmachines/bottle_plan.py | 3 + bot_bottle/cli/info.py | 3 + bot_bottle/manifest.py | 80 +++++- tests/unit/test_manifest_agent_git_user.py | 248 ++++++++++++++++++ 5 files changed, 330 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_manifest_agent_git_user.py diff --git a/bot_bottle/backend/docker/bottle_plan.py b/bot_bottle/backend/docker/bottle_plan.py index 2a65c74..8e49c07 100644 --- a/bot_bottle/backend/docker/bottle_plan.py +++ b/bot_bottle/backend/docker/bottle_plan.py @@ -85,6 +85,10 @@ class DockerBottlePlan(BottlePlan): print_multi("skills ", list(agent.skills)) info(f"bottle : {agent.bottle}") + identity = manifest.git_identity_summary(spec.agent_name) + if identity: + info(f" git identity : {identity}") + git_lines = [ f"{u.upstream_host}:{u.upstream_port}" for u in self.git_gate_plan.upstreams diff --git a/bot_bottle/backend/smolmachines/bottle_plan.py b/bot_bottle/backend/smolmachines/bottle_plan.py index 4af4214..e90714a 100644 --- a/bot_bottle/backend/smolmachines/bottle_plan.py +++ b/bot_bottle/backend/smolmachines/bottle_plan.py @@ -125,6 +125,9 @@ class SmolmachinesBottlePlan(BottlePlan): print_multi("env ", env_names) print_multi("skills ", list(agent.skills)) info(f"bottle : {agent.bottle}") + identity = manifest.git_identity_summary(spec.agent_name) + if identity: + info(f" git identity : {identity}") if upstreams: print_multi(" git gate ", upstreams) if routes: diff --git a/bot_bottle/cli/info.py b/bot_bottle/cli/info.py index db74464..9db0faf 100644 --- a/bot_bottle/cli/info.py +++ b/bot_bottle/cli/info.py @@ -31,6 +31,9 @@ def cmd_info(argv: list[str]) -> int: f"first line: {prompt_first_line or '(empty)'}" ) info(f"bottle : {agent.bottle}") + identity = manifest.git_identity_summary(args.name) + if identity: + info(f" git identity : {identity}") if bottle.git: for e in bottle.git: info( diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index fb5d776..430ce17 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -47,7 +47,7 @@ import ipaddress import json import os import re -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Mapping, cast @@ -692,6 +692,11 @@ class Agent: bottle: str skills: tuple[str, ...] = () prompt: str = "" + # Per-agent git identity (issue #94). Overlays the referenced + # bottle's git.user per-field at `Manifest.bottle_for`. Only the + # `user` block is allowed at the agent level; `git.remotes` stays + # bottle-only because it carries credentials and host trust. + git_user: GitUser = GitUser() @classmethod def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent": @@ -731,7 +736,25 @@ class Agent: else: die(f"agent '{name}' prompt must be a string (was {type(prompt_raw).__name__})") - return cls(bottle=bottle, skills=skills, prompt=prompt) + # git: agents may declare only `git.user` (name/email). Any + # other git key — notably `remotes` — is rejected: remotes + # carry credentials and host trust and stay bottle-only. + git_user = GitUser() + git_raw = d.get("git") + if git_raw is not None: + gd = _as_json_object(git_raw, f"agent '{name}' git") + for k in gd.keys(): + if k != "user": + die( + f"agent '{name}' git.{k} is not allowed at the " + f"agent level; only git.user (name/email) may be " + f"set on an agent. git.remotes is bottle-only " + f"(it carries credentials and host trust)." + ) + if "user" in gd: + git_user = GitUser.from_dict(name, gd["user"]) + + return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user) @dataclass(frozen=True) @@ -874,11 +897,50 @@ class Manifest: ) die(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).") + def _effective_git_user(self, agent_name: str) -> GitUser: + """Merge the agent's git.user over the referenced bottle's, + per-field, agent-wins-on-non-empty (issue #94). Same overlay + the `extends:` resolver applies between bottles + (`_merge_bottles`).""" + agent = self.agents[agent_name] + base = self.bottles[agent.bottle].git_user + over = agent.git_user + if over.is_empty(): + return base + return GitUser( + name=over.name or base.name, + email=over.email or base.email, + ) + def bottle_for(self, agent_name: str) -> Bottle: - """Resolve the Bottle the named agent references. The validator - guarantees both lookups succeed for a manifest built via - from_json_obj.""" - return self.bottles[self.agents[agent_name].bottle] + """Resolve the Bottle the named agent references, with the + agent's git.user overlaid on top. The validator guarantees both + lookups succeed for a manifest built via from_json_obj. + + The overlay lives here, the single point both backends call to + resolve an agent's bottle, so the docker / smolmachines git + provisioners pick up the merged identity unchanged.""" + bottle = self.bottles[self.agents[agent_name].bottle] + merged = self._effective_git_user(agent_name) + if merged == bottle.git_user: + return bottle + return replace(bottle, git_user=merged) + + def git_identity_summary(self, agent_name: str) -> str | None: + """One-line effective git identity with per-field provenance + for launch summaries, e.g. + `name=claude (agent), email=eric@dideric.is (bottle)`. + Returns None when neither agent nor bottle sets an identity.""" + over = self.agents[agent_name].git_user + merged = self._effective_git_user(agent_name) + if merged.is_empty(): + return None + parts: list[str] = [] + if merged.name: + parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})") + if merged.email: + parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})") + return ", ".join(parts) def _as_json_object(value: object, label: str) -> dict[str, object]: @@ -1053,7 +1115,7 @@ _BOTTLE_KEYS = frozenset( {"env", "extends", "agent_provider", "git", "egress", "supervise"} ) _AGENT_KEYS_REQUIRED = frozenset({"bottle"}) -_AGENT_KEYS_OPTIONAL = frozenset({"skills"}) +_AGENT_KEYS_OPTIONAL = frozenset({"skills", "git"}) # Claude Code subagent fields bot-bottle ignores at launch but # doesn't reject — lets the same file double as `~/.claude/agents/*.md`. _AGENT_KEYS_CC_PASSTHROUGH = frozenset({ @@ -1301,11 +1363,13 @@ def _load_agents_from_dir( ) # Build the dict Agent.from_dict expects. The body becomes # prompt; CC passthrough fields stay in fm and get ignored - # by from_dict (which only reads bottle/skills/prompt). + # by from_dict (which reads bottle/skills/git/prompt). agent_dict: dict[str, object] = { "bottle": fm.get("bottle"), "skills": fm.get("skills", []), "prompt": body.strip(), } + if "git" in fm: + agent_dict["git"] = fm["git"] out[name] = Agent.from_dict(name, agent_dict, bottle_names) return out diff --git a/tests/unit/test_manifest_agent_git_user.py b/tests/unit/test_manifest_agent_git_user.py new file mode 100644 index 0000000..887b161 --- /dev/null +++ b/tests/unit/test_manifest_agent_git_user.py @@ -0,0 +1,248 @@ +"""Unit: agent-level git.user overlay + provenance (PRD 0027, issue #94). + +An agent file may declare `git.user` (name/email). At +`Manifest.bottle_for()` it overlays the referenced bottle's +`git.user` per-field, agent-wins-on-non-empty. `git.remotes` is +rejected on agents. `Manifest.git_identity_summary()` reports the +effective identity with per-field `(agent)`/`(bottle)` provenance. + +The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`; +a temp-dir case locks the md loader (the `_AGENT_KEYS` allow + the +`git` threading into `agent_dict`).""" + +from __future__ import annotations + +import contextlib +import io +import os +import shutil +import tempfile +import textwrap +import unittest +from pathlib import Path + +from bot_bottle.log import Die +from bot_bottle.manifest import Manifest + + +def _die_message(callable_, *args, **kwargs) -> str: + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + try: + callable_(*args, **kwargs) + except Die: + return buf.getvalue() + raise AssertionError("expected Die was not raised") + + +def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: + bottle: dict = {} + if bottle_user is not None: + bottle = {"git": {"user": bottle_user}} + agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} + if agent_git is not None: + agent["git"] = agent_git + return Manifest.from_json_obj({ + "bottles": {"dev": bottle}, + "agents": {"impl": agent}, + }) + + +class TestAgentGitUserOverlay(unittest.TestCase): + def test_agent_supplies_both_fields(self): + m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}}) + u = m.bottle_for("impl").git_user + self.assertEqual("a", u.name) + self.assertEqual("a@b", u.email) + + def test_agent_name_only_email_falls_through_to_bottle(self): + m = _manifest( + bottle_user={"name": "B", "email": "b@c"}, + agent_git={"user": {"name": "a"}}, + ) + u = m.bottle_for("impl").git_user + self.assertEqual("a", u.name) # agent wins + self.assertEqual("b@c", u.email) # bottle falls through + + def test_agent_email_only_name_falls_through_to_bottle(self): + m = _manifest( + bottle_user={"name": "B", "email": "b@c"}, + agent_git={"user": {"email": "a@b"}}, + ) + u = m.bottle_for("impl").git_user + self.assertEqual("B", u.name) + self.assertEqual("a@b", u.email) + + def test_agent_identity_with_bottle_declaring_none(self): + m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}}) + # The underlying bottle declares no identity; the merged one does. + self.assertTrue(m.bottles["dev"].git_user.is_empty()) + self.assertFalse(m.bottle_for("impl").git_user.is_empty()) + + def test_bottle_only_identity_preserved_when_agent_silent(self): + m = _manifest(bottle_user={"name": "B", "email": "b@c"}) + u = m.bottle_for("impl").git_user + self.assertEqual("B", u.name) + self.assertEqual("b@c", u.email) + + def test_bottle_for_returns_same_instance_when_no_overlay(self): + # No agent git.user → no replace(); the cached Bottle is + # returned as-is (identity check guards against churn). + m = _manifest(bottle_user={"name": "B"}) + self.assertIs(m.bottles["dev"], m.bottle_for("impl")) + + def test_bottle_for_returns_same_instance_when_overlay_is_noop(self): + # Agent restates exactly what the bottle already has → merged + # == bottle.git_user → same instance, no replace(). + m = _manifest( + bottle_user={"name": "B", "email": "b@c"}, + agent_git={"user": {"name": "B", "email": "b@c"}}, + ) + self.assertIs(m.bottles["dev"], m.bottle_for("impl")) + + def test_other_bottle_fields_untouched_by_overlay(self): + m = Manifest.from_json_obj({ + "bottles": {"dev": { + "env": {"FOO": "bar"}, + "supervise": True, + "git": {"user": {"name": "B"}}, + }}, + "agents": {"impl": { + "bottle": "dev", "skills": [], "prompt": "", + "git": {"user": {"name": "a"}}, + }}, + }) + b = m.bottle_for("impl") + self.assertEqual("a", b.git_user.name) + self.assertEqual({"FOO": "bar"}, dict(b.env)) + self.assertTrue(b.supervise) + + +class TestAgentGitUserRejections(unittest.TestCase): + def test_agent_remotes_dies_bottle_only(self): + msg = _die_message(_manifest, agent_git={ + "remotes": {"h": {"Name": "r", "Upstream": "ssh://x/y.git"}}, + }) + self.assertIn("git.remotes", msg) + self.assertIn("bottle-only", msg) + + def test_agent_unknown_git_subkey_dies(self): + msg = _die_message(_manifest, agent_git={"nope": {}}) + self.assertIn("not allowed at the agent level", msg) + + def test_agent_git_user_both_empty_dies(self): + # Reuses GitUser.from_dict validation. + msg = _die_message(_manifest, agent_git={"user": {"name": "", "email": ""}}) + self.assertIn("neither name nor email", msg) + + +class TestGitIdentitySummary(unittest.TestCase): + def test_both_from_agent(self): + m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}}) + self.assertEqual( + "name=a (agent), email=a@b (agent)", + m.git_identity_summary("impl"), + ) + + def test_mixed_provenance(self): + m = _manifest( + bottle_user={"name": "B", "email": "b@c"}, + agent_git={"user": {"name": "a"}}, + ) + self.assertEqual( + "name=a (agent), email=b@c (bottle)", + m.git_identity_summary("impl"), + ) + + def test_bottle_only(self): + m = _manifest(bottle_user={"name": "B", "email": "b@c"}) + self.assertEqual( + "name=B (bottle), email=b@c (bottle)", + m.git_identity_summary("impl"), + ) + + def test_none_when_unset_anywhere(self): + m = _manifest() + self.assertIsNone(m.git_identity_summary("impl")) + + +_BOTTLE_DEV = """ + --- + git: + user: + name: bottle-name + email: bottle@example.com + --- + + dev bottle. +""" + +_AGENT_WITH_GIT = """ + --- + bottle: dev + git: + user: + name: agent-name + --- + + impl agent. +""" + +_AGENT_WITH_REMOTES = """ + --- + bottle: dev + git: + remotes: + h: + Name: r + Upstream: ssh://x/y.git + --- + + bad agent. +""" + + +class TestAgentGitUserMdLoader(unittest.TestCase): + """Locks the md path: `git` is an accepted agent key and threads + into the parsed Agent (not rejected as an unknown frontmatter + key), and agent `git.remotes` dies through the same loader.""" + + def setUp(self) -> None: + self.home = Path(tempfile.mkdtemp(prefix="cb-home-")) + self._orig_home = os.environ.get("HOME") + os.environ["HOME"] = str(self.home) + + def tearDown(self) -> None: + if self._orig_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._orig_home + shutil.rmtree(self.home, ignore_errors=True) + + def _write(self, rel: str, text: str) -> None: + p = self.home / ".bot-bottle" / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(textwrap.dedent(text).lstrip("\n")) + + def test_md_agent_git_user_overlays_bottle(self): + self._write("bottles/dev.md", _BOTTLE_DEV) + self._write("agents/impl.md", _AGENT_WITH_GIT) + m = Manifest.resolve(str(self.home)) + u = m.bottle_for("impl").git_user + self.assertEqual("agent-name", u.name) # agent wins + self.assertEqual("bottle@example.com", u.email) # bottle falls through + self.assertEqual( + "name=agent-name (agent), email=bottle@example.com (bottle)", + m.git_identity_summary("impl"), + ) + + def test_md_agent_remotes_dies(self): + self._write("bottles/dev.md", _BOTTLE_DEV) + self._write("agents/impl.md", _AGENT_WITH_REMOTES) + msg = _die_message(Manifest.resolve, str(self.home)) + self.assertIn("git.remotes", msg) + self.assertIn("bottle-only", msg) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0 From dcd90cd45e17ef65a862b8f710878db70a61d09c Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 28 May 2026 21:10:47 -0400 Subject: [PATCH 3/3] docs(manifest): document + demo agent-level git.user README manifest section documents the agent git.user overlay, the bottle-only git.remotes boundary, and the claimed-not-vouched trust note. Collapses the example: implementer carries its own identity against the shared dev bottle instead of an identity-only bottle. Refs #94 Co-Authored-By: Claude Opus 4.8 --- README.md | 17 +++++++++++++++++ examples/agents/implementer.md | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/README.md b/README.md index a5d76b2..528f533 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,10 @@ Dockerfile while keeping the bot-bottle sidecars in place. bottle: gitea-dev skills: - init-prd +git: + user: + name: gitea-helper + email: eric+gitea-helper@dideric.is --- You help maintain Gitea-hosted projects. @@ -375,6 +379,19 @@ frontmatter — bot-bottle ignores them at launch but doesn't reject them, so the same file can drop into `~/.claude/agents/` as a Claude Code subagent. +An agent may also declare `git.user` (`name` / `email`). It overlays +the referenced bottle's `git.user` per-field — the agent's non-empty +fields win, the rest fall through to the bottle — so two agents can +share one bottle and still commit under distinct identities without +an identity-only bottle (PRD 0027). Only `git.user` is allowed at the +agent level; `git.remotes` stays bottle-only because it carries +credentials and host trust. The launch preflight and `cli.py info` +print the effective identity annotated `(agent)` / `(bottle)` so you +can see where each field came from. Git authorship is not a +credential — push auth is the bottle's remote key/token — so a +repo-shipped agent setting its own identity grants no access; treat +an agent identity as *claimed, not vouched*. + Unknown top-level frontmatter keys die at load with a "did you mean" pointer; typos don't silently ghost into an empty config. diff --git a/examples/agents/implementer.md b/examples/agents/implementer.md index fd6360f..13df974 100644 --- a/examples/agents/implementer.md +++ b/examples/agents/implementer.md @@ -5,6 +5,10 @@ model: opus bottle: dev skills: - init-prd +git: + user: + name: implementer-bot + email: eric+implementer@dideric.is --- You are a feature-implementation agent running inside an ephemeral -- 2.52.0