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

187 lines
9.0 KiB
Markdown

# 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:
```bash
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:
```js
{ 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`:
```ts
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:
```ts
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:
```ts
...(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?