codex-plugin-doctor 0.11.0 → 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 +21 -2
- package/dist/audit/ecosystem-audit.d.ts +36 -0
- package/dist/audit/ecosystem-audit.js +135 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -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/run-cli.js +112 -5
- package/dist/security/security-audit.d.ts +2 -0
- package/dist/security/security-audit.js +40 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,9 +98,12 @@ codex-plugin-doctor list --installed
|
|
|
98
98
|
codex-plugin-doctor check --installed
|
|
99
99
|
codex-plugin-doctor check --installed --all-summary
|
|
100
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
|
|
101
104
|
codex-plugin-doctor check --installed github
|
|
102
|
-
codex-plugin-doctor explain plugin.manifest.missing
|
|
103
|
-
```
|
|
105
|
+
codex-plugin-doctor explain plugin.manifest.missing
|
|
106
|
+
```
|
|
104
107
|
|
|
105
108
|
Run from source:
|
|
106
109
|
|
|
@@ -178,6 +181,12 @@ codex-plugin-doctor doctor snapshot --json
|
|
|
178
181
|
codex-plugin-doctor doctor snapshot --output doctor-snapshot.json
|
|
179
182
|
codex-plugin-doctor doctor clients
|
|
180
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
|
|
181
190
|
codex-plugin-doctor init my-plugin
|
|
182
191
|
codex-plugin-doctor init my-mcp --template mcp-stdio
|
|
183
192
|
codex-plugin-doctor init remote-mcp --template mcp-http
|
|
@@ -185,6 +194,7 @@ codex-plugin-doctor init runtime-demo --template full-runtime
|
|
|
185
194
|
codex-plugin-doctor security .
|
|
186
195
|
codex-plugin-doctor security . --scorecard
|
|
187
196
|
codex-plugin-doctor security . --json
|
|
197
|
+
codex-plugin-doctor security . --policy security
|
|
188
198
|
codex-plugin-doctor compat .
|
|
189
199
|
codex-plugin-doctor compat . --all --scorecard
|
|
190
200
|
codex-plugin-doctor compat . --client codex
|
|
@@ -204,6 +214,9 @@ codex-plugin-doctor check .
|
|
|
204
214
|
codex-plugin-doctor check . --profile ci
|
|
205
215
|
codex-plugin-doctor check . --profile strict
|
|
206
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
|
|
207
220
|
codex-plugin-doctor check . --json
|
|
208
221
|
codex-plugin-doctor check . --explain
|
|
209
222
|
codex-plugin-doctor check . --json --output report.json
|
|
@@ -229,6 +242,12 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
|
|
|
229
242
|
|
|
230
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.
|
|
231
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.
|
|
250
|
+
|
|
232
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.
|
|
233
252
|
|
|
234
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.
|
|
@@ -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
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { CheckOptions, CheckResult } from "./domain/types.js";
|
|
2
2
|
export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard, type SecurityAudit } from "./security/security-audit.js";
|
|
3
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";
|
|
4
7
|
export declare function runCheck(targetPath: string, options?: CheckOptions): Promise<CheckResult>;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { validatePlugin } from "./core/validate-plugin.js";
|
|
2
2
|
export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
|
|
3
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";
|
|
4
7
|
export async function runCheck(targetPath, options = {}) {
|
|
5
8
|
return validatePlugin(targetPath, options);
|
|
6
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
|
+
}
|
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";
|
|
@@ -18,6 +19,7 @@ import { renderClientDoctor, renderEnvironmentDoctor, renderEnvironmentDoctorJso
|
|
|
18
19
|
import { initCiWorkflow } from "./core/init-ci.js";
|
|
19
20
|
import { initPluginPackage, initPluginTemplates, isInitPluginTemplate } from "./core/init-plugin.js";
|
|
20
21
|
import { runCheck } from "./index.js";
|
|
22
|
+
import { buildGenericMcpDoctor, renderGenericMcpDoctor, renderGenericMcpDoctorJson } from "./mcp/generic-mcp-doctor.js";
|
|
21
23
|
import { renderInstalledSummary } from "./reporting/render-installed-summary.js";
|
|
22
24
|
import { renderBadgeJson, renderBadgeMarkdown } from "./reporting/render-badge-report.js";
|
|
23
25
|
import { renderCompatibilityScorecard } from "./reporting/render-compatibility-scorecard.js";
|
|
@@ -28,6 +30,7 @@ import { buildMarkdownReport } from "./reporting/render-markdown-report.js";
|
|
|
28
30
|
import { renderRuleExplanation } from "./reporting/render-rule-explanation.js";
|
|
29
31
|
import { renderSarifReport } from "./reporting/render-sarif-report.js";
|
|
30
32
|
import { renderTextReport } from "./reporting/render-text-report.js";
|
|
33
|
+
import { applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames } from "./policy/policy-packs.js";
|
|
31
34
|
import { findRuleDefinition } from "./rules/rule-catalog.js";
|
|
32
35
|
import { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
|
|
33
36
|
import { createLiveStatusRenderer } from "./terminal/live-status-renderer.js";
|
|
@@ -55,7 +58,7 @@ const defaultIo = {
|
|
|
55
58
|
}
|
|
56
59
|
};
|
|
57
60
|
function printUsage(io) {
|
|
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");
|
|
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");
|
|
59
62
|
}
|
|
60
63
|
function renderInstalledPlugins(plugins) {
|
|
61
64
|
const lines = [
|
|
@@ -379,16 +382,107 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
379
382
|
}
|
|
380
383
|
const jsonOutput = remainingArgs.includes("--json");
|
|
381
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);
|
|
382
388
|
if (jsonOutput && scorecardOutput) {
|
|
383
389
|
io.writeStderr("Use either --json or --scorecard, not both.");
|
|
384
390
|
return 2;
|
|
385
391
|
}
|
|
386
|
-
|
|
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);
|
|
387
401
|
io.writeStdout(jsonOutput
|
|
388
402
|
? renderSecurityAuditJson(audit)
|
|
389
403
|
: renderSecurityScorecard(audit, { includeFindings: !scorecardOutput }));
|
|
390
404
|
return audit.status === "fail" ? 1 : 0;
|
|
391
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
|
+
}
|
|
392
486
|
if (command === "compat") {
|
|
393
487
|
const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
|
|
394
488
|
const compatFlags = maybePath && maybePath.startsWith("--")
|
|
@@ -540,6 +634,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
540
634
|
const profileIndex = normalizedFlags.indexOf("--profile");
|
|
541
635
|
const profileName = profileIndex === -1 ? null : normalizedFlags[profileIndex + 1];
|
|
542
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);
|
|
543
640
|
const historyIndex = normalizedFlags.indexOf("--history");
|
|
544
641
|
const historyPath = historyIndex === -1 ? null : normalizedFlags[historyIndex + 1];
|
|
545
642
|
if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
|
|
@@ -558,6 +655,14 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
558
655
|
io.writeStderr("Unknown profile. Supported profiles: ci, strict, publish.");
|
|
559
656
|
return 2;
|
|
560
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
|
+
}
|
|
561
666
|
if (historyIndex !== -1 && (!historyPath || historyPath.startsWith("--"))) {
|
|
562
667
|
io.writeStderr("Missing path after --history.");
|
|
563
668
|
return 2;
|
|
@@ -570,7 +675,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
570
675
|
io.writeStderr("History output requires a single package target.");
|
|
571
676
|
return 2;
|
|
572
677
|
}
|
|
573
|
-
const effectiveRuntimeProbeEnabled = runtimeProbeEnabled ||
|
|
678
|
+
const effectiveRuntimeProbeEnabled = runtimeProbeEnabled ||
|
|
679
|
+
checkProfile === "publish" ||
|
|
680
|
+
policyEnablesRuntime(policy);
|
|
574
681
|
const outputPolicy = determineOutputPolicy({
|
|
575
682
|
jsonOutput: jsonOutput || badgeJsonOutput,
|
|
576
683
|
markdownOutput: markdownOutput || badgeMarkdownOutput,
|
|
@@ -606,7 +713,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
606
713
|
runtimeTranscript: effectiveRuntimeProbeEnabled && verboseRuntime
|
|
607
714
|
? (line) => io.writeStderr(line)
|
|
608
715
|
: undefined
|
|
609
|
-
}), applyCheckProfile(config, checkProfile)),
|
|
716
|
+
}), applyPolicyToDoctorConfig(applyCheckProfile(config, checkProfile), policy)),
|
|
610
717
|
compatibilityMatrix
|
|
611
718
|
});
|
|
612
719
|
}
|
|
@@ -643,7 +750,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
643
750
|
runtimeTranscript: effectiveRuntimeProbeEnabled && verboseRuntime
|
|
644
751
|
? (line) => io.writeStderr(line)
|
|
645
752
|
: undefined
|
|
646
|
-
}), applyCheckProfile(await loadDoctorConfig(targetPath, configPath), checkProfile));
|
|
753
|
+
}), applyPolicyToDoctorConfig(applyCheckProfile(await loadDoctorConfig(targetPath, configPath), checkProfile), policy));
|
|
647
754
|
if (renderer) {
|
|
648
755
|
if (result.status === "fail") {
|
|
649
756
|
renderer.stopFailure("Validation failed");
|
|
@@ -10,6 +10,8 @@ export interface SecurityAudit {
|
|
|
10
10
|
};
|
|
11
11
|
findings: Finding[];
|
|
12
12
|
}
|
|
13
|
+
export declare function auditMcpServerConfig(rootPath: string, parsedConfig: unknown): Finding[];
|
|
14
|
+
export declare function buildSecurityAuditFromFindings(targetPath: string, findings: Finding[]): SecurityAudit;
|
|
13
15
|
export declare function buildSecurityAudit(targetPath: string): Promise<SecurityAudit>;
|
|
14
16
|
export declare function renderSecurityAuditJson(audit: SecurityAudit): string;
|
|
15
17
|
export declare function renderSecurityScorecard(audit: SecurityAudit, options?: {
|
|
@@ -40,24 +40,7 @@ function containsPipeInstaller(args) {
|
|
|
40
40
|
/\b(iwr|irm|invoke-webrequest|invoke-restmethod)\b[^|]*\|\s*(iex|invoke-expression)\b/.test(joinedArgs) ||
|
|
41
41
|
/\binvoke-expression\b/.test(joinedArgs));
|
|
42
42
|
}
|
|
43
|
-
|
|
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
|
-
}
|
|
43
|
+
export function auditMcpServerConfig(rootPath, parsedConfig) {
|
|
61
44
|
if (!isPlainObject(parsedConfig) || !isPlainObject(parsedConfig.mcpServers)) {
|
|
62
45
|
return [
|
|
63
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.")
|
|
@@ -93,6 +76,26 @@ async function auditMcpCommandSurface(discoveredPackage) {
|
|
|
93
76
|
}
|
|
94
77
|
return findings;
|
|
95
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
|
+
}
|
|
96
99
|
function dedupeFindings(findings) {
|
|
97
100
|
const seen = new Set();
|
|
98
101
|
return findings.filter((finding) => {
|
|
@@ -116,6 +119,22 @@ function buildFindingCounts(findings) {
|
|
|
116
119
|
function scoreSecurityAudit(findingCounts) {
|
|
117
120
|
return Math.max(0, 100 - (findingCounts.fail * 35) - (findingCounts.warn * 10));
|
|
118
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
|
+
}
|
|
119
138
|
export async function buildSecurityAudit(targetPath) {
|
|
120
139
|
const discoveredPackage = await discoverPackage(targetPath);
|
|
121
140
|
if (!discoveredPackage) {
|
|
@@ -133,23 +152,11 @@ export async function buildSecurityAudit(targetPath) {
|
|
|
133
152
|
}
|
|
134
153
|
const validationResult = await validatePlugin(discoveredPackage.rootPath);
|
|
135
154
|
const validationSecurityFindings = validationResult.findings.filter((finding) => finding.id.startsWith("plugin.security."));
|
|
136
|
-
const findings =
|
|
155
|
+
const findings = [
|
|
137
156
|
...validationSecurityFindings,
|
|
138
157
|
...(await auditMcpCommandSurface(discoveredPackage))
|
|
139
|
-
]
|
|
140
|
-
|
|
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
|
-
};
|
|
158
|
+
];
|
|
159
|
+
return buildSecurityAuditFromFindings(discoveredPackage.rootPath, findings);
|
|
153
160
|
}
|
|
154
161
|
export function renderSecurityAuditJson(audit) {
|
|
155
162
|
return JSON.stringify({
|
package/package.json
CHANGED