Skip to content

fix(github): refuse local-gh GitHub mutations under identity "app" - #388

Merged
miyaontherelay merged 5 commits into
mainfrom
fix/221-babysitter-app-identity
Aug 26, 2026
Merged

fix(github): refuse local-gh GitHub mutations under identity "app"#388
miyaontherelay merged 5 commits into
mainfrom
fix/221-babysitter-app-identity

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 26, 2026

Copy link
Copy Markdown
Member

Closes the un-gated half of #221.

What was actually wrong

#283 (shipped in 0.1.75) routed the Factory lifecycle writeback through the connected GitHub App under github.identity: "app". It did not touch every GitHub write. Two mutations were still unconditional shell-outs to gh, which authenticates as whichever human is logged in locally — so an operator who deliberately selected app could still produce human-authored writes without any signal.

One correction to the issue's premise, verified on origin/main: standalone-babysitter.ts performs no GitHub writes at all. Its only gh call is gh pr view --json (a read), and source: 'mount' | 'gh' | 'mount+gh' is read provenance. The babysitter's review replies and pushes are performed by the dispatched agent under the agent's own credential, not by the Factory process. The issue mislocated this write; the audit below locates it.

Complete sink audit

Search that produced it (non-test src/):

grep -rnE "spawn\(|execFile\(|execa" src --include='*.ts' | grep -v '\.test\.ts'
grep -rln "'gh'\|\"gh\"\|ghBin\|runGh\|GhCli" src --include='*.ts' | grep -v '\.test\.ts'
grep -rnE "'(create|edit|comment|merge|close|reopen|delete|ready|review)'," src --include='*.ts' | grep -v '\.test\.ts'
grep -rnE "'push'|\-X (POST|PATCH|PUT|DELETE)|--method" src --include='*.ts' | grep -v '\.test\.ts'

Identity-bearing writes — all 8:

# Sink file:line Gated on github.identity before after
1 PR create writeback/github.ts:341 (gh) / mount/relayfile-github-connection-write.ts:84 (app) yes — factory.ts:11214 unchanged
2 git push of a recovery branch writeback/github.ts:327 yes — same selection as #1 unchanged
3 Issue comment writeback/github.ts:412 (gh) / writeback/github.ts:139 (app) yes — defaultGithubWriteback unchanged
4 Status label add/remove writeback/github.ts:445-500 (gh) / :213 (app) yes unchanged
5 Issue close writeback/github.ts:633 (gh) / :272 (app) yes unchanged
6 Guarded squash merge github/merge-gate.ts:82 via defaultGhRunner:175 no refuses under app
7 Notion intake issue create intake/notion.ts:407 no refuses under app
8 Notion intake issue edit intake/notion.ts:416 no refuses under app

Reads — untouched by design (gh pr view carries no authorship, so gating them would break Factory without removing any attribution): github/standalone-babysitter.ts:86, github/merge-gate.ts:59, writeback/github.ts:360,389,405,425,545,570,614,656,674,702, intake/notion.ts:382,390,396.

Not a Factory sink: agent-side review replies and PR pushes, instructed at dispatch/templates.ts:282,286. These run under the dispatched agent's credential in its own checkout. Genuinely out of scope for a change to Factory's own write paths, and worth its own issue if the agent's attribution matters.

Why refuse instead of route

The connected GithubConnectionWrite surface exposes publishPullRequest, closePullRequest, postIssueComment, ensureRepositoryLabel, mutateIssueLabel, updateIssue — and no merge and no issue-create. Per the Axis B ruling on design-note #228, the no-GitHub-token constraint is not relaxed: Factory holds no PAT, no GITHUB_TOKEN, no App private key, and the invariant in mount/github-api-issue-read.ts that Factory never receives or invokes a GitHub credential stands. Faking the capability would break it. So the honest behavior is a documented refusal, per DoD item 4.

Neither refusal is reachable in the default cloud deployment (mergePolicy: "never", no Notion intake), and each names the recovery path — set identity to "user" or "auto" — so the gate does not take an honest caller hostage.

Must-fail / must-pass evidence

Red-check by neutering localGhMutationAllowed to its pre-change behavior (permit everything), which is exactly the defect:

################ RED CHECK: guard neutered to pre-#221 behavior ################
 ✓ MUST NOT FIRE: auto and user still squash-merge through the local gh CLI
 ✓ MUST NOT FIRE: a gate constructed without an identity keeps the historical behavior
 × MUST FIRE: identity "app" refuses the guarded merge and never spawns gh
 ✓ MUST NOT FIRE: identity "app" leaves the merge-gate READ working
 × MUST FIRE: the FactoryLoop selector propagates identity "app" to the merge gate
 ✓ MUST NOT FIRE: the selector leaves auto and an absent github key merging
 × MUST FIRE: identity "app" refuses Notion intake issue create and edit, without invoking gh
 ✓ MUST NOT FIRE: Notion intake under "user" still creates and edits through gh
 ✓ MUST NOT FIRE: identity "app" leaves Notion intake READS working
 Test Files  1 failed (1)
      Tests  3 failed | 6 passed (9)

################ GREEN CHECK: guard restored ################
 [all 9 ✓]
 Test Files  1 passed (1)
      Tests  9 passed (9)

All three MUST-FIRE cases fail red and pass green; all six MUST-NOT-FIRE cases pass in both directions, so the test discriminates the fix from a change that simply broke local runs. The selector case matters on its own: without it the guard would be a gate nobody invokes — the class refusing correctly while FactoryLoop kept constructing it with the default.

Other suites: build exit 0; featuremap:check exit 0; src/github src/intake src/config 175 passed; src/cli/fleet.test.ts src/orchestrator/factory.test.ts src/writeback 906 passed.

A defect the red-check exposed

The first draft of this test used the real gh runner, because GhCliIssuePublisher had no test seam — every method went straight to a module-level runGh. Running it created a junk issue (#385, since closed) and overwrote a merged PR's body (#7, restored byte-exact from its userContentEdits history, 3056 chars). A class whose every method mutates production and which cannot be constructed with a fake is a defect in its own right, so this PR gives it an injectable runner and the test now asserts calls is empty on the refusal path — proving the write is declined before the process boundary rather than after something already landed.

Review round (f315498)

Four threads, all valid, all fixed:

  • P1 x2 — the refusal consumed the exactly-once Notion claim. It was raised inside createIssue, which publishRepoTask reaches only after claimNotionDelivery. An app run therefore burned the claim and returned blocked with no receipt, so the operator's retry under user/auto was permanently blocked by its own aborted run. GithubIssuePublisher gained assertWritable(), called before any claim, receipt or network call.
  • P2 — I hardcoded new GhCliIssuePublisher('user') at the only production caller, so the Notion gate could never fire and the README claim was false. This was the merge-gate defect this PR fixes, reintroduced one file over. The CLI now resolves github.identity from the contract; an absent contract is auto (matching schema synthesis), while an unparseable one is an error rather than a silent downgrade to the permissive value.
  • P3 — README table listed createIssue for a combined create/edit row while the edit refusal names updateIssue. Split.

Each has its own red/green pair, including a CLI-level test that drives the real runFleetCli with a config declaring identity: "app" and asserts exit 1, a blocked result, and an unconsumed claim.

Blocker

Two server-side capabilities are missing before merge and Notion intake can be app-authored rather than refused. Both belong behind the existing App credential boundary in Relayfile Cloud — Factory must still hold no credential — and follow the confirmed-write contract the existing methods use (confirmWrite must acknowledge a real provider outcome; pending/failed/timeout must reject).

interface GithubConnectionWrite {
  mergePullRequest?(input: {
    repo: string
    number: number
    method: 'squash' | 'merge' | 'rebase'
    expectedHeadSha: string   // refuse if the head moved
    deleteBranch?: boolean
    author: 'app'
  }): Promise<{ merged: boolean; sha?: string }>

  createIssue?(input: {
    repo: string
    title: string
    body: string
    labels?: readonly string[]
    author: 'app'
  }): Promise<{ number: number; url: string }>
}

Tracking for the read half remains relayfile-cloud#159. This is a blocker on making those two paths app-authored, not on this PR — the split audit trail is closed either way, because an explicit app identity can no longer produce a human-attributed write.

Also decided by this PR

Still open, deliberately not in this PR

R1 — the laptop contract resolves to auto. config/schema.ts:423 synthesises an absent github key to auto, so a local Factory still writes lifecycle comments as the operator. That is a config decision for the principal (cut laptop over to app, or state that auto is intended), not a code change — and after this PR, choosing app locally is now safe in the sense that nothing will silently fall back.

Do not merge — the merge gate is the principal's.

Factory had two GitHub write identities. The lifecycle writeback honours
`github.identity` and, on exact `app`, is performed server-side by the
connected workspace GitHub App. Two mutations were never gated and still
shelled out to `gh`, which authenticates as whichever human is logged in
locally:

- the guarded squash merge (`src/github/merge-gate.ts`), and
- Notion intake issue create/edit (`src/intake/notion.ts`).

Neither can be re-routed today: the connected `GithubConnectionWrite` surface
exposes no `mergePullRequest` and no `createIssue`, and Factory must never
receive or invoke a GitHub credential of its own. So under an explicit `app`
identity both now refuse, naming the missing server-side capability and the
operator's recovery path, instead of silently writing as the operator. `auto`
and `user` are unchanged.

Also:

- `defaultMergeGate(config)` makes the FactoryLoop selection testable, the same
  shape as the existing `defaultGithubWriteback`.
- `GhCliIssuePublisher` takes an explicit identity, so Notion intake's operator
  attribution is a decision on the record rather than an unnoticed default, and
  an injectable `gh` runner, because the class previously had no test seam at
  all and could only be exercised against live GitHub.
- `StandalonePullRequest.source` keeps its `'gh'` member with the rationale
  written down: it is READ provenance, and this module performs no writes.
- The stale `TODO(issue-52)` marker is rewritten; issue 52 is closed as
  completed and no longer owns the work.

Reads are deliberately untouched: `gh pr view` carries no authorship.

Session-Id: f298a3ee-c3f6-4f98-ae86-610a2d044e22
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 624e05a0add496aca47ffc1030cd75930cfe6435.

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review Change Stack

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3aad389d-e88f-4708-adf3-f9ecf58c0704

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7928b0e6-d1ee-49ad-bd27-6c588477262f

📥 Commits

Reviewing files that changed from the base of the PR and between 0cb93c3 and 624e05a.

📒 Files selected for processing (9)
  • README.md
  • src/cli/fleet.ts
  • src/github/gh-identity.test.ts
  • src/github/gh-identity.ts
  • src/github/index.ts
  • src/github/merge-gate.ts
  • src/github/standalone-babysitter.ts
  • src/intake/notion.ts
  • src/orchestrator/factory.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change adds a GitHub write-identity policy. Local gh mutations now refuse under app identity. Merge gates and Notion intake enforce the policy, while read operations remain available. Tests cover identity modes, factory wiring, merge behavior, and issue publishing.

Changes

GitHub identity policy

Layer / File(s) Summary
Identity policy contract
src/github/gh-identity.ts, src/github/index.ts, README.md, src/github/standalone-babysitter.ts
Defines GithubWriteIdentity, authorization helpers, refusal messages, public exports, and documentation for mutation and read behavior.
Merge gate enforcement
src/github/merge-gate.ts, src/orchestrator/factory.ts, src/github/gh-identity.test.ts
Passes configured identity to the merge gate. The gate blocks disallowed CLI merges before execution. Tests cover identity modes and factory wiring.
Notion issue mutation enforcement
src/intake/notion.ts, src/cli/fleet.ts, src/github/gh-identity.test.ts
Injects the GitHub identity and CLI runner into issue publishing. Create and edit operations enforce the policy. Fleet intake uses user identity. Tests preserve read behavior and validate mutations.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 624e0

The PR prevents local GitHub mutations when using the app identity while preserving reads and existing user or auto behavior. No actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: miyaontherelay, kjgbot

Poem

A rabbit checks the GitHub gate,
App writes pause and user waits.
Reads still hop along the trail,
Tests guard each CLI detail.
Notion issues bloom when cleared.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 7 files. (2 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: refusing local GitHub CLI mutations when identity is "app".
Description check ✅ Passed The description is directly related to the changeset and explains the affected mutation paths, identity behavior, testing, and design rationale.
Full details: Docstring Coverage

Explanation

Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 7 files. (2 skipped: 1 unsupported, 1 too large.)

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/221-babysitter-app-identity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 624e05a0ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/intake/notion.ts

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 9 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/intake/notion.ts
Comment thread src/cli/fleet.ts Outdated
Comment thread README.md Outdated
CI's `package` job timed out `src/cli/teammate-mcp.test.ts` twice on this
branch while passing on the identical base commit. The cause was load, not
logic: `gh-identity.test.ts` became the third test file to import the
23.5k-line `src/orchestrator/factory.ts`, and the MCP test has a 5s timeout
over a microtask-driven in-memory transport.

Move the FactoryLoop selector's must-fire/must-not-fire pair into
`factory.test.ts`, which already imports that module. The proof is unchanged
and it now lives with the loop it selects for.

`defaultMergeGate` gains a test-only runner seam so the pair can assert the
`user`/`auto` arms actually merge. Without it those arms would have spawned a
real `gh pr merge`; production still passes nothing and gets `defaultGhRunner`.

Session-Id: f298a3ee-c3f6-4f98-ae86-610a2d044e22
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 8144b2092a543eaa3e17f5bfe50f96bdbcbfe6ec.

…e claiming

Addresses all four review threads on #388.

P1 (codex, cubic) — the identity refusal was raised inside `createIssue`, which
`publishRepoTask` reaches only after `claimNotionDelivery` has reserved the
durable exactly-once claim. An `app`-configured run therefore consumed the
claim and returned blocked without a receipt, so the operator's retry under
`user`/`auto` hit "durable Notion claim already exists" permanently — the gate
took a hostage with no recovery path. `GithubIssuePublisher` gains an optional
`assertWritable()`, called at the top of `publishRepoTask` before any claim,
receipt or network call. The in-method asserts remain as a backstop.

P2 (cubic) — `new GhCliIssuePublisher('user')` hardcoded the identity at the
only production caller, so the refusal could never fire and an operator with
`identity: "app"` would still get issues authored by their local `gh` user.
That is the same "gate nobody invokes" defect this PR fixes for the merge gate,
reintroduced one file over. The CLI now resolves `github.identity` from the
contract. An absent contract is `auto` (Notion intake does not otherwise
require one, and the schema synthesises absent to `auto`); a contract that
exists but cannot be parsed is an error rather than a silent downgrade to the
permissive value. `githubIdentitySchema` is exported so both readers share one
declaration.

P3 (cubic) — the README table listed `createIssue` for a combined create/edit
row while an edit refusal names `updateIssue`. Split into two rows.

Each fix has a must-fail/must-pass pair: the claim is not consumed and a
permitted retry succeeds; the CLI blocks under a config-declared `app`; and
`assertWritable` refuses without touching gh.

Session-Id: f298a3ee-c3f6-4f98-ae86-610a2d044e22
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head f31549829277eddb1d7514ee680673a9212a3396.

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 7 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/cli/fleet.ts Outdated
Comment thread src/intake/notion.ts Outdated
Comment thread README.md
…the task

Second review round on #388; all three threads valid.

P1 — `resolveNotionIntakeIdentity` read only a flat `github` key, so a split
`workspaceConfig`/`nodeConfig` contract declaring `identity: "app"` resolved to
`auto` and silently permitted the local-user write. It now reads both halves,
node winning, matching `combineSplitConfigInput`'s spread order.

P2 — the guard ran at the top of `publishRepoTask`, ahead of `findBySource`,
`repositoryVisibility` and `missingLabels`, so read-only reconciliation and
already-dispatched tasks were blocked under an app identity even though they
write nothing. The refusal now sits immediately before each mutation: before
`ensureNotionWorkUnitClaim` on the create path, so it still precedes the
durable claim, and immediately before `updateIssue` on the reconciliation path.

P3 — the README paragraph still described the hardcoded `"user"` this PR
removed. Rewritten to describe the resolved identity, the split-contract rule,
and the fact that only mutations refuse.

`FleetCliDeps.notionGithub` is a test seam that receives the resolved identity,
matching the existing `notionClaims`/`notionContracts` pattern, so the CLI test
proves the wiring without invoking gh.

Red/green for each: the flat-only reader resolves `auto` for a split contract;
a top-of-task guard blocks reconciliation; the claim is not consumed.

Session-Id: f298a3ee-c3f6-4f98-ae86-610a2d044e22
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head c796f44d1f47e15cfb8989cdccaf96d85da3f55d.

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/cli/fleet.ts Outdated
Comment thread src/intake/notion.ts
Session-Id: 01a03d7c-82f6-7b60-9500-45a3f158870b
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head f2270c09d38f9123e29c634e5cf934980c64ea94.

@miyaontherelay
miyaontherelay merged commit fa47e7c into main Aug 26, 2026
9 checks passed
@miyaontherelay
miyaontherelay deleted the fix/221-babysitter-app-identity branch August 26, 2026 17:52
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