codex-plugin-doctor 1.57.0 → 1.59.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 (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +20 -6
  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/output-contract.js +76 -0
  14. package/dist/core/package-path.d.ts +1 -0
  15. package/dist/core/package-path.js +25 -0
  16. package/dist/core/plugin-components.d.ts +7 -0
  17. package/dist/core/plugin-components.js +169 -0
  18. package/dist/core/plugin-hooks.d.ts +2 -0
  19. package/dist/core/plugin-hooks.js +190 -0
  20. package/dist/core/runtime-plan.js +97 -16
  21. package/dist/core/runtime-probe.js +3 -8
  22. package/dist/core/shell-completion.js +29 -0
  23. package/dist/core/submission-assets.d.ts +6 -0
  24. package/dist/core/submission-assets.js +430 -0
  25. package/dist/core/submission-preflight.d.ts +34 -0
  26. package/dist/core/submission-preflight.js +289 -0
  27. package/dist/core/submission-ruleset.d.ts +56 -0
  28. package/dist/core/submission-ruleset.js +38 -0
  29. package/dist/core/submission-skill-metadata.d.ts +9 -0
  30. package/dist/core/submission-skill-metadata.js +301 -0
  31. package/dist/core/validate-plugin.js +39 -17
  32. package/dist/domain/types.d.ts +34 -0
  33. package/dist/domain/types.js +13 -1
  34. package/dist/index.d.ts +5 -0
  35. package/dist/index.js +5 -0
  36. package/dist/mcp/generic-mcp-doctor.js +45 -29
  37. package/dist/reporting/render-submission-report.d.ts +5 -0
  38. package/dist/reporting/render-submission-report.js +68 -0
  39. package/dist/rules/rule-catalog.js +130 -4
  40. package/dist/run-cli.js +96 -0
  41. package/dist/security/security-audit.d.ts +1 -0
  42. package/dist/security/security-audit.js +104 -7
  43. package/examples/README.md +94 -94
  44. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  45. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  46. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  47. package/examples/codex-doctor-risky/.mcp.json +12 -12
  48. package/examples/codex-doctor-risky/mock-server.js +1 -1
  49. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  50. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  51. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  52. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  53. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  54. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  55. package/package.json +5 -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 } : {}),
@@ -0,0 +1,5 @@
1
+ import type { SubmissionPreflightReport } from "../core/submission-preflight.js";
2
+ export declare function renderSubmissionPreflightJson(report: SubmissionPreflightReport): string;
3
+ export declare function renderSubmissionPreflightText(report: SubmissionPreflightReport): string;
4
+ export declare function renderSubmissionPreflightMarkdown(report: SubmissionPreflightReport): string;
5
+ export declare function submissionPreflightExitCode(report: SubmissionPreflightReport, requireReady: boolean): 0 | 1;
@@ -0,0 +1,68 @@
1
+ function upper(value) {
2
+ return value.replace(/[-_]/gu, " ").toUpperCase();
3
+ }
4
+ function findingsByCheck(report) {
5
+ const findings = new Map(report.findings.map((finding) => [finding.id, finding]));
6
+ return report.checks.map((check) => ({
7
+ check,
8
+ findings: check.findingIds.flatMap((id) => {
9
+ const finding = findings.get(id);
10
+ return finding ? [finding] : [];
11
+ })
12
+ }));
13
+ }
14
+ function escapeMarkdown(value) {
15
+ return value.replace(/[\\`*_{}\[\]()<>#+\-.!|]/gu, "\\$&");
16
+ }
17
+ function textSummary(report) {
18
+ return [
19
+ `Ruleset: ${report.rulesetVersion}`,
20
+ `Target: ${report.targetType}`,
21
+ `Automatic status: ${upper(report.status)}`,
22
+ `Readiness: ${upper(report.readiness)}`,
23
+ `Summary: ${report.summary.passed} passed, ${report.summary.warnings} warnings, ${report.summary.blockers} blockers, ${report.summary.manualChecks} manual checks`,
24
+ "Manual review is required; automatic checks do not complete directory review."
25
+ ];
26
+ }
27
+ export function renderSubmissionPreflightJson(report) {
28
+ return `${JSON.stringify(report, null, 2)}\n`;
29
+ }
30
+ export function renderSubmissionPreflightText(report) {
31
+ const lines = ["Submission preflight", "====================", ...textSummary(report), "", "Findings by check"];
32
+ for (const { check, findings } of findingsByCheck(report)) {
33
+ lines.push(`${check.id} (${upper(check.status)})`);
34
+ lines.push(...(findings.length === 0
35
+ ? [" None"]
36
+ : findings.map((finding) => ` ${upper(finding.severity)} ${finding.id}: ${finding.message}`)));
37
+ }
38
+ lines.push("", "Manual checklist");
39
+ lines.push(...report.manualChecklist.map((item) => ` ${upper(item.state)} ${item.id}: ${item.label}`));
40
+ return `${lines.join("\n")}\n`;
41
+ }
42
+ export function renderSubmissionPreflightMarkdown(report) {
43
+ const lines = [
44
+ "# Submission preflight",
45
+ "",
46
+ `- Ruleset: ${escapeMarkdown(report.rulesetVersion)}`,
47
+ `- Target: ${escapeMarkdown(report.targetType)}`,
48
+ `- Automatic status: ${escapeMarkdown(upper(report.status))}`,
49
+ `- Readiness: ${escapeMarkdown(upper(report.readiness))}`,
50
+ `- Summary: ${report.summary.passed} passed, ${report.summary.warnings} warnings, ${report.summary.blockers} blockers, ${report.summary.manualChecks} manual checks`,
51
+ "",
52
+ "Manual review is required; automatic checks do not complete directory review.",
53
+ "",
54
+ "## Findings by check"
55
+ ];
56
+ for (const { check, findings } of findingsByCheck(report)) {
57
+ lines.push("", `### ${escapeMarkdown(check.id)} (${escapeMarkdown(upper(check.status))})`);
58
+ lines.push(...(findings.length === 0
59
+ ? ["- None"]
60
+ : findings.map((finding) => `- **${escapeMarkdown(upper(finding.severity))}** ${escapeMarkdown(finding.id)}: ${escapeMarkdown(finding.message)}`)));
61
+ }
62
+ lines.push("", "## Manual checklist");
63
+ lines.push(...report.manualChecklist.map((item) => `- ${escapeMarkdown(upper(item.state))}: ${escapeMarkdown(item.id)} — ${escapeMarkdown(item.label)}`));
64
+ return `${lines.join("\n")}\n`;
65
+ }
66
+ export function submissionPreflightExitCode(report, requireReady) {
67
+ return requireReady && report.status === "fail" ? 1 : 0;
68
+ }
@@ -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",
package/dist/run-cli.js CHANGED
@@ -19,6 +19,7 @@ import { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRe
19
19
  import { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson } from "./core/doctor-export-bundle.js";
20
20
  import { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestationJson, renderDoctorAttestationVerification, renderDoctorAttestationVerificationJson, verifyDoctorAttestation } from "./core/attestation.js";
21
21
  import { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
22
+ import { buildSubmissionPreflight } from "./core/submission-preflight.js";
22
23
  import { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
23
24
  import { buildExternalValidationCorpusReport, ExternalCorpusManifestError } from "./core/external-validation-corpus.js";
24
25
  import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, CorpusMetricsDiffError, CorpusMetricsManifestError, renderCorpusQualityMetricsDiffJson, renderCorpusQualityMetricsDiffMarkdown, renderCorpusQualityMetricsDiffText, renderCorpusQualityMetricsJson, renderCorpusQualityMetricsMarkdown, renderCorpusQualityMetricsText } from "./core/corpus-quality-metrics.js";
@@ -57,6 +58,7 @@ import { renderSuppressionList, renderSuppressionListJson, renderSuppressionMuta
57
58
  import { renderRuleExplanation } from "./reporting/render-rule-explanation.js";
58
59
  import { renderSarifReport } from "./reporting/render-sarif-report.js";
59
60
  import { renderTextReport } from "./reporting/render-text-report.js";
61
+ import { renderSubmissionPreflightJson, renderSubmissionPreflightMarkdown, renderSubmissionPreflightText, submissionPreflightExitCode } from "./reporting/render-submission-report.js";
60
62
  import { applyPolicyToDepAudit, applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames } from "./policy/policy-packs.js";
61
63
  import { findRuleDefinition } from "./rules/rule-catalog.js";
62
64
  import { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
@@ -86,6 +88,13 @@ const defaultIo = {
86
88
  }
87
89
  }
88
90
  };
91
+ function writeExactStdout(io, message) {
92
+ if (io === defaultIo) {
93
+ process.stdout.write(message);
94
+ return;
95
+ }
96
+ io.writeStdout(message);
97
+ }
89
98
  class CliUsageError extends Error {
90
99
  }
91
100
  function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
@@ -177,6 +186,7 @@ function printUsage(io) {
177
186
  io.writeStderr("Suppression governance: codex-plugin-doctor suppress list <path> [--fail-on-expired] [--fail-on-invalid] [--warn-expiring-within-days <days>]\n codex-plugin-doctor suppress prune <path> [--apply] [--json]");
178
187
  io.writeStderr("Baseline gating: codex-plugin-doctor baseline create <path> --output <path> [--runtime]\n codex-plugin-doctor check <path> --baseline <path>");
179
188
  io.writeStderr("Remote MCP runtime flags (check, mcp, release check, and doctor release-evidence): --runtime --allow-network [--allow-local-network] [--allow-session-lifecycle] [--require-remote-reliability]\n --allow-session-lifecycle requires --runtime --allow-network and can terminate a remote session.\n --require-remote-reliability requires --runtime --allow-network, blocks non-pass reliability, and does not grant network consent.");
189
+ io.writeStderr(" codex-plugin-doctor doctor submission <path> [--json|--markdown] [--output <path>] [--require-ready]");
180
190
  }
181
191
  const suppressUsageText = [
182
192
  "Usage:",
@@ -970,6 +980,74 @@ function parseSelectedFixActionIndexes(answer, actionCount) {
970
980
  ? actionIndexes
971
981
  : null;
972
982
  }
983
+ function parseSubmissionCommandArgs(args) {
984
+ let targetPath = null;
985
+ let jsonOutput = false;
986
+ let markdownOutput = false;
987
+ let outputPath = null;
988
+ let requireReady = false;
989
+ let optionsEnded = false;
990
+ for (let index = 0; index < args.length; index += 1) {
991
+ const argument = args[index];
992
+ if (argument === "--" && !optionsEnded) {
993
+ optionsEnded = true;
994
+ }
995
+ else if (optionsEnded) {
996
+ if (targetPath === null) {
997
+ targetPath = argument;
998
+ }
999
+ else {
1000
+ return new CliUsageError(`Unexpected submission argument: ${argument}.`);
1001
+ }
1002
+ }
1003
+ else if (argument === "--json") {
1004
+ if (jsonOutput)
1005
+ return new CliUsageError("Duplicate submission flag: --json.");
1006
+ jsonOutput = true;
1007
+ }
1008
+ else if (argument === "--markdown") {
1009
+ if (markdownOutput)
1010
+ return new CliUsageError("Duplicate submission flag: --markdown.");
1011
+ markdownOutput = true;
1012
+ }
1013
+ else if (argument === "--require-ready") {
1014
+ if (requireReady)
1015
+ return new CliUsageError("Duplicate submission flag: --require-ready.");
1016
+ requireReady = true;
1017
+ }
1018
+ else if (argument === "--output") {
1019
+ if (outputPath !== null)
1020
+ return new CliUsageError("Duplicate submission flag: --output.");
1021
+ const value = args[index + 1];
1022
+ if (!value || value.startsWith("--"))
1023
+ return new CliUsageError("Missing path after --output.");
1024
+ outputPath = value;
1025
+ index += 1;
1026
+ }
1027
+ else if (argument.startsWith("--output=")) {
1028
+ if (outputPath !== null)
1029
+ return new CliUsageError("Duplicate submission flag: --output.");
1030
+ const value = argument.slice("--output=".length);
1031
+ if (!value)
1032
+ return new CliUsageError("Missing path after --output.");
1033
+ outputPath = value;
1034
+ }
1035
+ else if (argument.startsWith("--")) {
1036
+ return new CliUsageError(`Unknown submission flag: ${argument}.`);
1037
+ }
1038
+ else if (targetPath === null) {
1039
+ targetPath = argument;
1040
+ }
1041
+ else {
1042
+ return new CliUsageError(`Unexpected submission argument: ${argument}.`);
1043
+ }
1044
+ }
1045
+ if (targetPath === null)
1046
+ return new CliUsageError("Missing target path for submission.");
1047
+ if (jsonOutput && markdownOutput)
1048
+ return new CliUsageError("Use either --json or --markdown, not both.");
1049
+ return { targetPath, jsonOutput, markdownOutput, outputPath, requireReady };
1050
+ }
973
1051
  export async function runCli(args, io = defaultIo, options = {}) {
974
1052
  const [command, maybePath, ...remainingArgs] = args;
975
1053
  const runtimeSandboxSupported = (command === "check" &&
@@ -1079,6 +1157,24 @@ export async function runCli(args, io = defaultIo, options = {}) {
1079
1157
  : renderDoctorOutputContract(contract, { outputPath }));
1080
1158
  return 0;
1081
1159
  }
1160
+ if (maybePath === "submission") {
1161
+ const parsedSubmissionArgs = parseSubmissionCommandArgs(remainingArgs);
1162
+ if (parsedSubmissionArgs instanceof CliUsageError) {
1163
+ io.writeStderr(parsedSubmissionArgs.message);
1164
+ return 2;
1165
+ }
1166
+ const report = await buildSubmissionPreflight(parsedSubmissionArgs.targetPath);
1167
+ const renderedReport = parsedSubmissionArgs.jsonOutput
1168
+ ? renderSubmissionPreflightJson(report)
1169
+ : parsedSubmissionArgs.markdownOutput
1170
+ ? renderSubmissionPreflightMarkdown(report)
1171
+ : renderSubmissionPreflightText(report);
1172
+ if (parsedSubmissionArgs.outputPath) {
1173
+ await writeFile(parsedSubmissionArgs.outputPath, renderedReport, "utf8");
1174
+ }
1175
+ writeExactStdout(io, renderedReport);
1176
+ return submissionPreflightExitCode(report, parsedSubmissionArgs.requireReady);
1177
+ }
1082
1178
  if (maybePath === "mcp") {
1083
1179
  const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
1084
1180
  ? remainingArgs[0]
@@ -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
  }