PR #1110 — gh-write Identity Hardening: Phase 1 Closeout (#423)
PR: #1110 Date: 2026-08-23 Relates to: #423, #569, #426
Goal at the end of the previous PR
The immediately preceding PR (#731, v1.0 UX consolidation) landed a lesson about full-suite regression discipline, but the thread this PR actually continues goes back further: PR #517 built synlynk's per-role GitHub App identity system — each role (dev, qa, architect, etc.) can carry its own scoped GitHub App token, so a dispatched job's writes are attributable to the role doing the work, not to one shared personal account. That system had shipped, but as of this session it had never been live-tested end-to-end against a real PR. The informal "full autonomy roadmap" captured in project-docs/memory.md (2026-08-22 entry) named turning on real GitHub branch protection, backed by these role-scoped identities, as Phase 1's exit criterion — but nobody had actually dispatched a job that posted a real gh pr review under a role identity and checked whether it worked.
What moved the goalpost this PR
This session ran that live test for the first time, on two harnesses:
- Agy (
job-488c152f) — succeeded, but only after finding and fixing a real bug:_run_tc7()was checkingsettings["allowRules"], a key that doesn't exist in the actual~/.gemini/antigravity-cli/settings.jsonschema (the real path issettings["permissions"]["allow"]). Filed as #1106, fixed in #1107. Also required discovering — by reading dispatch source directly, not from any doc — that--requires-gh-writealone wasn't sufficient; a separate--grant run:shellwas needed, or the dispatch failed closed withPermissionEnforcementError. - claude — succeeded cleanly on the first attempt with just
--grant run:shelladded up front. No preflight bugs, no local operator-config precondition, roughly 3x cheaper and faster than the Agy path.
Both results were verified against the live GitHub API (gh pr view 1105 --json reviews), not taken on the dispatched jobs' own self-reports — both reviews were confirmed authored by the distinct App bot login synlynk-synlynk-dev, not the shared personal account.
That test also surfaced that CLAUDE.md's #426 routing table ("route gh-write to Grok by default") was empirically false: Grok's dispatch sandbox denies bash entirely (job-3e428904 reported "OK, exit 0" but git diff origin/main showed zero real change — a silent no-op masked by a misleading job status). The static HARNESS_CAPABILITY_BASELINES table in synlynk/_constants.py disagreed with observed reality in both directions: agy.can_gh_write was False despite a live-proven success, grok.can_gh_write was True despite a live-proven total failure.
Rather than re-litigating the identity/token architecture itself (already built, per #517), this PR scoped down to three concrete gaps the live test exposed, captured in a design doc (docs/superpowers/specs/2026-08-23-gh-write-identity-hardening-design.md) before any code was touched.
What this PR shipped
Executed via superpowers:subagent-driven-development — one fresh Codex dispatch per task, spec-compliance and code-quality review against the actual diff before merging each one, PM/review/deploy work staying in this session per this project's role split.
- Corrected harness capability data (
synlynk/_constants.py):agy.can_gh_writeFalse→True,grok.can_gh_writeTrue→False, matching the live-tested results above. - Harness selection ordering (
synlynk/dispatch.py,_harness_for_org_role): a new_GH_WRITE_HARNESS_PRIORITY = ("claude", "agy")constant makes gh-write auto-selection prefer claude, then agy — never grok — while leaving non-gh-write selection alphabetical and unchanged. - #426 routing SOP correction (
synlynk/probe.py, both the static_CAPABILITY_ALLOCATION_SOPtemplate and the dynamic_repair_capability_allocation_sopgenerator, which regenerate the checked-in CLAUDE.md): replaced "Route any task that requires GitHub write actions to Grok by default" with the corrected claude-primary/Agy-fallback text and the concrete sandbox-limitation reasoning (Grok denies bash, Codex blocks network egress toapi.github.com). --requires-gh-writenow auto-impliesrun:shell(synlynk/dispatch.py): arequires_gh_write=Truedispatch injects"run:shell"intoeffective_grantsbefore_resolve_dispatch_permissionsbuilds the final permission set — closing the exact friction this session's own live test hit (forgetting the separate--grant run:shellflag).- Fail-closed role resolution for gh-write (
synlynk/dispatch.py,synlynk/cli.py): the oldagent_role or _role_for_story(story_id) or "dev"fallback silently authenticated every unlabeled gh-write dispatch as thedevrole — defeating #423's whole point of separating identities by role. Thator "dev"is now removed forrequires_gh_write=Truedispatches; if no role resolves via--as-agent, a role-tagged--story, or the new--role <role>flag,dispatch_agentand_build_subprocess_envboth raiseRuntimeErrorbefore any subprocess spawns.
Five new unit tests were added across tests/test_synlynk.py covering: the run:shell auto-imply (and its non-gh-write no-op counterpart), the fail-closed raise with no role, --role qa resolving correctly, and the _build_subprocess_env-level equivalents. Two pre-existing gaps were caught by the two-stage review process rather than shipping silently: a stray dangling markdown bullet left in the regenerated CLAUDE.md, and — found only during the Task 6 full-suite run — a stale, unrelated test assertion (test_directive_templates_contain_sop_headers) still literally checking for the superseded "Grok only" table text, which the Task 3 dispatch had correctly left alone (it was out of that task's declared scope) but which the merge nonetheless broke. Both were fixed as targeted follow-up dispatches rather than folded silently into an earlier commit.
One recurring friction worth naming: this session's own --requires-gh-write auto-detection regex (_task_requires_gh_write) kept false-positiving on the dispatch prompts for Tasks 4 and 5, because those tasks' required code content literally contains the string --requires-gh-write — and \bgh\b matches "gh" even hyphen-bounded. For Task 5 and Task 4, the prompts couldn't be reworded around it (the trigger was in code the plan required, not incidental prose), so dispatch used the project's own documented SYNLYNK_GH_WRITE_ALLOW_HOST_AUTH=1 escape hatch, verified safe because no role in this sandbox has a provisioned GitHub App token in the first place, and because both dispatched jobs' own instructions explicitly forbade touching gh at all.
Brainstorm visuals
None were created for this design — the three fixes were narrow and code-shaped, not architectural or visual.
What this achieves on the path to autonomy
Phase 1 of the full-autonomy roadmap's stated exit criterion — "branch protection can be turned on for real, backed by role-scoped identities" — depended on the identity mechanism actually working under realistic conditions (no manually-remembered extra flags, no silent identity conflation, correct default routing). This PR closes exactly those three gaps. It deliberately does not build the gh-write broker described in docs/superpowers/specs/2026-08-19-gh-write-broker-design.md (#865) — that design would make the broker the only path capable of a GitHub write by removing direct gh/MCP write access from every harness, which is a structurally stronger but separate effort. This PR is explicit interim hardening of the mechanism the broker is meant to eventually retire; a tracking issue (#1109) keeps that "Phase 1b" work from going silently orphaned.
Next goalpost
One live dispatch remains before Phase 1 can be marked fully done: a real --role qa (or similar) dispatch against a live PR, using the new fail-closed path and corrected default routing together, followed by actually turning on GitHub branch protection backed by that role's identity. That live-test-and-flip-protection step is Phase 1's true completion task, tracked separately from this PR's automated test suite. Once done, Phase 2 — porting the same qa-gate pattern to rxcc — can start.