Skip to main content

agentshield/adapter/
langchain.rs

1use std::path::Path;
2
3use crate::config::ScanPathFilter;
4use crate::error::Result;
5use crate::ir::*;
6
7/// LangChain framework adapter.
8///
9/// Detects LangChain projects by looking for:
10/// - `pyproject.toml` with `langchain` dependency
11/// - `requirements.txt` containing `langchain` or `langgraph`
12/// - `langgraph.json` configuration file
13/// - Python files importing `from langchain` / `from langchain_core` / `from langgraph`
14pub struct LangChainAdapter;
15
16impl super::Adapter for LangChainAdapter {
17    fn framework(&self) -> Framework {
18        Framework::LangChain
19    }
20
21    fn detect(&self, root: &Path) -> bool {
22        // Check for langgraph.json configuration file
23        if root.join("langgraph.json").exists() {
24            return true;
25        }
26
27        // Check package.json for @langchain dependencies
28        let package_json = root.join("package.json");
29        if package_json.exists() {
30            if let Some(content) = super::read_file_capped(&package_json) {
31                if content.contains("@langchain/")
32                    || content.contains("\"langchain\"")
33                    || content.contains("@langchain/core")
34                {
35                    return true;
36                }
37            }
38        }
39
40        super::pipeline::detect_python_framework(
41            root,
42            &["langchain", "langgraph"],
43            |trimmed| trimmed.starts_with("langchain") || trimmed.starts_with("langgraph"),
44            &[
45                "from langchain",
46                "import langchain",
47                "from langgraph",
48                "import langgraph",
49            ],
50        )
51    }
52
53    fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
54        let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
55        self.load_with_filter(root, &filter)
56    }
57
58    fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
59        super::pipeline::load_framework_target(
60            root,
61            filter,
62            Framework::LangChain,
63            "langchain-project",
64            |lang| {
65                matches!(
66                    lang,
67                    Language::Python | Language::TypeScript | Language::JavaScript
68                )
69            },
70            None,
71        )
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::adapter::Adapter;
79    use std::io::Write;
80    use std::path::PathBuf;
81    use tempfile::TempDir;
82
83    #[test]
84    fn test_detect_langchain_via_pyproject() {
85        let dir =
86            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/langchain_project");
87        let adapter = LangChainAdapter;
88        assert!(adapter.detect(&dir));
89    }
90
91    #[test]
92    fn test_detect_langchain_via_langgraph_json() {
93        let dir =
94            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/langchain_project");
95        let adapter = LangChainAdapter;
96        // The fixture has pyproject.toml, but langgraph.json also triggers detection
97        assert!(adapter.detect(&dir));
98    }
99
100    #[test]
101    fn test_detect_non_langchain_project() {
102        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
103            .join("tests/fixtures/mcp_servers/safe_calculator");
104        let adapter = LangChainAdapter;
105        assert!(!adapter.detect(&dir));
106    }
107
108    #[test]
109    fn test_load_langchain_finds_cmd_injection() {
110        let dir =
111            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/langchain_project");
112        let adapter = LangChainAdapter;
113        let targets = adapter.load(&dir, false).unwrap();
114        assert_eq!(targets.len(), 1);
115
116        let target = &targets[0];
117        assert_eq!(target.framework, Framework::LangChain);
118        assert_eq!(target.name, "langchain_project");
119
120        // Should find command injection in shell_tool.py
121        assert!(
122            !target.execution.commands.is_empty(),
123            "expected command execution findings from shell_tool.py"
124        );
125        // Should find tainted command args
126        assert!(
127            target
128                .execution
129                .commands
130                .iter()
131                .any(|c| c.command_arg.is_tainted()),
132            "expected tainted command source from subprocess.run with user input"
133        );
134    }
135
136    #[test]
137    fn test_load_langchain_finds_ssrf() {
138        let dir =
139            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/langchain_project");
140        let adapter = LangChainAdapter;
141        let targets = adapter.load(&dir, false).unwrap();
142        let target = &targets[0];
143
144        // Should find network operations in fetch_tool.py
145        assert!(
146            !target.execution.network_operations.is_empty(),
147            "expected network operation findings from fetch_tool.py"
148        );
149    }
150
151    #[test]
152    fn test_load_langchain_only_python_files() {
153        let dir =
154            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/langchain_project");
155        let adapter = LangChainAdapter;
156        let targets = adapter.load(&dir, false).unwrap();
157        let target = &targets[0];
158
159        // All source files should be Python
160        for sf in &target.source_files {
161            assert_eq!(
162                sf.language,
163                Language::Python,
164                "non-Python file found: {:?}",
165                sf.path
166            );
167        }
168    }
169
170    #[test]
171    fn test_detect_langchain_nested_python_import() {
172        let tmp_root = TempDir::new().unwrap();
173        let nested_dir = tmp_root.path().join("src/pkg");
174        std::fs::create_dir_all(&nested_dir).unwrap();
175
176        let mut file = std::fs::File::create(nested_dir.join("tool.py")).unwrap();
177        writeln!(file, "from langchain_core import chat_models").unwrap();
178
179        let adapter = LangChainAdapter;
180        assert!(adapter.detect(tmp_root.path()));
181    }
182}