codex-plugin-doctor 1.56.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 (42) 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.d.ts +5 -1
  9. package/dist/core/doctor-size.js +77 -3
  10. package/dist/core/init-plugin.js +2 -2
  11. package/dist/core/inspector-bridge.js +8 -13
  12. package/dist/core/mcp-config-normalizer.d.ts +18 -0
  13. package/dist/core/mcp-config-normalizer.js +50 -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/validate-plugin.js +39 -17
  23. package/dist/domain/types.d.ts +34 -0
  24. package/dist/domain/types.js +13 -1
  25. package/dist/mcp/generic-mcp-doctor.js +45 -29
  26. package/dist/rules/rule-catalog.js +130 -4
  27. package/dist/run-cli.js +32 -2
  28. package/dist/security/security-audit.d.ts +1 -0
  29. package/dist/security/security-audit.js +104 -7
  30. package/examples/README.md +94 -94
  31. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  32. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  33. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  34. package/examples/codex-doctor-risky/.mcp.json +12 -12
  35. package/examples/codex-doctor-risky/mock-server.js +1 -1
  36. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  37. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  38. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  39. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  40. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  41. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  42. package/package.json +1 -1
@@ -2,6 +2,9 @@ import { readFile, readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { withFindingFingerprints } from "../reporting/finding-fingerprint.js";
4
4
  import { discoverPackage } from "./discover-package.js";
5
+ import { normalizeMcpConfig } from "./mcp-config-normalizer.js";
6
+ import { validatePluginComponents } from "./plugin-components.js";
7
+ import { validatePluginHooks } from "./plugin-hooks.js";
5
8
  import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
6
9
  import { probeRuntime, remoteReliabilityGatePassed } from "./runtime-probe.js";
7
10
  function buildFailure(id, message, impact, suggestedFix, evidence) {
@@ -24,6 +27,9 @@ function buildWarning(id, message, impact, suggestedFix, evidence) {
24
27
  ...(evidence ? { evidence } : {})
25
28
  };
26
29
  }
30
+ function hasInvalidComponentPath(findings, field) {
31
+ return findings.some((finding) => finding.id === "plugin.manifest.invalid_path" && finding.evidence?.field === field);
32
+ }
27
33
  function remoteUrlIssueFindingId(issue) {
28
34
  return issue === "insecure_non_loopback"
29
35
  ? "plugin.security.insecure_http_url"
@@ -223,9 +229,11 @@ function validateRequiredManifestFields(discoveredPackage) {
223
229
  }
224
230
  async function validateSkillsDirectory(discoveredPackage) {
225
231
  const { manifest, rootPath } = discoveredPackage;
226
- if (!manifest.skills) {
232
+ if (manifest.skills === undefined) {
227
233
  return [];
228
234
  }
235
+ if (typeof manifest.skills !== "string")
236
+ return [];
229
237
  const skillsPath = path.resolve(rootPath, manifest.skills);
230
238
  if (!isPathWithinRoot(rootPath, skillsPath)) {
231
239
  return [
@@ -233,7 +241,7 @@ async function validateSkillsDirectory(discoveredPackage) {
233
241
  manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
234
242
  field: "skills",
235
243
  configuredPath: manifest.skills,
236
- resolvedPath: skillsPath
244
+ resolvedPath: relativePackagePath(rootPath, skillsPath)
237
245
  })
238
246
  ];
239
247
  }
@@ -246,13 +254,13 @@ async function validateSkillsDirectory(discoveredPackage) {
246
254
  manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
247
255
  field: "skills",
248
256
  configuredPath: manifest.skills,
249
- resolvedPath: skillsPath
257
+ resolvedPath: relativePackagePath(rootPath, skillsPath)
250
258
  })
251
259
  ];
252
260
  }
253
261
  async function validateSkillDefinitions(discoveredPackage) {
254
262
  const { manifest, rootPath } = discoveredPackage;
255
- if (!manifest.skills) {
263
+ if (typeof manifest.skills !== "string") {
256
264
  return [];
257
265
  }
258
266
  const skillsPath = path.resolve(rootPath, manifest.skills);
@@ -319,7 +327,7 @@ async function validateSkillDefinitions(discoveredPackage) {
319
327
  }
320
328
  async function validateMcpConfig(discoveredPackage) {
321
329
  const { manifest, rootPath } = discoveredPackage;
322
- if (!manifest.mcpServers) {
330
+ if (typeof manifest.mcpServers !== "string") {
323
331
  return [];
324
332
  }
325
333
  const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers);
@@ -353,20 +361,28 @@ async function validateMcpConfig(discoveredPackage) {
353
361
  buildFailure("plugin.mcp.invalid_json", "The referenced `.mcp.json` file is not valid JSON.", "Codex will not be able to parse bundled MCP server configuration.", `Fix the JSON syntax in \`${mcpConfigPath}\`.`, { configPath: relativePackagePath(rootPath, mcpConfigPath), field: "json" })
354
362
  ];
355
363
  }
356
- if (!isPlainObject(parsedConfig)) {
357
- return [
358
- buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a JSON object.", "Codex expects bundled MCP configuration to be object-shaped so server entries can be resolved reliably.", `Wrap the MCP configuration in a top-level object inside \`${mcpConfigPath}\`.`, { configPath: relativePackagePath(rootPath, mcpConfigPath), field: "root" })
359
- ];
360
- }
361
- const servers = parsedConfig.mcpServers;
362
- if (!isPlainObject(servers) || Object.keys(servers).length === 0) {
364
+ const normalizedConfig = normalizeMcpConfig(parsedConfig);
365
+ if (!normalizedConfig.ok) {
366
+ if (normalizedConfig.reason === "ambiguous_shape") {
367
+ return [
368
+ buildFailure("plugin.mcp.ambiguous_shape", "The referenced `.mcp.json` file uses an ambiguous MCP config layout.", "Codex cannot safely choose between multiple wrapper layouts when they appear in one configuration file.", "Use exactly one supported layout: a direct server map, `mcp_servers`, or `mcpServers`.", { configPath: relativePackagePath(rootPath, mcpConfigPath), field: "root" })
369
+ ];
370
+ }
371
+ if (normalizedConfig.field === "server" && normalizedConfig.invalidServerNames) {
372
+ return normalizedConfig.invalidServerNames.map((serverName) => buildFailure("plugin.mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "Codex cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${mcpConfigPath}\` to an object.`, {
373
+ configPath: relativePackagePath(rootPath, mcpConfigPath),
374
+ serverName,
375
+ field: "server"
376
+ }));
377
+ }
363
378
  return [
364
- buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a non-empty `mcpServers` object.", "Without a valid `mcpServers` object, Codex cannot discover the bundled MCP server definitions.", `Define bundled servers under \`mcpServers\` in \`${mcpConfigPath}\`.`, {
379
+ buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a valid MCP server map.", "Without valid server entries, Codex cannot discover the bundled MCP server definitions.", `Use a direct server map, \`mcp_servers\`, or \`mcpServers\` in \`${mcpConfigPath}\`.`, {
365
380
  configPath: relativePackagePath(rootPath, mcpConfigPath),
366
- field: "mcpServers"
381
+ field: normalizedConfig.field
367
382
  })
368
383
  ];
369
384
  }
385
+ const servers = normalizedConfig.servers;
370
386
  const findings = [];
371
387
  for (const [serverName, serverConfig] of Object.entries(servers)) {
372
388
  if (!isPlainObject(serverConfig)) {
@@ -433,11 +449,17 @@ export async function validatePlugin(targetPath, options = {}) {
433
449
  findings: withFindingFingerprints([await buildMissingManifestFailure(rootPath)], rootPath)
434
450
  };
435
451
  }
452
+ const componentFindings = await validatePluginComponents(discoveredPackage);
453
+ const hookFindings = await validatePluginHooks(discoveredPackage);
454
+ const hasInvalidSkillsPath = hasInvalidComponentPath(componentFindings, "skills");
455
+ const hasInvalidMcpPath = hasInvalidComponentPath(componentFindings, "mcpServers");
436
456
  const staticFindings = [
437
457
  ...validateRequiredManifestFields(discoveredPackage),
438
- ...(await validateSkillsDirectory(discoveredPackage)),
439
- ...(await validateSkillDefinitions(discoveredPackage)),
440
- ...(await validateMcpConfig(discoveredPackage))
458
+ ...componentFindings,
459
+ ...hookFindings,
460
+ ...(hasInvalidSkillsPath ? [] : await validateSkillsDirectory(discoveredPackage)),
461
+ ...(hasInvalidSkillsPath ? [] : await validateSkillDefinitions(discoveredPackage)),
462
+ ...(hasInvalidMcpPath ? [] : await validateMcpConfig(discoveredPackage))
441
463
  ];
442
464
  const staticFailed = staticFindings.some((finding) => finding.severity === "fail");
443
465
  const runtimeResult = options.runtime && !staticFailed
@@ -55,7 +55,41 @@ export interface PluginManifest {
55
55
  description?: string;
56
56
  skills?: string;
57
57
  mcpServers?: string;
58
+ author?: unknown;
59
+ homepage?: unknown;
60
+ repository?: unknown;
61
+ license?: unknown;
62
+ keywords?: unknown;
63
+ apps?: unknown;
64
+ hooks?: PluginHooks;
65
+ interface?: unknown;
66
+ }
67
+ export declare const pluginHookEvents: readonly ["PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact", "UserPromptSubmit", "SubagentStop", "Stop", "SessionStart", "SubagentStart", "SessionEnd"];
68
+ export type PluginHookEvent = (typeof pluginHookEvents)[number];
69
+ export interface PluginHookCommandHandler {
70
+ type: "command";
71
+ command: string;
72
+ commandWindows?: string;
73
+ timeout?: number;
74
+ statusMessage?: string;
75
+ additionalContextLimit?: number;
76
+ async?: boolean;
77
+ }
78
+ export interface PluginHookUnsupportedHandler {
79
+ type: "prompt" | "agent";
80
+ async?: boolean;
81
+ [key: string]: unknown;
82
+ }
83
+ export type PluginHookHandler = PluginHookCommandHandler | PluginHookUnsupportedHandler;
84
+ export interface PluginHookMatcherGroup {
85
+ matcher?: string;
86
+ hooks: PluginHookHandler[];
87
+ }
88
+ export interface PluginHookConfig {
89
+ description?: string;
90
+ hooks: Partial<Record<PluginHookEvent, PluginHookMatcherGroup[]>>;
58
91
  }
92
+ export type PluginHooks = string | string[] | PluginHookConfig | PluginHookConfig[];
59
93
  export interface DiscoveredPackage {
60
94
  rootPath: string;
61
95
  manifestPath: string;
@@ -1 +1,13 @@
1
- export {};
1
+ export const pluginHookEvents = [
2
+ "PreToolUse",
3
+ "PermissionRequest",
4
+ "PostToolUse",
5
+ "PreCompact",
6
+ "PostCompact",
7
+ "UserPromptSubmit",
8
+ "SubagentStop",
9
+ "Stop",
10
+ "SessionStart",
11
+ "SubagentStart",
12
+ "SessionEnd"
13
+ ];
@@ -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",
package/dist/run-cli.js CHANGED
@@ -169,7 +169,7 @@ function parseRemoteNetworkFlags(flags, runtime) {
169
169
  return { allowNetwork, allowLocalNetwork, allowSessionLifecycle, requireRemoteReliability };
170
170
  }
171
171
  function printUsage(io) {
172
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--runtime [--allow-network [--allow-local-network]]] [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus [--manifest <corpus.json>] [--json] [--output <path>]|corpus metrics --manifest <corpus.json> [--json|--markdown] [--output <path>] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan <path> [--sandbox docker] [--json|--markdown] [--output <path>]|runtime-policy <path> [--sandbox docker] [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>] [--upload]|mcp <path> [--runtime [--allow-network [--allow-local-network]]]|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor completion bash|zsh|fish\n codex-plugin-doctor config validate <path> [--json]\n codex-plugin-doctor release check <path> [--json] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
172
+ io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>] [--fail-on <rule-id>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--runtime [--allow-network [--allow-local-network]]] [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus [--manifest <corpus.json>] [--json] [--output <path>]|corpus metrics --manifest <corpus.json> [--json|--markdown] [--output <path>] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan <path> [--sandbox docker] [--json|--markdown] [--output <path>]|runtime-policy <path> [--sandbox docker] [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>] [--upload]|mcp <path> [--runtime [--allow-network [--allow-local-network]]]|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor completion bash|zsh|fish\n codex-plugin-doctor config validate <path> [--json]\n codex-plugin-doctor release check <path> [--json] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
173
173
  io.writeStderr("Registry readiness: codex-plugin-doctor registry check <server.json|directory> [--json] [--output <path>] [--require-registry-readiness]\n"
174
174
  + " codex-plugin-doctor registry inspect <server-name> --allow-network [--json] [--output <path>] [--require-registry-readiness]\n"
175
175
  + "Registry publication preflight: codex-plugin-doctor registry preflight <server.json|directory> [--allow-network] [--json] [--output <path>] [--require-publish-ready]");
@@ -2108,7 +2108,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
2108
2108
  ? remainingArgs
2109
2109
  : remainingArgs.slice(1);
2110
2110
  const jsonOutput = sizeFlags.includes("--json");
2111
- const report = await buildDoctorSize(sizeTarget);
2111
+ const npmPack = sizeFlags.includes("--npm");
2112
+ const report = await buildDoctorSize(sizeTarget, { npmPack });
2112
2113
  const output = jsonOutput
2113
2114
  ? renderDoctorSizeJson(report)
2114
2115
  : renderDoctorSize(report);
@@ -2905,6 +2906,13 @@ export async function runCli(args, io = defaultIo, options = {}) {
2905
2906
  : normalizedFlags[runtimeApprovalDigestIndex + 1];
2906
2907
  const changedSinceIndex = normalizedFlags.indexOf("--changed-since");
2907
2908
  const changedSinceRef = changedSinceIndex === -1 ? null : normalizedFlags[changedSinceIndex + 1];
2909
+ const failOnRules = [];
2910
+ for (let i = 0; i < normalizedFlags.length; i += 1) {
2911
+ if (normalizedFlags[i] === "--fail-on" && normalizedFlags[i + 1] && !normalizedFlags[i + 1].startsWith("--")) {
2912
+ failOnRules.push(normalizedFlags[i + 1]);
2913
+ i += 1;
2914
+ }
2915
+ }
2908
2916
  const baselineIndex = normalizedFlags.indexOf("--baseline");
2909
2917
  const baselinePath = baselineIndex === -1 ? null : normalizedFlags[baselineIndex + 1];
2910
2918
  if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
@@ -3150,6 +3158,28 @@ export async function runCli(args, io = defaultIo, options = {}) {
3150
3158
  failOnWarnings: doctorConfig.failOnWarnings
3151
3159
  })
3152
3160
  : configuredResult;
3161
+ if (failOnRules.length > 0) {
3162
+ result.findings = result.findings.map((finding) => {
3163
+ if (finding.severity === "warn" && failOnRules.includes(finding.id)) {
3164
+ return { ...finding, severity: "fail" };
3165
+ }
3166
+ return finding;
3167
+ });
3168
+ const hasFail = result.findings.some((f) => f.severity === "fail");
3169
+ const hasWarn = result.findings.some((f) => f.severity === "warn");
3170
+ if (hasFail) {
3171
+ result.status = "fail";
3172
+ result.exitCode = 1;
3173
+ }
3174
+ else if (hasWarn) {
3175
+ result.status = "warn";
3176
+ result.exitCode = 0;
3177
+ }
3178
+ else {
3179
+ result.status = "pass";
3180
+ result.exitCode = 0;
3181
+ }
3182
+ }
3153
3183
  if (renderer) {
3154
3184
  if (result.status === "fail") {
3155
3185
  renderer.stopFailure("Validation failed");
@@ -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[];