codex-plugin-doctor 1.24.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. 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.24.0
373
+ - uses: Esquetta/CodexPluginDoctor@v1.26.0
374
374
  with:
375
- version: "1.24.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);
@@ -1,5 +1,6 @@
1
1
  import { readFile, readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { withFindingFingerprints } from "../reporting/finding-fingerprint.js";
3
4
  import { discoverPackage } from "./discover-package.js";
4
5
  import { probeRuntime } from "./runtime-probe.js";
5
6
  function buildFailure(id, message, impact, suggestedFix, evidence) {
@@ -192,19 +193,20 @@ function isDescriptionLikelyVerbose(description, mode) {
192
193
  }
193
194
  function validateRequiredManifestFields(discoveredPackage) {
194
195
  const findings = [];
195
- const { manifest } = discoveredPackage;
196
+ const { manifest, rootPath } = discoveredPackage;
197
+ const manifestPath = relativePackagePath(rootPath, discoveredPackage.manifestPath);
196
198
  if (!manifest.name) {
197
- 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" }));
198
200
  }
199
201
  if (!manifest.version) {
200
- 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" }));
201
203
  }
202
204
  if (!manifest.description) {
203
- 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" }));
204
206
  }
205
207
  if (manifest.description &&
206
208
  isDescriptionLikelyVerbose(manifest.description, "plugin")) {
207
- 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" }));
208
210
  }
209
211
  return findings;
210
212
  }
@@ -229,7 +231,12 @@ async function validateSkillsDirectory(discoveredPackage) {
229
231
  return [];
230
232
  }
231
233
  return [
232
- 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
+ })
233
240
  ];
234
241
  }
235
242
  async function validateSkillDefinitions(discoveredPackage) {
@@ -253,27 +260,47 @@ async function validateSkillDefinitions(discoveredPackage) {
253
260
  const skillFilePath = path.join(skillRoot, "SKILL.md");
254
261
  const skillFileExists = await fileExists(skillFilePath);
255
262
  if (!skillFileExists) {
256
- 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
+ }));
257
267
  continue;
258
268
  }
259
269
  const skillContent = await readFile(skillFilePath, "utf8");
260
270
  const frontmatter = parseSkillFrontmatter(skillContent);
261
271
  if (!frontmatter?.name) {
262
- 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
+ }));
263
277
  }
264
278
  if (!frontmatter?.description) {
265
- 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
+ }));
266
284
  }
267
285
  if (frontmatter?.description &&
268
286
  isDescriptionLikelyVerbose(frontmatter.description, "skill")) {
269
- 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
+ }));
270
292
  }
271
293
  for (const supportReference of extractSupportAssetReferences(skillContent)) {
272
294
  const supportPath = path.resolve(skillRoot, supportReference);
273
295
  const supportFileExists = await fileExists(supportPath);
274
296
  const supportDirectoryExists = await directoryExists(supportPath);
275
297
  if (!supportFileExists && !supportDirectoryExists) {
276
- 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
+ }));
277
304
  }
278
305
  }
279
306
  }
@@ -298,7 +325,12 @@ async function validateMcpConfig(discoveredPackage) {
298
325
  const mcpConfigExists = await fileExists(mcpConfigPath);
299
326
  if (!mcpConfigExists) {
300
327
  return [
301
- 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
+ })
302
334
  ];
303
335
  }
304
336
  let parsedConfig;
@@ -307,31 +339,42 @@ async function validateMcpConfig(discoveredPackage) {
307
339
  }
308
340
  catch {
309
341
  return [
310
- 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" })
311
343
  ];
312
344
  }
313
345
  if (!isPlainObject(parsedConfig)) {
314
346
  return [
315
- 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" })
316
348
  ];
317
349
  }
318
350
  const servers = parsedConfig.mcpServers;
319
351
  if (!isPlainObject(servers) || Object.keys(servers).length === 0) {
320
352
  return [
321
- 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
+ })
322
357
  ];
323
358
  }
324
359
  const findings = [];
325
360
  for (const [serverName, serverConfig] of Object.entries(servers)) {
326
361
  if (!isPlainObject(serverConfig)) {
327
- 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
+ }));
328
367
  continue;
329
368
  }
330
369
  const command = serverConfig.command;
331
370
  const url = serverConfig.url;
332
371
  const env = serverConfig.env;
333
372
  if (typeof command !== "string" && typeof url !== "string") {
334
- 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
+ }));
335
378
  }
336
379
  if (isPlainObject(env)) {
337
380
  for (const [envKey, envValue] of Object.entries(env)) {
@@ -358,7 +401,7 @@ export async function validatePlugin(targetPath, options = {}) {
358
401
  targetPath: rootPath,
359
402
  status: "fail",
360
403
  exitCode: 1,
361
- findings: [await buildMissingManifestFailure(rootPath)]
404
+ findings: withFindingFingerprints([await buildMissingManifestFailure(rootPath)], rootPath)
362
405
  };
363
406
  }
364
407
  const runtimeResult = options.runtime
@@ -373,8 +416,9 @@ export async function validatePlugin(targetPath, options = {}) {
373
416
  ...(await validateMcpConfig(discoveredPackage)),
374
417
  ...(runtimeResult ? runtimeResult.findings : [])
375
418
  ];
376
- const hasFailures = findings.some((finding) => finding.severity === "fail");
377
- const hasWarnings = findings.some((finding) => finding.severity === "warn");
419
+ const fingerprintedFindings = withFindingFingerprints(findings, discoveredPackage.rootPath);
420
+ const hasFailures = fingerprintedFindings.some((finding) => finding.severity === "fail");
421
+ const hasWarnings = fingerprintedFindings.some((finding) => finding.severity === "warn");
378
422
  if (!hasFailures && !hasWarnings) {
379
423
  return {
380
424
  targetPath: discoveredPackage.rootPath,
@@ -388,7 +432,7 @@ export async function validatePlugin(targetPath, options = {}) {
388
432
  targetPath: discoveredPackage.rootPath,
389
433
  status: hasFailures ? "fail" : "warn",
390
434
  exitCode: hasFailures ? 1 : 0,
391
- findings,
435
+ findings: fingerprintedFindings,
392
436
  ...(runtimeResult ? { runtimeScorecard: runtimeResult.scorecard } : {})
393
437
  };
394
438
  }
@@ -8,6 +8,7 @@ export interface Finding {
8
8
  impact: string;
9
9
  suggestedFix: string;
10
10
  evidence?: FindingEvidence;
11
+ fingerprint?: string;
11
12
  }
12
13
  export interface CheckResult {
13
14
  targetPath: string;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { CheckOptions, CheckResult } from "./domain/types.js";
2
+ export { buildFindingFingerprint, formatFindingFingerprintLine, withFindingFingerprint, withFindingFingerprints } from "./reporting/finding-fingerprint.js";
2
3
  export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard, type SecurityAudit } from "./security/security-audit.js";
3
4
  export { buildTrustScore, renderTrustScore, renderTrustScoreJson, type BuildTrustScoreOptions, type TrustScoreReport } from "./security/trust-score.js";
4
5
  export { buildDoctorSnapshot, renderDoctorSnapshot, renderDoctorSnapshotJson, type DoctorSnapshot } from "./core/doctor-snapshot.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { validatePlugin } from "./core/validate-plugin.js";
2
+ export { buildFindingFingerprint, formatFindingFingerprintLine, withFindingFingerprint, withFindingFingerprints } from "./reporting/finding-fingerprint.js";
2
3
  export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
3
4
  export { buildTrustScore, renderTrustScore, renderTrustScoreJson } from "./security/trust-score.js";
4
5
  export { buildDoctorSnapshot, renderDoctorSnapshot, renderDoctorSnapshotJson } from "./core/doctor-snapshot.js";
@@ -2,14 +2,16 @@ import { stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { buildCompatibilityMatrix, readMcpConfigPath } from "../compatibility/compatibility-matrix.js";
4
4
  import { readJsonFile } from "../core/read-json-file.js";
5
+ import { formatFindingFingerprintLine, withFindingFingerprints } from "../reporting/finding-fingerprint.js";
5
6
  import { auditMcpServerConfig, buildSecurityAuditFromFindings } from "../security/security-audit.js";
6
- function buildFinding(severity, id, message, impact, suggestedFix) {
7
+ function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
7
8
  return {
8
9
  id,
9
10
  severity,
10
11
  message,
11
12
  impact,
12
- suggestedFix
13
+ suggestedFix,
14
+ ...(evidence ? { evidence } : {})
13
15
  };
14
16
  }
15
17
  function isPlainObject(value) {
@@ -29,12 +31,12 @@ function isPathWithinRoot(rootPath, candidatePath) {
29
31
  return (relativePath === "" ||
30
32
  (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
31
33
  }
32
- function buildStaticMcpFindings(mcpConfigPath, parsedConfig) {
33
- if (!mcpConfigPath) {
34
+ function buildStaticMcpFindings(configPath, parsedConfig) {
35
+ if (!configPath) {
34
36
  return {
35
37
  serverCount: 0,
36
38
  findings: [
37
- 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" })
38
40
  ]
39
41
  };
40
42
  }
@@ -42,7 +44,7 @@ function buildStaticMcpFindings(mcpConfigPath, parsedConfig) {
42
44
  return {
43
45
  serverCount: 0,
44
46
  findings: [
45
- 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" })
46
48
  ]
47
49
  };
48
50
  }
@@ -51,18 +53,18 @@ function buildStaticMcpFindings(mcpConfigPath, parsedConfig) {
51
53
  return {
52
54
  serverCount: 0,
53
55
  findings: [
54
- 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" })
55
57
  ]
56
58
  };
57
59
  }
58
60
  const findings = [];
59
61
  for (const [serverName, serverConfig] of Object.entries(servers)) {
60
62
  if (!isPlainObject(serverConfig)) {
61
- 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" }));
62
64
  continue;
63
65
  }
64
66
  if (typeof serverConfig.command !== "string" && typeof serverConfig.url !== "string") {
65
- 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" }));
66
68
  }
67
69
  }
68
70
  return {
@@ -91,33 +93,41 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}) {
91
93
  }
92
94
  else if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
93
95
  staticFindings = [
94
- 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
+ })
95
101
  ];
96
102
  }
97
103
  else {
98
104
  try {
99
105
  parsedConfig = await readJsonFile(mcpConfigPath);
100
- const staticResult = buildStaticMcpFindings(mcpConfigPath, parsedConfig);
106
+ const staticResult = buildStaticMcpFindings(path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"), parsedConfig);
101
107
  staticFindings = staticResult.findings;
102
108
  serverCount = staticResult.serverCount;
103
109
  }
104
110
  catch {
105
111
  staticFindings = [
106
- 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
+ })
107
116
  ];
108
117
  }
109
118
  }
110
119
  const security = buildSecurityAuditFromFindings(rootPath, mcpConfigPath && parsedConfig !== null
111
120
  ? auditMcpServerConfig(rootPath, parsedConfig)
112
121
  : []);
113
- const status = mergeReportStatus(staticFindings, security);
122
+ const fingerprintedStaticFindings = withFindingFingerprints(staticFindings, rootPath);
123
+ const status = mergeReportStatus(fingerprintedStaticFindings, security);
114
124
  return {
115
125
  targetPath: rootPath,
116
126
  status,
117
127
  exitCode: status === "fail" ? 1 : 0,
118
128
  mcpConfigPath,
119
129
  serverCount,
120
- findings: [...staticFindings, ...security.findings],
130
+ findings: [...fingerprintedStaticFindings, ...security.findings],
121
131
  security,
122
132
  compatibility
123
133
  };
@@ -159,6 +169,10 @@ export function renderGenericMcpDoctor(report) {
159
169
  lines.push(` Message: ${finding.message}`);
160
170
  lines.push(` Impact: ${finding.impact}`);
161
171
  lines.push(` Suggested fix: ${finding.suggestedFix}`);
172
+ const fingerprint = formatFindingFingerprintLine(finding);
173
+ if (fingerprint) {
174
+ lines.push(` Fingerprint: ${fingerprint}`);
175
+ }
162
176
  }
163
177
  };
164
178
  appendFindings("Failures", failures, "x");
@@ -0,0 +1,5 @@
1
+ import type { Finding } from "../domain/types.js";
2
+ export declare function buildFindingFingerprint(finding: Finding, rootPath: string): string;
3
+ export declare function withFindingFingerprint(finding: Finding, rootPath: string): Finding;
4
+ export declare function withFindingFingerprints(findings: Finding[], rootPath: string): Finding[];
5
+ export declare function formatFindingFingerprintLine(finding: Finding): string | null;
@@ -0,0 +1,50 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ const fingerprintVersion = 1;
4
+ function isPathEvidenceKey(key) {
5
+ return key.toLowerCase().endsWith("path");
6
+ }
7
+ function normalizePathEvidence(value, rootPath) {
8
+ const windowsPath = /^[a-z]:[\\/]/i.test(value);
9
+ const windowsRoot = /^[a-z]:[\\/]/i.test(rootPath);
10
+ if (windowsPath && windowsRoot) {
11
+ return path.win32.relative(rootPath, value).replaceAll("\\", "/") || ".";
12
+ }
13
+ if (value.startsWith("/") && rootPath.startsWith("/")) {
14
+ return path.posix.relative(rootPath, value) || ".";
15
+ }
16
+ return value.replaceAll("\\", "/");
17
+ }
18
+ function canonicalizeEvidenceValue(key, value, rootPath) {
19
+ return typeof value === "string" && isPathEvidenceKey(key)
20
+ ? normalizePathEvidence(value, rootPath)
21
+ : value;
22
+ }
23
+ function canonicalizeEvidence(evidence, rootPath) {
24
+ return Object.fromEntries(Object.entries(evidence ?? {})
25
+ .sort(([left], [right]) => left.localeCompare(right))
26
+ .map(([key, value]) => [
27
+ key,
28
+ canonicalizeEvidenceValue(key, value, rootPath)
29
+ ]));
30
+ }
31
+ export function buildFindingFingerprint(finding, rootPath) {
32
+ const payload = JSON.stringify({
33
+ version: fingerprintVersion,
34
+ ruleId: finding.id,
35
+ evidence: canonicalizeEvidence(finding.evidence, rootPath)
36
+ });
37
+ return createHash("sha256").update(payload).digest("hex");
38
+ }
39
+ export function withFindingFingerprint(finding, rootPath) {
40
+ return {
41
+ ...finding,
42
+ fingerprint: buildFindingFingerprint(finding, rootPath)
43
+ };
44
+ }
45
+ export function withFindingFingerprints(findings, rootPath) {
46
+ return findings.map((finding) => withFindingFingerprint(finding, rootPath));
47
+ }
48
+ export function formatFindingFingerprintLine(finding) {
49
+ return finding.fingerprint ?? null;
50
+ }
@@ -1,4 +1,5 @@
1
1
  import { formatFindingEvidenceLine } from "./format-finding-evidence.js";
2
+ import { formatFindingFingerprintLine } from "./finding-fingerprint.js";
2
3
  export function buildMarkdownReport(result, options) {
3
4
  const failCount = result.findings.filter((finding) => finding.severity === "fail").length;
4
5
  const warnCount = result.findings.filter((finding) => finding.severity === "warn").length;
@@ -39,6 +40,10 @@ export function buildMarkdownReport(result, options) {
39
40
  lines.push(`- Message: ${finding.message}`);
40
41
  lines.push(`- Impact: ${finding.impact}`);
41
42
  lines.push(`- Suggested fix: ${finding.suggestedFix}`);
43
+ const fingerprint = formatFindingFingerprintLine(finding);
44
+ if (fingerprint) {
45
+ lines.push(`- Fingerprint: \`${fingerprint}\``);
46
+ }
42
47
  const evidence = formatFindingEvidenceLine(finding);
43
48
  if (evidence) {
44
49
  lines.push(`- Evidence: ${evidence}`);
@@ -47,6 +47,13 @@ export function renderSarifReport(result) {
47
47
  }
48
48
  }
49
49
  ],
50
+ ...(finding.fingerprint
51
+ ? {
52
+ partialFingerprints: {
53
+ "codexPluginDoctor/v1": finding.fingerprint
54
+ }
55
+ }
56
+ : {}),
50
57
  ...(finding.evidence ? { properties: { evidence: finding.evidence } } : {})
51
58
  }))
52
59
  }
@@ -1,5 +1,6 @@
1
1
  import { findRuleDefinition } from "../rules/rule-catalog.js";
2
2
  import { formatFindingEvidenceLine } from "./format-finding-evidence.js";
3
+ import { formatFindingFingerprintLine } from "./finding-fingerprint.js";
3
4
  function getCounts(result) {
4
5
  const failCount = result.findings.filter((finding) => finding.severity === "fail").length;
5
6
  const warnCount = result.findings.filter((finding) => finding.severity === "warn").length;
@@ -61,6 +62,10 @@ export function renderTextReport(result, options = {}) {
61
62
  lines.push(` Message: ${finding.message}`);
62
63
  lines.push(` Impact: ${finding.impact}`);
63
64
  lines.push(` Suggested fix: ${finding.suggestedFix}`);
65
+ const fingerprint = formatFindingFingerprintLine(finding);
66
+ if (fingerprint) {
67
+ lines.push(` Fingerprint: ${fingerprint}`);
68
+ }
64
69
  const evidence = formatFindingEvidenceLine(finding);
65
70
  if (evidence) {
66
71
  lines.push(` Evidence: ${evidence}`);
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { discoverPackage } from "../core/discover-package.js";
4
4
  import { readJsonFile } from "../core/read-json-file.js";
5
5
  import { validatePlugin } from "../core/validate-plugin.js";
6
+ import { formatFindingFingerprintLine, withFindingFingerprints } from "../reporting/finding-fingerprint.js";
6
7
  import { formatFindingEvidenceLine } from "../reporting/format-finding-evidence.js";
7
8
  function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
8
9
  return {
@@ -310,35 +311,29 @@ function scoreSecurityAudit(findingCounts) {
310
311
  return Math.max(0, 100 - (findingCounts.fail * 35) - (findingCounts.warn * 10));
311
312
  }
312
313
  export function buildSecurityAuditFromFindings(targetPath, findings) {
313
- const dedupedFindings = dedupeFindings(findings);
314
- const findingCounts = buildFindingCounts(dedupedFindings);
314
+ const rootPath = path.resolve(targetPath);
315
+ const fingerprintedFindings = withFindingFingerprints(dedupeFindings(findings), rootPath);
316
+ const findingCounts = buildFindingCounts(fingerprintedFindings);
315
317
  const status = findingCounts.fail > 0
316
318
  ? "fail"
317
319
  : findingCounts.warn > 0
318
320
  ? "warn"
319
321
  : "pass";
320
322
  return {
321
- targetPath: path.resolve(targetPath),
323
+ targetPath: rootPath,
322
324
  status,
323
325
  score: scoreSecurityAudit(findingCounts),
324
326
  findingCounts,
325
- findings: dedupedFindings
327
+ findings: fingerprintedFindings
326
328
  };
327
329
  }
328
330
  export async function buildSecurityAudit(targetPath) {
329
331
  const discoveredPackage = await discoverPackage(targetPath);
330
332
  if (!discoveredPackage) {
331
333
  const findings = [
332
- 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" })
333
335
  ];
334
- const findingCounts = buildFindingCounts(findings);
335
- return {
336
- targetPath: path.resolve(targetPath),
337
- status: "fail",
338
- score: scoreSecurityAudit(findingCounts),
339
- findingCounts,
340
- findings
341
- };
336
+ return buildSecurityAuditFromFindings(targetPath, findings);
342
337
  }
343
338
  const validationResult = await validatePlugin(discoveredPackage.rootPath);
344
339
  const validationSecurityFindings = validationResult.findings.filter((finding) => finding.id.startsWith("plugin.security."));
@@ -382,6 +377,10 @@ export function renderSecurityScorecard(audit, options = {}) {
382
377
  lines.push(` Message: ${finding.message}`);
383
378
  lines.push(` Impact: ${finding.impact}`);
384
379
  lines.push(` Suggested fix: ${finding.suggestedFix}`);
380
+ const fingerprint = formatFindingFingerprintLine(finding);
381
+ if (fingerprint) {
382
+ lines.push(` Fingerprint: ${fingerprint}`);
383
+ }
385
384
  const evidence = formatFindingEvidenceLine(finding);
386
385
  if (evidence) {
387
386
  lines.push(` Evidence: ${evidence}`);
@@ -2,6 +2,7 @@ import { readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { discoverPackage } from "../core/discover-package.js";
4
4
  import { parseJsonText } from "../core/read-json-file.js";
5
+ import { formatFindingFingerprintLine, withFindingFingerprints } from "../reporting/finding-fingerprint.js";
5
6
  import { buildSecurityAudit } from "./security-audit.js";
6
7
  const lifecycleScripts = new Set([
7
8
  "preinstall",
@@ -10,13 +11,14 @@ const lifecycleScripts = new Set([
10
11
  "prepublish",
11
12
  "prepare"
12
13
  ]);
13
- function buildFinding(severity, id, message, impact, suggestedFix) {
14
+ function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
14
15
  return {
15
16
  id,
16
17
  severity,
17
18
  message,
18
19
  impact,
19
- suggestedFix
20
+ suggestedFix,
21
+ ...(evidence ? { evidence } : {})
20
22
  };
21
23
  }
22
24
  async function fileExists(targetPath) {
@@ -60,10 +62,10 @@ function auditScripts(packageJson) {
60
62
  }
61
63
  scriptsChecked += 1;
62
64
  if (containsRemotePipeInstall(scriptValue)) {
63
- 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 }));
64
66
  continue;
65
67
  }
66
- 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 }));
67
69
  }
68
70
  return {
69
71
  findings,
@@ -72,26 +74,41 @@ function auditScripts(packageJson) {
72
74
  }
73
75
  function dependencySections(packageJson) {
74
76
  return [
75
- packageJson.dependencies,
76
- packageJson.devDependencies,
77
- packageJson.optionalDependencies,
78
- packageJson.peerDependencies
79
- ].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
+ }));
80
87
  }
81
88
  function auditDependencies(packageJson) {
82
89
  const findings = [];
83
90
  let dependenciesChecked = 0;
84
- for (const dependencies of dependencySections(packageJson)) {
91
+ for (const { name: dependencySection, dependencies } of dependencySections(packageJson)) {
85
92
  for (const [dependencyName, versionSpec] of Object.entries(dependencies)) {
86
93
  if (typeof versionSpec !== "string") {
87
94
  continue;
88
95
  }
89
96
  dependenciesChecked += 1;
90
97
  if (versionSpec === "*" || versionSpec.toLowerCase() === "latest") {
91
- 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
+ }));
92
104
  }
93
105
  if (/^(git\+|github:|http:\/\/|https:\/\/)/i.test(versionSpec)) {
94
- 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
+ }));
95
112
  }
96
113
  }
97
114
  }
@@ -103,7 +120,7 @@ function auditDependencies(packageJson) {
103
120
  function dedupeFindings(findings) {
104
121
  const seen = new Set();
105
122
  return findings.filter((finding) => {
106
- const key = `${finding.id}\n${finding.message}`;
123
+ const key = `${finding.id}\n${JSON.stringify(finding.evidence ?? {})}`;
107
124
  if (seen.has(key)) {
108
125
  return false;
109
126
  }
@@ -130,11 +147,11 @@ export async function buildTrustScore(targetPath, options = {}) {
130
147
  : await discoverPackage(rootPath)
131
148
  ? await buildSecurityAudit(rootPath)
132
149
  : null;
133
- const findings = dedupeFindings([
150
+ const findings = withFindingFingerprints(dedupeFindings([
134
151
  ...scriptAudit.findings,
135
152
  ...dependencyAudit.findings,
136
153
  ...(securityAudit?.findings ?? [])
137
- ]);
154
+ ]), rootPath);
138
155
  const fail = findings.filter((finding) => finding.severity === "fail").length;
139
156
  const warn = findings.filter((finding) => finding.severity === "warn").length;
140
157
  const score = scoreFindings(findings);
@@ -189,6 +206,10 @@ export function renderTrustScore(report) {
189
206
  lines.push(` Message: ${finding.message}`);
190
207
  lines.push(` Impact: ${finding.impact}`);
191
208
  lines.push(` Suggested fix: ${finding.suggestedFix}`);
209
+ const fingerprint = formatFindingFingerprintLine(finding);
210
+ if (fingerprint) {
211
+ lines.push(` Fingerprint: ${fingerprint}`);
212
+ }
192
213
  }
193
214
  };
194
215
  appendSection("Failures", report.findings.filter((finding) => finding.severity === "fail"), "x");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.24.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",