fix: reject unsupported skill fields

This commit is contained in:
2026-07-16 09:06:45 +09:00
parent 62ef89a163
commit 4ddfccee2d
2 changed files with 240 additions and 1 deletions
+144 -1
View File
@@ -287,7 +287,19 @@ fn parse_skill_source(source: SkillSource) -> Result<ParsedSkill, Vec<SkillDiagn
));
return Err(diagnostics);
};
let frontmatter = match serde_yaml::from_str::<SkillFrontmatter>(frontmatter) {
let frontmatter_value = match serde_yaml::from_str::<serde_yaml::Value>(frontmatter) {
Ok(value) => value,
Err(error) => {
diagnostics.push(SkillDiagnostic::error(
"invalid_frontmatter_yaml",
format!("SKILL.md frontmatter is invalid YAML: {error}"),
Some(provenance.id.clone()),
));
return Err(diagnostics);
}
};
diagnose_unsupported_frontmatter_keys(&frontmatter_value, &provenance, &mut diagnostics);
let frontmatter = match serde_yaml::from_value::<SkillFrontmatter>(frontmatter_value) {
Ok(frontmatter) => frontmatter,
Err(error) => {
diagnostics.push(SkillDiagnostic::error(
@@ -442,6 +454,79 @@ fn validate_optional_string(
}
}
fn diagnose_unsupported_frontmatter_keys(
value: &serde_yaml::Value,
provenance: &SkillProvenance,
diagnostics: &mut Vec<SkillDiagnostic>,
) {
let Some(mapping) = value.as_mapping() else {
diagnostics.push(SkillDiagnostic::error(
"invalid_frontmatter_shape",
"SKILL.md frontmatter must be a YAML mapping",
Some(provenance.id.clone()),
));
return;
};
for key in mapping.keys() {
let Some(key) = key.as_str() else {
diagnostics.push(SkillDiagnostic::error(
"invalid_frontmatter_key",
"Skill frontmatter keys must be strings",
Some(provenance.id.clone()),
));
continue;
};
if !is_supported_frontmatter_key(key) {
let (code, message) = if is_workflow_projection_key(key) {
(
"unsupported_workflow_frontmatter_field",
format!(
"Skill frontmatter field `{key}` is a removed Workflow projection/invocation field and is not accepted as Skill semantics"
),
)
} else {
(
"unsupported_frontmatter_field",
format!(
"Skill frontmatter field `{key}` is not supported; supported fields are name, description, license, compatibility, metadata, and allowed-tools"
),
)
};
diagnostics.push(SkillDiagnostic::error(
code,
message,
Some(provenance.id.clone()),
));
}
}
}
fn is_supported_frontmatter_key(key: &str) -> bool {
matches!(
key,
"name" | "description" | "license" | "compatibility" | "metadata" | "allowed-tools"
)
}
fn is_workflow_projection_key(key: &str) -> bool {
matches!(
key,
"model_invokation"
| "model_invocation"
| "user_invocable"
| "workflow"
| "workflow_record"
| "workflow_invoke"
| "invocation"
| "invocations"
| "graph"
| "nodes"
| "edges"
| "triggers"
)
}
fn parse_allowed_tools(
value: serde_yaml::Value,
provenance: &SkillProvenance,
@@ -599,6 +684,64 @@ mod tests {
assert!(diagnostics.iter().any(|d| d.code == "name_parent_mismatch"));
}
#[test]
fn workflow_projection_frontmatter_fields_are_rejected() {
let tmp = tempfile::tempdir().unwrap();
write_skill(
tmp.path(),
"workflow-shaped",
"---\nname: workflow-shaped\ndescription: Use when proving workflow projection fields are rejected as Skills.\nmodel_invokation: old-typo\nuser_invocable: true\ngraph: {}\ninvocation:\n run: now\n---\n\n# Workflow Shaped",
);
let catalog = catalog(tmp.path());
assert!(
catalog
.entries
.iter()
.all(|entry| entry.name != "workflow-shaped")
);
let codes = catalog
.diagnostics
.iter()
.map(|diagnostic| diagnostic.code.as_str())
.collect::<Vec<_>>();
assert!(codes.contains(&"unsupported_workflow_frontmatter_field"));
for unsupported in ["model_invokation", "user_invocable", "graph", "invocation"] {
assert!(
catalog.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "unsupported_workflow_frontmatter_field"
&& diagnostic.message.contains(unsupported)
}),
"missing unsupported-field diagnostic for {unsupported}"
);
}
assert!(matches!(
detail(tmp.path(), "workflow-shaped"),
Err(SkillError::NotFound(_))
));
}
#[test]
fn unknown_frontmatter_fields_are_rejected() {
let tmp = tempfile::tempdir().unwrap();
write_skill(
tmp.path(),
"unknown-field",
"---\nname: unknown-field\ndescription: Use when proving unsupported Skill fields are rejected.\ncustom-authority: no\n---\n\n# Unknown",
);
let catalog = catalog(tmp.path());
assert!(
catalog
.entries
.iter()
.all(|entry| entry.name != "unknown-field")
);
assert!(catalog.diagnostics.iter().any(|diagnostic| diagnostic.code
== "unsupported_frontmatter_field"
&& diagnostic.message.contains("custom-authority")));
}
#[test]
fn workspace_skill_overrides_builtin() {
let tmp = tempfile::tempdir().unwrap();