use std::{
collections::BTreeSet,
fs::{self, File, OpenOptions},
io::{Read, Write},
path::{Path, PathBuf},
};
use anyhow::{bail, Context};
use serde_json::{json, Value};
use crate::{agent::CursorTool, permission::PermissionMode};
const CURSOR_CONFIG_READ_BUDGET_BYTES: u64 = 1024 * 1024;
#[derive(Clone, Debug)]
pub(crate) struct CursorConfigPaths {
pub(crate) cursor_config_dir: Option<PathBuf>,
pub(crate) xdg_config_home: Option<PathBuf>,
pub(crate) home: PathBuf,
}
impl CursorConfigPaths {
pub(crate) fn from_env(home: PathBuf) -> Self {
Self {
cursor_config_dir: std::env::var_os("CURSOR_CONFIG_DIR").map(PathBuf::from),
xdg_config_home: std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from),
home,
}
}
pub(crate) fn user_dir(&self) -> PathBuf {
let nonblank = |path: &&PathBuf| !path.as_os_str().to_string_lossy().trim().is_empty();
if let Some(dir) = self.cursor_config_dir.as_ref().filter(nonblank) {
dir.clone()
} else if let Some(dir) = self.xdg_config_home.as_ref().filter(nonblank) {
dir.join("cursor")
} else {
self.home.join(".cursor")
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum CursorCategory {
Read,
Search,
Write,
Shell,
Fetch,
Mcp,
Inert,
}
impl CursorCategory {
fn for_tool(tool: CursorTool) -> Self {
match tool {
CursorTool::Read => Self::Read,
CursorTool::Grep
| CursorTool::Glob
| CursorTool::Ls
| CursorTool::SemSearch
| CursorTool::ReadLints => Self::Search,
CursorTool::Edit | CursorTool::Delete | CursorTool::ApplyAgentDiff => Self::Write,
CursorTool::Shell | CursorTool::WriteShellStdin => Self::Shell,
CursorTool::WebFetch | CursorTool::Fetch | CursorTool::WebSearch => Self::Fetch,
CursorTool::Mcp | CursorTool::ListMcpResources | CursorTool::ReadMcpResource => {
Self::Mcp
}
CursorTool::UpdateTodos | CursorTool::ReadTodos | CursorTool::CreatePlan => Self::Inert,
}
}
fn limitation(self) -> &'static str {
match self {
Self::Search => "search is unfenceable",
Self::Inert => "session-artifact tools are not fenced",
Self::Read => "coarsened to Read",
Self::Write => "coarsened to Write",
Self::Shell => "coarsened to Shell",
Self::Fetch => "coarsened to Fetch permission requests",
Self::Mcp => "coarsened to best-effort Mcp permission requests (unverified)",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CursorFenceNotice {
pub(crate) exclusions: Vec<(CursorCategory, Vec<CursorTool>)>,
}
impl CursorFenceNotice {
pub(crate) fn render(&self) -> String {
let groups = self
.exclusions
.iter()
.map(|(category, tools)| {
let tools = tools
.iter()
.map(|tool| tool.as_flag())
.collect::<Vec<_>>()
.join(", ");
format!("{tools} ({})", category.limitation())
})
.collect::<Vec<_>>()
.join("; ");
format!("cursor ACP tools: exclusions cannot be enforced individually: {groups}")
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CursorFence {
pub(crate) allow: Vec<String>,
pub(crate) deny: Vec<&'static str>,
pub(crate) allowed_categories: BTreeSet<CursorCategory>,
pub(crate) notices: Vec<CursorFenceNotice>,
}
pub(crate) fn fence(mode: PermissionMode, tools: &[CursorTool]) -> CursorFence {
let present: BTreeSet<_> = tools
.iter()
.copied()
.map(CursorCategory::for_tool)
.collect();
let allowed_categories = match mode {
PermissionMode::Bypass => present.clone(),
PermissionMode::Plan
| PermissionMode::Auto
| PermissionMode::AllowEdits
| PermissionMode::Supervised => BTreeSet::new(),
};
let writes_blocked = !matches!(mode, PermissionMode::Bypass);
let mut deny = Vec::new();
if !present.contains(&CursorCategory::Read) {
deny.push("Read(**)");
}
if writes_blocked || !present.contains(&CursorCategory::Write) {
deny.push("Write(**)");
}
if writes_blocked || !present.contains(&CursorCategory::Shell) {
deny.push("Shell(*)");
}
let mut exclusions = Vec::new();
for category in [
CursorCategory::Search,
CursorCategory::Write,
CursorCategory::Shell,
CursorCategory::Fetch,
CursorCategory::Mcp,
CursorCategory::Inert,
] {
if matches!(category, CursorCategory::Search | CursorCategory::Inert)
|| allowed_categories.contains(&category)
{
let excluded: Vec<_> = CursorTool::ALL
.iter()
.copied()
.filter(|tool| CursorCategory::for_tool(*tool) == category && !tools.contains(tool))
.collect();
if !excluded.is_empty() {
exclusions.push((category, excluded));
}
}
}
CursorFence {
allow: Vec::new(),
deny,
allowed_categories,
notices: if exclusions.is_empty() {
Vec::new()
} else {
vec![CursorFenceNotice { exclusions }]
},
}
}
pub(crate) fn derive_config(
user_config: Option<Value>,
fence: &CursorFence,
) -> anyhow::Result<Value> {
let mut config = user_config.unwrap_or_else(|| json!({}));
let Some(object) = config.as_object_mut() else {
bail!("cursor cli-config.json must be a JSON object");
};
object.insert("approvalMode".into(), json!("allowlist"));
object.insert("autoAcceptWebSearch".into(), json!(false));
object.insert(
"permissions".into(),
json!({"allow": fence.allow, "deny": fence.deny}),
);
Ok(config)
}
#[derive(Debug)]
pub(crate) struct ManagedCursorConfig {
pub(crate) dir: PathBuf,
pub(crate) notices: Vec<String>,
}
pub(crate) fn write_run_config(
user_dir: &Path,
run_dir: &Path,
fence: &CursorFence,
) -> anyhow::Result<ManagedCursorConfig> {
let source = user_dir.join("cli-config.json");
let user_config = match File::open(&source) {
Ok(file) => {
let asked = file
.metadata()
.with_context(|| format!("could not stat {}", source.display()))?
.len();
if asked > CURSOR_CONFIG_READ_BUDGET_BYTES {
bail!("Cursor config read budget exceeded for {}: limit {CURSOR_CONFIG_READ_BUDGET_BYTES} bytes, asked {asked} bytes", source.display());
}
let mut bytes = Vec::new();
file.take(CURSOR_CONFIG_READ_BUDGET_BYTES + 1)
.read_to_end(&mut bytes)
.with_context(|| format!("could not read {}", source.display()))?;
if bytes.len() as u64 > CURSOR_CONFIG_READ_BUDGET_BYTES {
let asked = bytes.len();
bail!("Cursor config read budget exceeded for {}: limit {CURSOR_CONFIG_READ_BUDGET_BYTES} bytes, asked at least {asked} bytes", source.display());
}
Some(
serde_json::from_slice(&bytes)
.with_context(|| format!("invalid JSON in {}", source.display()))?,
)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => {
return Err(error).with_context(|| format!("could not read {}", source.display()))
}
};
let derived = derive_config(user_config, fence)
.with_context(|| format!("invalid Cursor config at {}", source.display()))?;
let bytes = serde_json::to_vec_pretty(&derived)?;
let dir = run_dir.join("cursor-config");
let mut builder = fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(&dir).with_context(|| {
format!(
"could not create private Cursor config directory {}",
dir.display()
)
})?;
let target = dir.join("cli-config.json");
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&target).with_context(|| {
format!(
"could not create private Cursor config {}",
target.display()
)
})?;
file.write_all(&bytes)
.with_context(|| format!("could not write Cursor config {}", target.display()))?;
Ok(ManagedCursorConfig {
dir,
notices: fence
.notices
.iter()
.map(CursorFenceNotice::render)
.collect(),
})
}
#[cfg(test)]
#[path = "acp_config_tests.rs"]
mod tests;