codex-plugin-doctor 0.11.0 → 0.13.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 +28 -3
- package/dist/audit/ecosystem-audit.d.ts +36 -0
- package/dist/audit/ecosystem-audit.js +135 -0
- package/dist/core/doctor-export-bundle.d.ts +22 -0
- package/dist/core/doctor-export-bundle.js +79 -0
- package/dist/core/doctor-recommendations.d.ts +42 -0
- package/dist/core/doctor-recommendations.js +161 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -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 +200 -5
- package/dist/security/security-audit.d.ts +2 -0
- package/dist/security/security-audit.js +40 -33
- package/dist/security/trust-score.d.ts +23 -0
- package/dist/security/trust-score.js +196 -0
- package/package.json +1 -1
|
@@ -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";
|
|
@@ -13,11 +14,14 @@ import { buildClineInstallPreview, renderClineInstallPreview } from "./compatibi
|
|
|
13
14
|
import { buildWindsurfInstallPreview, renderWindsurfInstallPreview } from "./compatibility/windsurf-install-preview.js";
|
|
14
15
|
import { applyDoctorConfig, loadDoctorConfig } from "./core/doctor-config.js";
|
|
15
16
|
import { buildDoctorSnapshot, renderDoctorSnapshot, renderDoctorSnapshotJson } from "./core/doctor-snapshot.js";
|
|
17
|
+
import { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRecommendationsJson } from "./core/doctor-recommendations.js";
|
|
18
|
+
import { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson } from "./core/doctor-export-bundle.js";
|
|
16
19
|
import { applyFixPlan, buildFixPlan, renderApplyFixResult, renderFixPlanJsonReport, renderFixPlan } from "./core/fix-plan.js";
|
|
17
20
|
import { renderClientDoctor, renderEnvironmentDoctor, renderEnvironmentDoctorJson } from "./core/environment-doctor.js";
|
|
18
21
|
import { initCiWorkflow } from "./core/init-ci.js";
|
|
19
22
|
import { initPluginPackage, initPluginTemplates, isInitPluginTemplate } from "./core/init-plugin.js";
|
|
20
23
|
import { runCheck } from "./index.js";
|
|
24
|
+
import { buildGenericMcpDoctor, renderGenericMcpDoctor, renderGenericMcpDoctorJson } from "./mcp/generic-mcp-doctor.js";
|
|
21
25
|
import { renderInstalledSummary } from "./reporting/render-installed-summary.js";
|
|
22
26
|
import { renderBadgeJson, renderBadgeMarkdown } from "./reporting/render-badge-report.js";
|
|
23
27
|
import { renderCompatibilityScorecard } from "./reporting/render-compatibility-scorecard.js";
|
|
@@ -28,8 +32,10 @@ import { buildMarkdownReport } from "./reporting/render-markdown-report.js";
|
|
|
28
32
|
import { renderRuleExplanation } from "./reporting/render-rule-explanation.js";
|
|
29
33
|
import { renderSarifReport } from "./reporting/render-sarif-report.js";
|
|
30
34
|
import { renderTextReport } from "./reporting/render-text-report.js";
|
|
35
|
+
import { applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames } from "./policy/policy-packs.js";
|
|
31
36
|
import { findRuleDefinition } from "./rules/rule-catalog.js";
|
|
32
37
|
import { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
|
|
38
|
+
import { buildTrustScore, renderTrustScore, renderTrustScoreJson } from "./security/trust-score.js";
|
|
33
39
|
import { createLiveStatusRenderer } from "./terminal/live-status-renderer.js";
|
|
34
40
|
import { determineOutputPolicy } from "./terminal/output-policy.js";
|
|
35
41
|
import { getSpinner } from "./terminal/spinner-registry.js";
|
|
@@ -55,7 +61,7 @@ const defaultIo = {
|
|
|
55
61
|
}
|
|
56
62
|
};
|
|
57
63
|
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");
|
|
64
|
+
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 [recommend <path>|trust <path>|export --bundle <path>|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
65
|
}
|
|
60
66
|
function renderInstalledPlugins(plugins) {
|
|
61
67
|
const lines = [
|
|
@@ -188,6 +194,91 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
188
194
|
const doctorFlags = maybePath?.startsWith("--")
|
|
189
195
|
? [maybePath, ...remainingArgs]
|
|
190
196
|
: remainingArgs;
|
|
197
|
+
if (maybePath === "recommend") {
|
|
198
|
+
const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
|
|
199
|
+
? remainingArgs[0]
|
|
200
|
+
: ".";
|
|
201
|
+
const recommendFlags = remainingArgs[0] && !remainingArgs[0].startsWith("--")
|
|
202
|
+
? remainingArgs.slice(1)
|
|
203
|
+
: remainingArgs;
|
|
204
|
+
const jsonOutput = recommendFlags.includes("--json");
|
|
205
|
+
const outputIndex = recommendFlags.indexOf("--output");
|
|
206
|
+
const outputPath = outputIndex === -1 ? null : recommendFlags[outputIndex + 1];
|
|
207
|
+
if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
|
|
208
|
+
io.writeStderr("Missing path after --output.");
|
|
209
|
+
return 2;
|
|
210
|
+
}
|
|
211
|
+
const report = await buildDoctorRecommendations(targetPath, {
|
|
212
|
+
environment: {
|
|
213
|
+
env: terminalContext.env,
|
|
214
|
+
platform: terminalContext.platform
|
|
215
|
+
},
|
|
216
|
+
runCheck: options.runCheckImpl
|
|
217
|
+
? (pathToCheck) => options.runCheckImpl(pathToCheck)
|
|
218
|
+
: undefined
|
|
219
|
+
});
|
|
220
|
+
const renderedReport = jsonOutput
|
|
221
|
+
? renderDoctorRecommendationsJson(report)
|
|
222
|
+
: renderDoctorRecommendations(report);
|
|
223
|
+
if (outputPath) {
|
|
224
|
+
await writeFile(outputPath, renderedReport, "utf8");
|
|
225
|
+
}
|
|
226
|
+
io.writeStdout(renderedReport);
|
|
227
|
+
return report.exitCode;
|
|
228
|
+
}
|
|
229
|
+
if (maybePath === "trust") {
|
|
230
|
+
const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
|
|
231
|
+
? remainingArgs[0]
|
|
232
|
+
: ".";
|
|
233
|
+
const trustFlags = remainingArgs[0] && !remainingArgs[0].startsWith("--")
|
|
234
|
+
? remainingArgs.slice(1)
|
|
235
|
+
: remainingArgs;
|
|
236
|
+
const jsonOutput = trustFlags.includes("--json");
|
|
237
|
+
const outputIndex = trustFlags.indexOf("--output");
|
|
238
|
+
const outputPath = outputIndex === -1 ? null : trustFlags[outputIndex + 1];
|
|
239
|
+
if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
|
|
240
|
+
io.writeStderr("Missing path after --output.");
|
|
241
|
+
return 2;
|
|
242
|
+
}
|
|
243
|
+
const report = await buildTrustScore(targetPath);
|
|
244
|
+
const renderedReport = jsonOutput
|
|
245
|
+
? renderTrustScoreJson(report)
|
|
246
|
+
: renderTrustScore(report);
|
|
247
|
+
if (outputPath) {
|
|
248
|
+
await writeFile(outputPath, renderedReport, "utf8");
|
|
249
|
+
}
|
|
250
|
+
io.writeStdout(renderedReport);
|
|
251
|
+
return report.exitCode;
|
|
252
|
+
}
|
|
253
|
+
if (maybePath === "export") {
|
|
254
|
+
const bundleIndex = remainingArgs.indexOf("--bundle");
|
|
255
|
+
if (bundleIndex === -1) {
|
|
256
|
+
io.writeStderr("Usage: codex-plugin-doctor doctor export --bundle <path> [--json] [--output <path>]");
|
|
257
|
+
return 2;
|
|
258
|
+
}
|
|
259
|
+
const targetPath = remainingArgs[bundleIndex + 1] && !remainingArgs[bundleIndex + 1].startsWith("--")
|
|
260
|
+
? remainingArgs[bundleIndex + 1]
|
|
261
|
+
: ".";
|
|
262
|
+
const jsonOutput = remainingArgs.includes("--json");
|
|
263
|
+
const outputIndex = remainingArgs.indexOf("--output");
|
|
264
|
+
const outputPath = outputIndex === -1 ? null : remainingArgs[outputIndex + 1];
|
|
265
|
+
if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
|
|
266
|
+
io.writeStderr("Missing path after --output.");
|
|
267
|
+
return 2;
|
|
268
|
+
}
|
|
269
|
+
const bundle = await buildDoctorExportBundle(targetPath, {
|
|
270
|
+
env: terminalContext.env,
|
|
271
|
+
platform: terminalContext.platform
|
|
272
|
+
});
|
|
273
|
+
const bundleJson = renderDoctorExportBundleJson(bundle);
|
|
274
|
+
if (outputPath) {
|
|
275
|
+
await writeFile(outputPath, bundleJson, "utf8");
|
|
276
|
+
}
|
|
277
|
+
io.writeStdout(jsonOutput
|
|
278
|
+
? bundleJson
|
|
279
|
+
: renderDoctorExportBundle(bundle, { outputPath }));
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
191
282
|
if (maybePath === "snapshot") {
|
|
192
283
|
const jsonOutput = doctorFlags.includes("--json");
|
|
193
284
|
const outputIndex = doctorFlags.indexOf("--output");
|
|
@@ -379,16 +470,107 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
379
470
|
}
|
|
380
471
|
const jsonOutput = remainingArgs.includes("--json");
|
|
381
472
|
const scorecardOutput = remainingArgs.includes("--scorecard");
|
|
473
|
+
const policyIndex = remainingArgs.indexOf("--policy");
|
|
474
|
+
const policyName = policyIndex === -1 ? null : remainingArgs[policyIndex + 1];
|
|
475
|
+
const policy = parsePolicyPack(policyName);
|
|
382
476
|
if (jsonOutput && scorecardOutput) {
|
|
383
477
|
io.writeStderr("Use either --json or --scorecard, not both.");
|
|
384
478
|
return 2;
|
|
385
479
|
}
|
|
386
|
-
|
|
480
|
+
if (policyIndex !== -1 && (!policyName || policyName.startsWith("--"))) {
|
|
481
|
+
io.writeStderr("Missing policy after --policy.");
|
|
482
|
+
return 2;
|
|
483
|
+
}
|
|
484
|
+
if (policyIndex !== -1 && !policy) {
|
|
485
|
+
io.writeStderr(`Unknown policy: ${policyName}. Supported policies: ${policyPackNames.join(", ")}.`);
|
|
486
|
+
return 2;
|
|
487
|
+
}
|
|
488
|
+
const audit = applyPolicyToSecurityAudit(await buildSecurityAudit(maybePath), policy);
|
|
387
489
|
io.writeStdout(jsonOutput
|
|
388
490
|
? renderSecurityAuditJson(audit)
|
|
389
491
|
: renderSecurityScorecard(audit, { includeFindings: !scorecardOutput }));
|
|
390
492
|
return audit.status === "fail" ? 1 : 0;
|
|
391
493
|
}
|
|
494
|
+
if (command === "mcp") {
|
|
495
|
+
if (!maybePath || maybePath.startsWith("--")) {
|
|
496
|
+
io.writeStderr("Missing target path. Usage: codex-plugin-doctor mcp <path> [--json] [--output <path>]");
|
|
497
|
+
return 2;
|
|
498
|
+
}
|
|
499
|
+
const jsonOutput = remainingArgs.includes("--json");
|
|
500
|
+
const outputIndex = remainingArgs.indexOf("--output");
|
|
501
|
+
const outputPath = outputIndex === -1 ? null : remainingArgs[outputIndex + 1];
|
|
502
|
+
if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
|
|
503
|
+
io.writeStderr("Missing path after --output.");
|
|
504
|
+
return 2;
|
|
505
|
+
}
|
|
506
|
+
const report = await buildGenericMcpDoctor(maybePath, {
|
|
507
|
+
env: terminalContext.env,
|
|
508
|
+
platform: terminalContext.platform
|
|
509
|
+
});
|
|
510
|
+
const renderedReport = jsonOutput
|
|
511
|
+
? renderGenericMcpDoctorJson(report)
|
|
512
|
+
: renderGenericMcpDoctor(report);
|
|
513
|
+
if (outputPath) {
|
|
514
|
+
await writeFile(outputPath, renderedReport, "utf8");
|
|
515
|
+
}
|
|
516
|
+
io.writeStdout(renderedReport);
|
|
517
|
+
return report.exitCode;
|
|
518
|
+
}
|
|
519
|
+
if (command === "audit") {
|
|
520
|
+
const auditFlags = maybePath ? [maybePath, ...remainingArgs] : remainingArgs;
|
|
521
|
+
const installed = auditFlags.includes("--installed");
|
|
522
|
+
if (!installed) {
|
|
523
|
+
io.writeStderr("Usage: codex-plugin-doctor audit --installed [filter] [--security] [--compat] [--json] [--output <path>]");
|
|
524
|
+
return 2;
|
|
525
|
+
}
|
|
526
|
+
const installedIndex = auditFlags.indexOf("--installed");
|
|
527
|
+
const installedFilter = auditFlags[installedIndex + 1] && !auditFlags[installedIndex + 1].startsWith("--")
|
|
528
|
+
? auditFlags[installedIndex + 1]
|
|
529
|
+
: null;
|
|
530
|
+
const jsonOutput = auditFlags.includes("--json");
|
|
531
|
+
const includeSecurity = auditFlags.includes("--security");
|
|
532
|
+
const includeCompatibility = auditFlags.includes("--compat");
|
|
533
|
+
const outputIndex = auditFlags.indexOf("--output");
|
|
534
|
+
const outputPath = outputIndex === -1 ? null : auditFlags[outputIndex + 1];
|
|
535
|
+
const policyIndex = auditFlags.indexOf("--policy");
|
|
536
|
+
const policyName = policyIndex === -1 ? null : auditFlags[policyIndex + 1];
|
|
537
|
+
const policy = parsePolicyPack(policyName);
|
|
538
|
+
if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
|
|
539
|
+
io.writeStderr("Missing path after --output.");
|
|
540
|
+
return 2;
|
|
541
|
+
}
|
|
542
|
+
if (policyIndex !== -1 && (!policyName || policyName.startsWith("--"))) {
|
|
543
|
+
io.writeStderr("Missing policy after --policy.");
|
|
544
|
+
return 2;
|
|
545
|
+
}
|
|
546
|
+
if (policyIndex !== -1 && !policy) {
|
|
547
|
+
io.writeStderr(`Unknown policy: ${policyName}. Supported policies: ${policyPackNames.join(", ")}.`);
|
|
548
|
+
return 2;
|
|
549
|
+
}
|
|
550
|
+
const report = await buildEcosystemAudit({
|
|
551
|
+
env: terminalContext.env,
|
|
552
|
+
platform: terminalContext.platform,
|
|
553
|
+
filter: installedFilter,
|
|
554
|
+
includeSecurity,
|
|
555
|
+
includeCompatibility,
|
|
556
|
+
failOnWarnings: policyFailsOnWarnings(policy),
|
|
557
|
+
validatePlugin: options.runCheckImpl ?? runCheck
|
|
558
|
+
});
|
|
559
|
+
if (report.summary.totalPlugins === 0) {
|
|
560
|
+
io.writeStderr(installedFilter
|
|
561
|
+
? `No installed Codex plugins matched '${installedFilter}'.`
|
|
562
|
+
: "No installed Codex plugins found.");
|
|
563
|
+
return 1;
|
|
564
|
+
}
|
|
565
|
+
const renderedReport = jsonOutput
|
|
566
|
+
? renderEcosystemAuditJson(report)
|
|
567
|
+
: renderEcosystemAudit(report);
|
|
568
|
+
if (outputPath) {
|
|
569
|
+
await writeFile(outputPath, renderedReport, "utf8");
|
|
570
|
+
}
|
|
571
|
+
io.writeStdout(renderedReport);
|
|
572
|
+
return report.status === "fail" ? 1 : 0;
|
|
573
|
+
}
|
|
392
574
|
if (command === "compat") {
|
|
393
575
|
const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
|
|
394
576
|
const compatFlags = maybePath && maybePath.startsWith("--")
|
|
@@ -540,6 +722,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
540
722
|
const profileIndex = normalizedFlags.indexOf("--profile");
|
|
541
723
|
const profileName = profileIndex === -1 ? null : normalizedFlags[profileIndex + 1];
|
|
542
724
|
const checkProfile = parseCheckProfile(profileName);
|
|
725
|
+
const policyIndex = normalizedFlags.indexOf("--policy");
|
|
726
|
+
const policyName = policyIndex === -1 ? null : normalizedFlags[policyIndex + 1];
|
|
727
|
+
const policy = parsePolicyPack(policyName);
|
|
543
728
|
const historyIndex = normalizedFlags.indexOf("--history");
|
|
544
729
|
const historyPath = historyIndex === -1 ? null : normalizedFlags[historyIndex + 1];
|
|
545
730
|
if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
|
|
@@ -558,6 +743,14 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
558
743
|
io.writeStderr("Unknown profile. Supported profiles: ci, strict, publish.");
|
|
559
744
|
return 2;
|
|
560
745
|
}
|
|
746
|
+
if (policyIndex !== -1 && (!policyName || policyName.startsWith("--"))) {
|
|
747
|
+
io.writeStderr("Missing policy after --policy.");
|
|
748
|
+
return 2;
|
|
749
|
+
}
|
|
750
|
+
if (policyIndex !== -1 && !policy) {
|
|
751
|
+
io.writeStderr(`Unknown policy: ${policyName}. Supported policies: ${policyPackNames.join(", ")}.`);
|
|
752
|
+
return 2;
|
|
753
|
+
}
|
|
561
754
|
if (historyIndex !== -1 && (!historyPath || historyPath.startsWith("--"))) {
|
|
562
755
|
io.writeStderr("Missing path after --history.");
|
|
563
756
|
return 2;
|
|
@@ -570,7 +763,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
570
763
|
io.writeStderr("History output requires a single package target.");
|
|
571
764
|
return 2;
|
|
572
765
|
}
|
|
573
|
-
const effectiveRuntimeProbeEnabled = runtimeProbeEnabled ||
|
|
766
|
+
const effectiveRuntimeProbeEnabled = runtimeProbeEnabled ||
|
|
767
|
+
checkProfile === "publish" ||
|
|
768
|
+
policyEnablesRuntime(policy);
|
|
574
769
|
const outputPolicy = determineOutputPolicy({
|
|
575
770
|
jsonOutput: jsonOutput || badgeJsonOutput,
|
|
576
771
|
markdownOutput: markdownOutput || badgeMarkdownOutput,
|
|
@@ -606,7 +801,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
606
801
|
runtimeTranscript: effectiveRuntimeProbeEnabled && verboseRuntime
|
|
607
802
|
? (line) => io.writeStderr(line)
|
|
608
803
|
: undefined
|
|
609
|
-
}), applyCheckProfile(config, checkProfile)),
|
|
804
|
+
}), applyPolicyToDoctorConfig(applyCheckProfile(config, checkProfile), policy)),
|
|
610
805
|
compatibilityMatrix
|
|
611
806
|
});
|
|
612
807
|
}
|
|
@@ -643,7 +838,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
643
838
|
runtimeTranscript: effectiveRuntimeProbeEnabled && verboseRuntime
|
|
644
839
|
? (line) => io.writeStderr(line)
|
|
645
840
|
: undefined
|
|
646
|
-
}), applyCheckProfile(await loadDoctorConfig(targetPath, configPath), checkProfile));
|
|
841
|
+
}), applyPolicyToDoctorConfig(applyCheckProfile(await loadDoctorConfig(targetPath, configPath), checkProfile), policy));
|
|
647
842
|
if (renderer) {
|
|
648
843
|
if (result.status === "fail") {
|
|
649
844
|
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({
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Finding } from "../domain/types.js";
|
|
2
|
+
export interface TrustScoreReport {
|
|
3
|
+
schemaVersion: "1.0.0";
|
|
4
|
+
generatedAt: string;
|
|
5
|
+
targetPath: string;
|
|
6
|
+
status: "pass" | "warn" | "fail";
|
|
7
|
+
exitCode: 0 | 1;
|
|
8
|
+
score: number;
|
|
9
|
+
findingCounts: {
|
|
10
|
+
fail: number;
|
|
11
|
+
warn: number;
|
|
12
|
+
total: number;
|
|
13
|
+
};
|
|
14
|
+
packageJson: {
|
|
15
|
+
present: boolean;
|
|
16
|
+
scriptsChecked: number;
|
|
17
|
+
dependenciesChecked: number;
|
|
18
|
+
};
|
|
19
|
+
findings: Finding[];
|
|
20
|
+
}
|
|
21
|
+
export declare function buildTrustScore(targetPath: string): Promise<TrustScoreReport>;
|
|
22
|
+
export declare function renderTrustScoreJson(report: TrustScoreReport): string;
|
|
23
|
+
export declare function renderTrustScore(report: TrustScoreReport): string;
|