From f9e3b6addab02eda3a9d624d9fe3b0d407b1e937 Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 28 May 2026 20:58:00 -0400 Subject: [PATCH] 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.