Last active
January 27, 2026 14:08
-
-
Save moodmosaic/70a6eecd0a8bdc1e203b9ae4b0e00cc1 to your computer and use it in GitHub Desktop.
Validate .claude/ follows "capabilities not workflows" — for calibrated LLM security research.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! Validation tests for the .claude/ directory structure. | |
| //! | |
| //! These tests enforce the layering model: GLOBAL CONTEXT is always-on and | |
| //! non-procedural, AGENTS express perspective without workflows, SKILLS | |
| //! describe capabilities without success criteria, and REFERENCES are | |
| //! explicitly elective playbooks. | |
| //! | |
| //! ## What We Check | |
| //! | |
| //! **CLAUDE.md (Global Context)** | |
| //! - No workflow verbs like "step 1", "first,", "then,", "finally,". | |
| //! - No fenced code blocks that would embed procedures. | |
| //! | |
| //! **agents/*.md** | |
| //! - Valid YAML frontmatter with name and description. | |
| //! - Maximum 120 lines to keep agents lean. | |
| //! - No fenced code blocks. | |
| //! - No step-by-step procedural patterns. | |
| //! | |
| //! **skills/*/SKILL.md** | |
| //! - Valid YAML frontmatter with name, description, and Capability section. | |
| //! - Maximum 500 lines. | |
| //! - Frontmatter contains only `name` and `description` keys. | |
| //! - No success criteria terms like "must ensure" or "requirements:". | |
| //! - No fenced code blocks or procedural markers. | |
| //! - Skills with references/ directory have a References section. | |
| //! | |
| //! **skills/*/references/*.md** | |
| //! - Must state "optional" near the top to be explicitly elective. | |
| use std::fs; | |
| use std::path::{Path, PathBuf}; | |
| /// Reads a file to string, panicking with a descriptive message on failure. | |
| fn read_to_string(path: &Path) -> String { | |
| fs::read_to_string(path).unwrap_or_else(|e| { | |
| panic!("failed to read {}: {e}", path.display()); | |
| }) | |
| } | |
| /// Counts lines in a string, treating trailing newline correctly. | |
| fn count_lines(s: &str) -> usize { | |
| s.lines().count() | |
| } | |
| /// Returns all subdirectories under the given root, sorted alphabetically. | |
| fn all_skill_dirs(root: &Path) -> Vec<PathBuf> { | |
| let mut out = Vec::new(); | |
| let entries = fs::read_dir(root).unwrap_or_else(|e| { | |
| panic!("failed to read_dir {}: {e}", root.display()); | |
| }); | |
| for entry in entries { | |
| let entry = entry.unwrap_or_else(|e| { | |
| panic!("failed to read_dir entry under {}: {e}", root.display()); | |
| }); | |
| let path = entry.path(); | |
| if path.is_dir() { | |
| out.push(path); | |
| } | |
| } | |
| out.sort(); | |
| out | |
| } | |
| /// Asserts that a SKILL.md file has valid YAML frontmatter with required keys. | |
| fn assert_skill_md_has_frontmatter(skill_md: &str, path: &Path) { | |
| assert!( | |
| skill_md.starts_with("---\n"), | |
| "missing YAML frontmatter start in {}", | |
| path.display() | |
| ); | |
| assert!( | |
| skill_md.contains("\nname:"), | |
| "missing `name:` in {} frontmatter", | |
| path.display() | |
| ); | |
| assert!( | |
| skill_md.contains("\ndescription:"), | |
| "missing `description:` in {} frontmatter", | |
| path.display() | |
| ); | |
| assert!( | |
| skill_md.contains("\n## Capability\n"), | |
| "missing `## Capability` section in {}", | |
| path.display() | |
| ); | |
| } | |
| /// Asserts that frontmatter contains only allowed keys, rejecting directives. | |
| /// | |
| /// This prevents embedding control-loop directives like `agent:` or `context:` | |
| /// that would make SKILL/AGENT files procedural rather than declarative. | |
| fn assert_frontmatter_keys_are_minimal(md: &str, path: &Path, allowed: &[&str]) { | |
| assert!(md.starts_with("---\n"), "missing frontmatter in {}", path.display()); | |
| let mut keys = Vec::new(); | |
| let mut seen_end = false; | |
| for (idx, line) in md.lines().enumerate() { | |
| if idx == 0 { | |
| continue; | |
| } | |
| if line == "---" { | |
| seen_end = true; | |
| break; | |
| } | |
| // Only consider top-level YAML keys (no indentation). | |
| if line.starts_with(' ') || line.starts_with('\t') { | |
| continue; | |
| } | |
| if let Some((key, _rest)) = line.split_once(':') { | |
| let key = key.trim(); | |
| if !key.is_empty() { | |
| keys.push(key.to_string()); | |
| } | |
| } | |
| } | |
| assert!(seen_end, "missing frontmatter end delimiter in {}", path.display()); | |
| for key in keys { | |
| assert!( | |
| allowed.iter().any(|a| *a == key), | |
| "unexpected frontmatter key `{}` in {} (allowed: {:?})", | |
| key, | |
| path.display(), | |
| allowed | |
| ); | |
| } | |
| } | |
| /// Checks if a line is under a markdown section containing the given substring. | |
| fn line_is_in_section(lines: &[&str], idx: usize, section_substring: &str) -> bool { | |
| let needle = section_substring.to_lowercase(); | |
| for j in (0..=idx).rev() { | |
| if let Some(rest) = lines[j].strip_prefix("## ") { | |
| return rest.to_lowercase().contains(&needle); | |
| } | |
| } | |
| false | |
| } | |
| /// Asserts that an agent markdown file has valid YAML frontmatter. | |
| fn assert_agent_md_has_frontmatter(agent_md: &str, path: &Path) { | |
| assert!( | |
| agent_md.starts_with("---\n"), | |
| "missing YAML frontmatter start in {}", | |
| path.display() | |
| ); | |
| assert!( | |
| agent_md.contains("\nname:"), | |
| "missing `name:` in {} frontmatter", | |
| path.display() | |
| ); | |
| assert!( | |
| agent_md.contains("\ndescription:"), | |
| "missing `description:` in {} frontmatter", | |
| path.display() | |
| ); | |
| } | |
| /// Verifies that skills directory exists with valid SKILL.md files. | |
| #[test] | |
| fn skills_structure_is_sane() { | |
| let skills_root = Path::new(".claude/skills"); | |
| assert!( | |
| skills_root.is_dir(), | |
| "missing skills root at {}", | |
| skills_root.display() | |
| ); | |
| let skill_dirs = all_skill_dirs(skills_root); | |
| assert!( | |
| skill_dirs.len() >= 2, | |
| "expected >=2 skill dirs under {}, got {}", | |
| skills_root.display(), | |
| skill_dirs.len() | |
| ); | |
| for dir in skill_dirs { | |
| let skill_md_path = dir.join("SKILL.md"); | |
| assert!( | |
| skill_md_path.is_file(), | |
| "missing SKILL.md in {}", | |
| dir.display() | |
| ); | |
| let skill_md = read_to_string(&skill_md_path); | |
| assert_skill_md_has_frontmatter(&skill_md, &skill_md_path); | |
| let lines = count_lines(&skill_md); | |
| assert!( | |
| lines < 500, | |
| "SKILL.md too long ({} lines) in {}", | |
| lines, | |
| skill_md_path.display() | |
| ); | |
| } | |
| } | |
| /// Verifies that agent files are lean and contain no code blocks. | |
| #[test] | |
| fn agents_are_minimal_and_declarative() { | |
| let agents_root = Path::new(".claude/agents"); | |
| assert!( | |
| agents_root.is_dir(), | |
| "missing agents root at {}", | |
| agents_root.display() | |
| ); | |
| let entries = fs::read_dir(agents_root).unwrap_or_else(|e| { | |
| panic!("failed to read_dir {}: {e}", agents_root.display()); | |
| }); | |
| let mut agent_files = Vec::new(); | |
| for entry in entries { | |
| let entry = entry.unwrap_or_else(|e| { | |
| panic!( | |
| "failed to read_dir entry under {}: {e}", | |
| agents_root.display() | |
| ); | |
| }); | |
| let path = entry.path(); | |
| if path.extension().and_then(|e| e.to_str()) == Some("md") { | |
| agent_files.push(path); | |
| } | |
| } | |
| agent_files.sort(); | |
| assert!( | |
| !agent_files.is_empty(), | |
| "expected at least one agent markdown file under {}", | |
| agents_root.display() | |
| ); | |
| for path in agent_files { | |
| let agent_md = read_to_string(&path); | |
| assert_agent_md_has_frontmatter(&agent_md, &path); | |
| // Agents are perspective/value layers. Keep them lean. | |
| let lines = count_lines(&agent_md); | |
| assert!( | |
| lines <= 120, | |
| "agent file too long ({} lines): {}", | |
| lines, | |
| path.display() | |
| ); | |
| // Avoid embedded playbooks or command blocks. | |
| assert!( | |
| !agent_md.contains("```"), | |
| "agent file contains fenced code block: {}", | |
| path.display() | |
| ); | |
| } | |
| } | |
| /// Ensures CLAUDE.md contains no step-by-step workflow language. | |
| #[test] | |
| fn global_context_has_no_workflow_verbs() { | |
| let claude_md = Path::new(".claude/CLAUDE.md"); | |
| assert!( | |
| claude_md.is_file(), | |
| "missing CLAUDE.md at {}", | |
| claude_md.display() | |
| ); | |
| let content = read_to_string(claude_md); | |
| // Workflow verbs that suggest step-by-step procedures. | |
| let workflow_verbs = [ | |
| "must then", | |
| "next,", | |
| "step 1", | |
| "step 2", | |
| "first,", | |
| "second,", | |
| "finally,", | |
| "afterward", | |
| "subsequently", | |
| ]; | |
| for verb in workflow_verbs { | |
| assert!( | |
| !content.to_lowercase().contains(verb), | |
| "CLAUDE.md contains workflow verb '{}' - global context should \ | |
| only contain norms and facts, not procedures", | |
| verb | |
| ); | |
| } | |
| } | |
| /// Ensures CLAUDE.md contains no fenced code blocks. | |
| #[test] | |
| fn global_context_has_no_fenced_code_blocks() { | |
| let claude_md = Path::new(".claude/CLAUDE.md"); | |
| assert!( | |
| claude_md.is_file(), | |
| "missing CLAUDE.md at {}", | |
| claude_md.display() | |
| ); | |
| let content = read_to_string(claude_md); | |
| assert!( | |
| !content.contains("```"), | |
| "CLAUDE.md contains fenced code block - global context should avoid \ | |
| embedding executable procedures" | |
| ); | |
| } | |
| /// Ensures SKILL.md files describe capabilities, not requirements. | |
| #[test] | |
| fn skill_files_have_no_success_criteria() { | |
| let skills_root = Path::new(".claude/skills"); | |
| let skill_dirs = all_skill_dirs(skills_root); | |
| // Terms that suggest success criteria or execution requirements. | |
| let success_criteria_terms = [ | |
| "success criteria", | |
| "must ensure", | |
| "must verify", | |
| "requirement:", | |
| "requirements:", | |
| "you must", | |
| "should ensure", | |
| "shall ensure", | |
| ]; | |
| for dir in skill_dirs { | |
| let skill_md_path = dir.join("SKILL.md"); | |
| let skill_md = read_to_string(&skill_md_path); | |
| for term in success_criteria_terms { | |
| assert!( | |
| !skill_md.to_lowercase().contains(term), | |
| "SKILL.md at {} contains success criteria term '{}' - \ | |
| skills should describe capabilities, not requirements", | |
| skill_md_path.display(), | |
| term | |
| ); | |
| } | |
| } | |
| } | |
| /// Ensures skill frontmatter contains only name and description. | |
| #[test] | |
| fn skill_frontmatter_is_minimal_and_non_directive() { | |
| let skills_root = Path::new(".claude/skills"); | |
| let skill_dirs = all_skill_dirs(skills_root); | |
| // Skills are optional affordances; keep frontmatter free of routing, | |
| // triggers, or model/tool constraints. | |
| let allowed_keys = ["name", "description"]; | |
| for dir in skill_dirs { | |
| let skill_md_path = dir.join("SKILL.md"); | |
| let skill_md = read_to_string(&skill_md_path); | |
| assert_frontmatter_keys_are_minimal(&skill_md, &skill_md_path, &allowed_keys); | |
| } | |
| } | |
| /// Ensures SKILL.md files have no code blocks or procedural markers. | |
| #[test] | |
| fn skill_files_have_no_embedded_playbooks() { | |
| let skills_root = Path::new(".claude/skills"); | |
| let skill_dirs = all_skill_dirs(skills_root); | |
| // Procedural markers that belong in references, not in SKILL.md itself. | |
| let procedural_markers = [ | |
| "## procedure", | |
| "## workflow", | |
| "## steps", | |
| "step 1", | |
| "step 2", | |
| "first,", | |
| "then,", | |
| "finally,", | |
| "checklist", | |
| "success criteria", | |
| ]; | |
| for dir in skill_dirs { | |
| let skill_md_path = dir.join("SKILL.md"); | |
| let skill_md = read_to_string(&skill_md_path); | |
| // Keep skills language-first; avoid embedding fenced command blocks. | |
| assert!( | |
| !skill_md.contains("```"), | |
| "SKILL.md at {} contains fenced code block - move procedures to \ | |
| references/ and keep SKILL.md declarative", | |
| skill_md_path.display() | |
| ); | |
| let lower = skill_md.to_lowercase(); | |
| let lines: Vec<&str> = skill_md.lines().collect(); | |
| for marker in procedural_markers { | |
| if !lower.contains(marker) { | |
| continue; | |
| } | |
| // Allow these terms only inside a `## References` section (rare, | |
| // but acceptable when describing a referenced protocol by name). | |
| for (i, line) in lines.iter().enumerate() { | |
| if line.to_lowercase().contains(marker) { | |
| assert!( | |
| line_is_in_section(&lines, i, "reference"), | |
| "SKILL.md at {} contains procedural marker '{}' outside \ | |
| References section at line {}", | |
| skill_md_path.display(), | |
| marker, | |
| i + 1 | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| /// Ensures agent files express perspective, not step-by-step procedures. | |
| #[test] | |
| fn agent_files_have_no_step_by_step_procedures() { | |
| let agents_root = Path::new(".claude/agents"); | |
| let entries = fs::read_dir(agents_root).unwrap_or_else(|e| { | |
| panic!("failed to read_dir {}: {e}", agents_root.display()); | |
| }); | |
| let mut agent_files = Vec::new(); | |
| for entry in entries { | |
| let entry = entry.unwrap_or_else(|e| { | |
| panic!( | |
| "failed to read_dir entry under {}: {e}", | |
| agents_root.display() | |
| ); | |
| }); | |
| let path = entry.path(); | |
| if path.extension().and_then(|e| e.to_str()) == Some("md") { | |
| agent_files.push(path); | |
| } | |
| } | |
| // Procedural patterns that suggest step-by-step workflows. | |
| let procedural_patterns = [ | |
| "step 1:", | |
| "step 2:", | |
| "1.", | |
| "2.", | |
| "3.", | |
| "first,", | |
| "then,", | |
| "next,", | |
| "finally,", | |
| "## procedure", | |
| "## workflow", | |
| "## steps", | |
| ]; | |
| for path in agent_files { | |
| let agent_md = read_to_string(&path); | |
| let content_lower = agent_md.to_lowercase(); | |
| for pattern in procedural_patterns { | |
| // Allow numbered lists in references section only. | |
| if pattern.chars().next().unwrap().is_ascii_digit() || pattern.ends_with(',') { | |
| let lines: Vec<&str> = agent_md.lines().collect(); | |
| for (i, line) in lines.iter().enumerate() { | |
| let line_lower = line.to_lowercase(); | |
| if line_lower.contains(pattern) { | |
| // Check if we're in a References section. | |
| let mut in_references = false; | |
| for j in (0..i).rev() { | |
| if lines[j].starts_with("## ") { | |
| if lines[j].to_lowercase().contains("reference") { | |
| in_references = true; | |
| } | |
| break; | |
| } | |
| } | |
| assert!( | |
| in_references, | |
| "agent file {} contains procedural pattern '{}' \ | |
| outside References section at line {} - agents \ | |
| should express perspective and values, not \ | |
| step-by-step procedures", | |
| path.display(), | |
| pattern, | |
| i + 1 | |
| ); | |
| } | |
| } | |
| } else if content_lower.contains(pattern) { | |
| panic!( | |
| "agent file {} contains procedural pattern '{}' - agents \ | |
| should express perspective and values, not step-by-step \ | |
| procedures", | |
| path.display(), | |
| pattern | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| /// Collects all markdown files from references/ directories under skills. | |
| fn reference_markdown_files_under(skills_root: &Path) -> Vec<PathBuf> { | |
| let mut out = Vec::new(); | |
| for dir in all_skill_dirs(skills_root) { | |
| let refs_dir = dir.join("references"); | |
| if !refs_dir.is_dir() { | |
| continue; | |
| } | |
| let entries = fs::read_dir(&refs_dir).unwrap_or_else(|e| { | |
| panic!("failed to read_dir {}: {e}", refs_dir.display()); | |
| }); | |
| for entry in entries { | |
| let entry = entry.unwrap_or_else(|e| { | |
| panic!( | |
| "failed to read_dir entry under {}: {e}", | |
| refs_dir.display() | |
| ); | |
| }); | |
| let path = entry.path(); | |
| if path.extension().and_then(|e| e.to_str()) == Some("md") { | |
| out.push(path); | |
| } | |
| } | |
| } | |
| out.sort(); | |
| out | |
| } | |
| /// Ensures reference files state they are optional near the top. | |
| #[test] | |
| fn reference_files_are_explicitly_elective() { | |
| let skills_root = Path::new(".claude/skills"); | |
| let reference_files = reference_markdown_files_under(skills_root); | |
| assert!( | |
| !reference_files.is_empty(), | |
| "expected at least one reference markdown file under {}", | |
| skills_root.display() | |
| ); | |
| for path in reference_files { | |
| let md = read_to_string(&path); | |
| let head = md | |
| .lines() | |
| .take(15) | |
| .collect::<Vec<_>>() | |
| .join("\n") | |
| .to_lowercase(); | |
| assert!( | |
| head.contains("optional"), | |
| "reference file {} should explicitly say it's optional near the top", | |
| path.display() | |
| ); | |
| } | |
| } | |
| /// Ensures skills with references/ directory have a References section. | |
| #[test] | |
| fn skills_with_references_dir_have_references_section() { | |
| let skills_root = Path::new(".claude/skills"); | |
| for dir in all_skill_dirs(skills_root) { | |
| let refs_dir = dir.join("references"); | |
| if !refs_dir.is_dir() { | |
| continue; | |
| } | |
| let skill_md_path = dir.join("SKILL.md"); | |
| let skill_md = read_to_string(&skill_md_path); | |
| assert!( | |
| skill_md.contains("\n## References\n"), | |
| "skill {} has references/ dir but SKILL.md lacks a '## References' section", | |
| dir.display() | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment