Skip to main content

agentshield/adapter/
cursor_rules.rs

1//! Cursor Rules adapter.
2//!
3//! Detects Cursor IDE configuration files and loads them into the unified IR.
4//!
5//! Supported files:
6//! - `.cursorrules` — project-level rules file (plain text)
7//! - `.cursor/mcp.json` — MCP server definitions used by Cursor
8
9use std::path::Path;
10
11use crate::config::ScanPathFilter;
12use crate::error::Result;
13use crate::ir::execution_surface::{CommandInvocation, EnvAccess, ExecutionSurface};
14use crate::ir::taint_builder::build_data_surface;
15use crate::ir::tool_surface::ToolSurface;
16use crate::ir::*;
17
18/// Cursor Rules adapter.
19///
20/// Detects Cursor IDE configuration by looking for:
21/// - `.cursorrules` (project-level rules file)
22/// - `.cursor/mcp.json` (Cursor MCP server config)
23pub struct CursorRulesAdapter;
24
25impl super::Adapter for CursorRulesAdapter {
26    fn framework(&self) -> Framework {
27        Framework::CursorRules
28    }
29
30    fn detect(&self, root: &Path) -> bool {
31        root.join(".cursorrules").exists() || root.join(".cursor").join("mcp.json").exists()
32    }
33
34    fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
35        let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
36        self.load_with_filter(root, &filter)
37    }
38
39    fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
40        let name = root
41            .file_name()
42            .map(|n| n.to_string_lossy().to_string())
43            .unwrap_or_else(|| "cursor-project".into());
44
45        let mut tools: Vec<ToolSurface> = Vec::new();
46        let mut execution = ExecutionSurface::default();
47        let mut source_files: Vec<SourceFile> = Vec::new();
48
49        // Load .cursorrules as a plain-text source file (no structured parsing needed)
50        let cursorrules_path = root.join(".cursorrules");
51        if cursorrules_path.exists() && filter.allows_path(root, &cursorrules_path) {
52            if let Some(sf) = read_as_source_file(&cursorrules_path) {
53                source_files.push(sf);
54            }
55        }
56
57        // Load .cursor/mcp.json — MCP server definitions
58        let mcp_json_path = root.join(".cursor").join("mcp.json");
59        if mcp_json_path.exists() && filter.allows_path(root, &mcp_json_path) {
60            if let Some(sf) = read_as_source_file(&mcp_json_path) {
61                source_files.push(sf);
62            }
63
64            if let Ok(content) = std::fs::read_to_string(&mcp_json_path) {
65                if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
66                    parse_mcp_servers(&value, &mcp_json_path, &mut tools, &mut execution);
67                }
68            }
69        }
70
71        let dependencies = super::mcp::parse_dependencies(root, filter);
72        let provenance = super::mcp::parse_provenance(root, filter);
73        let data = build_data_surface(&tools, &execution);
74
75        Ok(vec![ScanTarget {
76            name,
77            framework: Framework::CursorRules,
78            root_path: root.to_path_buf(),
79            tools,
80            execution,
81            data,
82            dependencies,
83            provenance,
84            source_files,
85        }])
86    }
87}
88
89/// Parse `mcpServers` entries from `.cursor/mcp.json`.
90///
91/// Each server entry becomes a `ToolSurface` (the server exposes tools to the agent)
92/// and a `CommandInvocation` (the command that starts the server process).
93/// Env vars in the `env` map are emitted as `EnvAccess` entries.
94fn parse_mcp_servers(
95    value: &serde_json::Value,
96    mcp_path: &Path,
97    tools: &mut Vec<ToolSurface>,
98    execution: &mut ExecutionSurface,
99) {
100    let servers = match value.get("mcpServers").and_then(|v| v.as_object()) {
101        Some(s) => s,
102        None => return,
103    };
104
105    for (server_name, server_cfg) in servers {
106        let command = server_cfg
107            .get("command")
108            .and_then(|v| v.as_str())
109            .unwrap_or("")
110            .to_string();
111
112        let args: Vec<String> = server_cfg
113            .get("args")
114            .and_then(|v| v.as_array())
115            .map(|arr| {
116                arr.iter()
117                    .filter_map(|a| a.as_str())
118                    .map(|s| s.to_string())
119                    .collect()
120            })
121            .unwrap_or_default();
122
123        // Build full command string for the invocation
124        let full_command = if args.is_empty() {
125            command.clone()
126        } else {
127            format!("{} {}", command, args.join(" "))
128        };
129
130        let location = SourceLocation {
131            file: mcp_path.to_path_buf(),
132            line: 1,
133            column: 0,
134            end_line: None,
135            end_column: None,
136        };
137
138        // Emit a ToolSurface representing the MCP server (it exposes tools to the agent)
139        tools.push(ToolSurface {
140            name: server_name.clone(),
141            description: Some(format!("MCP server '{}' configured in Cursor", server_name)),
142            input_schema: Some(serde_json::json!({
143                "type": "object",
144                "properties": {}
145            })),
146            output_schema: None,
147            declared_permissions: vec![],
148            defined_at: Some(location.clone()),
149        });
150
151        // Emit a CommandInvocation for the process that starts the server
152        if !command.is_empty() {
153            execution.commands.push(CommandInvocation {
154                function: command.clone(),
155                command_arg: ArgumentSource::Literal(full_command),
156                location: location.clone(),
157            });
158        }
159
160        // Emit EnvAccess entries for every declared env var
161        if let Some(env_map) = server_cfg.get("env").and_then(|v| v.as_object()) {
162            for (var_name, _var_value) in env_map {
163                let is_sensitive = looks_sensitive(var_name);
164                execution.env_accesses.push(EnvAccess {
165                    var_name: ArgumentSource::Literal(var_name.clone()),
166                    is_sensitive,
167                    location: location.clone(),
168                });
169            }
170        }
171    }
172}
173
174/// Heuristic: a variable name looks sensitive if it contains common secret keywords.
175fn looks_sensitive(name: &str) -> bool {
176    let upper = name.to_uppercase();
177    upper.contains("KEY")
178        || upper.contains("SECRET")
179        || upper.contains("TOKEN")
180        || upper.contains("PASSWORD")
181        || upper.contains("CREDENTIAL")
182        || upper.contains("AUTH")
183        || upper.starts_with("AWS_")
184        || upper.starts_with("GH_")
185        || upper.starts_with("GITHUB_")
186}
187
188/// Read a file as a `SourceFile` entry. Returns `None` if the file cannot be read.
189fn read_as_source_file(path: &Path) -> Option<SourceFile> {
190    let metadata = std::fs::metadata(path).ok()?;
191    if metadata.len() > 1_048_576 {
192        return None;
193    }
194    let content = std::fs::read_to_string(path).ok()?;
195    let ext = path
196        .extension()
197        .map(|e| e.to_string_lossy().to_string())
198        .unwrap_or_default();
199    let lang = if ext.is_empty() {
200        // .cursorrules has no extension — treat as Markdown (plain text)
201        Language::Markdown
202    } else {
203        Language::from_extension(&ext)
204    };
205    let hash = format!(
206        "{:x}",
207        sha2::Digest::finalize(sha2::Sha256::new().chain_update(content.as_bytes()))
208    );
209    Some(SourceFile {
210        path: path.to_path_buf(),
211        language: lang,
212        size_bytes: metadata.len(),
213        content_hash: hash,
214        content,
215    })
216}
217
218use sha2::Digest;
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::adapter::Adapter;
224    use std::path::PathBuf;
225
226    fn fixture_dir() -> PathBuf {
227        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cursor_rules")
228    }
229
230    #[test]
231    fn test_detect_cursor_rules() {
232        let dir = fixture_dir();
233        let adapter = CursorRulesAdapter;
234        assert!(
235            adapter.detect(&dir),
236            "should detect Cursor Rules fixture via .cursorrules or .cursor/mcp.json"
237        );
238    }
239
240    #[test]
241    fn test_detect_non_cursor_project() {
242        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
243            .join("tests/fixtures/mcp_servers/safe_calculator");
244        let adapter = CursorRulesAdapter;
245        assert!(
246            !adapter.detect(&dir),
247            "should not detect Cursor Rules in an MCP calculator fixture"
248        );
249    }
250
251    #[test]
252    fn test_load_cursor_rules_framework() {
253        let dir = fixture_dir();
254        let adapter = CursorRulesAdapter;
255        let targets = adapter.load(&dir, false).unwrap();
256        assert_eq!(targets.len(), 1);
257        assert_eq!(targets[0].framework, Framework::CursorRules);
258    }
259
260    #[test]
261    fn test_load_cursor_rules_mcp_servers_as_tools() {
262        let dir = fixture_dir();
263        let adapter = CursorRulesAdapter;
264        let targets = adapter.load(&dir, false).unwrap();
265        let target = &targets[0];
266
267        // Fixture .cursor/mcp.json has 2 servers: filesystem and github
268        assert_eq!(
269            target.tools.len(),
270            2,
271            "expected 2 tool entries (one per MCP server), got {}",
272            target.tools.len()
273        );
274
275        let tool_names: Vec<&str> = target.tools.iter().map(|t| t.name.as_str()).collect();
276        assert!(
277            tool_names.contains(&"filesystem"),
278            "expected 'filesystem' server tool"
279        );
280        assert!(
281            tool_names.contains(&"github"),
282            "expected 'github' server tool"
283        );
284    }
285
286    #[test]
287    fn test_load_cursor_rules_command_invocations() {
288        let dir = fixture_dir();
289        let adapter = CursorRulesAdapter;
290        let targets = adapter.load(&dir, false).unwrap();
291        let target = &targets[0];
292
293        // Both servers use `npx` as command
294        assert!(
295            !target.execution.commands.is_empty(),
296            "expected command invocations from MCP server configs"
297        );
298
299        let uses_npx = target
300            .execution
301            .commands
302            .iter()
303            .any(|c| c.function == "npx");
304        assert!(uses_npx, "expected 'npx' command from MCP server config");
305    }
306
307    #[test]
308    fn test_load_cursor_rules_env_accesses() {
309        let dir = fixture_dir();
310        let adapter = CursorRulesAdapter;
311        let targets = adapter.load(&dir, false).unwrap();
312        let target = &targets[0];
313
314        // github server has GITHUB_PERSONAL_ACCESS_TOKEN env var
315        assert!(
316            !target.execution.env_accesses.is_empty(),
317            "expected env accesses from github MCP server env map"
318        );
319
320        let has_pat = target.execution.env_accesses.iter().any(|e| {
321            matches!(&e.var_name, ArgumentSource::Literal(n) if n.contains("GITHUB_PERSONAL_ACCESS_TOKEN"))
322        });
323        assert!(has_pat, "expected GITHUB_PERSONAL_ACCESS_TOKEN env access");
324
325        // PAT should be flagged as sensitive
326        let pat_entry = target.execution.env_accesses.iter().find(|e| {
327            matches!(&e.var_name, ArgumentSource::Literal(n) if n.contains("GITHUB_PERSONAL_ACCESS_TOKEN"))
328        });
329        assert!(
330            pat_entry.map(|e| e.is_sensitive).unwrap_or(false),
331            "GITHUB_PERSONAL_ACCESS_TOKEN should be marked sensitive"
332        );
333    }
334
335    #[test]
336    fn test_load_cursor_rules_source_files() {
337        let dir = fixture_dir();
338        let adapter = CursorRulesAdapter;
339        let targets = adapter.load(&dir, false).unwrap();
340        let target = &targets[0];
341
342        assert!(
343            !target.source_files.is_empty(),
344            "expected source files from cursor fixture"
345        );
346
347        let file_names: Vec<String> = target
348            .source_files
349            .iter()
350            .map(|sf| {
351                sf.path
352                    .file_name()
353                    .unwrap_or_default()
354                    .to_string_lossy()
355                    .to_string()
356            })
357            .collect();
358
359        assert!(
360            file_names.contains(&".cursorrules".to_string()),
361            "expected .cursorrules in source files"
362        );
363        assert!(
364            file_names.contains(&"mcp.json".to_string()),
365            "expected mcp.json in source files"
366        );
367    }
368}