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