Files
platform/docs/agent-git-identity.md
T
pastilhas 027b10bd6e step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
2026-08-15 16:31:11 +00:00

9.0 KiB

Per-agent git identity

Status: idea, not implemented. Nothing in this document has been built. Written 2026-08-10 from a read of the live spawn path; the file:line references were verified against dc6b623.

The goal

A team of agents works on this project, sometimes several of them in the same repository at once. Each one should commit under its own identity, so git log answers "which agent wrote this" without anybody having to remember to say so.

Today it cannot. Every agent commits as the owner, because every agent is the owner as far as the OS is concerned.

How git identity can be overridden at all

Identity is unverified metadata, not authentication — a default, never a constraint. Anyone who can commit can claim any name and email, by any of:

git -c user.name=X -c user.email=x@y.z commit      # per-invocation config
git commit --author="X <x@y.z>"                     # author only; committer stays whoever ran it
GIT_AUTHOR_NAME=X GIT_AUTHOR_EMAIL=x@y.z \
GIT_COMMITTER_NAME=X GIT_COMMITTER_EMAIL=x@y.z git commit   # env; both identities

Precedence: --author > GIT_AUTHOR_* env > -c user.email > local config > global config.

Two consequences that shape the design below:

  • Author and committer are different fields. --author alone leaves the committer as the owner, and git log shows only the author by default. Set both, or the attribution is half-fiction. To read both: git log --format='%an <%ae> | %cn <%ce>'.
  • Environment beats instruction. Telling an agent "commit as X" in its prompt is a rule it can forget. GIT_AUTHOR_* in the process environment applies to every git invocation in that process whether or not anyone remembered. The whole point is to make the identity unforgettable rather than well-intentioned, so this belongs in the environment.

Why the obvious approach does not work here

The first instinct is a PM2 env block per agent, or setting process.env.GIT_AUTHOR_NAME in the sidecar at boot. Both are wrong on this platform, and for the same reason:

officer-agent is one process running many concurrent sessions. sessions is a Map<string, PersistentSession> (src/servers/sidecar/claude/claude-manager.ts:213); each session owns its own long-lived query() and its own warm claude child, reused across turns and collected after 30 minutes idle. A singleton lockfile enforces one sidecar per owner (user-instance.ts:95-98).

So a process-level variable — whether from PM2, from .env, or assigned in user-instance.ts — is shared by every agent and every session on the box. It can say "an agent did this". It cannot say which.

The ecosystem.config.cjs entry is bare anyway, which is worth recording since it looks like a place where env might already be happening:

{ name: 'officer-agent', script: 'bun', args: 'run src/servers/sidecar/claude/user-instance.ts', watch: false },

No env, no cwd, no interpreter — for any app in the file.

What the spawn path actually looks like

The live path is the Agent SDK, not Bun.spawn. claude-manager.ts:320-360:

const q = query({
  prompt: input.gen as AsyncIterable<SdkUserMessage>,
  options: {
    cwd: params.cwd ?? HOST_HOME,
    permissionMode: 'bypassPermissions',
    pathToClaudeCodeExecutable: CLAUDE_BIN,
    env: cleanEnv as Record<string, string>,
    ...
  },
});

cleanEnv is built once, at claude-manager.ts:315, by destructuring three keys back out of the sidecar's own environment:

const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env;

That is the whole story: the child gets the sidecar's full process.env minus the three nested-session guards, and nothing is added per turn.

This is the good news. env is already a per-query() option. It is built once today, but there is no structural reason it has to be — which makes claude-manager.ts:315 the single injection point for everything below.

What identity exists today

Almost none, and none of it at the OS level.

  • sessionKey — officer's uuid, the key in the sessions map. Reaches the child only as a transport field on the pushed message.
  • Agent name and persona are prompt-only. buildAgentPrompt (src/servers/api/agents/agent-runner.ts:71-79) inlines the agent's AGENT.md into the first user message. There is no systemPrompt, no --agents, no per-agent settings file.
  • The one durable per-agent handle is the working directory: getAgentRunsDir(agent.dirName) (agent-runner.ts:144), deliberately shared across all runs of that agent so the CLI groups their transcripts.

grep -rn "GIT_AUTHOR\|GIT_COMMITTER" src/ returns nothing. Verified. Any commit an agent makes today is attributed to whatever ~/.gitconfig says — the human owner, identically for every agent.

The proposed change

Three edits, all on the claude path:

  1. src/servers/sidecar/protocol.ts — add an optional gitIdentity: { name: string; email: string } to ClaudeSpawnStreamingParams (the type begins at line 170). Optional so every existing caller is untouched.
  2. claude-manager.ts:315 — build cleanEnv per session rather than once, merging the identity in as all four variables when present:
    ...(params.gitIdentity && {
      GIT_AUTHOR_NAME: params.gitIdentity.name,
      GIT_AUTHOR_EMAIL: params.gitIdentity.email,
      GIT_COMMITTER_NAME: params.gitIdentity.name,
      GIT_COMMITTER_EMAIL: params.gitIdentity.email,
    }),
    
  3. agent-runner.ts:144 — populate it from agent.dirName, next to where the cwd pin is already derived from the same field.

Chat sessions (api/chat/websocket.ts) would pass nothing and keep committing as the owner, which is almost certainly right: a chat turn is the human driving directly.

Use a domain that is actually controlled — <dirName>@officer.dev — so Gitea can be made to map or deliberately not-map these authors later.

Known limitations of the proposal

opencode cannot do this at all

Since the serve migration (a3dbda7, phase D) there is no process spawned per turn. One shared opencode serve starts at sidecar boot (src/servers/sidecar/opencode/index.ts:112-116) with no env key — full inheritance — and turns are driven over HTTP against it, with per-turn cwd carried as a request header (serve-runner.ts:155). Every session shares that one process environment.

Per-agent git identity on the opencode path therefore requires either a serve per agent, or an upstream API field. Neither is a small change, and this document does not propose one.

Attribution is not isolation, and isolation is the real problem

There is no filesystem isolation between agents. They share one real HOME (HOME_DIR=/home/pastilhas), one ~/.claude, one credential store; user-instance.ts:75-78 says this outright, and it is the stated reason chat is an execution permission that can never be granted. grep -ril worktree src/ returns nothing — worktrees are used nowhere.

cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely.

So two agents told to work on the same repository will share one working tree: fighting over index.lock, staging each other's half-finished edits, interleaving commits. Per-agent identity makes that legible after the fact. It does nothing to prevent it.

If agents are genuinely to work the same repo concurrently, the isolation question is the larger and more urgent one — a worktree or a clone per agent — and per-agent identity composes naturally with it (a worktree per agent is also the cleanest place to put a per-worktree git identity).

If worktrees do arrive, note this trap

git worktree shares one .git/config, so git config --local user.email in one worktree changes it for all of them. Per-worktree config needs extensions.worktreeConfig true and then git config --worktree user.email …. Environment variables sidestep the whole issue, which is another argument for the env approach above.

Deliberately not proposed

  • Signing. Per-agent SSH signing keys would make attribution unforgeable rather than conventional. For a trusted local fleet where every agent already runs as the owner with permissionMode: 'bypassPermissions', an agent that wanted to forge another's identity has far easier routes. Revisit only if agents stop being equally trusted.
  • Co-Authored-By: trailers. Useful when a human and an agent genuinely share a commit, and both Gitea and GitHub attribute them. Orthogonal to this, and a prompt-level convention rather than an environment one.

Open questions

  • Should pipeline steps (pipeline-executor.ts) get an identity too, or only named agents?
  • Is dirName the right identity, or should an agent's AGENT.md declare its own name and email — so the identity is authored where the persona is, rather than derived from a directory?
  • Does anything downstream — Gitea webhooks, activity feeds, the dashboards — assume commits belong to the owner and break when they do not?