Files
bot-bottle/docs/prds/0027-agent-git-user-identity.md
T
didericis 47c3ba63f8
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 58s
test / integration (push) Successful in 54s
test / unit (push) Successful in 32s
docs(prd): mark merged PRDs as Active
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>
2026-05-28 22:12:03 -04:00

9.3 KiB

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:

---
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.