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