codex-plugin-doctor 1.23.0 → 1.25.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. 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. Security findings include structured evidence where available, such as server names, config paths, env keys, arg flags, and escaped paths. Findings also include deterministic SHA-256 fingerprints derived from the rule ID and canonical evidence, with package paths normalized for cross-machine stability. Use `--json` for automation or `--scorecard` for a compact status view.
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.23.0
373
+ - uses: Esquetta/CodexPluginDoctor@v1.25.0
374
374
  with:
375
- version: "1.23.0"
375
+ version: "1.25.0"
376
376
  path: .
377
377
  runtime: "true"
378
378
  policy: codex-publish
@@ -1,23 +1,26 @@
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
- function buildFailure(id, message, impact, suggestedFix) {
6
+ function buildFailure(id, message, impact, suggestedFix, evidence) {
6
7
  return {
7
8
  id,
8
9
  severity: "fail",
9
10
  message,
10
11
  impact,
11
- suggestedFix
12
+ suggestedFix,
13
+ ...(evidence ? { evidence } : {})
12
14
  };
13
15
  }
14
- function buildWarning(id, message, impact, suggestedFix) {
16
+ function buildWarning(id, message, impact, suggestedFix, evidence) {
15
17
  return {
16
18
  id,
17
19
  severity: "warn",
18
20
  message,
19
21
  impact,
20
- suggestedFix
22
+ suggestedFix,
23
+ ...(evidence ? { evidence } : {})
21
24
  };
22
25
  }
23
26
  async function directoryExists(targetPath) {
@@ -66,6 +69,9 @@ function isPathWithinRoot(rootPath, candidatePath) {
66
69
  return (relativePath === "" ||
67
70
  (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
68
71
  }
72
+ function relativePackagePath(rootPath, targetPath) {
73
+ return path.relative(rootPath, targetPath).replace(/\\/g, "/") || ".";
74
+ }
69
75
  function isSensitiveKey(key) {
70
76
  return /(token|secret|password|api[_-]?key|private[_-]?key)/i.test(key);
71
77
  }
@@ -211,7 +217,12 @@ async function validateSkillsDirectory(discoveredPackage) {
211
217
  const skillsPath = path.resolve(rootPath, manifest.skills);
212
218
  if (!isPathWithinRoot(rootPath, skillsPath)) {
213
219
  return [
214
- buildFailure("plugin.security.path_traversal", "The plugin manifest points the skills path outside the package root.", "Paths that escape the package root make the package harder to audit and can expose unintended files during packaging or validation.", "Keep the `skills` path inside the plugin root.")
220
+ buildFailure("plugin.security.path_traversal", "The plugin manifest points the skills path outside the package root.", "Paths that escape the package root make the package harder to audit and can expose unintended files during packaging or validation.", "Keep the `skills` path inside the plugin root.", {
221
+ manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
222
+ field: "skills",
223
+ configuredPath: manifest.skills,
224
+ resolvedPath: skillsPath
225
+ })
215
226
  ];
216
227
  }
217
228
  const exists = await directoryExists(skillsPath);
@@ -277,7 +288,12 @@ async function validateMcpConfig(discoveredPackage) {
277
288
  const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers);
278
289
  if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
279
290
  return [
280
- buildFailure("plugin.security.path_traversal", "The plugin manifest points the MCP config path outside the package root.", "Paths that escape the package root make the package harder to audit and can expose unintended files during packaging or validation.", "Keep the `mcpServers` path inside the plugin root.")
291
+ buildFailure("plugin.security.path_traversal", "The plugin manifest points the MCP config path outside the package root.", "Paths that escape the package root make the package harder to audit and can expose unintended files during packaging or validation.", "Keep the `mcpServers` path inside the plugin root.", {
292
+ manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
293
+ field: "mcpServers",
294
+ configuredPath: manifest.mcpServers,
295
+ resolvedPath: mcpConfigPath
296
+ })
281
297
  ];
282
298
  }
283
299
  const mcpConfigExists = await fileExists(mcpConfigPath);
@@ -323,7 +339,12 @@ async function validateMcpConfig(discoveredPackage) {
323
339
  if (isSensitiveKey(envKey) &&
324
340
  typeof envValue === "string" &&
325
341
  looksLikeLiteralSecret(envValue)) {
326
- findings.push(buildFailure("plugin.security.hard_coded_secret", `The MCP server \`${serverName}\` contains a hard-coded secret-like env value for \`${envKey}\`.`, "Hard-coded credentials inside plugin bundles increase leakage risk and make secure rotation difficult.", `Replace the literal value for \`${envKey}\` with an environment reference or injected secret outside the package.`));
342
+ findings.push(buildFailure("plugin.security.hard_coded_secret", `The MCP server \`${serverName}\` contains a hard-coded secret-like env value for \`${envKey}\`.`, "Hard-coded credentials inside plugin bundles increase leakage risk and make secure rotation difficult.", `Replace the literal value for \`${envKey}\` with an environment reference or injected secret outside the package.`, {
343
+ serverName,
344
+ configPath: relativePackagePath(rootPath, mcpConfigPath),
345
+ envKey,
346
+ envValue: "[REDACTED]"
347
+ }));
327
348
  }
328
349
  }
329
350
  }
@@ -338,7 +359,7 @@ export async function validatePlugin(targetPath, options = {}) {
338
359
  targetPath: rootPath,
339
360
  status: "fail",
340
361
  exitCode: 1,
341
- findings: [await buildMissingManifestFailure(rootPath)]
362
+ findings: withFindingFingerprints([await buildMissingManifestFailure(rootPath)], rootPath)
342
363
  };
343
364
  }
344
365
  const runtimeResult = options.runtime
@@ -353,8 +374,9 @@ export async function validatePlugin(targetPath, options = {}) {
353
374
  ...(await validateMcpConfig(discoveredPackage)),
354
375
  ...(runtimeResult ? runtimeResult.findings : [])
355
376
  ];
356
- const hasFailures = findings.some((finding) => finding.severity === "fail");
357
- const hasWarnings = findings.some((finding) => finding.severity === "warn");
377
+ const fingerprintedFindings = withFindingFingerprints(findings, discoveredPackage.rootPath);
378
+ const hasFailures = fingerprintedFindings.some((finding) => finding.severity === "fail");
379
+ const hasWarnings = fingerprintedFindings.some((finding) => finding.severity === "warn");
358
380
  if (!hasFailures && !hasWarnings) {
359
381
  return {
360
382
  targetPath: discoveredPackage.rootPath,
@@ -368,7 +390,7 @@ export async function validatePlugin(targetPath, options = {}) {
368
390
  targetPath: discoveredPackage.rootPath,
369
391
  status: hasFailures ? "fail" : "warn",
370
392
  exitCode: hasFailures ? 1 : 0,
371
- findings,
393
+ findings: fingerprintedFindings,
372
394
  ...(runtimeResult ? { runtimeScorecard: runtimeResult.scorecard } : {})
373
395
  };
374
396
  }
@@ -1,10 +1,14 @@
1
1
  export type FindingSeverity = "warn" | "fail";
2
+ export type FindingEvidenceValue = string | number | boolean | null;
3
+ export type FindingEvidence = Record<string, FindingEvidenceValue>;
2
4
  export interface Finding {
3
5
  id: string;
4
6
  severity: FindingSeverity;
5
7
  message: string;
6
8
  impact: string;
7
9
  suggestedFix: string;
10
+ evidence?: FindingEvidence;
11
+ fingerprint?: string;
8
12
  }
9
13
  export interface CheckResult {
10
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,6 +2,7 @@ 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
7
  function buildFinding(severity, id, message, impact, suggestedFix) {
7
8
  return {
@@ -110,14 +111,15 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}) {
110
111
  const security = buildSecurityAuditFromFindings(rootPath, mcpConfigPath && parsedConfig !== null
111
112
  ? auditMcpServerConfig(rootPath, parsedConfig)
112
113
  : []);
113
- const status = mergeReportStatus(staticFindings, security);
114
+ const fingerprintedStaticFindings = withFindingFingerprints(staticFindings, rootPath);
115
+ const status = mergeReportStatus(fingerprintedStaticFindings, security);
114
116
  return {
115
117
  targetPath: rootPath,
116
118
  status,
117
119
  exitCode: status === "fail" ? 1 : 0,
118
120
  mcpConfigPath,
119
121
  serverCount,
120
- findings: [...staticFindings, ...security.findings],
122
+ findings: [...fingerprintedStaticFindings, ...security.findings],
121
123
  security,
122
124
  compatibility
123
125
  };
@@ -159,6 +161,10 @@ export function renderGenericMcpDoctor(report) {
159
161
  lines.push(` Message: ${finding.message}`);
160
162
  lines.push(` Impact: ${finding.impact}`);
161
163
  lines.push(` Suggested fix: ${finding.suggestedFix}`);
164
+ const fingerprint = formatFindingFingerprintLine(finding);
165
+ if (fingerprint) {
166
+ lines.push(` Fingerprint: ${fingerprint}`);
167
+ }
162
168
  }
163
169
  };
164
170
  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
+ }
@@ -0,0 +1,3 @@
1
+ import type { Finding, FindingEvidence } from "../domain/types.js";
2
+ export declare function formatFindingEvidence(evidence: FindingEvidence): string;
3
+ export declare function formatFindingEvidenceLine(finding: Finding): string | null;
@@ -0,0 +1,8 @@
1
+ export function formatFindingEvidence(evidence) {
2
+ return Object.entries(evidence)
3
+ .map(([key, value]) => `${key}=${String(value)}`)
4
+ .join(", ");
5
+ }
6
+ export function formatFindingEvidenceLine(finding) {
7
+ return finding.evidence ? formatFindingEvidence(finding.evidence) : null;
8
+ }
@@ -1,3 +1,5 @@
1
+ import { formatFindingEvidenceLine } from "./format-finding-evidence.js";
2
+ import { formatFindingFingerprintLine } from "./finding-fingerprint.js";
1
3
  export function buildMarkdownReport(result, options) {
2
4
  const failCount = result.findings.filter((finding) => finding.severity === "fail").length;
3
5
  const warnCount = result.findings.filter((finding) => finding.severity === "warn").length;
@@ -38,6 +40,14 @@ export function buildMarkdownReport(result, options) {
38
40
  lines.push(`- Message: ${finding.message}`);
39
41
  lines.push(`- Impact: ${finding.impact}`);
40
42
  lines.push(`- Suggested fix: ${finding.suggestedFix}`);
43
+ const fingerprint = formatFindingFingerprintLine(finding);
44
+ if (fingerprint) {
45
+ lines.push(`- Fingerprint: \`${fingerprint}\``);
46
+ }
47
+ const evidence = formatFindingEvidenceLine(finding);
48
+ if (evidence) {
49
+ lines.push(`- Evidence: ${evidence}`);
50
+ }
41
51
  lines.push("");
42
52
  }
43
53
  return lines.join("\n");
@@ -46,7 +46,15 @@ export function renderSarifReport(result) {
46
46
  }
47
47
  }
48
48
  }
49
- ]
49
+ ],
50
+ ...(finding.fingerprint
51
+ ? {
52
+ partialFingerprints: {
53
+ "codexPluginDoctor/v1": finding.fingerprint
54
+ }
55
+ }
56
+ : {}),
57
+ ...(finding.evidence ? { properties: { evidence: finding.evidence } } : {})
50
58
  }))
51
59
  }
52
60
  ]
@@ -1,4 +1,6 @@
1
1
  import { findRuleDefinition } from "../rules/rule-catalog.js";
2
+ import { formatFindingEvidenceLine } from "./format-finding-evidence.js";
3
+ import { formatFindingFingerprintLine } from "./finding-fingerprint.js";
2
4
  function getCounts(result) {
3
5
  const failCount = result.findings.filter((finding) => finding.severity === "fail").length;
4
6
  const warnCount = result.findings.filter((finding) => finding.severity === "warn").length;
@@ -60,6 +62,14 @@ export function renderTextReport(result, options = {}) {
60
62
  lines.push(` Message: ${finding.message}`);
61
63
  lines.push(` Impact: ${finding.impact}`);
62
64
  lines.push(` Suggested fix: ${finding.suggestedFix}`);
65
+ const fingerprint = formatFindingFingerprintLine(finding);
66
+ if (fingerprint) {
67
+ lines.push(` Fingerprint: ${fingerprint}`);
68
+ }
69
+ const evidence = formatFindingEvidenceLine(finding);
70
+ if (evidence) {
71
+ lines.push(` Evidence: ${evidence}`);
72
+ }
63
73
  if (explain) {
64
74
  const rule = findRuleDefinition(finding.id);
65
75
  if (rule) {
@@ -10,7 +10,9 @@ export interface SecurityAudit {
10
10
  };
11
11
  findings: Finding[];
12
12
  }
13
- export declare function auditMcpServerConfig(rootPath: string, parsedConfig: unknown): Finding[];
13
+ export declare function auditMcpServerConfig(rootPath: string, parsedConfig: unknown, options?: {
14
+ configPath?: string;
15
+ }): Finding[];
14
16
  export declare function buildSecurityAuditFromFindings(targetPath: string, findings: Finding[]): SecurityAudit;
15
17
  export declare function buildSecurityAudit(targetPath: string): Promise<SecurityAudit>;
16
18
  export declare function renderSecurityAuditJson(audit: SecurityAudit): string;
@@ -3,13 +3,16 @@ 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
- function buildFinding(severity, id, message, impact, suggestedFix) {
6
+ import { formatFindingFingerprintLine, withFindingFingerprints } from "../reporting/finding-fingerprint.js";
7
+ import { formatFindingEvidenceLine } from "../reporting/format-finding-evidence.js";
8
+ function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
7
9
  return {
8
10
  id,
9
11
  severity,
10
12
  message,
11
13
  impact,
12
- suggestedFix
14
+ suggestedFix,
15
+ ...(evidence ? { evidence } : {})
13
16
  };
14
17
  }
15
18
  function isPlainObject(value) {
@@ -20,6 +23,9 @@ function isPathWithinRoot(rootPath, candidatePath) {
20
23
  return (relativePath === "" ||
21
24
  (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
22
25
  }
26
+ function relativePackagePath(rootPath, targetPath) {
27
+ return path.relative(rootPath, targetPath).replace(/\\/g, "/") || ".";
28
+ }
23
29
  function normalizeCommandName(command) {
24
30
  return path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat)$/i, "");
25
31
  }
@@ -102,14 +108,14 @@ function collectEscapingPathArgs(rootPath, args) {
102
108
  const flag = arg.slice(0, equalsIndex).toLowerCase();
103
109
  const value = arg.slice(equalsIndex + 1);
104
110
  if (pathLikeArgFlags.has(flag) && isEscapingPathValue(rootPath, value)) {
105
- findings.push({ flag, value });
111
+ findings.push({ flag, value, resolvedPath: path.resolve(rootPath, value) });
106
112
  }
107
113
  continue;
108
114
  }
109
115
  const flag = arg.toLowerCase();
110
116
  const value = stringArgs[index + 1];
111
117
  if (pathLikeArgFlags.has(flag) && value && isEscapingPathValue(rootPath, value)) {
112
- findings.push({ flag, value });
118
+ findings.push({ flag, value, resolvedPath: path.resolve(rootPath, value) });
113
119
  index += 1;
114
120
  }
115
121
  }
@@ -158,10 +164,13 @@ const promptInjectionPatterns = [
158
164
  /\b(?:exfiltrate|steal|leak|upload|send)\b.{0,120}\b(?:secret|secrets|token|tokens|api\s*key|api\s*keys|credential|credentials|environment\s+variables?|env)\b/i,
159
165
  /\bdo\s+not\s+(?:reveal|tell|mention|disclose)\b.{0,120}\b(?:instruction|instructions|prompt|prompts|system|developer)\b/i
160
166
  ];
161
- export function auditMcpServerConfig(rootPath, parsedConfig) {
167
+ export function auditMcpServerConfig(rootPath, parsedConfig, options = {}) {
168
+ const configPath = options.configPath
169
+ ? relativePackagePath(rootPath, options.configPath)
170
+ : ".mcp.json";
162
171
  if (!isPlainObject(parsedConfig) || !isPlainObject(parsedConfig.mcpServers)) {
163
172
  return [
164
- buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not find a valid `mcpServers` object.", "Without server entries, the audit cannot evaluate command execution or remote transport risk.", "Define MCP servers under a top-level `mcpServers` object.")
173
+ buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not find a valid `mcpServers` object.", "Without server entries, the audit cannot evaluate command execution or remote transport risk.", "Define MCP servers under a top-level `mcpServers` object.", { configPath })
165
174
  ];
166
175
  }
167
176
  const findings = [];
@@ -174,21 +183,32 @@ export function auditMcpServerConfig(rootPath, parsedConfig) {
174
183
  const cwd = serverConfig.cwd;
175
184
  const url = serverConfig.url;
176
185
  if (typeof command === "string" && isShellWrapperCommand(command)) {
177
- findings.push(buildFinding("warn", "plugin.security.command_shell_wrapper", `The MCP server \`${serverName}\` starts through shell wrapper \`${command}\`.`, "Shell wrappers expand quoting, pipes, aliases, and platform-specific behavior, which makes the real execution path harder to audit.", "Prefer launching the concrete executable directly with explicit args."));
186
+ findings.push(buildFinding("warn", "plugin.security.command_shell_wrapper", `The MCP server \`${serverName}\` starts through shell wrapper \`${command}\`.`, "Shell wrappers expand quoting, pipes, aliases, and platform-specific behavior, which makes the real execution path harder to audit.", "Prefer launching the concrete executable directly with explicit args.", { serverName, configPath, command }));
178
187
  }
179
188
  if (containsEncodedCommandFlag(args)) {
180
- findings.push(buildFinding("fail", "plugin.security.encoded_command", `The MCP server \`${serverName}\` uses an encoded shell command flag.`, "Encoded command payloads hide the executed script from reviewers and increase supply-chain risk.", "Replace encoded shell payloads with a checked-in script or direct executable plus readable args."));
189
+ findings.push(buildFinding("fail", "plugin.security.encoded_command", `The MCP server \`${serverName}\` uses an encoded shell command flag.`, "Encoded command payloads hide the executed script from reviewers and increase supply-chain risk.", "Replace encoded shell payloads with a checked-in script or direct executable plus readable args.", { serverName, configPath, command: typeof command === "string" ? command : null }));
181
190
  }
182
191
  if (containsPipeInstaller(args)) {
183
- findings.push(buildFinding("fail", "plugin.security.remote_pipe_install", `The MCP server \`${serverName}\` appears to pipe remote content into a shell.`, "Download-and-execute install patterns can run unreviewed remote code during plugin startup.", "Pin dependencies through the package manager or check in a reviewed setup script instead of piping remote content to a shell."));
192
+ findings.push(buildFinding("fail", "plugin.security.remote_pipe_install", `The MCP server \`${serverName}\` appears to pipe remote content into a shell.`, "Download-and-execute install patterns can run unreviewed remote code during plugin startup.", "Pin dependencies through the package manager or check in a reviewed setup script instead of piping remote content to a shell.", { serverName, configPath, command: typeof command === "string" ? command : null }));
184
193
  }
185
194
  for (const riskyArg of collectEscapingPathArgs(rootPath, args)) {
186
- findings.push(buildFinding("fail", "plugin.security.path_traversal_risk", `The MCP server \`${serverName}\` passes \`${riskyArg.value}\` to path-like arg \`${riskyArg.flag}\`, which escapes the plugin root.`, "Path-like runtime arguments that point outside the package can make startup depend on unreviewed local files or load code outside the reviewed tarball.", "Keep runtime file arguments inside the plugin package root, or package the referenced file with the plugin."));
195
+ findings.push(buildFinding("fail", "plugin.security.path_traversal_risk", `The MCP server \`${serverName}\` passes \`${riskyArg.value}\` to path-like arg \`${riskyArg.flag}\`, which escapes the plugin root.`, "Path-like runtime arguments that point outside the package can make startup depend on unreviewed local files or load code outside the reviewed tarball.", "Keep runtime file arguments inside the plugin package root, or package the referenced file with the plugin.", {
196
+ serverName,
197
+ configPath,
198
+ argFlag: riskyArg.flag,
199
+ argValue: riskyArg.value,
200
+ resolvedPath: riskyArg.resolvedPath
201
+ }));
187
202
  }
188
203
  if (typeof cwd === "string") {
189
204
  const cwdPath = path.resolve(rootPath, cwd);
190
205
  if (!isPathWithinRoot(rootPath, cwdPath)) {
191
- findings.push(buildFinding("fail", "plugin.security.cwd_outside_root", `The MCP server \`${serverName}\` sets cwd outside the plugin root.`, "A working directory outside the package root can make server startup depend on unreviewed local files.", "Keep MCP server `cwd` inside the plugin package root or remove it."));
206
+ findings.push(buildFinding("fail", "plugin.security.cwd_outside_root", `The MCP server \`${serverName}\` sets cwd outside the plugin root.`, "A working directory outside the package root can make server startup depend on unreviewed local files.", "Keep MCP server `cwd` inside the plugin package root or remove it.", {
207
+ serverName,
208
+ configPath,
209
+ cwd,
210
+ resolvedPath: cwdPath
211
+ }));
192
212
  }
193
213
  }
194
214
  if (isPlainObject(serverConfig.env)) {
@@ -196,11 +216,16 @@ export function auditMcpServerConfig(rootPath, parsedConfig) {
196
216
  if (!isDangerousEnvUsage(rootPath, envKey, envValue)) {
197
217
  continue;
198
218
  }
199
- findings.push(buildFinding("fail", "plugin.security.dangerous_env_usage", `The MCP server \`${serverName}\` sets dangerous code-loading env variable \`${envKey}\`.`, "Environment variables that alter module lookup, preload native libraries, or inject runtime imports can execute code outside the reviewed package.", "Remove code-loading environment overrides, or keep referenced modules and preload files inside the reviewed plugin package."));
219
+ findings.push(buildFinding("fail", "plugin.security.dangerous_env_usage", `The MCP server \`${serverName}\` sets dangerous code-loading env variable \`${envKey}\`.`, "Environment variables that alter module lookup, preload native libraries, or inject runtime imports can execute code outside the reviewed package.", "Remove code-loading environment overrides, or keep referenced modules and preload files inside the reviewed plugin package.", {
220
+ serverName,
221
+ configPath,
222
+ envKey,
223
+ envValue: typeof envValue === "string" ? envValue : null
224
+ }));
200
225
  }
201
226
  }
202
227
  if (typeof url === "string" && /^http:\/\//i.test(url)) {
203
- findings.push(buildFinding("warn", "plugin.security.insecure_http_url", `The MCP server \`${serverName}\` uses an insecure HTTP URL.`, "Plain HTTP transports can expose MCP traffic on non-local networks and make endpoint identity harder to verify.", "Use HTTPS for remote MCP servers; reserve HTTP for explicit localhost development endpoints."));
228
+ findings.push(buildFinding("warn", "plugin.security.insecure_http_url", `The MCP server \`${serverName}\` uses an insecure HTTP URL.`, "Plain HTTP transports can expose MCP traffic on non-local networks and make endpoint identity harder to verify.", "Use HTTPS for remote MCP servers; reserve HTTP for explicit localhost development endpoints.", { serverName, configPath, url }));
204
229
  }
205
230
  }
206
231
  return findings;
@@ -238,7 +263,7 @@ async function auditPromptPoisoningSurface(rootPath) {
238
263
  continue;
239
264
  }
240
265
  const relativeFilePath = path.relative(rootPath, filePath).replace(/\\/g, "/");
241
- findings.push(buildFinding("fail", "plugin.security.prompt_injection_text", `The packaged text file \`${relativeFilePath}\` contains prompt-injection or secret-exfiltration style instructions.`, "Malicious or poisoned tool, prompt, resource, or skill text can instruct an agent to ignore higher-priority instructions or leak secrets when loaded into context.", "Remove hidden override or exfiltration instructions, then keep tool/prompt/resource descriptions scoped to the legitimate user-facing behavior."));
266
+ findings.push(buildFinding("fail", "plugin.security.prompt_injection_text", `The packaged text file \`${relativeFilePath}\` contains prompt-injection or secret-exfiltration style instructions.`, "Malicious or poisoned tool, prompt, resource, or skill text can instruct an agent to ignore higher-priority instructions or leak secrets when loaded into context.", "Remove hidden override or exfiltration instructions, then keep tool/prompt/resource descriptions scoped to the legitimate user-facing behavior.", { filePath: relativeFilePath }));
242
267
  }
243
268
  return findings;
244
269
  }
@@ -257,10 +282,10 @@ async function auditMcpCommandSurface(discoveredPackage) {
257
282
  }
258
283
  catch {
259
284
  return [
260
- buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not parse the referenced MCP config.", "Unreadable MCP configuration prevents review of server commands, URLs, and working directories before install.", "Fix the `.mcp.json` syntax, then rerun `codex-plugin-doctor security <path>`.")
285
+ buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not parse the referenced MCP config.", "Unreadable MCP configuration prevents review of server commands, URLs, and working directories before install.", "Fix the `.mcp.json` syntax, then rerun `codex-plugin-doctor security <path>`.", { configPath: relativePackagePath(rootPath, mcpConfigPath) })
261
286
  ];
262
287
  }
263
- return auditMcpServerConfig(rootPath, parsedConfig);
288
+ return auditMcpServerConfig(rootPath, parsedConfig, { configPath: mcpConfigPath });
264
289
  }
265
290
  function dedupeFindings(findings) {
266
291
  const seen = new Set();
@@ -286,35 +311,29 @@ function scoreSecurityAudit(findingCounts) {
286
311
  return Math.max(0, 100 - (findingCounts.fail * 35) - (findingCounts.warn * 10));
287
312
  }
288
313
  export function buildSecurityAuditFromFindings(targetPath, findings) {
289
- const dedupedFindings = dedupeFindings(findings);
290
- const findingCounts = buildFindingCounts(dedupedFindings);
314
+ const rootPath = path.resolve(targetPath);
315
+ const fingerprintedFindings = withFindingFingerprints(dedupeFindings(findings), rootPath);
316
+ const findingCounts = buildFindingCounts(fingerprintedFindings);
291
317
  const status = findingCounts.fail > 0
292
318
  ? "fail"
293
319
  : findingCounts.warn > 0
294
320
  ? "warn"
295
321
  : "pass";
296
322
  return {
297
- targetPath: path.resolve(targetPath),
323
+ targetPath: rootPath,
298
324
  status,
299
325
  score: scoreSecurityAudit(findingCounts),
300
326
  findingCounts,
301
- findings: dedupedFindings
327
+ findings: fingerprintedFindings
302
328
  };
303
329
  }
304
330
  export async function buildSecurityAudit(targetPath) {
305
331
  const discoveredPackage = await discoverPackage(targetPath);
306
332
  if (!discoveredPackage) {
307
333
  const findings = [
308
- 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.", { targetPath: path.resolve(targetPath) })
309
335
  ];
310
- const findingCounts = buildFindingCounts(findings);
311
- return {
312
- targetPath: path.resolve(targetPath),
313
- status: "fail",
314
- score: scoreSecurityAudit(findingCounts),
315
- findingCounts,
316
- findings
317
- };
336
+ return buildSecurityAuditFromFindings(targetPath, findings);
318
337
  }
319
338
  const validationResult = await validatePlugin(discoveredPackage.rootPath);
320
339
  const validationSecurityFindings = validationResult.findings.filter((finding) => finding.id.startsWith("plugin.security."));
@@ -358,6 +377,14 @@ export function renderSecurityScorecard(audit, options = {}) {
358
377
  lines.push(` Message: ${finding.message}`);
359
378
  lines.push(` Impact: ${finding.impact}`);
360
379
  lines.push(` Suggested fix: ${finding.suggestedFix}`);
380
+ const fingerprint = formatFindingFingerprintLine(finding);
381
+ if (fingerprint) {
382
+ lines.push(` Fingerprint: ${fingerprint}`);
383
+ }
384
+ const evidence = formatFindingEvidenceLine(finding);
385
+ if (evidence) {
386
+ lines.push(` Evidence: ${evidence}`);
387
+ }
361
388
  }
362
389
  };
363
390
  appendSection("Failures", audit.findings.filter((finding) => finding.severity === "fail"), "x");
@@ -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",
@@ -130,11 +131,11 @@ export async function buildTrustScore(targetPath, options = {}) {
130
131
  : await discoverPackage(rootPath)
131
132
  ? await buildSecurityAudit(rootPath)
132
133
  : null;
133
- const findings = dedupeFindings([
134
+ const findings = withFindingFingerprints(dedupeFindings([
134
135
  ...scriptAudit.findings,
135
136
  ...dependencyAudit.findings,
136
137
  ...(securityAudit?.findings ?? [])
137
- ]);
138
+ ]), rootPath);
138
139
  const fail = findings.filter((finding) => finding.severity === "fail").length;
139
140
  const warn = findings.filter((finding) => finding.severity === "warn").length;
140
141
  const score = scoreFindings(findings);
@@ -189,6 +190,10 @@ export function renderTrustScore(report) {
189
190
  lines.push(` Message: ${finding.message}`);
190
191
  lines.push(` Impact: ${finding.impact}`);
191
192
  lines.push(` Suggested fix: ${finding.suggestedFix}`);
193
+ const fingerprint = formatFindingFingerprintLine(finding);
194
+ if (fingerprint) {
195
+ lines.push(` Fingerprint: ${fingerprint}`);
196
+ }
192
197
  }
193
198
  };
194
199
  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.23.0",
3
+ "version": "1.25.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",