Skip to main content

agentshield/adapter/
crewai.rs

1use std::path::{Path, PathBuf};
2
3use crate::analysis::cross_file::apply_cross_file_sanitization;
4use crate::error::Result;
5use crate::ir::taint_builder::build_data_surface;
6use crate::ir::*;
7use crate::parser;
8
9/// CrewAI framework adapter.
10///
11/// Detects CrewAI projects by looking for:
12/// - `pyproject.toml` with `crewai` dependency or `[tool.crewai]` section
13/// - `requirements.txt` containing `crewai`
14/// - Python files importing `from crewai` or `from crewai_tools`
15pub struct CrewAiAdapter;
16
17impl super::Adapter for CrewAiAdapter {
18    fn framework(&self) -> Framework {
19        Framework::CrewAi
20    }
21
22    fn detect(&self, root: &Path) -> bool {
23        // Check pyproject.toml for crewai dependency or [tool.crewai] section
24        let pyproject = root.join("pyproject.toml");
25        if pyproject.exists() {
26            if let Ok(content) = std::fs::read_to_string(&pyproject) {
27                if content.contains("crewai") {
28                    return true;
29                }
30            }
31        }
32
33        // Check requirements.txt for crewai
34        let requirements = root.join("requirements.txt");
35        if requirements.exists() {
36            if let Ok(content) = std::fs::read_to_string(&requirements) {
37                if content.lines().any(|l| {
38                    let trimmed = l.trim();
39                    trimmed == "crewai"
40                        || trimmed.starts_with("crewai==")
41                        || trimmed.starts_with("crewai>=")
42                        || trimmed.starts_with("crewai[")
43                        || trimmed == "crewai-tools"
44                        || trimmed.starts_with("crewai-tools==")
45                        || trimmed.starts_with("crewai-tools>=")
46                }) {
47                    return true;
48                }
49            }
50        }
51
52        // Check Python files for crewai imports (only top-level, not recursive)
53        if let Ok(entries) = std::fs::read_dir(root) {
54            for entry in entries.flatten() {
55                let path = entry.path();
56                if path.extension().is_some_and(|e| e == "py") {
57                    if let Ok(content) = std::fs::read_to_string(&path) {
58                        if content.contains("from crewai")
59                            || content.contains("import crewai")
60                            || content.contains("from crewai_tools")
61                            || content.contains("import crewai_tools")
62                        {
63                            return true;
64                        }
65                    }
66                }
67            }
68        }
69
70        // Also check src/ directory for imports (common CrewAI layout)
71        let src_dir = root.join("src");
72        if src_dir.is_dir() {
73            if let Ok(entries) = std::fs::read_dir(&src_dir) {
74                for entry in entries.flatten() {
75                    let path = entry.path();
76                    if path.extension().is_some_and(|e| e == "py") {
77                        if let Ok(content) = std::fs::read_to_string(&path) {
78                            if content.contains("from crewai")
79                                || content.contains("import crewai")
80                                || content.contains("from crewai_tools")
81                                || content.contains("import crewai_tools")
82                            {
83                                return true;
84                            }
85                        }
86                    }
87                }
88            }
89        }
90
91        false
92    }
93
94    fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
95        let name = root
96            .file_name()
97            .map(|n| n.to_string_lossy().to_string())
98            .unwrap_or_else(|| "crewai-project".into());
99
100        let mut source_files = Vec::new();
101        let mut execution = execution_surface::ExecutionSurface::default();
102
103        // Phase 0: Collect source files (reuses MCP adapter's walker)
104        super::mcp::collect_source_files(root, ignore_tests, &mut source_files)?;
105
106        // Filter to Python-only (CrewAI is a Python framework)
107        source_files.retain(|sf| matches!(sf.language, Language::Python));
108
109        // Phase 1: Parse each Python file
110        let mut parsed_files: Vec<(PathBuf, parser::ParsedFile)> = Vec::new();
111        for sf in &source_files {
112            if let Some(parser) = parser::parser_for_language(sf.language) {
113                if let Ok(parsed) = parser.parse_file(&sf.path, &sf.content) {
114                    parsed_files.push((sf.path.clone(), parsed));
115                }
116            }
117        }
118
119        // Phase 2: Cross-file sanitizer-aware analysis
120        apply_cross_file_sanitization(&mut parsed_files);
121
122        // Phase 3: Merge into execution surface
123        for (_, parsed) in parsed_files {
124            execution.commands.extend(parsed.commands);
125            execution.file_operations.extend(parsed.file_operations);
126            execution
127                .network_operations
128                .extend(parsed.network_operations);
129            execution.env_accesses.extend(parsed.env_accesses);
130            execution.dynamic_exec.extend(parsed.dynamic_exec);
131        }
132
133        // Parse dependencies from pyproject.toml / requirements.txt
134        let dependencies = super::mcp::parse_dependencies(root);
135
136        // Parse provenance from pyproject.toml
137        let provenance = super::mcp::parse_provenance(root);
138
139        let tools = vec![];
140        let data = build_data_surface(&tools, &execution);
141
142        Ok(vec![ScanTarget {
143            name,
144            framework: Framework::CrewAi,
145            root_path: root.to_path_buf(),
146            tools,
147            execution,
148            data,
149            dependencies,
150            provenance,
151            source_files,
152        }])
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::adapter::Adapter;
160
161    #[test]
162    fn test_detect_crewai_via_pyproject() {
163        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/crewai_project");
164        let adapter = CrewAiAdapter;
165        assert!(adapter.detect(&dir));
166    }
167
168    #[test]
169    fn test_detect_non_crewai_project() {
170        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
171            .join("tests/fixtures/mcp_servers/safe_calculator");
172        let adapter = CrewAiAdapter;
173        assert!(!adapter.detect(&dir));
174    }
175
176    #[test]
177    fn test_load_crewai_finds_cmd_injection() {
178        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/crewai_project");
179        let adapter = CrewAiAdapter;
180        let targets = adapter.load(&dir, false).unwrap();
181        assert_eq!(targets.len(), 1);
182
183        let target = &targets[0];
184        assert_eq!(target.framework, Framework::CrewAi);
185        assert_eq!(target.name, "crewai_project");
186
187        // Should find command injection in vuln_tool.py
188        assert!(
189            !target.execution.commands.is_empty(),
190            "expected command execution findings from vuln_tool.py"
191        );
192        // Should find tainted command args
193        assert!(
194            target
195                .execution
196                .commands
197                .iter()
198                .any(|c| c.command_arg.is_tainted()),
199            "expected tainted command source from subprocess.run with user input"
200        );
201    }
202
203    #[test]
204    fn test_load_crewai_finds_ssrf() {
205        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/crewai_project");
206        let adapter = CrewAiAdapter;
207        let targets = adapter.load(&dir, false).unwrap();
208        let target = &targets[0];
209
210        // Should find network operations in fetch_tool.py
211        assert!(
212            !target.execution.network_operations.is_empty(),
213            "expected network operation findings from fetch_tool.py"
214        );
215    }
216
217    #[test]
218    fn test_load_crewai_parses_dependencies() {
219        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/crewai_project");
220        let adapter = CrewAiAdapter;
221        let targets = adapter.load(&dir, false).unwrap();
222        let target = &targets[0];
223
224        assert!(
225            target
226                .dependencies
227                .dependencies
228                .iter()
229                .any(|d| d.name == "crewai"),
230            "expected crewai in dependencies"
231        );
232    }
233
234    #[test]
235    fn test_load_crewai_only_python_files() {
236        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/crewai_project");
237        let adapter = CrewAiAdapter;
238        let targets = adapter.load(&dir, false).unwrap();
239        let target = &targets[0];
240
241        // All source files should be Python
242        for sf in &target.source_files {
243            assert_eq!(
244                sf.language,
245                Language::Python,
246                "non-Python file found: {:?}",
247                sf.path
248            );
249        }
250    }
251}