diff --git a/dist/detectors/claude-settings.js b/dist/detectors/claude-settings.js index 0353853..5e8d3a6 100644 --- a/dist/detectors/claude-settings.js +++ b/dist/detectors/claude-settings.js @@ -2,6 +2,20 @@ import { configPath, isRecord, lineOfJsonStringValue, readJsonObjectWithSource } export const CLAUDE_SETTINGS_FILE = '.claude/settings.json'; export const CLAUDE_TARGET_PATHS = [CLAUDE_SETTINGS_FILE]; export async function detectClaudeSettingsDrift(oldRoot, newRoot) { + // Invalid JSON used to escape from readJsonObjectWithSource and + // crash the CLI before any report could be produced. Surface it as + // a finding instead so fail-on semantics still apply. + const newSource = await readJsonObjectWithSource(configPath(newRoot, CLAUDE_SETTINGS_FILE)); + if (newSource.parseError) { + return [{ + kind: 'scope_trail.claude_settings_syntax_error', + severity: 'high', + file: CLAUDE_SETTINGS_FILE, + subject: CLAUDE_SETTINGS_FILE, + message: `Claude settings file failed to parse: ${newSource.parseError.message}`, + recommendation: 'Fix the JSON syntax. ScopeTrail cannot reason about permission drift while the file is invalid.' + }]; + } const oldSettings = await readClaudeSettings(oldRoot); const newSettings = await readClaudeSettings(newRoot); const findings = []; diff --git a/dist/detectors/codex-config.js b/dist/detectors/codex-config.js index 6cccb4c..3a694fe 100644 --- a/dist/detectors/codex-config.js +++ b/dist/detectors/codex-config.js @@ -5,6 +5,24 @@ import { isUnpinnedCommand, serverCommand, remoteEndpoint, isUnencryptedEndpoint export const CODEX_CONFIG_FILE = '.codex/config.toml'; export const CODEX_TARGET_PATHS = [CODEX_CONFIG_FILE]; export async function detectCodexConfigDrift(oldRoot, newRoot) { + // Detect a malformed new .codex/config.toml up front. The previous + // behavior was to silently swallow the TOML parse error and return + // an empty MCP server map, which let a hand-edited config that + // contained risky settings produce a clean "rating: none" report. + // Surface as a high-severity finding and skip the rest of the + // detector — diffing against a partially-parsed file would just + // produce noise. + const newParseError = await readCodexParseError(newRoot); + if (newParseError) { + return [{ + kind: 'scope_trail.codex_config_syntax_error', + severity: 'high', + file: CODEX_CONFIG_FILE, + subject: CODEX_CONFIG_FILE, + message: `Codex config "${CODEX_CONFIG_FILE}" failed to parse: ${newParseError.message}`, + recommendation: 'Fix the TOML syntax. ScopeTrail cannot reason about sandbox, approval, or MCP drift while the file is invalid.' + }]; + } const oldConfig = await readCodexConfig(oldRoot); const newConfig = await readCodexConfig(newRoot); const findings = []; @@ -51,24 +69,80 @@ export async function detectCodexConfigDrift(oldRoot, newRoot) { }); } } - const oldTrust = oldConfig.get('projects.trust_level'); - const newTrust = newConfig.get('projects.trust_level'); - if (newTrust?.value === 'trusted' && oldTrust?.value !== 'trusted') { - findings.push({ - kind: 'scope_trail.codex_project_trusted', - severity: 'high', - file: CODEX_CONFIG_FILE, - line: newTrust.line, - subject: 'projects.trust_level', - message: 'Codex project trust level was changed to trusted.', - recommendation: 'Only mark projects trusted when repository instructions, hooks, and tool permissions are reviewed.' - }); + // Per-project trust-level detection. The legacy regex parser + // collapsed every `[projects.]` section to a single `projects` + // bucket, so multiple project paths fought over one Map key — adding + // a *second* trusted project went undetected when a first trusted + // project already existed. Iterate the parsed TOML's `projects` + // object directly so each path is checked independently. + const oldTrustedProjects = await readTrustedProjects(oldRoot); + const newTrustedProjects = await readTrustedProjects(newRoot); + for (const projectPath of newTrustedProjects) { + if (!oldTrustedProjects.has(projectPath)) { + findings.push({ + kind: 'scope_trail.codex_project_trusted', + severity: 'high', + file: CODEX_CONFIG_FILE, + line: lineOfTomlKey(await readCodexText(newRoot), `projects.${projectPath}.trust_level`) || undefined, + subject: `projects.${projectPath}.trust_level`, + message: `Codex project "${projectPath}" was marked trusted.`, + 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; } +async function readCodexText(root) { + try { + return await readFile(configPath(root, CODEX_CONFIG_FILE), 'utf8'); + } + catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return ''; + } + throw error; + } +} +async function readCodexParseError(root) { + const text = await readCodexText(root); + if (!text) { + return undefined; + } + try { + parseToml(text); + return undefined; + } + catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } +} +async function readTrustedProjects(root) { + const text = await readCodexText(root); + if (!text) { + return new Set(); + } + let parsed; + try { + parsed = parseToml(text); + } + catch { + return new Set(); + } + const projects = parsed.projects; + if (!isPlainObject(projects)) { + return new Set(); + } + const trusted = new Set(); + for (const [name, entry] of Object.entries(projects)) { + if (isPlainObject(entry) && entry.trust_level === 'trusted') { + trusted.add(name); + } + } + return trusted; +} // 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 diff --git a/dist/detectors/mcp.js b/dist/detectors/mcp.js index ae3f365..3cf340f 100644 --- a/dist/detectors/mcp.js +++ b/dist/detectors/mcp.js @@ -50,6 +50,23 @@ export const MCP_TARGET_PATHS = MCP_CONFIGS.map((config) => config.path); export async function detectMcpDrift(oldRoot, newRoot) { const findings = []; for (const config of MCP_CONFIGS) { + // Surface invalid JSON as a finding instead of silently producing + // an empty server map (which would let real drift slip through + // and look like a clean report). Skip the diff for this file + // when the new copy is unparseable — false "added" findings on + // an empty parse would just add noise. + const newSource = await readJsonObjectWithSource(configPath(newRoot, config.path)); + if (newSource.parseError) { + findings.push({ + kind: 'scope_trail.mcp_config_syntax_error', + severity: 'high', + file: config.path, + subject: config.path, + message: `MCP config "${config.path}" failed to parse: ${newSource.parseError.message}`, + recommendation: 'Fix the JSON syntax. ScopeTrail cannot reason about server permissions while the file is invalid.' + }); + continue; + } const oldServers = await readMcpServers(oldRoot, config); const newServers = await readMcpServers(newRoot, config); for (const [name, newServer] of Object.entries(newServers)) { @@ -108,6 +125,21 @@ export async function detectMcpDrift(oldRoot, newRoot) { } for (const path of await listMcpSampleConfigPaths(oldRoot, newRoot)) { const config = { path, serverKeys: ['mcpServers', 'servers'] }; + const newSource = await readJsonObjectWithSource(configPath(newRoot, path)); + if (newSource.parseError) { + // Sample configs are advisory examples, not live servers, so + // syntax errors here are lower severity than the active + // .mcp.json equivalent. + findings.push({ + kind: 'scope_trail.mcp_sample_config_syntax_error', + severity: 'low', + file: path, + subject: path, + message: `Sample MCP config "${path}" failed to parse: ${newSource.parseError.message}`, + recommendation: 'Fix the JSON syntax so users who copy this sample get a parseable starting point.' + }); + continue; + } const oldServers = await readMcpServers(oldRoot, config); const newServers = await readMcpServers(newRoot, config); for (const [name, newServer] of Object.entries(newServers)) { diff --git a/dist/discovery.js b/dist/discovery.js index 09c62a3..7797780 100644 --- a/dist/discovery.js +++ b/dist/discovery.js @@ -9,12 +9,14 @@ export async function readJsonObject(path) { * agent-gov-core, then JSON.parse runs against the stripped (but * position-preserving) text. Missing files resolve to an empty object so * detectors can run on repos that haven't adopted Claude settings yet. + * + * Invalid JSON returns `{ json: {}, text: raw, parseError }` rather + * than throwing — callers emit findings, not crashes. */ export async function readJsonObjectWithSource(path) { + let raw; try { - const raw = await readFile(path, 'utf8'); - const parsed = JSON.parse(stripJsonComments(raw)); - return { json: isRecord(parsed) ? parsed : {}, text: raw }; + raw = await readFile(path, 'utf8'); } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { @@ -22,6 +24,17 @@ export async function readJsonObjectWithSource(path) { } throw error; } + try { + const parsed = JSON.parse(stripJsonComments(raw)); + return { json: isRecord(parsed) ? parsed : {}, text: raw }; + } + catch (error) { + return { + json: {}, + text: raw, + parseError: error instanceof Error ? error : new Error(String(error)) + }; + } } export function configPath(root, relativePath) { return join(root, relativePath); diff --git a/dist/mcp-risk.js b/dist/mcp-risk.js index a90320a..7e21518 100644 --- a/dist/mcp-risk.js +++ b/dist/mcp-risk.js @@ -68,7 +68,10 @@ export function isRemoteEndpoint(value) { if (url.protocol !== 'http:' && url.protocol !== 'https:') { return false; } - return !['localhost', '127.0.0.1', '::1'].includes(url.hostname); + // Node's URL parser returns IPv6 hostnames with surrounding + // brackets (`new URL('http://[::1]:3000').hostname === '[::1]'`), + // so `'::1'` alone never matched. Include both forms. + return !['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname); } catch { return false; diff --git a/src/detectors/claude-settings.ts b/src/detectors/claude-settings.ts index 4cead71..c649663 100644 --- a/src/detectors/claude-settings.ts +++ b/src/detectors/claude-settings.ts @@ -5,6 +5,21 @@ export const CLAUDE_SETTINGS_FILE = '.claude/settings.json'; export const CLAUDE_TARGET_PATHS: readonly string[] = [CLAUDE_SETTINGS_FILE]; export async function detectClaudeSettingsDrift(oldRoot: string, newRoot: string): Promise { + // Invalid JSON used to escape from readJsonObjectWithSource and + // crash the CLI before any report could be produced. Surface it as + // a finding instead so fail-on semantics still apply. + const newSource = await readJsonObjectWithSource(configPath(newRoot, CLAUDE_SETTINGS_FILE)); + if (newSource.parseError) { + return [{ + kind: 'scope_trail.claude_settings_syntax_error', + severity: 'high', + file: CLAUDE_SETTINGS_FILE, + subject: CLAUDE_SETTINGS_FILE, + message: `Claude settings file failed to parse: ${newSource.parseError.message}`, + recommendation: 'Fix the JSON syntax. ScopeTrail cannot reason about permission drift while the file is invalid.' + }]; + } + const oldSettings = await readClaudeSettings(oldRoot); const newSettings = await readClaudeSettings(newRoot); const findings: Finding[] = []; diff --git a/src/detectors/codex-config.ts b/src/detectors/codex-config.ts index 0f59499..e4ce88a 100644 --- a/src/detectors/codex-config.ts +++ b/src/detectors/codex-config.ts @@ -24,6 +24,25 @@ interface CodexMcpServer extends McpCommandShape { } export async function detectCodexConfigDrift(oldRoot: string, newRoot: string): Promise { + // Detect a malformed new .codex/config.toml up front. The previous + // behavior was to silently swallow the TOML parse error and return + // an empty MCP server map, which let a hand-edited config that + // contained risky settings produce a clean "rating: none" report. + // Surface as a high-severity finding and skip the rest of the + // detector — diffing against a partially-parsed file would just + // produce noise. + const newParseError = await readCodexParseError(newRoot); + if (newParseError) { + return [{ + kind: 'scope_trail.codex_config_syntax_error', + severity: 'high', + file: CODEX_CONFIG_FILE, + subject: CODEX_CONFIG_FILE, + message: `Codex config "${CODEX_CONFIG_FILE}" failed to parse: ${newParseError.message}`, + recommendation: 'Fix the TOML syntax. ScopeTrail cannot reason about sandbox, approval, or MCP drift while the file is invalid.' + }]; + } + const oldConfig = await readCodexConfig(oldRoot); const newConfig = await readCodexConfig(newRoot); const findings: Finding[] = []; @@ -74,18 +93,26 @@ export async function detectCodexConfigDrift(oldRoot: string, newRoot: string): } } - const oldTrust = oldConfig.get('projects.trust_level'); - const newTrust = newConfig.get('projects.trust_level'); - if (newTrust?.value === 'trusted' && oldTrust?.value !== 'trusted') { - findings.push({ - kind: 'scope_trail.codex_project_trusted', - severity: 'high', - file: CODEX_CONFIG_FILE, - line: newTrust.line, - subject: 'projects.trust_level', - message: 'Codex project trust level was changed to trusted.', - recommendation: 'Only mark projects trusted when repository instructions, hooks, and tool permissions are reviewed.' - }); + // Per-project trust-level detection. The legacy regex parser + // collapsed every `[projects.]` section to a single `projects` + // bucket, so multiple project paths fought over one Map key — adding + // a *second* trusted project went undetected when a first trusted + // project already existed. Iterate the parsed TOML's `projects` + // object directly so each path is checked independently. + const oldTrustedProjects = await readTrustedProjects(oldRoot); + const newTrustedProjects = await readTrustedProjects(newRoot); + for (const projectPath of newTrustedProjects) { + if (!oldTrustedProjects.has(projectPath)) { + findings.push({ + kind: 'scope_trail.codex_project_trusted', + severity: 'high', + file: CODEX_CONFIG_FILE, + line: lineOfTomlKey(await readCodexText(newRoot), `projects.${projectPath}.trust_level`) || undefined, + subject: `projects.${projectPath}.trust_level`, + message: `Codex project "${projectPath}" was marked trusted.`, + recommendation: 'Only mark projects trusted when repository instructions, hooks, and tool permissions are reviewed.' + }); + } } for (const finding of await detectCodexMcpDrift(oldRoot, newRoot)) { @@ -95,6 +122,54 @@ export async function detectCodexConfigDrift(oldRoot: string, newRoot: string): return findings; } +async function readCodexText(root: string): Promise { + try { + return await readFile(configPath(root, CODEX_CONFIG_FILE), 'utf8'); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return ''; + } + throw error; + } +} + +async function readCodexParseError(root: string): Promise { + const text = await readCodexText(root); + if (!text) { + return undefined; + } + try { + parseToml(text); + return undefined; + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } +} + +async function readTrustedProjects(root: string): Promise> { + const text = await readCodexText(root); + if (!text) { + return new Set(); + } + let parsed: Record; + try { + parsed = parseToml(text); + } catch { + return new Set(); + } + const projects = parsed.projects; + if (!isPlainObject(projects)) { + return new Set(); + } + const trusted = new Set(); + for (const [name, entry] of Object.entries(projects)) { + if (isPlainObject(entry) && entry.trust_level === 'trusted') { + trusted.add(name); + } + } + return trusted; +} + // 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 diff --git a/src/detectors/mcp.ts b/src/detectors/mcp.ts index 87c7e61..eb6e199 100644 --- a/src/detectors/mcp.ts +++ b/src/detectors/mcp.ts @@ -74,6 +74,24 @@ export async function detectMcpDrift(oldRoot: string, newRoot: string): Promise< const findings: Finding[] = []; for (const config of MCP_CONFIGS) { + // Surface invalid JSON as a finding instead of silently producing + // an empty server map (which would let real drift slip through + // and look like a clean report). Skip the diff for this file + // when the new copy is unparseable — false "added" findings on + // an empty parse would just add noise. + const newSource = await readJsonObjectWithSource(configPath(newRoot, config.path)); + if (newSource.parseError) { + findings.push({ + kind: 'scope_trail.mcp_config_syntax_error', + severity: 'high', + file: config.path, + subject: config.path, + message: `MCP config "${config.path}" failed to parse: ${newSource.parseError.message}`, + recommendation: 'Fix the JSON syntax. ScopeTrail cannot reason about server permissions while the file is invalid.' + }); + continue; + } + const oldServers = await readMcpServers(oldRoot, config); const newServers = await readMcpServers(newRoot, config); @@ -136,6 +154,21 @@ export async function detectMcpDrift(oldRoot: string, newRoot: string): Promise< for (const path of await listMcpSampleConfigPaths(oldRoot, newRoot)) { const config = { path, serverKeys: ['mcpServers', 'servers'] }; + const newSource = await readJsonObjectWithSource(configPath(newRoot, path)); + if (newSource.parseError) { + // Sample configs are advisory examples, not live servers, so + // syntax errors here are lower severity than the active + // .mcp.json equivalent. + findings.push({ + kind: 'scope_trail.mcp_sample_config_syntax_error', + severity: 'low', + file: path, + subject: path, + message: `Sample MCP config "${path}" failed to parse: ${newSource.parseError.message}`, + recommendation: 'Fix the JSON syntax so users who copy this sample get a parseable starting point.' + }); + continue; + } const oldServers = await readMcpServers(oldRoot, config); const newServers = await readMcpServers(newRoot, config); diff --git a/src/discovery.ts b/src/discovery.ts index 691d9ee..7cec0b8 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -13,6 +13,13 @@ export async function readJsonObject(path: string): Promise; text: string; + /** + * Set when the file existed but JSON parsing failed. The CLI used + * to crash with a raw SyntaxError on invalid JSON, bypassing the + * whole ScopeTrail report pipeline. Surface the error so detectors + * can emit a `*_config_syntax_error` finding instead. + */ + parseError?: Error; } /** @@ -20,19 +27,31 @@ export interface JsonObjectSource { * agent-gov-core, then JSON.parse runs against the stripped (but * position-preserving) text. Missing files resolve to an empty object so * detectors can run on repos that haven't adopted Claude settings yet. + * + * Invalid JSON returns `{ json: {}, text: raw, parseError }` rather + * than throwing — callers emit findings, not crashes. */ export async function readJsonObjectWithSource(path: string): Promise { + let raw: string; try { - const raw = await readFile(path, 'utf8'); - const parsed: unknown = JSON.parse(stripJsonComments(raw)); - return { json: isRecord(parsed) ? parsed : {}, text: raw }; + raw = await readFile(path, 'utf8'); } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { return { json: {}, text: '' }; } - throw error; } + + try { + const parsed: unknown = JSON.parse(stripJsonComments(raw)); + return { json: isRecord(parsed) ? parsed : {}, text: raw }; + } catch (error) { + return { + json: {}, + text: raw, + parseError: error instanceof Error ? error : new Error(String(error)) + }; + } } export function configPath(root: string, relativePath: string): string { diff --git a/src/mcp-risk.ts b/src/mcp-risk.ts index 9f1e161..6c52aa6 100644 --- a/src/mcp-risk.ts +++ b/src/mcp-risk.ts @@ -91,7 +91,10 @@ export function isRemoteEndpoint(value: string): boolean { return false; } - return !['localhost', '127.0.0.1', '::1'].includes(url.hostname); + // Node's URL parser returns IPv6 hostnames with surrounding + // brackets (`new URL('http://[::1]:3000').hostname === '[::1]'`), + // so `'::1'` alone never matched. Include both forms. + return !['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname); } catch { return false; } diff --git a/test/codex-config-drift.test.mjs b/test/codex-config-drift.test.mjs index 2775cbc..c03b186 100644 --- a/test/codex-config-drift.test.mjs +++ b/test/codex-config-drift.test.mjs @@ -6,6 +6,80 @@ import { detectCodexConfigDrift } from '../dist/detectors/codex-config.js'; const testDir = dirname(fileURLToPath(import.meta.url)); +test('codex_config_syntax_error: malformed TOML surfaces a finding instead of returning a clean report', async () => { + // Pre-fix gap: `parseToml` failure was silently swallowed, so a + // malformed .codex/config.toml that contained risky settings still + // produced rating: "none" / findingCount: 0. Worse than failing + // loudly because advisory output looks clean. + const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + + const root = mkdtempSync(join(testDir, '..', 'node_modules', '.scopetrail-codex-malformed-') + .replaceAll('\\', '/')); + try { + const oldDir = join(root, 'old'); + const newDir = join(root, 'new'); + mkdirSync(join(oldDir, '.codex'), { recursive: true }); + mkdirSync(join(newDir, '.codex'), { recursive: true }); + writeFileSync(join(oldDir, '.codex', 'config.toml'), 'sandbox_mode = "workspace-write"\n'); + // Unterminated quoted string makes the parser fail. + writeFileSync( + join(newDir, '.codex', 'config.toml'), + 'sandbox_mode = "danger-full-access\n[mcp_servers.evil]\nargs = ["@vendor/bad@latest"]\n' + ); + + const findings = await detectCodexConfigDrift(oldDir, newDir); + assert.equal(findings.length, 1, 'malformed TOML should produce exactly one syntax_error finding'); + assert.equal(findings[0].kind, 'scope_trail.codex_config_syntax_error'); + assert.equal(findings[0].severity, 'high'); + assert.match(findings[0].message, /failed to parse/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('codex_project_trusted: each [projects.] is tracked independently', async () => { + // Pre-fix gap: `normalizeSection` collapsed every `[projects.*]` + // section to `projects`, then `normalizeKey` produced + // `projects.trust_level` for any project — Map keys overwrote + // each other, so adding a second trusted project went undetected + // when a first trusted project already existed. + const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + + const root = mkdtempSync(join(testDir, '..', 'node_modules', '.scopetrail-codex-projects-') + .replaceAll('\\', '/')); + try { + const oldDir = join(root, 'old'); + const newDir = join(root, 'new'); + mkdirSync(join(oldDir, '.codex'), { recursive: true }); + mkdirSync(join(newDir, '.codex'), { recursive: true }); + writeFileSync( + join(oldDir, '.codex', 'config.toml'), + "[projects.'/home/dev/alpha']\ntrust_level = \"trusted\"\n" + ); + // New file: alpha still trusted, beta and gamma now also trusted. + writeFileSync( + join(newDir, '.codex', 'config.toml'), + "[projects.'/home/dev/alpha']\ntrust_level = \"trusted\"\n\n" + + "[projects.'/home/dev/beta']\ntrust_level = \"trusted\"\n\n" + + "[projects.'/home/dev/gamma']\ntrust_level = \"trusted\"\n" + ); + + const findings = await detectCodexConfigDrift(oldDir, newDir); + const trustedFindings = findings.filter((f) => f.kind === 'scope_trail.codex_project_trusted'); + const subjects = trustedFindings.map((f) => f.subject).sort(); + + assert.equal(trustedFindings.length, 2, 'expected exactly two new trusted-project findings'); + assert.deepEqual(subjects, [ + 'projects./home/dev/beta.trust_level', + 'projects./home/dev/gamma.trust_level' + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('detects Codex config permission drift', async () => { const oldDir = join(testDir, 'fixtures', 'codex-config-drift', 'old'); const newDir = join(testDir, 'fixtures', 'codex-config-drift', 'new'); @@ -18,7 +92,7 @@ test('detects Codex config permission drift', async () => { ['scope_trail.codex_sandbox_widened', 'sandbox_mode', 'critical', 1], ['scope_trail.codex_approval_weakened', 'approval_policy', 'high', 2], ['scope_trail.codex_network_enabled', 'sandbox_workspace_write.network_access', 'medium', 5], - ['scope_trail.codex_project_trusted', 'projects.trust_level', 'high', 8] + ['scope_trail.codex_project_trusted', 'projects.c:\\dev\\example.trust_level', 'high', 8] ] ); }); diff --git a/test/heuristics-and-snapshot.test.mjs b/test/heuristics-and-snapshot.test.mjs index 2ad8872..781fbca 100644 --- a/test/heuristics-and-snapshot.test.mjs +++ b/test/heuristics-and-snapshot.test.mjs @@ -68,6 +68,32 @@ test('isBroadAllow: bare Bash/Read/Write/Edit ARE broad (regression for security assert.equal(isBroadAllow('Edit'), true); }); +test('Claude detector: malformed settings.json surfaces a finding instead of crashing', async () => { + // Pre-fix gap: invalid JSON propagated SyntaxError out of the + // detector and crashed the CLI before any report could render. + // Now: high-severity claude_settings_syntax_error finding. + const { mkdtemp, mkdir, rm, writeFile } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + + const root = await mkdtemp(join(tmpdir(), 'scopetrail-claude-malformed-')); + try { + const oldRoot = join(root, 'old'); + const newRoot = join(root, 'new'); + await mkdir(join(oldRoot, '.claude'), { recursive: true }); + await mkdir(join(newRoot, '.claude'), { recursive: true }); + await writeFile(join(oldRoot, '.claude', 'settings.json'), JSON.stringify({ permissions: { allow: [], deny: [] } })); + await writeFile(join(newRoot, '.claude', 'settings.json'), '{ "permissions": { "allow": ["Bash"], '); + + const findings = await detectClaudeSettingsDrift(oldRoot, newRoot); + assert.equal(findings.length, 1, 'malformed settings.json should produce exactly one syntax_error finding'); + assert.equal(findings[0].kind, 'scope_trail.claude_settings_syntax_error'); + assert.equal(findings[0].severity, 'high'); + assert.match(findings[0].message, /failed to parse/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('Claude detector: bare Bash/Write/Edit get high severity (not medium)', async () => { // Pre-fix gap: `severityForAllow` required the opening paren // (`bash(`, `write(`, `edit(`) to assign `high`, so bare `"Bash"` diff --git a/test/mcp-drift.test.mjs b/test/mcp-drift.test.mjs index 044277e..9ba807b 100644 --- a/test/mcp-drift.test.mjs +++ b/test/mcp-drift.test.mjs @@ -6,6 +6,73 @@ import { detectMcpDrift, isMcpSampleConfigPath } from '../dist/detectors/mcp.js' const testDir = dirname(fileURLToPath(import.meta.url)); +test('mcp_config_syntax_error: malformed .mcp.json surfaces a finding instead of crashing the CLI', async () => { + // Pre-fix gap: JSON.parse errors escaped from readJsonObjectWithSource + // and the CLI exited 1 with a raw SyntaxError, bypassing the report + // pipeline and fail-on semantics entirely. Now: detector returns a + // high-severity finding and the report renders normally. + const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + + const root = mkdtempSync(join(tmpdir(), 'scopetrail-malformed-')); + try { + const oldDir = join(root, 'old'); + const newDir = join(root, 'new'); + mkdirSync(oldDir, { recursive: true }); + mkdirSync(newDir, { recursive: true }); + writeFileSync(join(oldDir, '.mcp.json'), JSON.stringify({ mcpServers: {} })); + // Trailing comma + unterminated object — invalid JSON. + writeFileSync(join(newDir, '.mcp.json'), '{ "mcpServers": { "evil": { "command": "npx", "args": ["@vendor/bad@latest"], }'); + + const findings = await detectMcpDrift(oldDir, newDir); + const syntaxError = findings.find((f) => f.kind === 'scope_trail.mcp_config_syntax_error'); + assert.ok(syntaxError, 'expected mcp_config_syntax_error finding'); + assert.equal(syntaxError.severity, 'high'); + assert.equal(syntaxError.file, '.mcp.json'); + assert.match(syntaxError.message, /failed to parse/); + + // No false-positive "added" findings for the half-parsed evil server. + const evilAdded = findings.find((f) => f.kind === 'scope_trail.mcp_server_added' && f.subject === 'evil'); + assert.equal(evilAdded, undefined, 'malformed config should not fire false mcp_server_added'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('mcp_remote_endpoint: IPv6 loopback [::1] is excluded as local, not flagged as remote', async () => { + // Node's URL parser returns IPv6 hostnames with brackets + // (`new URL('http://[::1]:3000').hostname === '[::1]'`), so the + // previous exclusion list of `['localhost', '127.0.0.1', '::1']` + // never matched the bracketed form. Local IPv6 MCP endpoints + // were getting flagged as remote. + const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + + const root = mkdtempSync(join(tmpdir(), 'scopetrail-ipv6-')); + try { + const oldDir = join(root, 'old'); + const newDir = join(root, 'new'); + mkdirSync(oldDir, { recursive: true }); + mkdirSync(newDir, { recursive: true }); + writeFileSync(join(oldDir, '.mcp.json'), JSON.stringify({ mcpServers: {} })); + writeFileSync( + join(newDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + 'ipv6-local': { serverUrl: 'http://[::1]:3000/mcp' }, + 'ipv4-local': { serverUrl: 'http://127.0.0.1:3000/mcp' } + } + }) + ); + + const findings = await detectMcpDrift(oldDir, newDir); + const remoteFindings = findings.filter((f) => f.kind === 'scope_trail.mcp_remote_endpoint'); + assert.equal(remoteFindings.length, 0, 'IPv6/IPv4 loopback should not fire remote-endpoint findings'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('mcp_sample_remote_endpoint: http:// fires high severity, https:// stays medium', async () => { // A copy-pasted sample config with an http:// endpoint silently // hands the user an unencrypted MCP transport. https:// is the