codex-plugin-doctor 1.25.0 → 1.26.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.
package/README.md CHANGED
@@ -314,7 +314,7 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
314
314
 
315
315
  `init [path] --template ...` creates targeted starter packages. `skill-only` is the default minimal skill package, `mcp-stdio` adds a local stdio MCP config and mock server, `mcp-http` scaffolds a streamable HTTP MCP config, and `full-runtime` generates a stdio sample that passes the runtime protocol probes.
316
316
 
317
- `security <path>` renders a focused package security scorecard. It reuses the existing package security findings, then adds deeper MCP command-surface checks for shell wrappers, encoded shell payloads, remote pipe-to-shell startup patterns, `cwd` values outside the plugin root, and plain HTTP URLs. Security findings include structured evidence where available, such as server names, config paths, env keys, arg flags, and escaped paths. Findings also include deterministic SHA-256 fingerprints derived from the rule ID and canonical evidence, with package paths normalized for cross-machine stability. Use `--json` for automation or `--scorecard` for a compact status view.
317
+ `security <path>` renders a focused package security scorecard. It reuses the existing package security findings, then adds deeper MCP command-surface checks for shell wrappers, encoded shell payloads, remote pipe-to-shell startup patterns, `cwd` values outside the plugin root, and plain HTTP URLs. Findings include structured, instance-specific evidence such as manifest fields, skill paths, server names, protocol methods, dependency sections, env keys, and bounded payload lengths. Deterministic SHA-256 fingerprints derive from the rule ID and canonical evidence, with package paths normalized for cross-machine stability. Secrets, prompt bodies, resource contents, tool payloads, stderr, and encoded commands are not copied into evidence. Use `--json` for automation or `--scorecard` for a compact status view.
318
318
 
319
319
  `compat --client claude-desktop` checks whether the MCP package can be added to the local Claude Desktop setup. On Windows it looks for `%APPDATA%\Claude\claude_desktop_config.json`; on macOS it looks for `~/Library/Application Support/Claude/claude_desktop_config.json`. A valid existing config returns `PASS`, a missing Claude Desktop install returns `WARN`, and a malformed local config returns `FAIL` so you do not add new servers into a broken config file. If the package server name already exists in Claude Desktop, the command returns `WARN` with the duplicate server name. Add `--install-preview` to print the JSON snippet that should be merged into `claude_desktop_config.json`; it does not modify files. Use `--apply --backup` only when you want the CLI to create a timestamped backup and merge the server config. Apply mode refuses to overwrite duplicate server names.
320
320
 
@@ -370,9 +370,9 @@ jobs:
370
370
  runs-on: ubuntu-latest
371
371
  steps:
372
372
  - uses: actions/checkout@v5
373
- - uses: Esquetta/CodexPluginDoctor@v1.25.0
373
+ - uses: Esquetta/CodexPluginDoctor@v1.26.0
374
374
  with:
375
- version: "1.25.0"
375
+ version: "1.26.0"
376
376
  path: .
377
377
  runtime: "true"
378
378
  policy: codex-publish
@@ -21,22 +21,55 @@ function createRuntimeScorecard() {
21
21
  promptGet: "unsupported"
22
22
  };
23
23
  }
24
- function buildFailure(id, message, impact, suggestedFix) {
24
+ function buildFailure(id, message, impact, suggestedFix, evidence) {
25
25
  return {
26
26
  id,
27
27
  severity: "fail",
28
28
  message,
29
29
  impact,
30
- suggestedFix
30
+ suggestedFix,
31
+ ...(evidence ? { evidence } : {})
31
32
  };
32
33
  }
33
- function buildWarning(id, message, impact, suggestedFix) {
34
+ function buildWarning(id, message, impact, suggestedFix, evidence) {
34
35
  return {
35
36
  id,
36
37
  severity: "warn",
37
38
  message,
38
39
  impact,
39
- suggestedFix
40
+ suggestedFix,
41
+ ...(evidence ? { evidence } : {})
42
+ };
43
+ }
44
+ function methodForRuntimeFinding(id) {
45
+ if (id.includes(".initialize."))
46
+ return "initialize";
47
+ if (id.includes(".tools_list."))
48
+ return "tools/list";
49
+ if (id.includes(".tool_call."))
50
+ return "tools/call";
51
+ if (id.includes(".resources_list."))
52
+ return "resources/list";
53
+ if (id.includes(".resource_read."))
54
+ return "resources/read";
55
+ if (id.includes(".resource_templates_list."))
56
+ return "resources/templates/list";
57
+ if (id.includes(".prompts_list."))
58
+ return "prompts/list";
59
+ if (id.includes(".prompt_get."))
60
+ return "prompts/get";
61
+ if (id.includes(".startup.") || id.includes(".exited_early"))
62
+ return "startup";
63
+ return "protocol";
64
+ }
65
+ function withRuntimeEvidence(finding, serverName) {
66
+ return {
67
+ ...finding,
68
+ evidence: {
69
+ serverName,
70
+ method: methodForRuntimeFinding(finding.id),
71
+ ...(finding.evidence ?? {})
72
+ }
40
73
  };
41
74
  }
42
75
  function isPlainObject(value) {
@@ -511,55 +544,56 @@ function getContentBlockLength(content) {
511
544
  }
512
545
  return 0;
513
546
  }
514
- function collectOversizedToolCallWarnings(message) {
547
+ function collectOversizedToolCallWarnings(message, evidence) {
515
548
  if (!isPlainObject(message.result) || !Array.isArray(message.result.content)) {
516
549
  return [];
517
550
  }
518
- const hasOversizedContent = message.result.content.some((content) => {
519
- return isPlainObject(content) && getContentBlockLength(content) > MAX_TOOL_CALL_CONTENT_LENGTH;
520
- });
521
- if (!hasOversizedContent) {
551
+ const contentLength = message.result.content.reduce((maximum, content) => {
552
+ return isPlainObject(content)
553
+ ? Math.max(maximum, getContentBlockLength(content))
554
+ : maximum;
555
+ }, 0);
556
+ if (contentLength <= MAX_TOOL_CALL_CONTENT_LENGTH) {
522
557
  return [];
523
558
  }
524
559
  return [
525
- buildWarning("plugin.runtime.tool_call.content_too_large", "The tools/call response contains unusually large content payloads.", "Large tool call payloads increase runtime cost, transcript size, and the risk of degraded model performance.", "Keep tool responses concise or move large payloads into resource references instead of inline content.")
560
+ buildWarning("plugin.runtime.tool_call.content_too_large", "The tools/call response contains unusually large content payloads.", "Large tool call payloads increase runtime cost, transcript size, and the risk of degraded model performance.", "Keep tool responses concise or move large payloads into resource references instead of inline content.", { ...evidence, contentLength })
526
561
  ];
527
562
  }
528
- function collectOversizedResourceReadWarnings(message) {
563
+ function collectOversizedResourceReadWarnings(message, evidence) {
529
564
  if (!isPlainObject(message.result) || !Array.isArray(message.result.contents)) {
530
565
  return [];
531
566
  }
532
- const hasOversizedContent = message.result.contents.some((content) => {
567
+ const contentLength = message.result.contents.reduce((maximum, content) => {
533
568
  if (!isPlainObject(content)) {
534
- return false;
569
+ return maximum;
535
570
  }
536
571
  const textLength = typeof content.text === "string" ? content.text.length : 0;
537
572
  const blobLength = typeof content.blob === "string" ? content.blob.length : 0;
538
- return (textLength > MAX_RESOURCE_READ_CONTENT_LENGTH ||
539
- blobLength > MAX_RESOURCE_READ_CONTENT_LENGTH);
540
- });
541
- if (!hasOversizedContent) {
573
+ return Math.max(maximum, textLength, blobLength);
574
+ }, 0);
575
+ if (contentLength <= MAX_RESOURCE_READ_CONTENT_LENGTH) {
542
576
  return [];
543
577
  }
544
578
  return [
545
- buildWarning("plugin.runtime.resource_read.content_too_large", "The resources/read response contains unusually large resource payloads.", "Large resource contents increase runtime cost and can make downstream model use less reliable.", "Reduce inline resource size or break large resources into smaller references.")
579
+ buildWarning("plugin.runtime.resource_read.content_too_large", "The resources/read response contains unusually large resource payloads.", "Large resource contents increase runtime cost and can make downstream model use less reliable.", "Reduce inline resource size or break large resources into smaller references.", { ...evidence, contentLength })
546
580
  ];
547
581
  }
548
- function collectOversizedPromptGetWarnings(message) {
582
+ function collectOversizedPromptGetWarnings(message, evidence) {
549
583
  if (!isPlainObject(message.result) || !Array.isArray(message.result.messages)) {
550
584
  return [];
551
585
  }
552
- const hasOversizedContent = message.result.messages.some((promptMessage) => {
586
+ const contentLength = message.result.messages.reduce((maximum, promptMessage) => {
553
587
  if (!isPlainObject(promptMessage) || !isPlainObject(promptMessage.content)) {
554
- return false;
588
+ return maximum;
555
589
  }
556
- return getContentBlockLength(promptMessage.content) > MAX_PROMPT_GET_CONTENT_LENGTH;
557
- });
558
- if (!hasOversizedContent) {
590
+ return Math.max(maximum, getContentBlockLength(promptMessage.content));
591
+ }, 0);
592
+ if (contentLength <= MAX_PROMPT_GET_CONTENT_LENGTH) {
559
593
  return [];
560
594
  }
561
595
  return [
562
- buildWarning("plugin.runtime.prompt_get.content_too_large", "The prompts/get response contains unusually large prompt message payloads.", "Large prompt bodies increase token cost and reduce the usefulness of prompt probing as a lightweight validation step.", "Keep prompt message content concise or move large context into resources.")
596
+ buildWarning("plugin.runtime.prompt_get.content_too_large", "The prompts/get response contains unusually large prompt message payloads.", "Large prompt bodies increase token cost and reduce the usefulness of prompt probing as a lightweight validation step.", "Keep prompt message content concise or move large context into resources.", { ...evidence, contentLength })
563
597
  ];
564
598
  }
565
599
  async function probeCommandServer(input) {
@@ -596,7 +630,10 @@ async function probeCommandServer(input) {
596
630
  child.kill("SIGTERM");
597
631
  }
598
632
  resolve({
599
- findings: [...warnings, ...(finding ? [finding] : [])],
633
+ findings: [
634
+ ...warnings.map((warning) => withRuntimeEvidence(warning, serverName)),
635
+ ...(finding ? [withRuntimeEvidence(finding, serverName)] : [])
636
+ ],
600
637
  scorecard
601
638
  });
602
639
  };
@@ -752,14 +789,16 @@ async function probeCommandServer(input) {
752
789
  const toolCallResponse = await sendRequest("tools/call", {
753
790
  name: callableTool.tool.name,
754
791
  arguments: callableTool.args
755
- }, buildFailure("plugin.runtime.tool_call.timeout", `The MCP server \`${serverName}\` did not answer the tools/call request in time.`, "A server that cannot complete a basic tool invocation in time will feel broken in real Codex usage.", "Inspect the selected tool implementation and reduce its response latency."));
792
+ }, buildFailure("plugin.runtime.tool_call.timeout", `The MCP server \`${serverName}\` did not answer the tools/call request in time.`, "A server that cannot complete a basic tool invocation in time will feel broken in real Codex usage.", "Inspect the selected tool implementation and reduce its response latency.", { toolName: callableTool.tool.name }));
756
793
  if (isErrorResponse(toolCallResponse) || !isValidCallToolResult(toolCallResponse)) {
757
794
  scorecard.toolsCall = "fail";
758
- settle(buildFailure("plugin.runtime.tool_call.invalid", `The MCP server \`${serverName}\` returned an invalid tools/call result.`, "Codex cannot safely consume malformed tool call results from the server.", "Return a CallToolResult with a `content` array containing valid MCP content blocks."));
795
+ settle(buildFailure("plugin.runtime.tool_call.invalid", `The MCP server \`${serverName}\` returned an invalid tools/call result.`, "Codex cannot safely consume malformed tool call results from the server.", "Return a CallToolResult with a `content` array containing valid MCP content blocks.", { toolName: callableTool.tool.name }));
759
796
  return;
760
797
  }
761
798
  scorecard.toolsCall = "pass";
762
- warnings.push(...collectOversizedToolCallWarnings(toolCallResponse));
799
+ warnings.push(...collectOversizedToolCallWarnings(toolCallResponse, {
800
+ toolName: callableTool.tool.name
801
+ }));
763
802
  }
764
803
  if (!hasResourcesCapability(initializeResponse)) {
765
804
  scorecard.resourcesList = "unsupported";
@@ -781,15 +820,17 @@ async function probeCommandServer(input) {
781
820
  if (resources.length > 0) {
782
821
  const resourceReadResponse = await sendRequest("resources/read", {
783
822
  uri: resources[0].uri
784
- }, buildFailure("plugin.runtime.resource_read.timeout", `The MCP server \`${serverName}\` did not answer the resources/read request in time.`, "A server that lists resources but cannot read them in time will feel broken in Codex.", "Inspect the resource read path and reduce latency before returning resource contents."));
823
+ }, buildFailure("plugin.runtime.resource_read.timeout", `The MCP server \`${serverName}\` did not answer the resources/read request in time.`, "A server that lists resources but cannot read them in time will feel broken in Codex.", "Inspect the resource read path and reduce latency before returning resource contents.", { resourceUri: resources[0].uri }));
785
824
  if (isErrorResponse(resourceReadResponse) ||
786
825
  !isValidReadResourceResult(resourceReadResponse)) {
787
826
  scorecard.resourceRead = "fail";
788
- settle(buildFailure("plugin.runtime.resource_read.invalid", `The MCP server \`${serverName}\` returned an invalid resources/read result.`, "Codex cannot safely consume malformed resource contents from `resources/read`.", "Return a `contents` array where each entry has a string `uri` plus either string `text` or string `blob`."));
827
+ settle(buildFailure("plugin.runtime.resource_read.invalid", `The MCP server \`${serverName}\` returned an invalid resources/read result.`, "Codex cannot safely consume malformed resource contents from `resources/read`.", "Return a `contents` array where each entry has a string `uri` plus either string `text` or string `blob`.", { resourceUri: resources[0].uri }));
789
828
  return;
790
829
  }
791
830
  scorecard.resourceRead = "pass";
792
- warnings.push(...collectOversizedResourceReadWarnings(resourceReadResponse));
831
+ warnings.push(...collectOversizedResourceReadWarnings(resourceReadResponse, {
832
+ resourceUri: resources[0].uri
833
+ }));
793
834
  }
794
835
  else {
795
836
  scorecard.resourceRead = "skipped";
@@ -842,15 +883,17 @@ async function probeCommandServer(input) {
842
883
  ...(buildPromptArguments(promptForGet)
843
884
  ? { arguments: buildPromptArguments(promptForGet) }
844
885
  : {})
845
- }, buildFailure("plugin.runtime.prompt_get.timeout", `The MCP server \`${serverName}\` did not answer the prompts/get request in time.`, "A server that lists prompts but cannot return prompt content in time will feel broken in Codex.", "Inspect the prompt retrieval path and reduce latency before returning prompt messages."));
886
+ }, buildFailure("plugin.runtime.prompt_get.timeout", `The MCP server \`${serverName}\` did not answer the prompts/get request in time.`, "A server that lists prompts but cannot return prompt content in time will feel broken in Codex.", "Inspect the prompt retrieval path and reduce latency before returning prompt messages.", { promptName: promptForGet.name }));
846
887
  if (isErrorResponse(promptGetResponse) ||
847
888
  !isValidPromptGetResult(promptGetResponse)) {
848
889
  scorecard.promptGet = "fail";
849
- settle(buildFailure("plugin.runtime.prompt_get.invalid", `The MCP server \`${serverName}\` returned an invalid prompts/get result.`, "Codex cannot safely consume malformed prompt messages from `prompts/get`.", "Return a `messages` array where each entry has a valid `role` and a valid MCP content block."));
890
+ settle(buildFailure("plugin.runtime.prompt_get.invalid", `The MCP server \`${serverName}\` returned an invalid prompts/get result.`, "Codex cannot safely consume malformed prompt messages from `prompts/get`.", "Return a `messages` array where each entry has a valid `role` and a valid MCP content block.", { promptName: promptForGet.name }));
850
891
  return;
851
892
  }
852
893
  scorecard.promptGet = "pass";
853
- warnings.push(...collectOversizedPromptGetWarnings(promptGetResponse));
894
+ warnings.push(...collectOversizedPromptGetWarnings(promptGetResponse, {
895
+ promptName: promptForGet.name
896
+ }));
854
897
  }
855
898
  }
856
899
  settle(null);
@@ -193,19 +193,20 @@ function isDescriptionLikelyVerbose(description, mode) {
193
193
  }
194
194
  function validateRequiredManifestFields(discoveredPackage) {
195
195
  const findings = [];
196
- const { manifest } = discoveredPackage;
196
+ const { manifest, rootPath } = discoveredPackage;
197
+ const manifestPath = relativePackagePath(rootPath, discoveredPackage.manifestPath);
197
198
  if (!manifest.name) {
198
- findings.push(buildFailure("plugin.manifest.name.missing", "The plugin manifest is missing a `name` field.", "Codex cannot identify the plugin reliably without a stable package name.", "Add a kebab-case `name` field to `.codex-plugin/plugin.json`."));
199
+ findings.push(buildFailure("plugin.manifest.name.missing", "The plugin manifest is missing a `name` field.", "Codex cannot identify the plugin reliably without a stable package name.", "Add a kebab-case `name` field to `.codex-plugin/plugin.json`.", { manifestPath, field: "name" }));
199
200
  }
200
201
  if (!manifest.version) {
201
- findings.push(buildFailure("plugin.manifest.version.missing", "The plugin manifest is missing a `version` field.", "Release and compatibility workflows cannot reason about the package version.", "Add a semantic `version` field to `.codex-plugin/plugin.json`."));
202
+ findings.push(buildFailure("plugin.manifest.version.missing", "The plugin manifest is missing a `version` field.", "Release and compatibility workflows cannot reason about the package version.", "Add a semantic `version` field to `.codex-plugin/plugin.json`.", { manifestPath, field: "version" }));
202
203
  }
203
204
  if (!manifest.description) {
204
- findings.push(buildFailure("plugin.manifest.description.missing", "The plugin manifest is missing a `description` field.", "The package will be harder to understand and present in Codex surfaces.", "Add a concise `description` field to `.codex-plugin/plugin.json`."));
205
+ findings.push(buildFailure("plugin.manifest.description.missing", "The plugin manifest is missing a `description` field.", "The package will be harder to understand and present in Codex surfaces.", "Add a concise `description` field to `.codex-plugin/plugin.json`.", { manifestPath, field: "description" }));
205
206
  }
206
207
  if (manifest.description &&
207
208
  isDescriptionLikelyVerbose(manifest.description, "plugin")) {
208
- findings.push(buildWarning("plugin.heuristic.description.too_long", "The plugin manifest description is likely too verbose.", "Overly long metadata increases context cost and can dilute plugin discovery quality.", "Shorten the manifest description to a precise one- or two-sentence summary."));
209
+ findings.push(buildWarning("plugin.heuristic.description.too_long", "The plugin manifest description is likely too verbose.", "Overly long metadata increases context cost and can dilute plugin discovery quality.", "Shorten the manifest description to a precise one- or two-sentence summary.", { manifestPath, field: "description" }));
209
210
  }
210
211
  return findings;
211
212
  }
@@ -230,7 +231,12 @@ async function validateSkillsDirectory(discoveredPackage) {
230
231
  return [];
231
232
  }
232
233
  return [
233
- buildFailure("plugin.skills.path.missing", "The plugin manifest points to a missing skills directory.", "Codex will not be able to load the packaged skills as expected.", `Create the skills directory at \`${skillsPath}\` or update the manifest path.`)
234
+ buildFailure("plugin.skills.path.missing", "The plugin manifest points to a missing skills directory.", "Codex will not be able to load the packaged skills as expected.", `Create the skills directory at \`${skillsPath}\` or update the manifest path.`, {
235
+ manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
236
+ field: "skills",
237
+ configuredPath: manifest.skills,
238
+ resolvedPath: skillsPath
239
+ })
234
240
  ];
235
241
  }
236
242
  async function validateSkillDefinitions(discoveredPackage) {
@@ -254,27 +260,47 @@ async function validateSkillDefinitions(discoveredPackage) {
254
260
  const skillFilePath = path.join(skillRoot, "SKILL.md");
255
261
  const skillFileExists = await fileExists(skillFilePath);
256
262
  if (!skillFileExists) {
257
- findings.push(buildFailure("plugin.skill.skill_md.missing", `The skill \`${skillDirectory.name}\` is missing \`SKILL.md\`.`, "Codex cannot load a skill directory that does not contain the required SKILL.md entrypoint.", `Add \`SKILL.md\` to \`${skillRoot}\` with at least \`name\` and \`description\` frontmatter.`));
263
+ findings.push(buildFailure("plugin.skill.skill_md.missing", `The skill \`${skillDirectory.name}\` is missing \`SKILL.md\`.`, "Codex cannot load a skill directory that does not contain the required SKILL.md entrypoint.", `Add \`SKILL.md\` to \`${skillRoot}\` with at least \`name\` and \`description\` frontmatter.`, {
264
+ skillName: skillDirectory.name,
265
+ skillPath: relativePackagePath(rootPath, skillFilePath)
266
+ }));
258
267
  continue;
259
268
  }
260
269
  const skillContent = await readFile(skillFilePath, "utf8");
261
270
  const frontmatter = parseSkillFrontmatter(skillContent);
262
271
  if (!frontmatter?.name) {
263
- findings.push(buildFailure("plugin.skill.name.missing", `The skill \`${skillDirectory.name}\` is missing a \`name\` field in frontmatter.`, "Codex cannot expose skill metadata correctly without a stable skill name.", `Add a \`name\` field to the frontmatter in \`${skillFilePath}\`.`));
272
+ findings.push(buildFailure("plugin.skill.name.missing", `The skill \`${skillDirectory.name}\` is missing a \`name\` field in frontmatter.`, "Codex cannot expose skill metadata correctly without a stable skill name.", `Add a \`name\` field to the frontmatter in \`${skillFilePath}\`.`, {
273
+ skillName: skillDirectory.name,
274
+ skillPath: relativePackagePath(rootPath, skillFilePath),
275
+ field: "name"
276
+ }));
264
277
  }
265
278
  if (!frontmatter?.description) {
266
- findings.push(buildFailure("plugin.skill.description.missing", `The skill \`${skillDirectory.name}\` is missing a \`description\` field in frontmatter.`, "Codex uses skill descriptions for discovery and implicit matching, so missing descriptions reduce skill usability.", `Add a scoped \`description\` field to the frontmatter in \`${skillFilePath}\`.`));
279
+ findings.push(buildFailure("plugin.skill.description.missing", `The skill \`${skillDirectory.name}\` is missing a \`description\` field in frontmatter.`, "Codex uses skill descriptions for discovery and implicit matching, so missing descriptions reduce skill usability.", `Add a scoped \`description\` field to the frontmatter in \`${skillFilePath}\`.`, {
280
+ skillName: skillDirectory.name,
281
+ skillPath: relativePackagePath(rootPath, skillFilePath),
282
+ field: "description"
283
+ }));
267
284
  }
268
285
  if (frontmatter?.description &&
269
286
  isDescriptionLikelyVerbose(frontmatter.description, "skill")) {
270
- findings.push(buildWarning("plugin.heuristic.skill_description.too_long", `The skill \`${skillDirectory.name}\` description is likely too verbose.`, "Overly long skill descriptions increase context cost and reduce the precision of skill matching.", `Shorten the \`description\` field in \`${skillFilePath}\` to a tightly scoped summary.`));
287
+ findings.push(buildWarning("plugin.heuristic.skill_description.too_long", `The skill \`${skillDirectory.name}\` description is likely too verbose.`, "Overly long skill descriptions increase context cost and reduce the precision of skill matching.", `Shorten the \`description\` field in \`${skillFilePath}\` to a tightly scoped summary.`, {
288
+ skillName: skillDirectory.name,
289
+ skillPath: relativePackagePath(rootPath, skillFilePath),
290
+ field: "description"
291
+ }));
271
292
  }
272
293
  for (const supportReference of extractSupportAssetReferences(skillContent)) {
273
294
  const supportPath = path.resolve(skillRoot, supportReference);
274
295
  const supportFileExists = await fileExists(supportPath);
275
296
  const supportDirectoryExists = await directoryExists(supportPath);
276
297
  if (!supportFileExists && !supportDirectoryExists) {
277
- findings.push(buildWarning("plugin.skill.asset_reference.missing", `The skill \`${skillDirectory.name}\` references missing support asset \`${supportReference}\`.`, "Skills that point to missing scripts, templates, assets, or examples are harder to execute and can fail when an agent follows the instructions.", `Create \`${supportPath}\` or update the reference in \`${skillFilePath}\`.`));
298
+ findings.push(buildWarning("plugin.skill.asset_reference.missing", `The skill \`${skillDirectory.name}\` references missing support asset \`${supportReference}\`.`, "Skills that point to missing scripts, templates, assets, or examples are harder to execute and can fail when an agent follows the instructions.", `Create \`${supportPath}\` or update the reference in \`${skillFilePath}\`.`, {
299
+ skillName: skillDirectory.name,
300
+ skillPath: relativePackagePath(rootPath, skillFilePath),
301
+ assetReference: supportReference,
302
+ assetPath: relativePackagePath(rootPath, supportPath)
303
+ }));
278
304
  }
279
305
  }
280
306
  }
@@ -299,7 +325,12 @@ async function validateMcpConfig(discoveredPackage) {
299
325
  const mcpConfigExists = await fileExists(mcpConfigPath);
300
326
  if (!mcpConfigExists) {
301
327
  return [
302
- buildFailure("plugin.mcp.path.missing", "The plugin manifest points to a missing `.mcp.json` file.", "Codex cannot load bundled MCP server definitions if the referenced config file does not exist.", `Create \`${mcpConfigPath}\` or update the manifest \`mcpServers\` path.`)
328
+ buildFailure("plugin.mcp.path.missing", "The plugin manifest points to a missing `.mcp.json` file.", "Codex cannot load bundled MCP server definitions if the referenced config file does not exist.", `Create \`${mcpConfigPath}\` or update the manifest \`mcpServers\` path.`, {
329
+ manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
330
+ field: "mcpServers",
331
+ configuredPath: manifest.mcpServers,
332
+ configPath: relativePackagePath(rootPath, mcpConfigPath)
333
+ })
303
334
  ];
304
335
  }
305
336
  let parsedConfig;
@@ -308,31 +339,42 @@ async function validateMcpConfig(discoveredPackage) {
308
339
  }
309
340
  catch {
310
341
  return [
311
- 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}\`.`)
342
+ 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" })
312
343
  ];
313
344
  }
314
345
  if (!isPlainObject(parsedConfig)) {
315
346
  return [
316
- 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}\`.`)
347
+ 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" })
317
348
  ];
318
349
  }
319
350
  const servers = parsedConfig.mcpServers;
320
351
  if (!isPlainObject(servers) || Object.keys(servers).length === 0) {
321
352
  return [
322
- 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}\`.`)
353
+ 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}\`.`, {
354
+ configPath: relativePackagePath(rootPath, mcpConfigPath),
355
+ field: "mcpServers"
356
+ })
323
357
  ];
324
358
  }
325
359
  const findings = [];
326
360
  for (const [serverName, serverConfig] of Object.entries(servers)) {
327
361
  if (!isPlainObject(serverConfig)) {
328
- findings.push(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.`));
362
+ findings.push(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.`, {
363
+ configPath: relativePackagePath(rootPath, mcpConfigPath),
364
+ serverName,
365
+ field: "server"
366
+ }));
329
367
  continue;
330
368
  }
331
369
  const command = serverConfig.command;
332
370
  const url = serverConfig.url;
333
371
  const env = serverConfig.env;
334
372
  if (typeof command !== "string" && typeof url !== "string") {
335
- findings.push(buildFailure("plugin.mcp.server.transport.missing", `The MCP server \`${serverName}\` must define either \`command\` or \`url\`.`, "Codex needs a process command for STDIO servers or a URL for streamable HTTP servers.", `Add either \`command\` or \`url\` to the \`${serverName}\` entry in \`${mcpConfigPath}\`.`));
373
+ findings.push(buildFailure("plugin.mcp.server.transport.missing", `The MCP server \`${serverName}\` must define either \`command\` or \`url\`.`, "Codex needs a process command for STDIO servers or a URL for streamable HTTP servers.", `Add either \`command\` or \`url\` to the \`${serverName}\` entry in \`${mcpConfigPath}\`.`, {
374
+ configPath: relativePackagePath(rootPath, mcpConfigPath),
375
+ serverName,
376
+ field: "transport"
377
+ }));
336
378
  }
337
379
  if (isPlainObject(env)) {
338
380
  for (const [envKey, envValue] of Object.entries(env)) {
@@ -4,13 +4,14 @@ import { buildCompatibilityMatrix, readMcpConfigPath } from "../compatibility/co
4
4
  import { readJsonFile } from "../core/read-json-file.js";
5
5
  import { formatFindingFingerprintLine, withFindingFingerprints } from "../reporting/finding-fingerprint.js";
6
6
  import { auditMcpServerConfig, buildSecurityAuditFromFindings } from "../security/security-audit.js";
7
- function buildFinding(severity, id, message, impact, suggestedFix) {
7
+ function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
8
8
  return {
9
9
  id,
10
10
  severity,
11
11
  message,
12
12
  impact,
13
- suggestedFix
13
+ suggestedFix,
14
+ ...(evidence ? { evidence } : {})
14
15
  };
15
16
  }
16
17
  function isPlainObject(value) {
@@ -30,12 +31,12 @@ function isPathWithinRoot(rootPath, candidatePath) {
30
31
  return (relativePath === "" ||
31
32
  (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
32
33
  }
33
- function buildStaticMcpFindings(mcpConfigPath, parsedConfig) {
34
- if (!mcpConfigPath) {
34
+ function buildStaticMcpFindings(configPath, parsedConfig) {
35
+ if (!configPath) {
35
36
  return {
36
37
  serverCount: 0,
37
38
  findings: [
38
- buildFinding("fail", "mcp.config.missing", "No MCP config was found for this target.", "A generic MCP package needs a `.mcp.json` file or a Codex manifest `mcpServers` reference before clients can discover servers.", "Add `.mcp.json` with a non-empty top-level `mcpServers` object.")
39
+ buildFinding("fail", "mcp.config.missing", "No MCP config was found for this target.", "A generic MCP package needs a `.mcp.json` file or a Codex manifest `mcpServers` reference before clients can discover servers.", "Add `.mcp.json` with a non-empty top-level `mcpServers` object.", { configPath: ".mcp.json", field: "mcpServers" })
39
40
  ]
40
41
  };
41
42
  }
@@ -43,7 +44,7 @@ function buildStaticMcpFindings(mcpConfigPath, parsedConfig) {
43
44
  return {
44
45
  serverCount: 0,
45
46
  findings: [
46
- 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 \`${mcpConfigPath}\`.`)
47
+ 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" })
47
48
  ]
48
49
  };
49
50
  }
@@ -52,18 +53,18 @@ function buildStaticMcpFindings(mcpConfigPath, parsedConfig) {
52
53
  return {
53
54
  serverCount: 0,
54
55
  findings: [
55
- 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 \`${mcpConfigPath}\`.`)
56
+ 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" })
56
57
  ]
57
58
  };
58
59
  }
59
60
  const findings = [];
60
61
  for (const [serverName, serverConfig] of Object.entries(servers)) {
61
62
  if (!isPlainObject(serverConfig)) {
62
- 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 \`${mcpConfigPath}\` to an object.`));
63
+ 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" }));
63
64
  continue;
64
65
  }
65
66
  if (typeof serverConfig.command !== "string" && typeof serverConfig.url !== "string") {
66
- findings.push(buildFinding("fail", "mcp.server.transport.missing", `The MCP server \`${serverName}\` must define either \`command\` or \`url\`.`, "MCP clients need a process command for stdio servers or a URL for remote servers.", `Add either \`command\` or \`url\` to the \`${serverName}\` entry in \`${mcpConfigPath}\`.`));
67
+ findings.push(buildFinding("fail", "mcp.server.transport.missing", `The MCP server \`${serverName}\` must define either \`command\` or \`url\`.`, "MCP clients need a process command for stdio servers or a URL for remote servers.", `Add either \`command\` or \`url\` to the \`${serverName}\` entry in \`${configPath}\`.`, { configPath, serverName, field: "transport" }));
67
68
  }
68
69
  }
69
70
  return {
@@ -92,19 +93,26 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}) {
92
93
  }
93
94
  else if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
94
95
  staticFindings = [
95
- 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.")
96
+ 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.", {
97
+ configPath: path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"),
98
+ resolvedPath: mcpConfigPath,
99
+ field: "configPath"
100
+ })
96
101
  ];
97
102
  }
98
103
  else {
99
104
  try {
100
105
  parsedConfig = await readJsonFile(mcpConfigPath);
101
- const staticResult = buildStaticMcpFindings(mcpConfigPath, parsedConfig);
106
+ const staticResult = buildStaticMcpFindings(path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"), parsedConfig);
102
107
  staticFindings = staticResult.findings;
103
108
  serverCount = staticResult.serverCount;
104
109
  }
105
110
  catch {
106
111
  staticFindings = [
107
- buildFinding("fail", "mcp.config.invalid_json", "The MCP config is not valid JSON.", "MCP clients cannot parse server configuration until the JSON syntax is valid.", `Fix the JSON syntax in \`${mcpConfigPath}\`.`)
112
+ buildFinding("fail", "mcp.config.invalid_json", "The MCP config is not valid JSON.", "MCP clients cannot parse server configuration until the JSON syntax is valid.", `Fix the JSON syntax in \`${mcpConfigPath}\`.`, {
113
+ configPath: path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"),
114
+ field: "json"
115
+ })
108
116
  ];
109
117
  }
110
118
  }
@@ -331,7 +331,7 @@ export async function buildSecurityAudit(targetPath) {
331
331
  const discoveredPackage = await discoverPackage(targetPath);
332
332
  if (!discoveredPackage) {
333
333
  const findings = [
334
- buildFinding("fail", "plugin.security.audit_unavailable", "The target directory is missing `.codex-plugin/plugin.json`, so the package security audit cannot run.", "Without a Codex plugin manifest, the audit cannot resolve packaged skills or MCP server configuration safely.", "Run the audit against a Codex plugin package root.", { targetPath: path.resolve(targetPath) })
334
+ buildFinding("fail", "plugin.security.audit_unavailable", "The target directory is missing `.codex-plugin/plugin.json`, so the package security audit cannot run.", "Without a Codex plugin manifest, the audit cannot resolve packaged skills or MCP server configuration safely.", "Run the audit against a Codex plugin package root.", { manifestPath: ".codex-plugin/plugin.json" })
335
335
  ];
336
336
  return buildSecurityAuditFromFindings(targetPath, findings);
337
337
  }
@@ -11,13 +11,14 @@ const lifecycleScripts = new Set([
11
11
  "prepublish",
12
12
  "prepare"
13
13
  ]);
14
- function buildFinding(severity, id, message, impact, suggestedFix) {
14
+ function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
15
15
  return {
16
16
  id,
17
17
  severity,
18
18
  message,
19
19
  impact,
20
- suggestedFix
20
+ suggestedFix,
21
+ ...(evidence ? { evidence } : {})
21
22
  };
22
23
  }
23
24
  async function fileExists(targetPath) {
@@ -61,10 +62,10 @@ function auditScripts(packageJson) {
61
62
  }
62
63
  scriptsChecked += 1;
63
64
  if (containsRemotePipeInstall(scriptValue)) {
64
- findings.push(buildFinding("fail", "trust.package.remote_pipe_install", `The package lifecycle script \`${scriptName}\` pipes remote content into a shell.`, "Remote download-and-execute scripts can run unreviewed code during install or publish workflows.", "Replace remote pipe execution with pinned package dependencies or a checked-in reviewed setup script."));
65
+ findings.push(buildFinding("fail", "trust.package.remote_pipe_install", `The package lifecycle script \`${scriptName}\` pipes remote content into a shell.`, "Remote download-and-execute scripts can run unreviewed code during install or publish workflows.", "Replace remote pipe execution with pinned package dependencies or a checked-in reviewed setup script.", { packageJsonPath: "package.json", scriptName }));
65
66
  continue;
66
67
  }
67
- findings.push(buildFinding("warn", "trust.package.lifecycle_script", `The package defines lifecycle script \`${scriptName}\`.`, "Lifecycle scripts execute automatically during package manager workflows and increase supply-chain review scope.", "Keep lifecycle scripts minimal, documented, and covered by release review."));
68
+ findings.push(buildFinding("warn", "trust.package.lifecycle_script", `The package defines lifecycle script \`${scriptName}\`.`, "Lifecycle scripts execute automatically during package manager workflows and increase supply-chain review scope.", "Keep lifecycle scripts minimal, documented, and covered by release review.", { packageJsonPath: "package.json", scriptName }));
68
69
  }
69
70
  return {
70
71
  findings,
@@ -73,26 +74,41 @@ function auditScripts(packageJson) {
73
74
  }
74
75
  function dependencySections(packageJson) {
75
76
  return [
76
- packageJson.dependencies,
77
- packageJson.devDependencies,
78
- packageJson.optionalDependencies,
79
- packageJson.peerDependencies
80
- ].filter(isPlainObject);
77
+ { name: "dependencies", dependencies: packageJson.dependencies },
78
+ { name: "devDependencies", dependencies: packageJson.devDependencies },
79
+ { name: "optionalDependencies", dependencies: packageJson.optionalDependencies },
80
+ { name: "peerDependencies", dependencies: packageJson.peerDependencies }
81
+ ]
82
+ .filter((entry) => isPlainObject(entry.dependencies))
83
+ .map((entry) => ({
84
+ name: entry.name,
85
+ dependencies: entry.dependencies
86
+ }));
81
87
  }
82
88
  function auditDependencies(packageJson) {
83
89
  const findings = [];
84
90
  let dependenciesChecked = 0;
85
- for (const dependencies of dependencySections(packageJson)) {
91
+ for (const { name: dependencySection, dependencies } of dependencySections(packageJson)) {
86
92
  for (const [dependencyName, versionSpec] of Object.entries(dependencies)) {
87
93
  if (typeof versionSpec !== "string") {
88
94
  continue;
89
95
  }
90
96
  dependenciesChecked += 1;
91
97
  if (versionSpec === "*" || versionSpec.toLowerCase() === "latest") {
92
- findings.push(buildFinding("warn", "trust.package.unpinned_dependency", `The dependency \`${dependencyName}\` uses broad version spec \`${versionSpec}\`.`, "Broad dependency ranges make package resolution less reproducible across installs and releases.", "Pin the dependency to a specific compatible range or exact version."));
98
+ findings.push(buildFinding("warn", "trust.package.unpinned_dependency", `The dependency \`${dependencyName}\` uses broad version spec \`${versionSpec}\`.`, "Broad dependency ranges make package resolution less reproducible across installs and releases.", "Pin the dependency to a specific compatible range or exact version.", {
99
+ packageJsonPath: "package.json",
100
+ dependencyName,
101
+ dependencySection,
102
+ versionSpec
103
+ }));
93
104
  }
94
105
  if (/^(git\+|github:|http:\/\/|https:\/\/)/i.test(versionSpec)) {
95
- findings.push(buildFinding("warn", "trust.package.remote_dependency", `The dependency \`${dependencyName}\` resolves from remote spec \`${versionSpec}\`.`, "Remote dependency specs can change outside the npm registry's normal version and integrity workflow.", "Prefer registry-published dependencies with pinned semver ranges."));
106
+ findings.push(buildFinding("warn", "trust.package.remote_dependency", `The dependency \`${dependencyName}\` resolves from remote spec \`${versionSpec}\`.`, "Remote dependency specs can change outside the npm registry's normal version and integrity workflow.", "Prefer registry-published dependencies with pinned semver ranges.", {
107
+ packageJsonPath: "package.json",
108
+ dependencyName,
109
+ dependencySection,
110
+ versionSpec
111
+ }));
96
112
  }
97
113
  }
98
114
  }
@@ -104,7 +120,7 @@ function auditDependencies(packageJson) {
104
120
  function dedupeFindings(findings) {
105
121
  const seen = new Set();
106
122
  return findings.filter((finding) => {
107
- const key = `${finding.id}\n${finding.message}`;
123
+ const key = `${finding.id}\n${JSON.stringify(finding.evidence ?? {})}`;
108
124
  if (seen.has(key)) {
109
125
  return false;
110
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",