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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <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.
|
||||
Reference in New Issue
Block a user