diff --git a/README.md b/README.md index f4dc6ff..2c3d05c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Code review for AI agent permission drift. ScopeTrail is a free OSS CLI and GitHub Action that reviews pull requests for risky changes to AI-agent configuration files. - `.mcp.json` -- `.mcp.json.sample`, `.mcp.json.template`, `.mcp.json.disabled`, and `.mcp.json.example` +- `.mcp.json.sample`, `.mcp.json.template`, `.mcp.json.disabled`, `.mcp.json.example`, and platform-suffixed examples such as `.mcp.json.windows.example` - `.cursor/mcp.json` - `.vscode/mcp.json` - `.codeium/windsurf/mcp_config.json` @@ -120,7 +120,7 @@ ScopeTrail v0 detects: - Cursor, VS Code, and Windsurf MCP config files using `mcpServers` or `servers` where supported. - Windsurf remote MCP endpoint changes through `serverUrl`. - Sample/template/disabled MCP config drift as a separate advisory category, not active server drift. -- Risky copied MCP examples such as `.mcp.json.sample`, `.mcp.json.template`, `.mcp.json.disabled`, and nested `mcp_config.json.example` files with unpinned commands or remote endpoints. +- Risky copied MCP examples such as `.mcp.json.sample`, `.mcp.json.template`, `.mcp.json.disabled`, `.mcp.json.windows.example`, `.mcp.json.example.mac`, and nested `mcp_config.json.example` files with unpinned commands or remote endpoints. - Broad Claude Code allow rules such as `Bash(npm *)` and `Read(~/**)`. Scoped grants (`WebFetch(domain:example.com)`, `mcp__github__get_issue`) are recognized as narrow and not flagged. - Removed Claude Code deny rules for sensitive files such as `.env`. - Claude Code hook changes: **removed**, **added**, and **command-changed** (a strict `PreToolUse` swapped for a no-op script is the same risk as a removal — both are now caught). diff --git a/docs/PILOT.md b/docs/PILOT.md index 0ec83ef..c69850e 100644 --- a/docs/PILOT.md +++ b/docs/PILOT.md @@ -36,7 +36,7 @@ Useful checks during the trial: - Did ScopeTrail catch real permission drift? - Did any warning feel noisy or too broad? -- Did sample/template/disabled MCP config findings correctly stay separate from active MCP server drift? +- Did sample/template/disabled MCP config findings, including platform-suffixed examples, correctly stay separate from active MCP server drift? - Did it miss an agent config surface your repository uses? - Would a team workflow need cross-repo visibility, policy ownership, exception workflow, or reporting? diff --git a/docs/TRUST.md b/docs/TRUST.md index 8369620..2c0f131 100644 --- a/docs/TRUST.md +++ b/docs/TRUST.md @@ -6,7 +6,7 @@ ScopeTrail is a local-only GitHub Action and CLI for reviewing AI-agent permissi ScopeTrail reads the checked-out repository and compares supported agent configuration files between the pull request base and head refs. Supported active files include `.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `.codeium/windsurf/mcp_config.json`, `.claude/settings.json`, and `.codex/config.toml`. -ScopeTrail also reviews sample/template/disabled MCP config files such as `.mcp.json.sample`, `.mcp.json.template`, `.mcp.json.disabled`, `.mcp.json.example`, and nested `mcp_config.json.example` variants. Those findings are reported separately from active MCP server drift so copied examples can be reviewed without implying they are live configuration. +ScopeTrail also reviews sample/template/disabled MCP config files such as `.mcp.json.sample`, `.mcp.json.template`, `.mcp.json.disabled`, `.mcp.json.example`, platform-suffixed MCP example files such as `.mcp.json.windows.example` and `.mcp.json.example.mac`, and nested `mcp_config.json.example` variants. Those findings are reported separately from active MCP server drift so copied examples can be reviewed without implying they are live configuration. In GitHub Actions, `fetch-depth: 0` is required so ScopeTrail can compare the pull request base and head commits instead of only seeing the latest checkout. diff --git a/src/detectors/mcp.ts b/src/detectors/mcp.ts index f45e843..66e75d5 100644 --- a/src/detectors/mcp.ts +++ b/src/detectors/mcp.ts @@ -20,6 +20,19 @@ const MCP_SAMPLE_CONFIG_FILENAMES = new Set([ 'mcp_config.json.example' ]); +const MCP_EXAMPLE_BASE_FILENAMES = ['.mcp.json', 'mcp_config.json'] as const; + +const MCP_PLATFORM_EXAMPLE_QUALIFIERS = new Set([ + 'darwin', + 'linux', + 'mac', + 'macos', + 'osx', + 'win', + 'win32', + 'windows' +]); + const IGNORED_SAMPLE_SCAN_DIRS = new Set([ '.git', 'node_modules', @@ -187,7 +200,25 @@ export function isMcpSampleConfigPath(relativePath: string): boolean { } const fileName = segments.at(-1); - return fileName ? MCP_SAMPLE_CONFIG_FILENAMES.has(fileName) : false; + return fileName + ? MCP_SAMPLE_CONFIG_FILENAMES.has(fileName) || isPlatformSuffixedMcpExampleFileName(fileName) + : false; +} + +function isPlatformSuffixedMcpExampleFileName(fileName: string): boolean { + for (const baseName of MCP_EXAMPLE_BASE_FILENAMES) { + const prefix = `${baseName}.`; + if (!fileName.startsWith(prefix)) { + continue; + } + + const qualifiers = fileName.slice(prefix.length).split('.').map((segment) => segment.toLowerCase()); + return qualifiers.length > 1 + && qualifiers.includes('example') + && qualifiers.every((segment) => segment === 'example' || MCP_PLATFORM_EXAMPLE_QUALIFIERS.has(segment)); + } + + return false; } async function listMcpSampleConfigPaths(...roots: string[]): Promise { diff --git a/test/fixtures/mcp-platform-sample-drift/new/examples/.mcp.json.example.mac b/test/fixtures/mcp-platform-sample-drift/new/examples/.mcp.json.example.mac new file mode 100644 index 0000000..18b631e --- /dev/null +++ b/test/fixtures/mcp-platform-sample-drift/new/examples/.mcp.json.example.mac @@ -0,0 +1,7 @@ +{ + "servers": { + "mac-docs": { + "url": "https://mcp.example.com/sse" + } + } +} diff --git a/test/fixtures/mcp-platform-sample-drift/new/examples/.mcp.json.windows.example b/test/fixtures/mcp-platform-sample-drift/new/examples/.mcp.json.windows.example new file mode 100644 index 0000000..624e17c --- /dev/null +++ b/test/fixtures/mcp-platform-sample-drift/new/examples/.mcp.json.windows.example @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "win-tools": { + "command": "npx", + "args": [ + "-y", + "@acme/win-tools@latest" + ] + } + } +} diff --git a/test/fixtures/mcp-platform-sample-drift/old/.gitkeep b/test/fixtures/mcp-platform-sample-drift/old/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/fixtures/mcp-platform-sample-drift/old/.gitkeep @@ -0,0 +1 @@ + diff --git a/test/git-diff.test.mjs b/test/git-diff.test.mjs index 680cc0c..01e441f 100644 --- a/test/git-diff.test.mjs +++ b/test/git-diff.test.mjs @@ -140,6 +140,56 @@ test('CLI git diff snapshots sample MCP config paths', async () => { } }); +test('CLI git diff snapshots platform-suffixed MCP example paths', async () => { + const repo = await mkdtemp(join(tmpdir(), 'scopetrail-git-platform-sample-')); + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'ScopeTrail Test'); + await execGit(repo, 'config', 'user.email', 'scopetrail@example.invalid'); + + await writeFile(join(repo, 'README.md'), 'base\n'); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base'); + const base = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await mkdir(join(repo, 'examples'), { recursive: true }); + await writeFile( + join(repo, 'examples', '.mcp.json.windows.example'), + `${JSON.stringify( + { + mcpServers: { + 'copy-risk': { + command: 'npx', + args: ['-y', '@acme/copy-risk@latest'] + } + } + }, + null, + 2 + )}\n` + ); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'add platform sample mcp config'); + const head = await gitStdout(repo, 'rev-parse', 'HEAD'); + + const { stdout } = await execFileAsync( + process.execPath, + ['dist/index.js', 'diff', '--repo', repo, '--base', base, '--head', head, '--format', 'json'], + { cwd: packageRoot } + ); + const report = JSON.parse(stdout); + + assert.deepEqual( + report.findings.map((finding) => finding.kind), + ['mcp_sample_server_added', 'mcp_sample_unpinned_command'] + ); + assert.equal(report.findings[0].file, 'examples/.mcp.json.windows.example'); + assert.equal(report.findings.some((finding) => finding.kind === 'mcp_server_added'), false); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + async function writeConfig(repo, { mcp, claude }) { await mkdir(join(repo, '.claude'), { recursive: true }); await writeFile(join(repo, '.mcp.json'), `${JSON.stringify(mcp, null, 2)}\n`); diff --git a/test/mcp-drift.test.mjs b/test/mcp-drift.test.mjs index b75c6e7..9d296d1 100644 --- a/test/mcp-drift.test.mjs +++ b/test/mcp-drift.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import { detectMcpDrift } from '../dist/detectors/mcp.js'; +import { detectMcpDrift, isMcpSampleConfigPath } from '../dist/detectors/mcp.js'; const testDir = dirname(fileURLToPath(import.meta.url)); @@ -75,3 +75,29 @@ test('detects sample MCP config drift without treating it as active server drift ] ); }); + +test('recognizes platform-suffixed MCP examples while ignoring backup files', () => { + assert.equal(isMcpSampleConfigPath('examples/.mcp.json.windows.example'), true); + assert.equal(isMcpSampleConfigPath('examples/.mcp.json.example.mac'), true); + assert.equal(isMcpSampleConfigPath('examples/.mcp.json.backup'), false); + assert.equal(isMcpSampleConfigPath('examples/.mcp.json.bak'), false); +}); + +test('detects platform-suffixed MCP example drift without treating it as active server drift', async () => { + const oldDir = join(testDir, 'fixtures', 'mcp-platform-sample-drift', 'old'); + const newDir = join(testDir, 'fixtures', 'mcp-platform-sample-drift', 'new'); + + const findings = await detectMcpDrift(oldDir, newDir); + + assert.equal(findings.some((finding) => finding.kind === 'mcp_server_added'), false); + assert.equal(findings.some((finding) => finding.kind === 'unpinned_mcp_command'), false); + assert.deepEqual( + findings.map((finding) => [finding.file, finding.kind, finding.subject, finding.severity, finding.line]), + [ + ['examples/.mcp.json.example.mac', 'mcp_sample_server_added', 'mac-docs', 'low', 3], + ['examples/.mcp.json.example.mac', 'mcp_sample_remote_endpoint', 'mac-docs', 'medium', 4], + ['examples/.mcp.json.windows.example', 'mcp_sample_server_added', 'win-tools', 'low', 3], + ['examples/.mcp.json.windows.example', 'mcp_sample_unpinned_command', 'win-tools', 'medium', 7] + ] + ); +}); diff --git a/test/public-docs.test.mjs b/test/public-docs.test.mjs index 6118fa0..2fca58f 100644 --- a/test/public-docs.test.mjs +++ b/test/public-docs.test.mjs @@ -38,8 +38,10 @@ test('public docs describe active and sample MCP config coverage', async () => { assert.match(readme, /sample\/template\/disabled MCP config drift/i); assert.match(readme, /\.mcp\.json\.sample/); + assert.match(readme, /\.mcp\.json\.windows\.example/); assert.match(readme, /mcp_config\.json\.example/); assert.match(trust, /sample\/template\/disabled MCP config files/i); + assert.match(trust, /platform-suffixed MCP example files/i); assert.match(pilot, /sample\/template\/disabled MCP config findings/i); });