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.
- package/LICENSE +21 -21
- package/README.md +20 -6
- 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/output-contract.js +76 -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/shell-completion.js +29 -0
- package/dist/core/submission-assets.d.ts +6 -0
- package/dist/core/submission-assets.js +430 -0
- package/dist/core/submission-preflight.d.ts +34 -0
- package/dist/core/submission-preflight.js +289 -0
- package/dist/core/submission-ruleset.d.ts +56 -0
- package/dist/core/submission-ruleset.js +38 -0
- package/dist/core/submission-skill-metadata.d.ts +9 -0
- package/dist/core/submission-skill-metadata.js +301 -0
- 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/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mcp/generic-mcp-doctor.js +45 -29
- package/dist/reporting/render-submission-report.d.ts +5 -0
- package/dist/reporting/render-submission-report.js +68 -0
- package/dist/rules/rule-catalog.js +130 -4
- package/dist/run-cli.js +96 -0
- 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 +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 (
|
|
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
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";
|