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.
Files changed (40) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -2
  3. package/dist/compatibility/claude-desktop-install-preview.js +4 -4
  4. package/dist/compatibility/cline-install-preview.js +4 -4
  5. package/dist/compatibility/compatibility-matrix.js +13 -17
  6. package/dist/compatibility/cursor-install-preview.js +4 -4
  7. package/dist/compatibility/windsurf-install-preview.js +4 -4
  8. package/dist/core/doctor-size.js +16 -1
  9. package/dist/core/init-plugin.js +2 -2
  10. package/dist/core/inspector-bridge.js +8 -13
  11. package/dist/core/mcp-config-normalizer.d.ts +18 -0
  12. package/dist/core/mcp-config-normalizer.js +50 -0
  13. package/dist/core/package-path.d.ts +1 -0
  14. package/dist/core/package-path.js +25 -0
  15. package/dist/core/plugin-components.d.ts +7 -0
  16. package/dist/core/plugin-components.js +169 -0
  17. package/dist/core/plugin-hooks.d.ts +2 -0
  18. package/dist/core/plugin-hooks.js +190 -0
  19. package/dist/core/runtime-plan.js +97 -16
  20. package/dist/core/runtime-probe.js +3 -8
  21. package/dist/core/validate-plugin.js +39 -17
  22. package/dist/domain/types.d.ts +34 -0
  23. package/dist/domain/types.js +13 -1
  24. package/dist/mcp/generic-mcp-doctor.js +45 -29
  25. package/dist/rules/rule-catalog.js +130 -4
  26. package/dist/security/security-audit.d.ts +1 -0
  27. package/dist/security/security-audit.js +104 -7
  28. package/examples/README.md +94 -94
  29. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  30. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  31. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  32. package/examples/codex-doctor-risky/.mcp.json +12 -12
  33. package/examples/codex-doctor-risky/mock-server.js +1 -1
  34. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  35. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  36. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  37. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  38. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  39. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  40. 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?.manifest.mcpServers) {
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(path.resolve(discoveredPackage.rootPath, discoveredPackage.manifest.mcpServers));
208
+ parsedConfig = await readJsonFile(mcpConfigPath);
130
209
  }
131
210
  catch {
132
211
  parsedConfig = {};
133
212
  }
134
- const serverEntries = isPlainObject(parsedConfig) && isPlainObject(parsedConfig.mcpServers)
135
- ? Object.entries(parsedConfig.mcpServers)
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: Array.isArray(serverConfig.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
- if (!isPlainObject(parsedConfig)) {
266
- return null;
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 (!manifest.skills) {
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 (!manifest.skills) {
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 (!manifest.mcpServers) {
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
- if (!isPlainObject(parsedConfig)) {
357
- return [
358
- buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a JSON object.", "Codex expects bundled MCP configuration to be object-shaped so server entries can be resolved reliably.", `Wrap the MCP configuration in a top-level object inside \`${mcpConfigPath}\`.`, { configPath: relativePackagePath(rootPath, mcpConfigPath), field: "root" })
359
- ];
360
- }
361
- const servers = parsedConfig.mcpServers;
362
- if (!isPlainObject(servers) || Object.keys(servers).length === 0) {
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 non-empty `mcpServers` object.", "Without a valid `mcpServers` object, Codex cannot discover the bundled MCP server definitions.", `Define bundled servers under \`mcpServers\` in \`${mcpConfigPath}\`.`, {
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: "mcpServers"
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
- ...(await validateSkillsDirectory(discoveredPackage)),
439
- ...(await validateSkillDefinitions(discoveredPackage)),
440
- ...(await validateMcpConfig(discoveredPackage))
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
@@ -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;
@@ -1 +1,13 @@
1
- export {};
1
+ export const pluginHookEvents = [
2
+ "PreToolUse",
3
+ "PermissionRequest",
4
+ "PostToolUse",
5
+ "PreCompact",
6
+ "PostCompact",
7
+ "UserPromptSubmit",
8
+ "SubagentStop",
9
+ "Stop",
10
+ "SessionStart",
11
+ "SubagentStart",
12
+ "SessionEnd"
13
+ ];