Skip to content

Commit aef0b41

Browse files
authored
Align Claude schemas with current documentation (#1343)
* fix: align Claude schemas with current docs * fix: recognize Claude plugin skill ownership * fix: resolve all Claude plugin skill roots * fix: scope root plugin skills exactly
1 parent 5a8710f commit aef0b41

15 files changed

Lines changed: 582 additions & 92 deletions

File tree

.github/spec-baselines.json

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://json-schema.org/draft/2020-12/schema",
33
"description": "Baseline hashes for canonical specification content. Human-facing URLs are kept separately from machine-readable content URLs.",
4-
"last_updated": "2026-08-05T00:00:00Z",
4+
"last_updated": "2026-08-09T09:41:00Z",
55
"sources": {
66
"s-tier": {
77
"agent-skills-spec": {
@@ -117,7 +117,7 @@
117117
"claude-code-memory": {
118118
"url": "https://code.claude.com/docs/en/memory",
119119
"content_url": "https://code.claude.com/docs/en/memory.md",
120-
"hash": "eea3a7b7d4da9409c6a627e26142373f52171526fcfe901070ac77d20cf15513",
120+
"hash": "a4559956d8451c7133362cc3230c16712f8d5c68fcd1a2122bd5a2600181af56",
121121
"rules": [
122122
"CC-MEM-001",
123123
"CC-MEM-002",
@@ -128,7 +128,7 @@
128128
"claude-code-hooks": {
129129
"url": "https://code.claude.com/docs/en/hooks",
130130
"content_url": "https://code.claude.com/docs/en/hooks.md",
131-
"hash": "b318b60616297d4a5a8c83d628a2061bf2c65dc0d05a17b5579482b971165ebb",
131+
"hash": "7a561d9a32dc7a42fbb83bfc137b9c0f21cad45e1e9a25a81f3622bca118adc6",
132132
"rules": [
133133
"CC-HK-001",
134134
"CC-HK-002",
@@ -146,18 +146,20 @@
146146
"claude-code-skills": {
147147
"url": "https://code.claude.com/docs/en/skills",
148148
"content_url": "https://code.claude.com/docs/en/skills.md",
149-
"hash": "1f08c689cea03dc2d12ff6646c33b740a3219ba0c2bec960ac05fcd7a4a4c308",
149+
"hash": "b52706dee098407fad34e0162b6799b971a29738e25ce0e0eccbe13c69c95b4c",
150150
"rules": [
151151
"CC-SK-001",
152152
"CC-SK-002",
153153
"CC-SK-004",
154-
"CC-SK-006"
154+
"CC-SK-006",
155+
"CC-SK-008",
156+
"CC-SK-017"
155157
]
156158
},
157159
"claude-code-plugins": {
158160
"url": "https://code.claude.com/docs/en/plugins-reference",
159161
"content_url": "https://code.claude.com/docs/en/plugins-reference.md",
160-
"hash": "a07104858eefd300ed179c1e8727d5d92d1611140f0e524e82f68ae010e13cf7",
162+
"hash": "59d788845be508a182bae5cde6dba663e4eb8d3e77b43bf8c6088cce40ec6027",
161163
"rules": [
162164
"CC-PL-002",
163165
"CC-PL-003",
@@ -169,7 +171,7 @@
169171
"claude-code-subagents": {
170172
"url": "https://code.claude.com/docs/en/sub-agents",
171173
"content_url": "https://code.claude.com/docs/en/sub-agents.md",
172-
"hash": "5b209909ee8304b3af61709d9a5fe7bacaf87feeec9b1dc8f8c48b3f4136d4cb",
174+
"hash": "a9a788cd9bc21cb95e860c4ab6e63efe6b1c406e8f0aa1aee8e66ce5e128037e",
173175
"rules": [
174176
"CC-AG-001",
175177
"CC-AG-002",

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- **Claude Code skill and hook schema compatibility**. Accept skills without
12+
explicit `name` or `description`, validate the documented `disallowed-tools`
13+
field with the existing Claude tool vocabulary, and parse prompt-hook
14+
`continueOnBlock` as a boolean.
15+
- **Claude Code specification baselines**. Advance the reviewed hooks, memory,
16+
plugins, skills, and subagent documentation hashes after auditing the current
17+
validation contracts.
18+
1019
## [0.47.0] - 2026-08-08
1120

1221
### Added

crates/agnix-core/src/rules/hooks/tests.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2619,6 +2619,48 @@ fn test_cc_hk_012_valid_json_no_error() {
26192619
assert_eq!(parse_errors.len(), 0);
26202620
}
26212621

2622+
#[test]
2623+
fn test_cc_hk_012_prompt_continue_on_block_boolean_is_valid() {
2624+
let content = r#"{
2625+
"hooks": {
2626+
"PreToolUse": [{
2627+
"hooks": [{
2628+
"type": "prompt",
2629+
"prompt": "Check the request: $ARGUMENTS",
2630+
"continueOnBlock": true
2631+
}]
2632+
}]
2633+
}
2634+
}"#;
2635+
2636+
let diagnostics = validate(content);
2637+
assert!(
2638+
diagnostics.iter().all(|d| d.rule != "CC-HK-012"),
2639+
"documented continueOnBlock boolean must parse: {diagnostics:?}"
2640+
);
2641+
}
2642+
2643+
#[test]
2644+
fn test_cc_hk_012_prompt_continue_on_block_rejects_non_boolean() {
2645+
let content = r#"{
2646+
"hooks": {
2647+
"PreToolUse": [{
2648+
"hooks": [{
2649+
"type": "prompt",
2650+
"prompt": "Check the request: $ARGUMENTS",
2651+
"continueOnBlock": "yes"
2652+
}]
2653+
}]
2654+
}
2655+
}"#;
2656+
2657+
let diagnostics = validate(content);
2658+
assert_eq!(
2659+
diagnostics.iter().filter(|d| d.rule == "CC-HK-012").count(),
2660+
1
2661+
);
2662+
}
2663+
26222664
#[test]
26232665
fn test_cc_hk_012_disabled() {
26242666
let mut config = LintConfig::default();

crates/agnix-core/src/rules/per_client_skill.rs

Lines changed: 207 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::diagnostics::{Diagnostic, Fix};
99
use crate::parsers::frontmatter::split_frontmatter;
1010
use crate::rules::{Validator, ValidatorMetadata};
1111
use rust_i18n::t;
12-
use std::path::Path;
12+
use std::path::{Component, Path, PathBuf};
1313

1414
/// Known clients that host SKILL.md files.
1515
///
@@ -119,6 +119,90 @@ pub(crate) fn detect_client(path: &Path) -> SkillClient {
119119
SkillClient::Unknown
120120
}
121121

122+
/// Normalize a plugin component path without allowing it to escape the plugin
123+
/// root. Claude accepts both `/` and `\` separators in manifest paths.
124+
fn safe_plugin_relative_path(raw: &str) -> Option<PathBuf> {
125+
let trimmed = raw.trim();
126+
if trimmed.is_empty() {
127+
return None;
128+
}
129+
130+
let normalized = trimmed.replace('\\', "/");
131+
let bytes = normalized.as_bytes();
132+
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
133+
return None;
134+
}
135+
136+
let mut relative = PathBuf::new();
137+
for component in Path::new(&normalized).components() {
138+
match component {
139+
Component::CurDir => {}
140+
Component::Normal(part) => relative.push(part),
141+
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
142+
}
143+
}
144+
Some(relative)
145+
}
146+
147+
/// Return whether `path` is a skill owned by a Claude Code plugin.
148+
///
149+
/// Claude always scans the plugin root's `skills/` directory and loads any
150+
/// additional string or array paths declared by the manifest's `skills` field.
151+
/// Require the documented sibling manifest so a generic `skills/foo/SKILL.md`
152+
/// tree is not misclassified as Claude-owned.
153+
fn is_claude_plugin_skill(path: &Path, config: &LintConfig) -> bool {
154+
if path.file_name().and_then(|name| name.to_str()) != Some("SKILL.md") {
155+
return false;
156+
}
157+
158+
let Some(parent) = path.parent() else {
159+
return false;
160+
};
161+
162+
for plugin_root in parent.ancestors() {
163+
let manifest_path = plugin_root.join(".claude-plugin").join("plugin.json");
164+
if !config.fs().is_file(&manifest_path) {
165+
continue;
166+
}
167+
168+
if path.starts_with(plugin_root.join("skills")) {
169+
return true;
170+
}
171+
172+
let Ok(manifest_content) = config.fs().read_to_string(&manifest_path) else {
173+
continue;
174+
};
175+
let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&manifest_content) else {
176+
continue;
177+
};
178+
let Some(declared_skills) = manifest.get("skills") else {
179+
continue;
180+
};
181+
182+
let declared_paths: Vec<&str> = match declared_skills {
183+
serde_json::Value::String(path) => vec![path],
184+
serde_json::Value::Array(paths) => {
185+
paths.iter().filter_map(serde_json::Value::as_str).collect()
186+
}
187+
_ => Vec::new(),
188+
};
189+
190+
if declared_paths.into_iter().any(|declared| {
191+
safe_plugin_relative_path(declared).is_some_and(|relative| {
192+
if relative.as_os_str().is_empty() {
193+
path.parent() == Some(plugin_root)
194+
} else {
195+
path.starts_with(plugin_root.join(relative))
196+
}
197+
})
198+
}) {
199+
return true;
200+
}
201+
}
202+
203+
false
204+
}
205+
122206
/// Map a `tools = [...]` entry (or `--tools` value) to a [`SkillClient`].
123207
/// Matching is case-insensitive to tolerate configs that use different casing.
124208
fn skill_client_from_tool_str(tool: &str) -> Option<SkillClient> {
@@ -151,6 +235,9 @@ pub(crate) fn resolve_skill_client(path: &Path, config: &LintConfig) -> SkillCli
151235
if by_path != SkillClient::Unknown {
152236
return by_path;
153237
}
238+
if is_claude_plugin_skill(path, config) {
239+
return SkillClient::ClaudeCode;
240+
}
154241

155242
let mut from_tools = config
156243
.tools()
@@ -303,7 +390,7 @@ impl Validator for PerClientSkillValidator {
303390
return diagnostics;
304391
}
305392

306-
let client = detect_client(path);
393+
let client = resolve_skill_client(path, config);
307394

308395
let per_client_rule = rule_id_for_client(client);
309396
let has_per_client = per_client_rule
@@ -437,6 +524,7 @@ mod tests {
437524
use super::*;
438525
use crate::config::LintConfig;
439526
use crate::rules::Validator;
527+
use std::fs;
440528

441529
fn make_skill(frontmatter: &str, body: &str) -> String {
442530
format!("---\n{}\n---\n{}", frontmatter, body)
@@ -552,6 +640,101 @@ mod tests {
552640
);
553641
}
554642

643+
#[test]
644+
fn test_resolve_skill_client_claude_plugin_layouts() {
645+
let temp = tempfile::tempdir().unwrap();
646+
let plugin_root = temp.path().join("my-plugin");
647+
let manifest = plugin_root.join(".claude-plugin").join("plugin.json");
648+
fs::create_dir_all(manifest.parent().unwrap()).unwrap();
649+
fs::write(&manifest, "{}").unwrap();
650+
651+
assert_eq!(
652+
resolve_skill_client(
653+
&plugin_root.join("skills/review/SKILL.md"),
654+
&LintConfig::default()
655+
),
656+
SkillClient::ClaudeCode
657+
);
658+
659+
assert_eq!(
660+
resolve_skill_client(&plugin_root.join("SKILL.md"), &LintConfig::default()),
661+
SkillClient::Unknown,
662+
"a root skill must be declared with skills: \".\""
663+
);
664+
665+
fs::write(&manifest, r#"{"skills":"."}"#).unwrap();
666+
assert_eq!(
667+
resolve_skill_client(&plugin_root.join("SKILL.md"), &LintConfig::default()),
668+
SkillClient::ClaudeCode
669+
);
670+
assert_eq!(
671+
resolve_skill_client(&plugin_root.join("docs/SKILL.md"), &LintConfig::default()),
672+
SkillClient::Unknown,
673+
"skills: \".\" must not claim nested SKILL.md files"
674+
);
675+
}
676+
677+
#[test]
678+
fn test_resolve_skill_client_claude_plugin_custom_skill_paths() {
679+
let temp = tempfile::tempdir().unwrap();
680+
let plugin_root = temp.path().join("my-plugin");
681+
let manifest = plugin_root.join(".claude-plugin").join("plugin.json");
682+
fs::create_dir_all(manifest.parent().unwrap()).unwrap();
683+
fs::write(
684+
&manifest,
685+
r#"{"skills":["./custom-skills",".\\more-skills"]}"#,
686+
)
687+
.unwrap();
688+
689+
for relative in [
690+
"custom-skills/review/SKILL.md",
691+
"more-skills/deploy/SKILL.md",
692+
] {
693+
assert_eq!(
694+
resolve_skill_client(&plugin_root.join(relative), &LintConfig::default()),
695+
SkillClient::ClaudeCode
696+
);
697+
}
698+
}
699+
700+
#[test]
701+
fn test_resolve_skill_client_rejects_escaping_plugin_skill_path() {
702+
let temp = tempfile::tempdir().unwrap();
703+
let plugin_root = temp.path().join("my-plugin");
704+
let manifest = plugin_root.join(".claude-plugin").join("plugin.json");
705+
fs::create_dir_all(manifest.parent().unwrap()).unwrap();
706+
fs::write(&manifest, r#"{"skills":"../external-skills"}"#).unwrap();
707+
708+
assert_eq!(
709+
resolve_skill_client(
710+
&temp.path().join("external-skills/review/SKILL.md"),
711+
&LintConfig::default()
712+
),
713+
SkillClient::Unknown
714+
);
715+
716+
fs::write(&manifest, r#"{"skills":"C:\\external-skills"}"#).unwrap();
717+
assert_eq!(
718+
resolve_skill_client(
719+
&plugin_root.join("C:/external-skills/review/SKILL.md"),
720+
&LintConfig::default()
721+
),
722+
SkillClient::Unknown
723+
);
724+
}
725+
726+
#[test]
727+
fn test_resolve_skill_client_generic_skills_without_plugin_manifest() {
728+
let temp = tempfile::tempdir().unwrap();
729+
assert_eq!(
730+
resolve_skill_client(
731+
&temp.path().join("skills/review/SKILL.md"),
732+
&LintConfig::default()
733+
),
734+
SkillClient::Unknown
735+
);
736+
}
737+
555738
// ===== Validation tests =====
556739

557740
#[test]
@@ -598,6 +781,28 @@ mod tests {
598781
);
599782
}
600783

784+
#[test]
785+
fn test_claude_plugin_custom_skill_skips_cross_platform_warning() {
786+
let temp = tempfile::tempdir().unwrap();
787+
let plugin_root = temp.path().join("my-plugin");
788+
let manifest = plugin_root.join(".claude-plugin").join("plugin.json");
789+
let skill_path = plugin_root.join("custom-skills/review/SKILL.md");
790+
fs::create_dir_all(manifest.parent().unwrap()).unwrap();
791+
fs::write(&manifest, r#"{"skills":"./custom-skills"}"#).unwrap();
792+
793+
let content = make_skill(
794+
"description: Review changes\ndisallowed-tools: Bash",
795+
"Body",
796+
);
797+
let diagnostics =
798+
PerClientSkillValidator.validate(&skill_path, &content, &LintConfig::default());
799+
800+
assert!(
801+
!diagnostics.iter().any(|d| d.rule == "XP-SK-001"),
802+
"Claude-only fields in manifest-owned skills are not portability issues: {diagnostics:?}"
803+
);
804+
}
805+
601806
#[test]
602807
fn test_cursor_unsupported_model() {
603808
let content = make_skill("name: my-skill\ndescription: A test\nmodel: opus", "Body");

0 commit comments

Comments
 (0)