Skip to content

Allow Claude to write#7294

Merged
gatesn merged 4 commits intodevelopfrom
ngates/claude-write
Apr 21, 2026
Merged

Allow Claude to write#7294
gatesn merged 4 commits intodevelopfrom
ngates/claude-write

Conversation

@gatesn
Copy link
Copy Markdown
Contributor

@gatesn gatesn commented Apr 6, 2026

  • Split Claude automation into two workflows with separate trust boundaries: claude-write.yml for write-capable issue automation and follow-up edits on Claude-owned PRs, and claude-review.yml for read-only PR review.
  • Sets up a GitHub app (vortex-claude) so PRs pushed by Claude can run CI
  • Only claude-write can access the GitHub App private key, and only through the claude-automation environment. No other workflow gets that key.
  • claude-write uses a short-lived GitHub App installation token, not GITHUB_TOKEN, so PRs opened by Claude trigger normal pull_request CI.
  • claude-write runs only for trusted collaborators with write/maintain/admin access.
  • claude-write is issue-driven by default. It may also run from a PR conversation comment, but only when that PR is a same-repo PR originally opened by the Vortex Claude GitHub App (CLAUDE_APP_LOGIN).
  • claude-write refuses fork PR content entirely. There is no promotion path for forks.
  • claude-review has no GitHub App private key and no write-capable repo token. It uses the built-in GITHUB_TOKEN only for reading repo state and posting review comments.
  • claude-review can review same-repo PRs, but it must not create commits, push branches, open PRs, or update PR branches.
  • PR conversation comments on Claude-owned PRs are routed to claude-write; claude-review explicitly stands down on that path to avoid duplicate runs.
  • Both workflows reject bot-triggered invocations and gate access before running Claude logic.
  • Both workflows use persist-credentials: false on checkout so git operations do not silently fall back to the built-in GITHUB_TOKEN.
  • Fork PRs are always refused for Claude automation because fork content is treated as untrusted prompt input.

@gatesn gatesn added the changelog/chore A trivial change label Apr 6, 2026
@connortsui20
Copy link
Copy Markdown
Contributor

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 6, 2026

Claude finished @connortsui20's task in 2m 6s —— View job


PR Review

Todo

  • Gather context from PR description
  • Read claude-write.yml
  • Read claude-review.yml
  • Read (deleted) claude.yml for comparison
  • Provide comprehensive review feedback

Overall

The security design here is solid. Splitting into two workflows with separate trust boundaries, using a GitHub App token for write operations, gating on collaborator permission level, refusing fork content entirely, and pinning action SHAs rather than using mutable tags are all good practices. The .github/ modification check as an extra guard is a nice defensive layer.

A few issues worth addressing:


Bug: refuse-fork comment renders with literal \n instead of newlines

claude-review.yml line 183:

].join("\\n"),

Inside a YAML literal block scalar (|), \\n is passed to JavaScript as the two-character string \n (backslash + n). So .join("\\n") joins the array elements with the literal text \n, not actual newline characters. GitHub renders the comment body as Markdown, and \n text is not a line break — the message will render as one long jumbled line.

Fix: use "\n" (single backslash in YAML literal block = actual newline in JS):

].join("\n"),

Fix this →


Minor: Loose equality in JS

claude-review.yml line 108:

if (actorHasWrite != 'true') {

Both operands are strings, so != and !== behave identically here, but !== is idiomatic JS and avoids accidental coercion surprises. The same style is used consistently everywhere else in both files (===). Small nit, but worth being consistent.


Silent failure when modifies_github_dir blocks a run

Both workflows refuse to run when the PR touches .github/ files, which is a sensible policy. However, unlike the fork_pr_refused case (which has a dedicated refuse-fork job that posts a comment explaining the policy), modifies_github_dir exits silently — no GitHub comment, no annotation, nothing. A user who posts @claude review on a PR that happens to touch .github/ will see... nothing, and have no idea why.

Consider adding a parallel refuse-github-dir job in claude-review.yml (similar to refuse-fork) that posts a brief explanation. Even a one-line "Claude automation is disabled for PRs that modify .github/ files" would be helpful.


Edge case: CLAUDE_APP_LOGIN unset in claude-review.yml

claude-review.yml lines 129–135:

} else if (
  context.eventName === 'issue_comment' &&
  trustedClaudeLogin &&        // <-- short-circuits if empty
  (pr.user?.login ?? '') === trustedClaudeLogin
) {
  reason = 'claude_pr_uses_write_workflow';
}

If CLAUDE_APP_LOGIN is not configured, trustedClaudeLogin is '', the && short-circuits, and claude-review.yml will run on issue comments on Claude-owned PRs instead of deferring to claude-write.yml. This is a misconfiguration, but the failure mode is silently wrong rather than loudly broken — the review workflow runs where the write workflow should. The claude-write.yml gate already handles the missing CLAUDE_APP_LOGIN case with missing_claude_app_login, so there's an asymmetry.

A log warning or explicit guard (e.g., if (!trustedClaudeLogin) { reason = 'missing_claude_app_login'; } early in the gate) might be worth adding so misconfiguration is surfaced immediately.


Design note: .github/ guard blocks review of this PR

The modifies_github_dir check applies to claude-review.yml as well, which means Claude cannot review any PR that modifies .github/ — including this one. That's presumably intentional (you wouldn't want the review workflow loading and executing workflow files from the PR being reviewed), but it's worth calling out explicitly in a comment in the YAML since it's a non-obvious consequence.


Minor: github.event.pull_request.number in concurrency group

claude-review.yml line 23:

group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}

For issue_comment events (which this workflow handles), github.event.pull_request is not present in the payload — so github.event.pull_request.number will be empty, and the fallback github.event.issue.number (which is the PR number for PR-linked issue comments) kicks in correctly. The logic is fine in practice, but github.event.pull_request.number being listed first when it never applies to issue_comment events is a bit misleading. A comment explaining the fallback would help future readers.


Nit: redundant Boolean() call

claude-write.yml line 104 / claude-review.yml line 123:

const isFork = !headRepo || Boolean(headRepo.fork) || headRepo.full_name !== ...

headRepo.fork is already a boolean from the GitHub API. Boolean(headRepo.fork) is a no-op but harmless.


What looks good

  • Pinning all actions to commit SHAs (not mutable tags) is excellent practice for supply chain security.
  • Separating the GitHub App private key into the claude-automation environment, which is only attached to the claude job (not the gate job), is the right design.
  • persist-credentials: false on both checkouts prevents credential leakage.
  • The bot_sender_refused check prevents automated loop amplification.
  • The checkout_ref is set to pr.head.sha (a fixed SHA) before the environment is attached, so even if the PR branch is updated between the gate and claude jobs, the checked-out code is deterministic.
  • allow-tools in claude-write.yml is appropriately scoped (cargo/uv only); claude-review.yml is further restricted to read-only git and search commands.

@github-actions
Copy link
Copy Markdown
Contributor

This PR has been marked as stale because it has been open for 14 days with no activity. Please comment or remove the stale label if you wish to keep it active, otherwise it will be closed in 7 days

@github-actions github-actions Bot added the stale This PR is stale and will be auto-closed soon label Apr 21, 2026
@connortsui20
Copy link
Copy Markdown
Contributor

I think that we have tools in place now to do this (2 humans in the loop always)? I'll close for now.

@gatesn gatesn reopened this Apr 21, 2026
gatesn added 2 commits April 21, 2026 13:05
Signed-off-by: Nicholas Gates <nick@nickgates.com>
Signed-off-by: Nicholas Gates <nick@nickgates.com>
@gatesn gatesn force-pushed the ngates/claude-write branch from 3926e34 to c612628 Compare April 21, 2026 17:06
robert3005
robert3005 previously approved these changes Apr 21, 2026
gatesn added 2 commits April 21, 2026 13:10
Signed-off-by: Nicholas Gates <nick@nickgates.com>
Signed-off-by: Nicholas Gates <nick@nickgates.com>
@gatesn gatesn enabled auto-merge (squash) April 21, 2026 18:10
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 21, 2026

Merging this PR will degrade performance by 23.02%

❌ 2 regressed benchmarks
✅ 1161 untouched benchmarks
⏩ 1462 skipped benchmarks1

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation take_10k_random 193.3 µs 251.2 µs -23.02%
Simulation take_10k_contiguous 266.2 µs 324.2 µs -17.88%

Comparing ngates/claude-write (077b1a1) with develop (223d1df)

Open in CodSpeed

Footnotes

  1. 1462 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@robert3005 robert3005 dismissed their stale review April 21, 2026 20:16

try again

@gatesn gatesn merged commit 26a766f into develop Apr 21, 2026
61 of 63 checks passed
@gatesn gatesn deleted the ngates/claude-write branch April 21, 2026 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/chore A trivial change stale This PR is stale and will be auto-closed soon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants