Skip to content

Identity separation: per-role and per-mix-bucket session env (#304)#308

Closed
polymath-orchestrator wants to merge 18 commits into
mainfrom
ao/agent-orchestrator-228/issue-304-identity-sep
Closed

Identity separation: per-role and per-mix-bucket session env (#304)#308
polymath-orchestrator wants to merge 18 commits into
mainfrom
ao/agent-orchestrator-228/issue-304-identity-sep

Conversation

@polymath-orchestrator

@polymath-orchestrator polymath-orchestrator commented Jul 12, 2026

Copy link
Copy Markdown

Refs #304 — deliberately does NOT close it. The mechanism ships here; acceptance criterion 2 is unmet by operator decision and the config cutover is blocked on credentials (both scored honestly below). An independent reviewer flagged auto-closing this as its top finding, and it was right: merging must not mark #304 done.

Workers push and call gh under the orchestrator's own login, so author and reviewer collapse to one identity and GitHub refuses a blocking review (Can not request changes on your own pull request). This gives a session a GitHub identity resolved from its role and its resolved harness, so a worker can author a PR under a login the reviewing orchestrator can actually block.

Design: docs/design/304-identity-separation.md.

Three findings that reshaped the issue

  1. The bot accounts already exist. nhod-claude and nhod-codex are already org members with write on this repo and on agent-vault, and already author PRs elsewhere (nhod-codexConsolidate runtime architecture and delete dead generations #271 here). The provisioning half was largely done — it had just never reached the orchestrator box, where one gh config and one SSH key collapse every pane to polymath-orchestrator.
  2. ⚠️ mainprotect has no pull_request rule — only deletion, non_fast_forward, required_status_checks, merge_queue. GitHub enforces CHANGES_REQUESTED as a merge blocker only when a review rule exists. So identity separation alone satisfies AC1 but not AC2 — see the honest scoring below.
  3. Seats are full (5/5, Team, agent-vault private), so reusing the existing bots costs nothing while a new one costs a seat.

What this ships

Env on RoleOverride and on each WorkerMix bucket, resolved by ProjectConfig.ResolveEnv:

projectEnv  <  RoleOverride(role).Env  <  mixBucket(resolvedHarness).Env  <  AO_* internals

Identity is a path, never a secret. The committed spec carries only GH_CONFIG_DIR and GIT_CONFIG_GLOBAL paths into ~/.ao/identities/<bot>/; tokens live on disk, outside git. A url.insteadOf rewrite (SSH → HTTPS) routes push auth through gh auth git-credential, which resolves against each pane's own GH_CONFIG_DIR — so one global credential helper yields a different identity per pane, and zero SSH keys need provisioning.

Two things worth reviewing closely

The POLYPOWERS_AUTOMERGE escalation. projectRuntimeEnv strips that key from project env precisely so a project cannot self-grant autonomous merge. A naive role-env merge would have let worker.env: {POLYPOWERS_AUTOMERGE: "1"} walk straight around the merge gate. The strip now applies to the merged map. Tested.

The switch trap. switchLiveHarness and relaunchTerminatedWithHarness receive the new harness as a parameter while the session record still holds the old one. Resolving identity from the record compiles, passes every spawn test, and silently leaves a switched session pushing under the previous bot's login. The harness is therefore a parameter, not a lookup — and TestSwitchHarness_IdentityEnvFollowsTheNewHarness was verified to fail against exactly that mistake.

Reviewer panes are deliberately not given a worker identity (review/launcher.go never calls projectRuntimeEnv), which is what keeps author and reviewer distinct. A future refactor that "unifies" the two env paths would re-collapse them — commented in code.

Two deliberate departures from the plan

  • No CDC redaction migration. SQLite json_remove has no array wildcard, so a trigger could redact the three role paths but never workerMix[i].env — partial coverage that reads as complete. Instead Validate() refuses credential-shaped keys in any role/bucket env and SetConfig calls it on every write, which is total. Also avoids burning a migration version (a recurring collision point between parallel PRs) on a weaker guard.
  • No ao doctor check. "An identity that collapses to the reviewing login is a failure" is Polymath review policy, not generic daemon behavior, and this tree is under the vanilla rule. The backend diff stays minimal and upstream-shaped; the policy check lives in ops/identity.mjs check --all.

The silent-corruption class this closes

The load-bearing surface was the read path, not set-config: ao project get --json re-marshals through the CLI's own mirror structs, and a field missing there is silently dropped — and that read path is what ops/project-config check/capture consume. Unfixed, worker.env would have been permanent phantom drift and capture would have erased the identity config apply had just installed. The frontend had the mirror-image bug: ProjectSettingsForm rebuilt mix rows as {agent, model, weight}, wiping bucket env on any settings save. Both are now regression-tested against the lossy versions (both tests verified to fail against them).

Acceptance criteria, scored honestly

# Criterion Status
1 Workers author PRs under a distinct login Mechanism shipped. Live once credentials exist and the spec is cut over.
2 A blocking --request-changes actually blocks the merge Not met, by decision — branch protection stays as-is per operator call. Proposed follow-up: teach ops/final-review-status.mjs check --mode autonomous to refuse a current-head CHANGES_REQUESTED from a non-author, which closes it on the path these PRs actually take.
3 BEADS_TO_GH_ASSIGNEE_MAP / claim+mirror still resolve Unaffected: no .beads/ here, and skills resolve the login from gh api user, which now returns the bot.

⚠️ Sequencing — the cutover is NOT in this PR, on purpose

GH_CONFIG_DIR pointing at a directory that does not exist makes gh unauthenticated. Landing identity env in the committed spec before the bots are authenticated on the box would break gh for every worker on the next project-config apply. Order is fixed: identity.mjs create → operator runs gh auth login per bot (the one step an agent cannot do) → identity.mjs check --all passes → then cut the spec over.

Testing

  • npm run ci:backend green (build, vet, -race, golangci-lint).
  • Frontend: 614/614.
  • npm run format:check green.
  • The two regression tests and the switch-trap test were each mutation-verified: reverted to the buggy version, confirmed red, restored, confirmed green.

Notes for the reviewer

  • Backend change → vanilla rule: upstream-shaped, upstream PR to follow regardless.
  • Touches backend/internal/session_manager/** (sensitive path) → autonomous merge parks for a human.
  • Residual risk, documented not hidden: separation is cooperative, not sandboxed. Panes still share $HOME, so a worker that deliberately unset GH_CONFIG_DIR could still act as the orchestrator. Strictly weaker threat model than today's structural single-identity, but not isolation.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements per-session GitHub identity separation by introducing env layering at the project-config level (project env < per-role env < per-workerMix-bucket env keyed by resolved harness), plus adds ops tooling to provision/check per-bot gh/git identity directories so workers can author PRs under a login distinct from the reviewing orchestrator.

Changes:

  • Backend: add env to RoleOverride and WorkerMixEntry, implement ProjectConfig.ResolveEnv(kind,harness) and wire session_manager runtime env resolution to the resolved harness (including switch/relaunch paths), while stripping POLYPOWERS_AUTOMERGE post-merge.
  • Surfaces: update CLI JSON mirror structs, OpenAPI + generated frontend schema, and frontend project settings form to round-trip bucket env without wiping it.
  • Ops: expand assertNoSecretEnv to cover role/bucket env maps and add ops/identity.mjs (+ core + tests) for identity directory creation and validation.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
ops/project-config-core.mjs Extends secret-shaped env key guard to role/bucket env maps.
ops/project-config-core.test.mjs Adds regression tests ensuring the secret guard reaches role/bucket env and allows identity path keys.
ops/identity.mjs New CLI to create/check/list per-bot identity directories and validate separation from orchestrator login.
ops/identity.test.mjs End-to-end tests for ops/identity.mjs using a fake gh and temp HOME.
ops/identity-core.mjs Pure helpers for identity path layout, gitconfig rendering, and validation rules.
ops/identity-core.test.mjs Unit tests for identity-core helpers and validation failure modes.
backend/internal/domain/projectconfig.go Adds per-role env + env resolution (ResolveEnv) and validates role/bucket env against secret-shaped keys.
backend/internal/domain/workermix.go Adds env to worker-mix entries and helper to pick env by harness (first match).
backend/internal/domain/projectconfig_env_test.go Domain-level tests for env layering precedence and worker-only bucket application.
backend/internal/session_manager/manager.go Threads harness into runtime env resolution; uses resolved harness on switch paths; strips automerge key post-merge.
backend/internal/session_manager/provision_test.go Updates tests for new projectRuntimeEnv(kind,harness,cfg) signature.
backend/internal/session_manager/identity_env_test.go Session-manager seam tests ensuring identity env follows resolved harness (including switch trap).
backend/internal/service/project/service_test.go Verifies config writes reject secret-shaped keys in role/bucket env.
backend/internal/cli/project.go CLI JSON mirror updated to round-trip role/bucket env fields.
backend/internal/httpd/apispec/openapi.yaml OpenAPI schema updated to include env on role override + worker mix entries.
frontend/src/api/schema.ts Regenerated client schema reflecting OpenAPI env additions.
frontend/src/renderer/components/ProjectSettingsForm.tsx Preserves workerMix[i].env through load/edit/save so settings edits don’t wipe identity env.
frontend/src/renderer/components/ProjectSettingsForm.test.tsx Regression test ensuring role/bucket env survives a save even though the form doesn’t expose it.
docs/design/304-identity-separation.md Design doc capturing rationale, env layering rules, sequencing, and residual risk.
Comments suppressed due to low confidence (1)

ops/project-config-core.mjs:98

  • The error message still says “Move secrets out of project env…”, but assertNoSecretEnv now scans multiple env maps (env, worker.env, orchestrator.env, prime.env, workerMix[i].env). When the offender is under a role/bucket env path, the current message is misleading for operators trying to fix the config.
		throw new Error(
			`refusing to write secret-like env key(s) to a committed spec: ${offenders.join(", ")}. ` +
				`Move secrets out of project env, or exempt specific non-secret keys via ` +
				`AO_PROJECT_CONFIG_ALLOW_ENV_KEYS=<comma-separated key names>.`,
		);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ops/identity.mjs
@polymath-orchestrator

Copy link
Copy Markdown
Author

final-review — verdict: clean · PARKED for human merge

Head reviewed: cfe1b390f1d0888beb0478cb81d7fd8f0c1afb33
Independent reviewer: Codex (gpt-5.5) — different model family from the implementer (Claude). PR-integrated reviewer: Copilot (arrived cycle 1).
Cycles: 6 · Findings fixed: 8 HIGH, 20 MEDIUM, 4 LOW/nit · Unresolved current-head threads: 0
CI: all 13 GitHub checks green on this SHA. Local: backend ci:backend green, frontend 615/615, ops 407/407, format:check green.

Reviewer's closing verdict: "READY. No CRITICAL or HIGH issue remains that makes merging unsafe. ResolveEnv layering, token shadowing, AO-owned precedence, spawn/switch/restore selection, and reviewer-pane isolation are sound."

⛔ PARKED — sensitive path, human merge required

The diff touches backend/internal/session_manager/**. Per the repo's sensitive-path rule, autonomous merge parks for a human regardless of how clean the review is. merge-park status set with reason=human-required.

The review earned its keep — 3 bugs that would have shipped a silently broken feature

Each looks correctly configured and fails silently, which is the exact failure mode #304 exists to eliminate.

  1. GH_TOKEN overrides GH_CONFIG_DIR (HIGH). gh resolves GH_TOKEN > GITHUB_TOKEN > hosts.yml, and a pane inherits the daemon's whole environment. An inherited token overrides the bot's stored credentials — and this is reachable in supported config, since ProjectConfig.Env may legitimately carry a GITHUB_TOKEN (that's why the CDC triggers redact $.env at all). Every worker would have kept authenticating as the orchestrator while looking perfectly configured. Verified live against gh. Now shadowed to empty (not deleted — the pane must override an inherited value) whenever a session selects an identity by path.

  2. Inherited git credential helpers answer first (HIGH). git collects helpers into a list and takes the first that answers. The identity gitconfig [include]s the real ~/.gitconfig above its own settings, so a store/cache/keychain helper there is asked first and hands back the orchestrator's credentials. Proven with git credential fill: without an empty helper = reset the ORCHESTRATOR answers; with it, the bot does. Validation now checks the list git will actually use — includes followed, resets applied, and covering generic, host-scoped and path-scoped credential sections.

  3. AO_RUN_FILE was forgeable (security). Every other AO-owned key is unconditionally assigned; AO_RUN_FILE was only set when the daemon has a run file, so otherwise a project-supplied value survived and could forge the path ao hooks callbacks resolve against. Found by the table-test the reviewer asked for — the original test only proved AO_SESSION_ID, which is precisely why it hid.

Also caught and fixed: a regression I introduced (check --all crashed with [object Object] exactly once a spec configured an identity — i.e. at cutover, the one moment the gate exists for), the settings form silently carrying a bucket's identity across an agent change, and a privilege-escalation path where worker.env could have self-granted POLYPOWERS_AUTOMERGE.

Where I pushed back

  • No CDC redaction migration. SQLite json_remove has no array wildcard, so a trigger could cover the three role paths but never workerMix[i].env — partial coverage that reads as complete. Validate() on the write path is total.
  • No ao doctor check. "An identity that collapses to the reviewing login is a failure" is Polymath review policy, not generic daemon behavior, and this tree is vanilla-rule. The check lives in ops/identity.mjs.
  • Copilot validation stays scoped to configured harnesses. The reviewer wanted it extended to every harness that exists; that would force COPILOT_GITHUB_TOKEN on every identity-using project. An explicit --agent copilot override fails loudly at runtime — an error, not a silent collapse. The reviewer accepted this.

⚠️ Two things this PR does NOT do

  • AC2 is unmet, by operator decision. mainprotect carries no pull_request rule, so a --request-changes will be visible but will not block the merge queue. Proposed follow-up: teach ops/final-review-status.mjs check --mode autonomous to refuse a current-head CHANGES_REQUESTED from a non-author.
  • The config cutover is deliberately excluded. GH_CONFIG_DIR pointing at a nonexistent directory makes gh unauthenticated, so landing identity env in the spec before the bots are authenticated would break gh for every worker on the next apply. Sequence: identity.mjs create → operator runs gh auth login per bot → check --allthen cut over.

This PR therefore refs #304 rather than closing it.

polymath-orchestrator added a commit that referenced this pull request Jul 12, 2026
Independent review (codex) confirmed the stale-SHA logic is correct, and found
five ways the gate could wave a merge through. Every one is the same shape as the
bug it exists to fix: an ambiguity treated as "fine".

HIGH — PR resolution could pick the WRONG pull request. `commits/{sha}/pulls`
returns every PR associated with a commit, merged and closed ones included, so
taking the first exact-head match could select a stale PR and mask a
CHANGES_REQUESTED on the one actually entering the merge queue — a direct bypass.
Now: only OPEN PRs whose head is this SHA, and exactly one, or fail closed.

- A non-array reviews response (null, an error body, a truncated read) meant "no
  reviews" and certified the merge. It means "we do not know". Fails closed; the
  old test had locked the unsafe behavior in.
- An unknown PR author passed when the review list was empty. Without the author
  we cannot tell a self-review from an independent one — author == reviewer is the
  exact collapse this prevents — so it fails closed with zero reviews too.
- A current-head verdict whose author could not be read silently VANISHED rather
  than blocking.
- Ordering used submitted_at, so a missing or malformed timestamp parsed to 0 and
  a LATER requested change could lose to an EARLIER approval, silently unblocking
  the merge. Now uses the API's documented response order with the review id as a
  deterministic tie-breaker.
- `--paginate` without `--slurp` emits one JSON document per page, so the parse
  threw past 100 reviews: fail-closed, but UNCLEARABLE — and an unclearable gate
  gets routed around, which is the failure mode we are trying to end.

Re-verified live: #308 still parks on human-required (not on reviews), human mode
is unchanged, and a SHA with no open PR fails closed.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
polymath-orchestrator added a commit that referenced this pull request Jul 12, 2026
The independent reviewer caught that my previous commit's message claimed three
fixes the committed code did not contain. It was right: a patch to
fetchHeadPullRequestReviews silently failed to apply (prettier had reflowed the
target), so only the call site changed — the file destructured `ambiguous` from a
function that never returned it, still took the first matching PR of any state,
and still paginated without --slurp.

My own live check passed anyway, because #308 happens to have exactly one open
PR. That is precisely the hole an independent reviewer reading the committed diff
closes, and precisely why "it worked when I ran it" is not verification.

Now applied, and the caller's logic is extracted into pure, tested seams —
selectHeadPullRequest() and flattenReviewPages() — because the bug lived in the
one part of this gate that had no tests while the evaluator's own tests were green:

- Only OPEN pulls whose head is exactly this SHA count, and exactly one must
  remain; ambiguity fails closed instead of guessing which PR is real.
- --slurp + flatten, so a PR with more than 100 reviews stays evaluable.
- A non-array reviews body reaches the evaluator UNCHANGED (not `?? []`) so it
  fails closed on an unread response rather than certifying the merge.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
polymath-orchestrator and others added 16 commits July 13, 2026 19:26
Records the resolved design decisions for #304 and the three findings that
reshape the issue: the nhod-claude/nhod-codex bots already exist with write
access, mainprotect carries no pull_request rule (so a blocking review would
not block), and the org is at 5/5 seats.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
…#304)

Adds Env to RoleOverride and WorkerMixEntry, plus ProjectConfig.ResolveEnv,
which layers project < role < mix-bucket env. The bucket is selected by the
session's RESOLVED harness, so an explicit --agent still lands on that
harness's identity; mix buckets are worker-only, so an orchestrator keeps its
own credentials even when its harness matches a worker bucket.

Validate refuses secret-shaped keys in role and bucket env: those carry
identity paths only. The storage CDC triggers redact $.env out of the change
log but cannot strip workerMix[i].env generically (SQLite json_remove has no
array wildcard), so the no-secrets invariant is enforced at the source.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
…ness (#304)

projectRuntimeEnv now takes the session's resolved harness and layers
project < role < mix-bucket env through ProjectConfig.ResolveEnv, so a worker
carries a GitHub identity distinct from the orchestrator that reviews its PR.

The harness is a parameter, not a lookup: switchLiveHarness and
relaunchTerminatedWithHarness carry the NEW harness while the session record
still holds the old one, so resolving from the record would compile, pass every
spawn test, and silently leave a switched session pushing under the previous
bot's login. Covered by a test that fails against exactly that mistake.

POLYPOWERS_AUTOMERGE is stripped from the merged map rather than only from
project env: without that, worker.env could self-grant autonomous merge and walk
straight around the merge gate.

Reviewer panes deliberately keep the daemon's credentials — review/launcher.go
never calls projectRuntimeEnv — which is what keeps author and reviewer distinct.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
)

The load-bearing surface is the READ path: `ao project get --json` re-marshals
through the CLI's own mirror structs, and a field missing there is silently
dropped — which is exactly what ops/project-config check and capture consume.
Left unfixed, worker.env would show as permanent phantom drift and capture would
erase the identity config apply had just installed. The frontend had the same
hazard in the other writer: ProjectSettingsForm rebuilt mix rows as
{agent, model, weight}, so any settings save wiped a bucket's env.

Both are regression-tested against the lossy versions. assertNoSecretEnv now
walks role and bucket env too: unlike top-level env those are not redacted out of
the change log by the CDC triggers (SQLite json_remove has no array wildcard for
workerMix[i]), so a secret there would be both committed and journaled.

Drift output deliberately does NOT blanket-redact role/bucket env: those are
guaranteed non-secret at the source, and redacting them would make identity drift
undiagnosable. Secret-named leaves are still redacted by the existing heuristic.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
…he write path (#304)

SetConfig -> validateProjectConfig -> Validate is the complete guard for the new
identity env maps, so pin it with a test.

Deliberately NOT extending the CDC redaction triggers, which is where the plan
pointed: SQLite json_remove has no array wildcard, so a trigger can redact the
three role paths but never workerMix[i].env — partial coverage that reads as
complete. The source guard covers every path, so redacting on the way to the
change log is moot, and we avoid burning a migration version (a recurring
collision point between parallel PRs) on a strictly weaker guarantee.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
#304)

Two decisions worth writing down:

The config cutover CANNOT precede the credentials. GH_CONFIG_DIR pointing at a
directory that does not exist yet makes gh unauthenticated, so landing identity
env in the committed spec before the bots are authenticated would break gh for
every worker on the next `project-config apply`. The cutover is therefore not
part of the mechanism change.

The identity check lives in ops, not `ao doctor`. "An identity that collapses to
the reviewing login is a failure" is Polymath review policy, not generic daemon
behavior, and this tree is under the vanilla rule — the backend diff stays
minimal and upstream-shaped (per-role env + resolution), while the policy check
goes in ops/identity.mjs where it belongs.

Also scores the acceptance criteria honestly: AC2 is not met by decision (branch
protection stays as-is), so the PR does not claim a gate it does not have.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
…304)

ops/identity.mjs create|check|list, over a pure ops/identity-core.mjs.

`check --all` carries the guard that would have CAUGHT #304: it resolves each
configured identity's login via GH_CONFIG_DIR and fails when that login is not
distinct from the orchestrator's. It also fails when the resolved login is a
third account — the likeliest misprovisioning is running `gh auth login` in the
right directory as the wrong user, and nothing in "non-empty and != orchestrator"
catches that, yet the directory name IS the bot login by construction.

Deliberately in ops rather than `ao doctor`: "an identity that collapses to the
reviewing login is a failure" is Polymath review policy, not generic daemon
behavior, and this tree is under the vanilla rule.

create never writes, reads, or prints a token — it makes dirs (0700) and a
gitconfig; the credential arrives only from the operator's own gh auth login.
The url.insteadOf rewrite in that gitconfig is load-bearing: delete it and the
pane falls back to the shared SSH key, i.e. back to pushing as the orchestrator.

Documents the noreply-email caveat: the id-prefixed form needs an authenticated
API call, which create cannot make before the bot has credentials.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
Codex review cycle 1: 2 high, 8 medium, 1 low. Two were real bugs.

SECURITY — AO_RUN_FILE could be forged by config env. Every other AO-owned key
is unconditionally assigned in spawnEnv, but AO_RUN_FILE was only set when the
daemon HAS a run file; otherwise a project-supplied value simply survived, so
config env could forge the path `ao hooks` callbacks resolve against. Now
deleted when absent. Found by the table-test the reviewer asked for (1j) — the
original test only proved AO_SESSION_ID, which is exactly why this hid.

BUG — the settings form carried a mix bucket's identity env across an agent
change. env is HARNESS-KEYED: repointing a row from claude-code to codex kept
the claude bot's GH_CONFIG_DIR, silently handing one bot's identity to another
harness's workers. Dropped on agent change; mutation-verified.

Also:
- ops: fail CLOSED when the orchestrator's own login cannot be resolved (1b).
  Distinctness from the reviewer is the ONE thing the check exists to prove;
  passing when it is unverifiable is how #304 went unnoticed to begin with.
- domain: align the Go secret heuristic with looksSecret in
  ops/project-config-core.mjs (1f). The narrower Go list let GH_PAT and NPM_AUTH
  through while the JS guard rejected them — a key only one side catches is a hole.
- domain: reject same-harness mix buckets that disagree on env (1g), which is
  what makes first-match-by-harness unambiguous instead of order-dependent.
- ops: parse the gitconfig into sections instead of regexing the whole file (1h)
  — a whole-file regex was satisfied by a directive in an unrelated section.
- ops: `check` takes explicit bot names (1d); `--all` only sweeps what is already
  provisioned, so it cannot notice a configured-but-unprovisioned bot.
- review: pin reviewer-pane isolation with a named test (1i). It holds by
  construction today; the test exists so a refactor that "unifies" the env paths
  cannot silently re-collapse author and reviewer.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
From the PR-integrated reviewer. `check --all <bot>` silently ignored the bot,
and unknown flags were swallowed — and accepting multiple bot names (needed so a
cutover can name its identities explicitly) made that sharper.

A silently-ignored argument is the worst failure mode for a tool that gates which
GitHub account workers push as: it would report "separated" for a set the
operator never actually asked about. Reject rather than guess.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
…304)

Codex review cycle 2 found two HIGHs. Both are the same shape as the original
bug — the identity looks correctly configured and the worker authenticates as
the orchestrator anyway — and both are verified against the real tools, not
just asserted.

1. gh reads GH_TOKEN > GITHUB_TOKEN > GH_CONFIG_DIR/hosts.yml, and a pane
   inherits the daemon's whole environment. An inherited token therefore
   OVERRIDES the bot's stored credentials. This is reachable in supported
   config: ProjectConfig.Env may legitimately carry a GITHUB_TOKEN (that is why
   the CDC triggers redact $.env at all). Verified live: with GH_TOKEN set, gh
   ignores hosts.yml entirely; with it shadowed to empty, gh falls back to the
   identity. projectRuntimeEnv now shadows both keys (empty, not deleted — the
   pane must override an INHERITED value) whenever the session selects an
   identity by path. Done in the daemon, not config: an operator who forgot the
   shadow keys would silently re-collapse author and reviewer.

2. git collects credential helpers into a LIST and asks them in order. The
   identity gitconfig [include]s the real ~/.gitconfig ABOVE its own settings,
   so a helper configured there (store/cache/keychain holding the orchestrator's
   credentials) is asked FIRST. Proven with `git credential fill`: without a
   reset the ORCHESTRATOR's helper answers; with an empty `helper =` reset, the
   bot's does. The reset is now emitted and REQUIRED by validation — checking
   only that our helper is present would pass the broken config.

Also from the cycle:
- Allowlist identity/transport keys the widened secret heuristic wrongly
  rejected (SSH_AUTH_SOCK, GIT_AUTHOR_NAME, SSL_CERT_FILE all contain "auth"/
  "cert" and are exactly what this field exists to hold). Go and ops allowlists
  are kept in sync and cross-tested.
- Same-harness bucket consistency compares EFFECTIVE env (role under bucket),
  so a bucket that inherits the role identity and one that spells it out are no
  longer a false positive.
- gitconfig parsing uses git's --get-all list semantics and treats an
  unparseable header as ending the section.
- `check --all` now covers every CONFIGURED identity (union of committed specs
  and disk), so a spec'd-but-unprovisioned bot FAILS instead of being skipped.
- orchestratorLogin() documents why it strips ambient identity env, and takes an
  AO_ORCHESTRATOR_LOGIN override.
- Cover the mix-SELECTED spawn path; every prior spawn test passed an explicit
  harness and so never exercised the mix.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
Codex cycle 3: two HIGHs, both on validation that looked right and wasn't.

1. The helper list must be EXACTLY ["", "!gh auth git-credential"]. "Reset is
   present AND ours is present" passes ["", "!orchestrator", "!gh ..."], where
   the orchestrator's helper is still asked first — git takes the first helper
   that answers.

2. Validating the outer file is not validating the config. An [include] can
   inject a helper AFTER our reset, so a file that reads perfectly still resolves
   to the orchestrator's credentials. ops now reads git's own resolution
   (`git config --file <f> --includes --get-all`) and applies git's reset
   semantics in a pure, unit-tested helper — `--get-all` reports raw values and
   does NOT collapse resets, so an empty value clears the list accumulated so
   far. Proven against real git: the include-after ordering is now rejected and
   the safe ordering accepted.

Also from the cycle:
- The check now REPRODUCES the runtime: its gh probe shadows GH_TOKEN/
  GITHUB_TOKEN exactly as the daemon does. It previously left an ambient token
  standing, so gh would answer with the AMBIENT account and the check could
  certify a bot it never actually consulted. A check that does not fail the way
  production fails is not a check.
- Neutralization keys on a NON-EMPTY GH_CONFIG_DIR: presence-only would treat
  GH_CONFIG_DIR="" as an identity and blank a project's tokens, destroying auth
  instead of redirecting it.
- Validate rejects a half-configured identity (GH_CONFIG_DIR without
  GIT_CONFIG_GLOBAL or vice versa) — gh acts as the bot while git authors as the
  orchestrator. Looks configured; is not.
- Validate rejects a copilot identity with no COPILOT_GITHUB_TOKEN: the identity
  shadows GH_TOKEN/GITHUB_TOKEN, which are exactly the adapter's own auth
  fallbacks, so such a worker would lose authentication at runtime with no hint
  why. Fail at config time instead.
- `check --all` checks the CONFIGURED paths, not a canonical path rebuilt from
  the bot name — a spec may point elsewhere, and rebuilding would green-light a
  directory the daemon never uses.
- GIT_SSH_COMMAND is not allowlisted: its value is an arbitrary command that can
  carry an inline credential. (The name heuristic cannot catch a secret in an
  arbitrary VALUE; that limit is now stated rather than papered over.)

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
…blind spot (#304)

Codex cycle 4. The first finding is a REGRESSION I introduced last cycle.

REGRESSION — `check --all` crashed with "invalid bot name [object Object]"
whenever a spec actually configured an identity: configuredIdentities() returns
objects and knownIdentities() returns strings, and cmdCheck treated both as
names. It broke precisely at cutover, the one moment the gate exists for, and my
earlier "it works" run only passed because no spec carried identity env yet.
Reproduced by temporarily adding identity env to the spec, then fixed and
re-verified the same way. Every target is now normalized to {bot, paths}.

HIGH — git applies the GENERIC `credential.helper` as well as the URL-scoped
one, and an [include] can set it. Querying only credential.https://github.com.helper
missed it entirely, so an injected generic helper produced a false green. ops now
reads the fully-resolved config with `--list` (which preserves the file ORDER that
decides which helper answers first) and keeps every helper applying to github.com.
Verified against real git: a generic helper injected via an include is now caught.

Also:
- Validation resolves the EFFECTIVE env per surface (the same ResolveEnv the
  runtime uses) instead of inspecting raw layers, which missed an identity
  supplied via ProjectConfig.Env and a copilot bucket inheriting the worker
  identity while carrying unrelated env of its own.
- The half-pair rule is relaxed correctly: GIT_CONFIG_GLOBAL alone is legitimate
  (a role may want its own author/signing config without changing gh). Only a
  GH_CONFIG_DIR without its GIT_CONFIG_GLOBAL is the split identity.
- Identity paths must be ABSOLUTE: a relative one resolves against each process's
  working directory, so the identity validated is not the identity used.
- GIT_SSH_COMMAND is now DENIED outright. Removing it from the allowlist was
  inert — the name heuristic never matched it — so the previous "fix" changed
  nothing and the JS test still asserted acceptance.
- `check --all` keys targets by the configured ghDir+gitconfig PAIR and never
  fabricates a missing GIT_CONFIG_GLOBAL; it reports the half-configured identity
  the daemon would also reject.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
)

Codex cycle 5 confirms the CORE is sound — ResolveEnv layering, token shadowing,
AO-owned precedence, spawn/switch/restore selection and reviewer-pane isolation.
What remained was one real gap in the ops checker plus small edges.

HIGH — git honors PATH-scoped credential sections too
(credential.https://github.com/org/repo.helper), not just generic and host-scoped.
An include could inject a repo-scoped orchestrator helper and `check` reported
green while git consulted it first. Verified against real git: the path-scoped
helper appears in git's resolved config and is now caught and rejected.

Also:
- Same-harness bucket consistency layers PROJECT env too, matching ResolveEnv.
  Omitting it made "one bucket inherits X from project env, another spells the
  same X out" a false positive though both resolve identically.
- The absolute-path rule applies to a standalone GIT_CONFIG_GLOBAL as well.
- allowKeys can no longer re-admit GIT_SSH_COMMAND: that escape hatch exists for
  a non-secret NAME that trips the heuristic, not for a key refused on the
  strength of its VALUE.

Deliberately NOT done: validating every harness that exists. The reviewer wanted
the copilot rule extended to explicit `--agent copilot` on projects with no
copilot bucket, but that would force COPILOT_GITHUB_TOKEN on every
identity-using project on the account. That override fails LOUDLY at runtime
(copilot cannot authenticate once its fallbacks are shadowed) — an error, not
the silent identity collapse this validation exists to prevent. Scoped to the
harnesses a project is actually configured to spawn, and said so in the code.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
…ment (#304)

Final review nits.

The denial now matches the Go validator exactly: role and bucket env only. Top-level
project env is a different contract — it may legitimately carry secrets (the storage
CDC triggers redact $.env out of the change log precisely because it can), so denying
it there would have rejected configs the daemon accepts.

Refs #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
@polymath-orchestrator
polymath-orchestrator force-pushed the ao/agent-orchestrator-228/issue-304-identity-sep branch from cfe1b39 to e0901fa Compare July 13, 2026 19:37
@polymath-orchestrator

Copy link
Copy Markdown
Author

review requested (SDLC marker)\n\nReviewer: Claude (independent local reviewer)\nHead: e0901faad2aec2b3d7f6099a4c46fc6df560e60f\nCycle: final-review rebase pass

@polymath-orchestrator

Copy link
Copy Markdown
Author

review verdict (SDLC marker)\n\nCycle: 1\nHead reviewed: e0901faad2aec2b3d7f6099a4c46fc6df560e60f\nReviewer: Claude (independent local reviewer)\nVerdict: fixes required\nFindings fixed: 4 medium/low ops identity/config validation findings plus stale nopool rebase concern verified fixed.\nFix head: 785e116464faf55143d17c6c0d83f53573e6a97b

@polymath-orchestrator

Copy link
Copy Markdown
Author

final-review cycle 2 verdict

2a. Reviewer/model family: Claude independent foreground review.

2b. Reviewed head: 785e116.

2c. Verdict: fixes required; 2 findings, both fixed.

2d. Fix head: 9b0744b.

2e. Fixes: removed the retired nopool helper/test restored during rebase, and updated docs/design/304-identity-separation.md so the phase and acceptance-status notes match the shipped PR #311/autonomous-merge path.

2f. Local verification after fixes: npm run ci:backend, npm run test:ops, npm run format:check, npm test --prefix frontend, npm run typecheck --prefix frontend, npm run build:web --prefix frontend.

@polymath-orchestrator

Copy link
Copy Markdown
Author

final-review cycle 3 verdict

3a. Reviewer/model family: Claude independent foreground recheck.

3b. Reviewed head: 9b0744b.

3c. Verdict: clean for the cycle-2 fixes.

3d. Findings: none. The retired nopool helper/symbols are absent from backend/internal/domain/workermix.go, remaining nopool occurrences are tests/docs asserting the legacy label is ignored, and docs/design/304-identity-separation.md now marks phase 4 shipped via #311 and AC2 met on the autonomous-merge path.

3e. Residual risk noted by reviewer: envForHarness depends on validateMixEnvConsistentPerHarness preserving same-harness env consistency; existing implementation/tests cover that invariant.

@polymath-orchestrator

Copy link
Copy Markdown
Author

final-review — verdict: clean · PARKED for human merge

1a. Head reviewed: 9b0744b.

1b. Independent reviewer: Claude foreground recheck after rebase and review fixes.

1c. Review result: clean. Cycle 1 findings fixed at 785e116; cycle 2 findings fixed at 9b0744b; cycle 3 recheck found no remaining findings.

1d. Review threads: 0 unresolved current-head threads. The only Copilot thread is outdated and resolved.

1e. Local CI: npm run ci:backend; npm run test:ops; npm run format:check; npm test --prefix frontend; npm run typecheck --prefix frontend; npm run build:web --prefix frontend.

1f. Remote CI: all GitHub checks are green on this SHA, including Go, Frontend, Prettier, gitleaks, and CLI E2E.

1g. Statuses set on this SHA: final-review=success, review-passed=success, merge-park=success with reason=human-required.

1h. Park reason: the diff touches backend/internal/session_manager/**, which is a sensitive path under this repo's policy, so autonomous merge must park for a human even with a clean gate.

@polymath-orchestrator

Copy link
Copy Markdown
Author

Closing without merge, per the #313 harmony plan (operator-approved implementation direction, 2026-07-13/14): separate GitHub bot identities, credential routing, and per-role/mix-bucket environment layering add a large subsystem that does not solve a current need of this single-operator local product. This is a rejection on product-scope grounds, not a deferral — see the 'Close without implementing' section of the plan comment on #313. The identity mechanism here is not security isolation; cross-family independent review plus the current-head final-review status remains the review control.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants