diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3016d72..30571ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,4 +24,9 @@ jobs: - run: npm run build + - name: Verify committed Action runtime + run: | + git diff --exit-code -- dist + test -z "$(git status --short -- dist)" + - run: npm test diff --git a/.gitignore b/.gitignore index 1eae0cf..c2658d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -dist/ node_modules/ diff --git a/README.md b/README.md index 05e0746..8721127 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,15 @@ jobs: with: fetch-depth: 0 - - uses: Conalh/ScopeTrail@v0.1.9 + - uses: Conalh/ScopeTrail@v0.1.10 with: fail-on: none ``` The action uploads nothing by default. It reads local git state from the checked-out repository, writes a Markdown report to the GitHub Actions step summary, and emits PR-visible warning annotations for each finding. Findings point at exact config lines when ScopeTrail can resolve them. +The Action runs the committed `dist/` runtime from the release tag. It does not run `npm ci` or `npm run build` in your repository's workflow. + Start with `fail-on: none` so ScopeTrail is advisory while you tune policy. Raise it to `high` or `critical` once the findings are trusted. `fetch-depth: 0` is required because ScopeTrail compares the pull request base and head refs. diff --git a/action.yml b/action.yml index e571f2f..3177f6c 100644 --- a/action.yml +++ b/action.yml @@ -35,20 +35,6 @@ outputs: runs: using: composite steps: - - name: Install ScopeTrail - shell: bash - run: | - set -euo pipefail - cd "$GITHUB_ACTION_PATH" - npm ci - - - name: Build ScopeTrail - shell: bash - run: | - set -euo pipefail - cd "$GITHUB_ACTION_PATH" - npm run build - - name: Run ScopeTrail permission drift review id: run shell: bash diff --git a/dist/detectors/claude-settings.js b/dist/detectors/claude-settings.js new file mode 100644 index 0000000..faaacb3 --- /dev/null +++ b/dist/detectors/claude-settings.js @@ -0,0 +1,210 @@ +import { configPath, isRecord, lineOfJsonStringValue, readJsonObjectWithSource } from '../discovery.js'; +export const CLAUDE_SETTINGS_FILE = '.claude/settings.json'; +export const CLAUDE_TARGET_PATHS = [CLAUDE_SETTINGS_FILE]; +export async function detectClaudeSettingsDrift(oldRoot, newRoot) { + const oldSettings = await readClaudeSettings(oldRoot); + const newSettings = await readClaudeSettings(newRoot); + const findings = []; + for (const [permission, line] of newSettings.allow) { + if (!oldSettings.allow.has(permission) && isBroadAllow(permission)) { + findings.push({ + kind: 'permission_allow_widened', + severity: severityForAllow(permission), + file: CLAUDE_SETTINGS_FILE, + line, + subject: permission, + message: `Claude permission allowlist now includes broad access: ${permission}.`, + recommendation: 'Prefer the narrowest command/path pattern that supports the workflow.' + }); + } + } + for (const permission of oldSettings.deny.keys()) { + if (!newSettings.deny.has(permission)) { + findings.push({ + kind: 'permission_deny_removed', + severity: severityForRemovedDeny(permission), + file: CLAUDE_SETTINGS_FILE, + subject: permission, + message: `Claude permission deny rule was removed: ${permission}.`, + recommendation: 'Keep deny rules for secrets, credentials, and protected files unless a reviewer approves the removal.' + }); + } + } + for (const [hookName, oldCommands] of oldSettings.hookCommands) { + if (!newSettings.hookCommands.has(hookName)) { + findings.push({ + kind: 'hook_removed', + severity: isHighImpactHook(hookName) ? 'high' : 'medium', + file: CLAUDE_SETTINGS_FILE, + subject: hookName, + message: `Claude hook "${hookName}" was removed.`, + recommendation: 'Confirm the removed hook is not enforcing approval, audit logging, or policy checks.' + }); + 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. + const newCommands = newSettings.hookCommands.get(hookName) ?? new Set(); + const changed = [...newCommands].filter((command) => !oldCommands.has(command)); + if (changed.length > 0 && newCommands.size === oldCommands.size) { + findings.push({ + kind: '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.' + }); + } + } + // Newly-added hooks aren't strictly drift in the security-loss sense, + // but a PR that *adds* a PreToolUse / PermissionRequest hook is a real + // policy event the reviewer should see. We flag it at low severity. + for (const hookName of newSettings.hookCommands.keys()) { + if (!oldSettings.hookCommands.has(hookName)) { + findings.push({ + kind: 'hook_added', + severity: 'low', + file: CLAUDE_SETTINGS_FILE, + subject: hookName, + message: `Claude hook "${hookName}" was added.`, + recommendation: 'Confirm the new hook is the intended policy surface.' + }); + } + } + return findings; +} +async function readClaudeSettings(root) { + const source = await readJsonObjectWithSource(configPath(root, CLAUDE_SETTINGS_FILE)); + const json = source.json; + const permissions = isRecord(json.permissions) ? json.permissions : {}; + const hooks = isRecord(json.hooks) ? json.hooks : {}; + return { + allow: readStringArrayWithLines(permissions.allow, source.text), + deny: readStringArrayWithLines(permissions.deny, source.text), + hookCommands: readHookCommands(hooks) + }; +} +// Each Claude Code hook entry is a list of matcher objects whose `hooks` +// field carries the actual command strings: +// +// { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", +// "command": "/path/guard.sh" }] }] } +// +// We collect every command string per hook name so a PR can be checked +// for both presence and content changes. +function readHookCommands(hooks) { + const result = new Map(); + for (const [name, value] of Object.entries(hooks)) { + if (!hookHasEntries(value)) { + continue; + } + const commands = new Set(); + const entries = Array.isArray(value) ? value : Object.values(value); + for (const entry of entries) { + if (!isRecord(entry)) { + continue; + } + const innerList = entry.hooks; + if (Array.isArray(innerList)) { + for (const inner of innerList) { + if (isRecord(inner) && typeof inner.command === 'string') { + commands.add(inner.command); + } + } + } + if (typeof entry.command === 'string') { + commands.add(entry.command); + } + } + result.set(name, commands); + } + return result; +} +function readStringArray(value) { + return Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : []; +} +function readStringArrayWithLines(value, sourceText) { + return new Map(readStringArray(value).map((entry) => [entry, lineOfJsonStringValue(sourceText, entry)])); +} +function hookHasEntries(value) { + if (Array.isArray(value)) { + return value.length > 0; + } + return isRecord(value) && Object.keys(value).length > 0; +} +// A permission only counts as broad when it grants more than a specific +// scoped target. Scoped forms like `WebFetch(domain:example.com)` and +// `mcp__github__get_issue` are narrow — the previous heuristic flagged +// both as broad, which surfaced false positives on every PR that scoped +// its grants properly. Bare tokens and explicit wildcards are still broad. +export function isBroadAllow(permission) { + const normalized = permission.toLowerCase(); + if (/\bbash\([^)]*\*[^)]*\)/.test(normalized)) { + return true; + } + if (/\b(read|write|edit)\((~|[a-z]:\\|\/|\*\*)/.test(normalized)) { + return true; + } + if (isBroadVerbGrant(normalized, ['webfetch', 'websearch', 'task'])) { + return true; + } + if (isBroadMcpGrant(normalized)) { + return true; + } + return false; +} +function isBroadVerbGrant(normalized, verbs) { + for (const verb of verbs) { + const match = new RegExp(`\\b${verb}\\b(\\([^)]*\\))?`).exec(normalized); + if (!match) { + continue; + } + const scope = match[1] ?? ''; + if (scope === '' || scope.includes('*')) { + return true; + } + } + return false; +} +function isBroadMcpGrant(normalized) { + // Claude Code MCP grants follow `mcp____`. Tool names + // contain underscores (`get_issue`), so we have to split on the + // literal `__` separator rather than a character class. + const start = normalized.indexOf('mcp__'); + if (start === -1) { + return false; + } + if (start > 0 && /[a-z0-9_]/.test(normalized[start - 1])) { + return false; + } + const rest = normalized.slice(start + 'mcp__'.length); + const grant = rest.match(/^[a-z0-9_*-]+/)?.[0] ?? ''; + if (!grant) { + return true; + } + const parts = grant.split('__'); + const server = parts[0]; + const tool = parts.length > 1 ? parts.slice(1).join('__') : undefined; + if (!server || server.includes('*')) { + return true; + } + return !tool || tool.includes('*'); +} +function severityForAllow(permission) { + const normalized = permission.toLowerCase(); + if (normalized.includes('bash(') || normalized.includes('write(') || normalized.includes('edit(')) { + return 'high'; + } + return 'medium'; +} +function severityForRemovedDeny(permission) { + const normalized = permission.toLowerCase(); + if (normalized.includes('.env') || normalized.includes('secret') || normalized.includes('credential') || normalized.includes('.pem')) { + return 'critical'; + } + return 'medium'; +} +function isHighImpactHook(hookName) { + return ['pretooluse', 'posttooluse', 'permissionrequest', 'sessionend'].includes(hookName.toLowerCase()); +} diff --git a/dist/detectors/codex-config.js b/dist/detectors/codex-config.js new file mode 100644 index 0000000..5c97f94 --- /dev/null +++ b/dist/detectors/codex-config.js @@ -0,0 +1,156 @@ +import { readFile } from 'node:fs/promises'; +import { configPath } from '../discovery.js'; +export const CODEX_CONFIG_FILE = '.codex/config.toml'; +export const CODEX_TARGET_PATHS = [CODEX_CONFIG_FILE]; +export async function detectCodexConfigDrift(oldRoot, newRoot) { + const oldConfig = await readCodexConfig(oldRoot); + const newConfig = await readCodexConfig(newRoot); + const findings = []; + for (const key of ['sandbox_mode', 'sandbox', 'windows.sandbox']) { + const oldEntry = oldConfig.get(key); + const newEntry = newConfig.get(key); + if (newEntry && sandboxRank(newEntry.value) > sandboxRank(oldEntry?.value)) { + findings.push({ + kind: 'codex_sandbox_widened', + severity: sandboxRank(newEntry.value) >= 3 ? 'critical' : 'high', + file: CODEX_CONFIG_FILE, + line: newEntry.line, + subject: key, + message: `Codex sandbox setting was widened to ${newEntry.value}.`, + recommendation: 'Keep Codex sandbox settings as narrow as the workflow allows and review full-access/elevated changes carefully.' + }); + } + } + const oldApproval = oldConfig.get('approval_policy'); + const newApproval = newConfig.get('approval_policy'); + if (newApproval && approvalRank(newApproval.value) > approvalRank(oldApproval?.value)) { + findings.push({ + kind: 'codex_approval_weakened', + severity: newApproval.value === 'never' ? 'high' : 'medium', + file: CODEX_CONFIG_FILE, + line: newApproval.line, + subject: 'approval_policy', + message: `Codex approval policy was weakened to ${newApproval.value}.`, + recommendation: 'Require human approval for risky commands unless the repository has a reviewed reason to run without prompts.' + }); + } + for (const key of ['network_access', 'sandbox_workspace_write.network_access']) { + const oldEntry = oldConfig.get(key); + const newEntry = newConfig.get(key); + if (newEntry?.value === 'true' && oldEntry?.value !== 'true') { + findings.push({ + kind: 'codex_network_enabled', + severity: 'medium', + file: CODEX_CONFIG_FILE, + line: newEntry.line, + subject: key, + message: `Codex network access was enabled for ${key}.`, + recommendation: 'Confirm network access is needed and that commands cannot exfiltrate secrets or fetch unreviewed code.' + }); + } + } + const oldTrust = oldConfig.get('projects.trust_level'); + const newTrust = newConfig.get('projects.trust_level'); + if (newTrust?.value === 'trusted' && oldTrust?.value !== 'trusted') { + findings.push({ + kind: '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.' + }); + } + return findings; +} +async function readCodexConfig(root) { + let text = ''; + try { + text = await readFile(configPath(root, CODEX_CONFIG_FILE), 'utf8'); + } + catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return new Map(); + } + throw error; + } + return parseTomlEntries(text); +} +function parseTomlEntries(text) { + const entries = new Map(); + let section = ''; + const lines = text.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const sectionMatch = /^\[([^\]]+)\]$/.exec(trimmed); + if (sectionMatch) { + section = normalizeSection(sectionMatch[1]); + continue; + } + const keyMatch = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(trimmed); + if (!keyMatch) { + continue; + } + const key = normalizeKey(section, keyMatch[1]); + const value = parseScalarValue(keyMatch[2]); + if (value !== undefined) { + entries.set(key, { line: index + 1, value }); + } + } + return entries; +} +function normalizeSection(section) { + const normalized = section.trim().toLowerCase(); + return normalized.startsWith('projects.') ? 'projects' : normalized; +} +function normalizeKey(section, key) { + const normalizedKey = key.trim().toLowerCase(); + return section ? `${section}.${normalizedKey}` : normalizedKey; +} +function parseScalarValue(rawValue) { + const trimmed = rawValue.trim(); + const stringMatch = /^"([^"]*)"/.exec(trimmed) ?? /^'([^']*)'/.exec(trimmed); + if (stringMatch) { + return stringMatch[1].toLowerCase(); + } + const bareMatch = /^(true|false|[A-Za-z0-9_.-]+)/.exec(trimmed); + return bareMatch?.[1].toLowerCase(); +} +function sandboxRank(value) { + if (!value) { + return -1; + } + if (['danger-full-access', 'danger_full_access', 'elevated'].includes(value)) { + return 3; + } + if (['workspace-write', 'workspace_write'].includes(value)) { + return 1; + } + if (['read-only', 'read_only'].includes(value)) { + return 0; + } + return -1; +} +function approvalRank(value) { + if (!value) { + return -1; + } + if (value === 'never') { + return 3; + } + if (value === 'on-failure') { + return 2; + } + if (value === 'on-request') { + return 1; + } + return 0; +} +function isNodeError(error) { + return error instanceof Error && 'code' in error; +} diff --git a/dist/detectors/mcp.js b/dist/detectors/mcp.js new file mode 100644 index 0000000..38e5bf1 --- /dev/null +++ b/dist/detectors/mcp.js @@ -0,0 +1,326 @@ +import { readdir } from 'node:fs/promises'; +import { configPath, isRecord, lineOfJsonKey, lineOfJsonStringValue, readJsonObjectWithSource } from '../discovery.js'; +const MCP_CONFIGS = [ + { path: '.mcp.json', serverKeys: ['mcpServers'] }, + { path: '.cursor/mcp.json', serverKeys: ['mcpServers', 'servers'] }, + { path: '.vscode/mcp.json', serverKeys: ['servers', 'mcpServers'] }, + { path: '.codeium/windsurf/mcp_config.json', serverKeys: ['mcpServers'] } +]; +const MCP_SAMPLE_CONFIG_FILENAMES = new Set([ + '.mcp.json.sample', + '.mcp.json.disabled', + '.mcp.json.template', + '.mcp.json.example', + 'mcp_config.json.sample', + 'mcp_config.json.disabled', + 'mcp_config.json.template', + 'mcp_config.json.example' +]); +const MCP_EXAMPLE_BASE_FILENAMES = ['.mcp.json', 'mcp_config.json']; +const MCP_PLATFORM_EXAMPLE_QUALIFIERS = new Set([ + 'darwin', + 'linux', + 'mac', + 'macos', + 'osx', + 'win', + 'win32', + 'windows' +]); +const IGNORED_SAMPLE_SCAN_DIRS = new Set([ + '.git', + 'node_modules', + 'dist', + 'build', + 'coverage', + '.next', + '.turbo' +]); +// Exported so git-snapshot can materialize every surface this detector +// reads. Keeping the source of truth in the detector prevents the +// snapshot list and the detector list from drifting (they did, before). +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) { + const oldServers = await readMcpServers(oldRoot, config); + const newServers = await readMcpServers(newRoot, config); + for (const [name, newServer] of Object.entries(newServers)) { + const oldServer = oldServers[name]; + if (!oldServer) { + findings.push({ + kind: 'mcp_server_added', + severity: 'high', + file: config.path, + line: newServer.line, + subject: name, + message: `MCP server "${name}" was added.`, + recommendation: 'Review the server package, pin its version, and confirm the tools it exposes before merging.' + }); + } + else if (serverCommand(newServer) !== serverCommand(oldServer)) { + findings.push({ + kind: 'mcp_server_command_changed', + severity: 'medium', + file: config.path, + line: lineForServerCommand(newServer) ?? newServer.line, + subject: name, + message: `MCP server "${name}" changed its launch command.`, + recommendation: 'Confirm the command change is intentional and still points at a trusted, pinned package.' + }); + } + if ((!oldServer || serverCommand(newServer) !== serverCommand(oldServer)) && isUnpinnedCommand(newServer)) { + findings.push({ + kind: 'unpinned_mcp_command', + severity: 'high', + file: config.path, + line: lineForUnpinnedCommand(newServer) ?? newServer.line, + subject: name, + message: `MCP server "${name}" uses an unpinned command: ${serverCommand(newServer)}.`, + recommendation: 'Pin executable packages to an exact version and avoid pipe-to-shell installation commands.' + }); + } + } + } + for (const path of await listMcpSampleConfigPaths(oldRoot, newRoot)) { + const config = { path, serverKeys: ['mcpServers', 'servers'] }; + const oldServers = await readMcpServers(oldRoot, config); + const newServers = await readMcpServers(newRoot, config); + for (const [name, newServer] of Object.entries(newServers)) { + const oldServer = oldServers[name]; + const changed = oldServer && serverCommand(newServer) !== serverCommand(oldServer); + if (!oldServer) { + findings.push({ + kind: 'mcp_sample_server_added', + severity: 'low', + file: path, + line: newServer.line, + subject: name, + message: `Sample/disabled MCP server "${name}" was added.`, + recommendation: 'Confirm this sample config is intentionally shipped and safe for users to copy before merging.' + }); + } + else if (changed) { + findings.push({ + kind: 'mcp_sample_server_command_changed', + severity: 'low', + file: path, + line: lineForServerCommand(newServer) ?? newServer.line, + subject: name, + message: `Sample/disabled MCP server "${name}" changed its launch command.`, + recommendation: 'Confirm this sample config change is intentional and safe for users to copy before merging.' + }); + } + if ((!oldServer || changed) && isUnpinnedCommand(newServer)) { + findings.push({ + kind: 'mcp_sample_unpinned_command', + severity: severityForSampleCommandRisk(newServer), + file: path, + line: lineForUnpinnedCommand(newServer) ?? newServer.line, + subject: name, + message: `Sample/disabled MCP server "${name}" uses an unpinned command: ${serverCommand(newServer)}.`, + recommendation: 'Pin sample MCP packages to an exact version so users do not copy a drifting install command.' + }); + } + const endpoint = remoteEndpoint(newServer); + if ((!oldServer || changed) && endpoint) { + findings.push({ + kind: 'mcp_sample_remote_endpoint', + severity: '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.' + }); + } + } + } + return findings; +} +async function readMcpServers(root, config) { + const source = await readJsonObjectWithSource(configPath(root, config.path)); + const json = source.json; + const rawServers = readServerMap(json, config.serverKeys); + if (!isRecord(rawServers)) { + return {}; + } + const servers = {}; + for (const [name, value] of Object.entries(rawServers)) { + if (!isRecord(value)) { + continue; + } + servers[name] = { + line: lineOfJsonKey(source.text, name), + sourceText: source.text, + command: typeof value.command === 'string' ? value.command : undefined, + args: Array.isArray(value.args) ? value.args.filter((arg) => typeof arg === 'string') : undefined, + url: typeof value.url === 'string' ? value.url : undefined, + serverUrl: typeof value.serverUrl === 'string' ? value.serverUrl : undefined + }; + } + return servers; +} +export function isMcpSampleConfigPath(relativePath) { + const normalized = normalizePath(relativePath); + const segments = normalized.split('/'); + if (segments.some((segment) => IGNORED_SAMPLE_SCAN_DIRS.has(segment))) { + return false; + } + const fileName = segments.at(-1); + return fileName + ? MCP_SAMPLE_CONFIG_FILENAMES.has(fileName) || isPlatformSuffixedMcpExampleFileName(fileName) + : false; +} +function isPlatformSuffixedMcpExampleFileName(fileName) { + for (const baseName of MCP_EXAMPLE_BASE_FILENAMES) { + const prefix = `${baseName}.`; + if (!fileName.startsWith(prefix)) { + continue; + } + const qualifiers = fileName.slice(prefix.length).split('.').map((segment) => segment.toLowerCase()); + return qualifiers.length > 1 + && qualifiers.includes('example') + && qualifiers.every((segment) => segment === 'example' || MCP_PLATFORM_EXAMPLE_QUALIFIERS.has(segment)); + } + return false; +} +async function listMcpSampleConfigPaths(...roots) { + const paths = new Set(); + for (const root of roots) { + await collectMcpSampleConfigPaths(root, '', paths); + } + return [...paths].sort(); +} +async function collectMcpSampleConfigPaths(root, relativeDir, paths) { + let entries; + try { + entries = await readdir(configPath(root, relativeDir), { withFileTypes: true }); + } + catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return; + } + throw error; + } + for (const entry of entries) { + 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; + } + if (entry.isFile() && isMcpSampleConfigPath(relativePath)) { + paths.add(relativePath); + } + } +} +function readServerMap(json, serverKeys) { + for (const key of serverKeys) { + if (isRecord(json[key])) { + return json[key]; + } + } + 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('-'); +} +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); +} +function lineForServerCommand(server) { + return firstLineForValues(server, [server.command, ...(server.args ?? []), server.url, server.serverUrl]) ?? server.line; +} +function lineForUnpinnedCommand(server) { + const command = serverCommand(server); + const normalized = command.toLowerCase(); + if (normalized.includes('@latest')) { + return firstLineForValues(server, [server.command, ...(server.args ?? []), server.url, server.serverUrl], (value) => value.toLowerCase().includes('@latest')); + } + if (/\b(curl|iwr|invoke-webrequest)\b/.test(normalized)) { + return firstLineForValues(server, [server.command, ...(server.args ?? []), server.url, server.serverUrl]); + } + if (['npx', 'uvx', 'pipx'].includes((server.command ?? '').toLowerCase())) { + return firstLineForValues(server, server.args ?? [], (arg) => looksLikePackageName(arg) && !hasExactVersion(arg)); + } + if (/https:\/\/github\.com\/[^ ]+/.test(normalized)) { + return firstLineForValues(server, [server.url, server.serverUrl, ...(server.args ?? [])], (value) => value.toLowerCase().includes('https://github.com/')); + } + return server.line; +} +function lineForRemoteEndpoint(server) { + return firstLineForValues(server, [server.url, server.serverUrl], isRemoteEndpoint); +} +function remoteEndpoint(server) { + return [server.url, server.serverUrl].find((value) => Boolean(value && isRemoteEndpoint(value))); +} +function isRemoteEndpoint(value) { + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return false; + } + return !['localhost', '127.0.0.1', '::1'].includes(url.hostname); + } + catch { + return false; + } +} +function firstLineForValues(server, values, predicate = () => true) { + const source = getSourceText(server); + for (const value of values) { + if (value && predicate(value)) { + const line = lineOfJsonStringValue(source, value); + if (line) { + return line; + } + } + } + return undefined; +} +function getSourceText(server) { + return server.sourceText ?? ''; +} +function normalizePath(path) { + return path.replaceAll('\\', '/'); +} +function isNodeError(error) { + return error instanceof Error && 'code' in error; +} diff --git a/dist/discovery.js b/dist/discovery.js new file mode 100644 index 0000000..e955155 --- /dev/null +++ b/dist/discovery.js @@ -0,0 +1,43 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +export async function readJsonObject(path) { + return (await readJsonObjectWithSource(path)).json; +} +export async function readJsonObjectWithSource(path) { + try { + const raw = await readFile(path, 'utf8'); + const parsed = JSON.parse(raw); + return { json: isRecord(parsed) ? parsed : {}, text: raw }; + } + catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return { json: {}, text: '' }; + } + throw error; + } +} +export function configPath(root, relativePath) { + return join(root, relativePath); +} +export function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +export function lineOfJsonKey(text, key) { + const keyPattern = new RegExp(`"${escapeRegExp(key)}"\\s*:`); + return lineOfPattern(text, keyPattern); +} +export function lineOfJsonStringValue(text, value) { + const encoded = JSON.stringify(value); + return lineOfPattern(text, new RegExp(escapeRegExp(encoded))); +} +function lineOfPattern(text, pattern) { + const lines = text.split(/\r?\n/); + const index = lines.findIndex((line) => pattern.test(line)); + return index === -1 ? undefined : index + 1; +} +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} +function isNodeError(error) { + return error instanceof Error && 'code' in error; +} diff --git a/dist/git-snapshot.js b/dist/git-snapshot.js new file mode 100644 index 0000000..152ec0a --- /dev/null +++ b/dist/git-snapshot.js @@ -0,0 +1,84 @@ +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { CLAUDE_TARGET_PATHS } from './detectors/claude-settings.js'; +import { CODEX_TARGET_PATHS } from './detectors/codex-config.js'; +import { MCP_TARGET_PATHS, isMcpSampleConfigPath } from './detectors/mcp.js'; +const execFileAsync = promisify(execFile); +// Union of every config path the detectors read. Sourced from each +// detector module so adding a new surface in one place can never leave +// the git-mode snapshot blind to it (the previous hard-coded list missed +// .cursor/mcp.json, .vscode/mcp.json, .codeium/windsurf/mcp_config.json, +// and .codex/config.toml — silently, in the actual GitHub Action path). +export const SNAPSHOT_PATHS = [ + ...MCP_TARGET_PATHS, + ...CLAUDE_TARGET_PATHS, + ...CODEX_TARGET_PATHS +]; +export async function materializeGitSnapshot(repo, ref) { + await verifyGitRef(repo, ref); + const root = await mkdtemp(join(tmpdir(), 'scopetrail-snapshot-')); + let completed = false; + try { + for (const relativePath of await snapshotPathsForRef(repo, ref)) { + const content = await readPathAtRef(repo, ref, relativePath); + if (content === null) { + continue; + } + const targetPath = join(root, relativePath); + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, content); + } + completed = true; + return { + root, + cleanup: async () => { + await rm(root, { recursive: true, force: true }); + } + }; + } + finally { + if (!completed) { + await rm(root, { recursive: true, force: true }); + } + } +} +async function snapshotPathsForRef(repo, ref) { + const paths = new Set(SNAPSHOT_PATHS); + for (const relativePath of await listPathsAtRef(repo, ref)) { + if (isMcpSampleConfigPath(relativePath)) { + paths.add(relativePath); + } + } + return [...paths].sort(); +} +async function verifyGitRef(repo, ref) { + await execFileAsync('git', ['-C', repo, 'rev-parse', '--verify', `${ref}^{commit}`]); +} +async function listPathsAtRef(repo, ref) { + const { stdout } = await execFileAsync('git', ['-C', repo, 'ls-tree', '-r', '--name-only', ref], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 + }); + return stdout.split(/\r?\n/).filter(Boolean); +} +async function readPathAtRef(repo, ref, relativePath) { + try { + const { stdout } = await execFileAsync('git', ['-C', repo, 'show', `${ref}:${relativePath}`], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 + }); + return stdout; + } + catch (error) { + if (isExecError(error)) { + return null; + } + throw error; + } +} +function isExecError(error) { + return error instanceof Error && 'code' in error; +} diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..87324e2 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +import { fileURLToPath } from 'node:url'; +import { detectClaudeSettingsDrift } from './detectors/claude-settings.js'; +import { detectCodexConfigDrift } from './detectors/codex-config.js'; +import { detectMcpDrift } from './detectors/mcp.js'; +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'); + return 0; + } + if (argv[0] === 'diff') { + return runDiff(argv.slice(1)); + } + process.stderr.write(`Unknown command: ${argv[0]}\n`); + return 2; +} +async function runDiff(argv) { + const parsed = parseDiffArgs(argv); + if (!parsed.ok) { + process.stderr.write(`${parsed.error}\n${usage()}\n`); + return 2; + } + 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; + } + const baseSnapshot = await materializeGitSnapshot(parsed.repo, parsed.base); + const headSnapshot = await materializeGitSnapshot(parsed.repo, parsed.head); + try { + const findings = [ + ...(await detectMcpDrift(baseSnapshot.root, headSnapshot.root)), + ...(await detectClaudeSettingsDrift(baseSnapshot.root, headSnapshot.root)), + ...(await detectCodexConfigDrift(baseSnapshot.root, headSnapshot.root)) + ]; + process.stdout.write(renderReport(createReport(findings), parsed.format)); + return 0; + } + finally { + await Promise.all([baseSnapshot.cleanup(), headSnapshot.cleanup()]); + } +} +function parseDiffArgs(argv) { + let oldRoot; + let newRoot; + let base; + let head; + let repo = process.cwd(); + let format = 'text'; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const value = argv[index + 1]; + if (arg === '--old') { + oldRoot = value; + index += 1; + } + else if (arg === '--new') { + newRoot = value; + index += 1; + } + else if (arg === '--repo') { + repo = value; + index += 1; + } + else if (arg === '--base') { + base = value; + index += 1; + } + else if (arg === '--head') { + head = value; + index += 1; + } + else if (arg === '--format') { + if (!isReportFormat(value)) { + return { ok: false, error: `Invalid format: ${value ?? ''}` }; + } + format = value; + index += 1; + } + else { + return { ok: false, error: `Unknown argument: ${arg}` }; + } + } + const hasDirectoryMode = oldRoot || newRoot; + const hasGitMode = base || head; + if (hasDirectoryMode && hasGitMode) { + return { ok: false, error: 'Use either --old/--new or --base/--head, not both.' }; + } + if (hasGitMode) { + if (!base) { + return { ok: false, error: 'Missing required --base argument.' }; + } + if (!head) { + return { ok: false, error: 'Missing required --head argument.' }; + } + return { ok: true, mode: 'git', repo, base, head, format }; + } + if (!oldRoot) { + return { ok: false, error: 'Missing required --old argument or --base argument.' }; + } + if (!newRoot) { + return { ok: false, error: 'Missing required --new argument.' }; + } + return { ok: true, mode: 'directories', oldRoot, newRoot, format }; +} +function isReportFormat(value) { + return value === 'text' || value === 'markdown' || value === 'json' || value === 'github'; +} +const invokedPath = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false; +if (invokedPath) { + process.exitCode = await main(); +} +function usage() { + return [ + 'Usage:', + ' scopetrail diff --old --new [--format text|markdown|json|github]', + ' scopetrail diff --repo --base --head [--format text|markdown|json|github]' + ].join('\n'); +} diff --git a/dist/report.js b/dist/report.js new file mode 100644 index 0000000..6ff1613 --- /dev/null +++ b/dist/report.js @@ -0,0 +1,102 @@ +const severityRank = { + none: 0, + low: 1, + medium: 2, + high: 3, + critical: 4 +}; +export function createReport(findings) { + return { + rating: rateFindings(findings), + findingCount: findings.length, + findings + }; +} +export function renderReport(report, format) { + if (format === 'json') { + return `${JSON.stringify(report, null, 2)}\n`; + } + if (format === 'markdown') { + return renderMarkdown(report); + } + if (format === 'github') { + return renderGithubAnnotations(report); + } + return renderText(report); +} +function rateFindings(findings) { + let rating = 'none'; + for (const finding of findings) { + if (severityRank[finding.severity] > severityRank[rating]) { + rating = finding.severity; + } + } + return rating; +} +function renderMarkdown(report) { + const lines = [`# ScopeTrail permission drift: ${report.rating.toUpperCase()}`, '']; + if (report.findings.length === 0) { + lines.push('No agent permission drift findings.'); + appendPilotFeedback(lines); + return `${lines.join('\n')}\n`; + } + lines.push(`This diff produced ${report.findingCount} finding${report.findingCount === 1 ? '' : 's'}.`, ''); + for (const severity of ['critical', 'high', 'medium', 'low']) { + const matches = report.findings.filter((finding) => finding.severity === severity); + if (matches.length === 0) { + continue; + } + lines.push(`## ${capitalize(severity)}`, ''); + for (const finding of matches) { + lines.push(`- **${finding.subject}** (${finding.file}): ${finding.message}`); + lines.push(` Recommendation: ${finding.recommendation}`); + } + lines.push(''); + } + appendPilotFeedback(lines); + return `${lines.join('\n').trimEnd()}\n`; +} +function appendPilotFeedback(lines) { + lines.push('', '## Pilot feedback', '', 'Trying ScopeTrail in advisory mode? Report whether this run was useful, noisy, or missing an agent config surface:', '', 'https://github.com/Conalh/ScopeTrail/issues/new?template=pilot-result.yml'); +} +function renderText(report) { + const lines = [`ScopeTrail permission drift: ${report.rating.toUpperCase()}`]; + for (const finding of report.findings) { + lines.push(`[${finding.severity.toUpperCase()}] ${finding.subject}: ${finding.message}`); + } + if (report.findings.length === 0) { + lines.push('No agent permission drift findings.'); + } + return `${lines.join('\n')}\n`; +} +function renderGithubAnnotations(report) { + if (report.findings.length === 0) { + return ''; + } + return report.findings + .map((finding) => { + const title = `ScopeTrail ${finding.severity} permission drift`; + const message = `${finding.message} Recommendation: ${finding.recommendation}`; + const properties = [`file=${escapeProperty(finding.file)}`]; + if (finding.line && finding.line > 0) { + properties.push(`line=${finding.line}`); + } + properties.push(`title=${escapeProperty(title)}`); + return `::warning ${properties.join(',')}::${escapeMessage(message)}`; + }) + .join('\n') + '\n'; +} +function escapeMessage(value) { + return value + .replaceAll('%', '%25') + .replaceAll('\r', '%0D') + .replaceAll('\n', '%0A'); +} +function escapeProperty(value) { + return escapeMessage(value) + .replaceAll(':', '%3A') + .replaceAll(',', '%2C'); +} +function capitalize(value) { + return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`; +} diff --git a/dist/types.js b/dist/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/types.js @@ -0,0 +1 @@ +export {}; diff --git a/docs/PILOT.md b/docs/PILOT.md index 00ec873..c1ab2db 100644 --- a/docs/PILOT.md +++ b/docs/PILOT.md @@ -23,7 +23,7 @@ jobs: with: fetch-depth: 0 - - uses: Conalh/ScopeTrail@v0.1.9 + - uses: Conalh/ScopeTrail@v0.1.10 with: fail-on: none ``` diff --git a/docs/TRUST.md b/docs/TRUST.md index 2c0f131..03165a5 100644 --- a/docs/TRUST.md +++ b/docs/TRUST.md @@ -18,6 +18,10 @@ ScopeTrail writes a Markdown report to the GitHub Actions step summary, emits PR ScopeTrail uploads nothing by default. It does not send repository contents, findings, or telemetry to a hosted service. +## Runtime Dependencies + +The GitHub Action runs the committed `dist/` runtime from the ScopeTrail release tag. It does not run `npm ci` or `npm run build` in the installing repository, so pilot repositories do not need to download ScopeTrail development dependencies during their PR checks. + ## Required GitHub Permissions Required permissions: `contents: read`. diff --git a/package-lock.json b/package-lock.json index de60304..fbc3123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scopetrail", - "version": "0.1.9", + "version": "0.1.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scopetrail", - "version": "0.1.9", + "version": "0.1.10", "license": "MIT", "bin": { "scopetrail": "dist/index.js" diff --git a/package.json b/package.json index 97387f3..4c7d283 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scopetrail", - "version": "0.1.9", + "version": "0.1.10", "description": "Code review for AI agent permission drift.", "type": "module", "bin": { diff --git a/test/action-metadata.test.mjs b/test/action-metadata.test.mjs index 8097419..df7fd0b 100644 --- a/test/action-metadata.test.mjs +++ b/test/action-metadata.test.mjs @@ -24,6 +24,16 @@ test('GitHub Action metadata exposes PR drift inputs', async () => { assert.match(action, /--format github/); }); +test('GitHub Action uses committed runtime without installing dependencies in consumer workflows', async () => { + const action = await readFile(join(packageRoot, 'action.yml'), 'utf8'); + const gitignore = await readFile(join(packageRoot, '.gitignore'), 'utf8'); + + assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/index\.js" diff --repo/); + assert.doesNotMatch(action, /npm ci/); + assert.doesNotMatch(action, /npm run build/); + assert.doesNotMatch(gitignore, /^dist\/$/m); +}); + test('public Action install tags match package version', async () => { const readme = await readFile(join(packageRoot, 'README.md'), 'utf8'); const pilotGuide = await readFile(join(packageRoot, 'docs', 'PILOT.md'), 'utf8'); @@ -31,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.9'); + assert.equal(version, '0.1.10'); assert.match(readme, installTagPattern); assert.match(pilotGuide, installTagPattern); }); diff --git a/test/ci-workflow.test.mjs b/test/ci-workflow.test.mjs index 5e935bf..0c7c8c0 100644 --- a/test/ci-workflow.test.mjs +++ b/test/ci-workflow.test.mjs @@ -19,3 +19,10 @@ test('repository has public CI for build and tests', async () => { assert.match(workflow, /npm run build/); assert.match(workflow, /npm test/); }); + +test('CI verifies committed Action runtime is current after build', async () => { + const workflow = await readFile(join(packageRoot, '.github', 'workflows', 'ci.yml'), 'utf8'); + + assert.match(workflow, /git diff --exit-code -- dist/); + assert.match(workflow, /git status --short -- dist/); +}); diff --git a/test/public-docs.test.mjs b/test/public-docs.test.mjs index 2fca58f..4e56d09 100644 --- a/test/public-docs.test.mjs +++ b/test/public-docs.test.mjs @@ -18,6 +18,8 @@ test('README links trust, adoption, and team validation docs from the public Act assert.match(readme, /\[Adoption checklist\]\(docs\/ADOPTION\.md\)/); assert.match(readme, /\[Team-layer validation\]\(docs\/TEAM_VALIDATION\.md\)/); assert.match(readme, /install with `fail-on: none`/i); + assert.match(readme, /runs the committed `dist\/` runtime/i); + assert.match(readme, /does not run `npm ci` or `npm run build`/i); }); test('trust doc describes local-only advisory GitHub Action behavior', async () => { @@ -25,6 +27,8 @@ test('trust doc describes local-only advisory GitHub Action behavior', async () assert.match(trust, /reads the checked-out repository/i); assert.match(trust, /uploads nothing by default/i); + assert.match(trust, /runs the committed `dist\/` runtime/i); + assert.match(trust, /does not run `npm ci` or `npm run build` in the installing repository/i); assert.match(trust, /permissions:\s*`contents: read`/i); assert.match(trust, /`fetch-depth: 0`/); assert.match(trust, /`fail-on: none`/);