# PRD 0027: Agent-level git user identity - **Status:** Active - **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.