From ff27fc402f64d56a9efee2de790e53bc302b030f Mon Sep 17 00:00:00 2001 From: Conal <33135619+Conalh@users.noreply.github.com> Date: Thu, 21 May 2026 18:12:43 -0700 Subject: [PATCH] Track bundled Action dist --- .gitignore | 1 - README.md | 2 +- dist/audit.js | 37 +++ dist/discovery.js | 171 ++++++++++++ dist/index.js | 90 +++++++ dist/mesh/engine.js | 577 +++++++++++++++++++++++++++++++++++++++++ dist/parsers/claude.js | 118 +++++++++ dist/parsers/codex.js | 491 +++++++++++++++++++++++++++++++++++ dist/parsers/errors.js | 22 ++ dist/parsers/index.js | 31 +++ dist/parsers/mcp.js | 131 ++++++++++ dist/report.js | 136 ++++++++++ dist/types.js | 1 + package-lock.json | 4 +- package.json | 2 +- test/workflow.test.mjs | 23 +- 16 files changed, 1827 insertions(+), 10 deletions(-) create mode 100644 dist/audit.js create mode 100644 dist/discovery.js create mode 100644 dist/index.js create mode 100644 dist/mesh/engine.js create mode 100644 dist/parsers/claude.js create mode 100644 dist/parsers/codex.js create mode 100644 dist/parsers/errors.js create mode 100644 dist/parsers/index.js create mode 100644 dist/parsers/mcp.js create mode 100644 dist/report.js create mode 100644 dist/types.js diff --git a/.gitignore b/.gitignore index b947077..c2658d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ node_modules/ -dist/ diff --git a/README.md b/README.md index a88dfb1..667fbae 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: Conalh/PolicyMesh@v0.1.15 + - uses: Conalh/PolicyMesh@v0.1.16 with: fail-on: none ``` diff --git a/dist/audit.js b/dist/audit.js new file mode 100644 index 0000000..4633f1f --- /dev/null +++ b/dist/audit.js @@ -0,0 +1,37 @@ +import { countConfiguredSurfaces, parseRepoPolicies } from './parsers/index.js'; +import { buildEffectiveUnion, buildSurfaceMatrix, runMeshRules } from './mesh/engine.js'; +const severityRank = { + none: 0, + low: 1, + medium: 2, + high: 3, + critical: 4 +}; +export async function auditRepo(root) { + const policies = await parseRepoPolicies(root); + const findings = [...(policies.parseFindings ?? []), ...runMeshRules(policies)]; + return { + rating: rateFindings(findings), + findingCount: findings.length, + surfaceCount: countConfiguredSurfaces(policies), + findings, + effectiveUnion: buildEffectiveUnion(policies), + matrix: buildSurfaceMatrix(policies) + }; +} +function rateFindings(findings) { + let rating = 'none'; + for (const finding of findings) { + if (severityRank[finding.severity] > severityRank[rating]) { + rating = finding.severity; + } + } + return rating; +} +export function meetsFailThreshold(rating, failOn) { + if (failOn === 'none') { + return false; + } + return severityRank[rating] >= severityRank[failOn]; +} +export { severityRank }; diff --git a/dist/discovery.js b/dist/discovery.js new file mode 100644 index 0000000..dcaf17d --- /dev/null +++ b/dist/discovery.js @@ -0,0 +1,171 @@ +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) { + let raw = ''; + try { + raw = await readFile(path, 'utf8'); + } + catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return { json: {}, text: '' }; + } + throw error; + } + const stripped = stripJsonComments(raw); + try { + const parsed = JSON.parse(stripped); + return { json: isRecord(parsed) ? parsed : {}, text: raw }; + } + catch (error) { + if (error instanceof SyntaxError) { + return { + json: {}, + text: raw, + parseError: { + message: error.message, + line: lineOfJsonParseError(stripped, error) + } + }; + } + throw error; + } +} +// VS Code and Cursor both ship MCP configs as JSONC — comments and the +// occasional trailing comma are normal, not malformed. We strip them +// before JSON.parse so those files audit cleanly. Replacing comment +// bytes with spaces (and preserving newlines in block comments) keeps +// the original byte/line positions intact for error reporting and the +// downstream line locators in lineOfJsonKey / lineOfJsonStringValue. +function stripJsonComments(raw) { + let out = ''; + let inString = false; + let escape = false; + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index]; + const next = raw[index + 1]; + if (inString) { + out += char; + if (escape) { + escape = false; + } + else if (char === '\\') { + escape = true; + } + else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + out += char; + continue; + } + if (char === '/' && next === '/') { + while (index < raw.length && raw[index] !== '\n') { + out += ' '; + index += 1; + } + // restore loop invariant: the for-loop's index++ will advance past '\n' + if (index < raw.length) { + out += raw[index]; + } + continue; + } + if (char === '/' && next === '*') { + out += ' '; + index += 2; + while (index < raw.length && !(raw[index] === '*' && raw[index + 1] === '/')) { + out += raw[index] === '\n' ? '\n' : ' '; + index += 1; + } + if (index < raw.length) { + out += ' '; + index += 1; // for-loop will advance past the '/' + } + continue; + } + out += char; + } + return stripTrailingCommas(out); +} +// Trailing commas before `]` or `}` are legal in JSONC; JSON.parse rejects +// them. Removing them after comment-stripping keeps byte positions stable +// because we replace each removed comma with a space. +function stripTrailingCommas(raw) { + let out = ''; + let inString = false; + let escape = false; + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index]; + if (inString) { + out += char; + if (escape) { + escape = false; + } + else if (char === '\\') { + escape = true; + } + else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + out += char; + continue; + } + if (char === ',') { + let look = index + 1; + while (look < raw.length && /\s/.test(raw[look])) { + look += 1; + } + if (raw[look] === ']' || raw[look] === '}') { + out += ' '; + continue; + } + } + out += char; + } + return out; +} +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 lineOfJsonParseError(text, error) { + const positionMatch = /position (\d+)/.exec(error.message); + if (!positionMatch) { + return undefined; + } + const position = Number(positionMatch[1]); + if (!Number.isInteger(position) || position < 0) { + return undefined; + } + return text.slice(0, position).split(/\r?\n/).length; +} +function isNodeError(error) { + return error instanceof Error && 'code' in error; +} diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..6cea370 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import { stat } from 'node:fs/promises'; +import { relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { auditRepo } from './audit.js'; +import { renderReport } from './report.js'; +export { auditRepo } from './audit.js'; +export async function main(argv = process.argv.slice(2)) { + if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { + process.stdout.write('Usage: policymesh audit --repo [--format text|markdown|json|github]\n'); + return 0; + } + if (argv[0] === 'audit') { + return runAudit(argv.slice(1)); + } + process.stderr.write(`Unknown command: ${argv[0]}\n`); + return 2; +} +async function runAudit(argv) { + const parsed = parseAuditArgs(argv); + if (!parsed.ok) { + process.stderr.write(`${parsed.error}\n${usage()}\n`); + return 2; + } + const repoError = await validateRepoPath(parsed.repo); + if (repoError) { + process.stderr.write(`${repoError}\n`); + return 2; + } + const report = await auditRepo(parsed.repo); + process.stdout.write(renderReport(report, parsed.format, { + githubAnnotationPathPrefix: githubAnnotationPathPrefix(parsed.repo) + })); + return 0; +} +function parseAuditArgs(argv) { + 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 === '--repo') { + if (!value || value.startsWith('--')) { + return { ok: false, error: 'Missing value for --repo' }; + } + repo = 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}` }; + } + } + return { ok: true, repo, format }; +} +function isReportFormat(value) { + return value === 'text' || value === 'markdown' || value === 'json' || value === 'github'; +} +function githubAnnotationPathPrefix(repo) { + const prefix = relative(process.cwd(), resolve(repo)); + return prefix && prefix !== '.' && !prefix.startsWith('..') ? prefix : undefined; +} +async function validateRepoPath(repo) { + try { + const stats = await stat(repo); + return stats.isDirectory() ? undefined : `Repository path is not a directory: ${repo}`; + } + catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return `Repository path does not exist: ${repo}`; + } + throw error; + } +} +function isNodeError(error) { + return error instanceof Error && 'code' in error; +} +const invokedPath = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false; +if (invokedPath) { + process.exitCode = await main(); +} +function usage() { + return 'Usage: policymesh audit --repo [--format text|markdown|json|github]'; +} diff --git a/dist/mesh/engine.js b/dist/mesh/engine.js new file mode 100644 index 0000000..4462742 --- /dev/null +++ b/dist/mesh/engine.js @@ -0,0 +1,577 @@ +import { isBroadAllow, isSensitiveDeny } from '../parsers/claude.js'; +import { codexSandboxRank } from '../parsers/codex.js'; +export function runMeshRules(policies) { + const findings = [ + ...detectMcpCommandMismatch(policies), + ...detectMcpServerMissing(policies), + ...detectMcpEnabledMismatch(policies), + ...detectMcpEnvMismatch(policies), + ...detectMcpHeaderMismatch(policies), + ...detectMcpUnpinned(policies), + ...detectClaudeMcpGrantMissingServer(policies), + ...detectClaudeDenyAllowOverlap(policies), + ...detectClaudeBroadAllowNoGuard(policies), + ...detectCodexNetworkWithoutReview(policies), + ...detectCodexTrustedWithRiskyMcp(policies), + ...detectCodexClaudePostureGap(policies) + ]; + return findings; +} +function detectClaudeMcpGrantMissingServer(policies) { + const claude = policies.claude; + if (!claude) { + return findingsEmpty(); + } + const configuredServers = new Set(policies.mcpSurfaces + .flatMap((surface) => surface.servers) + .map((server) => server.name.toLowerCase())); + const mcpSurfaces = uniqueSurfaces(policies.mcpSurfaces.map((surface) => surface.surfaceId)); + const findings = []; + for (const [permission, line] of claude.allow) { + const server = claudeMcpServerName(permission); + if (!server || configuredServers.has(server.toLowerCase())) { + continue; + } + findings.push({ + kind: 'claude_mcp_grant_missing_server', + severity: 'medium', + file: claude.file, + line, + locations: [{ file: claude.file, line, surface: 'claude' }], + subject: permission, + message: `Claude grants MCP server "${server}" via "${permission}", but no MCP config defines that server.`, + recommendation: 'Define the server in an MCP config file or remove the Claude MCP permission if the server is not intended.', + surfaces: uniqueSurfaces(['claude', ...mcpSurfaces]) + }); + } + return findings; +} +function detectMcpCommandMismatch(policies) { + const findings = []; + const byName = groupMcpServersByName(policies); + for (const [name, servers] of byName) { + const commands = new Map(); + for (const server of servers) { + const existing = commands.get(server.command) ?? []; + existing.push(server); + commands.set(server.command, existing); + } + if (commands.size <= 1) { + continue; + } + const commandList = [...commands.keys()].map((cmd) => `"${cmd}"`).join(' vs '); + const primary = servers[0]; + findings.push({ + kind: 'mcp_command_mismatch', + severity: 'high', + file: primary.file, + line: primary.line, + locations: servers.map((server) => ({ + file: server.file, + line: server.line, + surface: server.surfaceId + })), + subject: name, + message: `MCP server "${name}" has different launch commands across surfaces: ${commandList}.`, + recommendation: 'Use the same pinned MCP server definition in every MCP config file, or rename servers that intentionally differ.', + surfaces: uniqueSurfaces(servers.map((s) => s.surfaceId)) + }); + } + return findings; +} +function detectMcpServerMissing(policies) { + const findings = []; + if (policies.mcpSurfaces.length < 2) { + return findings; + } + const byName = groupMcpServersByName(policies); + const surfaceIds = policies.mcpSurfaces.map((s) => s.surfaceId); + const surfaceById = new Map(policies.mcpSurfaces.map((surface) => [surface.surfaceId, surface])); + for (const [name, servers] of byName) { + const present = new Set(servers.map((s) => s.surfaceId)); + const missing = surfaceIds.filter((id) => !present.has(id)); + if (missing.length === 0) { + continue; + } + const primary = servers[0]; + findings.push({ + kind: 'mcp_server_missing', + severity: 'low', + file: primary.file, + line: primary.line, + locations: [ + ...servers.map((server) => ({ + file: server.file, + line: server.line, + surface: server.surfaceId + })), + ...missing.map((surfaceId) => ({ + file: surfaceById.get(surfaceId)?.file ?? primary.file, + surface: surfaceId + })) + ], + subject: name, + message: `MCP server "${name}" is defined in ${formatSurfaceList(uniqueSurfaces(servers.map((s) => s.surfaceId)))} but missing from ${formatSurfaceList(missing)}.`, + recommendation: 'Align MCP server definitions across all MCP config files or document why a surface intentionally omits the server.', + surfaces: uniqueSurfaces([...present, ...missing]) + }); + } + return findings; +} +function detectMcpEnabledMismatch(policies) { + const findings = []; + const byName = groupMcpServersByName(policies); + for (const [name, servers] of byName) { + if (servers.length < 2) { + continue; + } + const states = new Set(servers.map((server) => server.enabled)); + if (states.size <= 1) { + continue; + } + const primary = servers[0]; + findings.push({ + kind: 'mcp_enabled_mismatch', + severity: 'medium', + file: primary.file, + line: primary.line, + locations: servers.map((server) => ({ + file: server.file, + line: server.line, + surface: server.surfaceId + })), + subject: name, + message: `MCP server "${name}" is ${summarizeEnabledStates(servers)}.`, + recommendation: 'Align MCP server enabled/disabled state across surfaces, or rename/document surfaces that intentionally expose different tool access.', + surfaces: uniqueSurfaces(servers.map((server) => server.surfaceId)) + }); + } + return findings; +} +function detectMcpEnvMismatch(policies) { + const findings = []; + const byName = groupMcpServersByName(policies); + for (const [name, servers] of byName) { + if (servers.length < 2) { + continue; + } + const envFingerprints = new Set(servers.map((server) => envFingerprint(server.env))); + if (envFingerprints.size <= 1) { + continue; + } + const envKeyFingerprints = new Set(servers.map((server) => envKeyFingerprint(server.env))); + const keySummary = summarizeEnvKeys(servers); + const primary = servers[0]; + const differingKeys = differingEnvKeys(servers); + findings.push({ + kind: 'mcp_env_mismatch', + severity: 'medium', + file: primary.file, + line: primary.line, + locations: servers.map((server) => ({ + file: server.file, + line: server.line, + surface: server.surfaceId + })), + subject: name, + message: envKeyFingerprints.size > 1 + ? `MCP server "${name}" environment variable names differ across surfaces: ${keySummary}.` + : `MCP server "${name}" environment values differ across surfaces for ${differingKeys.join(', ')}.`, + recommendation: 'Align MCP server environment variable names and secret sources across surfaces, or document why each agent needs different wiring.', + surfaces: uniqueSurfaces(servers.map((server) => server.surfaceId)) + }); + } + return findings; +} +function detectMcpHeaderMismatch(policies) { + const findings = []; + const byName = groupMcpServersByName(policies); + for (const [name, servers] of byName) { + if (servers.length < 2) { + continue; + } + const headerFingerprints = new Set(servers.map((server) => headerFingerprint(server.headers))); + if (headerFingerprints.size <= 1) { + continue; + } + const headerKeyFingerprints = new Set(servers.map((server) => headerKeyFingerprint(server.headers))); + const keySummary = summarizeHeaderKeys(servers); + const primary = servers[0]; + const differingKeys = differingHeaderKeys(servers); + findings.push({ + kind: 'mcp_header_mismatch', + severity: 'medium', + file: primary.file, + line: primary.line, + locations: servers.map((server) => ({ + file: server.file, + line: server.line, + surface: server.surfaceId + })), + subject: name, + message: headerKeyFingerprints.size > 1 + ? `MCP server "${name}" header names differ across surfaces: ${keySummary}.` + : `MCP server "${name}" header values differ across surfaces for ${differingKeys.join(', ')}.`, + recommendation: 'Align remote MCP server header names and secret sources across surfaces, or document why each agent needs different remote credentials.', + surfaces: uniqueSurfaces(servers.map((server) => server.surfaceId)) + }); + } + return findings; +} +function detectMcpUnpinned(policies) { + const findings = []; + for (const surface of policies.mcpSurfaces) { + for (const server of surface.servers) { + if (!server.unpinned) { + continue; + } + findings.push({ + kind: 'mcp_unpinned', + severity: 'medium', + file: server.file, + line: server.line, + subject: server.name, + message: `MCP server "${server.name}" uses an unpinned command: ${server.command}.`, + recommendation: 'Pin executable packages to an exact version and avoid @latest in shared agent configuration.', + surfaces: [server.surfaceId] + }); + } + } + return findings; +} +function detectClaudeDenyAllowOverlap(policies) { + const findings = []; + const claude = policies.claude; + if (!claude) { + return findings; + } + const broadAllows = [...claude.allow.keys()].filter(isBroadAllow); + const sensitiveDenies = [...claude.deny.keys()].filter(isSensitiveDeny); + if (broadAllows.length === 0 || sensitiveDenies.length === 0) { + return findings; + } + for (const deny of sensitiveDenies) { + const line = claude.deny.get(deny); + findings.push({ + kind: 'claude_deny_allow_overlap', + severity: 'medium', + file: claude.file, + line, + subject: deny, + message: `Claude denies "${deny}" but also has broad allow rules (${broadAllows.join(', ')}), creating mixed policy signals.`, + recommendation: 'Narrow broad allow patterns or ensure deny rules are enforced by hooks when permissions overlap.', + surfaces: ['claude'] + }); + } + return findings; +} +function detectClaudeBroadAllowNoGuard(policies) { + const findings = []; + const claude = policies.claude; + if (!claude) { + return findings; + } + const broadAllows = [...claude.allow.entries()].filter(([permission]) => isBroadAllow(permission)); + if (broadAllows.length === 0) { + return findings; + } + const hasPreToolUse = [...claude.hooks].some((hook) => hook.toLowerCase() === 'pretooluse'); + if (hasPreToolUse) { + return findings; + } + const [primaryAllow, line] = broadAllows[0]; + findings.push({ + kind: 'claude_broad_allow_no_guard', + severity: 'medium', + file: claude.file, + line, + subject: primaryAllow, + message: `Claude has broad allow rules (${broadAllows.map(([p]) => p).join(', ')}) without a PreToolUse hook.`, + recommendation: 'Add a PreToolUse hook to guard broad permissions, or narrow allow patterns to the minimum required scope.', + surfaces: ['claude'] + }); + return findings; +} +function detectCodexNetworkWithoutReview(policies) { + const codex = policies.codex; + if (!codex?.networkAccess) { + return findingsEmpty(); + } + const otherSurfaces = listOtherAgentSurfaces(policies); + if (otherSurfaces.length === 0) { + return findingsEmpty(); + } + return [{ + kind: 'codex_network_without_review', + severity: 'medium', + file: codex.file, + line: codex.networkLine, + subject: 'network_access', + message: 'Codex network access is enabled while other agent surfaces are also configured in this repository.', + recommendation: 'Review whether network access is required and ensure secrets cannot be exfiltrated through agent tooling.', + surfaces: ['codex', ...otherSurfaces] + }]; +} +function detectCodexTrustedWithRiskyMcp(policies) { + const codex = policies.codex; + if (!codex?.trusted) { + return findingsEmpty(); + } + const findings = []; + const unpinned = policies.mcpSurfaces.flatMap((s) => s.servers).filter((s) => s.unpinned); + const hasMismatch = detectMcpCommandMismatch(policies).length > 0; + if (unpinned.length > 0 || hasMismatch) { + const risky = unpinned[0]; + findings.push({ + kind: 'codex_trusted_with_risky_mcp', + severity: 'high', + file: risky?.file ?? codex.file, + line: risky?.line ?? codex.trustLine, + subject: risky?.name ?? 'projects.trust_level', + message: hasMismatch && unpinned.length > 0 + ? 'Codex project is trusted while MCP servers are unpinned and inconsistent across surfaces.' + : unpinned.length > 0 + ? `Codex project is trusted while MCP server "${risky.name}" is unpinned.` + : 'Codex project is trusted while MCP servers have mismatched commands across surfaces.', + recommendation: 'Do not mark projects trusted until MCP servers are pinned and consistent across all agent surfaces.', + surfaces: uniqueSurfaces([ + 'codex', + ...policies.mcpSurfaces.flatMap((s) => s.servers.map((srv) => srv.surfaceId)) + ]) + }); + } + return findings; +} +function detectCodexClaudePostureGap(policies) { + const codex = policies.codex; + const claude = policies.claude; + if (!codex || !claude) { + return findingsEmpty(); + } + const sandboxRank = codexSandboxRank(codex.sandbox); + if (sandboxRank < 1) { + return findingsEmpty(); + } + const hasStrictDenies = [...claude.deny.keys()].some(isSensitiveDeny); + if (!hasStrictDenies) { + return findingsEmpty(); + } + return [{ + kind: 'codex_claude_posture_gap', + severity: 'medium', + file: codex.file, + line: codex.sandboxLine, + subject: codex.sandbox ?? 'sandbox', + message: `Codex sandbox is "${codex.sandbox ?? 'widened'}" while Claude has strict deny rules with no equivalent Codex restriction.`, + recommendation: 'Align Codex sandbox posture with Claude deny rules, or document why Codex requires broader filesystem access.', + surfaces: ['codex', 'claude'] + }]; +} +export function buildEffectiveUnion(policies) { + const union = []; + const allServers = policies.mcpSurfaces.flatMap((s) => s.servers); + const uniqueServerNames = new Set(allServers.map((s) => s.name)); + if (uniqueServerNames.size > 0) { + union.push(`${uniqueServerNames.size} MCP server${uniqueServerNames.size === 1 ? '' : 's'} configured`); + } + const unpinnedCount = allServers.filter((s) => s.unpinned).length; + if (unpinnedCount > 0) { + union.push(`${unpinnedCount} unpinned MCP package${unpinnedCount === 1 ? '' : 's'}`); + } + if (policies.claude) { + const broadAllows = [...policies.claude.allow.keys()].filter(isBroadAllow); + if (broadAllows.some((p) => p.toLowerCase().includes('bash('))) { + union.push('bash wildcards allowed (Claude)'); + } + if (broadAllows.some((p) => p.toLowerCase().includes('read('))) { + union.push('broad read paths allowed (Claude)'); + } + if (policies.claude.deny.size > 0) { + union.push(`${policies.claude.deny.size} Claude deny rule${policies.claude.deny.size === 1 ? '' : 's'}`); + } + if (policies.claude.hooks.size > 0) { + union.push(`${policies.claude.hooks.size} Claude hook${policies.claude.hooks.size === 1 ? '' : 's'}`); + } + } + if (policies.codex?.networkAccess) { + union.push('network enabled (Codex)'); + } + if (policies.codex?.trusted) { + union.push('Codex project trusted'); + } + if (policies.codex?.sandbox) { + union.push(`Codex sandbox: ${policies.codex.sandbox}`); + } + const parseFindingCount = policies.parseFindings?.length ?? 0; + if (parseFindingCount > 0) { + union.push(`${parseFindingCount} unreadable agent config${parseFindingCount === 1 ? '' : 's'}`); + } + if (union.length === 0) { + union.push('No agent policy surfaces configured'); + } + return union; +} +export function buildSurfaceMatrix(policies) { + const rows = []; + const byName = groupMcpServersByName(policies); + for (const [name, servers] of byName) { + const values = {}; + for (const server of servers) { + values[server.surfaceId] = truncate(server.command, 48); + } + rows.push({ capability: `MCP: ${name}`, values }); + } + if (policies.claude) { + for (const [permission] of policies.claude.allow) { + rows.push({ + capability: `Allow: ${truncate(permission, 40)}`, + values: { claude: 'allow' } + }); + } + for (const [permission] of policies.claude.deny) { + rows.push({ + capability: `Deny: ${truncate(permission, 40)}`, + values: { claude: 'deny' } + }); + } + for (const hook of policies.claude.hooks) { + rows.push({ + capability: `Hook: ${hook}`, + values: { claude: 'present' } + }); + } + } + if (policies.codex) { + if (policies.codex.sandbox) { + rows.push({ + capability: 'Codex sandbox', + values: { codex: policies.codex.sandbox } + }); + } + if (policies.codex.approvalPolicy) { + rows.push({ + capability: 'Codex approval', + values: { codex: policies.codex.approvalPolicy } + }); + } + if (policies.codex.networkAccess !== undefined) { + rows.push({ + capability: 'Codex network', + values: { codex: policies.codex.networkAccess ? 'enabled' : 'disabled' } + }); + } + if (policies.codex.trusted !== undefined) { + rows.push({ + capability: 'Codex trust', + values: { codex: policies.codex.trusted ? 'trusted' : 'untrusted' } + }); + } + } + return rows; +} +function groupMcpServersByName(policies) { + const byName = new Map(); + for (const surface of policies.mcpSurfaces) { + for (const server of surface.servers) { + const existing = byName.get(server.name) ?? []; + existing.push(server); + byName.set(server.name, existing); + } + } + return byName; +} +function uniqueSurfaces(surfaces) { + return [...new Set(surfaces)]; +} +function uniqueSorted(values) { + return [...new Set(values)].sort(); +} +function envFingerprint(env) { + return Object.entries(env) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); +} +function envKeyFingerprint(env) { + return uniqueSorted(Object.keys(env)).join('\n'); +} +function differingEnvKeys(servers) { + const keys = uniqueSorted(servers.flatMap((server) => Object.keys(server.env))); + return keys.filter((key) => { + const values = new Set(servers.map((server) => server.env[key] ?? '')); + return values.size > 1; + }); +} +function summarizeEnvKeys(servers) { + return servers + .map((server) => { + const keys = uniqueSorted(Object.keys(server.env)); + return `${server.surfaceId} uses ${keys.length > 0 ? keys.join(', ') : 'no env variables'}`; + }) + .join('; '); +} +function headerFingerprint(headers) { + return Object.entries(headers) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); +} +function headerKeyFingerprint(headers) { + return uniqueSorted(Object.keys(headers)).join('\n'); +} +function differingHeaderKeys(servers) { + const keys = uniqueSorted(servers.flatMap((server) => Object.keys(server.headers))); + return keys.filter((key) => { + const values = new Set(servers.map((server) => server.headers[key] ?? '')); + return values.size > 1; + }); +} +function summarizeHeaderKeys(servers) { + return servers + .map((server) => { + const keys = uniqueSorted(Object.keys(server.headers)); + return `${server.surfaceId} uses ${keys.length > 0 ? keys.join(', ') : 'no headers'}`; + }) + .join('; '); +} +function summarizeEnabledStates(servers) { + return servers + .map((server) => `${server.enabled ? 'enabled' : 'disabled'} in ${server.surfaceId}`) + .join('; '); +} +function formatSurfaceList(surfaces) { + return surfaces.join(', '); +} +function listOtherAgentSurfaces(policies) { + const surfaces = policies.mcpSurfaces + .map((surface) => surface.surfaceId) + .filter((surface) => surface !== 'codex'); + if (policies.claude) { + surfaces.push('claude'); + } + for (const finding of policies.parseFindings ?? []) { + surfaces.push(...finding.surfaces.filter((surface) => surface !== 'codex')); + } + return uniqueSurfaces(surfaces); +} +function truncate(value, max) { + return value.length <= max ? value : `${value.slice(0, max - 3)}...`; +} +function findingsEmpty() { + return []; +} +function claudeMcpServerName(permission) { + const start = permission.toLowerCase().indexOf('mcp__'); + if (start === -1) { + return undefined; + } + if (start > 0 && /[a-z0-9_]/i.test(permission[start - 1])) { + return undefined; + } + const grant = permission.slice(start + 'mcp__'.length).match(/^[A-Za-z0-9_*-]+/)?.[0] ?? ''; + const server = grant.split('__')[0]; + if (!server || server.includes('*')) { + return undefined; + } + return server; +} diff --git a/dist/parsers/claude.js b/dist/parsers/claude.js new file mode 100644 index 0000000..80587bc --- /dev/null +++ b/dist/parsers/claude.js @@ -0,0 +1,118 @@ +import { configPath, isRecord, lineOfJsonStringValue, readJsonObjectWithSource } from '../discovery.js'; +import { configParseFinding } from './errors.js'; +const CLAUDE_SETTINGS_FILE = '.claude/settings.json'; +export async function parseClaudePolicy(root) { + const source = await readJsonObjectWithSource(configPath(root, CLAUDE_SETTINGS_FILE)); + if (!source.text.trim()) { + return { findings: [] }; + } + if (source.parseError) { + return { + findings: [configParseFinding(CLAUDE_SETTINGS_FILE, 'claude', source.parseError)] + }; + } + const json = source.json; + const permissions = isRecord(json.permissions) ? json.permissions : {}; + const hooks = isRecord(json.hooks) ? json.hooks : {}; + const allow = readStringArrayWithLines(permissions.allow, source.text); + const deny = readStringArrayWithLines(permissions.deny, source.text); + const hookNames = new Set(Object.entries(hooks) + .filter(([, value]) => hookHasEntries(value)) + .map(([name]) => name)); + if (allow.size === 0 && deny.size === 0 && hookNames.size === 0) { + return { findings: [] }; + } + return { + policy: { + surfaceId: 'claude', + file: CLAUDE_SETTINGS_FILE, + allow, + deny, + hooks: hookNames + }, + findings: [] + }; +} +// A permission counts as "broad" only 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 produced 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 pattern = new RegExp(`\\b${verb}\\b(\\([^)]*\\))?`); + const match = pattern.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____`. A grant of just + // `mcp__` means every tool from that server; `mcp____*` + // is the same thing spelled out. Anything narrower (a real tool name like + // `get_issue`) is a specific grant and should not count as broad. + // + // Tool names contain underscores (`get_issue`, `create_pull_request`), so + // we have to split on the literal `__` separator rather than a character + // class — otherwise greedy matching collapses tool names into the server. + 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('*'); +} +export function isSensitiveDeny(permission) { + const normalized = permission.toLowerCase(); + return normalized.includes('.env') + || normalized.includes('secret') + || normalized.includes('credential') + || normalized.includes('.pem'); +} +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; +} diff --git a/dist/parsers/codex.js b/dist/parsers/codex.js new file mode 100644 index 0000000..5cef9ff --- /dev/null +++ b/dist/parsers/codex.js @@ -0,0 +1,491 @@ +import { readFile } from 'node:fs/promises'; +import { configPath } from '../discovery.js'; +import { isUnpinnedCommand, serverCommand } from './mcp.js'; +import { configParseFinding } from './errors.js'; +const CODEX_CONFIG_FILE = '.codex/config.toml'; +const WATCHED_CODEX_KEYS = new Set([ + 'sandbox_mode', + 'sandbox', + 'windows.sandbox', + 'approval_policy', + 'network_access', + 'sandbox_workspace_write.network_access', + 'projects.trust_level' +]); +export async function parseCodexPolicy(root) { + let text = ''; + try { + text = await readFile(configPath(root, CODEX_CONFIG_FILE), 'utf8'); + } + catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return { findings: [] }; + } + throw error; + } + if (!text.trim()) { + return { findings: [] }; + } + const parsed = parseTomlEntries(text); + if (parsed.parseError) { + return { + findings: [configParseFinding(CODEX_CONFIG_FILE, 'codex', parsed.parseError)] + }; + } + const entries = parsed.entries; + const sandbox = entries.get('sandbox_mode') ?? entries.get('sandbox') ?? entries.get('windows.sandbox'); + const approval = entries.get('approval_policy'); + const network = entries.get('network_access') ?? entries.get('sandbox_workspace_write.network_access'); + const trust = entries.get('projects.trust_level'); + const hasPolicySettings = Boolean(sandbox || approval || network || trust); + const mcpSurface = parsed.mcpServers.length > 0 + ? { + surfaceId: 'codex', + file: CODEX_CONFIG_FILE, + servers: parsed.mcpServers + } + : undefined; + if (!hasPolicySettings && !mcpSurface) { + return { findings: [] }; + } + return { + policy: hasPolicySettings + ? { + surfaceId: 'codex', + file: CODEX_CONFIG_FILE, + sandbox: sandbox?.value, + sandboxLine: sandbox?.line, + approvalPolicy: approval?.value, + networkAccess: network?.value === 'true', + networkLine: network?.line, + trusted: trust?.value === 'trusted', + trustLine: trust?.line + } + : undefined, + mcpSurface, + findings: [] + }; +} +export function codexSandboxRank(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 parseTomlEntries(text) { + const entries = new Map(); + const mcpDrafts = 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 = sectionMatch[1].trim(); + const mcpSection = parseMcpServerSection(section); + if (mcpSection) { + getMcpDraft(mcpDrafts, mcpSection.name, index + 1); + } + continue; + } + if (trimmed.startsWith('[')) { + return { + entries, + mcpServers: buildCodexMcpServers(mcpDrafts), + parseError: { + message: 'Invalid TOML section header', + line: index + 1 + } + }; + } + const keyMatch = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(trimmed); + if (!keyMatch) { + const malformedKey = findMalformedWatchedKey(section, trimmed); + if (malformedKey) { + return { + entries, + mcpServers: buildCodexMcpServers(mcpDrafts), + parseError: { + message: `Invalid TOML assignment for "${malformedKey}"; expected key = value`, + line: index + 1 + } + }; + } + continue; + } + const mcpSection = parseMcpServerSection(section); + if (mcpSection) { + recordMcpServerValue(mcpDrafts, mcpSection, keyMatch[1], keyMatch[2], index + 1); + continue; + } + const key = normalizeKey(normalizeSection(section), keyMatch[1]); + const value = parseScalarValue(keyMatch[2]); + if (value !== undefined) { + entries.set(key, { line: index + 1, value }); + } + else if (WATCHED_CODEX_KEYS.has(key)) { + return { + entries, + mcpServers: buildCodexMcpServers(mcpDrafts), + parseError: { + message: `Invalid TOML scalar value for "${key}"`, + line: index + 1 + } + }; + } + } + return { entries, mcpServers: buildCodexMcpServers(mcpDrafts) }; +} +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 parseMcpServerSection(section) { + const parts = splitTomlPath(section); + if (parts[0] !== 'mcp_servers' || !parts[1]) { + return undefined; + } + if (parts.length === 2) { + return { name: parts[1] }; + } + if (parts.length === 3 && isCodexMcpSubtable(parts[2])) { + return { name: parts[1], subtable: parts[2] }; + } + return undefined; +} +function splitTomlPath(path) { + const parts = []; + let current = ''; + let quote; + let escape = false; + for (const char of path.trim()) { + if (quote) { + if (escape) { + current += char; + escape = false; + } + else if (char === '\\' && quote === '"') { + escape = true; + } + else if (char === quote) { + quote = undefined; + } + else { + current += char; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === '.') { + parts.push(current.trim()); + current = ''; + continue; + } + current += char; + } + parts.push(current.trim()); + return parts.filter((part) => part.length > 0); +} +function isCodexMcpSubtable(value) { + return value === 'env' || value === 'http_headers' || value === 'env_http_headers'; +} +function getMcpDraft(drafts, name, line) { + const existing = drafts.get(name); + if (existing) { + return existing; + } + const draft = { + line, + env: {}, + headers: {} + }; + drafts.set(name, draft); + return draft; +} +function recordMcpServerValue(drafts, section, rawKey, rawValue, line) { + const key = rawKey.trim().toLowerCase(); + const draft = getMcpDraft(drafts, section.name, line); + if (section.subtable === 'env') { + const value = parseStringValue(rawValue); + if (value !== undefined) { + draft.env[rawKey.trim()] = value; + } + return; + } + if (section.subtable === 'http_headers' || section.subtable === 'env_http_headers') { + const value = parseStringValue(rawValue); + if (value !== undefined) { + draft.headers[rawKey.trim()] = section.subtable === 'env_http_headers' ? `env:${value}` : value; + } + return; + } + if (key === 'command') { + draft.command = parseStringValue(rawValue); + } + else if (key === 'args') { + draft.args = parseStringArrayValue(rawValue); + } + else if (key === 'url') { + draft.url = parseStringValue(rawValue); + } + else if (key === 'server_url' || key === 'serverurl') { + draft.serverUrl = parseStringValue(rawValue); + } + else if (key === 'enabled') { + draft.enabled = parseBooleanValue(rawValue); + } + else if (key === 'env') { + draft.env = { ...draft.env, ...parseStringMapValue(rawValue, false) }; + } + else if (key === 'http_headers') { + draft.headers = { ...draft.headers, ...parseStringMapValue(rawValue, false) }; + } + else if (key === 'env_http_headers') { + draft.headers = { ...draft.headers, ...parseStringMapValue(rawValue, true) }; + } + else if (key === 'bearer_token_env_var') { + const value = parseStringValue(rawValue); + if (value !== undefined) { + draft.headers.Authorization = `env:${value}`; + } + } +} +function buildCodexMcpServers(drafts) { + const servers = []; + for (const [name, draft] of drafts) { + const command = serverCommand(draft); + if (!command) { + continue; + } + servers.push({ + name, + command, + enabled: draft.enabled !== false, + env: draft.env, + headers: draft.headers, + unpinned: isUnpinnedCommand(draft), + line: draft.line, + file: CODEX_CONFIG_FILE, + surfaceId: 'codex' + }); + } + return servers; +} +function parseStringValue(rawValue) { + const trimmed = stripTomlInlineComment(rawValue).trim(); + const doubleQuoted = /^"((?:\\.|[^"])*)"/.exec(trimmed); + if (doubleQuoted) { + return decodeTomlDoubleQuotedString(doubleQuoted[1]); + } + const singleQuoted = /^'([^']*)'/.exec(trimmed); + if (singleQuoted) { + return singleQuoted[1]; + } + return /^([^,\]}#\s]+)/.exec(trimmed)?.[1]; +} +function parseStringArrayValue(rawValue) { + const trimmed = stripTomlInlineComment(rawValue).trim(); + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) { + return undefined; + } + const entries = splitTomlList(trimmed.slice(1, -1)) + .map((entry) => parseStringValue(entry)) + .filter((entry) => entry !== undefined); + return entries; +} +function parseStringMapValue(rawValue, envBacked) { + const trimmed = stripTomlInlineComment(rawValue).trim(); + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return {}; + } + const map = {}; + for (const entry of splitTomlList(trimmed.slice(1, -1))) { + const [rawKey, rawEntryValue] = splitTomlAssignment(entry); + const key = parseMapKey(rawKey); + const value = parseStringValue(rawEntryValue ?? ''); + if (key && value !== undefined) { + map[key] = envBacked ? `env:${value}` : value; + } + } + return map; +} +function parseMapKey(rawKey) { + if (!rawKey) { + return undefined; + } + return parseStringValue(rawKey) ?? rawKey.trim(); +} +function parseBooleanValue(rawValue) { + const value = stripTomlInlineComment(rawValue).trim().toLowerCase(); + if (value.startsWith('true')) { + return true; + } + if (value.startsWith('false')) { + return false; + } + return undefined; +} +function splitTomlAssignment(entry) { + let quote; + let escape = false; + for (let index = 0; index < entry.length; index += 1) { + const char = entry[index]; + if (quote) { + if (escape) { + escape = false; + } + else if (char === '\\' && quote === '"') { + escape = true; + } + else if (char === quote) { + quote = undefined; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === '=') { + return [entry.slice(0, index), entry.slice(index + 1)]; + } + } + return [entry, undefined]; +} +function splitTomlList(value) { + const entries = []; + let current = ''; + let quote; + let escape = false; + let braceDepth = 0; + let bracketDepth = 0; + for (const char of value) { + if (quote) { + current += char; + if (escape) { + escape = false; + } + else if (char === '\\' && quote === '"') { + escape = true; + } + else if (char === quote) { + quote = undefined; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + if (char === '{') { + braceDepth += 1; + } + else if (char === '}') { + braceDepth -= 1; + } + else if (char === '[') { + bracketDepth += 1; + } + else if (char === ']') { + bracketDepth -= 1; + } + if (char === ',' && braceDepth === 0 && bracketDepth === 0) { + entries.push(current.trim()); + current = ''; + continue; + } + current += char; + } + if (current.trim()) { + entries.push(current.trim()); + } + return entries; +} +function stripTomlInlineComment(rawValue) { + let quote; + let escape = false; + for (let index = 0; index < rawValue.length; index += 1) { + const char = rawValue[index]; + if (quote) { + if (escape) { + escape = false; + } + else if (char === '\\' && quote === '"') { + escape = true; + } + else if (char === quote) { + quote = undefined; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === '#') { + return rawValue.slice(0, index); + } + } + return rawValue; +} +function decodeTomlDoubleQuotedString(value) { + return value.replace(/\\(["\\bfnrt])/g, (_match, escaped) => { + if (escaped === 'b') { + return '\b'; + } + if (escaped === 'f') { + return '\f'; + } + if (escaped === 'n') { + return '\n'; + } + if (escaped === 'r') { + return '\r'; + } + if (escaped === 't') { + return '\t'; + } + return escaped; + }); +} +function findMalformedWatchedKey(section, trimmed) { + const token = /^([A-Za-z0-9_.-]+)/.exec(trimmed)?.[1]; + if (!token) { + return undefined; + } + const key = normalizeKey(normalizeSection(section), token); + return WATCHED_CODEX_KEYS.has(key) ? key : undefined; +} +function isNodeError(error) { + return error instanceof Error && 'code' in error; +} diff --git a/dist/parsers/errors.js b/dist/parsers/errors.js new file mode 100644 index 0000000..42842eb --- /dev/null +++ b/dist/parsers/errors.js @@ -0,0 +1,22 @@ +const SURFACE_NAMES = { + root_mcp: 'Root MCP', + cursor_mcp: 'Cursor MCP', + vscode_mcp: 'VS Code MCP', + windsurf_mcp: 'Codeium/Windsurf MCP', + claude: 'Claude', + codex: 'Codex' +}; +export function configParseFinding(file, surface, parseError) { + const syntax = surface === 'codex' ? 'TOML' : 'JSON'; + return { + kind: 'config_parse_error', + severity: 'high', + file, + line: parseError.line, + locations: [{ file, line: parseError.line, surface }], + subject: file, + message: `Could not parse ${SURFACE_NAMES[surface]} config at ${file}: ${parseError.message}.`, + recommendation: `Fix the ${syntax} syntax so PolicyMesh can audit this agent policy surface.`, + surfaces: [surface] + }; +} diff --git a/dist/parsers/index.js b/dist/parsers/index.js new file mode 100644 index 0000000..4189a1d --- /dev/null +++ b/dist/parsers/index.js @@ -0,0 +1,31 @@ +import { parseClaudePolicy } from './claude.js'; +import { parseCodexPolicy } from './codex.js'; +import { parseMcpSurfaces } from './mcp.js'; +export async function parseRepoPolicies(root) { + const [mcp, claude, codex] = await Promise.all([ + parseMcpSurfaces(root), + parseClaudePolicy(root), + parseCodexPolicy(root) + ]); + return { + mcpSurfaces: codex.mcpSurface ? [...mcp.surfaces, codex.mcpSurface] : mcp.surfaces, + claude: claude.policy, + codex: codex.policy, + parseFindings: [...mcp.findings, ...claude.findings, ...codex.findings] + }; +} +export function countConfiguredSurfaces(policies) { + const surfaces = new Set(policies.mcpSurfaces.map((surface) => surface.surfaceId)); + if (policies.claude) { + surfaces.add('claude'); + } + if (policies.codex) { + surfaces.add('codex'); + } + for (const finding of policies.parseFindings ?? []) { + for (const surface of finding.surfaces) { + surfaces.add(surface); + } + } + return surfaces.size; +} diff --git a/dist/parsers/mcp.js b/dist/parsers/mcp.js new file mode 100644 index 0000000..c76d961 --- /dev/null +++ b/dist/parsers/mcp.js @@ -0,0 +1,131 @@ +import { configPath, isRecord, lineOfJsonKey, readJsonObjectWithSource } from '../discovery.js'; +import { configParseFinding } from './errors.js'; +const MCP_CONFIGS = [ + { surfaceId: 'root_mcp', path: '.mcp.json', serverKeys: ['mcpServers'] }, + { surfaceId: 'cursor_mcp', path: '.cursor/mcp.json', serverKeys: ['mcpServers', 'servers'] }, + { surfaceId: 'vscode_mcp', path: '.vscode/mcp.json', serverKeys: ['servers', 'mcpServers'] }, + { surfaceId: 'windsurf_mcp', path: '.codeium/windsurf/mcp_config.json', serverKeys: ['mcpServers'] } +]; +export async function parseMcpSurfaces(root) { + const surfaces = []; + const findings = []; + for (const config of MCP_CONFIGS) { + const { servers, configured, finding } = await readMcpServers(root, config); + if (finding) { + findings.push(finding); + } + if (configured) { + surfaces.push({ + surfaceId: config.surfaceId, + file: config.path, + servers + }); + } + } + return { surfaces, findings }; +} +async function readMcpServers(root, config) { + const source = await readJsonObjectWithSource(configPath(root, config.path)); + if (!source.text.trim()) { + return { servers: [], configured: false }; + } + if (source.parseError) { + return { + servers: [], + configured: false, + finding: configParseFinding(config.path, config.surfaceId, source.parseError) + }; + } + const rawServers = readServerMap(source.json, config.serverKeys); + if (!isRecord(rawServers)) { + return { servers: [], configured: false }; + } + const servers = []; + for (const [name, value] of Object.entries(rawServers)) { + if (!isRecord(value)) { + continue; + } + const raw = { + 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, + enabled: typeof value.enabled === 'boolean' ? value.enabled : undefined, + disabled: typeof value.disabled === 'boolean' ? value.disabled : undefined, + env: readStringMap(value.env), + headers: readStringMap(value.headers), + url: typeof value.url === 'string' ? value.url : undefined, + serverUrl: typeof value.serverUrl === 'string' ? value.serverUrl : undefined + }; + const command = serverCommand(raw); + if (!command) { + continue; + } + servers.push({ + name, + command, + enabled: serverEnabled(raw), + env: raw.env ?? {}, + headers: raw.headers ?? {}, + unpinned: isUnpinnedCommand(raw), + line: raw.line, + file: config.path, + surfaceId: config.surfaceId + }); + } + return { servers, configured: true }; +} +function readServerMap(json, serverKeys) { + for (const key of serverKeys) { + if (isRecord(json[key])) { + return json[key]; + } + } + return undefined; +} +function readStringMap(value) { + if (!isRecord(value)) { + return undefined; + } + const entries = Object.entries(value).filter((entry) => typeof entry[1] === 'string'); + return Object.fromEntries(entries); +} +export function serverCommand(server) { + return [server.command, ...(server.args ?? []), server.url, server.serverUrl].filter(Boolean).join(' '); +} +function serverEnabled(server) { + if (server.disabled !== undefined) { + return !server.disabled; + } + return server.enabled !== false; +} +export 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 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/dist/report.js b/dist/report.js new file mode 100644 index 0000000..ea06166 --- /dev/null +++ b/dist/report.js @@ -0,0 +1,136 @@ +export function renderReport(report, format, options = {}) { + if (format === 'json') { + return `${JSON.stringify(report, null, 2)}\n`; + } + if (format === 'markdown') { + return renderMarkdown(report); + } + if (format === 'github') { + return renderGithubAnnotations(report, options.githubAnnotationPathPrefix); + } + return renderText(report); +} +function renderMarkdown(report) { + const lines = [`# PolicyMesh agent policy review: ${report.rating.toUpperCase()}`, '']; + lines.push('## Effective capability union', ''); + for (const item of report.effectiveUnion) { + lines.push(`- ${item}`); + } + lines.push(''); + if (report.matrix.length > 0) { + lines.push('## Surface matrix', ''); + lines.push(`| Capability | ${SURFACE_COLUMNS.map(formatSurface).join(' | ')} |`); + lines.push(`| --- | ${SURFACE_COLUMNS.map(() => '---').join(' | ')} |`); + for (const row of report.matrix) { + const capability = escapeMarkdownTableCell(row.capability); + const cells = SURFACE_COLUMNS.map((surface) => escapeMarkdownTableCell(row.values[surface] ?? '-')); + lines.push(`| ${capability} | ${cells.join(' | ')} |`); + } + lines.push(''); + } + if (report.findings.length === 0) { + lines.push('No cross-surface policy conflicts or gaps detected.'); + return `${lines.join('\n')}\n`; + } + lines.push(`This audit produced ${report.findingCount} finding${report.findingCount === 1 ? '' : 's'} across ${report.surfaceCount} configured surface${report.surfaceCount === 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(` Surfaces: ${formatSurfaceList(finding.surfaces)}`); + lines.push(` Recommendation: ${finding.recommendation}`); + } + lines.push(''); + } + return `${lines.join('\n').trimEnd()}\n`; +} +function renderText(report) { + const lines = [`PolicyMesh agent policy review: ${report.rating.toUpperCase()}`]; + lines.push('', 'Effective capability union:'); + for (const item of report.effectiveUnion) { + lines.push(`- ${item}`); + } + for (const finding of report.findings) { + lines.push(`[${finding.severity.toUpperCase()}] ${finding.subject}: ${finding.message} Surfaces: ${formatSurfaceList(finding.surfaces)}.`); + } + if (report.findings.length === 0) { + lines.push('No cross-surface policy conflicts or gaps detected.'); + } + return `${lines.join('\n')}\n`; +} +function renderGithubAnnotations(report, pathPrefix) { + if (report.findings.length === 0) { + return ''; + } + return report.findings + .flatMap((finding) => { + const title = `PolicyMesh ${finding.severity} finding`; + const message = `${finding.message} Surfaces: ${formatSurfaceList(finding.surfaces)}. Recommendation: ${finding.recommendation}`; + return annotationLocations(finding).map((location) => { + const properties = [`file=${escapeProperty(prefixPath(location.file, pathPrefix))}`]; + if (location.line && location.line > 0) { + properties.push(`line=${location.line}`); + } + properties.push(`title=${escapeProperty(title)}`); + return `::warning ${properties.join(',')}::${escapeMessage(message)}`; + }); + }) + .join('\n') + '\n'; +} +const SURFACE_COLUMNS = [ + 'root_mcp', + 'cursor_mcp', + 'vscode_mcp', + 'windsurf_mcp', + 'claude', + 'codex' +]; +const SURFACE_LABELS = { + root_mcp: 'Root MCP', + cursor_mcp: 'Cursor MCP', + vscode_mcp: 'VS Code MCP', + windsurf_mcp: 'Codeium/Windsurf MCP', + claude: 'Claude', + codex: 'Codex' +}; +function formatSurface(surface) { + return SURFACE_LABELS[surface]; +} +function formatSurfaceList(surfaces) { + return surfaces.map(formatSurface).join(', '); +} +function annotationLocations(finding) { + return finding.locations?.length + ? finding.locations.map((location) => ({ file: location.file, line: location.line })) + : [{ file: finding.file, line: finding.line }]; +} +function prefixPath(file, prefix) { + if (!prefix) { + return normalizePath(file); + } + return `${normalizePath(prefix).replace(/\/$/, '')}/${normalizePath(file).replace(/^\.\//, '')}`; +} +function normalizePath(file) { + return file.replaceAll('\\', '/'); +} +function escapeMessage(value) { + return value + .replaceAll('%', '%25') + .replaceAll('\r', '%0D') + .replaceAll('\n', '%0A'); +} +function escapeProperty(value) { + return escapeMessage(value) + .replaceAll(':', '%3A') + .replaceAll(',', '%2C'); +} +function escapeMarkdownTableCell(value) { + return value.replaceAll('|', '\\|').replaceAll(/\r?\n/g, '
'); +} +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/package-lock.json b/package-lock.json index e2ea226..dd3e6a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "policymesh", - "version": "0.1.15", + "version": "0.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "policymesh", - "version": "0.1.15", + "version": "0.1.16", "license": "MIT", "bin": { "policymesh": "dist/index.js" diff --git a/package.json b/package.json index e4758f1..8766728 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "policymesh", - "version": "0.1.15", + "version": "0.1.16", "description": "Cross-surface AI agent policy consistency review.", "type": "module", "keywords": [ diff --git a/test/workflow.test.mjs b/test/workflow.test.mjs index 807889d..4670761 100644 --- a/test/workflow.test.mjs +++ b/test/workflow.test.mjs @@ -1,21 +1,24 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +const execFileAsync = promisify(execFile); const testDir = dirname(fileURLToPath(import.meta.url)); const packageRoot = join(testDir, '..'); -test('release metadata is prepared for v0.1.15 Action users', async () => { +test('release metadata is prepared for v0.1.16 Action users', async () => { const packageJson = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')); const packageLock = JSON.parse(await readFile(join(packageRoot, 'package-lock.json'), 'utf8')); const readme = await readFile(join(packageRoot, 'README.md'), 'utf8'); - assert.equal(packageJson.version, '0.1.15'); - assert.equal(packageLock.version, '0.1.15'); - assert.equal(packageLock.packages[''].version, '0.1.15'); - assert.match(readme, /uses: Conalh\/PolicyMesh@v0\.1\.15/); + assert.equal(packageJson.version, '0.1.16'); + assert.equal(packageLock.version, '0.1.16'); + assert.equal(packageLock.packages[''].version, '0.1.16'); + assert.match(readme, /uses: Conalh\/PolicyMesh@v0\.1\.16/); }); test('package metadata supports OSS discovery', async () => { @@ -54,10 +57,20 @@ test('action.yml declares audit inputs and outputs', async () => { test('published Action runs the bundled CLI without installing or rebuilding itself', async () => { const action = await readFile(join(packageRoot, 'action.yml'), 'utf8'); + const gitignore = await readFile(join(packageRoot, '.gitignore'), 'utf8'); + const { stdout } = await execFileAsync( + 'git', + ['ls-files', 'dist/index.js', 'dist/audit.js'], + { cwd: packageRoot } + ); + const trackedDistFiles = stdout.trim().split(/\r?\n/).filter(Boolean); assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/index\.js" audit --repo/); assert.doesNotMatch(action, /npm ci/); assert.doesNotMatch(action, /npm run build/); + assert.doesNotMatch(gitignore, /^dist\/\s*$/m); + assert.ok(trackedDistFiles.includes('dist/index.js')); + assert.ok(trackedDistFiles.includes('dist/audit.js')); }); test('CI workflow builds and tests PolicyMesh', async () => {