Skip to main content

agentshield/adapter/
openclaw.rs

1use std::path::{Path, PathBuf};
2
3use crate::config::ScanPathFilter;
4use crate::error::Result;
5use crate::ir::taint_builder::build_data_surface;
6use crate::ir::*;
7
8/// OpenClaw Skills adapter.
9///
10/// Detects by presence of `SKILL.md` at repository root or under
11/// `.agents/skills/<skill-name>/SKILL.md`.
12pub struct OpenClawAdapter;
13
14impl super::Adapter for OpenClawAdapter {
15    fn framework(&self) -> Framework {
16        Framework::OpenClaw
17    }
18
19    fn detect(&self, root: &Path) -> bool {
20        !find_openclaw_skill_roots(root).is_empty()
21    }
22
23    fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
24        let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
25        self.load_with_filter(root, &filter)
26    }
27
28    fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
29        find_openclaw_skill_roots(root)
30            .into_iter()
31            .map(|skill_root| load_skill_target(root, &skill_root, filter))
32            .collect()
33    }
34}
35
36fn load_skill_target(
37    scan_root: &Path,
38    skill_root: &Path,
39    filter: &ScanPathFilter,
40) -> Result<ScanTarget> {
41    let name = skill_root
42        .file_name()
43        .map(|name| name.to_string_lossy().into_owned())
44        .unwrap_or_else(|| "openclaw-skill".to_owned());
45    let source_files = collect_skill_source_files(scan_root, skill_root, filter)?;
46    let execution = super::pipeline::build_execution_surface(&source_files);
47    let tools = Vec::new();
48    let data = build_data_surface(&tools, &execution);
49
50    Ok(ScanTarget {
51        name,
52        framework: Framework::OpenClaw,
53        root_path: skill_root.to_path_buf(),
54        tools,
55        execution,
56        data,
57        dependencies: Default::default(),
58        provenance: Default::default(),
59        source_files,
60    })
61}
62
63fn collect_skill_source_files(
64    scan_root: &Path,
65    skill_root: &Path,
66    filter: &ScanPathFilter,
67) -> Result<Vec<SourceFile>> {
68    let walker = ignore::WalkBuilder::new(skill_root)
69        .hidden(true)
70        .git_ignore(true)
71        .max_depth(Some(3))
72        .build();
73    let mut source_files = Vec::new();
74
75    for entry in walker.flatten() {
76        let path = entry.path();
77        if !path.is_file()
78            || (filter.ignore_tests() && super::mcp::is_test_file(path))
79            || !filter.allows_path(scan_root, path)
80        {
81            continue;
82        }
83
84        let extension = path
85            .extension()
86            .map(|extension| extension.to_string_lossy())
87            .unwrap_or_default();
88        let language = Language::from_extension(&extension);
89        if !matches!(
90            language,
91            Language::Python | Language::Shell | Language::Markdown
92        ) {
93            continue;
94        }
95
96        let metadata = std::fs::metadata(path)?;
97        if metadata.len() > 1_048_576 {
98            continue;
99        }
100
101        if let Ok(content) = std::fs::read_to_string(path) {
102            use sha2::Digest;
103            let content_hash = format!(
104                "{:x}",
105                sha2::Sha256::new()
106                    .chain_update(content.as_bytes())
107                    .finalize()
108            );
109            source_files.push(SourceFile {
110                path: path.to_path_buf(),
111                language,
112                size_bytes: metadata.len(),
113                content_hash,
114                content,
115            });
116        }
117    }
118
119    Ok(source_files)
120}
121
122fn find_openclaw_skill_roots(root: &Path) -> Vec<PathBuf> {
123    let mut skills = Vec::new();
124
125    if root.join("SKILL.md").is_file() {
126        skills.push(root.to_path_buf());
127    }
128
129    let agents_dir = root.join(".agents").join("skills");
130    if !agents_dir.is_dir() {
131        return skills;
132    }
133
134    if let Ok(entries) = std::fs::read_dir(agents_dir) {
135        for entry in entries.flatten() {
136            let is_directory = entry
137                .file_type()
138                .map(|file_type| file_type.is_dir())
139                .unwrap_or(false);
140            if is_directory && entry.path().join("SKILL.md").is_file() {
141                skills.push(entry.path());
142            }
143        }
144    }
145
146    skills.sort();
147    skills.dedup();
148    skills
149}
150
151#[cfg(test)]
152mod tests {
153    use std::path::{Path, PathBuf};
154
155    use super::OpenClawAdapter;
156    use crate::adapter::Adapter;
157    use tempfile::tempdir;
158
159    fn write_skill(root: &Path, name: &str, source: &str) -> PathBuf {
160        let skill_root = root.join(".agents").join("skills").join(name);
161        std::fs::create_dir_all(&skill_root).expect("create skill dir");
162        std::fs::write(skill_root.join("SKILL.md"), format!("# {name}\n"))
163            .expect("write skill file");
164        std::fs::write(skill_root.join("tool.py"), source).expect("write source file");
165        skill_root
166    }
167
168    #[test]
169    fn detects_agents_skills_layout() {
170        let temp = tempdir().expect("create tempdir");
171        let skill_root = write_skill(temp.path(), "mbras-harness", "def process():\n    pass\n");
172
173        let adapter = OpenClawAdapter;
174        let root = temp.path();
175
176        assert!(adapter.detect(root));
177
178        let targets = adapter
179            .load(root, false)
180            .expect("openclaw load should succeed");
181        assert_eq!(targets.len(), 1);
182        assert_eq!(targets[0].name, "mbras-harness");
183        assert_eq!(targets[0].root_path, skill_root);
184        assert!(!targets[0].source_files.is_empty());
185    }
186
187    #[test]
188    fn loads_each_skill_as_an_isolated_target_in_stable_order() {
189        let temp = tempdir().expect("create tempdir");
190        let alpha = write_skill(
191            temp.path(),
192            "alpha",
193            "import subprocess\nsubprocess.run(user_input, shell=True)\n",
194        );
195        let zulu = write_skill(
196            temp.path(),
197            "zulu",
198            "import requests\nrequests.get(user_url)\n",
199        );
200
201        let targets = OpenClawAdapter
202            .load(temp.path(), false)
203            .expect("load isolated skills");
204
205        assert_eq!(targets.len(), 2);
206        assert_eq!(targets[0].name, "alpha");
207        assert_eq!(targets[0].root_path, alpha);
208        assert!(
209            targets[0]
210                .source_files
211                .iter()
212                .all(|source| source.path.starts_with(&targets[0].root_path))
213        );
214        assert!(!targets[0].execution.commands.is_empty());
215        assert!(targets[0].execution.network_operations.is_empty());
216
217        assert_eq!(targets[1].name, "zulu");
218        assert_eq!(targets[1].root_path, zulu);
219        assert!(
220            targets[1]
221                .source_files
222                .iter()
223                .all(|source| source.path.starts_with(&targets[1].root_path))
224        );
225        assert!(targets[1].execution.commands.is_empty());
226        assert!(!targets[1].execution.network_operations.is_empty());
227    }
228
229    #[test]
230    fn does_not_treat_deeper_nested_skill_as_direct_layout() {
231        let temp = tempdir().expect("create tempdir");
232        let nested = temp
233            .path()
234            .join(".agents")
235            .join("skills")
236            .join("group")
237            .join("nested");
238        std::fs::create_dir_all(&nested).expect("create nested dir");
239        std::fs::write(nested.join("SKILL.md"), "# nested\n").expect("write nested skill");
240
241        assert!(!OpenClawAdapter.detect(temp.path()));
242    }
243
244    #[test]
245    fn root_skill_and_agents_skill_remain_separate_targets() {
246        let temp = tempdir().expect("create tempdir");
247        std::fs::write(temp.path().join("SKILL.md"), "# root\n").expect("write root skill");
248        let nested = write_skill(temp.path(), "nested", "def nested():\n    pass\n");
249
250        let targets = OpenClawAdapter
251            .load(temp.path(), false)
252            .expect("load root and nested skills");
253
254        assert_eq!(targets.len(), 2);
255        assert_eq!(targets[0].root_path, temp.path());
256        assert_eq!(targets[1].root_path, nested);
257    }
258}