<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:blog="https://jonesrussell.github.io/blog/ns"><channel><title>Ci on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/ci/</link><description>Recent content in Ci on Web Developer Blog</description><image><title>Web Developer Blog</title><url>https://jonesrussell.github.io/blog/images/og-default.png</url><link>https://jonesrussell.github.io/blog/images/og-default.png</link></image><generator>Hugo -- 0.165.0</generator><language>en-us</language><lastBuildDate>Tue, 01 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/ci/feed.xml" rel="self" type="application/rss+xml"/><item><title>Hardening a manual Claude Code review workflow in GitHub Actions</title><link>https://jonesrussell.github.io/blog/hardening-claude-code-review-workflow-github-actions/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/hardening-claude-code-review-workflow-github-actions/</guid><category>devops</category><blog:tag>github-actions</blog:tag><blog:tag>claude-code</blog:tag><blog:tag>ci</blog:tag><blog:tag>security</blog:tag><description>How a human-triggered &amp;lsquo;@claude review&amp;rsquo; workflow got locked down to read-only, bounded-diff, revision-verified reviews after the official tag-mode example turned out to be implementation-capable by default.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p><a href="https://github.com/goformx/goformx">goformx</a> uses a mixed-provider workflow: Codex writes some pull requests, Claude writes others, and each gets reviewed by the other provider on request. The Claude side runs on the official <a href="https://github.com/anthropics/claude-code-action">Claude Code Action</a>, triggered by a maintainer commenting <code>@claude review</code> on a PR. That sounds simple to wire up. It wasn&rsquo;t safe to wire up as-is, because the action&rsquo;s default &ldquo;tag mode&rdquo; is built for implementation, not read-only review — three gaps in the default, five fixes to close them.</p>
<h2 id="why-the-default-example-wasnt-enough">Why the Default Example Wasn&rsquo;t Enough</h2>
<p>The action ships an official <a href="https://github.com/anthropics/claude-code-action/blob/a874e9ecd7bb36efdad65429c6b35815f5a08f10/examples/pr-review-comprehensive.yml">progress-tracked review example</a> that auto-triggers on every PR event and posts a tracked comment. It&rsquo;s a good starting point, but three things about tag mode don&rsquo;t fit a review-only job:</p>
<ul>
<li><strong>Tag mode is implementation-capable by default.</strong> It&rsquo;s designed for &ldquo;@claude fix this,&rdquo; not &ldquo;@claude look but don&rsquo;t touch.&rdquo;</li>
<li><strong>GitHub Actions expression matching is case-insensitive.</strong> An <code>if:</code> condition comparing a comment body to <code>@claude review</code> will also match <code>@Claude Review</code>, <code>@CLAUDE REVIEW</code>, and anything else GitHub considers equal — not the exact trigger you intended.</li>
<li><strong>Tool permissions in the SDK accumulate, they don&rsquo;t replace.</strong> Passing a restricted tool list doesn&rsquo;t override the mode&rsquo;s defaults; both apply, so an allow-list alone can&rsquo;t get you to read-only.</li>
</ul>
<p>The workflow adapts the official example at a pinned action revision, keeps the manual-only trigger and subscription auth, and layers on the following fixes.</p>
<h2 id="fix-1-enforce-the-exact-trigger">Fix 1: Enforce the Exact Trigger</h2>
<p>The outer <code>if:</code> on the job is a first, cheap filter, but because Actions expressions are case-insensitive it can&rsquo;t be the whole check. A trusted <code>actions/github-script</code> preflight step re-validates everything in JavaScript before any model call happens:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">comment</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">payload</span>.<span style="color:#a6e22e">comment</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">payload</span>.<span style="color:#a6e22e">issue</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">pull_request</span> <span style="color:#f92672">||</span> <span style="color:#a6e22e">comment</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">body</span> <span style="color:#f92672">!==</span> <span style="color:#e6db74">&#39;@claude review&#39;</span> <span style="color:#f92672">||</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">comment</span>.<span style="color:#a6e22e">user</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">login</span> <span style="color:#f92672">!==</span> <span style="color:#e6db74">&#39;jonesrussell&#39;</span> <span style="color:#f92672">||</span> <span style="color:#a6e22e">comment</span>.<span style="color:#a6e22e">user</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">type</span> <span style="color:#f92672">!==</span> <span style="color:#e6db74">&#39;User&#39;</span> <span style="color:#f92672">||</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">actor</span> <span style="color:#f92672">!==</span> <span style="color:#e6db74">&#39;jonesrussell&#39;</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">core</span>.<span style="color:#a6e22e">setFailed</span>(<span style="color:#e6db74">&#39;An exact human maintainer review request is required.&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This rejects extra text, trailing newlines, wrong casing, bot comments, and anyone who isn&rsquo;t the maintainer — all before the job spends a single turn talking to Claude. It also checks that the PR is still open, and records <code>head_sha</code>/<code>base_sha</code> for later verification.</p>
<h2 id="fix-2-bound-the-diff-fail-closed">Fix 2: Bound the Diff, Fail Closed</h2>
<p>Tag mode&rsquo;s native changed-file context is a file list, not a diff — Claude would have to reconstruct the actual changes from scratch. Instead, a setup step downloads the real diff to runner-temp storage with a hard size cap:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> {<span style="color:#a6e22e">data</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">diff</span>} <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">github</span>.<span style="color:#a6e22e">rest</span>.<span style="color:#a6e22e">pulls</span>.<span style="color:#a6e22e">get</span>({
</span></span><span style="display:flex;"><span>  ...<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">repo</span>, <span style="color:#a6e22e">pull_number</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">issue</span>.<span style="color:#a6e22e">number</span>, <span style="color:#a6e22e">mediaType</span><span style="color:#f92672">:</span> {<span style="color:#a6e22e">format</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;diff&#39;</span>}
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">typeof</span> <span style="color:#a6e22e">diff</span> <span style="color:#f92672">!==</span> <span style="color:#e6db74">&#39;string&#39;</span> <span style="color:#f92672">||</span> <span style="color:#f92672">!</span><span style="color:#a6e22e">diff</span>.<span style="color:#a6e22e">trim</span>() <span style="color:#f92672">||</span> <span style="color:#a6e22e">Buffer</span>.<span style="color:#a6e22e">byteLength</span>(<span style="color:#a6e22e">diff</span>, <span style="color:#e6db74">&#39;utf8&#39;</span>) <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">1048576</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#39;Review diff is missing, invalid or exceeds the 1 MiB review bound.&#39;</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">require</span>(<span style="color:#e6db74">&#39;node:fs&#39;</span>).<span style="color:#a6e22e">writeFileSync</span>(<span style="color:#a6e22e">diffPath</span>, <span style="color:#a6e22e">diff</span>, {<span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0o600</span>, <span style="color:#a6e22e">flag</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;wx&#39;</span>});
</span></span></code></pre></div><p>Missing, empty, or oversized diffs fail the job before Claude ever runs. The cap is <strong>1 MiB</strong>; there&rsquo;s no silent fallback to a partial or truncated review.</p>
<h2 id="fix-3-deny-tools-instead-of-just-allowing-them">Fix 3: Deny Tools Instead of Just Allowing Them</h2>
<p>The SDK&rsquo;s argument parser accumulates <code>allowedTools</code> rather than replacing the mode defaults, so the workflow can&rsquo;t rely on an allow-list alone. It sets the permission mode explicitly, restricts built-in tools to <strong>Read, Glob, Grep</strong>, and layers an explicit deny list on top:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">claude_args</span>: &gt;-<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  --max-turns 16
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  --permission-mode default
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  --setting-sources user
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  --strict-mcp-config
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  --tools &#34;Read,Glob,Grep&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  --disallowedTools &#34;Bash,Edit,Write,NotebookEdit,Agent,Task,mcp__github_ci__*,mcp__github_file_ops__*&#34;</span>
</span></span></code></pre></div><p>Some of those denials cover capabilities that aren&rsquo;t even enabled at the pinned action revision. That&rsquo;s deliberate — defense against future defaults changing underneath the pin.</p>
<h2 id="fix-4-keep-git-metadata-off-limits">Fix 4: Keep Git Metadata Off Limits</h2>
<p>Tag mode checks out the PR head and stores its short-lived job token in <code>.git/config</code>. A review agent with unrestricted <code>Read</code> could read that token straight out of the checkout. The workflow denies it explicitly in the action&rsquo;s settings:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{<span style="color:#f92672">&#34;disableAllHooks&#34;</span>:<span style="color:#66d9ef">true</span>,<span style="color:#f92672">&#34;permissions&#34;</span>:{<span style="color:#f92672">&#34;deny&#34;</span>:[<span style="color:#e6db74">&#34;Read(./.git)&#34;</span>,<span style="color:#e6db74">&#34;Read(./.git/**)&#34;</span>]}}
</span></span></code></pre></div><p>Denying reads to <code>.git</code> also blocks <code>Grep</code>/<code>Glob</code> from searching that directory, and hooks are disabled entirely so only user settings and the action&rsquo;s own MCP configuration load.</p>
<h2 id="fix-5-catch-stale-or-unverified-reviews">Fix 5: Catch Stale or Unverified Reviews</h2>
<p>A successful job run isn&rsquo;t proof the review still applies. If the PR gets pushed to while Claude is reviewing it, the findings could describe a revision that no longer exists. A final step re-checks the PR&rsquo;s current <code>head_sha</code>/<code>base_sha</code> against what the preflight recorded, and confirms the local checkout matches too:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">checkedHead</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">require</span>(<span style="color:#e6db74">&#39;node:child_process&#39;</span>).<span style="color:#a6e22e">execFileSync</span>(
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#39;git&#39;</span>, [<span style="color:#e6db74">&#39;rev-parse&#39;</span>, <span style="color:#e6db74">&#39;HEAD&#39;</span>], {<span style="color:#a6e22e">encoding</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;utf8&#39;</span>}
</span></span><span style="display:flex;"><span>).<span style="color:#a6e22e">trim</span>();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">pr</span>.<span style="color:#a6e22e">state</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;open&#39;</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">pr</span>.<span style="color:#a6e22e">head</span>.<span style="color:#a6e22e">sha</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">process</span>.<span style="color:#a6e22e">env</span>.<span style="color:#a6e22e">REVIEW_HEAD</span> <span style="color:#f92672">&amp;&amp;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">pr</span>.<span style="color:#a6e22e">base</span>.<span style="color:#a6e22e">sha</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">process</span>.<span style="color:#a6e22e">env</span>.<span style="color:#a6e22e">REVIEW_BASE</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">checkedHead</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">process</span>.<span style="color:#a6e22e">env</span>.<span style="color:#a6e22e">REVIEW_HEAD</span>) <span style="color:#66d9ef">return</span>;
</span></span></code></pre></div><p>If any of those checks fail, the workflow posts a comment marking the review <strong>STALE or UNVERIFIED</strong> with a link to the run, and fails the job. That comment matters as much as the review itself — without it, a stale finding just sits there looking authoritative.</p>
<h2 id="what-changed-at-a-glance">What Changed, at a Glance</h2>
<table>
	<thead>
			<tr>
					<th>Risk</th>
					<th>Default tag mode</th>
					<th>Hardened workflow</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Trigger matching</td>
					<td>Case-insensitive <code>if:</code> only</td>
					<td>JS preflight enforces exact comment, author, PR state</td>
			</tr>
			<tr>
					<td>Diff visibility</td>
					<td>File list only</td>
					<td>Downloaded diff, 1 MiB bound, fails closed</td>
			</tr>
			<tr>
					<td>Tool access</td>
					<td>Implementation-capable</td>
					<td><code>Read,Glob,Grep</code> only, explicit deny list</td>
			</tr>
			<tr>
					<td>Git metadata</td>
					<td>Job token readable in <code>.git/config</code></td>
					<td><code>Read</code> denied for <code>.git</code> and its contents</td>
			</tr>
			<tr>
					<td>Stale results</td>
					<td>Not checked</td>
					<td>Head/base/checkout re-verified, STALE comment on mismatch</td>
			</tr>
	</tbody>
</table>
<h2 id="testing-without-spending-a-turn">Testing Without Spending a Turn</h2>
<p>The workflow&rsquo;s own tests don&rsquo;t call the model at all. A separate CI job runs mocked-response tests against the preflight and revision-check logic, plus <a href="https://github.com/rhysd/actionlint">actionlint</a> against both workflow files:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>node --test .github/scripts/manual-review.test.cjs
</span></span><span style="display:flex;"><span>go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -shellcheck<span style="color:#f92672">=</span> -pyflakes<span style="color:#f92672">=</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  .github/workflows/manual-claude-review.yml .github/workflows/manual-review-tests.yml
</span></span></code></pre></div><p>That proves the guardrails and workflow syntax are correct. It doesn&rsquo;t prove the provider&rsquo;s runtime tool enforcement — a live <code>@claude review</code> request against an authorized PR is still the only way to confirm end to end that the final comment gets posted and failures are reported correctly.</p>
<h2 id="budget-stays-bounded-too">Budget Stays Bounded Too</h2>
<p>The job itself is capped three ways: <strong>16 turns</strong>, an <strong>8-minute</strong> limit on the review step, and a <strong>20-minute</strong> ceiling on the whole job. Auth comes from a <code>CLAUDE_CODE_OAUTH_TOKEN</code> repository secret tied to a Claude Max subscription. There&rsquo;s no API-key fallback, so a misconfigured secret fails the preflight instead of silently billing somewhere else.</p>
<p>None of this is exotic. It&rsquo;s the same instinct as any other CI hardening: don&rsquo;t trust a string comparison you haven&rsquo;t tested for case sensitivity, don&rsquo;t hand out more filesystem access than the job needs, and don&rsquo;t let a slow job&rsquo;s output outlive the code it described.</p>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>