diff --git a/src/adapter/openclaw.rs b/src/adapter/openclaw.rs index 1d04711..c8b1454 100644 --- a/src/adapter/openclaw.rs +++ b/src/adapter/openclaw.rs @@ -1,15 +1,14 @@ use std::path::{Path, PathBuf}; -use crate::analysis::cross_file::apply_cross_file_sanitization; use crate::config::ScanPathFilter; use crate::error::Result; use crate::ir::taint_builder::build_data_surface; use crate::ir::*; -use crate::parser; /// OpenClaw Skills adapter. /// -/// Detects by presence of `SKILL.md` file. +/// Detects by presence of `SKILL.md` at repository root or under +/// `.agents/skills//SKILL.md`. pub struct OpenClawAdapter; impl super::Adapter for OpenClawAdapter { @@ -18,7 +17,7 @@ impl super::Adapter for OpenClawAdapter { } fn detect(&self, root: &Path) -> bool { - root.join("SKILL.md").exists() + !find_openclaw_skill_roots(root).is_empty() } fn load(&self, root: &Path, ignore_tests: bool) -> Result> { @@ -27,108 +26,229 @@ impl super::Adapter for OpenClawAdapter { } fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result> { - let name = root - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "openclaw-skill".into()); - - let mut source_files = Vec::new(); - let mut execution = execution_surface::ExecutionSurface::default(); - - // Collect source files (Python and Shell scripts) - let walker = ignore::WalkBuilder::new(root) - .hidden(true) - .git_ignore(true) - .max_depth(Some(3)) - .build(); - - for entry in walker.flatten() { - let path = entry.path(); - if !path.is_file() { - continue; - } + find_openclaw_skill_roots(root) + .into_iter() + .map(|skill_root| load_skill_target(root, &skill_root, filter)) + .collect() + } +} - if filter.ignore_tests() && super::mcp::is_test_file(path) { - continue; - } +fn load_skill_target( + scan_root: &Path, + skill_root: &Path, + filter: &ScanPathFilter, +) -> Result { + let name = skill_root + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "openclaw-skill".to_owned()); + let source_files = collect_skill_source_files(scan_root, skill_root, filter)?; + let execution = super::pipeline::build_execution_surface(&source_files); + let tools = Vec::new(); + let data = build_data_surface(&tools, &execution); + + Ok(ScanTarget { + name, + framework: Framework::OpenClaw, + root_path: skill_root.to_path_buf(), + tools, + execution, + data, + dependencies: Default::default(), + provenance: Default::default(), + source_files, + }) +} - if !filter.allows_path(root, path) { - continue; - } +fn collect_skill_source_files( + scan_root: &Path, + skill_root: &Path, + filter: &ScanPathFilter, +) -> Result> { + let walker = ignore::WalkBuilder::new(skill_root) + .hidden(true) + .git_ignore(true) + .max_depth(Some(3)) + .build(); + let mut source_files = Vec::new(); + + for entry in walker.flatten() { + let path = entry.path(); + if !path.is_file() + || (filter.ignore_tests() && super::mcp::is_test_file(path)) + || !filter.allows_path(scan_root, path) + { + continue; + } - let ext = path - .extension() - .map(|e| e.to_string_lossy().to_string()) - .unwrap_or_default(); - let lang = Language::from_extension(&ext); - - if !matches!( - lang, - Language::Python | Language::Shell | Language::Markdown - ) { - continue; - } + let extension = path + .extension() + .map(|extension| extension.to_string_lossy()) + .unwrap_or_default(); + let language = Language::from_extension(&extension); + if !matches!( + language, + Language::Python | Language::Shell | Language::Markdown + ) { + continue; + } - let metadata = std::fs::metadata(path)?; - if metadata.len() > 1_048_576 { - continue; - } + let metadata = std::fs::metadata(path)?; + if metadata.len() > 1_048_576 { + continue; + } - if let Ok(content) = std::fs::read_to_string(path) { - use sha2::Digest; - let hash = format!( - "{:x}", - sha2::Sha256::new() - .chain_update(content.as_bytes()) - .finalize() - ); - source_files.push(SourceFile { - path: path.to_path_buf(), - language: lang, - size_bytes: metadata.len(), - content_hash: hash, - content, - }); - } + if let Ok(content) = std::fs::read_to_string(path) { + use sha2::Digest; + let content_hash = format!( + "{:x}", + sha2::Sha256::new() + .chain_update(content.as_bytes()) + .finalize() + ); + source_files.push(SourceFile { + path: path.to_path_buf(), + language, + size_bytes: metadata.len(), + content_hash, + content, + }); } + } + + Ok(source_files) +} + +fn find_openclaw_skill_roots(root: &Path) -> Vec { + let mut skills = Vec::new(); + + if root.join("SKILL.md").is_file() { + skills.push(root.to_path_buf()); + } - // Phase 1: Parse source files - let mut parsed_files: Vec<(PathBuf, parser::ParsedFile)> = Vec::new(); - for sf in &source_files { - if let Some(parser) = parser::parser_for_language(sf.language) { - if let Ok(parsed) = parser.parse_file(&sf.path, &sf.content) { - parsed_files.push((sf.path.clone(), parsed)); - } + let agents_dir = root.join(".agents").join("skills"); + if !agents_dir.is_dir() { + return skills; + } + + if let Ok(entries) = std::fs::read_dir(agents_dir) { + for entry in entries.flatten() { + let is_directory = entry + .file_type() + .map(|file_type| file_type.is_dir()) + .unwrap_or(false); + if is_directory && entry.path().join("SKILL.md").is_file() { + skills.push(entry.path()); } } + } - // Phase 2: Cross-file sanitizer-aware analysis - apply_cross_file_sanitization(&mut parsed_files); - - // Phase 3: Merge into execution surface - for (_, parsed) in parsed_files { - execution.commands.extend(parsed.commands); - execution.file_operations.extend(parsed.file_operations); - execution - .network_operations - .extend(parsed.network_operations); - execution.env_accesses.extend(parsed.env_accesses); - execution.dynamic_exec.extend(parsed.dynamic_exec); - } + skills.sort(); + skills.dedup(); + skills +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::OpenClawAdapter; + use crate::adapter::Adapter; + use tempfile::tempdir; + + fn write_skill(root: &Path, name: &str, source: &str) -> PathBuf { + let skill_root = root.join(".agents").join("skills").join(name); + std::fs::create_dir_all(&skill_root).expect("create skill dir"); + std::fs::write(skill_root.join("SKILL.md"), format!("# {name}\n")) + .expect("write skill file"); + std::fs::write(skill_root.join("tool.py"), source).expect("write source file"); + skill_root + } + + #[test] + fn detects_agents_skills_layout() { + let temp = tempdir().expect("create tempdir"); + let skill_root = write_skill(temp.path(), "mbras-harness", "def process():\n pass\n"); + + let adapter = OpenClawAdapter; + let root = temp.path(); + + assert!(adapter.detect(root)); + + let targets = adapter + .load(root, false) + .expect("openclaw load should succeed"); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].name, "mbras-harness"); + assert_eq!(targets[0].root_path, skill_root); + assert!(!targets[0].source_files.is_empty()); + } + + #[test] + fn loads_each_skill_as_an_isolated_target_in_stable_order() { + let temp = tempdir().expect("create tempdir"); + let alpha = write_skill( + temp.path(), + "alpha", + "import subprocess\nsubprocess.run(user_input, shell=True)\n", + ); + let zulu = write_skill( + temp.path(), + "zulu", + "import requests\nrequests.get(user_url)\n", + ); + + let targets = OpenClawAdapter + .load(temp.path(), false) + .expect("load isolated skills"); + + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].name, "alpha"); + assert_eq!(targets[0].root_path, alpha); + assert!(targets[0] + .source_files + .iter() + .all(|source| source.path.starts_with(&targets[0].root_path))); + assert!(!targets[0].execution.commands.is_empty()); + assert!(targets[0].execution.network_operations.is_empty()); + + assert_eq!(targets[1].name, "zulu"); + assert_eq!(targets[1].root_path, zulu); + assert!(targets[1] + .source_files + .iter() + .all(|source| source.path.starts_with(&targets[1].root_path))); + assert!(targets[1].execution.commands.is_empty()); + assert!(!targets[1].execution.network_operations.is_empty()); + } + + #[test] + fn does_not_treat_deeper_nested_skill_as_direct_layout() { + let temp = tempdir().expect("create tempdir"); + let nested = temp + .path() + .join(".agents") + .join("skills") + .join("group") + .join("nested"); + std::fs::create_dir_all(&nested).expect("create nested dir"); + std::fs::write(nested.join("SKILL.md"), "# nested\n").expect("write nested skill"); + + assert!(!OpenClawAdapter.detect(temp.path())); + } + + #[test] + fn root_skill_and_agents_skill_remain_separate_targets() { + let temp = tempdir().expect("create tempdir"); + std::fs::write(temp.path().join("SKILL.md"), "# root\n").expect("write root skill"); + let nested = write_skill(temp.path(), "nested", "def nested():\n pass\n"); + + let targets = OpenClawAdapter + .load(temp.path(), false) + .expect("load root and nested skills"); - let tools = vec![]; - let data = build_data_surface(&tools, &execution); - - Ok(vec![ScanTarget { - name, - framework: Framework::OpenClaw, - root_path: root.to_path_buf(), - tools, - execution, - data, - dependencies: Default::default(), - provenance: Default::default(), - source_files, - }]) + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].root_path, temp.path()); + assert_eq!(targets[1].root_path, nested); } }