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
14 changes: 14 additions & 0 deletions dist/detectors/claude-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
98 changes: 86 additions & 12 deletions dist/detectors/codex-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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.<path>]` 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
Expand Down
32 changes: 32 additions & 0 deletions dist/detectors/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down
19 changes: 16 additions & 3 deletions dist/discovery.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { stripJsonComments, lineOfJsonKey as coreLineOfJsonKey, lineOfJsonStringValue as coreLineOfJsonStringValue, } from 'agent-gov-core';
Expand All @@ -9,19 +9,32 @@
* 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') {
return { json: {}, text: '' };
}
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);
Expand Down
5 changes: 4 additions & 1 deletion dist/mcp-risk.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// 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
Expand Down Expand Up @@ -68,7 +68,10 @@
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;
Expand Down
15 changes: 15 additions & 0 deletions src/detectors/claude-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Finding[]> {
// 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[] = [];
Expand Down
99 changes: 87 additions & 12 deletions src/detectors/codex-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ interface CodexMcpServer extends McpCommandShape {
}

export async function detectCodexConfigDrift(oldRoot: string, newRoot: string): Promise<Finding[]> {
// 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[] = [];
Expand Down Expand Up @@ -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.<path>]` 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)) {
Expand All @@ -95,6 +122,54 @@ export async function detectCodexConfigDrift(oldRoot: string, newRoot: string):
return findings;
}

async function readCodexText(root: string): Promise<string> {
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<Error | undefined> {
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<Set<string>> {
const text = await readCodexText(root);
if (!text) {
return new Set();
}
let parsed: Record<string, unknown>;
try {
parsed = parseToml(text);
} catch {
return new Set();
}
const projects = parsed.projects;
if (!isPlainObject(projects)) {
return new Set();
}
const trusted = new Set<string>();
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
Expand Down
Loading