codex-plugin-doctor 1.57.0 → 1.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -2
  3. package/dist/compatibility/claude-desktop-install-preview.js +4 -4
  4. package/dist/compatibility/cline-install-preview.js +4 -4
  5. package/dist/compatibility/compatibility-matrix.js +13 -17
  6. package/dist/compatibility/cursor-install-preview.js +4 -4
  7. package/dist/compatibility/windsurf-install-preview.js +4 -4
  8. package/dist/core/doctor-size.js +16 -1
  9. package/dist/core/init-plugin.js +2 -2
  10. package/dist/core/inspector-bridge.js +8 -13
  11. package/dist/core/mcp-config-normalizer.d.ts +18 -0
  12. package/dist/core/mcp-config-normalizer.js +50 -0
  13. package/dist/core/package-path.d.ts +1 -0
  14. package/dist/core/package-path.js +25 -0
  15. package/dist/core/plugin-components.d.ts +7 -0
  16. package/dist/core/plugin-components.js +169 -0
  17. package/dist/core/plugin-hooks.d.ts +2 -0
  18. package/dist/core/plugin-hooks.js +190 -0
  19. package/dist/core/runtime-plan.js +97 -16
  20. package/dist/core/runtime-probe.js +3 -8
  21. package/dist/core/validate-plugin.js +39 -17
  22. package/dist/domain/types.d.ts +34 -0
  23. package/dist/domain/types.js +13 -1
  24. package/dist/mcp/generic-mcp-doctor.js +45 -29
  25. package/dist/rules/rule-catalog.js +130 -4
  26. package/dist/security/security-audit.d.ts +1 -0
  27. package/dist/security/security-audit.js +104 -7
  28. package/examples/README.md +94 -94
  29. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  30. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  31. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  32. package/examples/codex-doctor-risky/.mcp.json +12 -12
  33. package/examples/codex-doctor-risky/mock-server.js +1 -1
  34. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  35. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  36. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  37. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  38. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  39. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  40. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { realpath, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { buildCompatibilityMatrix, readMcpConfigPath } from "../compatibility/compatibility-matrix.js";
4
+ import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
4
5
  import { readJsonFile } from "../core/read-json-file.js";
5
6
  import { probeRuntimeConfig, remoteReliabilityGatePassed } from "../core/runtime-probe.js";
6
7
  import { formatFindingFingerprintLine, withFindingFingerprints } from "../reporting/finding-fingerprint.js";
@@ -15,9 +16,6 @@ function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
15
16
  ...(evidence ? { evidence } : {})
16
17
  };
17
18
  }
18
- function isPlainObject(value) {
19
- return typeof value === "object" && value !== null && !Array.isArray(value);
20
- }
21
19
  async function fileExists(targetPath) {
22
20
  try {
23
21
  const details = await stat(targetPath);
@@ -32,6 +30,13 @@ function isPathWithinRoot(rootPath, candidatePath) {
32
30
  return (relativePath === "" ||
33
31
  (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
34
32
  }
33
+ function isMcpConfigPathOutsideRootError(error) {
34
+ return error instanceof Error && (error.message === "MCP config path resolves outside the package root." ||
35
+ error.message === "Manifest MCP config path resolves outside the package root.");
36
+ }
37
+ function buildMcpConfigPathOutsideRootFinding(configPath) {
38
+ return buildFinding("fail", "mcp.config.path_outside_root", "The MCP config path resolves outside the target root.", "A package that reads MCP configuration outside its root is harder to audit and can depend on unreviewed local files.", "Keep `.mcp.json` or the manifest `mcpServers` reference inside the package root.", { configPath, field: "configPath" });
39
+ }
35
40
  function buildStaticMcpFindings(configPath, parsedConfig) {
36
41
  if (!configPath) {
37
42
  return {
@@ -41,29 +46,24 @@ function buildStaticMcpFindings(configPath, parsedConfig) {
41
46
  ]
42
47
  };
43
48
  }
44
- if (!isPlainObject(parsedConfig)) {
45
- return {
46
- serverCount: 0,
47
- findings: [
48
- buildFinding("fail", "mcp.config.invalid_shape", "The MCP config must be a JSON object.", "MCP clients expect object-shaped configuration so server entries can be resolved deterministically.", `Wrap the MCP config in a top-level object inside \`${configPath}\`.`, { configPath, field: "root" })
49
- ]
50
- };
51
- }
52
- const servers = parsedConfig.mcpServers;
53
- if (!isPlainObject(servers) || Object.keys(servers).length === 0) {
49
+ const normalizedConfig = normalizeMcpConfig(parsedConfig);
50
+ if (!normalizedConfig.ok) {
51
+ if (normalizedConfig.field === "server" && normalizedConfig.invalidServerNames) {
52
+ return {
53
+ serverCount: 0,
54
+ findings: normalizedConfig.invalidServerNames.map((serverName) => buildFinding("fail", "mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "MCP clients cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${configPath}\` to an object.`, { configPath, serverName, field: "server" }))
55
+ };
56
+ }
54
57
  return {
55
58
  serverCount: 0,
56
59
  findings: [
57
- buildFinding("fail", "mcp.config.invalid_shape", "The MCP config must contain a non-empty `mcpServers` object.", "Without server entries, MCP clients cannot discover any package capabilities.", `Define MCP servers under \`mcpServers\` in \`${configPath}\`.`, { configPath, field: "mcpServers" })
60
+ buildFinding("fail", "mcp.config.invalid_shape", "The MCP config must contain a valid MCP server map.", "Without server entries, MCP clients cannot discover any package capabilities.", `Use a direct server map, \`mcp_servers\`, or \`mcpServers\` in \`${configPath}\`.`, { configPath, field: normalizedConfig.field })
58
61
  ]
59
62
  };
60
63
  }
64
+ const servers = normalizedConfig.servers;
61
65
  const findings = [];
62
66
  for (const [serverName, serverConfig] of Object.entries(servers)) {
63
- if (!isPlainObject(serverConfig)) {
64
- findings.push(buildFinding("fail", "mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "MCP clients cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${configPath}\` to an object.`, { configPath, serverName, field: "server" }));
65
- continue;
66
- }
67
67
  const command = serverConfig.command;
68
68
  const url = serverConfig.url;
69
69
  if (typeof command !== "string" && typeof url !== "string") {
@@ -90,7 +90,18 @@ function mergeReportStatus(findings, security) {
90
90
  export async function buildGenericMcpDoctor(targetPath, environment = {}, options = {}) {
91
91
  const rootPath = path.resolve(targetPath);
92
92
  const compatibility = await buildCompatibilityMatrix(rootPath, environment);
93
- const mcpConfigPath = await readMcpConfigPath(rootPath);
93
+ let mcpConfigPath;
94
+ let mcpConfigPathOutsideRoot = false;
95
+ try {
96
+ mcpConfigPath = await readMcpConfigPath(rootPath);
97
+ }
98
+ catch (error) {
99
+ if (!isMcpConfigPathOutsideRootError(error)) {
100
+ throw error;
101
+ }
102
+ mcpConfigPath = null;
103
+ mcpConfigPathOutsideRoot = true;
104
+ }
94
105
  const canonicalRootPath = await realpath(rootPath).catch(() => null);
95
106
  const canonicalMcpConfigPath = mcpConfigPath
96
107
  ? await realpath(mcpConfigPath).catch(() => null)
@@ -98,18 +109,17 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}, option
98
109
  let parsedConfig = null;
99
110
  let staticFindings = [];
100
111
  let serverCount = 0;
101
- if (!mcpConfigPath || !(await fileExists(mcpConfigPath))) {
112
+ if (mcpConfigPathOutsideRoot) {
113
+ staticFindings = [buildMcpConfigPathOutsideRootFinding(".mcp.json")];
114
+ }
115
+ else if (!mcpConfigPath || !(await fileExists(mcpConfigPath))) {
102
116
  staticFindings = buildStaticMcpFindings(null, null).findings;
103
117
  }
104
118
  else if (!canonicalRootPath ||
105
119
  !canonicalMcpConfigPath ||
106
120
  !isPathWithinRoot(canonicalRootPath, canonicalMcpConfigPath)) {
107
121
  staticFindings = [
108
- buildFinding("fail", "mcp.config.path_outside_root", "The MCP config path resolves outside the target root.", "A package that reads MCP configuration outside its root is harder to audit and can depend on unreviewed local files.", "Keep `.mcp.json` or the manifest `mcpServers` reference inside the package root.", {
109
- configPath: path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"),
110
- resolvedPath: canonicalMcpConfigPath ?? mcpConfigPath,
111
- field: "configPath"
112
- })
122
+ buildMcpConfigPathOutsideRootFinding(path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"))
113
123
  ];
114
124
  }
115
125
  else {
@@ -128,9 +138,12 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}, option
128
138
  ];
129
139
  }
130
140
  }
131
- const security = buildSecurityAuditFromFindings(rootPath, mcpConfigPath && parsedConfig !== null
132
- ? auditMcpServerConfig(rootPath, parsedConfig, { configPath: mcpConfigPath })
133
- : []);
141
+ const security = buildSecurityAuditFromFindings(rootPath, [
142
+ ...staticFindings.filter((finding) => finding.id === "mcp.config.path_outside_root"),
143
+ ...(mcpConfigPath && parsedConfig !== null
144
+ ? auditMcpServerConfig(rootPath, parsedConfig, { configPath: mcpConfigPath })
145
+ : [])
146
+ ]);
134
147
  const runtimeResult = options.runtime &&
135
148
  mcpConfigPath !== null &&
136
149
  parsedConfig !== null &&
@@ -156,7 +169,10 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}, option
156
169
  exitCode: status === "fail" ? 1 : 0,
157
170
  mcpConfigPath,
158
171
  serverCount,
159
- findings: [...fingerprintedFindings, ...security.findings],
172
+ findings: [
173
+ ...fingerprintedFindings,
174
+ ...security.findings.filter((finding) => !fingerprintedFindings.some((staticFinding) => staticFinding.fingerprint === finding.fingerprint))
175
+ ],
160
176
  security,
161
177
  compatibility,
162
178
  ...(runtimeResult ? { runtimeScorecard: runtimeResult.scorecard } : {}),
@@ -53,6 +53,123 @@ export const ruleCatalog = [
53
53
  fix: "Add a short, specific `description` field to `.codex-plugin/plugin.json`.",
54
54
  example: '{ "description": "Validates GitHub PR automation workflows before release." }'
55
55
  },
56
+ {
57
+ id: "plugin.manifest.invalid_field",
58
+ category: "package",
59
+ defaultSeverity: "fail",
60
+ summary: "A plugin manifest optional field is invalid.",
61
+ why: "Malformed optional metadata cannot be interpreted reliably by Codex clients.",
62
+ fix: "Use the official type for the affected field in `.codex-plugin/plugin.json`.",
63
+ example: '{ "keywords": ["validation", "mcp"] }'
64
+ },
65
+ {
66
+ id: "plugin.manifest.invalid_path",
67
+ category: "package",
68
+ defaultSeverity: "fail",
69
+ summary: "A plugin manifest path is not a safe package-relative path.",
70
+ why: "Paths outside the plugin package can expose files that are not part of the plugin bundle.",
71
+ fix: "Use a `./` path that remains inside the plugin package.",
72
+ example: '{ "skills": "./skills" }'
73
+ },
74
+ {
75
+ id: "plugin.app.missing_file",
76
+ category: "package",
77
+ defaultSeverity: "fail",
78
+ summary: "The plugin manifest points to a missing .app.json file.",
79
+ why: "Codex cannot load an app manifest that is absent or not a regular file.",
80
+ fix: "Create the referenced app manifest as a regular JSON file inside the plugin package.",
81
+ example: '{ "apps": "./apps/example.app.json" }'
82
+ },
83
+ {
84
+ id: "plugin.app.invalid_json",
85
+ category: "package",
86
+ defaultSeverity: "fail",
87
+ summary: "The referenced .app.json file is not valid JSON.",
88
+ why: "Codex cannot parse the app manifest.",
89
+ fix: "Fix the JSON syntax in the referenced app manifest.",
90
+ example: "Use syntactically valid JSON in the referenced .app.json file."
91
+ },
92
+ {
93
+ id: "plugin.app.invalid_path",
94
+ category: "package",
95
+ defaultSeverity: "fail",
96
+ summary: "A plugin app path is not a safe package-relative path.",
97
+ why: "Paths outside the plugin package can expose files that are not part of the plugin bundle.",
98
+ fix: "Use an `./` app path that remains inside the plugin package.",
99
+ example: '{ "apps": "./apps/example.app.json" }'
100
+ },
101
+ {
102
+ id: "plugin.hook.missing_file",
103
+ category: "package",
104
+ defaultSeverity: "fail",
105
+ summary: "The plugin lifecycle hook source file is missing.",
106
+ why: "Codex cannot load a hook configuration file that is absent or not a regular file.",
107
+ fix: "Create the referenced hook configuration JSON file inside the plugin package.",
108
+ example: '{ "hooks": "./hooks/hooks.json" }'
109
+ },
110
+ {
111
+ id: "plugin.hook.invalid_json",
112
+ category: "package",
113
+ defaultSeverity: "fail",
114
+ summary: "The plugin lifecycle hook source is not valid JSON.",
115
+ why: "Codex cannot parse the hook configuration.",
116
+ fix: "Fix the JSON syntax in the hook configuration file.",
117
+ example: "Use syntactically valid JSON in the hook configuration file."
118
+ },
119
+ {
120
+ id: "plugin.hook.invalid_shape",
121
+ category: "package",
122
+ defaultSeverity: "fail",
123
+ summary: "The plugin lifecycle hook configuration has an invalid shape.",
124
+ why: "Codex cannot safely interpret malformed lifecycle hook metadata.",
125
+ fix: "Use the official hook configuration schema for the affected field.",
126
+ example: '{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "node hooks/check.js" }] }] } }'
127
+ },
128
+ {
129
+ id: "plugin.hook.invalid_path",
130
+ category: "package",
131
+ defaultSeverity: "fail",
132
+ summary: "The plugin lifecycle hook source is not a safe package-relative path.",
133
+ why: "Paths outside the plugin package can expose unreviewed files to hook configuration loading.",
134
+ fix: "Use a `./` hook configuration path that stays within the plugin package.",
135
+ example: '{ "hooks": "./hooks/hooks.json" }'
136
+ },
137
+ {
138
+ id: "plugin.hook.unsupported_event",
139
+ category: "package",
140
+ defaultSeverity: "fail",
141
+ summary: "A plugin lifecycle hook event is not supported.",
142
+ why: "Codex will not invoke hook events outside the official lifecycle event set.",
143
+ fix: "Use one of the official plugin lifecycle hook events.",
144
+ example: '{ "hooks": { "SessionStart": [] } }'
145
+ },
146
+ {
147
+ id: "plugin.hook.unsupported_handler",
148
+ category: "package",
149
+ defaultSeverity: "warn",
150
+ summary: "A plugin lifecycle hook uses a handler type the host skips.",
151
+ why: "Prompt and agent lifecycle handlers are not executed by this host.",
152
+ fix: "Use a command handler for behavior that must run in this host.",
153
+ example: '{ "type": "command", "command": "node hooks/check.js" }'
154
+ },
155
+ {
156
+ id: "plugin.hook.async_unsupported",
157
+ category: "package",
158
+ defaultSeverity: "warn",
159
+ summary: "A plugin lifecycle hook requests unsupported asynchronous execution.",
160
+ why: "The host does not support asynchronous lifecycle hook execution.",
161
+ fix: "Remove `async: true` or run the command synchronously.",
162
+ example: '{ "type": "command", "command": "node hooks/check.js" }'
163
+ },
164
+ {
165
+ id: "plugin.hook.matcher_ignored",
166
+ category: "package",
167
+ defaultSeverity: "warn",
168
+ summary: "A plugin lifecycle hook matcher is ignored for this event.",
169
+ why: "This lifecycle event does not use matcher filtering.",
170
+ fix: "Remove the matcher from this event group.",
171
+ example: '{ "hooks": { "Stop": [{ "hooks": [] }] } }'
172
+ },
56
173
  {
57
174
  id: "plugin.heuristic.description.too_long",
58
175
  category: "package",
@@ -138,10 +255,19 @@ export const ruleCatalog = [
138
255
  id: "plugin.mcp.invalid_shape",
139
256
  category: "mcp",
140
257
  defaultSeverity: "fail",
141
- summary: "The `.mcp.json` file does not expose a valid `mcpServers` object.",
142
- why: "Codex expects MCP configuration to be object-shaped with named server entries.",
143
- fix: "Define a non-empty top-level `mcpServers` object.",
144
- example: '{ "mcpServers": { "doctor": { "command": "node", "args": ["server.js"] } } }'
258
+ summary: "The `.mcp.json` file does not contain one non-empty supported server map.",
259
+ why: "Codex accepts exactly a direct server map, an `mcp_servers` wrapper, or the legacy `mcpServers` wrapper.",
260
+ fix: "Use exactly one non-empty direct server map, `mcp_servers`, or legacy `mcpServers` wrapper.",
261
+ example: '{ "doctor": { "command": "node", "args": ["server.js"] } }'
262
+ },
263
+ {
264
+ id: "plugin.mcp.ambiguous_shape",
265
+ category: "mcp",
266
+ defaultSeverity: "fail",
267
+ summary: "The `.mcp.json` file uses an ambiguous MCP config layout.",
268
+ why: "Codex cannot safely choose between multiple wrapper layouts when they appear in one configuration file.",
269
+ fix: "Use exactly one supported layout: a direct server map, `mcp_servers`, or `mcpServers`.",
270
+ example: '{ "weather": { "command": "node", "args": ["server.js"] } }'
145
271
  },
146
272
  {
147
273
  id: "plugin.mcp.server.invalid",
@@ -10,6 +10,7 @@ export interface SecurityAudit {
10
10
  };
11
11
  findings: Finding[];
12
12
  }
13
+ export declare function auditHookCommand(rootPath: string, sourcePath: string, event: string, field: string, command: string): Finding[];
13
14
  export declare function auditMcpServerConfig(rootPath: string, parsedConfig: unknown, options?: {
14
15
  configPath?: string;
15
16
  }): Finding[];
@@ -1,6 +1,7 @@
1
1
  import { readFile, readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { discoverPackage } from "../core/discover-package.js";
4
+ import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
4
5
  import { readJsonFile } from "../core/read-json-file.js";
5
6
  import { inspectRemoteMcpUrl } from "../core/remote-url-policy.js";
6
7
  import { validatePlugin } from "../core/validate-plugin.js";
@@ -58,6 +59,101 @@ function containsPipeInstaller(args) {
58
59
  /\b(iwr|irm|invoke-webrequest|invoke-restmethod)\b[^|]*\|\s*(iex|invoke-expression)\b/.test(joinedArgs) ||
59
60
  /\binvoke-expression\b/.test(joinedArgs));
60
61
  }
62
+ function relativeSourcePath(rootPath, sourcePath) {
63
+ return relativePackagePath(rootPath, path.resolve(rootPath, sourcePath));
64
+ }
65
+ export function auditHookCommand(rootPath, sourcePath, event, field, command) {
66
+ const evidence = { sourcePath: relativeSourcePath(rootPath, sourcePath), event, field };
67
+ const findings = [];
68
+ if (/(?:^|\s)[/-]enc(?:odedcommand)?(?=\s|$)/i.test(command)) {
69
+ findings.push(buildFinding("fail", "plugin.security.encoded_command", "A plugin lifecycle hook uses an encoded shell command flag.", "Encoded command payloads hide the executed script from reviewers and increase supply-chain risk.", "Replace encoded shell payloads with a checked-in script or readable direct command.", evidence));
70
+ }
71
+ if (containsHookRemotePipeInstaller(command)) {
72
+ findings.push(buildFinding("fail", "plugin.security.remote_pipe_install", "A plugin lifecycle hook appears to pipe remote content into a shell.", "Download-and-execute patterns can run unreviewed remote code when a host invokes the hook.", "Pin dependencies through the package manager or use a reviewed local script instead of piping remote content to a shell.", evidence));
73
+ }
74
+ if (/^\s*(?:cmd(?:\.exe)?\s+\/c|(?:powershell|pwsh)(?:\.exe)?\s+-(?:command|c)\b)/i.test(command)) {
75
+ findings.push(buildFinding("warn", "plugin.security.command_shell_wrapper", "A plugin lifecycle hook starts through a shell wrapper.", "Shell wrappers expand quoting, pipes, aliases, and platform-specific behavior, which makes the execution path harder to audit.", "Prefer a concrete executable or checked-in script with explicit arguments.", evidence));
76
+ }
77
+ return findings;
78
+ }
79
+ function containsHookRemotePipeInstaller(command) {
80
+ const firstPipeIndex = command.indexOf("|");
81
+ if (firstPipeIndex === -1)
82
+ return false;
83
+ const leftHandSide = command.slice(0, firstPipeIndex);
84
+ const rightHandSide = command.slice(firstPipeIndex + 1).trim();
85
+ const interpreter = /^(?:\/(?:[^/\s|]+\/)*(?:sh|bash)\b|(?:sh|bash)\b|(?:powershell|pwsh)(?:\.exe)?\s+-(?:command|c)\s+-\s*(?:$|[;&|])|(?:iex|invoke-expression)\b)/i;
86
+ return isInvokedHookDownloader(leftHandSide) && interpreter.test(rightHandSide);
87
+ }
88
+ function isInvokedHookDownloader(command) {
89
+ const tokens = command.trim().split(/\s+/).filter(Boolean);
90
+ let index = 0;
91
+ while (index < tokens.length) {
92
+ const token = tokens[index].toLowerCase();
93
+ if (token === "env") {
94
+ index += 1;
95
+ while (index < tokens.length) {
96
+ const envToken = tokens[index];
97
+ if (envToken === "--") {
98
+ index += 1;
99
+ break;
100
+ }
101
+ if (envToken === "-i" || envToken === "--ignore-environment") {
102
+ index += 1;
103
+ continue;
104
+ }
105
+ if (envToken === "-u" || envToken === "--unset") {
106
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tokens[index + 1] ?? "")) {
107
+ return false;
108
+ }
109
+ index += 2;
110
+ continue;
111
+ }
112
+ if (/^--unset=[A-Za-z_][A-Za-z0-9_]*$/.test(envToken)) {
113
+ index += 1;
114
+ continue;
115
+ }
116
+ if (envToken === "-C" || envToken === "--chdir") {
117
+ if (!tokens[index + 1] || tokens[index + 1].startsWith("-")) {
118
+ return false;
119
+ }
120
+ index += 2;
121
+ continue;
122
+ }
123
+ if (/^--chdir=\S+$/.test(envToken)) {
124
+ index += 1;
125
+ continue;
126
+ }
127
+ if (/^[A-Za-z_][A-Za-z0-9_]*=\S*$/.test(envToken)) {
128
+ index += 1;
129
+ continue;
130
+ }
131
+ break;
132
+ }
133
+ continue;
134
+ }
135
+ if (token === "command") {
136
+ index += 1;
137
+ if (tokens[index] === "-p" || tokens[index]?.toLowerCase() === "--default-search-path") {
138
+ index += 1;
139
+ }
140
+ if (tokens[index] === "--") {
141
+ index += 1;
142
+ }
143
+ continue;
144
+ }
145
+ break;
146
+ }
147
+ const downloader = normalizeCommandName(tokens[index] ?? "");
148
+ if (new Set(["curl", "wget", "iwr", "irm", "invoke-webrequest", "invoke-restmethod"]).has(downloader)) {
149
+ return true;
150
+ }
151
+ const shell = normalizeCommandName(tokens[index] ?? "");
152
+ return ((shell === "powershell" || shell === "pwsh") &&
153
+ /^-(?:command|c)$/i.test(tokens[index + 1] ?? "") &&
154
+ new Set(["curl", "wget", "iwr", "irm", "invoke-webrequest", "invoke-restmethod"])
155
+ .has(normalizeCommandName(tokens[index + 2] ?? "")));
156
+ }
61
157
  const pathLikeArgFlags = new Set([
62
158
  "--config",
63
159
  "--config-path",
@@ -200,16 +296,14 @@ export function auditMcpServerConfig(rootPath, parsedConfig, options = {}) {
200
296
  const configPath = options.configPath
201
297
  ? relativePackagePath(rootPath, options.configPath)
202
298
  : ".mcp.json";
203
- if (!isPlainObject(parsedConfig) || !isPlainObject(parsedConfig.mcpServers)) {
299
+ const normalizedConfig = normalizeMcpConfig(parsedConfig);
300
+ if (!normalizedConfig.ok) {
204
301
  return [
205
- buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not find a valid `mcpServers` object.", "Without server entries, the audit cannot evaluate command execution or remote transport risk.", "Define MCP servers under a top-level `mcpServers` object.", { configPath })
302
+ buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not find a valid MCP server map.", "Without server entries, the audit cannot evaluate command execution or remote transport risk.", "Use a direct server map, `mcp_servers`, or `mcpServers`.", { configPath })
206
303
  ];
207
304
  }
208
305
  const findings = [];
209
- for (const [serverName, serverConfig] of Object.entries(parsedConfig.mcpServers)) {
210
- if (!isPlainObject(serverConfig)) {
211
- continue;
212
- }
306
+ for (const [serverName, serverConfig] of Object.entries(normalizedConfig.servers)) {
213
307
  const command = serverConfig.command;
214
308
  const args = serverConfig.args;
215
309
  const cwd = serverConfig.cwd;
@@ -395,7 +489,10 @@ async function auditMcpCommandSurface(discoveredPackage) {
395
489
  function dedupeFindings(findings) {
396
490
  const seen = new Set();
397
491
  return findings.filter((finding) => {
398
- const key = `${finding.id}\n${finding.message}`;
492
+ const hookLocation = finding.evidence?.sourcePath && finding.evidence.event && finding.evidence.field
493
+ ? `${finding.evidence.sourcePath}\n${finding.evidence.event}\n${finding.evidence.field}`
494
+ : "";
495
+ const key = `${finding.id}\n${finding.message}\n${hookLocation}`;
399
496
  if (seen.has(key)) {
400
497
  return false;
401
498
  }
@@ -1,94 +1,94 @@
1
- # Examples
2
-
3
- This folder contains manual example packs for local testing. Unlike `tests/fixtures`, these examples are meant for humans to run directly against the CLI.
4
-
5
- ## Example Packs
6
-
7
- Use the smallest example that matches what you need to prove:
8
-
9
- - `codex-doctor-starter` for static manifest and skill validation.
10
- - `codex-doctor-runtime` for full MCP runtime probing and compatibility scoring.
11
- - `codex-doctor-risky` for security finding output.
12
- - `codex-doctor-generic-mcp` for MCP-only packages without a Codex plugin manifest.
13
-
14
- ### `codex-doctor-starter`
15
-
16
- Minimal valid Codex plugin package with one skill and no runtime MCP server.
17
-
18
- Expected result:
19
-
20
- - static validation passes
21
- - no runtime probing needed
22
-
23
- Command:
24
-
25
- ```bash
26
- codex-plugin-doctor check examples/codex-doctor-starter
27
- ```
28
-
29
- ### `codex-doctor-runtime`
30
-
31
- Valid Codex plugin package with:
32
-
33
- - skill metadata
34
- - `.mcp.json`
35
- - mock MCP stdio server
36
- - `tools/list`
37
- - `tools/call`
38
- - `resources/list`
39
- - `resources/read`
40
- - `resources/templates/list`
41
- - `prompts/list`
42
- - `prompts/get`
43
-
44
- Expected result:
45
-
46
- - static validation passes
47
- - runtime validation passes
48
- - runtime scorecard shows all supported runtime capabilities as `pass`
49
-
50
- Command:
51
-
52
- ```bash
53
- codex-plugin-doctor check examples/codex-doctor-runtime --json --runtime --verbose-runtime
54
- ```
55
-
56
- ### `codex-doctor-risky`
57
-
58
- Intentionally flawed package for showing failure output.
59
-
60
- Expected result:
61
-
62
- - security finding for hard-coded secret
63
-
64
- Command:
65
-
66
- ```bash
67
- codex-plugin-doctor check examples/codex-doctor-risky --ascii
68
- ```
69
-
70
- ### `codex-doctor-generic-mcp`
71
-
72
- Generic MCP package with a `.mcp.json` file and no Codex plugin manifest.
73
-
74
- Expected result:
75
-
76
- - `codex-plugin-doctor mcp` passes generic MCP static health checks
77
- - `codex-plugin-doctor check` still reports the missing Codex plugin manifest
78
-
79
- Command:
80
-
81
- ```bash
82
- codex-plugin-doctor mcp examples/codex-doctor-generic-mcp
83
- ```
84
-
85
- ## Suggested Local Flow
86
-
87
- ```bash
88
- npm install
89
- npm run build
90
- npm link
91
- codex-plugin-doctor check examples/codex-doctor-starter
92
- codex-plugin-doctor check examples/codex-doctor-runtime --json --runtime --verbose-runtime
93
- codex-plugin-doctor check examples/codex-doctor-risky --ascii
94
- ```
1
+ # Examples
2
+
3
+ This folder contains manual example packs for local testing. Unlike `tests/fixtures`, these examples are meant for humans to run directly against the CLI.
4
+
5
+ ## Example Packs
6
+
7
+ Use the smallest example that matches what you need to prove:
8
+
9
+ - `codex-doctor-starter` for static manifest and skill validation.
10
+ - `codex-doctor-runtime` for full MCP runtime probing and compatibility scoring.
11
+ - `codex-doctor-risky` for security finding output.
12
+ - `codex-doctor-generic-mcp` for MCP-only packages without a Codex plugin manifest.
13
+
14
+ ### `codex-doctor-starter`
15
+
16
+ Minimal valid Codex plugin package with one skill and no runtime MCP server.
17
+
18
+ Expected result:
19
+
20
+ - static validation passes
21
+ - no runtime probing needed
22
+
23
+ Command:
24
+
25
+ ```bash
26
+ codex-plugin-doctor check examples/codex-doctor-starter
27
+ ```
28
+
29
+ ### `codex-doctor-runtime`
30
+
31
+ Valid Codex plugin package with:
32
+
33
+ - skill metadata
34
+ - `.mcp.json`
35
+ - mock MCP stdio server
36
+ - `tools/list`
37
+ - `tools/call`
38
+ - `resources/list`
39
+ - `resources/read`
40
+ - `resources/templates/list`
41
+ - `prompts/list`
42
+ - `prompts/get`
43
+
44
+ Expected result:
45
+
46
+ - static validation passes
47
+ - runtime validation passes
48
+ - runtime scorecard shows all supported runtime capabilities as `pass`
49
+
50
+ Command:
51
+
52
+ ```bash
53
+ codex-plugin-doctor check examples/codex-doctor-runtime --json --runtime --verbose-runtime
54
+ ```
55
+
56
+ ### `codex-doctor-risky`
57
+
58
+ Intentionally flawed package for showing failure output.
59
+
60
+ Expected result:
61
+
62
+ - security finding for hard-coded secret
63
+
64
+ Command:
65
+
66
+ ```bash
67
+ codex-plugin-doctor check examples/codex-doctor-risky --ascii
68
+ ```
69
+
70
+ ### `codex-doctor-generic-mcp`
71
+
72
+ Generic MCP package with a `.mcp.json` file and no Codex plugin manifest.
73
+
74
+ Expected result:
75
+
76
+ - `codex-plugin-doctor mcp` passes generic MCP static health checks
77
+ - `codex-plugin-doctor check` still reports the missing Codex plugin manifest
78
+
79
+ Command:
80
+
81
+ ```bash
82
+ codex-plugin-doctor mcp examples/codex-doctor-generic-mcp
83
+ ```
84
+
85
+ ## Suggested Local Flow
86
+
87
+ ```bash
88
+ npm install
89
+ npm run build
90
+ npm link
91
+ codex-plugin-doctor check examples/codex-doctor-starter
92
+ codex-plugin-doctor check examples/codex-doctor-runtime --json --runtime --verbose-runtime
93
+ codex-plugin-doctor check examples/codex-doctor-risky --ascii
94
+ ```
@@ -1,10 +1,10 @@
1
- {
2
- "mcpServers": {
3
- "genericRuntime": {
4
- "command": "node",
5
- "args": [
6
- "server.js"
7
- ]
8
- }
9
- }
10
- }
1
+ {
2
+ "mcpServers": {
3
+ "genericRuntime": {
4
+ "command": "node",
5
+ "args": [
6
+ "server.js"
7
+ ]
8
+ }
9
+ }
10
+ }
@@ -1 +1 @@
1
- process.stdin.resume();
1
+ process.stdin.resume();