Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/PILOT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
2 changes: 1 addition & 1 deletion docs/TRUST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
33 changes: 32 additions & 1 deletion src/detectors/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<string[]> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"servers": {
"mac-docs": {

Check warning on line 3 in test/fixtures/mcp-platform-sample-drift/new/examples/.mcp.json.example.mac

View workflow job for this annotation

GitHub Actions / permission-drift

ScopeTrail low permission drift

Sample/disabled MCP server "mac-docs" was added. Recommendation: Confirm this sample config is intentionally shipped and safe for users to copy before merging.
"url": "https://mcp.example.com/sse"

Check warning on line 4 in test/fixtures/mcp-platform-sample-drift/new/examples/.mcp.json.example.mac

View workflow job for this annotation

GitHub Actions / permission-drift

ScopeTrail medium permission drift

Sample/disabled MCP server "mac-docs" points at remote endpoint: https://mcp.example.com/sse. Recommendation: Confirm the endpoint is intended for copied sample configs and does not expose unexpected data or tools.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"mcpServers": {
"win-tools": {

Check warning on line 3 in test/fixtures/mcp-platform-sample-drift/new/examples/.mcp.json.windows.example

View workflow job for this annotation

GitHub Actions / permission-drift

ScopeTrail low permission drift

Sample/disabled MCP server "win-tools" was added. Recommendation: Confirm this sample config is intentionally shipped and safe for users to copy before merging.
"command": "npx",
"args": [
"-y",
"@acme/win-tools@latest"

Check warning on line 7 in test/fixtures/mcp-platform-sample-drift/new/examples/.mcp.json.windows.example

View workflow job for this annotation

GitHub Actions / permission-drift

ScopeTrail medium permission drift

Sample/disabled MCP server "win-tools" uses an unpinned command: npx -y @acme/win-tools@latest. Recommendation: Pin sample MCP packages to an exact version so users do not copy a drifting install command.
]
}
}
}
1 change: 1 addition & 0 deletions test/fixtures/mcp-platform-sample-drift/old/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

50 changes: 50 additions & 0 deletions test/git-diff.test.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
Expand Down Expand Up @@ -140,6 +140,56 @@
}
});

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`);
Expand Down
28 changes: 27 additions & 1 deletion test/mcp-drift.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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]
]
);
});
2 changes: 2 additions & 0 deletions test/public-docs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down