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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ ScopeTrail is a free OSS CLI and GitHub Action that reviews pull requests for ri
- `.vscode/mcp.json`
- `.codeium/windsurf/mcp_config.json`
- `mcp_config.json.sample`, `mcp_config.json.template`, `mcp_config.json.disabled`, and `mcp_config.json.example`
- Prefixed sample MCP configs such as `example_mcp_config.json`, `claude_mcp_config.json`, `cursor_mcp_config.json`, and `vscode_mcp_config.json`
- `.claude/settings.json`
- `.codex/config.toml`
- Terminal, Markdown, JSON, and line-level GitHub annotation output
Expand Down Expand Up @@ -92,7 +93,7 @@ jobs:
with:
fetch-depth: 0

- uses: Conalh/ScopeTrail@v0.1.10
- uses: Conalh/ScopeTrail@v0.1.11
with:
fail-on: none
```
Expand Down Expand Up @@ -122,7 +123,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`, `.mcp.json.windows.example`, `.mcp.json.example.mac`, 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`, nested `mcp_config.json.example`, and prefixed files such as `example_mcp_config.json` 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
10 changes: 9 additions & 1 deletion dist/detectors/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ const MCP_SAMPLE_CONFIG_FILENAMES = new Set([
'mcp_config.json.template',
'mcp_config.json.example'
]);
const MCP_PREFIXED_SAMPLE_CONFIG_FILENAMES = new Set([
'example_mcp_config.json',
'claude_mcp_config.json',
'cursor_mcp_config.json',
'vscode_mcp_config.json'
]);
const MCP_EXAMPLE_BASE_FILENAMES = ['.mcp.json', 'mcp_config.json'];
const MCP_PLATFORM_EXAMPLE_QUALIFIERS = new Set([
'darwin',
Expand Down Expand Up @@ -169,7 +175,9 @@ export function isMcpSampleConfigPath(relativePath) {
}
const fileName = segments.at(-1);
return fileName
? MCP_SAMPLE_CONFIG_FILENAMES.has(fileName) || isPlatformSuffixedMcpExampleFileName(fileName)
? MCP_SAMPLE_CONFIG_FILENAMES.has(fileName)
|| MCP_PREFIXED_SAMPLE_CONFIG_FILENAMES.has(fileName)
|| isPlatformSuffixedMcpExampleFileName(fileName)
: false;
}
function isPlatformSuffixedMcpExampleFileName(fileName) {
Expand Down
4 changes: 2 additions & 2 deletions docs/PILOT.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
with:
fetch-depth: 0

- uses: Conalh/ScopeTrail@v0.1.10
- uses: Conalh/ScopeTrail@v0.1.11
with:
fail-on: none
```
Expand All @@ -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, including platform-suffixed examples, correctly stay separate from active MCP server drift?
- Did sample/template/disabled MCP config findings, including platform-suffixed and prefixed MCP config 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`, 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.
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`, nested `mcp_config.json.example` variants, and prefixed MCP config example files such as `example_mcp_config.json`, `claude_mcp_config.json`, `cursor_mcp_config.json`, and `vscode_mcp_config.json`. 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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "scopetrail",
"version": "0.1.10",
"version": "0.1.11",
"description": "Code review for AI agent permission drift.",
"type": "module",
"bin": {
Expand Down
11 changes: 10 additions & 1 deletion src/detectors/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ const MCP_SAMPLE_CONFIG_FILENAMES = new Set([
'mcp_config.json.example'
]);

const MCP_PREFIXED_SAMPLE_CONFIG_FILENAMES = new Set([
'example_mcp_config.json',
'claude_mcp_config.json',
'cursor_mcp_config.json',
'vscode_mcp_config.json'
]);

const MCP_EXAMPLE_BASE_FILENAMES = ['.mcp.json', 'mcp_config.json'] as const;

const MCP_PLATFORM_EXAMPLE_QUALIFIERS = new Set([
Expand Down Expand Up @@ -201,7 +208,9 @@ export function isMcpSampleConfigPath(relativePath: string): boolean {

const fileName = segments.at(-1);
return fileName
? MCP_SAMPLE_CONFIG_FILENAMES.has(fileName) || isPlatformSuffixedMcpExampleFileName(fileName)
? MCP_SAMPLE_CONFIG_FILENAMES.has(fileName)
|| MCP_PREFIXED_SAMPLE_CONFIG_FILENAMES.has(fileName)
|| isPlatformSuffixedMcpExampleFileName(fileName)
: false;
}

Expand Down
2 changes: 1 addition & 1 deletion test/action-metadata.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ test('public Action install tags match package version', async () => {
const version = packageJson.version;
const installTagPattern = new RegExp(`Conalh/ScopeTrail@v${version.replaceAll('.', '\\.')}`);

assert.equal(version, '0.1.10');
assert.equal(version, '0.1.11');
assert.match(readme, installTagPattern);
assert.match(pilotGuide, installTagPattern);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"servers": {
"cursor-docs": {

Check warning on line 3 in test/fixtures/mcp-prefixed-sample-drift/new/examples/cursor_mcp_config.json

View workflow job for this annotation

GitHub Actions / permission-drift

ScopeTrail low permission drift

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

Check warning on line 4 in test/fixtures/mcp-prefixed-sample-drift/new/examples/cursor_mcp_config.json

View workflow job for this annotation

GitHub Actions / permission-drift

ScopeTrail medium permission drift

Sample/disabled MCP server "cursor-docs" points at remote endpoint: https://mcp.example.com/cursor. 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": {
"copy-risk": {

Check warning on line 3 in test/fixtures/mcp-prefixed-sample-drift/new/examples/example_mcp_config.json

View workflow job for this annotation

GitHub Actions / permission-drift

ScopeTrail low permission drift

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

Check warning on line 7 in test/fixtures/mcp-prefixed-sample-drift/new/examples/example_mcp_config.json

View workflow job for this annotation

GitHub Actions / permission-drift

ScopeTrail medium permission drift

Sample/disabled MCP server "copy-risk" uses an unpinned command: npx -y @acme/copy-risk@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-prefixed-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 @@ -190,6 +190,56 @@
}
});

test('CLI git diff snapshots prefixed MCP config example paths', async () => {
const repo = await mkdtemp(join(tmpdir(), 'scopetrail-git-prefixed-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', 'example_mcp_config.json'),
`${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 prefixed 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/example_mcp_config.json');
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
29 changes: 29 additions & 0 deletions test/mcp-drift.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ test('recognizes platform-suffixed MCP examples while ignoring backup files', ()
assert.equal(isMcpSampleConfigPath('examples/.mcp.json.bak'), false);
});

test('recognizes prefixed MCP config examples while ignoring broad registry and backup names', () => {
assert.equal(isMcpSampleConfigPath('examples/example_mcp_config.json'), true);
assert.equal(isMcpSampleConfigPath('examples/claude_mcp_config.json'), true);
assert.equal(isMcpSampleConfigPath('examples/cursor_mcp_config.json'), true);
assert.equal(isMcpSampleConfigPath('examples/vscode_mcp_config.json'), true);
assert.equal(isMcpSampleConfigPath('examples/registry_mcp_config.json'), false);
assert.equal(isMcpSampleConfigPath('examples/example_mcp_config.json.bak'), false);
assert.equal(isMcpSampleConfigPath('dist/example_mcp_config.json'), 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');
Expand All @@ -101,3 +111,22 @@ test('detects platform-suffixed MCP example drift without treating it as active
]
);
});

test('detects prefixed MCP config example drift without treating it as active server drift', async () => {
const oldDir = join(testDir, 'fixtures', 'mcp-prefixed-sample-drift', 'old');
const newDir = join(testDir, 'fixtures', 'mcp-prefixed-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/cursor_mcp_config.json', 'mcp_sample_server_added', 'cursor-docs', 'low', 3],
['examples/cursor_mcp_config.json', 'mcp_sample_remote_endpoint', 'cursor-docs', 'medium', 4],
['examples/example_mcp_config.json', 'mcp_sample_server_added', 'copy-risk', 'low', 3],
['examples/example_mcp_config.json', 'mcp_sample_unpinned_command', 'copy-risk', 'medium', 7]
]
);
});
3 changes: 3 additions & 0 deletions test/public-docs.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 { readFile } from 'node:fs/promises';
Expand Down Expand Up @@ -44,9 +44,12 @@
assert.match(readme, /\.mcp\.json\.sample/);
assert.match(readme, /\.mcp\.json\.windows\.example/);
assert.match(readme, /mcp_config\.json\.example/);
assert.match(readme, /example_mcp_config\.json/);
assert.match(trust, /sample\/template\/disabled MCP config files/i);
assert.match(trust, /platform-suffixed MCP example files/i);
assert.match(trust, /prefixed MCP config example files/i);
assert.match(pilot, /sample\/template\/disabled MCP config findings/i);
assert.match(pilot, /prefixed MCP config examples/i);
});

test('adoption checklist defines advisory-first rollout and feedback path', async () => {
Expand Down
Loading