47c3ba63f8
Flip Status: Draft -> Active for the 23 PRDs whose work has shipped to main (including 0027, now that PR #95 has merged). Leaves the terminal-status PRDs unchanged: 0007 and 0010 (Superseded) and 0014 (Retargeted) were replaced, not shipped as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
227 lines
9.3 KiB
Markdown
227 lines
9.3 KiB
Markdown
# 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 <anything>` 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 <agent>` 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.
|