codex-plugin-doctor 1.57.0 → 1.59.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 (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +20 -6
  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/output-contract.js +76 -0
  14. package/dist/core/package-path.d.ts +1 -0
  15. package/dist/core/package-path.js +25 -0
  16. package/dist/core/plugin-components.d.ts +7 -0
  17. package/dist/core/plugin-components.js +169 -0
  18. package/dist/core/plugin-hooks.d.ts +2 -0
  19. package/dist/core/plugin-hooks.js +190 -0
  20. package/dist/core/runtime-plan.js +97 -16
  21. package/dist/core/runtime-probe.js +3 -8
  22. package/dist/core/shell-completion.js +29 -0
  23. package/dist/core/submission-assets.d.ts +6 -0
  24. package/dist/core/submission-assets.js +430 -0
  25. package/dist/core/submission-preflight.d.ts +34 -0
  26. package/dist/core/submission-preflight.js +289 -0
  27. package/dist/core/submission-ruleset.d.ts +56 -0
  28. package/dist/core/submission-ruleset.js +38 -0
  29. package/dist/core/submission-skill-metadata.d.ts +9 -0
  30. package/dist/core/submission-skill-metadata.js +301 -0
  31. package/dist/core/validate-plugin.js +39 -17
  32. package/dist/domain/types.d.ts +34 -0
  33. package/dist/domain/types.js +13 -1
  34. package/dist/index.d.ts +5 -0
  35. package/dist/index.js +5 -0
  36. package/dist/mcp/generic-mcp-doctor.js +45 -29
  37. package/dist/reporting/render-submission-report.d.ts +5 -0
  38. package/dist/reporting/render-submission-report.js +68 -0
  39. package/dist/rules/rule-catalog.js +130 -4
  40. package/dist/run-cli.js +96 -0
  41. package/dist/security/security-audit.d.ts +1 -0
  42. package/dist/security/security-audit.js +104 -7
  43. package/examples/README.md +94 -94
  44. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  45. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  46. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  47. package/examples/codex-doctor-risky/.mcp.json +12 -12
  48. package/examples/codex-doctor-risky/mock-server.js +1 -1
  49. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  50. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  51. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  52. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  53. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  54. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  55. package/package.json +5 -1
@@ -0,0 +1,301 @@
1
+ import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { isAlias, isNode, parseDocument, visit } from "yaml";
4
+ const maxSkillBytes = 1024 * 1024;
5
+ const maxAgentBytes = 256 * 1024;
6
+ const maxAggregateMetadataBytes = 16 * 1024 * 1024;
7
+ const maxDirectoryEntries = 256;
8
+ const maxSkillDirectories = 100;
9
+ const unsupportedText = /[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u;
10
+ const interfaceKeys = new Set(["display_name", "short_description", "icon_small", "icon_large", "brand_color", "default_prompt"]);
11
+ const policyKeys = new Set(["products", "allow_implicit_invocation"]);
12
+ const dependencyKeys = new Set(["tools"]);
13
+ const toolDescriptorKeys = new Set(["type", "value", "description", "transport", "url"]);
14
+ const agentKeys = new Set(["interface", "policy", "dependencies"]);
15
+ function isRecord(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ function isWithin(rootPath, candidatePath) {
19
+ const relative = path.relative(rootPath, candidatePath);
20
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
21
+ }
22
+ function packagePath(rootPath, targetPath) {
23
+ return path.relative(rootPath, targetPath).split(path.sep).join("/");
24
+ }
25
+ function finding(id, message, evidence) {
26
+ return evidence === undefined
27
+ ? { id, severity: "fail", message }
28
+ : { id, severity: "fail", message, evidence };
29
+ }
30
+ function supportedText(value, limit = Number.MAX_SAFE_INTEGER) {
31
+ return typeof value === "string" && value.trim().length > 0 && value.length <= limit && !unsupportedText.test(value);
32
+ }
33
+ function parseSafeYaml(source) {
34
+ const document = parseDocument(source, { schema: "core", uniqueKeys: true, strict: true, prettyErrors: false });
35
+ let alias = false;
36
+ let nonCoreTag = false;
37
+ visit(document, (_key, node) => {
38
+ if (isAlias(node))
39
+ alias = true;
40
+ if (isNode(node) && node.tag !== undefined && !node.tag.startsWith("tag:yaml.org,2002:"))
41
+ nonCoreTag = true;
42
+ });
43
+ if (document.errors.length > 0 || alias || nonCoreTag)
44
+ return { error: "yaml" };
45
+ try {
46
+ const value = document.toJS({ maxAliasCount: 0 });
47
+ return isRecord(value) ? { value } : { error: "shape" };
48
+ }
49
+ catch {
50
+ return { error: "yaml" };
51
+ }
52
+ }
53
+ function splitSkillFile(source) {
54
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/u.exec(source);
55
+ if (!match || match[2].trim().length === 0)
56
+ return null;
57
+ return { frontmatter: match[1], body: match[2] };
58
+ }
59
+ async function safeDirectory(rootPath, skillsPath) {
60
+ try {
61
+ const [canonicalRoot, canonicalSkills, details] = await Promise.all([realpath(rootPath), realpath(skillsPath), stat(skillsPath)]);
62
+ return details.isDirectory() && isWithin(canonicalRoot, canonicalSkills) ? { canonicalRoot, canonicalSkills } : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ function budgetFinding(bytes) {
69
+ return finding("plugin.submission.skill.budget_exceeded", "Skill metadata exceeds the aggregate submission preflight size limit.", {
70
+ count: bytes, limit: maxAggregateMetadataBytes
71
+ });
72
+ }
73
+ async function readSafeUtf8(filePath, maximum, budget) {
74
+ try {
75
+ const details = await stat(filePath);
76
+ if (!details.isFile() || details.size > maximum)
77
+ return { kind: "invalid" };
78
+ const nextBytes = budget.bytes + details.size;
79
+ if (nextBytes > maxAggregateMetadataBytes)
80
+ return { kind: "budget", nextBytes };
81
+ budget.bytes = nextBytes;
82
+ return { kind: "source", source: new TextDecoder("utf-8", { fatal: true }).decode(await readFile(filePath)) };
83
+ }
84
+ catch {
85
+ return { kind: "invalid" };
86
+ }
87
+ }
88
+ function rejectUnknownKeys(value, allowed) {
89
+ return Object.keys(value).some((key) => !allowed.has(key));
90
+ }
91
+ function isToolDescriptor(value) {
92
+ return isRecord(value) && !rejectUnknownKeys(value, toolDescriptorKeys)
93
+ && (value.type === "mcp" || value.type === "cli")
94
+ && (value.type !== "cli" || (value.transport === undefined && value.url === undefined))
95
+ && supportedText(value.value)
96
+ && (value.description === undefined || supportedText(value.description))
97
+ && (value.transport === undefined || supportedText(value.transport))
98
+ && (value.url === undefined || supportedText(value.url));
99
+ }
100
+ async function validateIconPath(rootPath, canonicalRoot, skillRoot, skillPath, field, value) {
101
+ if (typeof value !== "string" || value.trim() !== value || !supportedText(value)
102
+ || path.isAbsolute(value) || /^[A-Za-z]:[\\/]/u.test(value)) {
103
+ return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field });
104
+ }
105
+ const iconPath = path.resolve(skillRoot, value);
106
+ if (!isWithin(rootPath, iconPath)) {
107
+ return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field });
108
+ }
109
+ try {
110
+ const [canonicalIcon, details] = await Promise.all([realpath(iconPath), stat(iconPath)]);
111
+ if (!details.isFile() || !isWithin(canonicalRoot, canonicalIcon))
112
+ throw new Error("unsafe icon");
113
+ return null;
114
+ }
115
+ catch {
116
+ return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field });
117
+ }
118
+ }
119
+ async function validateAgentFile(rootPath, skillRoot, skillPath, budget) {
120
+ const agentPath = path.join(skillRoot, "agents", "openai.yaml");
121
+ let agentDetails;
122
+ try {
123
+ agentDetails = await lstat(agentPath);
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ if (agentDetails.isSymbolicLink()) {
129
+ return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata must not be a symbolic link.", { path: skillPath })];
130
+ }
131
+ if (!agentDetails.isFile()) {
132
+ return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a regular file.", { path: packagePath(rootPath, agentPath) })];
133
+ }
134
+ let canonicalRoot;
135
+ let canonicalSkill;
136
+ let canonicalAgent;
137
+ try {
138
+ [canonicalRoot, canonicalSkill, canonicalAgent] = await Promise.all([realpath(rootPath), realpath(skillRoot), realpath(agentPath)]);
139
+ }
140
+ catch {
141
+ return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a readable regular file.", { path: packagePath(rootPath, agentPath) })];
142
+ }
143
+ if (!isWithin(canonicalRoot, canonicalAgent) || !isWithin(canonicalSkill, canonicalAgent)) {
144
+ return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata resolves outside its skill.", { path: skillPath })];
145
+ }
146
+ const source = await readSafeUtf8(agentPath, maxAgentBytes, budget);
147
+ if (source.kind === "budget") {
148
+ return [budgetFinding(source.nextBytes)];
149
+ }
150
+ if (source.kind === "invalid") {
151
+ return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a bounded UTF-8 regular file.", { path: packagePath(rootPath, agentPath), limit: maxAgentBytes })];
152
+ }
153
+ const parsed = parseSafeYaml(source.source);
154
+ if ("error" in parsed) {
155
+ return [finding(parsed.error === "yaml" ? "plugin.submission.skill.agent.invalid_yaml" : "plugin.submission.skill.agent.invalid_shape", "Optional agent metadata must be a safe YAML mapping.", { path: packagePath(rootPath, agentPath) })];
156
+ }
157
+ const metadata = parsed.value;
158
+ if (rejectUnknownKeys(metadata, agentKeys) || !isRecord(metadata.interface) || rejectUnknownKeys(metadata.interface, interfaceKeys)
159
+ || !supportedText(metadata.interface.display_name) || !supportedText(metadata.interface.short_description)) {
160
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an unsupported shape.", { path: packagePath(rootPath, agentPath) })];
161
+ }
162
+ if (metadata.interface.brand_color !== undefined && (typeof metadata.interface.brand_color !== "string" || !/^#[0-9A-Fa-f]{6}$/u.test(metadata.interface.brand_color))) {
163
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an invalid brand color.", { path: packagePath(rootPath, agentPath), field: "brand_color" })];
164
+ }
165
+ if (metadata.interface.default_prompt !== undefined && !supportedText(metadata.interface.default_prompt)) {
166
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an invalid default prompt.", { path: packagePath(rootPath, agentPath), field: "default_prompt" })];
167
+ }
168
+ if (metadata.policy !== undefined && (!isRecord(metadata.policy) || rejectUnknownKeys(metadata.policy, policyKeys)
169
+ || (metadata.policy.products !== undefined && (!Array.isArray(metadata.policy.products) || metadata.policy.products.length === 0
170
+ || new Set(metadata.policy.products).size !== metadata.policy.products.length
171
+ || metadata.policy.products.some((product) => product !== "CHAT" && product !== "CODEX")))
172
+ || (metadata.policy.allow_implicit_invocation !== undefined && typeof metadata.policy.allow_implicit_invocation !== "boolean"))) {
173
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent policy has an unsupported shape.", { path: packagePath(rootPath, agentPath), field: "policy" })];
174
+ }
175
+ if (metadata.dependencies !== undefined && (!isRecord(metadata.dependencies) || rejectUnknownKeys(metadata.dependencies, dependencyKeys)
176
+ || !Array.isArray(metadata.dependencies.tools) || metadata.dependencies.tools.length === 0
177
+ || metadata.dependencies.tools.some((tool) => !isToolDescriptor(tool)))) {
178
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent dependencies have an unsupported shape.", { path: packagePath(rootPath, agentPath), field: "dependencies" })];
179
+ }
180
+ for (const field of ["icon_small", "icon_large"]) {
181
+ const value = metadata.interface[field];
182
+ if (value === undefined)
183
+ continue;
184
+ const iconFinding = await validateIconPath(rootPath, canonicalRoot, skillRoot, skillPath, field, value);
185
+ if (iconFinding)
186
+ return [iconFinding];
187
+ }
188
+ return [];
189
+ }
190
+ export async function validateSubmissionSkillMetadata(discoveredPackage, targetType) {
191
+ const { manifest, rootPath } = discoveredPackage;
192
+ if (manifest.skills === undefined) {
193
+ return targetType === "skills-only"
194
+ ? { findings: [finding("plugin.submission.skill.required", "Skills-only submissions require a valid skill.")], skillCount: 0 }
195
+ : { findings: [], skillCount: 0 };
196
+ }
197
+ if (manifest.skills !== "./skills" && manifest.skills !== "./skills/") {
198
+ return { findings: [finding("plugin.submission.skill.invalid_manifest", "Skills must be declared as the root ./skills directory.", { field: "skills" })], skillCount: 0 };
199
+ }
200
+ const skillsPath = path.resolve(rootPath, manifest.skills);
201
+ const safe = await safeDirectory(rootPath, skillsPath);
202
+ if (!safe) {
203
+ return { findings: [finding("plugin.submission.skill.invalid_path", "Skills directory must be canonically contained in the package.", { path: "skills" })], skillCount: 0 };
204
+ }
205
+ const findings = [];
206
+ const identities = new Set();
207
+ let skillCount = 0;
208
+ const budget = { bytes: 0 };
209
+ let entries;
210
+ try {
211
+ entries = await opendir(skillsPath);
212
+ }
213
+ catch {
214
+ return { findings: [finding("plugin.submission.skill.invalid_path", "Skills directory cannot be inspected safely.", { path: "skills" })], skillCount: 0 };
215
+ }
216
+ let entryCount = 0;
217
+ let skillDirectoryCount = 0;
218
+ for await (const entry of entries) {
219
+ entryCount += 1;
220
+ if (entryCount > maxDirectoryEntries) {
221
+ findings.push(finding("plugin.submission.skill.too_many", "Skills directory exceeds the submission preflight entry limit.", { count: entryCount, limit: maxDirectoryEntries }));
222
+ break;
223
+ }
224
+ if (entry.name.startsWith(".") || !entry.isDirectory())
225
+ continue;
226
+ skillDirectoryCount += 1;
227
+ if (skillDirectoryCount > maxSkillDirectories) {
228
+ findings.push(finding("plugin.submission.skill.too_many", "Skills directory exceeds the submission preflight skill limit.", { count: skillDirectoryCount, limit: maxSkillDirectories }));
229
+ break;
230
+ }
231
+ const skillRoot = path.join(skillsPath, entry.name);
232
+ const skillFile = path.join(skillRoot, "SKILL.md");
233
+ const relativeSkillPath = packagePath(rootPath, skillFile);
234
+ let canonicalSkill;
235
+ try {
236
+ canonicalSkill = await realpath(skillRoot);
237
+ if (!isWithin(safe.canonicalSkills, canonicalSkill))
238
+ throw new Error("unsafe skill root");
239
+ }
240
+ catch {
241
+ findings.push(finding("plugin.submission.skill.invalid_path", "Skill directory resolves outside the declared skills directory.", { path: packagePath(rootPath, skillRoot) }));
242
+ continue;
243
+ }
244
+ try {
245
+ const details = await lstat(skillFile);
246
+ if (details.isSymbolicLink()) {
247
+ findings.push(finding("plugin.submission.skill.invalid_path", "Skill entrypoint must not be a symbolic link.", { path: relativeSkillPath }));
248
+ continue;
249
+ }
250
+ if (!details.isFile() || !isWithin(canonicalSkill, await realpath(skillFile))) {
251
+ findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a contained regular file.", { path: relativeSkillPath }));
252
+ continue;
253
+ }
254
+ }
255
+ catch {
256
+ findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a contained regular file.", { path: relativeSkillPath }));
257
+ continue;
258
+ }
259
+ const source = await readSafeUtf8(skillFile, maxSkillBytes, budget);
260
+ if (source.kind === "budget") {
261
+ findings.push(budgetFinding(source.nextBytes));
262
+ break;
263
+ }
264
+ if (source.kind === "invalid") {
265
+ findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a bounded UTF-8 regular file.", { path: relativeSkillPath, limit: maxSkillBytes }));
266
+ continue;
267
+ }
268
+ const split = splitSkillFile(source.source);
269
+ if (!split) {
270
+ findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint requires delimited frontmatter and a nonempty body.", { path: relativeSkillPath }));
271
+ continue;
272
+ }
273
+ const parsed = parseSafeYaml(split.frontmatter);
274
+ if ("error" in parsed) {
275
+ findings.push(finding(parsed.error === "yaml" ? "plugin.submission.skill.invalid_yaml" : "plugin.submission.skill.invalid_shape", "Skill frontmatter must be a safe YAML mapping.", { path: relativeSkillPath }));
276
+ continue;
277
+ }
278
+ const name = parsed.value.name;
279
+ const description = parsed.value.description;
280
+ const normalizedName = typeof name === "string" ? name.normalize("NFKC").trim() : "";
281
+ const pluginName = typeof manifest.name === "string" ? manifest.name.normalize("NFKC").trim() : "";
282
+ if (!supportedText(name) || !supportedText(description, 1024) || normalizedName.length === 0
283
+ || `${pluginName}:${normalizedName}`.length > 64 || identities.has(normalizedName)) {
284
+ findings.push(finding("plugin.submission.skill.identity", "Skill identity metadata is invalid or duplicated.", { path: relativeSkillPath, limit: 64 }));
285
+ continue;
286
+ }
287
+ identities.add(normalizedName);
288
+ skillCount += 1;
289
+ const agentFindings = await validateAgentFile(rootPath, skillRoot, packagePath(rootPath, skillRoot), budget);
290
+ findings.push(...agentFindings);
291
+ if (agentFindings.some((item) => item.id === "plugin.submission.skill.budget_exceeded"))
292
+ break;
293
+ }
294
+ if (targetType === "skills-only" && skillCount === 0 && !findings.some((item) => item.id === "plugin.submission.skill.required")) {
295
+ findings.push(finding("plugin.submission.skill.required", "Skills-only submissions require at least one valid skill.", { count: 0 }));
296
+ }
297
+ if (targetType === "mcp-backed" && skillCount === 0 && findings.length === 0) {
298
+ findings.push(finding("plugin.submission.skill.invalid_file", "Declared skills must include at least one valid immediate skill entrypoint.", { path: "skills" }));
299
+ }
300
+ return { findings, skillCount };
301
+ }
@@ -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
+ ];
package/dist/index.d.ts CHANGED
@@ -14,6 +14,11 @@ export { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestatio
14
14
  export { buildExternalValidationCorpusReport, ExternalCorpusManifestError, loadExternalCorpusManifest, type BuildExternalValidationCorpusOptions, type ExternalCorpusCaseResult, type ExternalCorpusClassificationCounts, type ExternalCorpusFindingReview, type ExternalCorpusManifest, type ExternalCorpusMode, type ExternalCorpusProfile, type ExternalCorpusSourceType, type ExternalValidationCorpusReport, type FindingReviewClassification } from "./core/external-validation-corpus.js";
15
15
  export { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, CorpusMetricsDiffError, CorpusMetricsManifestError, loadCorpusMetricsManifest, reconcileCorpusMetricFindings, renderCorpusQualityMetricsDiffJson, renderCorpusQualityMetricsDiffMarkdown, renderCorpusQualityMetricsDiffText, renderCorpusQualityMetricsJson, renderCorpusQualityMetricsMarkdown, renderCorpusQualityMetricsText, type BuildCorpusMetricsDiffOptions, type BuildCorpusMetricsOptions, type CorpusMetricCounts, type CorpusMetricCountDeltas, type CorpusMetricFinding, type CorpusMetricFindingReview, type CorpusMetricReconciliation, type CorpusMetricsManifest, type CorpusMetricSource, type CorpusMetricThresholdCheck, type CorpusMetricThresholds, type CorpusMetricValues, type CorpusMetricValueComparison, type CorpusQualityMetricsDiffReport, type CorpusQualityMetricsDiffTarget, type CorpusQualityMetricsReport, type CorpusQualityMetricsTargetResult } from "./core/corpus-quality-metrics.js";
16
16
  export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson, type DoctorOutputContract, type OutputContractRule, type OutputContractSchema } from "./core/output-contract.js";
17
+ export { submissionRuleset, submissionManualChecks } from "./core/submission-ruleset.js";
18
+ export { buildSubmissionPreflight, type SubmissionCheck, type SubmissionFinding, type SubmissionManualCheck, type SubmissionPreflightReport } from "./core/submission-preflight.js";
19
+ export { validateSubmissionAssets, type SubmissionAssetResult } from "./core/submission-assets.js";
20
+ export { validateSubmissionSkillMetadata, type SubmissionSkillMetadataResult } from "./core/submission-skill-metadata.js";
21
+ export { renderSubmissionPreflightJson, renderSubmissionPreflightMarkdown, renderSubmissionPreflightText, submissionPreflightExitCode } from "./reporting/render-submission-report.js";
17
22
  export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport, type BuildDoctorValidationCorpusOptions, type DoctorValidationCorpusReport, type ValidationCorpusCaseDefinition, type ValidationCorpusCaseResult } from "./core/validation-corpus.js";
18
23
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis, type PackageAnalysis, type PackageAnalysisOptions, type PackageAnalysisStage, type PackageAnalysisTiming } from "./core/package-analysis.js";
19
24
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson, type BuildDoctorPerformanceReportOptions, type DoctorPerformanceReport, type DoctorPerformanceStage, type DoctorPerformanceStageName } from "./core/performance-report.js";
package/dist/index.js CHANGED
@@ -14,6 +14,11 @@ export { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestatio
14
14
  export { buildExternalValidationCorpusReport, ExternalCorpusManifestError, loadExternalCorpusManifest } from "./core/external-validation-corpus.js";
15
15
  export { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, CorpusMetricsDiffError, CorpusMetricsManifestError, loadCorpusMetricsManifest, reconcileCorpusMetricFindings, renderCorpusQualityMetricsDiffJson, renderCorpusQualityMetricsDiffMarkdown, renderCorpusQualityMetricsDiffText, renderCorpusQualityMetricsJson, renderCorpusQualityMetricsMarkdown, renderCorpusQualityMetricsText } from "./core/corpus-quality-metrics.js";
16
16
  export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
17
+ export { submissionRuleset, submissionManualChecks } from "./core/submission-ruleset.js";
18
+ export { buildSubmissionPreflight } from "./core/submission-preflight.js";
19
+ export { validateSubmissionAssets } from "./core/submission-assets.js";
20
+ export { validateSubmissionSkillMetadata } from "./core/submission-skill-metadata.js";
21
+ export { renderSubmissionPreflightJson, renderSubmissionPreflightMarkdown, renderSubmissionPreflightText, submissionPreflightExitCode } from "./reporting/render-submission-report.js";
17
22
  export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
18
23
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./core/package-analysis.js";
19
24
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";