Skip to main content

hikmah_kernel/
validate.rs

1//! Package validator for the repository: manifests, versions, hooks, and skills.
2use crate::error::{KernelError, Result};
3use regex::Regex;
4use serde_json::Value;
5use std::collections::BTreeSet;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9const PLUGIN_NAME: &str = "hikmah-stack";
10
11fn invalid(message: impl Into<String>) -> KernelError {
12    KernelError::Invalid(message.into())
13}
14
15pub fn validate_repo(root: impl AsRef<Path>) -> Result<Vec<String>> {
16    let root = root.as_ref();
17    let required = [
18        ".codex-plugin/plugin.json",
19        ".claude-plugin/plugin.json",
20        ".claude-plugin/marketplace.json",
21        ".agents/plugins/marketplace.json",
22        "kimi.plugin.json",
23        "hooks/hooks.json",
24        "hooks/codex.json",
25        "hooks/truth_gate.sh",
26        "hooks/truth_gate.py",
27        "hooks/truth_gate_cases.json",
28        "skills/operator-core/SKILL.md",
29        "skills/agent-radar/SKILL.md",
30        "skills/decision-forge/SKILL.md",
31        "skills/ship-guard/SKILL.md",
32        "skills/hikmah-orchestrator/SKILL.md",
33        "skills/cognitive-kernel/SKILL.md",
34        "docs/COGNITIVE_KERNEL.md",
35        "docs/MEMORY.md",
36        "docs/DECISION_PORT.md",
37        "runtime/hikmah-kernel/Cargo.toml",
38    ];
39    let mut notes = Vec::new();
40    for path in required {
41        if !root.join(path).is_file() {
42            return Err(invalid(format!("missing required file: {path}")));
43        }
44    }
45
46    let mut json = |path: &str| -> Result<Value> {
47        let text = fs::read_to_string(root.join(path))?;
48        let value: Value = serde_json::from_str(&text)
49            .map_err(|error| invalid(format!("{path} is not valid JSON: {error}")))?;
50        notes.push(format!("json ok: {path}"));
51        Ok(value)
52    };
53    let codex = json(".codex-plugin/plugin.json")?;
54    let claude = json(".claude-plugin/plugin.json")?;
55    let claude_market = json(".claude-plugin/marketplace.json")?;
56    let agents_market = json(".agents/plugins/marketplace.json")?;
57    let kimi = json("kimi.plugin.json")?;
58    let hooks = json("hooks/hooks.json")?;
59    let codex_hooks = json("hooks/codex.json")?;
60    let cases = json("hooks/truth_gate_cases.json")?;
61
62    // Versions: one release number everywhere.
63    let cargo_version = cargo_package_version(&root.join("runtime/hikmah-kernel/Cargo.toml"))?;
64    let mut versions = vec![
65        (
66            "runtime/hikmah-kernel/Cargo.toml".to_string(),
67            cargo_version.clone(),
68        ),
69        (
70            ".codex-plugin/plugin.json".into(),
71            field(&codex, "version")?,
72        ),
73        (
74            ".claude-plugin/plugin.json".into(),
75            field(&claude, "version")?,
76        ),
77        ("kimi.plugin.json".into(), field(&kimi, "version")?),
78    ];
79    for (index, plugin) in plugin_entries(&claude_market).iter().enumerate() {
80        if let Some(version) = plugin.get("version").and_then(Value::as_str) {
81            versions.push((
82                format!(".claude-plugin/marketplace.json plugins[{index}]"),
83                version.to_string(),
84            ));
85        }
86    }
87    for (path, version) in &versions {
88        if *version != cargo_version {
89            return Err(invalid(format!(
90                "version mismatch: {path} has {version}, Cargo.toml has {cargo_version}"
91            )));
92        }
93    }
94    notes.push(format!(
95        "version {cargo_version} in {} places",
96        versions.len()
97    ));
98
99    // Names: every host installs the same plugin.
100    let mut names = vec![
101        (
102            ".codex-plugin/plugin.json".to_string(),
103            field(&codex, "name")?,
104        ),
105        (".claude-plugin/plugin.json".into(), field(&claude, "name")?),
106        ("kimi.plugin.json".into(), field(&kimi, "name")?),
107    ];
108    for (label, market) in [
109        (".claude-plugin/marketplace.json", &claude_market),
110        (".agents/plugins/marketplace.json", &agents_market),
111    ] {
112        for plugin in plugin_entries(market) {
113            names.push((label.to_string(), field(plugin, "name")?));
114        }
115    }
116    for (path, name) in &names {
117        if name != PLUGIN_NAME {
118            return Err(invalid(format!(
119                "plugin name mismatch: {path} has {name}, expected {PLUGIN_NAME}"
120            )));
121        }
122    }
123    notes.push(format!(
124        "plugin name {PLUGIN_NAME} in {} places",
125        names.len()
126    ));
127
128    // Hooks: every script a hook command references must exist.
129    let script = Regex::new(r"hooks/[A-Za-z0-9_.\-]+").expect("script regex");
130    let mut referenced = BTreeSet::new();
131    for config in [&hooks, &codex_hooks] {
132        collect_commands(config, &mut |command| {
133            for m in script.find_iter(command) {
134                referenced.insert(m.as_str().to_string());
135            }
136        });
137    }
138    for path in &referenced {
139        if !root.join(path).is_file() {
140            return Err(invalid(format!("hook command references missing {path}")));
141        }
142    }
143    notes.push(format!(
144        "{} hook script reference(s) resolve",
145        referenced.len()
146    ));
147
148    let case_count = cases
149        .get("cases")
150        .and_then(Value::as_array)
151        .map(Vec::len)
152        .unwrap_or(0);
153    if case_count == 0 {
154        return Err(invalid("hooks/truth_gate_cases.json has no cases"));
155    }
156    notes.push(format!("{case_count} Truth Gate golden cases"));
157
158    // Skills: frontmatter name equals the directory, description is present.
159    let mut skill_names = BTreeSet::new();
160    for entry in fs::read_dir(root.join("skills"))? {
161        let entry = entry?;
162        if !entry.file_type()?.is_dir() {
163            continue;
164        }
165        let dir_name = entry.file_name().to_string_lossy().to_string();
166        let skill_path: PathBuf = entry.path().join("SKILL.md");
167        if !skill_path.is_file() {
168            return Err(invalid(format!(
169                "skill directory missing SKILL.md: {}",
170                entry.path().display()
171            )));
172        }
173        let text = fs::read_to_string(&skill_path)?;
174        let name = parse_frontmatter_field(&text, "name").unwrap_or_default();
175        if name.is_empty() {
176            return Err(invalid(format!(
177                "skill missing name: {}",
178                skill_path.display()
179            )));
180        }
181        if name != dir_name {
182            return Err(invalid(format!(
183                "skill name `{name}` does not match its directory `{dir_name}`"
184            )));
185        }
186        let description = parse_frontmatter_field(&text, "description").unwrap_or_default();
187        if description.is_empty() {
188            return Err(invalid(format!(
189                "skill missing frontmatter description: {}",
190                skill_path.display()
191            )));
192        }
193        if !skill_names.insert(name.clone()) {
194            return Err(invalid(format!("duplicate skill name: {name}")));
195        }
196        check_skill_links(&entry.path())?;
197    }
198    notes.push(format!("{} unique skills", skill_names.len()));
199    Ok(notes)
200}
201
202/// Skills are installed one directory at a time, so a relative Markdown link must resolve to an
203/// existing file inside the same skill directory. Absolute URLs and in-page anchors are allowed.
204fn check_skill_links(skill_dir: &Path) -> Result<()> {
205    let mut pending = vec![skill_dir.to_path_buf()];
206    while let Some(dir) = pending.pop() {
207        for entry in fs::read_dir(&dir)? {
208            let path = entry?.path();
209            if path.is_dir() {
210                pending.push(path);
211                continue;
212            }
213            if path.extension().and_then(|e| e.to_str()) != Some("md") {
214                continue;
215            }
216            let text = fs::read_to_string(&path)?;
217            for target in markdown_link_targets(&text) {
218                if let Some(problem) = skill_link_problem(skill_dir, &dir, target) {
219                    return Err(invalid(format!(
220                        "{}: link `{target}` {problem}; skills must be self-contained",
221                        path.display()
222                    )));
223                }
224            }
225        }
226    }
227    Ok(())
228}
229
230fn markdown_link_targets(text: &str) -> Vec<&str> {
231    let mut targets = Vec::new();
232    let mut rest = text;
233    while let Some(start) = rest.find("](") {
234        rest = &rest[start + 2..];
235        let Some(end) = rest.find(')') else { break };
236        let target = rest[..end].split_whitespace().next().unwrap_or("");
237        targets.push(target);
238        rest = &rest[end..];
239    }
240    targets
241}
242
243fn skill_link_problem(skill_dir: &Path, file_dir: &Path, target: &str) -> Option<&'static str> {
244    let path_part = target.split('#').next().unwrap_or("");
245    if path_part.is_empty() || target.contains("://") || target.starts_with("mailto:") {
246        return None;
247    }
248    if path_part.starts_with('/') {
249        return Some("is an absolute path");
250    }
251    let base = file_dir.strip_prefix(skill_dir).ok()?;
252    let mut depth: Vec<&std::ffi::OsStr> = base.iter().collect();
253    for part in Path::new(path_part).components() {
254        match part {
255            std::path::Component::CurDir => {}
256            std::path::Component::ParentDir => {
257                if depth.pop().is_none() {
258                    return Some("escapes the skill directory");
259                }
260            }
261            std::path::Component::Normal(name) => depth.push(name),
262            _ => return Some("is not a relative path"),
263        }
264    }
265    let resolved = depth
266        .iter()
267        .fold(skill_dir.to_path_buf(), |acc, part| acc.join(part));
268    if resolved.exists() {
269        None
270    } else {
271        Some("does not resolve inside the skill directory")
272    }
273}
274
275fn field(value: &Value, key: &str) -> Result<String> {
276    value
277        .get(key)
278        .and_then(Value::as_str)
279        .map(ToOwned::to_owned)
280        .ok_or_else(|| invalid(format!("manifest missing `{key}`")))
281}
282
283fn plugin_entries(market: &Value) -> Vec<&Value> {
284    market
285        .get("plugins")
286        .and_then(Value::as_array)
287        .map(|plugins| plugins.iter().collect())
288        .unwrap_or_default()
289}
290
291fn collect_commands(value: &Value, visit: &mut dyn FnMut(&str)) {
292    match value {
293        Value::Object(map) => {
294            if let Some(command) = map.get("command").and_then(Value::as_str) {
295                visit(command);
296            }
297            for child in map.values() {
298                collect_commands(child, visit);
299            }
300        }
301        Value::Array(items) => {
302            for item in items {
303                collect_commands(item, visit);
304            }
305        }
306        _ => {}
307    }
308}
309
310fn cargo_package_version(path: &Path) -> Result<String> {
311    let text = fs::read_to_string(path)?;
312    let mut in_package = false;
313    for line in text.lines() {
314        let trimmed = line.trim();
315        if trimmed.starts_with('[') {
316            in_package = trimmed == "[package]";
317            continue;
318        }
319        if in_package {
320            if let Some(rest) = trimmed.strip_prefix("version") {
321                if let Some(value) = rest.trim_start().strip_prefix('=') {
322                    return Ok(value.trim().trim_matches('"').to_string());
323                }
324            }
325        }
326    }
327    Err(invalid("Cargo.toml has no [package] version"))
328}
329
330fn parse_frontmatter_field(text: &str, key: &str) -> Option<String> {
331    let mut lines = text.lines();
332    if lines.next()?.trim() != "---" {
333        return None;
334    }
335    for line in lines {
336        let trimmed = line.trim();
337        if trimmed == "---" {
338            break;
339        }
340        if let Some(value) = trimmed.strip_prefix(&format!("{key}:")) {
341            return Some(value.trim().trim_matches('"').to_string());
342        }
343    }
344    None
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn skill_links_must_stay_inside_the_skill() {
353        let skill = std::env::temp_dir().join(format!("hikmah-skill-links-{}", std::process::id()));
354        fs::create_dir_all(skill.join("references")).unwrap();
355        fs::write(skill.join("references/notes.md"), "x").unwrap();
356        let refs = skill.join("references");
357
358        assert_eq!(
359            skill_link_problem(&skill, &skill, "references/notes.md"),
360            None
361        );
362        assert_eq!(
363            skill_link_problem(&skill, &refs, "../references/notes.md#top"),
364            None
365        );
366        assert_eq!(
367            skill_link_problem(&skill, &skill, "https://example.com/a.md"),
368            None
369        );
370        assert_eq!(skill_link_problem(&skill, &skill, "#section"), None);
371        assert!(skill_link_problem(&skill, &skill, "../../docs/MEMORY.md").is_some());
372        assert!(skill_link_problem(&skill, &refs, "../../other/SKILL.md").is_some());
373        assert!(skill_link_problem(&skill, &skill, "references/missing.md").is_some());
374        assert!(skill_link_problem(&skill, &skill, "/etc/passwd").is_some());
375
376        assert_eq!(
377            markdown_link_targets("see [a](x.md \"title\") and [b](https://e.com)"),
378            vec!["x.md", "https://e.com"]
379        );
380        fs::remove_dir_all(&skill).unwrap();
381    }
382}