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>
9.3 KiB
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.remotesIdentityFile / token.user.name/user.emailgrant zero access. - It's already forgeable. The agent inside the bottle can run
git config user.email <anything>orgit 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.userblock to the agent frontmatter schema (name and/or email), reusing the existingGitUservalidator. - An agent's declared
git.useroverlays the referenced bottle'sgit.userper-field; each non-empty agent field wins, empties fall through to the bottle. Identical overlay semantics to the bottleextends:merge (PRD 0025). git.remotesin 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.useris 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.userand nothing else; agents do not gainegress,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:
---
bottle: claude-dev
git:
user:
name: claude-implementer
email: eric+claude-implementer@dideric.is
---
You are a feature-implementation agent ...
git.useracceptsnameand/oremail(string-or-die; at least one non-empty), validated by the existingGitUser.from_dict.git.remotes(or anygitkey other thanuser) in an agent file dies at parse: "git.remotes is bottle-only".gitis added to_AGENT_KEYS; the agent md-loader threads the rawgitblock into the dictAgent.from_dictconsumes.
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
- PRD (this commit). Sets the design.
- Schema + overlay + tests.
- Add
"git"to_AGENT_KEYS. - Add
git_user: GitUser = GitUser()toAgent; parse it inAgent.from_dict, reusingGitUser.from_dict; reject anygitkey other thanuserwith a bottle-only message. - Thread
fm.get("git")through_load_agents_from_dir'sagent_dict. - Apply the per-field overlay in
Manifest.bottle_for(). - Add
Manifest.git_identity_summary()and print it in bothbottle_plan.pypreflights andcli/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.remotesdies; agent with nogitblock unchanged; bottle-only behavior preserved; provenance summary returns the right(agent)/(bottle)tags andNonewhen unset.
- Add
- Docs + example. README manifest section: note
git.useris allowed on agents and overlays the bottle;git.remotesstays bottle-only. Collapse the example —examples/agents/implementer.mdcarries its owngit.useragainst the shareddevbottle, 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 thatbottle_forleaves every non-git_userbottle field untouched. - No integration changes needed: provisioners consume the
already-merged
Bottleviabottle_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;gitis 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 ignoresgit), but worth noting. Out of scope.