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 +3 -3
- package/dist/core/runtime-probe.js +78 -35
- package/dist/core/validate-plugin.js +65 -21
- package/dist/domain/types.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/generic-mcp-doctor.js +28 -14
- package/dist/reporting/finding-fingerprint.d.ts +5 -0
- package/dist/reporting/finding-fingerprint.js +50 -0
- package/dist/reporting/render-markdown-report.js +5 -0
- package/dist/reporting/render-sarif-report.js +7 -0
- package/dist/reporting/render-text-report.js +5 -0
- package/dist/security/security-audit.js +12 -13
- package/dist/security/trust-score.js +36 -15
- package/package.json +1 -1
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.
|
|
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.
|
|
373
|
+
- uses: Esquetta/CodexPluginDoctor@v1.26.0
|
|
374
374
|
with:
|
|
375
|
-
version: "1.
|
|
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
|
|
519
|
-
return isPlainObject(content)
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
567
|
+
const contentLength = message.result.contents.reduce((maximum, content) => {
|
|
533
568
|
if (!isPlainObject(content)) {
|
|
534
|
-
return
|
|
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
|
|
539
|
-
|
|
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
|
|
586
|
+
const contentLength = message.result.messages.reduce((maximum, promptMessage) => {
|
|
553
587
|
if (!isPlainObject(promptMessage) || !isPlainObject(promptMessage.content)) {
|
|
554
|
-
return
|
|
588
|
+
return maximum;
|
|
555
589
|
}
|
|
556
|
-
return getContentBlockLength(promptMessage.content)
|
|
557
|
-
});
|
|
558
|
-
if (
|
|
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: [
|
|
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
|
|
377
|
-
const
|
|
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
|
}
|
package/dist/domain/types.d.ts
CHANGED
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(
|
|
33
|
-
if (!
|
|
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 \`${
|
|
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 \`${
|
|
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 \`${
|
|
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 \`${
|
|
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
|
|
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: [...
|
|
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
|
|
314
|
-
const
|
|
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:
|
|
323
|
+
targetPath: rootPath,
|
|
322
324
|
status,
|
|
323
325
|
score: scoreSecurityAudit(findingCounts),
|
|
324
326
|
findingCounts,
|
|
325
|
-
findings:
|
|
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.", {
|
|
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
|
-
|
|
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
|
-
]
|
|
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.
|
|
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