codex-plugin-doctor 1.57.0 → 1.58.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/LICENSE +21 -21
- package/README.md +3 -2
- package/dist/compatibility/claude-desktop-install-preview.js +4 -4
- package/dist/compatibility/cline-install-preview.js +4 -4
- package/dist/compatibility/compatibility-matrix.js +13 -17
- package/dist/compatibility/cursor-install-preview.js +4 -4
- package/dist/compatibility/windsurf-install-preview.js +4 -4
- package/dist/core/doctor-size.js +16 -1
- package/dist/core/init-plugin.js +2 -2
- package/dist/core/inspector-bridge.js +8 -13
- package/dist/core/mcp-config-normalizer.d.ts +18 -0
- package/dist/core/mcp-config-normalizer.js +50 -0
- package/dist/core/package-path.d.ts +1 -0
- package/dist/core/package-path.js +25 -0
- package/dist/core/plugin-components.d.ts +7 -0
- package/dist/core/plugin-components.js +169 -0
- package/dist/core/plugin-hooks.d.ts +2 -0
- package/dist/core/plugin-hooks.js +190 -0
- package/dist/core/runtime-plan.js +97 -16
- package/dist/core/runtime-probe.js +3 -8
- package/dist/core/validate-plugin.js +39 -17
- package/dist/domain/types.d.ts +34 -0
- package/dist/domain/types.js +13 -1
- package/dist/mcp/generic-mcp-doctor.js +45 -29
- package/dist/rules/rule-catalog.js +130 -4
- package/dist/security/security-audit.d.ts +1 -0
- package/dist/security/security-audit.js +104 -7
- package/examples/README.md +94 -94
- package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
- package/examples/codex-doctor-generic-mcp/server.js +1 -1
- package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-risky/.mcp.json +12 -12
- package/examples/codex-doctor-risky/mock-server.js +1 -1
- package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
- package/examples/codex-doctor-runtime/.mcp.json +9 -9
- package/examples/codex-doctor-runtime/mock-server.js +237 -237
- package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
- package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
- package/package.json +1 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pluginHookEvents } from "../domain/types.js";
|
|
4
|
+
import { auditHookCommand } from "../security/security-audit.js";
|
|
5
|
+
import { resolveSafePackagePath } from "./plugin-components.js";
|
|
6
|
+
const ignoredMatcherEvents = new Set(["Stop", "UserPromptSubmit"]);
|
|
7
|
+
function isPlainObject(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function packagePath(rootPath, targetPath) {
|
|
11
|
+
return path.relative(rootPath, targetPath).replace(/\\/g, "/") || ".";
|
|
12
|
+
}
|
|
13
|
+
function finding(severity, id, message, impact, suggestedFix, evidence) {
|
|
14
|
+
return { severity, id, message, impact, suggestedFix, evidence };
|
|
15
|
+
}
|
|
16
|
+
function invalidShape(sourcePath, field) {
|
|
17
|
+
return finding("fail", "plugin.hook.invalid_shape", "The plugin lifecycle hook configuration has an invalid shape.", "Codex cannot safely interpret malformed lifecycle hook metadata.", "Use the official hook configuration schema for this field.", { sourcePath, field });
|
|
18
|
+
}
|
|
19
|
+
function unsupportedEvent(sourcePath, event) {
|
|
20
|
+
return finding("fail", "plugin.hook.unsupported_event", `The plugin lifecycle hook event \`${event}\` is not supported.`, "Codex will not invoke hook events outside the official lifecycle event set.", "Use one of the official plugin lifecycle hook events.", { sourcePath, event });
|
|
21
|
+
}
|
|
22
|
+
function invalidHookPath(manifestPath) {
|
|
23
|
+
return finding("fail", "plugin.hook.invalid_path", "The plugin lifecycle hook source must be a safe package-relative path.", "Paths outside the plugin package can expose unreviewed files to hook configuration loading.", "Use a `./` hook configuration path that stays within the plugin package.", { sourcePath: manifestPath, field: "hooks" });
|
|
24
|
+
}
|
|
25
|
+
function warning(id, sourcePath, event, field) {
|
|
26
|
+
const messages = {
|
|
27
|
+
"plugin.hook.unsupported_handler": [
|
|
28
|
+
"A plugin lifecycle hook uses a handler type the host skips.",
|
|
29
|
+
"Prompt and agent lifecycle handlers are not executed by this host.",
|
|
30
|
+
"Use a command handler for behavior that must run in this host."
|
|
31
|
+
],
|
|
32
|
+
"plugin.hook.async_unsupported": [
|
|
33
|
+
"A plugin lifecycle hook requests unsupported asynchronous execution.",
|
|
34
|
+
"The host does not support asynchronous lifecycle hook execution.",
|
|
35
|
+
"Remove `async: true` or run the command synchronously."
|
|
36
|
+
],
|
|
37
|
+
"plugin.hook.matcher_ignored": [
|
|
38
|
+
"A plugin lifecycle hook matcher is ignored for this event.",
|
|
39
|
+
"This lifecycle event does not use matcher filtering.",
|
|
40
|
+
"Remove the matcher from this event group."
|
|
41
|
+
]
|
|
42
|
+
};
|
|
43
|
+
const [message, impact, suggestedFix] = messages[id];
|
|
44
|
+
return finding("warn", id, message, impact, suggestedFix, { sourcePath, event, field });
|
|
45
|
+
}
|
|
46
|
+
function validateHandler(rootPath, value, sourcePath, event, groupIndex, handlerIndex) {
|
|
47
|
+
const field = `hooks.${event}[${groupIndex}].hooks[${handlerIndex}]`;
|
|
48
|
+
if (!isPlainObject(value) || typeof value.type !== "string") {
|
|
49
|
+
return [invalidShape(sourcePath, field)];
|
|
50
|
+
}
|
|
51
|
+
const findings = [];
|
|
52
|
+
if (value.type !== "command" && value.type !== "prompt" && value.type !== "agent") {
|
|
53
|
+
return [invalidShape(sourcePath, `${field}.type`)];
|
|
54
|
+
}
|
|
55
|
+
if (value.async !== undefined && typeof value.async !== "boolean") {
|
|
56
|
+
findings.push(invalidShape(sourcePath, `${field}.async`));
|
|
57
|
+
}
|
|
58
|
+
else if (value.async === true) {
|
|
59
|
+
findings.push(warning("plugin.hook.async_unsupported", sourcePath, event, `${field}.async`));
|
|
60
|
+
}
|
|
61
|
+
if (value.type === "prompt" || value.type === "agent") {
|
|
62
|
+
findings.push(warning("plugin.hook.unsupported_handler", sourcePath, event, `${field}.type`));
|
|
63
|
+
return findings;
|
|
64
|
+
}
|
|
65
|
+
if (typeof value.command !== "string") {
|
|
66
|
+
findings.push(invalidShape(sourcePath, `${field}.command`));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
findings.push(...auditHookCommand(rootPath, sourcePath, event, `${field}.command`, value.command));
|
|
70
|
+
}
|
|
71
|
+
if (value.commandWindows !== undefined) {
|
|
72
|
+
if (typeof value.commandWindows !== "string") {
|
|
73
|
+
findings.push(invalidShape(sourcePath, `${field}.commandWindows`));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
findings.push(...auditHookCommand(rootPath, sourcePath, event, `${field}.commandWindows`, value.commandWindows));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (value.timeout !== undefined && (typeof value.timeout !== "number" || !Number.isFinite(value.timeout) || value.timeout <= 0)) {
|
|
80
|
+
findings.push(invalidShape(sourcePath, `${field}.timeout`));
|
|
81
|
+
}
|
|
82
|
+
if (event === "SessionEnd" && typeof value.timeout === "number" && value.timeout > 3) {
|
|
83
|
+
findings.push(invalidShape(sourcePath, `${field}.timeout`));
|
|
84
|
+
}
|
|
85
|
+
if (value.statusMessage !== undefined && typeof value.statusMessage !== "string") {
|
|
86
|
+
findings.push(invalidShape(sourcePath, `${field}.statusMessage`));
|
|
87
|
+
}
|
|
88
|
+
if (value.additionalContextLimit !== undefined && (typeof value.additionalContextLimit !== "number" ||
|
|
89
|
+
!Number.isFinite(value.additionalContextLimit) ||
|
|
90
|
+
value.additionalContextLimit < 0)) {
|
|
91
|
+
findings.push(invalidShape(sourcePath, `${field}.additionalContextLimit`));
|
|
92
|
+
}
|
|
93
|
+
return findings;
|
|
94
|
+
}
|
|
95
|
+
function validateConfig(rootPath, config, sourcePath) {
|
|
96
|
+
if (!isPlainObject(config))
|
|
97
|
+
return [invalidShape(sourcePath, "root")];
|
|
98
|
+
const findings = [];
|
|
99
|
+
if (config.description !== undefined && typeof config.description !== "string") {
|
|
100
|
+
findings.push(invalidShape(sourcePath, "description"));
|
|
101
|
+
}
|
|
102
|
+
if (!isPlainObject(config.hooks)) {
|
|
103
|
+
findings.push(invalidShape(sourcePath, "hooks"));
|
|
104
|
+
return findings;
|
|
105
|
+
}
|
|
106
|
+
for (const [event, groups] of Object.entries(config.hooks)) {
|
|
107
|
+
if (!pluginHookEvents.includes(event)) {
|
|
108
|
+
findings.push(unsupportedEvent(sourcePath, event));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(groups)) {
|
|
112
|
+
findings.push(invalidShape(sourcePath, `hooks.${event}`));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
groups.forEach((group, groupIndex) => {
|
|
116
|
+
const groupField = `hooks.${event}[${groupIndex}]`;
|
|
117
|
+
if (!isPlainObject(group)) {
|
|
118
|
+
findings.push(invalidShape(sourcePath, groupField));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (group.matcher !== undefined && typeof group.matcher !== "string") {
|
|
122
|
+
findings.push(invalidShape(sourcePath, `${groupField}.matcher`));
|
|
123
|
+
}
|
|
124
|
+
else if (typeof group.matcher === "string" && ignoredMatcherEvents.has(event)) {
|
|
125
|
+
findings.push(warning("plugin.hook.matcher_ignored", sourcePath, event, `${groupField}.matcher`));
|
|
126
|
+
}
|
|
127
|
+
if (!Array.isArray(group.hooks)) {
|
|
128
|
+
findings.push(invalidShape(sourcePath, `${groupField}.hooks`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
group.hooks.forEach((handler, handlerIndex) => {
|
|
132
|
+
findings.push(...validateHandler(rootPath, handler, sourcePath, event, groupIndex, handlerIndex));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return findings;
|
|
137
|
+
}
|
|
138
|
+
async function validateHookFile(rootPath, manifestPath, source) {
|
|
139
|
+
const resolved = await resolveSafePackagePath(rootPath, source);
|
|
140
|
+
if (!resolved) {
|
|
141
|
+
return [invalidHookPath(manifestPath)];
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
if (!(await stat(resolved.path)).isFile())
|
|
145
|
+
throw new Error("not a regular file");
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [finding("fail", "plugin.hook.missing_file", "The plugin lifecycle hook source file is missing.", "Codex cannot load a hook configuration file that is absent or not a regular file.", "Create the referenced hook configuration JSON file inside the plugin package.", { sourcePath: resolved.packagePath })];
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
return validateConfig(rootPath, JSON.parse(await readFile(resolved.path, "utf8")), resolved.packagePath);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return [finding("fail", "plugin.hook.invalid_json", "The plugin lifecycle hook source is not valid JSON.", "Codex cannot parse the hook configuration.", "Fix the JSON syntax in the hook configuration file.", { sourcePath: resolved.packagePath })];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function validatePluginHooks(discoveredPackage) {
|
|
158
|
+
const { manifest, rootPath } = discoveredPackage;
|
|
159
|
+
const manifestPath = packagePath(rootPath, discoveredPackage.manifestPath);
|
|
160
|
+
const configuredHooks = manifest.hooks;
|
|
161
|
+
if (configuredHooks === undefined) {
|
|
162
|
+
const defaultPath = "./hooks/hooks.json";
|
|
163
|
+
const resolved = await resolveSafePackagePath(rootPath, defaultPath);
|
|
164
|
+
if (!resolved)
|
|
165
|
+
return [invalidHookPath(manifestPath)];
|
|
166
|
+
try {
|
|
167
|
+
if (!(await stat(resolved.path)).isFile())
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
return validateHookFile(rootPath, manifestPath, defaultPath);
|
|
174
|
+
}
|
|
175
|
+
if (typeof configuredHooks === "string") {
|
|
176
|
+
return validateHookFile(rootPath, manifestPath, configuredHooks);
|
|
177
|
+
}
|
|
178
|
+
if (Array.isArray(configuredHooks)) {
|
|
179
|
+
if (configuredHooks.length === 0)
|
|
180
|
+
return [];
|
|
181
|
+
if (configuredHooks.every((value) => typeof value === "string")) {
|
|
182
|
+
return (await Promise.all(configuredHooks.map((source) => validateHookFile(rootPath, manifestPath, source)))).flat();
|
|
183
|
+
}
|
|
184
|
+
if (configuredHooks.every(isPlainObject)) {
|
|
185
|
+
return configuredHooks.flatMap((config) => validateConfig(rootPath, config, manifestPath));
|
|
186
|
+
}
|
|
187
|
+
return [invalidShape(manifestPath, "hooks")];
|
|
188
|
+
}
|
|
189
|
+
return validateConfig(rootPath, configuredHooks, manifestPath);
|
|
190
|
+
}
|
|
@@ -2,10 +2,12 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { packageVersion } from "../version.js";
|
|
4
4
|
import { discoverPackage } from "./discover-package.js";
|
|
5
|
+
import { normalizeMcpConfig } from "./mcp-config-normalizer.js";
|
|
5
6
|
import { readJsonFile } from "./read-json-file.js";
|
|
6
7
|
import { buildSecurityAudit } from "../security/security-audit.js";
|
|
7
8
|
import { DOCKER_RUNTIME_IMAGE } from "./runtime-sandbox.js";
|
|
8
9
|
import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
|
|
10
|
+
import { resolveContainedPackagePath } from "./package-path.js";
|
|
9
11
|
function isPlainObject(value) {
|
|
10
12
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
13
|
}
|
|
@@ -68,7 +70,7 @@ function remoteNetworkClass(rawUrl) {
|
|
|
68
70
|
}
|
|
69
71
|
return "public_https";
|
|
70
72
|
}
|
|
71
|
-
function planDigestPayload(plan) {
|
|
73
|
+
function planDigestPayload(plan, rawServerArgs = {}) {
|
|
72
74
|
return {
|
|
73
75
|
schemaVersion: plan.schemaVersion,
|
|
74
76
|
kind: "doctor.runtime.plan.digest.v1",
|
|
@@ -76,7 +78,10 @@ function planDigestPayload(plan) {
|
|
|
76
78
|
execution: plan.execution,
|
|
77
79
|
status: plan.status,
|
|
78
80
|
summary: plan.summary,
|
|
79
|
-
servers: plan.servers
|
|
81
|
+
servers: plan.servers.map((server) => ({
|
|
82
|
+
...server,
|
|
83
|
+
args: rawServerArgs[server.name] ?? server.args
|
|
84
|
+
})),
|
|
80
85
|
findings: plan.findings.map((finding) => ({
|
|
81
86
|
id: finding.id,
|
|
82
87
|
severity: finding.severity,
|
|
@@ -84,13 +89,59 @@ function planDigestPayload(plan) {
|
|
|
84
89
|
}))
|
|
85
90
|
};
|
|
86
91
|
}
|
|
87
|
-
function buildRuntimePlanDigest(plan) {
|
|
88
|
-
return sha256(stableStringify(planDigestPayload(plan)));
|
|
92
|
+
function buildRuntimePlanDigest(plan, rawServerArgs = {}) {
|
|
93
|
+
return sha256(stableStringify(planDigestPayload(plan, rawServerArgs)));
|
|
94
|
+
}
|
|
95
|
+
const secretRuntimeArgumentFlag = /^--?(?:token|api[-_]?key|apikey|password|secret|credential|authorization|bearer)$/i;
|
|
96
|
+
const secretRuntimeArgumentInline = /^(--?(?:token|api[-_]?key|apikey|password|secret|credential|authorization|bearer))[=:].*$/i;
|
|
97
|
+
const authorizationBearerHeader = /^authorization\s*:\s*bearer\s+\S+/i;
|
|
98
|
+
const inlineAuthorizationBearerHeader = /^(?:--header|-H)=authorization\s*:\s*bearer\s+\S+/i;
|
|
99
|
+
function redactRuntimeArguments(args) {
|
|
100
|
+
return args.map((arg, index) => {
|
|
101
|
+
const inlineMatch = arg.match(secretRuntimeArgumentInline);
|
|
102
|
+
if (inlineMatch) {
|
|
103
|
+
return `${inlineMatch[1]}=[REDACTED]`;
|
|
104
|
+
}
|
|
105
|
+
if (authorizationBearerHeader.test(arg)) {
|
|
106
|
+
return "[REDACTED]";
|
|
107
|
+
}
|
|
108
|
+
if (inlineAuthorizationBearerHeader.test(arg)) {
|
|
109
|
+
return "[REDACTED]";
|
|
110
|
+
}
|
|
111
|
+
if (index > 0 && secretRuntimeArgumentFlag.test(args[index - 1])) {
|
|
112
|
+
return "[REDACTED]";
|
|
113
|
+
}
|
|
114
|
+
return arg;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function buildFailedRuntimePlan(targetPath, generatedAt, execution, finding) {
|
|
118
|
+
const partialPlan = {
|
|
119
|
+
schemaVersion: "1.0.0",
|
|
120
|
+
kind: "doctor.runtime.plan",
|
|
121
|
+
version: packageVersion,
|
|
122
|
+
targetPath,
|
|
123
|
+
status: "fail",
|
|
124
|
+
exitCode: 1,
|
|
125
|
+
runtimeExecution: "not_started",
|
|
126
|
+
execution,
|
|
127
|
+
summary: {
|
|
128
|
+
serverCount: 0,
|
|
129
|
+
executableServerCount: 0,
|
|
130
|
+
highRiskServerCount: 0,
|
|
131
|
+
findings: { fail: 1, warn: 0, total: 1 }
|
|
132
|
+
},
|
|
133
|
+
servers: [],
|
|
134
|
+
findings: [finding]
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
...partialPlan,
|
|
138
|
+
generatedAt,
|
|
139
|
+
digest: buildRuntimePlanDigest(partialPlan)
|
|
140
|
+
};
|
|
89
141
|
}
|
|
90
142
|
export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date().toISOString(), options = {}) {
|
|
91
143
|
const rootPath = path.resolve(targetPath);
|
|
92
144
|
const discoveredPackage = await discoverPackage(rootPath);
|
|
93
|
-
const security = await buildSecurityAudit(rootPath);
|
|
94
145
|
const execution = options.sandbox === "docker"
|
|
95
146
|
? {
|
|
96
147
|
backend: "docker",
|
|
@@ -99,7 +150,8 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
|
|
|
99
150
|
packageMount: "read_only"
|
|
100
151
|
}
|
|
101
152
|
: { backend: "native", image: null, network: "host", packageMount: "host" };
|
|
102
|
-
if (!discoveredPackage
|
|
153
|
+
if (!discoveredPackage || !Object.hasOwn(discoveredPackage.manifest, "mcpServers")) {
|
|
154
|
+
const security = await buildSecurityAudit(rootPath);
|
|
103
155
|
const partialPlan = {
|
|
104
156
|
schemaVersion: "1.0.0",
|
|
105
157
|
kind: "doctor.runtime.plan",
|
|
@@ -124,16 +176,43 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
|
|
|
124
176
|
digest: buildRuntimePlanDigest(partialPlan)
|
|
125
177
|
};
|
|
126
178
|
}
|
|
179
|
+
const manifestMcpServers = discoveredPackage.manifest.mcpServers;
|
|
180
|
+
if (typeof manifestMcpServers !== "string" ||
|
|
181
|
+
!manifestMcpServers ||
|
|
182
|
+
!manifestMcpServers.startsWith("./")) {
|
|
183
|
+
return buildFailedRuntimePlan(discoveredPackage.rootPath, generatedAt, execution, {
|
|
184
|
+
id: typeof manifestMcpServers === "string"
|
|
185
|
+
? "plugin.manifest.invalid_path"
|
|
186
|
+
: "plugin.manifest.invalid_field",
|
|
187
|
+
severity: "fail",
|
|
188
|
+
message: "The plugin manifest mcpServers field must be a non-empty package-relative path.",
|
|
189
|
+
impact: "Runtime planning cannot safely resolve malformed MCP server configuration metadata.",
|
|
190
|
+
suggestedFix: "Use a non-empty ./ path for mcpServers that remains inside the package.",
|
|
191
|
+
evidence: { manifestPath: ".codex-plugin/plugin.json", field: "mcpServers" }
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const mcpConfigPath = await resolveContainedPackagePath(discoveredPackage.rootPath, manifestMcpServers);
|
|
195
|
+
if (!mcpConfigPath) {
|
|
196
|
+
return buildFailedRuntimePlan(discoveredPackage.rootPath, generatedAt, execution, {
|
|
197
|
+
id: "plugin.security.mcp_config_path",
|
|
198
|
+
severity: "fail",
|
|
199
|
+
message: "The MCP server config path resolves outside the package root.",
|
|
200
|
+
impact: "Runtime planning cannot safely inspect MCP server metadata outside the package.",
|
|
201
|
+
suggestedFix: "Use an MCP server config path that remains inside the package root.",
|
|
202
|
+
evidence: { manifestPath: ".codex-plugin/plugin.json", field: "mcpServers" }
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const security = await buildSecurityAudit(rootPath);
|
|
127
206
|
let parsedConfig;
|
|
128
207
|
try {
|
|
129
|
-
parsedConfig = await readJsonFile(
|
|
208
|
+
parsedConfig = await readJsonFile(mcpConfigPath);
|
|
130
209
|
}
|
|
131
210
|
catch {
|
|
132
211
|
parsedConfig = {};
|
|
133
212
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
213
|
+
const normalizedConfig = normalizeMcpConfig(parsedConfig);
|
|
214
|
+
const serverEntries = normalizedConfig.ok ? Object.entries(normalizedConfig.servers) : [];
|
|
215
|
+
const rawServerArgs = {};
|
|
137
216
|
const servers = serverEntries
|
|
138
217
|
.filter((entry) => isPlainObject(entry[1]))
|
|
139
218
|
.map(([serverName, serverConfig]) => {
|
|
@@ -142,13 +221,15 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
|
|
|
142
221
|
const { riskLevel, riskReasons } = buildRisk(serverName, serverConfig, security.findings);
|
|
143
222
|
const networkClass = url ? remoteNetworkClass(url) : undefined;
|
|
144
223
|
const sanitizedUrl = url ? inspectRemoteMcpUrl(url).sanitizedUrl : null;
|
|
224
|
+
const args = Array.isArray(serverConfig.args)
|
|
225
|
+
? serverConfig.args.filter((arg) => typeof arg === "string")
|
|
226
|
+
: [];
|
|
227
|
+
rawServerArgs[serverName] = args;
|
|
145
228
|
return {
|
|
146
229
|
name: serverName,
|
|
147
230
|
transport: command ? "stdio" : "http",
|
|
148
231
|
command,
|
|
149
|
-
args:
|
|
150
|
-
? serverConfig.args.filter((arg) => typeof arg === "string")
|
|
151
|
-
: [],
|
|
232
|
+
args: redactRuntimeArguments(args),
|
|
152
233
|
cwd: command ? normalizeCwd(discoveredPackage.rootPath, serverConfig.cwd) : null,
|
|
153
234
|
url: sanitizedUrl,
|
|
154
235
|
...(networkClass ? { networkClass } : {}),
|
|
@@ -182,12 +263,12 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
|
|
|
182
263
|
kind: "doctor.runtime.plan",
|
|
183
264
|
version: packageVersion,
|
|
184
265
|
targetPath: discoveredPackage.rootPath,
|
|
185
|
-
status: highRiskServerCount > 0
|
|
266
|
+
status: !normalizedConfig.ok || highRiskServerCount > 0 || security.status === "fail"
|
|
186
267
|
? "fail"
|
|
187
268
|
: security.status === "warn"
|
|
188
269
|
? "warn"
|
|
189
270
|
: "pass",
|
|
190
|
-
exitCode: (highRiskServerCount > 0 ? 1 : 0),
|
|
271
|
+
exitCode: (!normalizedConfig.ok || highRiskServerCount > 0 || security.status === "fail" ? 1 : 0),
|
|
191
272
|
runtimeExecution: "not_started",
|
|
192
273
|
execution,
|
|
193
274
|
summary: {
|
|
@@ -202,7 +283,7 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
|
|
|
202
283
|
return {
|
|
203
284
|
...partialPlan,
|
|
204
285
|
generatedAt,
|
|
205
|
-
digest: buildRuntimePlanDigest(partialPlan)
|
|
286
|
+
digest: buildRuntimePlanDigest(partialPlan, rawServerArgs)
|
|
206
287
|
};
|
|
207
288
|
}
|
|
208
289
|
export function evaluateRuntimeApproval(plan, options) {
|
|
@@ -6,6 +6,7 @@ import readline from "node:readline";
|
|
|
6
6
|
import { evaluateMcpConformance } from "./mcp-conformance.js";
|
|
7
7
|
import { probeRemoteMcpServer } from "./remote-mcp-probe.js";
|
|
8
8
|
import { buildRuntimeLaunch, DOCKER_RUNTIME_STARTUP_TIMEOUT_MS, RuntimeSandboxError } from "./runtime-sandbox.js";
|
|
9
|
+
import { normalizeMcpConfig } from "./mcp-config-normalizer.js";
|
|
9
10
|
import { formatRequestTranscript as formatRequestTranscriptForLog, formatResponseTranscript as formatResponseTranscriptForLog } from "./runtime-transcript.js";
|
|
10
11
|
const MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
11
12
|
const PROMPT_PROBE_PLACEHOLDER = "codex-plugin-doctor-probe";
|
|
@@ -262,14 +263,8 @@ async function loadMcpServers(mcpConfigPath) {
|
|
|
262
263
|
catch {
|
|
263
264
|
return null;
|
|
264
265
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
const servers = parsedConfig.mcpServers;
|
|
269
|
-
if (!isPlainObject(servers)) {
|
|
270
|
-
return null;
|
|
271
|
-
}
|
|
272
|
-
return servers;
|
|
266
|
+
const normalizedConfig = normalizeMcpConfig(parsedConfig);
|
|
267
|
+
return normalizedConfig.ok ? normalizedConfig.servers : null;
|
|
273
268
|
}
|
|
274
269
|
function getCapabilities(message) {
|
|
275
270
|
if (!isPlainObject(message.result)) {
|
|
@@ -2,6 +2,9 @@ import { readFile, readdir, stat } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { withFindingFingerprints } from "../reporting/finding-fingerprint.js";
|
|
4
4
|
import { discoverPackage } from "./discover-package.js";
|
|
5
|
+
import { normalizeMcpConfig } from "./mcp-config-normalizer.js";
|
|
6
|
+
import { validatePluginComponents } from "./plugin-components.js";
|
|
7
|
+
import { validatePluginHooks } from "./plugin-hooks.js";
|
|
5
8
|
import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
|
|
6
9
|
import { probeRuntime, remoteReliabilityGatePassed } from "./runtime-probe.js";
|
|
7
10
|
function buildFailure(id, message, impact, suggestedFix, evidence) {
|
|
@@ -24,6 +27,9 @@ function buildWarning(id, message, impact, suggestedFix, evidence) {
|
|
|
24
27
|
...(evidence ? { evidence } : {})
|
|
25
28
|
};
|
|
26
29
|
}
|
|
30
|
+
function hasInvalidComponentPath(findings, field) {
|
|
31
|
+
return findings.some((finding) => finding.id === "plugin.manifest.invalid_path" && finding.evidence?.field === field);
|
|
32
|
+
}
|
|
27
33
|
function remoteUrlIssueFindingId(issue) {
|
|
28
34
|
return issue === "insecure_non_loopback"
|
|
29
35
|
? "plugin.security.insecure_http_url"
|
|
@@ -223,9 +229,11 @@ function validateRequiredManifestFields(discoveredPackage) {
|
|
|
223
229
|
}
|
|
224
230
|
async function validateSkillsDirectory(discoveredPackage) {
|
|
225
231
|
const { manifest, rootPath } = discoveredPackage;
|
|
226
|
-
if (
|
|
232
|
+
if (manifest.skills === undefined) {
|
|
227
233
|
return [];
|
|
228
234
|
}
|
|
235
|
+
if (typeof manifest.skills !== "string")
|
|
236
|
+
return [];
|
|
229
237
|
const skillsPath = path.resolve(rootPath, manifest.skills);
|
|
230
238
|
if (!isPathWithinRoot(rootPath, skillsPath)) {
|
|
231
239
|
return [
|
|
@@ -233,7 +241,7 @@ async function validateSkillsDirectory(discoveredPackage) {
|
|
|
233
241
|
manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
|
|
234
242
|
field: "skills",
|
|
235
243
|
configuredPath: manifest.skills,
|
|
236
|
-
resolvedPath: skillsPath
|
|
244
|
+
resolvedPath: relativePackagePath(rootPath, skillsPath)
|
|
237
245
|
})
|
|
238
246
|
];
|
|
239
247
|
}
|
|
@@ -246,13 +254,13 @@ async function validateSkillsDirectory(discoveredPackage) {
|
|
|
246
254
|
manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
|
|
247
255
|
field: "skills",
|
|
248
256
|
configuredPath: manifest.skills,
|
|
249
|
-
resolvedPath: skillsPath
|
|
257
|
+
resolvedPath: relativePackagePath(rootPath, skillsPath)
|
|
250
258
|
})
|
|
251
259
|
];
|
|
252
260
|
}
|
|
253
261
|
async function validateSkillDefinitions(discoveredPackage) {
|
|
254
262
|
const { manifest, rootPath } = discoveredPackage;
|
|
255
|
-
if (
|
|
263
|
+
if (typeof manifest.skills !== "string") {
|
|
256
264
|
return [];
|
|
257
265
|
}
|
|
258
266
|
const skillsPath = path.resolve(rootPath, manifest.skills);
|
|
@@ -319,7 +327,7 @@ async function validateSkillDefinitions(discoveredPackage) {
|
|
|
319
327
|
}
|
|
320
328
|
async function validateMcpConfig(discoveredPackage) {
|
|
321
329
|
const { manifest, rootPath } = discoveredPackage;
|
|
322
|
-
if (
|
|
330
|
+
if (typeof manifest.mcpServers !== "string") {
|
|
323
331
|
return [];
|
|
324
332
|
}
|
|
325
333
|
const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers);
|
|
@@ -353,20 +361,28 @@ async function validateMcpConfig(discoveredPackage) {
|
|
|
353
361
|
buildFailure("plugin.mcp.invalid_json", "The referenced `.mcp.json` file is not valid JSON.", "Codex will not be able to parse bundled MCP server configuration.", `Fix the JSON syntax in \`${mcpConfigPath}\`.`, { configPath: relativePackagePath(rootPath, mcpConfigPath), field: "json" })
|
|
354
362
|
];
|
|
355
363
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
364
|
+
const normalizedConfig = normalizeMcpConfig(parsedConfig);
|
|
365
|
+
if (!normalizedConfig.ok) {
|
|
366
|
+
if (normalizedConfig.reason === "ambiguous_shape") {
|
|
367
|
+
return [
|
|
368
|
+
buildFailure("plugin.mcp.ambiguous_shape", "The referenced `.mcp.json` file uses an ambiguous MCP config layout.", "Codex cannot safely choose between multiple wrapper layouts when they appear in one configuration file.", "Use exactly one supported layout: a direct server map, `mcp_servers`, or `mcpServers`.", { configPath: relativePackagePath(rootPath, mcpConfigPath), field: "root" })
|
|
369
|
+
];
|
|
370
|
+
}
|
|
371
|
+
if (normalizedConfig.field === "server" && normalizedConfig.invalidServerNames) {
|
|
372
|
+
return normalizedConfig.invalidServerNames.map((serverName) => buildFailure("plugin.mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "Codex cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${mcpConfigPath}\` to an object.`, {
|
|
373
|
+
configPath: relativePackagePath(rootPath, mcpConfigPath),
|
|
374
|
+
serverName,
|
|
375
|
+
field: "server"
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
363
378
|
return [
|
|
364
|
-
buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a
|
|
379
|
+
buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a valid MCP server map.", "Without valid server entries, Codex cannot discover the bundled MCP server definitions.", `Use a direct server map, \`mcp_servers\`, or \`mcpServers\` in \`${mcpConfigPath}\`.`, {
|
|
365
380
|
configPath: relativePackagePath(rootPath, mcpConfigPath),
|
|
366
|
-
field:
|
|
381
|
+
field: normalizedConfig.field
|
|
367
382
|
})
|
|
368
383
|
];
|
|
369
384
|
}
|
|
385
|
+
const servers = normalizedConfig.servers;
|
|
370
386
|
const findings = [];
|
|
371
387
|
for (const [serverName, serverConfig] of Object.entries(servers)) {
|
|
372
388
|
if (!isPlainObject(serverConfig)) {
|
|
@@ -433,11 +449,17 @@ export async function validatePlugin(targetPath, options = {}) {
|
|
|
433
449
|
findings: withFindingFingerprints([await buildMissingManifestFailure(rootPath)], rootPath)
|
|
434
450
|
};
|
|
435
451
|
}
|
|
452
|
+
const componentFindings = await validatePluginComponents(discoveredPackage);
|
|
453
|
+
const hookFindings = await validatePluginHooks(discoveredPackage);
|
|
454
|
+
const hasInvalidSkillsPath = hasInvalidComponentPath(componentFindings, "skills");
|
|
455
|
+
const hasInvalidMcpPath = hasInvalidComponentPath(componentFindings, "mcpServers");
|
|
436
456
|
const staticFindings = [
|
|
437
457
|
...validateRequiredManifestFields(discoveredPackage),
|
|
438
|
-
...
|
|
439
|
-
...
|
|
440
|
-
...(await
|
|
458
|
+
...componentFindings,
|
|
459
|
+
...hookFindings,
|
|
460
|
+
...(hasInvalidSkillsPath ? [] : await validateSkillsDirectory(discoveredPackage)),
|
|
461
|
+
...(hasInvalidSkillsPath ? [] : await validateSkillDefinitions(discoveredPackage)),
|
|
462
|
+
...(hasInvalidMcpPath ? [] : await validateMcpConfig(discoveredPackage))
|
|
441
463
|
];
|
|
442
464
|
const staticFailed = staticFindings.some((finding) => finding.severity === "fail");
|
|
443
465
|
const runtimeResult = options.runtime && !staticFailed
|
package/dist/domain/types.d.ts
CHANGED
|
@@ -55,7 +55,41 @@ export interface PluginManifest {
|
|
|
55
55
|
description?: string;
|
|
56
56
|
skills?: string;
|
|
57
57
|
mcpServers?: string;
|
|
58
|
+
author?: unknown;
|
|
59
|
+
homepage?: unknown;
|
|
60
|
+
repository?: unknown;
|
|
61
|
+
license?: unknown;
|
|
62
|
+
keywords?: unknown;
|
|
63
|
+
apps?: unknown;
|
|
64
|
+
hooks?: PluginHooks;
|
|
65
|
+
interface?: unknown;
|
|
66
|
+
}
|
|
67
|
+
export declare const pluginHookEvents: readonly ["PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact", "UserPromptSubmit", "SubagentStop", "Stop", "SessionStart", "SubagentStart", "SessionEnd"];
|
|
68
|
+
export type PluginHookEvent = (typeof pluginHookEvents)[number];
|
|
69
|
+
export interface PluginHookCommandHandler {
|
|
70
|
+
type: "command";
|
|
71
|
+
command: string;
|
|
72
|
+
commandWindows?: string;
|
|
73
|
+
timeout?: number;
|
|
74
|
+
statusMessage?: string;
|
|
75
|
+
additionalContextLimit?: number;
|
|
76
|
+
async?: boolean;
|
|
77
|
+
}
|
|
78
|
+
export interface PluginHookUnsupportedHandler {
|
|
79
|
+
type: "prompt" | "agent";
|
|
80
|
+
async?: boolean;
|
|
81
|
+
[key: string]: unknown;
|
|
82
|
+
}
|
|
83
|
+
export type PluginHookHandler = PluginHookCommandHandler | PluginHookUnsupportedHandler;
|
|
84
|
+
export interface PluginHookMatcherGroup {
|
|
85
|
+
matcher?: string;
|
|
86
|
+
hooks: PluginHookHandler[];
|
|
87
|
+
}
|
|
88
|
+
export interface PluginHookConfig {
|
|
89
|
+
description?: string;
|
|
90
|
+
hooks: Partial<Record<PluginHookEvent, PluginHookMatcherGroup[]>>;
|
|
58
91
|
}
|
|
92
|
+
export type PluginHooks = string | string[] | PluginHookConfig | PluginHookConfig[];
|
|
59
93
|
export interface DiscoveredPackage {
|
|
60
94
|
rootPath: string;
|
|
61
95
|
manifestPath: string;
|
package/dist/domain/types.js
CHANGED