codex-plugin-doctor 0.14.0 → 0.16.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
@@ -176,6 +176,12 @@ Run these from a Codex plugin package root:
176
176
  codex-plugin-doctor --version
177
177
  codex-plugin-doctor self-test
178
178
  codex-plugin-doctor doctor
179
+ codex-plugin-doctor doctor npm codex-plugin-doctor
180
+ codex-plugin-doctor doctor npm codex-plugin-doctor --json --output npm-preinstall.json
181
+ codex-plugin-doctor doctor inspector .
182
+ codex-plugin-doctor doctor inspector . --server context7 --json --output inspector-command.json
183
+ codex-plugin-doctor doctor diff --before ./old-plugin --after ./new-plugin
184
+ codex-plugin-doctor doctor diff --before ./old-plugin --after ./new-plugin --json --output risk-diff.json
179
185
  codex-plugin-doctor doctor recommend .
180
186
  codex-plugin-doctor doctor recommend . --json --output recommendations.json
181
187
  codex-plugin-doctor doctor trust .
@@ -250,7 +256,7 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
250
256
 
251
257
  `self-test` runs the bundled runtime-complete sample through static validation, runtime MCP probes, and the compatibility scorecard. It is the fastest post-install check after `npm install -g codex-plugin-doctor`.
252
258
 
253
- `doctor` checks the local environment, including package version, platform, Node version, npm global prefix, Codex home, and Codex plugin cache visibility. The text output also includes recommended next commands for self-test, installed plugin discovery, runtime checks, compatibility scoring, and CI setup. `doctor recommend <path>` turns validation, security, and compatibility signals into a prioritized action plan with blocker, high, medium, and info actions. Add `--json` for automation or `--output recommendations.json` to write the report to disk. `doctor trust <path>` creates a local trust score from package lifecycle scripts, dependency specs, and MCP security findings. Use it before release when you want supply-chain risks summarized as one score. `doctor perf <path>` profiles the shared package analysis pipeline and reports per-stage durations for validation, config, security, compatibility, trust, recommendations, and total runtime. `doctor export --bundle <path>` creates a redacted operator handoff bundle that includes validation JSON, security scorecard data, compatibility matrix, recommendations, and trust score in one file. `doctor snapshot` creates a redacted diagnostics bundle with environment health, client config readiness, installed plugin metadata, and next commands. Add `--json` for machine-readable output or `--output doctor-snapshot.json` to write the bundle to disk. `doctor clients` reports local Codex, Claude Desktop, Cursor, Cline, and Windsurf config readiness. `doctor --update-check` compares the installed CLI version with the latest npm version and prints the upgrade command when a newer release is available.
259
+ `doctor` checks the local environment, including package version, platform, Node version, npm global prefix, Codex home, and Codex plugin cache visibility. The text output also includes recommended next commands for self-test, installed plugin discovery, runtime checks, compatibility scoring, and CI setup. `doctor npm <package>` runs a preinstall scan by packing the npm package with scripts disabled, extracting the publish tarball, and running validation, security, trust, and recommendation checks against the shipped contents. Add `--json` for automation or `--output npm-preinstall.json` to write the report to disk. `doctor inspector <path>` builds a safe MCP Inspector launch command from a packaged `.mcp.json` file without starting the Inspector proxy automatically. Use `--server <name>` when the package contains multiple MCP server entries. `doctor diff --before <path> --after <path>` compares two package roots and reports new findings, resolved findings, trust score delta, and whether risk increased. `doctor recommend <path>` turns validation, security, and compatibility signals into a prioritized action plan with blocker, high, medium, and info actions. Add `--json` for automation or `--output recommendations.json` to write the report to disk. `doctor trust <path>` creates a local trust score from package lifecycle scripts, dependency specs, and MCP security findings. Use it before release when you want supply-chain risks summarized as one score. `doctor perf <path>` profiles the shared package analysis pipeline and reports per-stage durations for validation, config, security, compatibility, trust, recommendations, and total runtime. `doctor export --bundle <path>` creates a redacted operator handoff bundle that includes validation JSON, security scorecard data, compatibility matrix, recommendations, and trust score in one file. `doctor snapshot` creates a redacted diagnostics bundle with environment health, client config readiness, installed plugin metadata, and next commands. Add `--json` for machine-readable output or `--output doctor-snapshot.json` to write the bundle to disk. `doctor clients` reports local Codex, Claude Desktop, Cursor, Cline, and Windsurf config readiness. `doctor --update-check` compares the installed CLI version with the latest npm version and prints the upgrade command when a newer release is available.
254
260
 
255
261
  `audit --installed` runs a local ecosystem audit against every discovered Codex plugin in the installed plugin cache. Add `--security` to include security scorecards, `--compat` to include the all-client compatibility matrix, and `--json --output local-audit.json` when you want a shareable machine-readable report. Add `--cache` to reuse unchanged plugin results between runs; add `--changed` to only report plugins whose fingerprint changed since the last cached audit. Use `--cache-file path/to/audit-cache.json` when CI or scripted runs need an explicit cache location.
256
262
 
@@ -0,0 +1,23 @@
1
+ export interface DoctorInspectorReport {
2
+ schemaVersion: "1.0.0";
3
+ generatedAt: string;
4
+ kind: "doctor.inspector";
5
+ targetPath: string;
6
+ status: "pass" | "fail";
7
+ exitCode: 0 | 1;
8
+ mcpConfigPath: string | null;
9
+ serverName: string | null;
10
+ command: {
11
+ executable: string;
12
+ args: string[];
13
+ } | null;
14
+ message: string;
15
+ }
16
+ export interface BuildDoctorInspectorReportOptions {
17
+ serverName?: string | null;
18
+ }
19
+ export declare function buildDoctorInspectorReport(targetPath: string, options?: BuildDoctorInspectorReportOptions): Promise<DoctorInspectorReport>;
20
+ export declare function renderDoctorInspectorReportJson(report: DoctorInspectorReport): string;
21
+ export declare function renderDoctorInspectorReport(report: DoctorInspectorReport, options?: {
22
+ outputPath?: string | null;
23
+ }): string;
@@ -0,0 +1,100 @@
1
+ import path from "node:path";
2
+ import { readJsonFile } from "./read-json-file.js";
3
+ import { discoverPackage } from "./discover-package.js";
4
+ function isPlainObject(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+ function isPathWithinRoot(rootPath, candidatePath) {
8
+ const relativePath = path.relative(rootPath, candidatePath);
9
+ return (relativePath === "" ||
10
+ (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
11
+ }
12
+ function buildFailure(targetPath, message, mcpConfigPath = null) {
13
+ return {
14
+ schemaVersion: "1.0.0",
15
+ generatedAt: new Date().toISOString(),
16
+ kind: "doctor.inspector",
17
+ targetPath: path.resolve(targetPath),
18
+ status: "fail",
19
+ exitCode: 1,
20
+ mcpConfigPath,
21
+ serverName: null,
22
+ command: null,
23
+ message
24
+ };
25
+ }
26
+ export async function buildDoctorInspectorReport(targetPath, options = {}) {
27
+ const discoveredPackage = await discoverPackage(targetPath);
28
+ if (!discoveredPackage?.manifest.mcpServers) {
29
+ return buildFailure(targetPath, "The target package does not declare an MCP server config in `.codex-plugin/plugin.json`.");
30
+ }
31
+ const mcpConfigPath = path.resolve(discoveredPackage.rootPath, discoveredPackage.manifest.mcpServers);
32
+ if (!isPathWithinRoot(discoveredPackage.rootPath, mcpConfigPath)) {
33
+ return buildFailure(discoveredPackage.rootPath, "The target package points the MCP server config outside the package root.", mcpConfigPath);
34
+ }
35
+ let parsedConfig;
36
+ try {
37
+ parsedConfig = await readJsonFile(mcpConfigPath);
38
+ }
39
+ catch {
40
+ return buildFailure(discoveredPackage.rootPath, "The MCP server config could not be parsed as JSON.", mcpConfigPath);
41
+ }
42
+ if (!isPlainObject(parsedConfig) || !isPlainObject(parsedConfig.mcpServers)) {
43
+ return buildFailure(discoveredPackage.rootPath, "The MCP server config does not contain a valid `mcpServers` object.", mcpConfigPath);
44
+ }
45
+ const serverNames = Object.keys(parsedConfig.mcpServers).sort();
46
+ const selectedServerName = options.serverName ?? serverNames[0] ?? null;
47
+ if (!selectedServerName || !serverNames.includes(selectedServerName)) {
48
+ return buildFailure(discoveredPackage.rootPath, options.serverName
49
+ ? `The MCP server config does not contain server \`${options.serverName}\`.`
50
+ : "The MCP server config does not contain any server entries.", mcpConfigPath);
51
+ }
52
+ return {
53
+ schemaVersion: "1.0.0",
54
+ generatedAt: new Date().toISOString(),
55
+ kind: "doctor.inspector",
56
+ targetPath: discoveredPackage.rootPath,
57
+ status: "pass",
58
+ exitCode: 0,
59
+ mcpConfigPath,
60
+ serverName: selectedServerName,
61
+ command: {
62
+ executable: "npx",
63
+ args: [
64
+ "-y",
65
+ "@modelcontextprotocol/inspector",
66
+ "--config",
67
+ mcpConfigPath,
68
+ "--server",
69
+ selectedServerName
70
+ ]
71
+ },
72
+ message: "Run this command to open the MCP Inspector for the selected packaged server."
73
+ };
74
+ }
75
+ export function renderDoctorInspectorReportJson(report) {
76
+ return JSON.stringify(report, null, 2);
77
+ }
78
+ export function renderDoctorInspectorReport(report, options = {}) {
79
+ const lines = [
80
+ "Doctor MCP Inspector",
81
+ "====================",
82
+ `Target: ${report.targetPath}`,
83
+ `Status: ${report.status.toUpperCase()}`,
84
+ `Message: ${report.message}`
85
+ ];
86
+ if (report.mcpConfigPath) {
87
+ lines.push(`Config: ${report.mcpConfigPath}`);
88
+ }
89
+ if (report.serverName) {
90
+ lines.push(`Server: ${report.serverName}`);
91
+ }
92
+ if (options.outputPath) {
93
+ lines.push(`Output: ${options.outputPath}`);
94
+ }
95
+ if (report.command) {
96
+ lines.push("", "Command", "-------");
97
+ lines.push([report.command.executable, ...report.command.args].join(" "));
98
+ }
99
+ return lines.join("\n");
100
+ }
@@ -0,0 +1,33 @@
1
+ import type { CompatibilityEnvironment } from "../compatibility/compatibility-matrix.js";
2
+ import type { JsonReport } from "../domain/types.js";
3
+ import type { SecurityAudit } from "../security/security-audit.js";
4
+ import type { TrustScoreReport } from "../security/trust-score.js";
5
+ import type { DoctorRecommendationsReport } from "./doctor-recommendations.js";
6
+ export interface DoctorNpmPackageReport {
7
+ schemaVersion: "1.0.0";
8
+ generatedAt: string;
9
+ kind: "doctor.npm";
10
+ packageSpec: string;
11
+ package: {
12
+ name: string | null;
13
+ version: string | null;
14
+ fileCount: number | null;
15
+ };
16
+ summary: {
17
+ status: "pass" | "warn" | "fail";
18
+ exitCode: 0 | 1;
19
+ safeToInstall: boolean;
20
+ };
21
+ validation: JsonReport;
22
+ security: SecurityAudit;
23
+ trust: TrustScoreReport;
24
+ recommendations: DoctorRecommendationsReport;
25
+ }
26
+ export interface BuildDoctorNpmPackageReportOptions {
27
+ environment?: CompatibilityEnvironment;
28
+ }
29
+ export declare function buildDoctorNpmPackageReport(packageSpec: string, options?: BuildDoctorNpmPackageReportOptions): Promise<DoctorNpmPackageReport>;
30
+ export declare function renderDoctorNpmPackageReportJson(report: DoctorNpmPackageReport): string;
31
+ export declare function renderDoctorNpmPackageReport(report: DoctorNpmPackageReport, options?: {
32
+ outputPath?: string | null;
33
+ }): string;
@@ -0,0 +1,173 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { gunzip } from "node:zlib";
7
+ import { buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./package-analysis.js";
8
+ const execFileAsync = promisify(execFile);
9
+ const gunzipAsync = promisify(gunzip);
10
+ function npmCommand() {
11
+ return "npm";
12
+ }
13
+ function isPathWithinRoot(rootPath, candidatePath) {
14
+ const relativePath = path.relative(rootPath, candidatePath);
15
+ return (relativePath === "" ||
16
+ (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
17
+ }
18
+ function readTarString(buffer, start, length) {
19
+ const slice = buffer.subarray(start, start + length);
20
+ const end = slice.indexOf(0);
21
+ const finalSlice = end === -1 ? slice : slice.subarray(0, end);
22
+ return finalSlice.toString("utf8").trim();
23
+ }
24
+ function readTarSize(buffer) {
25
+ const rawSize = readTarString(buffer, 124, 12).replace(/\0/g, "").trim();
26
+ const parsedSize = Number.parseInt(rawSize, 8);
27
+ return Number.isFinite(parsedSize) ? parsedSize : 0;
28
+ }
29
+ function isEmptyHeader(header) {
30
+ return header.every((byte) => byte === 0);
31
+ }
32
+ async function extractTarGz(tarballPath, destinationPath) {
33
+ const tarBuffer = await gunzipAsync(await readFile(tarballPath));
34
+ let offset = 0;
35
+ await mkdir(destinationPath, { recursive: true });
36
+ while (offset + 512 <= tarBuffer.length) {
37
+ const header = tarBuffer.subarray(offset, offset + 512);
38
+ if (isEmptyHeader(header)) {
39
+ break;
40
+ }
41
+ const name = readTarString(header, 0, 100);
42
+ const prefix = readTarString(header, 345, 155);
43
+ const entryName = prefix ? `${prefix}/${name}` : name;
44
+ const size = readTarSize(header);
45
+ const typeFlag = readTarString(header, 156, 1);
46
+ const fileStart = offset + 512;
47
+ const fileEnd = fileStart + size;
48
+ const targetPath = path.resolve(destinationPath, entryName);
49
+ if (!isPathWithinRoot(destinationPath, targetPath)) {
50
+ throw new Error(`Refusing to extract tar entry outside destination: ${entryName}`);
51
+ }
52
+ if (typeFlag === "5") {
53
+ await mkdir(targetPath, { recursive: true });
54
+ }
55
+ else if (typeFlag === "" || typeFlag === "0") {
56
+ await mkdir(path.dirname(targetPath), { recursive: true });
57
+ await writeFile(targetPath, tarBuffer.subarray(fileStart, fileEnd));
58
+ }
59
+ offset = fileStart + Math.ceil(size / 512) * 512;
60
+ }
61
+ }
62
+ async function directoryExists(targetPath) {
63
+ try {
64
+ const details = await stat(targetPath);
65
+ return details.isDirectory();
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ async function resolvePackageSpecForPack(packageSpec) {
72
+ const candidatePath = path.resolve(packageSpec);
73
+ try {
74
+ await stat(candidatePath);
75
+ return candidatePath;
76
+ }
77
+ catch {
78
+ return packageSpec;
79
+ }
80
+ }
81
+ async function packNpmPackage(packageSpec, destinationPath) {
82
+ const resolvedPackageSpec = await resolvePackageSpecForPack(packageSpec);
83
+ const { stdout } = await execFileAsync(npmCommand(), [
84
+ "pack",
85
+ resolvedPackageSpec,
86
+ "--json",
87
+ "--ignore-scripts",
88
+ "--pack-destination",
89
+ destinationPath
90
+ ], {
91
+ cwd: destinationPath,
92
+ maxBuffer: 10 * 1024 * 1024,
93
+ shell: process.platform === "win32"
94
+ });
95
+ const packEntries = JSON.parse(stdout);
96
+ const metadata = packEntries[0];
97
+ if (!metadata?.filename) {
98
+ throw new Error(`npm pack did not return a tarball for ${packageSpec}`);
99
+ }
100
+ return {
101
+ tarballPath: path.isAbsolute(metadata.filename)
102
+ ? metadata.filename
103
+ : path.join(destinationPath, metadata.filename),
104
+ metadata
105
+ };
106
+ }
107
+ export async function buildDoctorNpmPackageReport(packageSpec, options = {}) {
108
+ const workspacePath = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-npm-"));
109
+ try {
110
+ const { tarballPath, metadata } = await packNpmPackage(packageSpec, workspacePath);
111
+ const extractPath = path.join(workspacePath, "extract");
112
+ await extractTarGz(tarballPath, extractPath);
113
+ const packageRoot = await directoryExists(path.join(extractPath, "package"))
114
+ ? path.join(extractPath, "package")
115
+ : extractPath;
116
+ const analysis = await buildPackageAnalysis(packageRoot, {
117
+ environment: options.environment
118
+ });
119
+ const recommendations = buildDoctorRecommendationsFromAnalysis(analysis);
120
+ return {
121
+ schemaVersion: "1.0.0",
122
+ generatedAt: analysis.generatedAt,
123
+ kind: "doctor.npm",
124
+ packageSpec,
125
+ package: {
126
+ name: typeof metadata.name === "string" ? metadata.name : null,
127
+ version: typeof metadata.version === "string" ? metadata.version : null,
128
+ fileCount: Array.isArray(metadata.files) ? metadata.files.length : null
129
+ },
130
+ summary: {
131
+ status: recommendations.status,
132
+ exitCode: recommendations.exitCode,
133
+ safeToInstall: recommendations.status === "pass"
134
+ },
135
+ validation: analysis.validationJson,
136
+ security: analysis.security,
137
+ trust: analysis.trust,
138
+ recommendations
139
+ };
140
+ }
141
+ finally {
142
+ await rm(workspacePath, { recursive: true, force: true });
143
+ }
144
+ }
145
+ export function renderDoctorNpmPackageReportJson(report) {
146
+ return JSON.stringify(report, null, 2);
147
+ }
148
+ export function renderDoctorNpmPackageReport(report, options = {}) {
149
+ const packageLabel = report.package.name
150
+ ? `${report.package.name}${report.package.version ? `@${report.package.version}` : ""}`
151
+ : report.packageSpec;
152
+ const lines = [
153
+ "Doctor npm Preinstall Scan",
154
+ "==========================",
155
+ `Package: ${packageLabel}`,
156
+ `Spec: ${report.packageSpec}`,
157
+ `Status: ${report.summary.status.toUpperCase()}`,
158
+ `Safe to install: ${report.summary.safeToInstall ? "yes" : "no"}`,
159
+ `Security: ${report.security.status.toUpperCase()} (${report.security.score}/100)`,
160
+ `Trust: ${report.trust.status.toUpperCase()} (${report.trust.score}/100)`,
161
+ `Actions: ${report.recommendations.actions.length}`
162
+ ];
163
+ if (options.outputPath) {
164
+ lines.push(`Output: ${options.outputPath}`);
165
+ }
166
+ if (report.recommendations.actions.length > 0) {
167
+ lines.push("", "Top Actions", "-----------");
168
+ for (const action of report.recommendations.actions.slice(0, 5)) {
169
+ lines.push(`[${action.priority.toUpperCase()}] ${action.title}`);
170
+ }
171
+ }
172
+ return lines.join("\n");
173
+ }
@@ -0,0 +1,33 @@
1
+ import type { CompatibilityEnvironment } from "../compatibility/compatibility-matrix.js";
2
+ import type { Finding } from "../domain/types.js";
3
+ export type RiskFindingCategory = "validation" | "security";
4
+ export interface RiskDiffFinding extends Finding {
5
+ category: RiskFindingCategory;
6
+ }
7
+ export interface DoctorRiskDiffReport {
8
+ schemaVersion: "1.0.0";
9
+ generatedAt: string;
10
+ kind: "doctor.risk.diff";
11
+ beforePath: string;
12
+ afterPath: string;
13
+ status: "pass" | "warn" | "fail";
14
+ exitCode: 0 | 1;
15
+ summary: {
16
+ riskIncreased: boolean;
17
+ newFindings: number;
18
+ resolvedFindings: number;
19
+ trustScoreBefore: number;
20
+ trustScoreAfter: number;
21
+ trustScoreDelta: number;
22
+ };
23
+ newFindings: RiskDiffFinding[];
24
+ resolvedFindings: RiskDiffFinding[];
25
+ }
26
+ export interface BuildDoctorRiskDiffReportOptions {
27
+ environment?: CompatibilityEnvironment;
28
+ }
29
+ export declare function buildDoctorRiskDiffReport(beforePath: string, afterPath: string, options?: BuildDoctorRiskDiffReportOptions): Promise<DoctorRiskDiffReport>;
30
+ export declare function renderDoctorRiskDiffReportJson(report: DoctorRiskDiffReport): string;
31
+ export declare function renderDoctorRiskDiffReport(report: DoctorRiskDiffReport, options?: {
32
+ outputPath?: string | null;
33
+ }): string;
@@ -0,0 +1,102 @@
1
+ import { buildPackageAnalysis } from "./package-analysis.js";
2
+ function findingKey(finding) {
3
+ return `${finding.category}\n${finding.id}\n${finding.message}`;
4
+ }
5
+ function collectComparableFindings(analysis) {
6
+ const findings = [
7
+ ...analysis.validation.findings.map((finding) => ({
8
+ ...finding,
9
+ category: "validation"
10
+ })),
11
+ ...analysis.security.findings.map((finding) => ({
12
+ ...finding,
13
+ category: "security"
14
+ }))
15
+ ];
16
+ const seen = new Set();
17
+ return findings.filter((finding) => {
18
+ const key = findingKey(finding);
19
+ if (seen.has(key)) {
20
+ return false;
21
+ }
22
+ seen.add(key);
23
+ return true;
24
+ });
25
+ }
26
+ function compareFindings(beforeFindings, afterFindings) {
27
+ const beforeKeys = new Set(beforeFindings.map(findingKey));
28
+ const afterKeys = new Set(afterFindings.map(findingKey));
29
+ return {
30
+ newFindings: afterFindings.filter((finding) => !beforeKeys.has(findingKey(finding))),
31
+ resolvedFindings: beforeFindings.filter((finding) => !afterKeys.has(findingKey(finding)))
32
+ };
33
+ }
34
+ export async function buildDoctorRiskDiffReport(beforePath, afterPath, options = {}) {
35
+ const [beforeAnalysis, afterAnalysis] = await Promise.all([
36
+ buildPackageAnalysis(beforePath, { environment: options.environment }),
37
+ buildPackageAnalysis(afterPath, { environment: options.environment })
38
+ ]);
39
+ const { newFindings, resolvedFindings } = compareFindings(collectComparableFindings(beforeAnalysis), collectComparableFindings(afterAnalysis));
40
+ const trustScoreDelta = afterAnalysis.trust.score - beforeAnalysis.trust.score;
41
+ const hasNewFailure = newFindings.some((finding) => finding.severity === "fail");
42
+ const hasNewWarning = newFindings.some((finding) => finding.severity === "warn");
43
+ const riskIncreased = hasNewFailure || trustScoreDelta < 0;
44
+ const status = riskIncreased
45
+ ? "fail"
46
+ : hasNewWarning
47
+ ? "warn"
48
+ : "pass";
49
+ return {
50
+ schemaVersion: "1.0.0",
51
+ generatedAt: new Date().toISOString(),
52
+ kind: "doctor.risk.diff",
53
+ beforePath: beforeAnalysis.targetPath,
54
+ afterPath: afterAnalysis.targetPath,
55
+ status,
56
+ exitCode: status === "fail" ? 1 : 0,
57
+ summary: {
58
+ riskIncreased,
59
+ newFindings: newFindings.length,
60
+ resolvedFindings: resolvedFindings.length,
61
+ trustScoreBefore: beforeAnalysis.trust.score,
62
+ trustScoreAfter: afterAnalysis.trust.score,
63
+ trustScoreDelta
64
+ },
65
+ newFindings,
66
+ resolvedFindings
67
+ };
68
+ }
69
+ export function renderDoctorRiskDiffReportJson(report) {
70
+ return JSON.stringify(report, null, 2);
71
+ }
72
+ export function renderDoctorRiskDiffReport(report, options = {}) {
73
+ const lines = [
74
+ "Doctor Risk Diff",
75
+ "================",
76
+ `Status: ${report.status.toUpperCase()}`,
77
+ `Before: ${report.beforePath}`,
78
+ `After: ${report.afterPath}`,
79
+ `Risk increased: ${report.summary.riskIncreased ? "yes" : "no"}`,
80
+ `Trust score delta: ${report.summary.trustScoreDelta}`,
81
+ `New findings: ${report.summary.newFindings}`,
82
+ `Resolved findings: ${report.summary.resolvedFindings}`
83
+ ];
84
+ if (options.outputPath) {
85
+ lines.push(`Output: ${options.outputPath}`);
86
+ }
87
+ if (report.newFindings.length > 0) {
88
+ lines.push("", "New Findings", "------------");
89
+ for (const finding of report.newFindings) {
90
+ lines.push(`[${finding.severity.toUpperCase()}] ${finding.category}: ${finding.id}`);
91
+ lines.push(` Message: ${finding.message}`);
92
+ }
93
+ }
94
+ if (report.resolvedFindings.length > 0) {
95
+ lines.push("", "Resolved Findings", "-----------------");
96
+ for (const finding of report.resolvedFindings) {
97
+ lines.push(`[${finding.severity.toUpperCase()}] ${finding.category}: ${finding.id}`);
98
+ lines.push(` Message: ${finding.message}`);
99
+ }
100
+ }
101
+ return lines.join("\n");
102
+ }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,9 @@ export { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRe
6
6
  export { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson, type DoctorExportBundle } from "./core/doctor-export-bundle.js";
7
7
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis, type PackageAnalysis, type PackageAnalysisOptions, type PackageAnalysisStage, type PackageAnalysisTiming } from "./core/package-analysis.js";
8
8
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson, type BuildDoctorPerformanceReportOptions, type DoctorPerformanceReport, type DoctorPerformanceStage, type DoctorPerformanceStageName } from "./core/performance-report.js";
9
+ export { buildDoctorNpmPackageReport, renderDoctorNpmPackageReport, renderDoctorNpmPackageReportJson, type BuildDoctorNpmPackageReportOptions, type DoctorNpmPackageReport } from "./core/npm-package-doctor.js";
10
+ export { buildDoctorRiskDiffReport, renderDoctorRiskDiffReport, renderDoctorRiskDiffReportJson, type BuildDoctorRiskDiffReportOptions, type DoctorRiskDiffReport, type RiskDiffFinding, type RiskFindingCategory } from "./core/risk-diff.js";
11
+ export { buildDoctorInspectorReport, renderDoctorInspectorReport, renderDoctorInspectorReportJson, type BuildDoctorInspectorReportOptions, type DoctorInspectorReport } from "./core/inspector-bridge.js";
9
12
  export { buildEcosystemAudit, renderEcosystemAudit, renderEcosystemAuditJson, type EcosystemAuditReport } from "./audit/ecosystem-audit.js";
10
13
  export { applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames, type PolicyPackName } from "./policy/policy-packs.js";
11
14
  export { buildGenericMcpDoctor, renderGenericMcpDoctor, renderGenericMcpDoctorJson, type GenericMcpDoctorReport } from "./mcp/generic-mcp-doctor.js";
package/dist/index.js CHANGED
@@ -6,6 +6,9 @@ export { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRe
6
6
  export { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson } from "./core/doctor-export-bundle.js";
7
7
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./core/package-analysis.js";
8
8
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";
9
+ export { buildDoctorNpmPackageReport, renderDoctorNpmPackageReport, renderDoctorNpmPackageReportJson } from "./core/npm-package-doctor.js";
10
+ export { buildDoctorRiskDiffReport, renderDoctorRiskDiffReport, renderDoctorRiskDiffReportJson } from "./core/risk-diff.js";
11
+ export { buildDoctorInspectorReport, renderDoctorInspectorReport, renderDoctorInspectorReportJson } from "./core/inspector-bridge.js";
9
12
  export { buildEcosystemAudit, renderEcosystemAudit, renderEcosystemAuditJson } from "./audit/ecosystem-audit.js";
10
13
  export { applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames } from "./policy/policy-packs.js";
11
14
  export { buildGenericMcpDoctor, renderGenericMcpDoctor, renderGenericMcpDoctorJson } from "./mcp/generic-mcp-doctor.js";
@@ -215,6 +215,15 @@ export const ruleCatalog = [
215
215
  fix: "Use HTTPS for remote MCP servers; reserve HTTP for explicit localhost development endpoints.",
216
216
  example: '{ "url": "https://example.com/mcp" }'
217
217
  },
218
+ {
219
+ id: "plugin.security.prompt_injection_text",
220
+ category: "security",
221
+ defaultSeverity: "fail",
222
+ summary: "Packaged text contains prompt-injection or secret-exfiltration instructions.",
223
+ why: "Poisoned tool, prompt, resource, or skill text can instruct an agent to ignore higher-priority instructions or leak secrets when loaded into context.",
224
+ fix: "Remove hidden override or exfiltration instructions and keep descriptions scoped to legitimate behavior.",
225
+ example: "Keep SKILL.md, prompt, resource, and tool descriptions direct and user-facing."
226
+ },
218
227
  {
219
228
  id: "plugin.runtime.exited_early",
220
229
  category: "runtime",
package/dist/run-cli.js CHANGED
@@ -17,6 +17,9 @@ import { buildDoctorSnapshot, renderDoctorSnapshot, renderDoctorSnapshotJson } f
17
17
  import { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRecommendationsJson } from "./core/doctor-recommendations.js";
18
18
  import { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson } from "./core/doctor-export-bundle.js";
19
19
  import { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";
20
+ import { buildDoctorNpmPackageReport, renderDoctorNpmPackageReport, renderDoctorNpmPackageReportJson } from "./core/npm-package-doctor.js";
21
+ import { buildDoctorRiskDiffReport, renderDoctorRiskDiffReport, renderDoctorRiskDiffReportJson } from "./core/risk-diff.js";
22
+ import { buildDoctorInspectorReport, renderDoctorInspectorReport, renderDoctorInspectorReportJson } from "./core/inspector-bridge.js";
20
23
  import { applyFixPlan, buildFixPlan, renderApplyFixResult, renderFixPlanJsonReport, renderFixPlan } from "./core/fix-plan.js";
21
24
  import { renderClientDoctor, renderEnvironmentDoctor, renderEnvironmentDoctorJson } from "./core/environment-doctor.js";
22
25
  import { initCiWorkflow } from "./core/init-ci.js";
@@ -62,7 +65,7 @@ const defaultIo = {
62
65
  }
63
66
  };
64
67
  function printUsage(io) {
65
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--verbose-runtime] [--explain] [--no-animations] [--ascii]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor mcp <path> [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor doctor [recommend <path>|trust <path>|perf <path>|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
68
+ io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--verbose-runtime] [--explain] [--no-animations] [--ascii]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor mcp <path> [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor doctor [npm <package>|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path>|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
66
69
  }
67
70
  function renderInstalledPlugins(plugins) {
68
71
  const lines = [
@@ -227,6 +230,98 @@ export async function runCli(args, io = defaultIo, options = {}) {
227
230
  io.writeStdout(renderedReport);
228
231
  return report.exitCode;
229
232
  }
233
+ if (maybePath === "npm") {
234
+ const packageSpec = remainingArgs[0] && !remainingArgs[0].startsWith("--")
235
+ ? remainingArgs[0]
236
+ : null;
237
+ if (!packageSpec) {
238
+ io.writeStderr("Missing package spec. Usage: codex-plugin-doctor doctor npm <package> [--json] [--output <path>]");
239
+ return 2;
240
+ }
241
+ const npmFlags = remainingArgs.slice(1);
242
+ const jsonOutput = npmFlags.includes("--json");
243
+ const outputIndex = npmFlags.indexOf("--output");
244
+ const outputPath = outputIndex === -1 ? null : npmFlags[outputIndex + 1];
245
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
246
+ io.writeStderr("Missing path after --output.");
247
+ return 2;
248
+ }
249
+ const report = await buildDoctorNpmPackageReport(packageSpec, {
250
+ environment: {
251
+ env: terminalContext.env,
252
+ platform: terminalContext.platform
253
+ }
254
+ });
255
+ const renderedReport = jsonOutput
256
+ ? renderDoctorNpmPackageReportJson(report)
257
+ : renderDoctorNpmPackageReport(report, { outputPath });
258
+ if (outputPath) {
259
+ await writeFile(outputPath, renderedReport, "utf8");
260
+ }
261
+ io.writeStdout(renderedReport);
262
+ return report.summary.exitCode;
263
+ }
264
+ if (maybePath === "diff") {
265
+ const beforeIndex = remainingArgs.indexOf("--before");
266
+ const afterIndex = remainingArgs.indexOf("--after");
267
+ const beforePath = beforeIndex === -1 ? null : remainingArgs[beforeIndex + 1];
268
+ const afterPath = afterIndex === -1 ? null : remainingArgs[afterIndex + 1];
269
+ if (!beforePath || beforePath.startsWith("--") || !afterPath || afterPath.startsWith("--")) {
270
+ io.writeStderr("Usage: codex-plugin-doctor doctor diff --before <path> --after <path> [--json] [--output <path>]");
271
+ return 2;
272
+ }
273
+ const jsonOutput = remainingArgs.includes("--json");
274
+ const outputIndex = remainingArgs.indexOf("--output");
275
+ const outputPath = outputIndex === -1 ? null : remainingArgs[outputIndex + 1];
276
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
277
+ io.writeStderr("Missing path after --output.");
278
+ return 2;
279
+ }
280
+ const report = await buildDoctorRiskDiffReport(beforePath, afterPath, {
281
+ environment: {
282
+ env: terminalContext.env,
283
+ platform: terminalContext.platform
284
+ }
285
+ });
286
+ const renderedReport = jsonOutput
287
+ ? renderDoctorRiskDiffReportJson(report)
288
+ : renderDoctorRiskDiffReport(report, { outputPath });
289
+ if (outputPath) {
290
+ await writeFile(outputPath, renderedReport, "utf8");
291
+ }
292
+ io.writeStdout(renderedReport);
293
+ return report.exitCode;
294
+ }
295
+ if (maybePath === "inspector") {
296
+ const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
297
+ ? remainingArgs[0]
298
+ : ".";
299
+ const inspectorFlags = remainingArgs[0] && !remainingArgs[0].startsWith("--")
300
+ ? remainingArgs.slice(1)
301
+ : remainingArgs;
302
+ const jsonOutput = inspectorFlags.includes("--json");
303
+ const outputIndex = inspectorFlags.indexOf("--output");
304
+ const outputPath = outputIndex === -1 ? null : inspectorFlags[outputIndex + 1];
305
+ const serverIndex = inspectorFlags.indexOf("--server");
306
+ const serverName = serverIndex === -1 ? null : inspectorFlags[serverIndex + 1];
307
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
308
+ io.writeStderr("Missing path after --output.");
309
+ return 2;
310
+ }
311
+ if (serverIndex !== -1 && (!serverName || serverName.startsWith("--"))) {
312
+ io.writeStderr("Missing server name after --server.");
313
+ return 2;
314
+ }
315
+ const report = await buildDoctorInspectorReport(targetPath, { serverName });
316
+ const renderedReport = jsonOutput
317
+ ? renderDoctorInspectorReportJson(report)
318
+ : renderDoctorInspectorReport(report, { outputPath });
319
+ if (outputPath) {
320
+ await writeFile(outputPath, renderedReport, "utf8");
321
+ }
322
+ io.writeStdout(renderedReport);
323
+ return report.exitCode;
324
+ }
230
325
  if (maybePath === "trust") {
231
326
  const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
232
327
  ? remainingArgs[0]
@@ -1,3 +1,4 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
1
2
  import path from "node:path";
2
3
  import { discoverPackage } from "../core/discover-package.js";
3
4
  import { readJsonFile } from "../core/read-json-file.js";
@@ -40,6 +41,25 @@ function containsPipeInstaller(args) {
40
41
  /\b(iwr|irm|invoke-webrequest|invoke-restmethod)\b[^|]*\|\s*(iex|invoke-expression)\b/.test(joinedArgs) ||
41
42
  /\binvoke-expression\b/.test(joinedArgs));
42
43
  }
44
+ const poisonScanExtensions = new Set([
45
+ ".json",
46
+ ".md",
47
+ ".mdx",
48
+ ".txt",
49
+ ".yaml",
50
+ ".yml"
51
+ ]);
52
+ const poisonScanSkippedDirectories = new Set([
53
+ ".git",
54
+ "coverage",
55
+ "dist",
56
+ "node_modules"
57
+ ]);
58
+ const promptInjectionPatterns = [
59
+ /\bignore\s+(?:all\s+)?(?:previous|prior|system|developer)\s+instructions?\b/i,
60
+ /\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,
61
+ /\bdo\s+not\s+(?:reveal|tell|mention|disclose)\b.{0,120}\b(?:instruction|instructions|prompt|prompts|system|developer)\b/i
62
+ ];
43
63
  export function auditMcpServerConfig(rootPath, parsedConfig) {
44
64
  if (!isPlainObject(parsedConfig) || !isPlainObject(parsedConfig.mcpServers)) {
45
65
  return [
@@ -76,6 +96,43 @@ export function auditMcpServerConfig(rootPath, parsedConfig) {
76
96
  }
77
97
  return findings;
78
98
  }
99
+ async function collectPromptPoisoningScanFiles(rootPath, currentPath = rootPath) {
100
+ const entries = await readdir(currentPath, { withFileTypes: true });
101
+ const filePaths = [];
102
+ for (const entry of entries) {
103
+ const entryPath = path.join(currentPath, entry.name);
104
+ if (entry.isDirectory()) {
105
+ if (poisonScanSkippedDirectories.has(entry.name)) {
106
+ continue;
107
+ }
108
+ filePaths.push(...(await collectPromptPoisoningScanFiles(rootPath, entryPath)));
109
+ continue;
110
+ }
111
+ if (!entry.isFile() || !poisonScanExtensions.has(path.extname(entry.name).toLowerCase())) {
112
+ continue;
113
+ }
114
+ const details = await stat(entryPath);
115
+ if (details.size <= 256 * 1024) {
116
+ filePaths.push(entryPath);
117
+ }
118
+ }
119
+ return filePaths;
120
+ }
121
+ function containsPromptInjectionText(content) {
122
+ return promptInjectionPatterns.some((pattern) => pattern.test(content));
123
+ }
124
+ async function auditPromptPoisoningSurface(rootPath) {
125
+ const findings = [];
126
+ for (const filePath of await collectPromptPoisoningScanFiles(rootPath)) {
127
+ const content = await readFile(filePath, "utf8");
128
+ if (!containsPromptInjectionText(content)) {
129
+ continue;
130
+ }
131
+ const relativeFilePath = path.relative(rootPath, filePath).replace(/\\/g, "/");
132
+ 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."));
133
+ }
134
+ return findings;
135
+ }
79
136
  async function auditMcpCommandSurface(discoveredPackage) {
80
137
  const { manifest, rootPath } = discoveredPackage;
81
138
  if (!manifest.mcpServers) {
@@ -154,7 +211,8 @@ export async function buildSecurityAudit(targetPath) {
154
211
  const validationSecurityFindings = validationResult.findings.filter((finding) => finding.id.startsWith("plugin.security."));
155
212
  const findings = [
156
213
  ...validationSecurityFindings,
157
- ...(await auditMcpCommandSurface(discoveredPackage))
214
+ ...(await auditMcpCommandSurface(discoveredPackage)),
215
+ ...(await auditPromptPoisoningSurface(discoveredPackage.rootPath))
158
216
  ];
159
217
  return buildSecurityAuditFromFindings(discoveredPackage.rootPath, findings);
160
218
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "0.14.0",
3
+ "version": "0.16.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",