Disable auto-approval of project MCP servers when .mcp.json changes in PR#942
Disable auto-approval of project MCP servers when .mcp.json changes in PR#942ashwin-ant wants to merge 2 commits intomainfrom
Conversation
src/entrypoints/run.ts
Outdated
| const { data: changedFiles } = await octokit.rest.pulls.listFiles({ | ||
| owner: context.repository.owner, | ||
| repo: context.repository.repo, | ||
| pull_number: context.entityNumber, | ||
| per_page: 100, | ||
| }); |
There was a problem hiding this comment.
Bug: Pagination bypass allows .mcp.json to be hidden in large PRs
pulls.listFiles returns at most per_page results (100 here) and requires pagination for PRs with more files. An attacker could pad a PR with 100+ changed files to push .mcp.json off the first page, silently bypassing this security check.
GitHub orders files alphabetically, so root .mcp.json sorts early (. < most chars), but a nested path like zzz/.mcp.json would sort late and be trivially exploitable.
Consider using octokit.paginate to iterate all pages:
| const { data: changedFiles } = await octokit.rest.pulls.listFiles({ | |
| owner: context.repository.owner, | |
| repo: context.repository.repo, | |
| pull_number: context.entityNumber, | |
| per_page: 100, | |
| }); | |
| const changedFiles = await octokit.paginate( | |
| octokit.rest.pulls.listFiles, | |
| { | |
| owner: context.repository.owner, | |
| repo: context.repository.repo, | |
| pull_number: context.entityNumber, | |
| per_page: 100, | |
| }, | |
| ); |
Note: the GitHub API has a hard cap of 3,000 files per PR regardless of pagination, but this covers the vast majority of cases.
There was a problem hiding this comment.
+1, this seems like a legitimate bug.
| } catch (e) { | ||
| console.log( | ||
| `Could not check PR changed files: ${e}. Defaulting to mcpJsonChanged=false.`, | ||
| ); |
There was a problem hiding this comment.
Security: Fail-open on error is the wrong default for a security check
If the API call fails (rate limiting, network error, token permission issue), this defaults to mcpJsonChanged = false, meaning all project MCP servers are auto-approved. For a check explicitly designed to prevent RCE via malicious MCP servers, this should fail closed:
| } catch (e) { | |
| console.log( | |
| `Could not check PR changed files: ${e}. Defaulting to mcpJsonChanged=false.`, | |
| ); | |
| } catch (e) { | |
| console.log( | |
| `Could not check PR changed files: ${e}. Defaulting to mcpJsonChanged=true (fail-closed).`, | |
| ); | |
| mcpJsonChanged = true; |
| export async function setupClaudeCodeSettings( | ||
| settingsInput?: string, | ||
| mcpJsonChanged = false, | ||
| homeDir?: string, |
There was a problem hiding this comment.
Bug: This signature change breaks the call site in base-action/src/index.ts
The standalone base-action/src/index.ts calls this function as:
await setupClaudeCodeSettings(
process.env.INPUT_SETTINGS,
undefined, // homeDir <-- now maps to mcpJsonChanged, not homeDir
);After inserting mcpJsonChanged as the second parameter, that call now passes undefined for mcpJsonChanged (happens to be falsy, so it works by accident) and never passes homeDir. The // homeDir comment is now incorrect.
This needs to be fixed in index.ts — at minimum update it to (process.env.INPUT_SETTINGS, false, undefined).
More broadly, consider using an options object instead of positional parameters to avoid this class of bug:
setupClaudeCodeSettings(options: {
settingsInput?: string;
mcpJsonChanged?: boolean;
homeDir?: string;
})This is especially important since base-action is published as a standalone package (@anthropic-ai/claude-code-base-action) and the CLAUDE.md warns "Don't break its public API."
| if (!mcpJsonChanged) { | ||
| // Only auto-approve project MCP servers if .mcp.json hasn't changed in this PR. | ||
| // If .mcp.json changed, the checked-out version may differ from the base branch. | ||
| settings.enableAllProjectMcpServers = true; | ||
| console.log(`Updated settings with enableAllProjectMcpServers: true`); | ||
| } else { | ||
| console.log( | ||
| `Skipping enableAllProjectMcpServers=true because .mcp.json changed in this PR`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Security gap: User settings input can re-enable enableAllProjectMcpServers when .mcp.json changed
When mcpJsonChanged is true, this code skips setting enableAllProjectMcpServers = true. However, the merge on line 59 (settings = { ...settings, ...inputSettings }) already ran — so if the user's INPUT_SETTINGS includes enableAllProjectMcpServers: true, it's already in the settings object and the else branch doesn't remove it.
This means a workflow configured with enableAllProjectMcpServers: true in its settings input would override the protection. Consider explicitly deleting or setting it to false in the else branch:
| if (!mcpJsonChanged) { | |
| // Only auto-approve project MCP servers if .mcp.json hasn't changed in this PR. | |
| // If .mcp.json changed, the checked-out version may differ from the base branch. | |
| settings.enableAllProjectMcpServers = true; | |
| console.log(`Updated settings with enableAllProjectMcpServers: true`); | |
| } else { | |
| console.log( | |
| `Skipping enableAllProjectMcpServers=true because .mcp.json changed in this PR`, | |
| ); | |
| } | |
| if (!mcpJsonChanged) { | |
| // Only auto-approve project MCP servers if .mcp.json hasn't changed in this PR. | |
| // If .mcp.json changed, the checked-out version may differ from the base branch. | |
| settings.enableAllProjectMcpServers = true; | |
| console.log(`Updated settings with enableAllProjectMcpServers: true`); | |
| } else { | |
| // Force-disable even if user input set it to true, since .mcp.json is untrusted. | |
| delete settings.enableAllProjectMcpServers; | |
| console.log( | |
| `Removing enableAllProjectMcpServers because .mcp.json changed in this PR`, | |
| ); | |
| } |
The test suite also doesn't cover the case where input sets enableAllProjectMcpServers: true with mcpJsonChanged: true.
Code Review SummaryThe security motivation here is sound — auto-approving project MCP servers when Must fix
Should fix
Consider
|
src/entrypoints/run.ts
Outdated
| const { data: changedFiles } = await octokit.rest.pulls.listFiles({ | ||
| owner: context.repository.owner, | ||
| repo: context.repository.repo, | ||
| pull_number: context.entityNumber, | ||
| per_page: 100, | ||
| }); |
There was a problem hiding this comment.
+1, this seems like a legitimate bug.
| } catch (e) { | ||
| console.log( | ||
| `Could not check PR changed files: ${e}. Defaulting to mcpJsonChanged=false.`, | ||
| ); |
…n PR When a PR modifies .mcp.json, the checked-out working directory contains a version that may differ from the base branch. Blindly setting enableAllProjectMcpServers=true in that context auto-approves those MCP servers without the user having reviewed them. Fix: before calling setupClaudeCodeSettings, check whether .mcp.json changed in the triggering PR using the GitHub REST API (pulls.listFiles). If it did, pass mcpJsonChanged=true to suppress the enableAllProjectMcpServers=true override. When .mcp.json is unmodified (the common case), behaviour is unchanged. - Add mcpJsonChanged parameter to setupClaudeCodeSettings - Add PR file-list check in src/entrypoints/run.ts - Update tests: update existing override test, add two new mcpJsonChanged=true tests
- Use octokit.rest.paginate() to fetch all pages of PR changed files, preventing attackers from padding PRs with 100+ files to push .mcp.json off the first page - Change catch block to fail closed (mcpJsonChanged=true) so MCP servers are not auto-approved when the API call fails
3b21ebf to
f4ac33b
Compare
What and why
When a PR modifies
.mcp.json, the checked-out working directory contains a version that may differ from the base branch. The current code unconditionally setsenableAllProjectMcpServers = trueinsetupClaudeCodeSettings, which auto-approves all project MCP servers — including those introduced in the PR's.mcp.json— without the user having reviewed them.This PR makes auto-approval conditional: if
.mcp.jsonchanged in the triggering PR, we skip settingenableAllProjectMcpServers = trueso those servers are not automatically trusted.Changes
base-action/src/setup-claude-code-settings.tsAdded a
mcpJsonChangedboolean parameter (defaultfalse). TheenableAllProjectMcpServers = trueassignment now only runs whenmcpJsonChangedis false. When it's true, a log message is emitted instead. All existing behaviour is preserved for non-PR events and for PRs that don't touch.mcp.json.src/entrypoints/run.tsBefore calling
setupClaudeCodeSettings, useoctokit.rest.pulls.listFilesto fetch the files changed in the PR (when the event is a PR-type event). If any changed file is.mcp.jsonor ends with/.mcp.json, passmcpJsonChanged=true. Errors from the API call are caught and logged; on any failure the flag staysfalse(safe default).base-action/test/setup-claude-code-settings.test.tsfalseformcpJsonChanged(no behaviour change)mcpJsonChanged=falsemcpJsonChanged=true: one verifyingenableAllProjectMcpServersstaysundefinedwhen not set in input, one verifying it staysfalsewhen the input sets it tofalse