codex-plugin-doctor 0.10.0 → 0.11.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`
@@ -165,12 +173,18 @@ Run these from a Codex plugin package root:
165
173
  codex-plugin-doctor --version
166
174
  codex-plugin-doctor self-test
167
175
  codex-plugin-doctor doctor
176
+ codex-plugin-doctor doctor snapshot
177
+ codex-plugin-doctor doctor snapshot --json
178
+ codex-plugin-doctor doctor snapshot --output doctor-snapshot.json
168
179
  codex-plugin-doctor doctor clients
169
180
  codex-plugin-doctor doctor --update-check
170
181
  codex-plugin-doctor init my-plugin
171
182
  codex-plugin-doctor init my-mcp --template mcp-stdio
172
183
  codex-plugin-doctor init remote-mcp --template mcp-http
173
184
  codex-plugin-doctor init runtime-demo --template full-runtime
185
+ codex-plugin-doctor security .
186
+ codex-plugin-doctor security . --scorecard
187
+ codex-plugin-doctor security . --json
174
188
  codex-plugin-doctor compat .
175
189
  codex-plugin-doctor compat . --all --scorecard
176
190
  codex-plugin-doctor compat . --client codex
@@ -213,10 +227,12 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
213
227
 
214
228
  `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
229
 
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.
230
+ `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.
217
231
 
218
232
  `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
233
 
234
+ `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.
235
+
220
236
  `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
237
 
222
238
  `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,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
+ }
@@ -4,6 +4,11 @@ import { validatePlugin } from "./validate-plugin.js";
4
4
  function relativeToTarget(targetPath, candidatePath) {
5
5
  return path.relative(targetPath, candidatePath).replace(/\\/g, "/");
6
6
  }
7
+ function isPathWithinRoot(rootPath, candidatePath) {
8
+ const relativePath = path.relative(rootPath, candidatePath);
9
+ return (relativePath === "" ||
10
+ (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
11
+ }
7
12
  export async function buildFixPlan(targetPath) {
8
13
  const result = await validatePlugin(targetPath);
9
14
  const rootPath = result.targetPath;
@@ -38,6 +43,12 @@ export async function buildFixPlan(targetPath) {
38
43
  .catch(() => ({}));
39
44
  if (typeof manifest.skills === "string") {
40
45
  const skillsPath = path.resolve(rootPath, manifest.skills);
46
+ if (!isPathWithinRoot(rootPath, skillsPath)) {
47
+ return {
48
+ targetPath: rootPath,
49
+ actions
50
+ };
51
+ }
41
52
  if (await directoryExists(skillsPath)) {
42
53
  for (const entry of await readdir(skillsPath, { withFileTypes: true })) {
43
54
  if (!entry.isDirectory()) {
@@ -70,6 +81,12 @@ export async function buildFixPlan(targetPath) {
70
81
  }
71
82
  if (typeof manifest.mcpServers === "string") {
72
83
  const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers);
84
+ if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
85
+ return {
86
+ targetPath: rootPath,
87
+ actions
88
+ };
89
+ }
73
90
  if (!(await fileExists(mcpConfigPath))) {
74
91
  actions.push({
75
92
  id: "mcp.scaffold_config",
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
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";
2
4
  export declare function runCheck(targetPath: string, options?: CheckOptions): Promise<CheckResult>;
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
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";
2
4
  export async function runCheck(targetPath, options = {}) {
3
5
  return validatePlugin(targetPath, options);
4
6
  }
@@ -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
@@ -12,6 +12,7 @@ import { buildCursorInstallPreview, renderCursorInstallPreview } from "./compati
12
12
  import { buildClineInstallPreview, renderClineInstallPreview } from "./compatibility/cline-install-preview.js";
13
13
  import { buildWindsurfInstallPreview, renderWindsurfInstallPreview } from "./compatibility/windsurf-install-preview.js";
14
14
  import { applyDoctorConfig, loadDoctorConfig } from "./core/doctor-config.js";
15
+ import { buildDoctorSnapshot, renderDoctorSnapshot, renderDoctorSnapshotJson } from "./core/doctor-snapshot.js";
15
16
  import { applyFixPlan, buildFixPlan, renderApplyFixResult, renderFixPlanJsonReport, renderFixPlan } from "./core/fix-plan.js";
16
17
  import { renderClientDoctor, renderEnvironmentDoctor, renderEnvironmentDoctorJson } from "./core/environment-doctor.js";
17
18
  import { initCiWorkflow } from "./core/init-ci.js";
@@ -28,6 +29,7 @@ import { renderRuleExplanation } from "./reporting/render-rule-explanation.js";
28
29
  import { renderSarifReport } from "./reporting/render-sarif-report.js";
29
30
  import { renderTextReport } from "./reporting/render-text-report.js";
30
31
  import { findRuleDefinition } from "./rules/rule-catalog.js";
32
+ import { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
31
33
  import { createLiveStatusRenderer } from "./terminal/live-status-renderer.js";
32
34
  import { determineOutputPolicy } from "./terminal/output-policy.js";
33
35
  import { getSpinner } from "./terminal/spinner-registry.js";
@@ -53,7 +55,7 @@ const defaultIo = {
53
55
  }
54
56
  };
55
57
  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");
58
+ 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 security <path> [--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
59
  }
58
60
  function renderInstalledPlugins(plugins) {
59
61
  const lines = [
@@ -186,6 +188,24 @@ export async function runCli(args, io = defaultIo, options = {}) {
186
188
  const doctorFlags = maybePath?.startsWith("--")
187
189
  ? [maybePath, ...remainingArgs]
188
190
  : remainingArgs;
191
+ if (maybePath === "snapshot") {
192
+ const jsonOutput = doctorFlags.includes("--json");
193
+ const outputIndex = doctorFlags.indexOf("--output");
194
+ const outputPath = outputIndex === -1 ? null : doctorFlags[outputIndex + 1];
195
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
196
+ io.writeStderr("Missing path after --output.");
197
+ return 2;
198
+ }
199
+ const snapshot = await buildDoctorSnapshot(terminalContext);
200
+ const snapshotJson = renderDoctorSnapshotJson(snapshot);
201
+ if (outputPath) {
202
+ await writeFile(outputPath, snapshotJson, "utf8");
203
+ }
204
+ io.writeStdout(jsonOutput
205
+ ? snapshotJson
206
+ : renderDoctorSnapshot(snapshot, { outputPath }));
207
+ return 0;
208
+ }
189
209
  if (doctorFlags.includes("--update-check")) {
190
210
  const latestVersion = await (options.resolveLatestVersion ?? resolveLatestNpmVersion)();
191
211
  io.writeStdout(renderUpdateCheck(latestVersion));
@@ -352,6 +372,23 @@ export async function runCli(args, io = defaultIo, options = {}) {
352
372
  : renderApplyFixResult(result));
353
373
  return 0;
354
374
  }
375
+ if (command === "security") {
376
+ if (!maybePath || maybePath.startsWith("--")) {
377
+ io.writeStderr("Missing target path. Usage: codex-plugin-doctor security <path> [--json|--scorecard]");
378
+ return 2;
379
+ }
380
+ const jsonOutput = remainingArgs.includes("--json");
381
+ const scorecardOutput = remainingArgs.includes("--scorecard");
382
+ if (jsonOutput && scorecardOutput) {
383
+ io.writeStderr("Use either --json or --scorecard, not both.");
384
+ return 2;
385
+ }
386
+ const audit = await buildSecurityAudit(maybePath);
387
+ io.writeStdout(jsonOutput
388
+ ? renderSecurityAuditJson(audit)
389
+ : renderSecurityScorecard(audit, { includeFindings: !scorecardOutput }));
390
+ return audit.status === "fail" ? 1 : 0;
391
+ }
355
392
  if (command === "compat") {
356
393
  const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
357
394
  const compatFlags = maybePath && maybePath.startsWith("--")
@@ -0,0 +1,17 @@
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 buildSecurityAudit(targetPath: string): Promise<SecurityAudit>;
14
+ export declare function renderSecurityAuditJson(audit: SecurityAudit): string;
15
+ export declare function renderSecurityScorecard(audit: SecurityAudit, options?: {
16
+ includeFindings?: boolean;
17
+ }): string;
@@ -0,0 +1,192 @@
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
+ async function auditMcpCommandSurface(discoveredPackage) {
44
+ const { manifest, rootPath } = discoveredPackage;
45
+ if (!manifest.mcpServers) {
46
+ return [];
47
+ }
48
+ const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers);
49
+ if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
50
+ return [];
51
+ }
52
+ let parsedConfig;
53
+ try {
54
+ parsedConfig = await readJsonFile(mcpConfigPath);
55
+ }
56
+ catch {
57
+ return [
58
+ 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>`.")
59
+ ];
60
+ }
61
+ if (!isPlainObject(parsedConfig) || !isPlainObject(parsedConfig.mcpServers)) {
62
+ return [
63
+ 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.")
64
+ ];
65
+ }
66
+ const findings = [];
67
+ for (const [serverName, serverConfig] of Object.entries(parsedConfig.mcpServers)) {
68
+ if (!isPlainObject(serverConfig)) {
69
+ continue;
70
+ }
71
+ const command = serverConfig.command;
72
+ const args = serverConfig.args;
73
+ const cwd = serverConfig.cwd;
74
+ const url = serverConfig.url;
75
+ if (typeof command === "string" && isShellWrapperCommand(command)) {
76
+ 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."));
77
+ }
78
+ if (containsEncodedCommandFlag(args)) {
79
+ 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."));
80
+ }
81
+ if (containsPipeInstaller(args)) {
82
+ 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."));
83
+ }
84
+ if (typeof cwd === "string") {
85
+ const cwdPath = path.resolve(rootPath, cwd);
86
+ if (!isPathWithinRoot(rootPath, cwdPath)) {
87
+ 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."));
88
+ }
89
+ }
90
+ if (typeof url === "string" && /^http:\/\//i.test(url)) {
91
+ 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."));
92
+ }
93
+ }
94
+ return findings;
95
+ }
96
+ function dedupeFindings(findings) {
97
+ const seen = new Set();
98
+ return findings.filter((finding) => {
99
+ const key = `${finding.id}\n${finding.message}`;
100
+ if (seen.has(key)) {
101
+ return false;
102
+ }
103
+ seen.add(key);
104
+ return true;
105
+ });
106
+ }
107
+ function buildFindingCounts(findings) {
108
+ const fail = findings.filter((finding) => finding.severity === "fail").length;
109
+ const warn = findings.filter((finding) => finding.severity === "warn").length;
110
+ return {
111
+ fail,
112
+ warn,
113
+ total: findings.length
114
+ };
115
+ }
116
+ function scoreSecurityAudit(findingCounts) {
117
+ return Math.max(0, 100 - (findingCounts.fail * 35) - (findingCounts.warn * 10));
118
+ }
119
+ export async function buildSecurityAudit(targetPath) {
120
+ const discoveredPackage = await discoverPackage(targetPath);
121
+ if (!discoveredPackage) {
122
+ const findings = [
123
+ 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.")
124
+ ];
125
+ const findingCounts = buildFindingCounts(findings);
126
+ return {
127
+ targetPath: path.resolve(targetPath),
128
+ status: "fail",
129
+ score: scoreSecurityAudit(findingCounts),
130
+ findingCounts,
131
+ findings
132
+ };
133
+ }
134
+ const validationResult = await validatePlugin(discoveredPackage.rootPath);
135
+ const validationSecurityFindings = validationResult.findings.filter((finding) => finding.id.startsWith("plugin.security."));
136
+ const findings = dedupeFindings([
137
+ ...validationSecurityFindings,
138
+ ...(await auditMcpCommandSurface(discoveredPackage))
139
+ ]);
140
+ const findingCounts = buildFindingCounts(findings);
141
+ const status = findingCounts.fail > 0
142
+ ? "fail"
143
+ : findingCounts.warn > 0
144
+ ? "warn"
145
+ : "pass";
146
+ return {
147
+ targetPath: discoveredPackage.rootPath,
148
+ status,
149
+ score: scoreSecurityAudit(findingCounts),
150
+ findingCounts,
151
+ findings
152
+ };
153
+ }
154
+ export function renderSecurityAuditJson(audit) {
155
+ return JSON.stringify({
156
+ schemaVersion: "1.0.0",
157
+ generatedAt: new Date().toISOString(),
158
+ ...audit
159
+ }, null, 2);
160
+ }
161
+ export function renderSecurityScorecard(audit, options = {}) {
162
+ const lines = [
163
+ "Security Scorecard",
164
+ "==================",
165
+ `Target: ${audit.targetPath}`,
166
+ `Status: ${audit.status.toUpperCase()}`,
167
+ `Score: ${audit.score}/100`,
168
+ `Summary: ${audit.findingCounts.fail} fail, ${audit.findingCounts.warn} warn, ${audit.findingCounts.total} total`
169
+ ];
170
+ if (audit.findings.length === 0) {
171
+ lines.push("", "No security findings.");
172
+ return lines.join("\n");
173
+ }
174
+ if (options.includeFindings === false) {
175
+ return lines.join("\n");
176
+ }
177
+ const appendSection = (title, findings, marker) => {
178
+ if (findings.length === 0) {
179
+ return;
180
+ }
181
+ lines.push("", title, "--------");
182
+ for (const finding of findings) {
183
+ lines.push(`${marker} ${finding.id}`);
184
+ lines.push(` Message: ${finding.message}`);
185
+ lines.push(` Impact: ${finding.impact}`);
186
+ lines.push(` Suggested fix: ${finding.suggestedFix}`);
187
+ }
188
+ };
189
+ appendSection("Failures", audit.findings.filter((finding) => finding.severity === "fail"), "x");
190
+ appendSection("Warnings", audit.findings.filter((finding) => finding.severity === "warn"), "!");
191
+ return lines.join("\n");
192
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "0.10.0",
3
+ "version": "0.11.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"