Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions dist/mesh/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,23 @@ function detectMcpCommandMismatch(policies) {
const findings = [];
const byName = groupMcpServersByName(policies);
for (const [name, servers] of byName) {
const commands = new Map();
// Group by canonical identity, not raw command string, so neutral
// differences (npx -y vs npx, .cmd/.exe suffix, flag reordering)
// don't produce false-positive mcp_command_mismatch findings.
const byIdentity = new Map();
for (const server of servers) {
const existing = commands.get(server.command) ?? [];
const existing = byIdentity.get(server.canonicalIdentity) ?? [];
existing.push(server);
commands.set(server.command, existing);
byIdentity.set(server.canonicalIdentity, existing);
}
if (commands.size <= 1) {
if (byIdentity.size <= 1) {
continue;
}
const commandList = [...commands.keys()].map((cmd) => `"${cmd}"`).join(' vs ');
// Message still shows the user-visible commands so the finding is
// actionable — even though grouping was on canonical identity.
const commandList = [...new Set(servers.map((s) => s.command))]
.map((cmd) => `"${cmd}"`)
.join(' vs ');
const primary = servers[0];
findings.push({
kind: 'mcp_command_mismatch',
Expand Down
6 changes: 6 additions & 0 deletions dist/parsers/codex.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFile } from 'node:fs/promises';
import { normalizeMcpCommand } from 'agent-gov-core';
import { configPath } from '../discovery.js';
import { isUnpinnedCommand, serverCommand } from './mcp.js';
import { configParseFinding } from './errors.js';
Expand Down Expand Up @@ -289,6 +290,11 @@
servers.push({
name,
command,
canonicalIdentity: normalizeMcpCommand({
command: draft.command,
args: draft.args,
url: draft.url ?? draft.serverUrl,
}),
enabled: draft.enabled !== false,
env: draft.env,
headers: draft.headers,
Expand Down
6 changes: 6 additions & 0 deletions dist/parsers/mcp.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizeMcpCommand } from 'agent-gov-core';
import { configPath, isRecord, lineOfJsonKey, readJsonObjectWithSource } from '../discovery.js';
import { configParseFinding } from './errors.js';
const MCP_CONFIGS = [
Expand Down Expand Up @@ -65,6 +66,11 @@
servers.push({
name,
command,
canonicalIdentity: normalizeMcpCommand({
command: raw.command,
args: raw.args,
url: raw.url ?? raw.serverUrl,
}),
enabled: serverEnabled(raw),
env: raw.env ?? {},
headers: raw.headers ?? {},
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"name": "policymesh",
"version": "0.1.18",
Expand Down Expand Up @@ -31,7 +31,7 @@
"test": "node --test"
},
"dependencies": {
"agent-gov-core": "github:Conalh/agent-gov-core#v0.1.1"
"agent-gov-core": "github:Conalh/agent-gov-core#v0.1.2"

Check warning on line 34 in package.json

View workflow job for this annotation

GitHub Actions / scope-review

TaskBound low scope creep

Changed dependency agent-gov-core from github:Conalh/agent-gov-core#v0.1.1 to github:Conalh/agent-gov-core#v0.1.2. Recommendation: Review whether the version change is in scope for the task.
},
"devDependencies": {
"@types/node": "^24.0.0",
Expand Down
17 changes: 12 additions & 5 deletions src/mesh/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,25 @@ function detectMcpCommandMismatch(policies: RepoPolicies): Finding[] {
const byName = groupMcpServersByName(policies);

for (const [name, servers] of byName) {
const commands = new Map<string, McpServer[]>();
// Group by canonical identity, not raw command string, so neutral
// differences (npx -y vs npx, .cmd/.exe suffix, flag reordering)
// don't produce false-positive mcp_command_mismatch findings.
const byIdentity = new Map<string, McpServer[]>();
for (const server of servers) {
const existing = commands.get(server.command) ?? [];
const existing = byIdentity.get(server.canonicalIdentity) ?? [];
existing.push(server);
commands.set(server.command, existing);
byIdentity.set(server.canonicalIdentity, existing);
}

if (commands.size <= 1) {
if (byIdentity.size <= 1) {
continue;
}

const commandList = [...commands.keys()].map((cmd) => `"${cmd}"`).join(' vs ');
// Message still shows the user-visible commands so the finding is
// actionable — even though grouping was on canonical identity.
const commandList = [...new Set(servers.map((s) => s.command))]
.map((cmd) => `"${cmd}"`)
.join(' vs ');
const primary = servers[0];
findings.push({
kind: 'mcp_command_mismatch',
Expand Down
6 changes: 6 additions & 0 deletions src/parsers/codex.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFile } from 'node:fs/promises';
import { normalizeMcpCommand } from 'agent-gov-core';
import { configPath } from '../discovery.js';
import { isUnpinnedCommand, serverCommand } from './mcp.js';
import { configParseFinding } from './errors.js';
Expand Down Expand Up @@ -362,6 +363,11 @@
servers.push({
name,
command,
canonicalIdentity: normalizeMcpCommand({
command: draft.command,
args: draft.args,
url: draft.url ?? draft.serverUrl,
}),
enabled: draft.enabled !== false,
env: draft.env,
headers: draft.headers,
Expand Down
6 changes: 6 additions & 0 deletions src/parsers/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizeMcpCommand } from 'agent-gov-core';
import { configPath, isRecord, lineOfJsonKey, readJsonObjectWithSource } from '../discovery.js';
import { configParseFinding } from './errors.js';
import type { Finding, McpServer, McpSurface, SurfaceId } from '../types.js';
Expand Down Expand Up @@ -104,6 +105,11 @@
servers.push({
name,
command,
canonicalIdentity: normalizeMcpCommand({
command: raw.command,
args: raw.args,
url: raw.url ?? raw.serverUrl,
}),
enabled: serverEnabled(raw),
env: raw.env ?? {},
headers: raw.headers ?? {},
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export type Severity = 'low' | 'medium' | 'high' | 'critical';

export type SurfaceId =
Expand Down Expand Up @@ -29,7 +29,18 @@

export interface McpServer {
name: string;
/** Human-readable launch string. Used in messages/matrix rows only. */
command: string;
/**
* Canonical identity of the launch command from agent-gov-core's
* normalizeMcpCommand, computed *without* env. Two servers with the same
* canonicalIdentity launch the same workload, even if their raw command
* strings differ in neutral ways (flag reordering, `-y`/`--yes`,
* `.cmd`/`.exe` suffix). The mismatch detector groups by this field,
* not by `command`. Env differences are reported separately by
* mcp_env_mismatch and intentionally excluded here.
*/
canonicalIdentity: string;
enabled: boolean;
env: Record<string, string>;
headers: Record<string, string>;
Expand Down
24 changes: 24 additions & 0 deletions test/cli-output.test.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
Expand Down Expand Up @@ -337,6 +337,30 @@
assert.ok(report.matrix.some((row) => row.capability === 'MCP: github' && row.values.codex?.includes('@modelcontextprotocol/server-github@2.0.0')));
});

test('CLI does not flag mcp_command_mismatch on neutral -y flag drift between surfaces', async () => {
// Regression for the PolicyMesh audit's false-positive class:
// root MCP uses `npx -y <pkg>`, Cursor uses `npx <pkg>`. `-y` only
// suppresses npx's install prompt — it doesn't change what runs.
// Pre-fix, this fixture produced a high-severity mcp_command_mismatch
// because the detector grouped by the raw joined command string.
// Post-fix, the detector groups by normalizeMcpCommand canonical
// identity, which drops `-y`/`--yes`, so the surfaces are equivalent.
const repo = join(testDir, 'fixtures', 'mcp-command-neutral-flag-equivalence');

const { stdout } = await execFileAsync(
process.execPath,
['dist/index.js', 'audit', '--repo', repo, '--format', 'json'],
{ cwd: packageRoot }
);
const report = JSON.parse(stdout);

const mismatchFindings = report.findings.filter(
(finding) => finding.kind === 'mcp_command_mismatch'
);
assert.deepEqual(mismatchFindings, [], 'expected no mcp_command_mismatch findings');
assert.equal(report.surfaceCount, 2);
});

test('CLI reports Codeium plugin MCP command drift against root MCP config', async () => {
const repo = join(testDir, 'fixtures', 'codeium-plugin-mcp-config');

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["@modelcontextprotocol/server-github@1.2.3"]
}
}
}
8 changes: 8 additions & 0 deletions test/fixtures/mcp-command-neutral-flag-equivalence/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github@1.2.3"]
}
}
}