agentshield/adapter/
cursor_rules.rs1use 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
18pub 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 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 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
89fn 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 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 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 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 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
174fn 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
188fn 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 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 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 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 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 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}