codex-plugin-doctor 1.59.0 → 1.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -19
- package/dist/core/bounded-http-client.js +1 -0
- package/dist/core/mcp-discovery.d.ts +23 -0
- package/dist/core/mcp-discovery.js +148 -0
- package/dist/core/output-contract.js +62 -0
- package/dist/core/shell-completion.js +23 -0
- package/dist/core/submission-archive-preflight.d.ts +24 -0
- package/dist/core/submission-archive-preflight.js +259 -0
- package/dist/core/submission-archive-reader.d.ts +21 -0
- package/dist/core/submission-archive-reader.js +624 -0
- package/dist/core/submission-archive-ruleset.d.ts +58 -0
- package/dist/core/submission-archive-ruleset.js +61 -0
- package/dist/core/submission-assets.d.ts +3 -1
- package/dist/core/submission-assets.js +52 -31
- package/dist/core/submission-package-reader.d.ts +15 -0
- package/dist/core/submission-package-reader.js +274 -0
- package/dist/core/submission-preflight.d.ts +2 -0
- package/dist/core/submission-preflight.js +2 -2
- package/dist/core/submission-skill-metadata.d.ts +5 -3
- package/dist/core/submission-skill-metadata.js +117 -136
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/reporting/render-mcp-discovery-report.d.ts +2 -0
- package/dist/reporting/render-mcp-discovery-report.js +14 -0
- package/dist/reporting/render-submission-archive-report.d.ts +4 -0
- package/dist/reporting/render-submission-archive-report.js +52 -0
- package/dist/run-cli.d.ts +2 -0
- package/dist/run-cli.js +139 -1
- package/package.json +5 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
import { isAlias, isNode, parseDocument, visit } from "yaml";
|
|
3
|
+
import { createDirectorySubmissionPackageReader } from "./submission-package-reader.js";
|
|
4
4
|
const maxSkillBytes = 1024 * 1024;
|
|
5
5
|
const maxAgentBytes = 256 * 1024;
|
|
6
6
|
const maxAggregateMetadataBytes = 16 * 1024 * 1024;
|
|
@@ -15,17 +15,8 @@ const agentKeys = new Set(["interface", "policy", "dependencies"]);
|
|
|
15
15
|
function isRecord(value) {
|
|
16
16
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
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
18
|
function finding(id, message, evidence) {
|
|
26
|
-
return evidence === undefined
|
|
27
|
-
? { id, severity: "fail", message }
|
|
28
|
-
: { id, severity: "fail", message, evidence };
|
|
19
|
+
return evidence === undefined ? { id, severity: "fail", message } : { id, severity: "fail", message, evidence };
|
|
29
20
|
}
|
|
30
21
|
function supportedText(value, limit = Number.MAX_SAFE_INTEGER) {
|
|
31
22
|
return typeof value === "string" && value.trim().length > 0 && value.length <= limit && !unsupportedText.test(value);
|
|
@@ -52,34 +43,53 @@ function parseSafeYaml(source) {
|
|
|
52
43
|
}
|
|
53
44
|
function splitSkillFile(source) {
|
|
54
45
|
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/u.exec(source);
|
|
55
|
-
|
|
46
|
+
return !match || match[2].trim().length === 0 ? null : { frontmatter: match[1], body: match[2] };
|
|
47
|
+
}
|
|
48
|
+
function skillsRoot(value) {
|
|
49
|
+
if (typeof value !== "string" || !value.startsWith("./") || value.includes("\\") || /[\u0000-\u001F\u007F]/u.test(value))
|
|
56
50
|
return null;
|
|
57
|
-
|
|
51
|
+
const normalized = path.posix.normalize(value.slice(2)).replace(/\/$/u, "");
|
|
52
|
+
return normalized === "skills" ? normalized : null;
|
|
58
53
|
}
|
|
59
|
-
|
|
60
|
-
|
|
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 {
|
|
54
|
+
function relativePackagePath(base, value) {
|
|
55
|
+
if (path.posix.isAbsolute(value) || /^[A-Za-z]:[\\/]/u.test(value))
|
|
65
56
|
return null;
|
|
66
|
-
|
|
57
|
+
const resolved = path.posix.normalize(path.posix.join(base, value));
|
|
58
|
+
return resolved === ".." || resolved.startsWith("../") || resolved === "." ? null : resolved;
|
|
59
|
+
}
|
|
60
|
+
function normalizeResolvedPackagePath(value) {
|
|
61
|
+
if (typeof value !== "string" || value === "" || value.includes("\\") || path.posix.isAbsolute(value)
|
|
62
|
+
|| /^[A-Za-z]:/u.test(value) || /[\u0000-\u001F\u007F]/u.test(value))
|
|
63
|
+
return null;
|
|
64
|
+
const normalized = path.posix.normalize(value);
|
|
65
|
+
if (normalized === "" || normalized === "." || normalized === ".." || normalized.startsWith("../"))
|
|
66
|
+
return null;
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
function isWithinPackagePath(root, candidate) {
|
|
70
|
+
return candidate === root || candidate.startsWith(`${root}/`);
|
|
67
71
|
}
|
|
68
72
|
function budgetFinding(bytes) {
|
|
69
73
|
return finding("plugin.submission.skill.budget_exceeded", "Skill metadata exceeds the aggregate submission preflight size limit.", {
|
|
70
74
|
count: bytes, limit: maxAggregateMetadataBytes
|
|
71
75
|
});
|
|
72
76
|
}
|
|
73
|
-
async function
|
|
77
|
+
async function safeStat(reader, packagePath) {
|
|
78
|
+
return reader.stat(packagePath).catch(() => null);
|
|
79
|
+
}
|
|
80
|
+
async function readSafeUtf8(reader, packagePath, maximum, budget) {
|
|
81
|
+
const details = await safeStat(reader, packagePath);
|
|
82
|
+
if (details === null || details.safeResolution !== "safe" || details.resolvedKind !== "file" || details.size > maximum)
|
|
83
|
+
return { kind: "invalid" };
|
|
84
|
+
const nextBytes = budget.bytes + details.size;
|
|
85
|
+
if (nextBytes > maxAggregateMetadataBytes)
|
|
86
|
+
return { kind: "budget", nextBytes };
|
|
87
|
+
const bytes = await reader.read(packagePath, maximum).catch(() => null);
|
|
88
|
+
if (bytes === null || bytes.byteLength !== details.size)
|
|
89
|
+
return { kind: "invalid" };
|
|
74
90
|
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
91
|
budget.bytes = nextBytes;
|
|
82
|
-
return { kind: "source", source: new TextDecoder("utf-8", { fatal: true }).decode(
|
|
92
|
+
return { kind: "source", source: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
|
|
83
93
|
}
|
|
84
94
|
catch {
|
|
85
95
|
return { kind: "invalid" };
|
|
@@ -97,205 +107,176 @@ function isToolDescriptor(value) {
|
|
|
97
107
|
&& (value.transport === undefined || supportedText(value.transport))
|
|
98
108
|
&& (value.url === undefined || supportedText(value.url));
|
|
99
109
|
}
|
|
100
|
-
async function validateIconPath(
|
|
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)) {
|
|
110
|
+
async function validateIconPath(reader, skillRoot, skillPath, field, value) {
|
|
111
|
+
if (typeof value !== "string" || value.trim() !== value || !supportedText(value)) {
|
|
107
112
|
return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field });
|
|
108
113
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
throw new Error("unsafe icon");
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
114
|
+
const iconPath = relativePackagePath(skillRoot, value);
|
|
115
|
+
const details = iconPath === null ? null : await safeStat(reader, iconPath);
|
|
116
|
+
if (details === null || details.safeResolution !== "safe" || details.resolvedKind !== "file") {
|
|
116
117
|
return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field });
|
|
117
118
|
}
|
|
119
|
+
return null;
|
|
118
120
|
}
|
|
119
|
-
async function validateAgentFile(
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
121
|
+
async function validateAgentFile(reader, skillRoot, resolvedSkillRoot, budget) {
|
|
122
|
+
const skillPath = skillRoot;
|
|
123
|
+
const agentPath = `${skillRoot}/agents/openai.yaml`;
|
|
124
|
+
const agentDetails = await safeStat(reader, agentPath);
|
|
125
|
+
if (agentDetails === null)
|
|
126
126
|
return [];
|
|
127
|
-
|
|
128
|
-
if (agentDetails.isSymbolicLink()) {
|
|
127
|
+
if (agentDetails.kind === "symlink") {
|
|
129
128
|
return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata must not be a symbolic link.", { path: skillPath })];
|
|
130
129
|
}
|
|
131
|
-
if (
|
|
132
|
-
return [finding("plugin.submission.skill.agent.
|
|
130
|
+
if (agentDetails.safeResolution === "outside") {
|
|
131
|
+
return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata resolves outside its skill.", { path: skillPath })];
|
|
133
132
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
let canonicalAgent;
|
|
137
|
-
try {
|
|
138
|
-
[canonicalRoot, canonicalSkill, canonicalAgent] = await Promise.all([realpath(rootPath), realpath(skillRoot), realpath(agentPath)]);
|
|
133
|
+
if (agentDetails.safeResolution !== "safe") {
|
|
134
|
+
return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a readable regular file.", { path: agentPath })];
|
|
139
135
|
}
|
|
140
|
-
|
|
141
|
-
return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a
|
|
136
|
+
if (agentDetails.resolvedKind !== "file") {
|
|
137
|
+
return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a regular file.", { path: agentPath })];
|
|
142
138
|
}
|
|
143
|
-
|
|
139
|
+
const normalizedSkillRoot = normalizeResolvedPackagePath(resolvedSkillRoot);
|
|
140
|
+
const resolvedAgentPath = normalizeResolvedPackagePath(agentDetails.resolvedPackagePath ?? agentPath);
|
|
141
|
+
if (normalizedSkillRoot === null || resolvedAgentPath === null || !isWithinPackagePath(normalizedSkillRoot, resolvedAgentPath)) {
|
|
144
142
|
return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata resolves outside its skill.", { path: skillPath })];
|
|
145
143
|
}
|
|
146
|
-
const source = await readSafeUtf8(agentPath, maxAgentBytes, budget);
|
|
147
|
-
if (source.kind === "budget")
|
|
144
|
+
const source = await readSafeUtf8(reader, agentPath, maxAgentBytes, budget);
|
|
145
|
+
if (source.kind === "budget")
|
|
148
146
|
return [budgetFinding(source.nextBytes)];
|
|
149
|
-
}
|
|
150
147
|
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:
|
|
148
|
+
return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a bounded UTF-8 regular file.", { path: agentPath, limit: maxAgentBytes })];
|
|
152
149
|
}
|
|
153
150
|
const parsed = parseSafeYaml(source.source);
|
|
154
151
|
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:
|
|
152
|
+
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: agentPath })];
|
|
156
153
|
}
|
|
157
154
|
const metadata = parsed.value;
|
|
158
155
|
if (rejectUnknownKeys(metadata, agentKeys) || !isRecord(metadata.interface) || rejectUnknownKeys(metadata.interface, interfaceKeys)
|
|
159
156
|
|| !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:
|
|
157
|
+
return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an unsupported shape.", { path: agentPath })];
|
|
161
158
|
}
|
|
162
159
|
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:
|
|
160
|
+
return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an invalid brand color.", { path: agentPath, field: "brand_color" })];
|
|
164
161
|
}
|
|
165
162
|
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:
|
|
163
|
+
return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an invalid default prompt.", { path: agentPath, field: "default_prompt" })];
|
|
167
164
|
}
|
|
168
165
|
if (metadata.policy !== undefined && (!isRecord(metadata.policy) || rejectUnknownKeys(metadata.policy, policyKeys)
|
|
169
166
|
|| (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")))
|
|
167
|
+
|| new Set(metadata.policy.products).size !== metadata.policy.products.length || metadata.policy.products.some((product) => product !== "CHAT" && product !== "CODEX")))
|
|
172
168
|
|| (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:
|
|
169
|
+
return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent policy has an unsupported shape.", { path: agentPath, field: "policy" })];
|
|
174
170
|
}
|
|
175
171
|
if (metadata.dependencies !== undefined && (!isRecord(metadata.dependencies) || rejectUnknownKeys(metadata.dependencies, dependencyKeys)
|
|
176
|
-
|| !Array.isArray(metadata.dependencies.tools) || metadata.dependencies.tools.length === 0
|
|
177
|
-
|
|
178
|
-
return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent dependencies have an unsupported shape.", { path: packagePath(rootPath, agentPath), field: "dependencies" })];
|
|
172
|
+
|| !Array.isArray(metadata.dependencies.tools) || metadata.dependencies.tools.length === 0 || metadata.dependencies.tools.some((tool) => !isToolDescriptor(tool)))) {
|
|
173
|
+
return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent dependencies have an unsupported shape.", { path: agentPath, field: "dependencies" })];
|
|
179
174
|
}
|
|
180
175
|
for (const field of ["icon_small", "icon_large"]) {
|
|
181
176
|
const value = metadata.interface[field];
|
|
182
177
|
if (value === undefined)
|
|
183
178
|
continue;
|
|
184
|
-
const iconFinding = await validateIconPath(
|
|
185
|
-
if (iconFinding)
|
|
179
|
+
const iconFinding = await validateIconPath(reader, skillRoot, skillPath, field, value);
|
|
180
|
+
if (iconFinding !== null)
|
|
186
181
|
return [iconFinding];
|
|
187
182
|
}
|
|
188
183
|
return [];
|
|
189
184
|
}
|
|
190
|
-
export async function
|
|
191
|
-
const { manifest, rootPath } = discoveredPackage;
|
|
185
|
+
export async function validateSubmissionSkillMetadataFromReader(manifest, targetType, reader) {
|
|
192
186
|
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 };
|
|
187
|
+
return targetType === "skills-only" ? { findings: [finding("plugin.submission.skill.required", "Skills-only submissions require a valid skill.")], skillCount: 0 } : { findings: [], skillCount: 0 };
|
|
196
188
|
}
|
|
197
|
-
|
|
189
|
+
const root = skillsRoot(manifest.skills);
|
|
190
|
+
if (root === null) {
|
|
198
191
|
return { findings: [finding("plugin.submission.skill.invalid_manifest", "Skills must be declared as the root ./skills directory.", { field: "skills" })], skillCount: 0 };
|
|
199
192
|
}
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
193
|
+
const rootDetails = await safeStat(reader, root);
|
|
194
|
+
if (rootDetails === null || rootDetails.safeResolution !== "safe" || rootDetails.resolvedKind !== "directory") {
|
|
195
|
+
return { findings: [finding("plugin.submission.skill.invalid_path", "Skills directory must be canonically contained in the package.", { path: root })], skillCount: 0 };
|
|
196
|
+
}
|
|
197
|
+
const listed = await reader.list(root).catch(() => null);
|
|
198
|
+
if (listed === null) {
|
|
199
|
+
return { findings: [finding("plugin.submission.skill.invalid_path", "Skills directory cannot be inspected safely.", { path: root })], skillCount: 0 };
|
|
204
200
|
}
|
|
205
201
|
const findings = [];
|
|
206
202
|
const identities = new Set();
|
|
207
|
-
let skillCount = 0;
|
|
208
203
|
const budget = { bytes: 0 };
|
|
209
|
-
let
|
|
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;
|
|
204
|
+
let skillCount = 0;
|
|
217
205
|
let skillDirectoryCount = 0;
|
|
218
|
-
|
|
219
|
-
|
|
206
|
+
const entries = [...listed].sort((left, right) => left.packagePath.localeCompare(right.packagePath));
|
|
207
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
208
|
+
const entry = entries[index];
|
|
209
|
+
const entryCount = index + 1;
|
|
220
210
|
if (entryCount > maxDirectoryEntries) {
|
|
221
211
|
findings.push(finding("plugin.submission.skill.too_many", "Skills directory exceeds the submission preflight entry limit.", { count: entryCount, limit: maxDirectoryEntries }));
|
|
222
212
|
break;
|
|
223
213
|
}
|
|
224
|
-
|
|
214
|
+
const name = path.posix.basename(entry.packagePath);
|
|
215
|
+
if (path.posix.dirname(entry.packagePath) !== root || name.startsWith(".") || entry.resolvedKind !== "directory")
|
|
216
|
+
continue;
|
|
217
|
+
if (entry.safeResolution !== "safe" || entry.kind === "symlink") {
|
|
218
|
+
findings.push(finding("plugin.submission.skill.invalid_path", "Skill directory resolves outside the declared skills directory.", { path: entry.packagePath }));
|
|
225
219
|
continue;
|
|
220
|
+
}
|
|
226
221
|
skillDirectoryCount += 1;
|
|
227
222
|
if (skillDirectoryCount > maxSkillDirectories) {
|
|
228
223
|
findings.push(finding("plugin.submission.skill.too_many", "Skills directory exceeds the submission preflight skill limit.", { count: skillDirectoryCount, limit: maxSkillDirectories }));
|
|
229
224
|
break;
|
|
230
225
|
}
|
|
231
|
-
const skillRoot =
|
|
232
|
-
const skillFile =
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
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) }));
|
|
226
|
+
const skillRoot = entry.packagePath;
|
|
227
|
+
const skillFile = `${skillRoot}/SKILL.md`;
|
|
228
|
+
const fileDetails = await safeStat(reader, skillFile);
|
|
229
|
+
if (fileDetails?.kind === "symlink" || fileDetails?.safeResolution === "outside") {
|
|
230
|
+
findings.push(finding("plugin.submission.skill.invalid_path", "Skill entrypoint must not be a symbolic link.", { path: skillFile }));
|
|
242
231
|
continue;
|
|
243
232
|
}
|
|
244
|
-
|
|
245
|
-
|
|
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 }));
|
|
233
|
+
if (fileDetails === null || fileDetails.safeResolution !== "safe" || fileDetails.resolvedKind !== "file") {
|
|
234
|
+
findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a contained regular file.", { path: skillFile }));
|
|
257
235
|
continue;
|
|
258
236
|
}
|
|
259
|
-
const source = await readSafeUtf8(skillFile, maxSkillBytes, budget);
|
|
237
|
+
const source = await readSafeUtf8(reader, skillFile, maxSkillBytes, budget);
|
|
260
238
|
if (source.kind === "budget") {
|
|
261
239
|
findings.push(budgetFinding(source.nextBytes));
|
|
262
240
|
break;
|
|
263
241
|
}
|
|
264
242
|
if (source.kind === "invalid") {
|
|
265
|
-
findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a bounded UTF-8 regular file.", { path:
|
|
243
|
+
findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a bounded UTF-8 regular file.", { path: skillFile, limit: maxSkillBytes }));
|
|
266
244
|
continue;
|
|
267
245
|
}
|
|
268
246
|
const split = splitSkillFile(source.source);
|
|
269
|
-
if (
|
|
270
|
-
findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint requires delimited frontmatter and a nonempty body.", { path:
|
|
247
|
+
if (split === null) {
|
|
248
|
+
findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint requires delimited frontmatter and a nonempty body.", { path: skillFile }));
|
|
271
249
|
continue;
|
|
272
250
|
}
|
|
273
251
|
const parsed = parseSafeYaml(split.frontmatter);
|
|
274
252
|
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:
|
|
253
|
+
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: skillFile }));
|
|
276
254
|
continue;
|
|
277
255
|
}
|
|
278
|
-
const
|
|
256
|
+
const nameValue = parsed.value.name;
|
|
279
257
|
const description = parsed.value.description;
|
|
280
|
-
const normalizedName = typeof
|
|
258
|
+
const normalizedName = typeof nameValue === "string" ? nameValue.normalize("NFKC").trim() : "";
|
|
281
259
|
const pluginName = typeof manifest.name === "string" ? manifest.name.normalize("NFKC").trim() : "";
|
|
282
|
-
if (!supportedText(
|
|
283
|
-
|
|
284
|
-
findings.push(finding("plugin.submission.skill.identity", "Skill identity metadata is invalid or duplicated.", { path: relativeSkillPath, limit: 64 }));
|
|
260
|
+
if (!supportedText(nameValue) || !supportedText(description, 1024) || normalizedName.length === 0 || `${pluginName}:${normalizedName}`.length > 64 || identities.has(normalizedName)) {
|
|
261
|
+
findings.push(finding("plugin.submission.skill.identity", "Skill identity metadata is invalid or duplicated.", { path: skillFile, limit: 64 }));
|
|
285
262
|
continue;
|
|
286
263
|
}
|
|
287
264
|
identities.add(normalizedName);
|
|
288
265
|
skillCount += 1;
|
|
289
|
-
const
|
|
266
|
+
const resolvedSkillRoot = entry.resolvedPackagePath ?? skillRoot;
|
|
267
|
+
const agentFindings = await validateAgentFile(reader, skillRoot, resolvedSkillRoot, budget);
|
|
290
268
|
findings.push(...agentFindings);
|
|
291
269
|
if (agentFindings.some((item) => item.id === "plugin.submission.skill.budget_exceeded"))
|
|
292
270
|
break;
|
|
293
271
|
}
|
|
294
|
-
if (targetType === "skills-only" && skillCount === 0
|
|
272
|
+
if (targetType === "skills-only" && skillCount === 0) {
|
|
295
273
|
findings.push(finding("plugin.submission.skill.required", "Skills-only submissions require at least one valid skill.", { count: 0 }));
|
|
296
274
|
}
|
|
297
275
|
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:
|
|
276
|
+
findings.push(finding("plugin.submission.skill.invalid_file", "Declared skills must include at least one valid immediate skill entrypoint.", { path: root }));
|
|
299
277
|
}
|
|
300
278
|
return { findings, skillCount };
|
|
301
279
|
}
|
|
280
|
+
export async function validateSubmissionSkillMetadata(discoveredPackage, targetType) {
|
|
281
|
+
return validateSubmissionSkillMetadataFromReader(discoveredPackage.manifest, targetType, createDirectorySubmissionPackageReader(discoveredPackage.rootPath));
|
|
282
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -16,9 +16,11 @@ export { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, C
|
|
|
16
16
|
export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson, type DoctorOutputContract, type OutputContractRule, type OutputContractSchema } from "./core/output-contract.js";
|
|
17
17
|
export { submissionRuleset, submissionManualChecks } from "./core/submission-ruleset.js";
|
|
18
18
|
export { buildSubmissionPreflight, type SubmissionCheck, type SubmissionFinding, type SubmissionManualCheck, type SubmissionPreflightReport } from "./core/submission-preflight.js";
|
|
19
|
+
export { buildSubmissionArchivePreflight, submissionArchiveExitCode, type SubmissionArchivePreflightReport, type SubmissionArchiveRootLayout } from "./core/submission-archive-preflight.js";
|
|
19
20
|
export { validateSubmissionAssets, type SubmissionAssetResult } from "./core/submission-assets.js";
|
|
20
21
|
export { validateSubmissionSkillMetadata, type SubmissionSkillMetadataResult } from "./core/submission-skill-metadata.js";
|
|
21
22
|
export { renderSubmissionPreflightJson, renderSubmissionPreflightMarkdown, renderSubmissionPreflightText, submissionPreflightExitCode } from "./reporting/render-submission-report.js";
|
|
23
|
+
export { renderSubmissionArchiveJson, renderSubmissionArchiveMarkdown, renderSubmissionArchiveText } from "./reporting/render-submission-archive-report.js";
|
|
22
24
|
export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport, type BuildDoctorValidationCorpusOptions, type DoctorValidationCorpusReport, type ValidationCorpusCaseDefinition, type ValidationCorpusCaseResult } from "./core/validation-corpus.js";
|
|
23
25
|
export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis, type PackageAnalysis, type PackageAnalysisOptions, type PackageAnalysisStage, type PackageAnalysisTiming } from "./core/package-analysis.js";
|
|
24
26
|
export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson, type BuildDoctorPerformanceReportOptions, type DoctorPerformanceReport, type DoctorPerformanceStage, type DoctorPerformanceStageName } from "./core/performance-report.js";
|
package/dist/index.js
CHANGED
|
@@ -16,9 +16,11 @@ export { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, C
|
|
|
16
16
|
export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
|
|
17
17
|
export { submissionRuleset, submissionManualChecks } from "./core/submission-ruleset.js";
|
|
18
18
|
export { buildSubmissionPreflight } from "./core/submission-preflight.js";
|
|
19
|
+
export { buildSubmissionArchivePreflight, submissionArchiveExitCode } from "./core/submission-archive-preflight.js";
|
|
19
20
|
export { validateSubmissionAssets } from "./core/submission-assets.js";
|
|
20
21
|
export { validateSubmissionSkillMetadata } from "./core/submission-skill-metadata.js";
|
|
21
22
|
export { renderSubmissionPreflightJson, renderSubmissionPreflightMarkdown, renderSubmissionPreflightText, submissionPreflightExitCode } from "./reporting/render-submission-report.js";
|
|
23
|
+
export { renderSubmissionArchiveJson, renderSubmissionArchiveMarkdown, renderSubmissionArchiveText } from "./reporting/render-submission-archive-report.js";
|
|
22
24
|
export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
|
|
23
25
|
export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./core/package-analysis.js";
|
|
24
26
|
export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function renderMcpDiscoveryReport(report) {
|
|
2
|
+
return [
|
|
3
|
+
"MCP Discovery",
|
|
4
|
+
"=============",
|
|
5
|
+
`Status: ${report.status}`,
|
|
6
|
+
`Scope: ${report.scope}`,
|
|
7
|
+
`Requested protocol: ${report.requestedVersion}`,
|
|
8
|
+
`Advertised versions: ${report.supportedVersions.join(", ") || "not available"}`,
|
|
9
|
+
`Discovery response: ${report.coverage.discovery}`,
|
|
10
|
+
`Runtime: ${report.coverage.runtime}`,
|
|
11
|
+
"Discovery does not establish full protocol conformance or client compatibility.",
|
|
12
|
+
...report.findings.map((finding) => `${finding.severity.toUpperCase()} ${finding.id}: ${finding.message}\n ${finding.suggestedFix}`)
|
|
13
|
+
].join("\n");
|
|
14
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SubmissionArchivePreflightReport } from "../core/submission-archive-preflight.js";
|
|
2
|
+
export declare function renderSubmissionArchiveJson(report: SubmissionArchivePreflightReport): string;
|
|
3
|
+
export declare function renderSubmissionArchiveText(report: SubmissionArchivePreflightReport): string;
|
|
4
|
+
export declare function renderSubmissionArchiveMarkdown(report: SubmissionArchivePreflightReport): string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
function upper(value) {
|
|
2
|
+
return value.replace(/[-_]/gu, " ").toUpperCase();
|
|
3
|
+
}
|
|
4
|
+
function escapeMarkdown(value) {
|
|
5
|
+
return value.replace(/[\\`*_{}\[\]()<>#+\-.!|]/gu, "\\$&");
|
|
6
|
+
}
|
|
7
|
+
function summary(report) {
|
|
8
|
+
return [
|
|
9
|
+
`Ruleset: ${report.rulesetVersion}`,
|
|
10
|
+
`Archive: ${report.archive.fileName}`,
|
|
11
|
+
`Root layout: ${report.archive.rootLayout}`,
|
|
12
|
+
`Automatic status: ${upper(report.status)}`,
|
|
13
|
+
`Readiness: ${upper(report.readiness)}`,
|
|
14
|
+
`Summary: ${report.summary.passed} passed, ${report.summary.warnings} warnings, ${report.summary.blockers} blockers, ${report.summary.manualChecks} manual checks`,
|
|
15
|
+
"Manual review is required; automatic checks do not complete archive review."
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
export function renderSubmissionArchiveJson(report) {
|
|
19
|
+
return `${JSON.stringify(report, null, 2)}\n`;
|
|
20
|
+
}
|
|
21
|
+
export function renderSubmissionArchiveText(report) {
|
|
22
|
+
const lines = ["Submission archive preflight", "============================", ...summary(report), "", "Findings"];
|
|
23
|
+
lines.push(...(report.findings.length === 0
|
|
24
|
+
? [" None"]
|
|
25
|
+
: report.findings.map((finding) => ` ${upper(finding.severity)} ${finding.id}: ${finding.message}`)));
|
|
26
|
+
lines.push("", "Coverage");
|
|
27
|
+
lines.push(...(report.coverage.length === 0
|
|
28
|
+
? [" None"]
|
|
29
|
+
: report.coverage.map((item) => ` ${upper(item.status)} ${item.id}: ${item.reason}`)));
|
|
30
|
+
lines.push("", "Manual checklist");
|
|
31
|
+
lines.push(...report.manualChecklist.map((item) => ` ${upper(item.state)} ${item.id}: ${item.label}`));
|
|
32
|
+
return `${lines.join("\n")}\n`;
|
|
33
|
+
}
|
|
34
|
+
export function renderSubmissionArchiveMarkdown(report) {
|
|
35
|
+
const lines = [
|
|
36
|
+
"# Submission archive preflight",
|
|
37
|
+
"",
|
|
38
|
+
...summary(report).map((item) => `- ${escapeMarkdown(item)}`),
|
|
39
|
+
"",
|
|
40
|
+
"## Findings"
|
|
41
|
+
];
|
|
42
|
+
lines.push(...(report.findings.length === 0
|
|
43
|
+
? ["- None"]
|
|
44
|
+
: report.findings.map((finding) => `- **${escapeMarkdown(upper(finding.severity))}** ${escapeMarkdown(finding.id)}: ${escapeMarkdown(finding.message)}`)));
|
|
45
|
+
lines.push("", "## Coverage");
|
|
46
|
+
lines.push(...(report.coverage.length === 0
|
|
47
|
+
? ["- None"]
|
|
48
|
+
: report.coverage.map((item) => `- ${escapeMarkdown(upper(item.status))}: ${escapeMarkdown(item.id)} — ${escapeMarkdown(item.reason)}`)));
|
|
49
|
+
lines.push("", "## Manual checklist");
|
|
50
|
+
lines.push(...report.manualChecklist.map((item) => `- ${escapeMarkdown(upper(item.state))}: ${escapeMarkdown(item.id)} — ${escapeMarkdown(item.label)}`));
|
|
51
|
+
return `${lines.join("\n")}\n`;
|
|
52
|
+
}
|
package/dist/run-cli.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { discoverMcpServer } from "./core/mcp-discovery.js";
|
|
1
2
|
import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
|
|
2
3
|
import { runCheck } from "./index.js";
|
|
3
4
|
import { writeRawDoctorConfig } from "./index.js";
|
|
@@ -13,6 +14,7 @@ export interface CliTerminalContext {
|
|
|
13
14
|
platform?: NodeJS.Platform;
|
|
14
15
|
}
|
|
15
16
|
export interface RunCliOptions {
|
|
17
|
+
discoverMcpServerImpl?: typeof discoverMcpServer;
|
|
16
18
|
terminalContext?: CliTerminalContext;
|
|
17
19
|
runCheckImpl?: typeof runCheck;
|
|
18
20
|
writeRawDoctorConfigImpl?: typeof writeRawDoctorConfig;
|