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 +43 -8
- package/dist/audit/ecosystem-audit.d.ts +36 -0
- package/dist/audit/ecosystem-audit.js +135 -0
- package/dist/core/doctor-snapshot.d.ts +20 -0
- package/dist/core/doctor-snapshot.js +66 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mcp/generic-mcp-doctor.d.ts +16 -0
- package/dist/mcp/generic-mcp-doctor.js +166 -0
- package/dist/policy/policy-packs.d.ts +9 -0
- package/dist/policy/policy-packs.js +33 -0
- package/dist/release/release-sync.d.ts +24 -0
- package/dist/release/release-sync.js +46 -0
- package/dist/rules/rule-catalog.js +54 -0
- package/dist/run-cli.js +148 -4
- package/dist/security/security-audit.d.ts +19 -0
- package/dist/security/security-audit.js +199 -0
- package/package.json +2 -1
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
|
-
|
|
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 ||
|
|
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.
|
|
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"
|