agentshield/adapter/
cursor_rules.rs1use 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
17pub 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 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 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
83fn 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 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 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 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 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
168fn 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
182fn 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 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 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 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 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 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}