codex-plugin-doctor 0.10.1 → 0.12.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
@@ -33,7 +33,7 @@ This tool gives plugin authors a repeatable preflight check before distribution.
33
33
 
34
34
  ## What It Checks
35
35
 
36
- Static validation:
36
+ Static validation:
37
37
 
38
38
  - required `.codex-plugin/plugin.json`
39
39
  - manifest fields: `name`, `version`, `description`
@@ -42,10 +42,18 @@ Static validation:
42
42
  - YAML single-line and block-scalar skill descriptions
43
43
  - `.mcp.json` structure
44
44
  - path traversal risks
45
- - hard-coded secret-like env values
46
- - description quality heuristics tuned against real plugin packages
47
-
48
- Runtime MCP validation with `--runtime`:
45
+ - hard-coded secret-like env values
46
+ - description quality heuristics tuned against real plugin packages
47
+
48
+ Security scorecard with `security`:
49
+
50
+ - shell wrapper command warnings for MCP servers
51
+ - encoded shell command failures
52
+ - remote content piped into shell failures
53
+ - MCP server `cwd` paths that escape the package root
54
+ - plain HTTP remote transport warnings
55
+
56
+ Runtime MCP validation with `--runtime`:
49
57
 
50
58
  - `initialize`
51
59
  - `notifications/initialized`
@@ -90,9 +98,12 @@ codex-plugin-doctor list --installed
90
98
  codex-plugin-doctor check --installed
91
99
  codex-plugin-doctor check --installed --all-summary
92
100
  codex-plugin-doctor check --installed --compat --all-summary
101
+ codex-plugin-doctor audit --installed --security --compat
102
+ codex-plugin-doctor audit --installed --security --compat --policy security
103
+ codex-plugin-doctor mcp path/to/mcp-package
93
104
  codex-plugin-doctor check --installed github
94
- codex-plugin-doctor explain plugin.manifest.missing
95
- ```
105
+ codex-plugin-doctor explain plugin.manifest.missing
106
+ ```
96
107
 
97
108
  Run from source:
98
109
 
@@ -165,12 +176,25 @@ Run these from a Codex plugin package root:
165
176
  codex-plugin-doctor --version
166
177
  codex-plugin-doctor self-test
167
178
  codex-plugin-doctor doctor
179
+ codex-plugin-doctor doctor snapshot
180
+ codex-plugin-doctor doctor snapshot --json
181
+ codex-plugin-doctor doctor snapshot --output doctor-snapshot.json
168
182
  codex-plugin-doctor doctor clients
169
183
  codex-plugin-doctor doctor --update-check
184
+ codex-plugin-doctor audit --installed
185
+ codex-plugin-doctor audit --installed --security --compat
186
+ codex-plugin-doctor audit --installed --security --compat --json --output local-audit.json
187
+ codex-plugin-doctor mcp .
188
+ codex-plugin-doctor mcp . --json
189
+ codex-plugin-doctor mcp . --json --output mcp-doctor.json
170
190
  codex-plugin-doctor init my-plugin
171
191
  codex-plugin-doctor init my-mcp --template mcp-stdio
172
192
  codex-plugin-doctor init remote-mcp --template mcp-http
173
193
  codex-plugin-doctor init runtime-demo --template full-runtime
194
+ codex-plugin-doctor security .
195
+ codex-plugin-doctor security . --scorecard
196
+ codex-plugin-doctor security . --json
197
+ codex-plugin-doctor security . --policy security
174
198
  codex-plugin-doctor compat .
175
199
  codex-plugin-doctor compat . --all --scorecard
176
200
  codex-plugin-doctor compat . --client codex
@@ -190,6 +214,9 @@ codex-plugin-doctor check .
190
214
  codex-plugin-doctor check . --profile ci
191
215
  codex-plugin-doctor check . --profile strict
192
216
  codex-plugin-doctor check . --profile publish
217
+ codex-plugin-doctor check . --policy codex-publish
218
+ codex-plugin-doctor check . --policy mcp-strict
219
+ codex-plugin-doctor check . --policy security
193
220
  codex-plugin-doctor check . --json
194
221
  codex-plugin-doctor check . --explain
195
222
  codex-plugin-doctor check . --json --output report.json
@@ -213,10 +240,18 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
213
240
 
214
241
  `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`.
215
242
 
216
- `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 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.
243
+ `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 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.
244
+
245
+ `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.
246
+
247
+ `--policy codex-publish|mcp-strict|security` applies opinionated gates without requiring a local `.codex-doctor.json`. `codex-publish` fails warnings and enables runtime probes for release checks, `mcp-strict` does the same for MCP-heavy packages, and `security` fails warning-level security findings so advisory risks can block a local audit or CI gate.
248
+
249
+ `mcp <path>` diagnoses generic MCP packages that may not have a Codex plugin manifest. It looks for `.mcp.json` or a manifest `mcpServers` reference, validates the top-level `mcpServers` object and server transports, adds MCP command-surface security findings, and includes the all-client compatibility matrix in the same report.
217
250
 
218
251
  `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.
219
252
 
253
+ `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.
254
+
220
255
  `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.
221
256
 
222
257
  `compat --client cursor` checks whether the MCP package can be added to Cursor. It prefers a project-level `.cursor/mcp.json` when one already exists in the target package, then falls back to the global `~/.cursor/mcp.json` path. A valid existing config returns `PASS`, a missing Cursor config returns `WARN`, malformed JSON returns `FAIL`, and duplicate MCP server names return `WARN`. Add `--install-preview` to print the JSON snippet that should be merged into Cursor's `mcp.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.
@@ -0,0 +1,36 @@
1
+ import type { CompatibilityMatrix } from "../compatibility/compatibility-matrix.js";
2
+ import type { InstalledPlugin } from "../core/discover-installed-plugins.js";
3
+ import type { CheckResult } from "../domain/types.js";
4
+ import type { SecurityAudit } from "../security/security-audit.js";
5
+ export interface EcosystemAuditOptions {
6
+ env?: Record<string, string | undefined>;
7
+ platform?: NodeJS.Platform;
8
+ filter?: string | null;
9
+ includeSecurity?: boolean;
10
+ includeCompatibility?: boolean;
11
+ failOnWarnings?: boolean;
12
+ validatePlugin: (targetPath: string) => Promise<CheckResult>;
13
+ }
14
+ export interface EcosystemAuditPluginResult {
15
+ plugin: InstalledPlugin;
16
+ status: "pass" | "warn" | "fail";
17
+ validation: CheckResult;
18
+ security?: SecurityAudit;
19
+ compatibility?: CompatibilityMatrix;
20
+ }
21
+ export interface EcosystemAuditReport {
22
+ schemaVersion: "1.0.0";
23
+ generatedAt: string;
24
+ status: "pass" | "warn" | "fail";
25
+ summary: {
26
+ totalPlugins: number;
27
+ pass: number;
28
+ warn: number;
29
+ fail: number;
30
+ };
31
+ plugins: EcosystemAuditPluginResult[];
32
+ priorityActions: string[];
33
+ }
34
+ export declare function buildEcosystemAudit(options: EcosystemAuditOptions): Promise<EcosystemAuditReport>;
35
+ export declare function renderEcosystemAuditJson(report: EcosystemAuditReport): string;
36
+ export declare function renderEcosystemAudit(report: EcosystemAuditReport): string;
@@ -0,0 +1,135 @@
1
+ import { buildCompatibilityMatrix, matrixExitCode } from "../compatibility/compatibility-matrix.js";
2
+ import { discoverInstalledPlugins, filterInstalledPlugins } from "../core/discover-installed-plugins.js";
3
+ import { buildSecurityAudit } from "../security/security-audit.js";
4
+ function mergeStatus(statuses) {
5
+ if (statuses.includes("fail")) {
6
+ return "fail";
7
+ }
8
+ if (statuses.includes("warn")) {
9
+ return "warn";
10
+ }
11
+ return "pass";
12
+ }
13
+ function compatibilityStatus(matrix) {
14
+ if (!matrix) {
15
+ return "pass";
16
+ }
17
+ if (matrixExitCode(matrix) === 1) {
18
+ return "fail";
19
+ }
20
+ return matrix.results.some((result) => result.status === "warn") ? "warn" : "pass";
21
+ }
22
+ function summarizePlugins(plugins) {
23
+ return {
24
+ totalPlugins: plugins.length,
25
+ pass: plugins.filter((plugin) => plugin.status === "pass").length,
26
+ warn: plugins.filter((plugin) => plugin.status === "warn").length,
27
+ fail: plugins.filter((plugin) => plugin.status === "fail").length
28
+ };
29
+ }
30
+ function buildPriorityActions(plugins) {
31
+ const actions = [];
32
+ const cleanReason = (reason) => reason.replace(/[.。]+$/u, "");
33
+ for (const plugin of plugins.filter((item) => item.status === "fail")) {
34
+ const validationFinding = plugin.validation.findings[0];
35
+ const securityFinding = plugin.security?.findings[0];
36
+ const compatibilityFinding = plugin.compatibility?.results.find((result) => result.status === "fail");
37
+ const reason = validationFinding?.id ??
38
+ securityFinding?.id ??
39
+ compatibilityFinding?.summary ??
40
+ "unknown failure";
41
+ actions.push(`${plugin.plugin.name}: fix ${cleanReason(reason)}.`);
42
+ }
43
+ for (const plugin of plugins.filter((item) => item.status === "warn")) {
44
+ const securityFinding = plugin.security?.findings[0];
45
+ const compatibilityFinding = plugin.compatibility?.results.find((result) => result.status === "warn");
46
+ const reason = securityFinding?.id ??
47
+ compatibilityFinding?.summary ??
48
+ plugin.validation.findings[0]?.id ??
49
+ "warning";
50
+ actions.push(`${plugin.plugin.name}: review ${cleanReason(reason)}.`);
51
+ }
52
+ return actions;
53
+ }
54
+ export async function buildEcosystemAudit(options) {
55
+ const installedPlugins = filterInstalledPlugins(await discoverInstalledPlugins({ env: options.env }), options.filter ?? null);
56
+ const environment = {
57
+ env: options.env,
58
+ platform: options.platform
59
+ };
60
+ const plugins = [];
61
+ for (const plugin of installedPlugins) {
62
+ const validation = await options.validatePlugin(plugin.rootPath);
63
+ const security = options.includeSecurity
64
+ ? await buildSecurityAudit(plugin.rootPath)
65
+ : undefined;
66
+ const compatibility = options.includeCompatibility
67
+ ? await buildCompatibilityMatrix(plugin.rootPath, environment)
68
+ : undefined;
69
+ const rawStatus = mergeStatus([
70
+ validation.status,
71
+ security?.status ?? "pass",
72
+ compatibilityStatus(compatibility)
73
+ ]);
74
+ const status = options.failOnWarnings && rawStatus === "warn"
75
+ ? "fail"
76
+ : rawStatus;
77
+ plugins.push({
78
+ plugin,
79
+ status,
80
+ validation,
81
+ ...(security ? { security } : {}),
82
+ ...(compatibility ? { compatibility } : {})
83
+ });
84
+ }
85
+ const summary = summarizePlugins(plugins);
86
+ const status = summary.fail > 0
87
+ ? "fail"
88
+ : summary.warn > 0
89
+ ? "warn"
90
+ : "pass";
91
+ return {
92
+ schemaVersion: "1.0.0",
93
+ generatedAt: new Date().toISOString(),
94
+ status,
95
+ summary,
96
+ plugins,
97
+ priorityActions: buildPriorityActions(plugins)
98
+ };
99
+ }
100
+ export function renderEcosystemAuditJson(report) {
101
+ return JSON.stringify(report, null, 2);
102
+ }
103
+ export function renderEcosystemAudit(report) {
104
+ const lines = [
105
+ "Local Ecosystem Audit",
106
+ "=====================",
107
+ `Status: ${report.status.toUpperCase()}`,
108
+ `Installed plugins: ${report.summary.totalPlugins}`,
109
+ `Summary: ${report.summary.fail} fail, ${report.summary.warn} warn, ${report.summary.pass} pass`
110
+ ];
111
+ if (report.plugins.length === 0) {
112
+ lines.push("", "No installed Codex plugins found.");
113
+ return lines.join("\n");
114
+ }
115
+ lines.push("", "Plugins", "-------");
116
+ for (const item of report.plugins) {
117
+ const version = item.plugin.version ? `@${item.plugin.version}` : "";
118
+ lines.push(`- ${item.plugin.name}${version}: ${item.status.toUpperCase()}`);
119
+ lines.push(` Validation: ${item.validation.status.toUpperCase()}`);
120
+ if (item.security) {
121
+ lines.push(` Security: ${item.security.status.toUpperCase()} (${item.security.score}/100)`);
122
+ }
123
+ if (item.compatibility) {
124
+ const compatibilitySummary = item.compatibility.results
125
+ .map((result) => `${result.client}=${result.status}`)
126
+ .join(", ");
127
+ lines.push(` Compatibility: ${compatibilitySummary}`);
128
+ }
129
+ }
130
+ if (report.priorityActions.length > 0) {
131
+ lines.push("", "Priority Actions", "----------------");
132
+ lines.push(...report.priorityActions.map((action) => `- ${action}`));
133
+ }
134
+ return lines.join("\n");
135
+ }
@@ -0,0 +1,20 @@
1
+ import type { CliTerminalContext } from "../run-cli.js";
2
+ import { type ClientDoctorResult, type EnvironmentDoctorReport } from "./environment-doctor.js";
3
+ import { type InstalledPlugin } from "./discover-installed-plugins.js";
4
+ export interface DoctorSnapshot {
5
+ schemaVersion: "1.0.0";
6
+ generatedAt: string;
7
+ version: string;
8
+ environment: EnvironmentDoctorReport;
9
+ clients: ClientDoctorResult[];
10
+ installedPlugins: {
11
+ count: number;
12
+ plugins: InstalledPlugin[];
13
+ };
14
+ recommendations: string[];
15
+ }
16
+ export declare function buildDoctorSnapshot(terminalContext: CliTerminalContext): Promise<DoctorSnapshot>;
17
+ export declare function renderDoctorSnapshotJson(snapshot: DoctorSnapshot): string;
18
+ export declare function renderDoctorSnapshot(snapshot: DoctorSnapshot, options?: {
19
+ outputPath?: string | null;
20
+ }): string;
@@ -0,0 +1,66 @@
1
+ import { buildClientDoctorReport, buildEnvironmentDoctorReport } from "./environment-doctor.js";
2
+ import { discoverInstalledPlugins } from "./discover-installed-plugins.js";
3
+ import { packageVersion } from "../version.js";
4
+ export async function buildDoctorSnapshot(terminalContext) {
5
+ const [environment, clients, installedPlugins] = await Promise.all([
6
+ buildEnvironmentDoctorReport(terminalContext),
7
+ buildClientDoctorReport(terminalContext),
8
+ discoverInstalledPlugins({ env: terminalContext.env })
9
+ ]);
10
+ return {
11
+ schemaVersion: "1.0.0",
12
+ generatedAt: new Date().toISOString(),
13
+ version: packageVersion,
14
+ environment,
15
+ clients,
16
+ installedPlugins: {
17
+ count: installedPlugins.length,
18
+ plugins: installedPlugins
19
+ },
20
+ recommendations: [
21
+ "codex-plugin-doctor self-test",
22
+ "codex-plugin-doctor list --installed",
23
+ "codex-plugin-doctor check --installed --all-summary",
24
+ "codex-plugin-doctor compat . --all --scorecard"
25
+ ]
26
+ };
27
+ }
28
+ export function renderDoctorSnapshotJson(snapshot) {
29
+ return JSON.stringify(snapshot, null, 2);
30
+ }
31
+ export function renderDoctorSnapshot(snapshot, options = {}) {
32
+ const passClients = snapshot.clients.filter((client) => client.status === "pass").length;
33
+ const warnClients = snapshot.clients.filter((client) => client.status === "warn").length;
34
+ const lines = [
35
+ "Codex Plugin Doctor Snapshot",
36
+ "============================",
37
+ `Generated: ${snapshot.generatedAt}`,
38
+ `Version: ${snapshot.version}`,
39
+ `Platform: ${snapshot.environment.platform}`,
40
+ `Node: ${snapshot.environment.node}`,
41
+ `Codex home: ${snapshot.environment.codexHome.status.toUpperCase()}${snapshot.environment.codexHome.path ? ` (${snapshot.environment.codexHome.path})` : ""}`,
42
+ `Codex plugin cache: ${snapshot.environment.codexPluginCache.status.toUpperCase()}${snapshot.environment.codexPluginCache.path ? ` (${snapshot.environment.codexPluginCache.path})` : ""}`,
43
+ `Installed plugins: ${snapshot.installedPlugins.count}`,
44
+ `Clients: ${passClients} pass, ${warnClients} warn`
45
+ ];
46
+ if (options.outputPath) {
47
+ lines.push(`Output: ${options.outputPath}`);
48
+ }
49
+ lines.push("", "Clients", "-------");
50
+ for (const client of snapshot.clients) {
51
+ lines.push(`${client.client}: ${client.status.toUpperCase()} - ${client.summary}`);
52
+ }
53
+ lines.push("", "Installed Plugins", "-----------------");
54
+ if (snapshot.installedPlugins.plugins.length === 0) {
55
+ lines.push("No installed Codex plugins found.");
56
+ }
57
+ else {
58
+ for (const plugin of snapshot.installedPlugins.plugins) {
59
+ const version = plugin.version ? `@${plugin.version}` : "";
60
+ lines.push(`- ${plugin.name}${version} (${plugin.relativePath})`);
61
+ }
62
+ }
63
+ lines.push("", "Recommended Next Commands", "-------------------------");
64
+ lines.push(...snapshot.recommendations);
65
+ return lines.join("\n");
66
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,7 @@
1
1
  import type { CheckOptions, CheckResult } from "./domain/types.js";
2
+ export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard, type SecurityAudit } from "./security/security-audit.js";
3
+ export { buildDoctorSnapshot, renderDoctorSnapshot, renderDoctorSnapshotJson, type DoctorSnapshot } from "./core/doctor-snapshot.js";
4
+ export { buildEcosystemAudit, renderEcosystemAudit, renderEcosystemAuditJson, type EcosystemAuditReport } from "./audit/ecosystem-audit.js";
5
+ export { applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames, type PolicyPackName } from "./policy/policy-packs.js";
6
+ export { buildGenericMcpDoctor, renderGenericMcpDoctor, renderGenericMcpDoctorJson, type GenericMcpDoctorReport } from "./mcp/generic-mcp-doctor.js";
2
7
  export declare function runCheck(targetPath: string, options?: CheckOptions): Promise<CheckResult>;
package/dist/index.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { validatePlugin } from "./core/validate-plugin.js";
2
+ export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
3
+ export { buildDoctorSnapshot, renderDoctorSnapshot, renderDoctorSnapshotJson } from "./core/doctor-snapshot.js";
4
+ export { buildEcosystemAudit, renderEcosystemAudit, renderEcosystemAuditJson } from "./audit/ecosystem-audit.js";
5
+ export { applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames } from "./policy/policy-packs.js";
6
+ export { buildGenericMcpDoctor, renderGenericMcpDoctor, renderGenericMcpDoctorJson } from "./mcp/generic-mcp-doctor.js";
2
7
  export async function runCheck(targetPath, options = {}) {
3
8
  return validatePlugin(targetPath, options);
4
9
  }
@@ -0,0 +1,16 @@
1
+ import { type CompatibilityEnvironment, type CompatibilityMatrix } from "../compatibility/compatibility-matrix.js";
2
+ import type { Finding } from "../domain/types.js";
3
+ import { type SecurityAudit } from "../security/security-audit.js";
4
+ export interface GenericMcpDoctorReport {
5
+ targetPath: string;
6
+ status: "pass" | "warn" | "fail";
7
+ exitCode: 0 | 1;
8
+ mcpConfigPath: string | null;
9
+ serverCount: number;
10
+ findings: Finding[];
11
+ security: SecurityAudit;
12
+ compatibility: CompatibilityMatrix;
13
+ }
14
+ export declare function buildGenericMcpDoctor(targetPath: string, environment?: CompatibilityEnvironment): Promise<GenericMcpDoctorReport>;
15
+ export declare function renderGenericMcpDoctorJson(report: GenericMcpDoctorReport): string;
16
+ export declare function renderGenericMcpDoctor(report: GenericMcpDoctorReport): string;
@@ -0,0 +1,166 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { buildCompatibilityMatrix, readMcpConfigPath } from "../compatibility/compatibility-matrix.js";
4
+ import { readJsonFile } from "../core/read-json-file.js";
5
+ import { auditMcpServerConfig, buildSecurityAuditFromFindings } from "../security/security-audit.js";
6
+ function buildFinding(severity, id, message, impact, suggestedFix) {
7
+ return {
8
+ id,
9
+ severity,
10
+ message,
11
+ impact,
12
+ suggestedFix
13
+ };
14
+ }
15
+ function isPlainObject(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ async function fileExists(targetPath) {
19
+ try {
20
+ const details = await stat(targetPath);
21
+ return details.isFile();
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ function isPathWithinRoot(rootPath, candidatePath) {
28
+ const relativePath = path.relative(rootPath, candidatePath);
29
+ return (relativePath === "" ||
30
+ (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
31
+ }
32
+ function buildStaticMcpFindings(mcpConfigPath, parsedConfig) {
33
+ if (!mcpConfigPath) {
34
+ return {
35
+ serverCount: 0,
36
+ findings: [
37
+ buildFinding("fail", "mcp.config.missing", "No MCP config was found for this target.", "A generic MCP package needs a `.mcp.json` file or a Codex manifest `mcpServers` reference before clients can discover servers.", "Add `.mcp.json` with a non-empty top-level `mcpServers` object.")
38
+ ]
39
+ };
40
+ }
41
+ if (!isPlainObject(parsedConfig)) {
42
+ return {
43
+ serverCount: 0,
44
+ findings: [
45
+ buildFinding("fail", "mcp.config.invalid_shape", "The MCP config must be a JSON object.", "MCP clients expect object-shaped configuration so server entries can be resolved deterministically.", `Wrap the MCP config in a top-level object inside \`${mcpConfigPath}\`.`)
46
+ ]
47
+ };
48
+ }
49
+ const servers = parsedConfig.mcpServers;
50
+ if (!isPlainObject(servers) || Object.keys(servers).length === 0) {
51
+ return {
52
+ serverCount: 0,
53
+ findings: [
54
+ buildFinding("fail", "mcp.config.invalid_shape", "The MCP config must contain a non-empty `mcpServers` object.", "Without server entries, MCP clients cannot discover any package capabilities.", `Define MCP servers under \`mcpServers\` in \`${mcpConfigPath}\`.`)
55
+ ]
56
+ };
57
+ }
58
+ const findings = [];
59
+ for (const [serverName, serverConfig] of Object.entries(servers)) {
60
+ if (!isPlainObject(serverConfig)) {
61
+ findings.push(buildFinding("fail", "mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "MCP clients cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${mcpConfigPath}\` to an object.`));
62
+ continue;
63
+ }
64
+ if (typeof serverConfig.command !== "string" && typeof serverConfig.url !== "string") {
65
+ findings.push(buildFinding("fail", "mcp.server.transport.missing", `The MCP server \`${serverName}\` must define either \`command\` or \`url\`.`, "MCP clients need a process command for stdio servers or a URL for remote servers.", `Add either \`command\` or \`url\` to the \`${serverName}\` entry in \`${mcpConfigPath}\`.`));
66
+ }
67
+ }
68
+ return {
69
+ serverCount: Object.keys(servers).length,
70
+ findings
71
+ };
72
+ }
73
+ function mergeReportStatus(staticFindings, security) {
74
+ if (staticFindings.some((finding) => finding.severity === "fail") || security.status === "fail") {
75
+ return "fail";
76
+ }
77
+ if (staticFindings.some((finding) => finding.severity === "warn") || security.status === "warn") {
78
+ return "warn";
79
+ }
80
+ return "pass";
81
+ }
82
+ export async function buildGenericMcpDoctor(targetPath, environment = {}) {
83
+ const rootPath = path.resolve(targetPath);
84
+ const compatibility = await buildCompatibilityMatrix(rootPath, environment);
85
+ const mcpConfigPath = await readMcpConfigPath(rootPath);
86
+ let parsedConfig = null;
87
+ let staticFindings = [];
88
+ let serverCount = 0;
89
+ if (!mcpConfigPath || !(await fileExists(mcpConfigPath))) {
90
+ staticFindings = buildStaticMcpFindings(null, null).findings;
91
+ }
92
+ else if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
93
+ staticFindings = [
94
+ buildFinding("fail", "mcp.config.path_outside_root", "The MCP config path resolves outside the target root.", "A package that reads MCP configuration outside its root is harder to audit and can depend on unreviewed local files.", "Keep `.mcp.json` or the manifest `mcpServers` reference inside the package root.")
95
+ ];
96
+ }
97
+ else {
98
+ try {
99
+ parsedConfig = await readJsonFile(mcpConfigPath);
100
+ const staticResult = buildStaticMcpFindings(mcpConfigPath, parsedConfig);
101
+ staticFindings = staticResult.findings;
102
+ serverCount = staticResult.serverCount;
103
+ }
104
+ catch {
105
+ staticFindings = [
106
+ buildFinding("fail", "mcp.config.invalid_json", "The MCP config is not valid JSON.", "MCP clients cannot parse server configuration until the JSON syntax is valid.", `Fix the JSON syntax in \`${mcpConfigPath}\`.`)
107
+ ];
108
+ }
109
+ }
110
+ const security = buildSecurityAuditFromFindings(rootPath, mcpConfigPath && parsedConfig !== null
111
+ ? auditMcpServerConfig(rootPath, parsedConfig)
112
+ : []);
113
+ const status = mergeReportStatus(staticFindings, security);
114
+ return {
115
+ targetPath: rootPath,
116
+ status,
117
+ exitCode: status === "fail" ? 1 : 0,
118
+ mcpConfigPath,
119
+ serverCount,
120
+ findings: [...staticFindings, ...security.findings],
121
+ security,
122
+ compatibility
123
+ };
124
+ }
125
+ export function renderGenericMcpDoctorJson(report) {
126
+ return JSON.stringify({
127
+ schemaVersion: "1.0.0",
128
+ generatedAt: new Date().toISOString(),
129
+ ...report
130
+ }, null, 2);
131
+ }
132
+ export function renderGenericMcpDoctor(report) {
133
+ const lines = [
134
+ "Generic MCP Doctor",
135
+ "==================",
136
+ `Target: ${report.targetPath}`,
137
+ `Status: ${report.status.toUpperCase()}`,
138
+ `MCP config: ${report.mcpConfigPath ?? "not found"}`,
139
+ `Servers: ${report.serverCount}`,
140
+ `Security: ${report.security.status.toUpperCase()} (${report.security.score}/100)`,
141
+ `Compatibility: ${report.compatibility.results
142
+ .map((result) => `${result.client}=${result.status}`)
143
+ .join(", ")}`
144
+ ];
145
+ if (report.findings.length === 0) {
146
+ lines.push("", "No findings.");
147
+ return lines.join("\n");
148
+ }
149
+ const failures = report.findings.filter((finding) => finding.severity === "fail");
150
+ const warnings = report.findings.filter((finding) => finding.severity === "warn");
151
+ const appendFindings = (title, findings, marker) => {
152
+ if (findings.length === 0) {
153
+ return;
154
+ }
155
+ lines.push("", title, "--------");
156
+ for (const finding of findings) {
157
+ lines.push(`${marker} ${finding.id}`);
158
+ lines.push(` Message: ${finding.message}`);
159
+ lines.push(` Impact: ${finding.impact}`);
160
+ lines.push(` Suggested fix: ${finding.suggestedFix}`);
161
+ }
162
+ };
163
+ appendFindings("Failures", failures, "x");
164
+ appendFindings("Warnings", warnings, "!");
165
+ return lines.join("\n");
166
+ }
@@ -0,0 +1,9 @@
1
+ import type { DoctorConfig } from "../core/doctor-config.js";
2
+ import type { SecurityAudit } from "../security/security-audit.js";
3
+ export declare const policyPackNames: readonly ["codex-publish", "mcp-strict", "security"];
4
+ export type PolicyPackName = (typeof policyPackNames)[number];
5
+ export declare function parsePolicyPack(value: string | null): PolicyPackName | null;
6
+ export declare function policyEnablesRuntime(policy: PolicyPackName | null): boolean;
7
+ export declare function policyFailsOnWarnings(policy: PolicyPackName | null): boolean;
8
+ export declare function applyPolicyToDoctorConfig(config: DoctorConfig, policy: PolicyPackName | null): DoctorConfig;
9
+ export declare function applyPolicyToSecurityAudit(audit: SecurityAudit, policy: PolicyPackName | null): SecurityAudit;
@@ -0,0 +1,33 @@
1
+ export const policyPackNames = ["codex-publish", "mcp-strict", "security"];
2
+ export function parsePolicyPack(value) {
3
+ if (!value) {
4
+ return null;
5
+ }
6
+ return policyPackNames.includes(value)
7
+ ? value
8
+ : null;
9
+ }
10
+ export function policyEnablesRuntime(policy) {
11
+ return policy === "codex-publish" || policy === "mcp-strict";
12
+ }
13
+ export function policyFailsOnWarnings(policy) {
14
+ return policy !== null;
15
+ }
16
+ export function applyPolicyToDoctorConfig(config, policy) {
17
+ if (!policyFailsOnWarnings(policy)) {
18
+ return config;
19
+ }
20
+ return {
21
+ ...config,
22
+ failOnWarnings: true
23
+ };
24
+ }
25
+ export function applyPolicyToSecurityAudit(audit, policy) {
26
+ if (!policyFailsOnWarnings(policy) || audit.status !== "warn") {
27
+ return audit;
28
+ }
29
+ return {
30
+ ...audit,
31
+ status: "fail"
32
+ };
33
+ }
@@ -0,0 +1,24 @@
1
+ export interface GitHubReleaseSyncState {
2
+ tagName: string;
3
+ isDraft: boolean;
4
+ isPrerelease: boolean;
5
+ }
6
+ export interface ReleaseSyncEvaluationInput {
7
+ version: string;
8
+ npmVersion: string;
9
+ remoteTagOutput: string;
10
+ githubRelease: GitHubReleaseSyncState | null;
11
+ latestReleaseTag: string;
12
+ }
13
+ export interface ReleaseSyncCheck {
14
+ id: string;
15
+ status: "pass" | "fail";
16
+ message: string;
17
+ }
18
+ export interface ReleaseSyncReport {
19
+ version: string;
20
+ status: "pass" | "fail";
21
+ checks: ReleaseSyncCheck[];
22
+ }
23
+ export declare function evaluateReleaseSync(input: ReleaseSyncEvaluationInput): ReleaseSyncReport;
24
+ export declare function renderReleaseSyncReport(report: ReleaseSyncReport): string;
@@ -0,0 +1,46 @@
1
+ function buildCheck(id, status, message) {
2
+ return {
3
+ id,
4
+ status,
5
+ message
6
+ };
7
+ }
8
+ export function evaluateReleaseSync(input) {
9
+ const expectedTag = `v${input.version}`;
10
+ const checks = [];
11
+ checks.push(input.npmVersion === input.version
12
+ ? buildCheck("npm.version", "pass", `npm latest is ${input.version}.`)
13
+ : buildCheck("npm.version", "fail", `npm latest is ${input.npmVersion || "missing"}, expected ${input.version}.`));
14
+ checks.push(input.remoteTagOutput.includes(`refs/tags/${expectedTag}`)
15
+ ? buildCheck("git.remote_tag", "pass", `Remote tag ${expectedTag} exists.`)
16
+ : buildCheck("git.remote_tag", "fail", `Remote tag ${expectedTag} is missing.`));
17
+ const releaseMatches = input.githubRelease?.tagName === expectedTag &&
18
+ !input.githubRelease.isDraft &&
19
+ !input.githubRelease.isPrerelease;
20
+ checks.push(releaseMatches
21
+ ? buildCheck("github.release", "pass", `GitHub release ${expectedTag} is published.`)
22
+ : buildCheck("github.release", "fail", input.githubRelease
23
+ ? `GitHub release state is tag=${input.githubRelease.tagName}, draft=${input.githubRelease.isDraft}, prerelease=${input.githubRelease.isPrerelease}; expected published ${expectedTag}.`
24
+ : `GitHub release ${expectedTag} is missing.`));
25
+ checks.push(input.latestReleaseTag === expectedTag
26
+ ? buildCheck("github.latest_release", "pass", `GitHub latest release is ${expectedTag}.`)
27
+ : buildCheck("github.latest_release", "fail", `GitHub latest release is ${input.latestReleaseTag || "missing"}, expected ${expectedTag}.`));
28
+ return {
29
+ version: input.version,
30
+ status: checks.some((check) => check.status === "fail") ? "fail" : "pass",
31
+ checks
32
+ };
33
+ }
34
+ export function renderReleaseSyncReport(report) {
35
+ const lines = [
36
+ "Release Sync Verification",
37
+ "=========================",
38
+ `Version: ${report.version}`,
39
+ `Status: ${report.status.toUpperCase()}`
40
+ ];
41
+ for (const check of report.checks) {
42
+ lines.push("", `${check.status === "pass" ? "ok" : "x"} ${check.id}`);
43
+ lines.push(` ${check.message}`);
44
+ }
45
+ return lines.join("\n");
46
+ }
@@ -161,6 +161,60 @@ export const ruleCatalog = [
161
161
  fix: "Replace literal secrets with environment references or externally injected secrets.",
162
162
  example: '{ "env": { "OPENAI_API_KEY": "${OPENAI_API_KEY}" } }'
163
163
  },
164
+ {
165
+ id: "plugin.security.audit_unavailable",
166
+ category: "security",
167
+ defaultSeverity: "fail",
168
+ summary: "The security audit could not inspect the package surface.",
169
+ why: "A missing manifest or unreadable MCP configuration prevents the tool from evaluating package-local execution risks.",
170
+ fix: "Run against a valid Codex plugin root and fix `.mcp.json` syntax or shape errors before auditing.",
171
+ example: "codex-plugin-doctor security examples/codex-doctor-runtime"
172
+ },
173
+ {
174
+ id: "plugin.security.command_shell_wrapper",
175
+ category: "security",
176
+ defaultSeverity: "warn",
177
+ summary: "An MCP server starts through a shell wrapper.",
178
+ why: "Shell wrappers can hide quoting, pipes, aliases, and platform-specific execution behavior from reviewers.",
179
+ fix: "Launch the concrete executable directly with explicit args.",
180
+ example: '{ "command": "node", "args": ["server.js"] }'
181
+ },
182
+ {
183
+ id: "plugin.security.encoded_command",
184
+ category: "security",
185
+ defaultSeverity: "fail",
186
+ summary: "An MCP server uses an encoded shell command.",
187
+ why: "Encoded payloads hide the executed script and make supply-chain review unreliable.",
188
+ fix: "Replace encoded command payloads with a checked-in script or direct executable plus readable args.",
189
+ example: '{ "command": "node", "args": ["scripts/server.js"] }'
190
+ },
191
+ {
192
+ id: "plugin.security.remote_pipe_install",
193
+ category: "security",
194
+ defaultSeverity: "fail",
195
+ summary: "An MCP server pipes remote content into a shell.",
196
+ why: "Download-and-execute startup patterns can run unreviewed remote code as soon as a client starts the server.",
197
+ fix: "Pin dependencies through a package manager or check in a reviewed setup script.",
198
+ example: '{ "command": "npx", "args": ["-y", "@scope/server"] }'
199
+ },
200
+ {
201
+ id: "plugin.security.cwd_outside_root",
202
+ category: "security",
203
+ defaultSeverity: "fail",
204
+ summary: "An MCP server sets `cwd` outside the plugin root.",
205
+ why: "External working directories make startup depend on local files that are not part of the reviewed package.",
206
+ fix: "Keep `cwd` inside the plugin root or remove it.",
207
+ example: '{ "cwd": "." }'
208
+ },
209
+ {
210
+ id: "plugin.security.insecure_http_url",
211
+ category: "security",
212
+ defaultSeverity: "warn",
213
+ summary: "An MCP server uses a plain HTTP URL.",
214
+ why: "Plain HTTP can expose MCP traffic and does not verify endpoint identity on non-local networks.",
215
+ fix: "Use HTTPS for remote MCP servers; reserve HTTP for explicit localhost development endpoints.",
216
+ example: '{ "url": "https://example.com/mcp" }'
217
+ },
164
218
  {
165
219
  id: "plugin.runtime.exited_early",
166
220
  category: "runtime",
package/dist/run-cli.js CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { discoverInstalledPlugins, filterInstalledPlugins } from "./core/discover-installed-plugins.js";
7
+ import { buildEcosystemAudit, renderEcosystemAudit, renderEcosystemAuditJson } from "./audit/ecosystem-audit.js";
7
8
  import { appendValidationHistoryEntry, readValidationHistory, summarizeValidationHistory } from "./core/validation-history.js";
8
9
  import { buildCompatibilityMatrix, matrixExitCode } from "./compatibility/compatibility-matrix.js";
9
10
  import { applyInstallPreview, renderApplyInstallResult } from "./compatibility/apply-install-preview.js";
@@ -12,11 +13,13 @@ import { buildCursorInstallPreview, renderCursorInstallPreview } from "./compati
12
13
  import { buildClineInstallPreview, renderClineInstallPreview } from "./compatibility/cline-install-preview.js";
13
14
  import { buildWindsurfInstallPreview, renderWindsurfInstallPreview } from "./compatibility/windsurf-install-preview.js";
14
15
  import { applyDoctorConfig, loadDoctorConfig } from "./core/doctor-config.js";
16
+ import { buildDoctorSnapshot, renderDoctorSnapshot, renderDoctorSnapshotJson } from "./core/doctor-snapshot.js";
15
17
  import { applyFixPlan, buildFixPlan, renderApplyFixResult, renderFixPlanJsonReport, renderFixPlan } from "./core/fix-plan.js";
16
18
  import { renderClientDoctor, renderEnvironmentDoctor, renderEnvironmentDoctorJson } from "./core/environment-doctor.js";
17
19
  import { initCiWorkflow } from "./core/init-ci.js";
18
20
  import { initPluginPackage, initPluginTemplates, isInitPluginTemplate } from "./core/init-plugin.js";
19
21
  import { runCheck } from "./index.js";
22
+ import { buildGenericMcpDoctor, renderGenericMcpDoctor, renderGenericMcpDoctorJson } from "./mcp/generic-mcp-doctor.js";
20
23
  import { renderInstalledSummary } from "./reporting/render-installed-summary.js";
21
24
  import { renderBadgeJson, renderBadgeMarkdown } from "./reporting/render-badge-report.js";
22
25
  import { renderCompatibilityScorecard } from "./reporting/render-compatibility-scorecard.js";
@@ -27,7 +30,9 @@ import { buildMarkdownReport } from "./reporting/render-markdown-report.js";
27
30
  import { renderRuleExplanation } from "./reporting/render-rule-explanation.js";
28
31
  import { renderSarifReport } from "./reporting/render-sarif-report.js";
29
32
  import { renderTextReport } from "./reporting/render-text-report.js";
33
+ import { applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames } from "./policy/policy-packs.js";
30
34
  import { findRuleDefinition } from "./rules/rule-catalog.js";
35
+ import { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
31
36
  import { createLiveStatusRenderer } from "./terminal/live-status-renderer.js";
32
37
  import { determineOutputPolicy } from "./terminal/output-policy.js";
33
38
  import { getSpinner } from "./terminal/spinner-registry.js";
@@ -53,7 +58,7 @@ const defaultIo = {
53
58
  }
54
59
  };
55
60
  function printUsage(io) {
56
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--verbose-runtime] [--explain] [--no-animations] [--ascii]\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 [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");
61
+ 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>]\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 [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");
57
62
  }
58
63
  function renderInstalledPlugins(plugins) {
59
64
  const lines = [
@@ -186,6 +191,24 @@ export async function runCli(args, io = defaultIo, options = {}) {
186
191
  const doctorFlags = maybePath?.startsWith("--")
187
192
  ? [maybePath, ...remainingArgs]
188
193
  : remainingArgs;
194
+ if (maybePath === "snapshot") {
195
+ const jsonOutput = doctorFlags.includes("--json");
196
+ const outputIndex = doctorFlags.indexOf("--output");
197
+ const outputPath = outputIndex === -1 ? null : doctorFlags[outputIndex + 1];
198
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
199
+ io.writeStderr("Missing path after --output.");
200
+ return 2;
201
+ }
202
+ const snapshot = await buildDoctorSnapshot(terminalContext);
203
+ const snapshotJson = renderDoctorSnapshotJson(snapshot);
204
+ if (outputPath) {
205
+ await writeFile(outputPath, snapshotJson, "utf8");
206
+ }
207
+ io.writeStdout(jsonOutput
208
+ ? snapshotJson
209
+ : renderDoctorSnapshot(snapshot, { outputPath }));
210
+ return 0;
211
+ }
189
212
  if (doctorFlags.includes("--update-check")) {
190
213
  const latestVersion = await (options.resolveLatestVersion ?? resolveLatestNpmVersion)();
191
214
  io.writeStdout(renderUpdateCheck(latestVersion));
@@ -352,6 +375,114 @@ export async function runCli(args, io = defaultIo, options = {}) {
352
375
  : renderApplyFixResult(result));
353
376
  return 0;
354
377
  }
378
+ if (command === "security") {
379
+ if (!maybePath || maybePath.startsWith("--")) {
380
+ io.writeStderr("Missing target path. Usage: codex-plugin-doctor security <path> [--json|--scorecard]");
381
+ return 2;
382
+ }
383
+ const jsonOutput = remainingArgs.includes("--json");
384
+ const scorecardOutput = remainingArgs.includes("--scorecard");
385
+ const policyIndex = remainingArgs.indexOf("--policy");
386
+ const policyName = policyIndex === -1 ? null : remainingArgs[policyIndex + 1];
387
+ const policy = parsePolicyPack(policyName);
388
+ if (jsonOutput && scorecardOutput) {
389
+ io.writeStderr("Use either --json or --scorecard, not both.");
390
+ return 2;
391
+ }
392
+ if (policyIndex !== -1 && (!policyName || policyName.startsWith("--"))) {
393
+ io.writeStderr("Missing policy after --policy.");
394
+ return 2;
395
+ }
396
+ if (policyIndex !== -1 && !policy) {
397
+ io.writeStderr(`Unknown policy: ${policyName}. Supported policies: ${policyPackNames.join(", ")}.`);
398
+ return 2;
399
+ }
400
+ const audit = applyPolicyToSecurityAudit(await buildSecurityAudit(maybePath), policy);
401
+ io.writeStdout(jsonOutput
402
+ ? renderSecurityAuditJson(audit)
403
+ : renderSecurityScorecard(audit, { includeFindings: !scorecardOutput }));
404
+ return audit.status === "fail" ? 1 : 0;
405
+ }
406
+ if (command === "mcp") {
407
+ if (!maybePath || maybePath.startsWith("--")) {
408
+ io.writeStderr("Missing target path. Usage: codex-plugin-doctor mcp <path> [--json] [--output <path>]");
409
+ return 2;
410
+ }
411
+ const jsonOutput = remainingArgs.includes("--json");
412
+ const outputIndex = remainingArgs.indexOf("--output");
413
+ const outputPath = outputIndex === -1 ? null : remainingArgs[outputIndex + 1];
414
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
415
+ io.writeStderr("Missing path after --output.");
416
+ return 2;
417
+ }
418
+ const report = await buildGenericMcpDoctor(maybePath, {
419
+ env: terminalContext.env,
420
+ platform: terminalContext.platform
421
+ });
422
+ const renderedReport = jsonOutput
423
+ ? renderGenericMcpDoctorJson(report)
424
+ : renderGenericMcpDoctor(report);
425
+ if (outputPath) {
426
+ await writeFile(outputPath, renderedReport, "utf8");
427
+ }
428
+ io.writeStdout(renderedReport);
429
+ return report.exitCode;
430
+ }
431
+ if (command === "audit") {
432
+ const auditFlags = maybePath ? [maybePath, ...remainingArgs] : remainingArgs;
433
+ const installed = auditFlags.includes("--installed");
434
+ if (!installed) {
435
+ io.writeStderr("Usage: codex-plugin-doctor audit --installed [filter] [--security] [--compat] [--json] [--output <path>]");
436
+ return 2;
437
+ }
438
+ const installedIndex = auditFlags.indexOf("--installed");
439
+ const installedFilter = auditFlags[installedIndex + 1] && !auditFlags[installedIndex + 1].startsWith("--")
440
+ ? auditFlags[installedIndex + 1]
441
+ : null;
442
+ const jsonOutput = auditFlags.includes("--json");
443
+ const includeSecurity = auditFlags.includes("--security");
444
+ const includeCompatibility = auditFlags.includes("--compat");
445
+ const outputIndex = auditFlags.indexOf("--output");
446
+ const outputPath = outputIndex === -1 ? null : auditFlags[outputIndex + 1];
447
+ const policyIndex = auditFlags.indexOf("--policy");
448
+ const policyName = policyIndex === -1 ? null : auditFlags[policyIndex + 1];
449
+ const policy = parsePolicyPack(policyName);
450
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
451
+ io.writeStderr("Missing path after --output.");
452
+ return 2;
453
+ }
454
+ if (policyIndex !== -1 && (!policyName || policyName.startsWith("--"))) {
455
+ io.writeStderr("Missing policy after --policy.");
456
+ return 2;
457
+ }
458
+ if (policyIndex !== -1 && !policy) {
459
+ io.writeStderr(`Unknown policy: ${policyName}. Supported policies: ${policyPackNames.join(", ")}.`);
460
+ return 2;
461
+ }
462
+ const report = await buildEcosystemAudit({
463
+ env: terminalContext.env,
464
+ platform: terminalContext.platform,
465
+ filter: installedFilter,
466
+ includeSecurity,
467
+ includeCompatibility,
468
+ failOnWarnings: policyFailsOnWarnings(policy),
469
+ validatePlugin: options.runCheckImpl ?? runCheck
470
+ });
471
+ if (report.summary.totalPlugins === 0) {
472
+ io.writeStderr(installedFilter
473
+ ? `No installed Codex plugins matched '${installedFilter}'.`
474
+ : "No installed Codex plugins found.");
475
+ return 1;
476
+ }
477
+ const renderedReport = jsonOutput
478
+ ? renderEcosystemAuditJson(report)
479
+ : renderEcosystemAudit(report);
480
+ if (outputPath) {
481
+ await writeFile(outputPath, renderedReport, "utf8");
482
+ }
483
+ io.writeStdout(renderedReport);
484
+ return report.status === "fail" ? 1 : 0;
485
+ }
355
486
  if (command === "compat") {
356
487
  const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
357
488
  const compatFlags = maybePath && maybePath.startsWith("--")
@@ -503,6 +634,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
503
634
  const profileIndex = normalizedFlags.indexOf("--profile");
504
635
  const profileName = profileIndex === -1 ? null : normalizedFlags[profileIndex + 1];
505
636
  const checkProfile = parseCheckProfile(profileName);
637
+ const policyIndex = normalizedFlags.indexOf("--policy");
638
+ const policyName = policyIndex === -1 ? null : normalizedFlags[policyIndex + 1];
639
+ const policy = parsePolicyPack(policyName);
506
640
  const historyIndex = normalizedFlags.indexOf("--history");
507
641
  const historyPath = historyIndex === -1 ? null : normalizedFlags[historyIndex + 1];
508
642
  if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
@@ -521,6 +655,14 @@ export async function runCli(args, io = defaultIo, options = {}) {
521
655
  io.writeStderr("Unknown profile. Supported profiles: ci, strict, publish.");
522
656
  return 2;
523
657
  }
658
+ if (policyIndex !== -1 && (!policyName || policyName.startsWith("--"))) {
659
+ io.writeStderr("Missing policy after --policy.");
660
+ return 2;
661
+ }
662
+ if (policyIndex !== -1 && !policy) {
663
+ io.writeStderr(`Unknown policy: ${policyName}. Supported policies: ${policyPackNames.join(", ")}.`);
664
+ return 2;
665
+ }
524
666
  if (historyIndex !== -1 && (!historyPath || historyPath.startsWith("--"))) {
525
667
  io.writeStderr("Missing path after --history.");
526
668
  return 2;
@@ -533,7 +675,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
533
675
  io.writeStderr("History output requires a single package target.");
534
676
  return 2;
535
677
  }
536
- const effectiveRuntimeProbeEnabled = runtimeProbeEnabled || checkProfile === "publish";
678
+ const effectiveRuntimeProbeEnabled = runtimeProbeEnabled ||
679
+ checkProfile === "publish" ||
680
+ policyEnablesRuntime(policy);
537
681
  const outputPolicy = determineOutputPolicy({
538
682
  jsonOutput: jsonOutput || badgeJsonOutput,
539
683
  markdownOutput: markdownOutput || badgeMarkdownOutput,
@@ -569,7 +713,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
569
713
  runtimeTranscript: effectiveRuntimeProbeEnabled && verboseRuntime
570
714
  ? (line) => io.writeStderr(line)
571
715
  : undefined
572
- }), applyCheckProfile(config, checkProfile)),
716
+ }), applyPolicyToDoctorConfig(applyCheckProfile(config, checkProfile), policy)),
573
717
  compatibilityMatrix
574
718
  });
575
719
  }
@@ -606,7 +750,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
606
750
  runtimeTranscript: effectiveRuntimeProbeEnabled && verboseRuntime
607
751
  ? (line) => io.writeStderr(line)
608
752
  : undefined
609
- }), applyCheckProfile(await loadDoctorConfig(targetPath, configPath), checkProfile));
753
+ }), applyPolicyToDoctorConfig(applyCheckProfile(await loadDoctorConfig(targetPath, configPath), checkProfile), policy));
610
754
  if (renderer) {
611
755
  if (result.status === "fail") {
612
756
  renderer.stopFailure("Validation failed");
@@ -0,0 +1,19 @@
1
+ import type { Finding } from "../domain/types.js";
2
+ export interface SecurityAudit {
3
+ targetPath: string;
4
+ status: "pass" | "warn" | "fail";
5
+ score: number;
6
+ findingCounts: {
7
+ fail: number;
8
+ warn: number;
9
+ total: number;
10
+ };
11
+ findings: Finding[];
12
+ }
13
+ export declare function auditMcpServerConfig(rootPath: string, parsedConfig: unknown): Finding[];
14
+ export declare function buildSecurityAuditFromFindings(targetPath: string, findings: Finding[]): SecurityAudit;
15
+ export declare function buildSecurityAudit(targetPath: string): Promise<SecurityAudit>;
16
+ export declare function renderSecurityAuditJson(audit: SecurityAudit): string;
17
+ export declare function renderSecurityScorecard(audit: SecurityAudit, options?: {
18
+ includeFindings?: boolean;
19
+ }): string;
@@ -0,0 +1,199 @@
1
+ import path from "node:path";
2
+ import { discoverPackage } from "../core/discover-package.js";
3
+ import { readJsonFile } from "../core/read-json-file.js";
4
+ import { validatePlugin } from "../core/validate-plugin.js";
5
+ function buildFinding(severity, id, message, impact, suggestedFix) {
6
+ return {
7
+ id,
8
+ severity,
9
+ message,
10
+ impact,
11
+ suggestedFix
12
+ };
13
+ }
14
+ function isPlainObject(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ function isPathWithinRoot(rootPath, candidatePath) {
18
+ const relativePath = path.relative(rootPath, candidatePath);
19
+ return (relativePath === "" ||
20
+ (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
21
+ }
22
+ function normalizeCommandName(command) {
23
+ return path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat)$/i, "");
24
+ }
25
+ function isShellWrapperCommand(command) {
26
+ return new Set(["cmd", "powershell", "pwsh", "bash", "sh"]).has(normalizeCommandName(command));
27
+ }
28
+ function containsEncodedCommandFlag(args) {
29
+ return Array.isArray(args) && args.some((arg) => typeof arg === "string" && /^[-/]enc(odedcommand)?$/i.test(arg));
30
+ }
31
+ function containsPipeInstaller(args) {
32
+ if (!Array.isArray(args)) {
33
+ return false;
34
+ }
35
+ const joinedArgs = args
36
+ .filter((arg) => typeof arg === "string")
37
+ .join(" ")
38
+ .toLowerCase();
39
+ return (/\b(curl|wget)\b[^|]*\|\s*(sh|bash)\b/.test(joinedArgs) ||
40
+ /\b(iwr|irm|invoke-webrequest|invoke-restmethod)\b[^|]*\|\s*(iex|invoke-expression)\b/.test(joinedArgs) ||
41
+ /\binvoke-expression\b/.test(joinedArgs));
42
+ }
43
+ export function auditMcpServerConfig(rootPath, parsedConfig) {
44
+ if (!isPlainObject(parsedConfig) || !isPlainObject(parsedConfig.mcpServers)) {
45
+ return [
46
+ 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.")
47
+ ];
48
+ }
49
+ const findings = [];
50
+ for (const [serverName, serverConfig] of Object.entries(parsedConfig.mcpServers)) {
51
+ if (!isPlainObject(serverConfig)) {
52
+ continue;
53
+ }
54
+ const command = serverConfig.command;
55
+ const args = serverConfig.args;
56
+ const cwd = serverConfig.cwd;
57
+ const url = serverConfig.url;
58
+ if (typeof command === "string" && isShellWrapperCommand(command)) {
59
+ 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."));
60
+ }
61
+ if (containsEncodedCommandFlag(args)) {
62
+ 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."));
63
+ }
64
+ if (containsPipeInstaller(args)) {
65
+ 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."));
66
+ }
67
+ if (typeof cwd === "string") {
68
+ const cwdPath = path.resolve(rootPath, cwd);
69
+ if (!isPathWithinRoot(rootPath, cwdPath)) {
70
+ 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."));
71
+ }
72
+ }
73
+ if (typeof url === "string" && /^http:\/\//i.test(url)) {
74
+ 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."));
75
+ }
76
+ }
77
+ return findings;
78
+ }
79
+ async function auditMcpCommandSurface(discoveredPackage) {
80
+ const { manifest, rootPath } = discoveredPackage;
81
+ if (!manifest.mcpServers) {
82
+ return [];
83
+ }
84
+ const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers);
85
+ if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
86
+ return [];
87
+ }
88
+ let parsedConfig;
89
+ try {
90
+ parsedConfig = await readJsonFile(mcpConfigPath);
91
+ }
92
+ catch {
93
+ return [
94
+ 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>`.")
95
+ ];
96
+ }
97
+ return auditMcpServerConfig(rootPath, parsedConfig);
98
+ }
99
+ function dedupeFindings(findings) {
100
+ const seen = new Set();
101
+ return findings.filter((finding) => {
102
+ const key = `${finding.id}\n${finding.message}`;
103
+ if (seen.has(key)) {
104
+ return false;
105
+ }
106
+ seen.add(key);
107
+ return true;
108
+ });
109
+ }
110
+ function buildFindingCounts(findings) {
111
+ const fail = findings.filter((finding) => finding.severity === "fail").length;
112
+ const warn = findings.filter((finding) => finding.severity === "warn").length;
113
+ return {
114
+ fail,
115
+ warn,
116
+ total: findings.length
117
+ };
118
+ }
119
+ function scoreSecurityAudit(findingCounts) {
120
+ return Math.max(0, 100 - (findingCounts.fail * 35) - (findingCounts.warn * 10));
121
+ }
122
+ export function buildSecurityAuditFromFindings(targetPath, findings) {
123
+ const dedupedFindings = dedupeFindings(findings);
124
+ const findingCounts = buildFindingCounts(dedupedFindings);
125
+ const status = findingCounts.fail > 0
126
+ ? "fail"
127
+ : findingCounts.warn > 0
128
+ ? "warn"
129
+ : "pass";
130
+ return {
131
+ targetPath: path.resolve(targetPath),
132
+ status,
133
+ score: scoreSecurityAudit(findingCounts),
134
+ findingCounts,
135
+ findings: dedupedFindings
136
+ };
137
+ }
138
+ export async function buildSecurityAudit(targetPath) {
139
+ const discoveredPackage = await discoverPackage(targetPath);
140
+ if (!discoveredPackage) {
141
+ const findings = [
142
+ 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.")
143
+ ];
144
+ const findingCounts = buildFindingCounts(findings);
145
+ return {
146
+ targetPath: path.resolve(targetPath),
147
+ status: "fail",
148
+ score: scoreSecurityAudit(findingCounts),
149
+ findingCounts,
150
+ findings
151
+ };
152
+ }
153
+ const validationResult = await validatePlugin(discoveredPackage.rootPath);
154
+ const validationSecurityFindings = validationResult.findings.filter((finding) => finding.id.startsWith("plugin.security."));
155
+ const findings = [
156
+ ...validationSecurityFindings,
157
+ ...(await auditMcpCommandSurface(discoveredPackage))
158
+ ];
159
+ return buildSecurityAuditFromFindings(discoveredPackage.rootPath, findings);
160
+ }
161
+ export function renderSecurityAuditJson(audit) {
162
+ return JSON.stringify({
163
+ schemaVersion: "1.0.0",
164
+ generatedAt: new Date().toISOString(),
165
+ ...audit
166
+ }, null, 2);
167
+ }
168
+ export function renderSecurityScorecard(audit, options = {}) {
169
+ const lines = [
170
+ "Security Scorecard",
171
+ "==================",
172
+ `Target: ${audit.targetPath}`,
173
+ `Status: ${audit.status.toUpperCase()}`,
174
+ `Score: ${audit.score}/100`,
175
+ `Summary: ${audit.findingCounts.fail} fail, ${audit.findingCounts.warn} warn, ${audit.findingCounts.total} total`
176
+ ];
177
+ if (audit.findings.length === 0) {
178
+ lines.push("", "No security findings.");
179
+ return lines.join("\n");
180
+ }
181
+ if (options.includeFindings === false) {
182
+ return lines.join("\n");
183
+ }
184
+ const appendSection = (title, findings, marker) => {
185
+ if (findings.length === 0) {
186
+ return;
187
+ }
188
+ lines.push("", title, "--------");
189
+ for (const finding of findings) {
190
+ lines.push(`${marker} ${finding.id}`);
191
+ lines.push(` Message: ${finding.message}`);
192
+ lines.push(` Impact: ${finding.impact}`);
193
+ lines.push(` Suggested fix: ${finding.suggestedFix}`);
194
+ }
195
+ };
196
+ appendSection("Failures", audit.findings.filter((finding) => finding.severity === "fail"), "x");
197
+ appendSection("Warnings", audit.findings.filter((finding) => finding.severity === "warn"), "!");
198
+ return lines.join("\n");
199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "0.10.1",
3
+ "version": "0.12.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",
@@ -27,6 +27,7 @@
27
27
  "prepare-rc": "tsx scripts/prepare-release-candidate.ts",
28
28
  "prepare-release": "npm test && npm run build && npm pack --dry-run",
29
29
  "release-check": "node scripts/release-check.mjs",
30
+ "verify-release-sync": "node scripts/verify-release-sync.mjs",
30
31
  "prepublishOnly": "npm test && npm run build",
31
32
  "test": "vitest run",
32
33
  "test:watch": "vitest"