use std::path::Path;
use crate::error::Result;
use crate::ir::execution_surface::{CommandInvocation, EnvAccess, ExecutionSurface};
use crate::ir::taint_builder::build_data_surface;
use crate::ir::tool_surface::ToolSurface;
use crate::ir::*;
pub struct CursorRulesAdapter;
impl super::Adapter for CursorRulesAdapter {
fn framework(&self) -> Framework {
Framework::CursorRules
}
fn detect(&self, root: &Path) -> bool {
root.join(".cursorrules").exists() || root.join(".cursor").join("mcp.json").exists()
}
fn load(&self, root: &Path, _ignore_tests: bool) -> Result<Vec<ScanTarget>> {
let name = root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "cursor-project".into());
let mut tools: Vec<ToolSurface> = Vec::new();
let mut execution = ExecutionSurface::default();
let mut source_files: Vec<SourceFile> = Vec::new();
let cursorrules_path = root.join(".cursorrules");
if cursorrules_path.exists() {
if let Some(sf) = read_as_source_file(&cursorrules_path) {
source_files.push(sf);
}
}
let mcp_json_path = root.join(".cursor").join("mcp.json");
if mcp_json_path.exists() {
if let Some(sf) = read_as_source_file(&mcp_json_path) {
source_files.push(sf);
}
if let Ok(content) = std::fs::read_to_string(&mcp_json_path) {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
parse_mcp_servers(&value, &mcp_json_path, &mut tools, &mut execution);
}
}
}
let dependencies = super::mcp::parse_dependencies(root);
let provenance = super::mcp::parse_provenance(root);
let data = build_data_surface(&tools, &execution);
Ok(vec![ScanTarget {
name,
framework: Framework::CursorRules,
root_path: root.to_path_buf(),
tools,
execution,
data,
dependencies,
provenance,
source_files,
}])
}
}
fn parse_mcp_servers(
value: &serde_json::Value,
mcp_path: &Path,
tools: &mut Vec<ToolSurface>,
execution: &mut ExecutionSurface,
) {
let servers = match value.get("mcpServers").and_then(|v| v.as_object()) {
Some(s) => s,
None => return,
};
for (server_name, server_cfg) in servers {
let command = server_cfg
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let args: Vec<String> = server_cfg
.get("args")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|a| a.as_str())
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
let full_command = if args.is_empty() {
command.clone()
} else {
format!("{} {}", command, args.join(" "))
};
let location = SourceLocation {
file: mcp_path.to_path_buf(),
line: 1,
column: 0,
end_line: None,
end_column: None,
};
tools.push(ToolSurface {
name: server_name.clone(),
description: Some(format!("MCP server '{}' configured in Cursor", server_name)),
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {}
})),
output_schema: None,
declared_permissions: vec![],
defined_at: Some(location.clone()),
});
if !command.is_empty() {
execution.commands.push(CommandInvocation {
function: command.clone(),
command_arg: ArgumentSource::Literal(full_command),
location: location.clone(),
});
}
if let Some(env_map) = server_cfg.get("env").and_then(|v| v.as_object()) {
for (var_name, _var_value) in env_map {
let is_sensitive = looks_sensitive(var_name);
execution.env_accesses.push(EnvAccess {
var_name: ArgumentSource::Literal(var_name.clone()),
is_sensitive,
location: location.clone(),
});
}
}
}
}
fn looks_sensitive(name: &str) -> bool {
let upper = name.to_uppercase();
upper.contains("KEY")
|| upper.contains("SECRET")
|| upper.contains("TOKEN")
|| upper.contains("PASSWORD")
|| upper.contains("CREDENTIAL")
|| upper.contains("AUTH")
|| upper.starts_with("AWS_")
|| upper.starts_with("GH_")
|| upper.starts_with("GITHUB_")
}
fn read_as_source_file(path: &Path) -> Option<SourceFile> {
let metadata = std::fs::metadata(path).ok()?;
if metadata.len() > 1_048_576 {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_string())
.unwrap_or_default();
let lang = if ext.is_empty() {
Language::Markdown
} else {
Language::from_extension(&ext)
};
let hash = format!(
"{:x}",
sha2::Digest::finalize(sha2::Sha256::new().chain_update(content.as_bytes()))
);
Some(SourceFile {
path: path.to_path_buf(),
language: lang,
size_bytes: metadata.len(),
content_hash: hash,
content,
})
}
use sha2::Digest;
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::Adapter;
use std::path::PathBuf;
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cursor_rules")
}
#[test]
fn test_detect_cursor_rules() {
let dir = fixture_dir();
let adapter = CursorRulesAdapter;
assert!(
adapter.detect(&dir),
"should detect Cursor Rules fixture via .cursorrules or .cursor/mcp.json"
);
}
#[test]
fn test_detect_non_cursor_project() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/mcp_servers/safe_calculator");
let adapter = CursorRulesAdapter;
assert!(
!adapter.detect(&dir),
"should not detect Cursor Rules in an MCP calculator fixture"
);
}
#[test]
fn test_load_cursor_rules_framework() {
let dir = fixture_dir();
let adapter = CursorRulesAdapter;
let targets = adapter.load(&dir, false).unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].framework, Framework::CursorRules);
}
#[test]
fn test_load_cursor_rules_mcp_servers_as_tools() {
let dir = fixture_dir();
let adapter = CursorRulesAdapter;
let targets = adapter.load(&dir, false).unwrap();
let target = &targets[0];
assert_eq!(
target.tools.len(),
2,
"expected 2 tool entries (one per MCP server), got {}",
target.tools.len()
);
let tool_names: Vec<&str> = target.tools.iter().map(|t| t.name.as_str()).collect();
assert!(
tool_names.contains(&"filesystem"),
"expected 'filesystem' server tool"
);
assert!(
tool_names.contains(&"github"),
"expected 'github' server tool"
);
}
#[test]
fn test_load_cursor_rules_command_invocations() {
let dir = fixture_dir();
let adapter = CursorRulesAdapter;
let targets = adapter.load(&dir, false).unwrap();
let target = &targets[0];
assert!(
!target.execution.commands.is_empty(),
"expected command invocations from MCP server configs"
);
let uses_npx = target
.execution
.commands
.iter()
.any(|c| c.function == "npx");
assert!(uses_npx, "expected 'npx' command from MCP server config");
}
#[test]
fn test_load_cursor_rules_env_accesses() {
let dir = fixture_dir();
let adapter = CursorRulesAdapter;
let targets = adapter.load(&dir, false).unwrap();
let target = &targets[0];
assert!(
!target.execution.env_accesses.is_empty(),
"expected env accesses from github MCP server env map"
);
let has_pat = target.execution.env_accesses.iter().any(|e| {
matches!(&e.var_name, ArgumentSource::Literal(n) if n.contains("GITHUB_PERSONAL_ACCESS_TOKEN"))
});
assert!(has_pat, "expected GITHUB_PERSONAL_ACCESS_TOKEN env access");
let pat_entry = target.execution.env_accesses.iter().find(|e| {
matches!(&e.var_name, ArgumentSource::Literal(n) if n.contains("GITHUB_PERSONAL_ACCESS_TOKEN"))
});
assert!(
pat_entry.map(|e| e.is_sensitive).unwrap_or(false),
"GITHUB_PERSONAL_ACCESS_TOKEN should be marked sensitive"
);
}
#[test]
fn test_load_cursor_rules_source_files() {
let dir = fixture_dir();
let adapter = CursorRulesAdapter;
let targets = adapter.load(&dir, false).unwrap();
let target = &targets[0];
assert!(
!target.source_files.is_empty(),
"expected source files from cursor fixture"
);
let file_names: Vec<String> = target
.source_files
.iter()
.map(|sf| {
sf.path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
})
.collect();
assert!(
file_names.contains(&".cursorrules".to_string()),
"expected .cursorrules in source files"
);
assert!(
file_names.contains(&"mcp.json".to_string()),
"expected mcp.json in source files"
);
}
}