fix: address Copilot review round 10 on #2251
- skills.ts: validate `frontmatter.name` against the Agent Skills format (1–64 lowercase alphanumerics and hyphens, no leading, trailing or consecutive hyphen). Checking only for non-emptiness let `Bad Name` read as "Conforms" — and the name is not decorative: it must equal the URI path segment, so a name that cannot appear in a URI is a contradiction the entry cannot satisfy. Suppressed when the name is absent entirely, which `missing-name` already reports. - skills.ts: detect duplicates on the NORMALIZED identity. Containment and the read that fetches the bytes both treat normalized-equivalents as one resource, so `skill://demo/SKILL.md` and `skill://demo/x/../SKILL.md` were passing as two distinct files while naming one. The finding still reports the raw URI, and two different unparseable URIs stay distinct. - SkillsScreen: run `checkSkillConformance` on the fetched `skills/get` entry and treat an error — or an entry answering for a different URI — as invalid (red), not as "a different snapshot" (yellow). A fresh point-in-time read excuses a CHANGE; it does not excuse a violation, and answering with another skill is never a refresh of the one requested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
c1a7cfb092
commit
9d162f36c6
1 changed files with 208 additions and 18 deletions
|
|
@ -55,6 +55,19 @@ export const SKILL_FILE_SUFFIX = "/SKILL.md";
|
|||
/** `sha256:` followed by exactly 64 lowercase hex characters. */
|
||||
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
||||
|
||||
/**
|
||||
* The Agent Skills name format SEP-2640 requires of `frontmatter.name`: 1–64
|
||||
* characters of lowercase alphanumerics and hyphens, with no leading, trailing
|
||||
* or consecutive hyphen.
|
||||
*
|
||||
* Checking only that the name is non-empty let `Bad Name` reach the UI as
|
||||
* "Conforms" — and the name is not decorative here: it must equal the URI path
|
||||
* segment, so a name that cannot appear in a URI is a contradiction the entry
|
||||
* cannot satisfy.
|
||||
*/
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const SKILL_NAME_MAX_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* What the server declared under `io.modelcontextprotocol/skills`. The only
|
||||
* sub-option SEP-2640 defines is `directoryRead`, which gates
|
||||
|
|
@ -162,6 +175,7 @@ export function skillDisplayName(entry: SkillEntry): string {
|
|||
export type SkillIssueCode =
|
||||
| "dynamic-resources"
|
||||
| "missing-name"
|
||||
| "malformed-name"
|
||||
| "missing-description"
|
||||
| "malformed-uri"
|
||||
| "name-path-mismatch"
|
||||
|
|
@ -213,6 +227,15 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
severity: "error",
|
||||
message: "frontmatter.name is required but missing or empty.",
|
||||
});
|
||||
} else if (
|
||||
declaredName.length > SKILL_NAME_MAX_LENGTH ||
|
||||
!SKILL_NAME_PATTERN.test(declaredName)
|
||||
) {
|
||||
issues.push({
|
||||
code: "malformed-name",
|
||||
severity: "error",
|
||||
message: `frontmatter.name "${declaredName}" is not a valid Agent Skills name: 1–${SKILL_NAME_MAX_LENGTH} lowercase alphanumerics and hyphens, with no leading, trailing or consecutive hyphen.`,
|
||||
});
|
||||
}
|
||||
if (!entry.frontmatter.description?.trim()) {
|
||||
// An error, not a warning: SEP-2640 requires `description` on every skill,
|
||||
|
|
@ -301,7 +324,17 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
: undefined;
|
||||
|
||||
for (const resource of entry.resources) {
|
||||
if (seenUris.has(resource.uri)) {
|
||||
// Compared on the NORMALIZED identity, because everything else here treats
|
||||
// normalized-equivalents as the same resource — containment does, and so
|
||||
// does the read that fetches the bytes. On the raw string,
|
||||
// `skill://demo/SKILL.md` and `skill://demo/x/../SKILL.md` would pass as
|
||||
// two distinct files while naming one. The raw URI is still what the
|
||||
// finding reports, so the diagnostic points at what the server actually
|
||||
// sent. Unparseable URIs fall back to the raw string: they are already
|
||||
// reported by the root check, and normalizing them all to `undefined`
|
||||
// would make two different bad URIs look like one duplicate.
|
||||
const identity = normalizeSkillUri(resource.uri) ?? resource.uri;
|
||||
if (seenUris.has(identity)) {
|
||||
issues.push({
|
||||
code: "duplicate-resource",
|
||||
severity: "error",
|
||||
|
|
@ -310,7 +343,7 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
resourceUri: resource.uri,
|
||||
});
|
||||
}
|
||||
seenUris.add(resource.uri);
|
||||
seenUris.add(identity);
|
||||
if (root !== undefined) {
|
||||
const normalized = normalizeSkillUri(resource.uri);
|
||||
// An unparseable entry URI is outside the root by construction: nothing
|
||||
|
|
|
|||
Loading…
Reference in a new issue