diff --git a/.gitattributes b/.gitattributes
index 176a458..7b3c517 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,18 @@
* text=auto
+
+# Pin source and generated text files to LF so Windows checkouts under
+# core.autocrlf=true don't oscillate between LF and CRLF.
+*.js text eol=lf
+*.mjs text eol=lf
+*.cjs text eol=lf
+*.ts text eol=lf
+*.json text eol=lf
+*.jsonc text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+*.toml text eol=lf
+*.md text eol=lf
+*.sh text eol=lf
+.gitattributes text eol=lf
+.gitignore text eol=lf
+LICENSE text eol=lf
diff --git a/.github/ISSUE_TEMPLATE/team-adoption.yml b/.github/ISSUE_TEMPLATE/team-adoption.yml
new file mode 100644
index 0000000..95092ec
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/team-adoption.yml
@@ -0,0 +1,48 @@
+name: Team adoption signal
+description: Share rollout pain when ScopeTrail's gap is about ownership, reporting, exceptions, or many repositories.
+title: "[team adoption]: "
+labels: ["adoption", "team-signal"]
+body:
+ - type: textarea
+ id: pain
+ attributes:
+ label: Rollout pain
+ description: Describe the team-level gap. ScopeTrail is intentionally small at v0, so signal beats prescription here.
+ validations:
+ required: true
+ - type: dropdown
+ id: theme
+ attributes:
+ label: Primary theme
+ description: Which adoption gap does this fall under?
+ options:
+ - Ownership (who triages findings?)
+ - Reporting (rollup across repos / over time)
+ - Exceptions (allowlists, suppressions, justified drift)
+ - Many repositories (scale of rollout)
+ - Other
+ validations:
+ required: true
+ - type: dropdown
+ id: scope
+ attributes:
+ label: Affected scope
+ description: Help prioritize whether this matters for one repo or repeated team workflows.
+ options:
+ - One repository
+ - Multiple repositories
+ - Organization-wide pattern
+ - Not sure yet
+ validations:
+ required: true
+ - type: input
+ id: repository-count
+ attributes:
+ label: Approximate repository count
+ description: Optional estimate if this affects more than one repository.
+ placeholder: "Example: 1, 5, 20+"
+ - type: textarea
+ id: current-workaround
+ attributes:
+ label: Current workaround
+ description: How is the team handling this today? (Manual review, spreadsheet, a different tool, nothing yet, etc.)
diff --git a/.gitignore b/.gitignore
index 5f1c5c9..c12810d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
node_modules/
-# Local codex working state — not for tracking
-.codex/
+# Local codex working state — not for tracking. The detector fixtures
+# under test/fixtures/**/.codex/ are deliberately included; only the
+# top-level dogfood .codex/ is ignored.
+/.codex/
diff --git a/README.md b/README.md
index 45810db..2d579f7 100644
--- a/README.md
+++ b/README.md
@@ -129,6 +129,7 @@ ScopeTrail v0 detects:
- 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).
- Codex config drift such as full-access/elevated sandboxes, weakened approval policy, enabled network access, or trusted project settings.
+- Codex `[mcp_servers.NAME]` additions, launch-command changes, and unpinned commands (the same risk model as `.mcp.json` MCP detection, applied to TOML).
The git-mode snapshot list is derived from the detectors themselves, so adding a new surface in one place can never leave the GitHub Action silently blind to it. A regression test fails the build if a detector's target paths aren't covered.
diff --git a/action.yml b/action.yml
index e599806..611bd62 100644
--- a/action.yml
+++ b/action.yml
@@ -65,9 +65,20 @@ runs:
report_file="${RUNNER_TEMP:-.}/scopetrail-report.md"
json_file="${RUNNER_TEMP:-.}/scopetrail-report.json"
- node "$GITHUB_ACTION_PATH/dist/index.js" diff --repo "$repo" --base "$base" --head "$head" --format markdown | tee "$report_file"
- node "$GITHUB_ACTION_PATH/dist/index.js" diff --repo "$repo" --base "$base" --head "$head" --format json > "$json_file"
- node "$GITHUB_ACTION_PATH/dist/index.js" diff --repo "$repo" --base "$base" --head "$head" --format github
+ # Single scan: stdout streams GitHub annotations so the runner
+ # picks up ::warning lines, while --out-markdown and --out-json
+ # capture the other two renderings from the same run. Previously
+ # this ran the CLI three times (markdown/json/github), repeating
+ # both git snapshot materialization and full detector work.
+ node "$GITHUB_ACTION_PATH/dist/index.js" diff \
+ --repo "$repo" --base "$base" --head "$head" \
+ --format github \
+ --out-markdown "$report_file" \
+ --out-json "$json_file"
+
+ # Surface the markdown report in the Action log for parity
+ # with the prior `tee` of `--format markdown`.
+ cat "$report_file"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
cat "$report_file" >> "$GITHUB_STEP_SUMMARY"
diff --git a/dist/detectors/claude-settings.js b/dist/detectors/claude-settings.js
index b0b5bdd..4e3a872 100644
--- a/dist/detectors/claude-settings.js
+++ b/dist/detectors/claude-settings.js
@@ -42,18 +42,22 @@ export async function detectClaudeSettingsDrift(oldRoot, newRoot) {
});
continue;
}
- // Swapping a strict guard for a no-op script is just as material as
- // removing the hook outright — and the previous detector missed it.
+ // Any drift in the command set is material — swapping a strict
+ // guard for a no-op, dropping one guard out of a multi-guard hook,
+ // or appending a no-op alongside the strict guard. The previous
+ // check required `newCommands.size === oldCommands.size`, which
+ // missed adds and drops that changed the count.
const newCommands = newSettings.hookCommands.get(hookName) ?? new Set();
- const changed = [...newCommands].filter((command) => !oldCommands.has(command));
- if (changed.length > 0 && newCommands.size === oldCommands.size) {
+ const added = [...newCommands].filter((command) => !oldCommands.has(command));
+ const removed = [...oldCommands].filter((command) => !newCommands.has(command));
+ if (added.length > 0 || removed.length > 0) {
findings.push({
kind: 'scope_trail.hook_command_changed',
severity: isHighImpactHook(hookName) ? 'high' : 'medium',
file: CLAUDE_SETTINGS_FILE,
subject: hookName,
- message: `Claude hook "${hookName}" command(s) changed: ${changed.join(', ')}.`,
- recommendation: 'Review the new command — a weakened guard (e.g., a no-op script) is the same risk as a removed hook.'
+ message: hookCommandChangeMessage(hookName, added, removed),
+ recommendation: 'Review the change — a removed guard, a no-op appended next to a strict one, or any rewrite of a hook command can all weaken policy.'
});
}
}
@@ -140,15 +144,23 @@ function hookHasEntries(value) {
// its grants properly. Bare tokens and explicit wildcards are still broad.
export function isBroadAllow(permission) {
const normalized = permission.toLowerCase();
- if (/\bbash\([^)]*\*[^)]*\)/.test(normalized)) {
+ // Bare verb (no parentheses) or wildcard-scoped verb for any of the
+ // dangerous operations. This catches `"Bash"`, `"Read"`, `"Write"`,
+ // `"Edit"`, `"WebFetch"`, etc. — bare tokens that grant unlimited
+ // access. Previously only WebFetch/WebSearch/Task went through this
+ // check, which silently let `"Bash"` and bare `"Read"`/`"Write"`/
+ // `"Edit"` slip past unflagged.
+ if (isBroadVerbGrant(normalized, ['bash', 'read', 'write', 'edit', 'webfetch', 'websearch', 'task'])) {
return true;
}
+ // Scoped grants whose target is a rooted path (absolute, home-rel,
+ // or Windows drive). `Read(/etc/passwd)` doesn't include `*` but is
+ // still broader than a workspace-relative path. Stays separate from
+ // the verb-grant check because that path-shape rule only applies to
+ // file-access verbs, not network/task verbs.
if (/\b(read|write|edit)\((~|[a-z]:\\|\/|\*\*)/.test(normalized)) {
return true;
}
- if (isBroadVerbGrant(normalized, ['webfetch', 'websearch', 'task'])) {
- return true;
- }
if (isBroadMcpGrant(normalized)) {
return true;
}
@@ -208,3 +220,13 @@ function severityForRemovedDeny(permission) {
function isHighImpactHook(hookName) {
return ['pretooluse', 'posttooluse', 'permissionrequest', 'sessionend'].includes(hookName.toLowerCase());
}
+function hookCommandChangeMessage(hookName, added, removed) {
+ const parts = [];
+ if (added.length > 0) {
+ parts.push(`added: ${added.join(', ')}`);
+ }
+ if (removed.length > 0) {
+ parts.push(`removed: ${removed.join(', ')}`);
+ }
+ return `Claude hook "${hookName}" command(s) changed (${parts.join('; ')}).`;
+}
diff --git a/dist/detectors/codex-config.js b/dist/detectors/codex-config.js
index 19bd0b1..925e939 100644
--- a/dist/detectors/codex-config.js
+++ b/dist/detectors/codex-config.js
@@ -1,5 +1,7 @@
import { readFile } from 'node:fs/promises';
+import { lineOfTomlKey, parseToml } from 'agent-gov-core';
import { configPath } from '../discovery.js';
+import { isUnpinnedCommand, serverCommand } from '../mcp-risk.js';
export const CODEX_CONFIG_FILE = '.codex/config.toml';
export const CODEX_TARGET_PATHS = [CODEX_CONFIG_FILE];
export async function detectCodexConfigDrift(oldRoot, newRoot) {
@@ -62,8 +64,114 @@ export async function detectCodexConfigDrift(oldRoot, newRoot) {
recommendation: 'Only mark projects trusted when repository instructions, hooks, and tool permissions are reviewed.'
});
}
+ for (const finding of await detectCodexMcpDrift(oldRoot, newRoot)) {
+ findings.push(finding);
+ }
+ return findings;
+}
+// Codex `.codex/config.toml` carries the same `[mcp_servers.NAME]`
+// shape that ScopeTrail already flags in `.mcp.json` — without this
+// detector, a Codex user can add `[mcp_servers.stripe-admin]` with
+// `args = ["-y", "@vendor/stripe-mcp@latest"]` and the unpinned MCP
+// risk model never sees it.
+async function detectCodexMcpDrift(oldRoot, newRoot) {
+ const findings = [];
+ const oldServers = await readCodexMcpServers(oldRoot);
+ const newServers = await readCodexMcpServers(newRoot);
+ for (const [name, newServer] of newServers) {
+ const oldServer = oldServers.get(name);
+ const commandChanged = oldServer && serverCommand(newServer) !== serverCommand(oldServer);
+ if (!oldServer) {
+ findings.push({
+ kind: 'scope_trail.codex_mcp_server_added',
+ severity: 'high',
+ file: CODEX_CONFIG_FILE,
+ line: lineForServer(newServer),
+ subject: name,
+ message: `Codex MCP server "${name}" was added.`,
+ recommendation: 'Review the server package, pin its version, and confirm the tools it exposes before merging.'
+ });
+ }
+ else if (commandChanged) {
+ findings.push({
+ kind: 'scope_trail.codex_mcp_server_command_changed',
+ severity: 'medium',
+ file: CODEX_CONFIG_FILE,
+ line: lineForServer(newServer),
+ subject: name,
+ message: `Codex MCP server "${name}" changed its launch command.`,
+ recommendation: 'Confirm the command change is intentional and still points at a trusted, pinned package.'
+ });
+ }
+ if ((!oldServer || commandChanged) && isUnpinnedCommand(newServer)) {
+ findings.push({
+ kind: 'scope_trail.codex_unpinned_mcp_command',
+ severity: 'high',
+ file: CODEX_CONFIG_FILE,
+ line: lineForServer(newServer),
+ subject: name,
+ message: `Codex MCP server "${name}" uses an unpinned command: ${serverCommand(newServer)}.`,
+ recommendation: 'Pin executable packages to an exact version and avoid pipe-to-shell installation commands.'
+ });
+ }
+ }
return findings;
}
+async function readCodexMcpServers(root) {
+ const path = configPath(root, CODEX_CONFIG_FILE);
+ let text = '';
+ try {
+ text = await readFile(path, 'utf8');
+ }
+ catch (error) {
+ if (isNodeError(error) && error.code === 'ENOENT') {
+ return new Map();
+ }
+ throw error;
+ }
+ let parsed;
+ try {
+ parsed = parseToml(text);
+ }
+ catch {
+ return new Map();
+ }
+ const rawServers = parsed.mcp_servers;
+ if (!isPlainObject(rawServers)) {
+ return new Map();
+ }
+ const servers = new Map();
+ for (const [name, entry] of Object.entries(rawServers)) {
+ if (!isPlainObject(entry)) {
+ continue;
+ }
+ servers.set(name, {
+ name,
+ text,
+ command: typeof entry.command === 'string' ? entry.command : undefined,
+ args: Array.isArray(entry.args)
+ ? entry.args.filter((arg) => typeof arg === 'string')
+ : undefined,
+ url: typeof entry.url === 'string' ? entry.url : undefined
+ });
+ }
+ return servers;
+}
+function lineForServer(server) {
+ // Point at the leaf the reviewer most needs to see — `command`
+ // first, then any of the args/url keys. Fall back to file-level
+ // when nothing matches so the finding still surfaces.
+ for (const leaf of ['command', 'args', 'url']) {
+ const line = lineOfTomlKey(server.text, `mcp_servers.${server.name}.${leaf}`);
+ if (line) {
+ return line;
+ }
+ }
+ return undefined;
+}
+function isPlainObject(value) {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
async function readCodexConfig(root) {
let text = '';
try {
diff --git a/dist/detectors/mcp.js b/dist/detectors/mcp.js
index e42fc36..9823048 100644
--- a/dist/detectors/mcp.js
+++ b/dist/detectors/mcp.js
@@ -1,5 +1,6 @@
import { readdir } from 'node:fs/promises';
import { configPath, isRecord, lineOfJsonKey, lineOfJsonStringValue, readJsonObjectWithSource } from '../discovery.js';
+import { isPipeToShellCommand, isUnpinnedCommand, serverCommand } from '../mcp-risk.js';
const MCP_CONFIGS = [
{ path: '.mcp.json', serverKeys: ['mcpServers'] },
{ path: '.cursor/mcp.json', serverKeys: ['mcpServers', 'servers'] },
@@ -130,14 +131,24 @@ export async function detectMcpDrift(oldRoot, newRoot) {
}
const endpoint = remoteEndpoint(newServer);
if ((!oldServer || changed) && endpoint) {
+ const unencrypted = isUnencryptedEndpoint(endpoint);
findings.push({
kind: 'scope_trail.mcp_sample_remote_endpoint',
- severity: 'medium',
+ // An `http://` endpoint in a sample config is worse than an
+ // `https://` one: anyone who copies the sample inherits a
+ // MitM-vulnerable connection. Bump to high; https stays at
+ // medium because the copy-and-paste risk is "is this the
+ // right endpoint?" not "is this transport safe?".
+ severity: unencrypted ? 'high' : 'medium',
file: path,
line: lineForRemoteEndpoint(newServer) ?? newServer.line,
subject: name,
- message: `Sample/disabled MCP server "${name}" points at remote endpoint: ${endpoint}.`,
- recommendation: 'Confirm the endpoint is intended for copied sample configs and does not expose unexpected data or tools.'
+ message: unencrypted
+ ? `Sample/disabled MCP server "${name}" points at an unencrypted remote endpoint: ${endpoint}.`
+ : `Sample/disabled MCP server "${name}" points at remote endpoint: ${endpoint}.`,
+ recommendation: unencrypted
+ ? 'Use https:// for sample remote MCP endpoints — copy-pasted samples should not silently downgrade users to unencrypted transport.'
+ : 'Confirm the endpoint is intended for copied sample configs and does not expose unexpected data or tools.'
});
}
}
@@ -211,18 +222,22 @@ async function collectMcpSampleConfigPaths(root, relativeDir, paths) {
}
throw error;
}
- for (const entry of entries) {
+ // Walk subdirectories in parallel. Each `readdir` is independent
+ // and `paths` is a Set mutated from the same event loop, so add
+ // operations are race-free in single-threaded Node. The caller
+ // already sorts the result, so insertion order doesn't matter.
+ await Promise.all(entries.map(async (entry) => {
const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (!IGNORED_SAMPLE_SCAN_DIRS.has(entry.name)) {
await collectMcpSampleConfigPaths(root, relativePath, paths);
}
- continue;
+ return;
}
if (entry.isFile() && isMcpSampleConfigPath(relativePath)) {
paths.add(relativePath);
}
- }
+ }));
}
function readServerMap(json, serverKeys) {
for (const key of serverKeys) {
@@ -232,36 +247,9 @@ function readServerMap(json, serverKeys) {
}
return undefined;
}
-function serverCommand(server) {
- return [server.command, ...(server.args ?? []), server.url, server.serverUrl].filter(Boolean).join(' ');
-}
-function isUnpinnedCommand(server) {
- const command = serverCommand(server);
- const normalized = command.toLowerCase();
- if (normalized.includes('@latest')) {
- return true;
- }
- if (/https:\/\/github\.com\/[^ ]+/.test(normalized)) {
- return true;
- }
- if (/\bcurl\b.+\|\s*(bash|sh)\b/.test(normalized)) {
- return true;
- }
- if (/\b(iwr|invoke-webrequest)\b.+\|\s*(iex|invoke-expression)\b/.test(normalized)) {
- return true;
- }
- const packageLikeArgs = server.args ?? [];
- return ['npx', 'uvx', 'pipx'].includes((server.command ?? '').toLowerCase())
- && packageLikeArgs.some((arg) => looksLikePackageName(arg) && !hasExactVersion(arg));
-}
function severityForSampleCommandRisk(server) {
return isPipeToShellCommand(server) ? 'high' : 'medium';
}
-function isPipeToShellCommand(server) {
- const normalized = serverCommand(server).toLowerCase();
- return /\bcurl\b.+\|\s*(bash|sh)\b/.test(normalized)
- || /\b(iwr|invoke-webrequest)\b.+\|\s*(iex|invoke-expression)\b/.test(normalized);
-}
function looksLikePackageName(value) {
return /^[a-z0-9@][a-z0-9._/@-]+$/i.test(value) && !value.startsWith('-');
}
@@ -311,6 +299,14 @@ function isRemoteEndpoint(value) {
return false;
}
}
+function isUnencryptedEndpoint(value) {
+ try {
+ return new URL(value).protocol === 'http:';
+ }
+ catch {
+ return false;
+ }
+}
function firstLineForValues(server, values, predicate = () => true) {
const source = getSourceText(server);
for (const value of values) {
diff --git a/dist/index.js b/dist/index.js
index 87324e2..e953c0c 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1,4 +1,5 @@
#!/usr/bin/env node
+import { writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { detectClaudeSettingsDrift } from './detectors/claude-settings.js';
import { detectCodexConfigDrift } from './detectors/codex-config.js';
@@ -7,7 +8,7 @@ import { materializeGitSnapshot } from './git-snapshot.js';
import { createReport, renderReport } from './report.js';
export async function main(argv = process.argv.slice(2)) {
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
- process.stdout.write('Usage: scopetrail diff --old
--new [--format text|markdown|json]\n');
+ process.stdout.write('Usage: scopetrail diff --old --new [--format text|markdown|json|github] [--out-markdown PATH] [--out-json PATH]\n');
return 0;
}
if (argv[0] === 'diff') {
@@ -22,28 +23,44 @@ async function runDiff(argv) {
process.stderr.write(`${parsed.error}\n${usage()}\n`);
return 2;
}
+ let oldRoot;
+ let newRoot;
+ let cleanup;
if (parsed.mode === 'directories') {
- const findings = [
- ...(await detectMcpDrift(parsed.oldRoot, parsed.newRoot)),
- ...(await detectClaudeSettingsDrift(parsed.oldRoot, parsed.newRoot)),
- ...(await detectCodexConfigDrift(parsed.oldRoot, parsed.newRoot))
- ];
- process.stdout.write(renderReport(createReport(findings), parsed.format));
- return 0;
+ oldRoot = parsed.oldRoot;
+ newRoot = parsed.newRoot;
+ }
+ else {
+ const baseSnapshot = await materializeGitSnapshot(parsed.repo, parsed.base);
+ const headSnapshot = await materializeGitSnapshot(parsed.repo, parsed.head);
+ oldRoot = baseSnapshot.root;
+ newRoot = headSnapshot.root;
+ cleanup = async () => {
+ await Promise.all([baseSnapshot.cleanup(), headSnapshot.cleanup()]);
+ };
}
- const baseSnapshot = await materializeGitSnapshot(parsed.repo, parsed.base);
- const headSnapshot = await materializeGitSnapshot(parsed.repo, parsed.head);
try {
+ // Run all detectors once and render the resulting report into
+ // every requested output. Previously the GitHub Action invoked
+ // the CLI three times for markdown/json/github, which repeated
+ // git snapshot materialization and detector work on each call.
const findings = [
- ...(await detectMcpDrift(baseSnapshot.root, headSnapshot.root)),
- ...(await detectClaudeSettingsDrift(baseSnapshot.root, headSnapshot.root)),
- ...(await detectCodexConfigDrift(baseSnapshot.root, headSnapshot.root))
+ ...(await detectMcpDrift(oldRoot, newRoot)),
+ ...(await detectClaudeSettingsDrift(oldRoot, newRoot)),
+ ...(await detectCodexConfigDrift(oldRoot, newRoot))
];
- process.stdout.write(renderReport(createReport(findings), parsed.format));
+ const report = createReport(findings);
+ if (parsed.outMarkdown) {
+ await writeFile(parsed.outMarkdown, renderReport(report, 'markdown'));
+ }
+ if (parsed.outJson) {
+ await writeFile(parsed.outJson, renderReport(report, 'json'));
+ }
+ process.stdout.write(renderReport(report, parsed.format));
return 0;
}
finally {
- await Promise.all([baseSnapshot.cleanup(), headSnapshot.cleanup()]);
+ await cleanup?.();
}
}
function parseDiffArgs(argv) {
@@ -53,6 +70,8 @@ function parseDiffArgs(argv) {
let head;
let repo = process.cwd();
let format = 'text';
+ let outMarkdown;
+ let outJson;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const value = argv[index + 1];
@@ -83,6 +102,20 @@ function parseDiffArgs(argv) {
format = value;
index += 1;
}
+ else if (arg === '--out-markdown') {
+ if (!value) {
+ return { ok: false, error: 'Missing path for --out-markdown.' };
+ }
+ outMarkdown = value;
+ index += 1;
+ }
+ else if (arg === '--out-json') {
+ if (!value) {
+ return { ok: false, error: 'Missing path for --out-json.' };
+ }
+ outJson = value;
+ index += 1;
+ }
else {
return { ok: false, error: `Unknown argument: ${arg}` };
}
@@ -99,7 +132,7 @@ function parseDiffArgs(argv) {
if (!head) {
return { ok: false, error: 'Missing required --head [ argument.' };
}
- return { ok: true, mode: 'git', repo, base, head, format };
+ return { ok: true, mode: 'git', repo, base, head, format, outMarkdown, outJson };
}
if (!oldRoot) {
return { ok: false, error: 'Missing required --old argument or --base ][ argument.' };
@@ -107,7 +140,7 @@ function parseDiffArgs(argv) {
if (!newRoot) {
return { ok: false, error: 'Missing required --new argument.' };
}
- return { ok: true, mode: 'directories', oldRoot, newRoot, format };
+ return { ok: true, mode: 'directories', oldRoot, newRoot, format, outMarkdown, outJson };
}
function isReportFormat(value) {
return value === 'text' || value === 'markdown' || value === 'json' || value === 'github';
@@ -119,7 +152,7 @@ if (invokedPath) {
function usage() {
return [
'Usage:',
- ' scopetrail diff --old --new [--format text|markdown|json|github]',
- ' scopetrail diff --repo --base ][ --head ][ [--format text|markdown|json|github]'
+ ' scopetrail diff --old --new [--format text|markdown|json|github] [--out-markdown PATH] [--out-json PATH]',
+ ' scopetrail diff --repo --base ][ --head ][ [--format text|markdown|json|github] [--out-markdown PATH] [--out-json PATH]'
].join('\n');
}
diff --git a/dist/mcp-risk.js b/dist/mcp-risk.js
new file mode 100644
index 0000000..56390da
--- /dev/null
+++ b/dist/mcp-risk.js
@@ -0,0 +1,61 @@
+// Shared MCP launch-command risk model. Both .mcp.json (JSON) and
+// .codex/config.toml (TOML) carry the same shape of risky command —
+// @latest tags, github tarballs, curl-pipe-sh installers, unpinned
+// npx/uvx/pipx packages. Keeping the heuristic in one module means
+// the two detectors stay aligned as the risk model evolves.
+export function serverCommand(spec) {
+ return [spec.command, ...(spec.args ?? []), spec.url, spec.serverUrl].filter(Boolean).join(' ');
+}
+export function isUnpinnedCommand(spec) {
+ const command = serverCommand(spec);
+ const normalized = command.toLowerCase();
+ if (normalized.includes('@latest')) {
+ return true;
+ }
+ if (/https:\/\/github\.com\/[^ ]+/.test(normalized)) {
+ return true;
+ }
+ if (/\bcurl\b.+\|\s*(bash|sh)\b/.test(normalized)) {
+ return true;
+ }
+ if (/\b(iwr|invoke-webrequest)\b.+\|\s*(iex|invoke-expression)\b/.test(normalized)) {
+ return true;
+ }
+ const packageLikeArgs = spec.args ?? [];
+ const cmd = (spec.command ?? '').toLowerCase();
+ if (['npm', 'yarn', 'pnpm'].includes(cmd) && packageLikeArgs.length > 1) {
+ const sub = packageLikeArgs[0].toLowerCase();
+ const isExecutor = (cmd === 'npm' && (sub === 'exec' || sub === 'x')) ||
+ (cmd === 'yarn' && sub === 'dlx') ||
+ (cmd === 'pnpm' && (sub === 'dlx' || sub === 'exec' || sub === 'x'));
+ if (isExecutor) {
+ const packageArgs = packageLikeArgs.slice(1).filter((arg) => !arg.startsWith('-'));
+ if (packageArgs.length > 0) {
+ const pkg = packageArgs[0];
+ if (looksLikePackageName(pkg) && !hasExactVersion(pkg)) {
+ return true;
+ }
+ }
+ }
+ }
+ // `bunx` is Bun's npx equivalent and ships as its own binary, so it
+ // surfaces as `command: "bunx"` in MCP configs.
+ return ['npx', 'uvx', 'pipx', 'bunx'].includes(cmd)
+ && packageLikeArgs.some((arg) => looksLikePackageName(arg) && !hasExactVersion(arg));
+}
+export function isPipeToShellCommand(spec) {
+ const normalized = serverCommand(spec).toLowerCase();
+ return /\bcurl\b.+\|\s*(bash|sh)\b/.test(normalized)
+ || /\b(iwr|invoke-webrequest)\b.+\|\s*(iex|invoke-expression)\b/.test(normalized);
+}
+function looksLikePackageName(value) {
+ return /^[a-z0-9@][a-z0-9._/@-]+$/i.test(value) && !value.startsWith('-');
+}
+function hasExactVersion(value) {
+ const packageVersion = value.startsWith('@') ? value.indexOf('@', 1) : value.indexOf('@');
+ if (packageVersion === -1) {
+ return false;
+ }
+ const version = value.slice(packageVersion + 1);
+ return /^\d+\.\d+\.\d+/.test(version);
+}
diff --git a/package-lock.json b/package-lock.json
index f911f8a..7d5d528 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "0.1.11",
"license": "MIT",
"dependencies": {
- "agent-gov-core": "^0.4.0"
+ "agent-gov-core": "^0.5.0"
},
"bin": {
"scopetrail": "dist/index.js"
@@ -30,9 +30,9 @@
}
},
"node_modules/agent-gov-core": {
- "version": "0.4.3",
- "resolved": "https://registry.npmjs.org/agent-gov-core/-/agent-gov-core-0.4.3.tgz",
- "integrity": "sha512-vf8C+AahSlq+e7lHfgylKFH/wrIbemEKGPVU4kSCc7CoMqhoEw07dWvY4jqqsBW0M97hc7ki9g/QeGGIMA7NNQ==",
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/agent-gov-core/-/agent-gov-core-0.5.0.tgz",
+ "integrity": "sha512-rOfbEQM6PrU+gquFe3zWw1FA7U/Aaql7laSkHdvmC345NXOXlTww4jwC5FE9orY82Bv2M8czc+ImoS38K2Z/+g==",
"license": "MIT",
"engines": {
"node": ">=20"
diff --git a/package.json b/package.json
index 1f8693c..ae81be9 100644
--- a/package.json
+++ b/package.json
@@ -6,12 +6,20 @@
"bin": {
"scopetrail": "./dist/index.js"
},
+ "files": [
+ "dist",
+ "action.yml",
+ "docs",
+ "assets/demo-pr-annotations.png",
+ "README.md",
+ "LICENSE"
+ ],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "node --test"
},
"dependencies": {
- "agent-gov-core": "^0.4.0"
+ "agent-gov-core": "^0.5.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
diff --git a/src/detectors/claude-settings.ts b/src/detectors/claude-settings.ts
index b679eee..4bd11fa 100644
--- a/src/detectors/claude-settings.ts
+++ b/src/detectors/claude-settings.ts
@@ -49,18 +49,22 @@ export async function detectClaudeSettingsDrift(oldRoot: string, newRoot: string
continue;
}
- // Swapping a strict guard for a no-op script is just as material as
- // removing the hook outright — and the previous detector missed it.
+ // Any drift in the command set is material — swapping a strict
+ // guard for a no-op, dropping one guard out of a multi-guard hook,
+ // or appending a no-op alongside the strict guard. The previous
+ // check required `newCommands.size === oldCommands.size`, which
+ // missed adds and drops that changed the count.
const newCommands = newSettings.hookCommands.get(hookName) ?? new Set();
- const changed = [...newCommands].filter((command) => !oldCommands.has(command));
- if (changed.length > 0 && newCommands.size === oldCommands.size) {
+ const added = [...newCommands].filter((command) => !oldCommands.has(command));
+ const removed = [...oldCommands].filter((command) => !newCommands.has(command));
+ if (added.length > 0 || removed.length > 0) {
findings.push({
kind: 'scope_trail.hook_command_changed',
severity: isHighImpactHook(hookName) ? 'high' : 'medium',
file: CLAUDE_SETTINGS_FILE,
subject: hookName,
- message: `Claude hook "${hookName}" command(s) changed: ${changed.join(', ')}.`,
- recommendation: 'Review the new command — a weakened guard (e.g., a no-op script) is the same risk as a removed hook.'
+ message: hookCommandChangeMessage(hookName, added, removed),
+ recommendation: 'Review the change — a removed guard, a no-op appended next to a strict one, or any rewrite of a hook command can all weaken policy.'
});
}
}
@@ -169,15 +173,23 @@ function hookHasEntries(value: unknown): boolean {
export function isBroadAllow(permission: string): boolean {
const normalized = permission.toLowerCase();
- if (/\bbash\([^)]*\*[^)]*\)/.test(normalized)) {
+ // Bare verb (no parentheses) or wildcard-scoped verb for any of the
+ // dangerous operations. This catches `"Bash"`, `"Read"`, `"Write"`,
+ // `"Edit"`, `"WebFetch"`, etc. — bare tokens that grant unlimited
+ // access. Previously only WebFetch/WebSearch/Task went through this
+ // check, which silently let `"Bash"` and bare `"Read"`/`"Write"`/
+ // `"Edit"` slip past unflagged.
+ if (isBroadVerbGrant(normalized, ['bash', 'read', 'write', 'edit', 'webfetch', 'websearch', 'task'])) {
return true;
}
+ // Scoped grants whose target is a rooted path (absolute, home-rel,
+ // or Windows drive). `Read(/etc/passwd)` doesn't include `*` but is
+ // still broader than a workspace-relative path. Stays separate from
+ // the verb-grant check because that path-shape rule only applies to
+ // file-access verbs, not network/task verbs.
if (/\b(read|write|edit)\((~|[a-z]:\\|\/|\*\*)/.test(normalized)) {
return true;
}
- if (isBroadVerbGrant(normalized, ['webfetch', 'websearch', 'task'])) {
- return true;
- }
if (isBroadMcpGrant(normalized)) {
return true;
}
@@ -247,3 +259,14 @@ function severityForRemovedDeny(permission: string): Severity {
function isHighImpactHook(hookName: string): boolean {
return ['pretooluse', 'posttooluse', 'permissionrequest', 'sessionend'].includes(hookName.toLowerCase());
}
+
+function hookCommandChangeMessage(hookName: string, added: string[], removed: string[]): string {
+ const parts: string[] = [];
+ if (added.length > 0) {
+ parts.push(`added: ${added.join(', ')}`);
+ }
+ if (removed.length > 0) {
+ parts.push(`removed: ${removed.join(', ')}`);
+ }
+ return `Claude hook "${hookName}" command(s) changed (${parts.join('; ')}).`;
+}
diff --git a/src/detectors/codex-config.ts b/src/detectors/codex-config.ts
index f29fc10..282450b 100644
--- a/src/detectors/codex-config.ts
+++ b/src/detectors/codex-config.ts
@@ -1,5 +1,7 @@
import { readFile } from 'node:fs/promises';
+import { lineOfTomlKey, parseToml } from 'agent-gov-core';
import { configPath } from '../discovery.js';
+import { isUnpinnedCommand, serverCommand, type McpCommandShape } from '../mcp-risk.js';
import type { Finding } from '../types.js';
export const CODEX_CONFIG_FILE = '.codex/config.toml';
@@ -10,6 +12,11 @@ interface TomlEntry {
value: string;
}
+interface CodexMcpServer extends McpCommandShape {
+ text: string;
+ name: string;
+}
+
export async function detectCodexConfigDrift(oldRoot: string, newRoot: string): Promise {
const oldConfig = await readCodexConfig(oldRoot);
const newConfig = await readCodexConfig(newRoot);
@@ -75,9 +82,126 @@ export async function detectCodexConfigDrift(oldRoot: string, newRoot: string):
});
}
+ for (const finding of await detectCodexMcpDrift(oldRoot, newRoot)) {
+ findings.push(finding);
+ }
+
return findings;
}
+// Codex `.codex/config.toml` carries the same `[mcp_servers.NAME]`
+// shape that ScopeTrail already flags in `.mcp.json` — without this
+// detector, a Codex user can add `[mcp_servers.stripe-admin]` with
+// `args = ["-y", "@vendor/stripe-mcp@latest"]` and the unpinned MCP
+// risk model never sees it.
+async function detectCodexMcpDrift(oldRoot: string, newRoot: string): Promise {
+ const findings: Finding[] = [];
+ const oldServers = await readCodexMcpServers(oldRoot);
+ const newServers = await readCodexMcpServers(newRoot);
+
+ for (const [name, newServer] of newServers) {
+ const oldServer = oldServers.get(name);
+ const commandChanged = oldServer && serverCommand(newServer) !== serverCommand(oldServer);
+
+ if (!oldServer) {
+ findings.push({
+ kind: 'scope_trail.codex_mcp_server_added',
+ severity: 'high',
+ file: CODEX_CONFIG_FILE,
+ line: lineForServer(newServer),
+ subject: name,
+ message: `Codex MCP server "${name}" was added.`,
+ recommendation: 'Review the server package, pin its version, and confirm the tools it exposes before merging.'
+ });
+ } else if (commandChanged) {
+ findings.push({
+ kind: 'scope_trail.codex_mcp_server_command_changed',
+ severity: 'medium',
+ file: CODEX_CONFIG_FILE,
+ line: lineForServer(newServer),
+ subject: name,
+ message: `Codex MCP server "${name}" changed its launch command.`,
+ recommendation: 'Confirm the command change is intentional and still points at a trusted, pinned package.'
+ });
+ }
+
+ if ((!oldServer || commandChanged) && isUnpinnedCommand(newServer)) {
+ findings.push({
+ kind: 'scope_trail.codex_unpinned_mcp_command',
+ severity: 'high',
+ file: CODEX_CONFIG_FILE,
+ line: lineForServer(newServer),
+ subject: name,
+ message: `Codex MCP server "${name}" uses an unpinned command: ${serverCommand(newServer)}.`,
+ recommendation: 'Pin executable packages to an exact version and avoid pipe-to-shell installation commands.'
+ });
+ }
+ }
+
+ return findings;
+}
+
+async function readCodexMcpServers(root: string): Promise]