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 +22 -6
- package/dist/core/doctor-snapshot.d.ts +20 -0
- package/dist/core/doctor-snapshot.js +66 -0
- package/dist/core/fix-plan.js +17 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -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 +38 -1
- package/dist/security/security-audit.d.ts +17 -0
- package/dist/security/security-audit.js +192 -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`
|
|
@@ -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
|
+
}
|
package/dist/core/fix-plan.js
CHANGED
|
@@ -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.
|
|
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"
|