codex-plugin-doctor 1.26.0 → 1.27.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
@@ -336,16 +336,27 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
336
336
 
337
337
  `fix --dry-run` renders safe automatic fix plans without changing files. `fix --interactive --backup` shows the same numbered plan, then applies everything after `yes` or only selected action numbers such as `1,3`. `fix --apply --backup` applies supported safe fixes, such as manifest defaults and missing skills directories, after creating backups.
338
338
 
339
- Optional local policy file:
340
-
341
- ```json
342
- {
343
- "ignoreRules": ["plugin.heuristic.description.too_long"],
344
- "failOnWarnings": true
345
- }
346
- ```
347
-
348
- Run these when you want Codex Plugin Doctor to find plugins from the local Codex installation:
339
+ Optional local policy file:
340
+
341
+ ```json
342
+ {
343
+ "ignoreRules": ["plugin.heuristic.description.too_long"],
344
+ "suppressions": [
345
+ {
346
+ "fingerprint": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
347
+ "reason": "Accepted until the upstream package layout is corrected.",
348
+ "expiresAt": "2026-07-31"
349
+ }
350
+ ],
351
+ "failOnWarnings": true
352
+ }
353
+ ```
354
+
355
+ `ignoreRules` disables every finding with a matching rule ID. Prefer `suppressions` when accepting one specific finding instance: each record requires the exact 64-character lowercase SHA-256 fingerprint shown in Doctor reports, a non-empty review reason, and a real `YYYY-MM-DD` expiration date.
356
+
357
+ Active suppressions remove only the matching finding from the active result and preserve it under `suppressedFindings` for audit output. Expired or invalid records do not suppress findings; Doctor emits `suppression.expired` or `suppression.invalid` warnings instead. Duplicate active records use the first valid match.
358
+
359
+ Run these when you want Codex Plugin Doctor to find plugins from the local Codex installation:
349
360
 
350
361
  ```bash
351
362
  codex-plugin-doctor list --installed
@@ -370,9 +381,9 @@ jobs:
370
381
  runs-on: ubuntu-latest
371
382
  steps:
372
383
  - uses: actions/checkout@v5
373
- - uses: Esquetta/CodexPluginDoctor@v1.26.0
384
+ - uses: Esquetta/CodexPluginDoctor@v1.27.0
374
385
  with:
375
- version: "1.26.0"
386
+ version: "1.27.0"
376
387
  path: .
377
388
  runtime: "true"
378
389
  policy: codex-publish
@@ -2,6 +2,9 @@ import type { CheckResult } from "../domain/types.js";
2
2
  export interface DoctorConfig {
3
3
  ignoreRules: string[];
4
4
  failOnWarnings: boolean;
5
+ suppressions: unknown[];
5
6
  }
6
7
  export declare function loadDoctorConfig(targetPath: string, explicitConfigPath?: string | null): Promise<DoctorConfig>;
7
- export declare function applyDoctorConfig(result: CheckResult, config: DoctorConfig): CheckResult;
8
+ export declare function applyDoctorConfig(result: CheckResult, config: DoctorConfig, options?: {
9
+ now?: Date;
10
+ }): CheckResult;
@@ -1,8 +1,10 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { withFindingFingerprint } from "../reporting/finding-fingerprint.js";
3
4
  const defaultConfig = {
4
5
  ignoreRules: [],
5
- failOnWarnings: false
6
+ failOnWarnings: false,
7
+ suppressions: []
6
8
  };
7
9
  function isStringArray(value) {
8
10
  return Array.isArray(value) && value.every((item) => typeof item === "string");
@@ -19,15 +21,69 @@ export async function loadDoctorConfig(targetPath, explicitConfigPath) {
19
21
  : defaultConfig.ignoreRules,
20
22
  failOnWarnings: typeof parsed.failOnWarnings === "boolean"
21
23
  ? parsed.failOnWarnings
22
- : defaultConfig.failOnWarnings
24
+ : defaultConfig.failOnWarnings,
25
+ suppressions: Array.isArray(parsed.suppressions)
26
+ ? parsed.suppressions
27
+ : defaultConfig.suppressions
23
28
  };
24
29
  }
25
30
  catch {
26
31
  return defaultConfig;
27
32
  }
28
33
  }
29
- export function applyDoctorConfig(result, config) {
30
- const findings = result.findings.filter((finding) => !config.ignoreRules.includes(finding.id));
34
+ export function applyDoctorConfig(result, config, options = {}) {
35
+ const configuredFindings = result.findings.filter((finding) => !config.ignoreRules.includes(finding.id));
36
+ const now = options.now ?? new Date();
37
+ const activeSuppressions = new Map();
38
+ const governanceWarnings = [];
39
+ const summary = {
40
+ applied: 0,
41
+ expired: 0,
42
+ invalid: 0
43
+ };
44
+ for (const [index, suppression] of config.suppressions.entries()) {
45
+ const validation = validateSuppression(suppression);
46
+ if (!validation.valid) {
47
+ summary.invalid += 1;
48
+ governanceWarnings.push(buildGovernanceWarning(result.targetPath, "suppression.invalid", "A targeted suppression record is invalid and was not applied.", "Invalid suppression records can create false confidence because they do not match or expire as intended.", "Fix or remove the invalid suppression record in `.codex-doctor.json`.", {
49
+ suppressionIndex: index,
50
+ field: validation.field
51
+ }));
52
+ continue;
53
+ }
54
+ if (isExpired(validation.expiresAt, now)) {
55
+ summary.expired += 1;
56
+ governanceWarnings.push(buildGovernanceWarning(result.targetPath, "suppression.expired", "A targeted suppression has expired and was not applied.", "Expired risk acceptance must be reviewed again before the finding can be suppressed.", "Remove the expired suppression or replace it with a newly reviewed expiration date.", {
57
+ suppressionIndex: index,
58
+ fingerprint: validation.fingerprint,
59
+ expiresAt: validation.expiresAt
60
+ }));
61
+ continue;
62
+ }
63
+ if (!activeSuppressions.has(validation.fingerprint)) {
64
+ activeSuppressions.set(validation.fingerprint, {
65
+ reason: validation.reason,
66
+ expiresAt: validation.expiresAt
67
+ });
68
+ }
69
+ }
70
+ const findings = [];
71
+ const suppressedFindings = [];
72
+ for (const finding of configuredFindings) {
73
+ const suppression = finding.fingerprint
74
+ ? activeSuppressions.get(finding.fingerprint)
75
+ : undefined;
76
+ if (!suppression) {
77
+ findings.push(finding);
78
+ continue;
79
+ }
80
+ summary.applied += 1;
81
+ suppressedFindings.push({
82
+ ...finding,
83
+ suppression
84
+ });
85
+ }
86
+ findings.push(...governanceWarnings);
31
87
  const hasFailures = findings.some((finding) => finding.severity === "fail");
32
88
  const hasWarnings = findings.some((finding) => finding.severity === "warn");
33
89
  const warningFailure = config.failOnWarnings && hasWarnings;
@@ -40,6 +96,63 @@ export function applyDoctorConfig(result, config) {
40
96
  ...result,
41
97
  status,
42
98
  exitCode: status === "fail" ? 1 : 0,
43
- findings
99
+ findings,
100
+ ...(config.suppressions.length > 0
101
+ ? {
102
+ suppressedFindings,
103
+ suppressionSummary: summary
104
+ }
105
+ : {})
44
106
  };
45
107
  }
108
+ function isPlainObject(value) {
109
+ return typeof value === "object" && value !== null && !Array.isArray(value);
110
+ }
111
+ function isRealDate(value) {
112
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
113
+ if (!match) {
114
+ return false;
115
+ }
116
+ const year = Number(match[1]);
117
+ const month = Number(match[2]);
118
+ const day = Number(match[3]);
119
+ const date = new Date(Date.UTC(year, month - 1, day));
120
+ return (date.getUTCFullYear() === year &&
121
+ date.getUTCMonth() === month - 1 &&
122
+ date.getUTCDate() === day);
123
+ }
124
+ function validateSuppression(value) {
125
+ if (!isPlainObject(value)) {
126
+ return { valid: false, field: "record" };
127
+ }
128
+ if (typeof value.fingerprint !== "string" ||
129
+ !/^[a-f0-9]{64}$/.test(value.fingerprint)) {
130
+ return { valid: false, field: "fingerprint" };
131
+ }
132
+ if (typeof value.reason !== "string" || value.reason.trim().length === 0) {
133
+ return { valid: false, field: "reason" };
134
+ }
135
+ if (typeof value.expiresAt !== "string" || !isRealDate(value.expiresAt)) {
136
+ return { valid: false, field: "expiresAt" };
137
+ }
138
+ return {
139
+ valid: true,
140
+ fingerprint: value.fingerprint,
141
+ reason: value.reason.trim(),
142
+ expiresAt: value.expiresAt
143
+ };
144
+ }
145
+ function isExpired(expiresAt, now) {
146
+ const expirationBoundary = Date.parse(`${expiresAt}T00:00:00.000Z`) + 86_400_000;
147
+ return now.getTime() >= expirationBoundary;
148
+ }
149
+ function buildGovernanceWarning(targetPath, id, message, impact, suggestedFix, evidence) {
150
+ return withFindingFingerprint({
151
+ id,
152
+ severity: "warn",
153
+ message,
154
+ impact,
155
+ suggestedFix,
156
+ evidence
157
+ }, targetPath);
158
+ }
@@ -10,11 +10,25 @@ export interface Finding {
10
10
  evidence?: FindingEvidence;
11
11
  fingerprint?: string;
12
12
  }
13
+ export interface FindingSuppression {
14
+ reason: string;
15
+ expiresAt: string;
16
+ }
17
+ export interface SuppressedFinding extends Finding {
18
+ suppression: FindingSuppression;
19
+ }
20
+ export interface SuppressionSummary {
21
+ applied: number;
22
+ expired: number;
23
+ invalid: number;
24
+ }
13
25
  export interface CheckResult {
14
26
  targetPath: string;
15
27
  status: "pass" | "warn" | "fail";
16
28
  exitCode: 0 | 1;
17
29
  findings: Finding[];
30
+ suppressedFindings?: SuppressedFinding[];
31
+ suppressionSummary?: SuppressionSummary;
18
32
  runtimeScorecard?: RuntimeScorecard;
19
33
  }
20
34
  export interface CheckOptions {
@@ -50,6 +64,8 @@ export interface JsonReport {
50
64
  generatedAt: string;
51
65
  summary: JsonReportSummary;
52
66
  findings: Finding[];
67
+ suppressedFindings?: SuppressedFinding[];
68
+ suppressionSummary?: SuppressionSummary;
53
69
  }
54
70
  export type RuntimeCapabilityStatus = "pass" | "fail" | "warn" | "skipped" | "unsupported";
55
71
  export interface RuntimeScorecard {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { CheckOptions, CheckResult } from "./domain/types.js";
2
+ export { applyDoctorConfig, loadDoctorConfig, type DoctorConfig } from "./core/doctor-config.js";
2
3
  export { buildFindingFingerprint, formatFindingFingerprintLine, withFindingFingerprint, withFindingFingerprints } from "./reporting/finding-fingerprint.js";
3
4
  export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard, type SecurityAudit } from "./security/security-audit.js";
4
5
  export { buildTrustScore, renderTrustScore, renderTrustScoreJson, type BuildTrustScoreOptions, type TrustScoreReport } from "./security/trust-score.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { validatePlugin } from "./core/validate-plugin.js";
2
+ export { applyDoctorConfig, loadDoctorConfig } from "./core/doctor-config.js";
2
3
  export { buildFindingFingerprint, formatFindingFingerprintLine, withFindingFingerprint, withFindingFingerprints } from "./reporting/finding-fingerprint.js";
3
4
  export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
4
5
  export { buildTrustScore, renderTrustScore, renderTrustScoreJson } from "./security/trust-score.js";
@@ -18,7 +18,13 @@ export function buildJsonReport(result, options) {
18
18
  total: result.findings.length
19
19
  }
20
20
  },
21
- findings: result.findings
21
+ findings: result.findings,
22
+ ...(result.suppressedFindings
23
+ ? { suppressedFindings: result.suppressedFindings }
24
+ : {}),
25
+ ...(result.suppressionSummary
26
+ ? { suppressionSummary: result.suppressionSummary }
27
+ : {})
22
28
  };
23
29
  }
24
30
  export function renderJsonReport(result, options) {
@@ -16,7 +16,10 @@ export function buildMarkdownReport(result, options) {
16
16
  `| Warn Findings | ${warnCount} |`,
17
17
  `| Total Findings | ${result.findings.length} |`
18
18
  ];
19
- if (result.findings.length === 0) {
19
+ if (result.suppressionSummary) {
20
+ lines.push(`| Suppressions Applied | ${result.suppressionSummary.applied} |`, `| Suppressions Expired | ${result.suppressionSummary.expired} |`, `| Suppressions Invalid | ${result.suppressionSummary.invalid} |`);
21
+ }
22
+ if (result.findings.length === 0 && !result.suppressedFindings?.length) {
20
23
  lines.push("", "No findings.");
21
24
  return lines.join("\n");
22
25
  }
@@ -33,22 +36,39 @@ export function buildMarkdownReport(result, options) {
33
36
  lines.push(`| prompts/list | ${result.runtimeScorecard.promptsList.toUpperCase()} |`);
34
37
  lines.push(`| prompts/get | ${result.runtimeScorecard.promptGet.toUpperCase()} |`);
35
38
  }
36
- lines.push("", "## Findings", "");
37
- for (const finding of result.findings) {
38
- lines.push(`### ${finding.severity.toUpperCase()} \`${finding.id}\``);
39
- lines.push("");
40
- lines.push(`- Message: ${finding.message}`);
41
- lines.push(`- Impact: ${finding.impact}`);
42
- lines.push(`- Suggested fix: ${finding.suggestedFix}`);
43
- const fingerprint = formatFindingFingerprintLine(finding);
44
- if (fingerprint) {
45
- lines.push(`- Fingerprint: \`${fingerprint}\``);
39
+ if (result.findings.length > 0) {
40
+ lines.push("", "## Findings", "");
41
+ for (const finding of result.findings) {
42
+ lines.push(`### ${finding.severity.toUpperCase()} \`${finding.id}\``);
43
+ lines.push("");
44
+ lines.push(`- Message: ${finding.message}`);
45
+ lines.push(`- Impact: ${finding.impact}`);
46
+ lines.push(`- Suggested fix: ${finding.suggestedFix}`);
47
+ const fingerprint = formatFindingFingerprintLine(finding);
48
+ if (fingerprint) {
49
+ lines.push(`- Fingerprint: \`${fingerprint}\``);
50
+ }
51
+ const evidence = formatFindingEvidenceLine(finding);
52
+ if (evidence) {
53
+ lines.push(`- Evidence: ${evidence}`);
54
+ }
55
+ lines.push("");
46
56
  }
47
- const evidence = formatFindingEvidenceLine(finding);
48
- if (evidence) {
49
- lines.push(`- Evidence: ${evidence}`);
57
+ }
58
+ else {
59
+ lines.push("", "No active findings.");
60
+ }
61
+ if (result.suppressedFindings?.length) {
62
+ lines.push("", "## Suppressed Findings", "");
63
+ for (const finding of result.suppressedFindings) {
64
+ lines.push(`### ${finding.severity.toUpperCase()} \`${finding.id}\``);
65
+ lines.push("");
66
+ lines.push(`- Message: ${finding.message}`);
67
+ lines.push(`- Fingerprint: \`${finding.fingerprint ?? "unavailable"}\``);
68
+ lines.push(`- Reason: ${finding.suppression.reason}`);
69
+ lines.push(`- Expires: ${finding.suppression.expiresAt}`);
70
+ lines.push("");
50
71
  }
51
- lines.push("");
52
72
  }
53
73
  return lines.join("\n");
54
74
  }
@@ -55,7 +55,15 @@ export function renderSarifReport(result) {
55
55
  }
56
56
  : {}),
57
57
  ...(finding.evidence ? { properties: { evidence: finding.evidence } } : {})
58
- }))
58
+ })),
59
+ ...(result.suppressionSummary
60
+ ? {
61
+ properties: {
62
+ suppressionSummary: result.suppressionSummary,
63
+ suppressedFindings: result.suppressedFindings ?? []
64
+ }
65
+ }
66
+ : {})
59
67
  }
60
68
  ]
61
69
  };
@@ -35,7 +35,10 @@ export function renderTextReport(result, options = {}) {
35
35
  `Target: ${result.targetPath}`,
36
36
  `Summary: ${failCount} fail, ${warnCount} warn, ${totalCount} total`
37
37
  ];
38
- if (result.findings.length === 0) {
38
+ if (result.suppressionSummary) {
39
+ lines.push(`Suppressions: ${result.suppressionSummary.applied} applied, ${result.suppressionSummary.expired} expired, ${result.suppressionSummary.invalid} invalid`);
40
+ }
41
+ if (result.findings.length === 0 && !result.suppressedFindings?.length) {
39
42
  if (result.runtimeScorecard) {
40
43
  lines.push("", "Runtime Scorecard", "----------------");
41
44
  lines.push(`initialize: ${result.runtimeScorecard.initialize}`);
@@ -82,6 +85,16 @@ export function renderTextReport(result, options = {}) {
82
85
  };
83
86
  appendSection("Failures", failures, glyphs.fail);
84
87
  appendSection("Warnings", warnings, glyphs.warn);
88
+ if (result.suppressedFindings?.length) {
89
+ lines.push("", "Suppressed Findings", "-------------------");
90
+ for (const finding of result.suppressedFindings) {
91
+ lines.push(`- ${finding.id}`);
92
+ lines.push(` Message: ${finding.message}`);
93
+ lines.push(` Fingerprint: ${finding.fingerprint ?? "unavailable"}`);
94
+ lines.push(` Reason: ${finding.suppression.reason}`);
95
+ lines.push(` Expires: ${finding.suppression.expiresAt}`);
96
+ }
97
+ }
85
98
  if (result.runtimeScorecard) {
86
99
  lines.push("", "Runtime Scorecard", "----------------");
87
100
  lines.push(`initialize: ${result.runtimeScorecard.initialize}`);
@@ -1,4 +1,4 @@
1
- export type RuleCategory = "package" | "skill" | "mcp" | "runtime" | "security";
1
+ export type RuleCategory = "package" | "skill" | "mcp" | "runtime" | "security" | "configuration";
2
2
  export type RuleSeverity = "fail" | "warn";
3
3
  export interface RuleDefinition {
4
4
  id: string;
@@ -1,4 +1,22 @@
1
1
  export const ruleCatalog = [
2
+ {
3
+ id: "suppression.invalid",
4
+ category: "configuration",
5
+ defaultSeverity: "warn",
6
+ summary: "A targeted suppression record is invalid.",
7
+ why: "Invalid suppression records are not applied and can create false confidence about accepted findings.",
8
+ fix: "Fix or remove the invalid suppression record in `.codex-doctor.json`.",
9
+ example: '{ "fingerprint": "<64 lowercase hex characters>", "reason": "Reviewed exception.", "expiresAt": "2026-07-31" }'
10
+ },
11
+ {
12
+ id: "suppression.expired",
13
+ category: "configuration",
14
+ defaultSeverity: "warn",
15
+ summary: "A targeted suppression has expired.",
16
+ why: "Expired risk acceptance must be reviewed again before the finding can be suppressed.",
17
+ fix: "Remove the expired record or replace it with a newly reviewed expiration date.",
18
+ example: '{ "fingerprint": "<fingerprint>", "reason": "Reviewed again.", "expiresAt": "2026-08-31" }'
19
+ },
2
20
  {
3
21
  id: "plugin.manifest.missing",
4
22
  category: "package",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.26.0",
3
+ "version": "1.27.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",