1use 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
19pub 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 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 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
90fn 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 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 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 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 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
180fn 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 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 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 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 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 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}