Skip to main content

agentshield/adapter/
crewai.rs

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