From da412f14be36c48a4c96cdfc2bb1c8e8fb613e88 Mon Sep 17 00:00:00 2001 From: rohitkumardanda Date: Fri, 13 Nov 2026 00:48:40 +0000 Subject: [PATCH] fix(agents): keep agent permissions out of project settings (#3000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project's .openidea/settings.toml could loosen what coding agents do without asking: auto-approve commands, empty the blocked list, pick the mode new tasks start in, and add MCP servers every agent session starts. Project settings can no longer set agentSecurity.allowedCommands or agents.defaults (user settings only, like mcpServer), and a project's agentSecurity.blockedCommands adds to the user's list instead of replacing it. Workspace MCP servers start only once the user has turned that exact definition on in Settings › Agents › External MCP Servers; servers saved there count as turned on, servers imported or changed in the file stay off until turned on. The Command Execution Policy page drops its Global / This project switch. Co-authored-by: rohitdanda <10144914+rohitdanda@users.noreply.github.com> --- crates/ide-host/src/lib.rs | 2 + crates/ide-host/src/ops/agent.rs | 99 ++++++++++++++++-- crates/ide-host/src/project.rs | 4 +- crates/ide-host/src/trust.rs | 55 ++++++++++ crates/ide-host/tests/agent_mcp.rs | 77 ++++++++++++++ crates/ide-workspace/src/settings.rs | 116 +++++++++++++++++++--- docs/en/reference/settings.mdx | 2 + docs/zh/reference/settings.mdx | 2 + src/features/agents/McpServers.module.css | 8 ++ src/features/agents/McpServersPage.tsx | 8 ++ src/features/agents/agentPolicy.test.tsx | 3 +- src/features/agents/index.ts | 1 - src/features/agents/mcpServers.test.tsx | 2 + src/platform/locales/text/en.json | 1 + src/platform/locales/text/zh.json | 1 + 15 files changed, 353 insertions(+), 28 deletions(-) create mode 100644 crates/ide-host/tests/agent_mcp.rs diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index ea433bd65..84c3f726c 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -61,6 +61,7 @@ pub struct Host { pub mcp_reports: mcp::Reports, pub mcp_requests: mcp::Requests, pub trust: trust::TrustStore, + pub(crate) mcp_approvals: Arc, pub config_dir: PathBuf, /// Persistent user state, including credentials and recovery history. pub data_dir: PathBuf, @@ -141,6 +142,7 @@ impl Host { mcp_reports: Default::default(), mcp_requests: Default::default(), trust: trust::TrustStore::load(&config_dir, &data_dir), + mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)), proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")), proxy_session_password: Mutex::default(), agent_secrets: Arc::new(agent_secrets::AgentSecrets::new(&data_dir)), diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index da42e0dff..04eece66c 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -381,7 +381,7 @@ fn persist_mcp_map( Ok(()) } -fn update_mcp_maps(host: &Host, window: &str, f: F) -> IdeResult> +fn update_mcp_maps(host: &Host, window: &str, approve: Option<&str>, f: F) -> IdeResult> where F: FnOnce(&mut McpServerMap, &mut McpServerMap, Option<&std::path::Path>) -> Result<(), String>, { @@ -390,43 +390,83 @@ where let prev_workspace = workspace.clone(); let project_root = project.as_ref().map(|p| p.root.as_path()); f(&mut user, &mut workspace, project_root).map_err(IdeError::invalid)?; + if let (Some(name), Some(p)) = (approve, &project) + && let Some(server) = workspace.get(name) + { + host.mcp_approvals.approve(&p.root, name, server)?; + } if workspace != prev_workspace { persist_mcp_map(host, project.as_ref(), McpScope::Workspace, &workspace)?; } if user != prev_user { persist_mcp_map(host, project.as_ref(), McpScope::User, &user)?; } - Ok(ide_agent::mcp::servers_from_settings(&user, &workspace)) + Ok(listed(host, project.as_ref(), &user, workspace)) +} + +/// Workspace servers the user hasn't turned on in Settings are listed as off: agents don't start them. +fn listed( + host: &Host, + project: Option<&std::sync::Arc>, + user: &McpServerMap, + workspace: McpServerMap, +) -> Vec { + let workspace = match project { + Some(p) => host.mcp_approvals.apply(&p.root, workspace), + None => workspace, + }; + ide_agent::mcp::servers_from_settings(user, &workspace) +} + +/// The MCP servers agent sessions of the project at `root` get: the user's, and the project's the +/// user turned on. +pub(crate) fn session_mcp_servers( + settings: &ide_workspace::SettingsStore, + approvals: &crate::trust::McpServerApprovals, + root: &std::path::Path, +) -> Vec { + use ide_workspace::settings::SettingsScope; + let user = settings.layer(SettingsScope::Global).agents.mcp_servers; + let workspace = approvals.apply(root, settings.layer(SettingsScope::Project).agents.mcp_servers); + ide_agent::mcp::servers_from_settings(&user, &workspace) } /// Lists configured MCP servers across User (`settings.toml`) and Workspace (`.openidea/settings.toml`) scopes. pub fn mcp_list(host: &Host, window: &str) -> IdeResult> { - let (user, workspace, _) = mcp_maps(host, window); - Ok(ide_agent::mcp::servers_from_settings(&user, &workspace)) + let (user, workspace, project) = mcp_maps(host, window); + Ok(listed(host, project.as_ref(), &user, workspace)) } -/// Creates or updates a User or Workspace MCP server and returns the updated list. +/// Creates or updates a User or Workspace MCP server and returns the updated list. A Workspace +/// server saved here is one the user turned on. pub fn mcp_save(host: &Host, window: &str, input: McpServerInput) -> IdeResult> { - update_mcp_maps(host, window, |user, workspace, project_root| { + let approve = (input.scope == McpScope::Workspace).then(|| input.name.trim().to_owned()); + update_mcp_maps(host, window, approve.as_deref(), |user, workspace, project_root| { ide_agent::mcp::apply_save(user, workspace, project_root.is_some(), input) }) } /// Enables or disables an MCP server and returns the updated list. pub fn mcp_set_enabled(host: &Host, window: &str, id: String, enabled: bool) -> IdeResult> { - update_mcp_maps(host, window, |user, workspace, _| { + let approve = ide_agent::mcp::parse_server_id(&id) + .filter(|(scope, _)| enabled && *scope == McpScope::Workspace) + .map(|(_, name)| name.to_owned()); + update_mcp_maps(host, window, approve.as_deref(), |user, workspace, _| { ide_agent::mcp::apply_set_enabled(user, workspace, &id, enabled).map(|_| ()) }) } /// Removes a User or Workspace MCP server and returns the updated list. pub fn mcp_remove(host: &Host, window: &str, id: String) -> IdeResult> { - update_mcp_maps(host, window, |user, workspace, _| ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ())) + update_mcp_maps(host, window, None, |user, workspace, _| { + ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ()) + }) } -/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once. +/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once; +/// they stay off until the user turns them on. pub fn mcp_import(host: &Host, window: &str) -> IdeResult> { - update_mcp_maps(host, window, |_, workspace, project_root| { + update_mcp_maps(host, window, None, |_, workspace, project_root| { let root = project_root.ok_or_else(|| "Open a project to import workspace MCP servers".to_string())?; ide_agent::mcp::apply_import(workspace, root); Ok(()) @@ -550,4 +590,43 @@ mod tests { assert!(validate_project_root(&root, Some(&rejected)).is_err(), "{rejected:?}"); } } + + #[test] + fn sessions_get_the_users_servers_and_the_projects_turned_on_ones() { + let tmp = tempfile::tempdir().unwrap(); + let (config, root) = (tmp.path().join("config"), tmp.path().join("project")); + std::fs::create_dir_all(&config).unwrap(); + std::fs::create_dir_all(root.join(".openidea")).unwrap(); + std::fs::write(config.join("settings.toml"), "[agents.mcpServers.shared]\ncommand = \"user-cmd\"\n").unwrap(); + std::fs::write( + root.join(".openidea/settings.toml"), + "[agents.mcpServers.shared]\ncommand = \"project-cmd\"\n\n[agents.mcpServers.extra]\ncommand = \"extra\"\n", + ) + .unwrap(); + let settings = ide_workspace::SettingsStore::load(&config, Some(&root)); + let approvals = crate::trust::McpServerApprovals::load(&tmp.path().join("data")); + let on = |servers: Vec| -> Vec<(McpScope, String, String)> { + servers + .into_iter() + .filter(|s| s.enabled) + .map(|s| (s.scope, s.name, s.command.unwrap_or_default())) + .collect() + }; + assert_eq!( + on(session_mcp_servers(&settings, &approvals, &root)), + [(McpScope::User, "shared".to_owned(), "user-cmd".to_owned())] + ); + + let extra = settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers["extra"].clone(); + approvals.approve(&root, "extra", &extra).unwrap(); + assert!(on(session_mcp_servers(&settings, &approvals, &root)).contains(&( + McpScope::Workspace, + "extra".to_owned(), + "extra".to_owned() + ))); + // Remembered across restarts, and only for this project. + let reloaded = crate::trust::McpServerApprovals::load(&tmp.path().join("data")); + assert!(reloaded.approved(&root, "extra", &extra)); + assert!(!reloaded.approved(&tmp.path().join("other"), "extra", &extra)); + } } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index fa89d0579..c57b0646d 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -670,8 +670,10 @@ impl Host { self.runtime.clone(), )); let settings = settings.clone(); + let approvals = self.mcp_approvals.clone(); + let root = root.to_path_buf(); agents.set_mcp_servers_hook(Box::new(move || { - ide_agent::mcp::servers_from_settings(&Default::default(), &settings.effective().agents.mcp_servers) + crate::ops::agent::session_mcp_servers(&settings, &approvals, &root) })); (db, http_history, agents) } diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 0017160e3..1bc4699d3 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -142,6 +142,61 @@ fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> { Ok(()) } +type McpServers = std::collections::BTreeMap; + +/// Workspace MCP servers (a project's `agents.mcpServers`) the user turned on in Settings, by a +/// fingerprint of the project and the server's definition. Agents start a workspace server only +/// while its definition is one the user turned on, so a server added or changed in the project's +/// file stays off until then. +pub struct McpServerApprovals { + file: PathBuf, + approved: Mutex>, +} + +impl McpServerApprovals { + pub fn load(data: &Path) -> Self { + let file = data.join("mcp-server-approvals.json"); + let approved = std::fs::read(&file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + Self { file, approved: Mutex::new(approved) } + } + + fn fingerprint(root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> String { + // Turning a server off and on again doesn't change what it runs. + let server = ide_core::settings::AgentMcpServer { enabled: true, ..server.clone() }; + let bytes = serde_json::to_vec(&(root, name, server)).expect("MCP server serializes"); + blake3::hash(&bytes).to_hex().to_string() + } + + pub fn approved(&self, root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> bool { + self.approved.lock().contains(&Self::fingerprint(root, name, server)) + } + + pub fn approve(&self, root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> IdeResult<()> { + let fingerprint = Self::fingerprint(root, name, server); + let mut approved = self.approved.lock(); + if approved.contains(&fingerprint) { + return Ok(()); + } + let mut next = approved.clone(); + next.insert(fingerprint); + if let Some(parent) = self.file.parent() { + std::fs::create_dir_all(parent)?; + } + let bytes = serde_json::to_vec_pretty(&next).map_err(|e| IdeError::internal(e.to_string()))?; + ide_vfs::write_atomic(&self.file, &bytes)?; + *approved = next; + Ok(()) + } + + /// `workspace` with the servers the user hasn't turned on switched off. + pub fn apply(&self, root: &Path, mut workspace: McpServers) -> McpServers { + for (name, server) in &mut workspace { + server.enabled &= self.approved(root, name, server); + } + workspace + } +} + impl crate::Host { pub async fn set_project_trust(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> { self.trust.decide(path, trusted, folder)?; diff --git a/crates/ide-host/tests/agent_mcp.rs b/crates/ide-host/tests/agent_mcp.rs new file mode 100644 index 000000000..30adfcda9 --- /dev/null +++ b/crates/ide-host/tests/agent_mcp.rs @@ -0,0 +1,77 @@ +//! External MCP servers in Settings › Agents, as the host stores and lists them. + +use ide_agent::{McpScope, McpServer, McpServerInput}; +use ide_core::NullSink; +use ide_core::settings::McpTransport; +use ide_host::ops::agent::{mcp_import, mcp_list, mcp_save, mcp_set_enabled}; +use ide_host::{Host, HostConfig}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +fn open(base: &Path) -> (Arc, PathBuf) { + let root = dunce::canonicalize(base).unwrap().join("project"); + std::fs::create_dir_all(root.join(".openidea")).unwrap(); + let host = Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + (host, root) +} + +fn enabled(list: &[McpServer], id: &str) -> bool { + list.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("{id} not listed: {list:?}")).enabled +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_servers_from_the_project_file_stay_off_until_the_user_turns_them_on() { + let tmp = tempfile::tempdir().unwrap(); + let (host, root) = open(tmp.path()); + let settings_file = root.join(".openidea/settings.toml"); + std::fs::write(&settings_file, "[agents.mcpServers.helper]\ncommand = \"node\"\nargs = [\"helper.js\"]\n").unwrap(); + host.project("main").unwrap().settings.reload(); + + assert!(!enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper")); + assert!(enabled(&mcp_set_enabled(&host, "main", "workspace::helper".into(), true).unwrap(), "workspace::helper")); + assert!(enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper")); + + // A changed definition is off again until turned back on. + std::fs::write(&settings_file, "[agents.mcpServers.helper]\ncommand = \"node\"\nargs = [\"other.js\"]\n").unwrap(); + host.project("main").unwrap().settings.reload(); + assert!(!enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper")); + + // What the user adds in Settings is on. + let saved = mcp_save( + &host, + "main", + McpServerInput { + id: None, + name: "mine".into(), + scope: McpScope::Workspace, + transport: McpTransport::Stdio, + enabled: true, + command: Some("uvx".into()), + args: vec!["mcp-server-git".into()], + env: vec![], + url: None, + headers: vec![], + agents: vec![], + }, + ) + .unwrap(); + assert!(enabled(&saved, "workspace::mine")); + + // Imported servers come from the project's own files: off until turned on. + std::fs::write(root.join(".mcp.json"), r#"{"mcpServers":{"imported":{"command":"uvx","args":["x"]}}}"#).unwrap(); + let imported = mcp_import(&host, "main").unwrap(); + assert!(!enabled(&imported, "workspace::imported")); + assert!(enabled(&imported, "workspace::mine")); +} diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index 531ac1c6f..7d1168328 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -27,12 +27,44 @@ pub enum SettingsScope { Project, } -fn remove_global_settings(layer: &mut Json) { - if let Some(layer) = layer.as_object_mut() { - layer.remove("mcpServer"); +/// Settings a project's own file can't hold: the IDE's MCP server, imported color schemes, and +/// what agents may do without asking (commands run without asking, the modes new tasks start in). +const USER_ONLY: [&[&str]; 4] = + [&["mcpServer"], &["editor", "customSchemes"], &["agentSecurity", "allowedCommands"], &["agents", "defaults"]]; + +/// The [`USER_ONLY`] setting that writing `value` at `key` would set, as a dotted key. +fn user_only_setting(key: &str, value: &Json) -> Option { + let segments: Vec<&str> = key.split('.').collect(); + USER_ONLY + .iter() + .find(|path| match path.split_at_checked(segments.len()) { + Some((head, rest)) => head == segments && rest.iter().try_fold(value, |v, seg| v.get(seg)).is_some(), + None => segments.starts_with(path), + }) + .map(|path| path.join(".")) +} + +/// Applies a project layer's limits before it merges onto `below`: [`USER_ONLY`] settings are +/// dropped, and its blocked agent commands add to the ones below instead of replacing them. +fn restrict_project_layer(below: &Json, layer: &mut Json) { + for path in USER_ONLY { + let (last, parents) = path.split_last().expect("non-empty path"); + if let Some(table) = parents.iter().try_fold(&mut *layer, |v, seg| v.get_mut(seg)).and_then(Json::as_object_mut) + { + table.remove(*last); + } } - if let Some(editor) = layer.get_mut("editor").and_then(Json::as_object_mut) { - editor.remove("customSchemes"); + if let (Some(inherited), Some(blocked)) = ( + below.pointer("/agentSecurity/blockedCommands").and_then(Json::as_array), + layer.pointer_mut("/agentSecurity/blockedCommands").and_then(Json::as_array_mut), + ) { + let mut all = inherited.clone(); + for pattern in blocked.drain(..) { + if !all.contains(&pattern) { + all.push(pattern); + } + } + *blocked = all; } } @@ -224,7 +256,7 @@ impl SettingsStore { for (scope, file) in self.layers() { let Some(mut layer) = read_layer(&file) else { continue }; if scope == SettingsScope::Project { - remove_global_settings(&mut layer); + restrict_project_layer(&merged, &mut layer); } if let Err(e) = try_merge(&mut merged, layer) { tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display()); @@ -254,7 +286,7 @@ impl SettingsStore { && let Some(mut layer) = read_layer(file) { if scope == SettingsScope::Project { - remove_global_settings(&mut layer); + restrict_project_layer(&merged, &mut layer); } if let Err(e) = try_merge(&mut merged, layer) { tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display()); @@ -268,14 +300,9 @@ impl SettingsStore { /// wrong type are rejected with `InvalidInput`; the file is left untouched. pub fn set(&self, scope: SettingsScope, key: &str, value: serde_json::Value) -> IdeResult { if scope == SettingsScope::Project - && (key == "editor.customSchemes" - || key.starts_with("editor.customSchemes.") - || (key == "editor" && value.get("customSchemes").is_some())) + && let Some(setting) = user_only_setting(key, &value) { - return Err(IdeError::invalid("custom color schemes belong to user settings")); - } - if scope == SettingsScope::Project && (key == "mcpServer" || key.starts_with("mcpServer.")) { - return Err(IdeError::invalid("MCP server belongs to user settings")); + return Err(IdeError::invalid(format!("{setting} belongs to user settings"))); } let file = self.file(scope).ok_or_else(|| IdeError::invalid("no project is open"))?.to_path_buf(); let segments: Vec<&str> = key.split('.').collect(); @@ -316,7 +343,7 @@ impl SettingsStore { let layer = if is_target { parse_layer(&new_text).ok() } else { read_layer(&layer_file) }; let Some(mut layer) = layer else { continue }; if layer_scope == SettingsScope::Project { - remove_global_settings(&mut layer); + restrict_project_layer(&merged, &mut layer); } if let Err(e) = try_merge(&mut merged, layer) && is_target @@ -1030,4 +1057,63 @@ mod tests { .enabled ); } + + #[test] + fn a_project_file_can_only_add_to_the_agent_policy() { + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("config"); + let project = tmp.path().join("project"); + std::fs::create_dir_all(project.join(".openidea")).unwrap(); + std::fs::create_dir_all(&config).unwrap(); + std::fs::write( + config.join("settings.toml"), + "[agentSecurity]\nallowedCommands = [\"git status\"]\nblockedCommands = [\"git push*\"]\n\n\ + [agents.defaults.claude]\nmodel = \"opus\"\n", + ) + .unwrap(); + let project_file = project.join(".openidea/settings.toml"); + std::fs::write( + &project_file, + "[agentSecurity]\nallowedCommands = [\"node\"]\nblockedCommands = [\"npm publish*\"]\n\n\ + [agents.defaults.claude]\nmode = \"bypassPermissions\"\n\n\ + [agents.defaults.codex]\nmode = \"full-access\"\n", + ) + .unwrap(); + let store = SettingsStore::load(&config, Some(&project)); + let s = store.effective(); + assert_eq!(s.agent_security.allowed_commands, ["git status"]); + assert_eq!(s.agent_security.blocked_commands, ["git push*", "npm publish*"]); + assert_eq!( + s.agents.defaults, + BTreeMap::from([("claude".to_owned(), BTreeMap::from([("model".to_owned(), "opus".to_owned())]))]) + ); + let layer = store.layer(SettingsScope::Project); + assert!(layer.agent_security.allowed_commands.is_empty()); + assert!(layer.agents.defaults.is_empty()); + + // An empty project list doesn't clear the user's, nor the built-in one. + std::fs::write(&project_file, "[agentSecurity]\nblockedCommands = []\n").unwrap(); + assert_eq!(store.reload().agent_security.blocked_commands, ["git push*"]); + std::fs::remove_file(config.join("settings.toml")).unwrap(); + assert_eq!( + store.reload().agent_security.blocked_commands, + ide_core::settings::AgentSecuritySettings::default().blocked_commands + ); + + for (key, value) in [ + ("agentSecurity.allowedCommands", json!(["node"])), + ("agentSecurity", json!({"allowedCommands": ["node"], "blockedCommands": []})), + ("agents.defaults", json!({"claude": {"mode": "bypassPermissions"}})), + ("agents.defaults.claude", json!({"mode": "bypassPermissions"})), + ("agents.defaults.claude.mode", json!("bypassPermissions")), + ("agents", json!({"defaults": {"codex": {"mode": "full-access"}}})), + ] { + assert!(store.set(SettingsScope::Project, key, value).is_err(), "{key}"); + } + let s = store.set(SettingsScope::Project, "agentSecurity.blockedCommands", json!(["curl*"])).unwrap(); + assert!(s.agent_security.blocked_commands.ends_with(&["curl*".to_owned()])); + assert!(s.agent_security.blocked_commands.contains(&"rm -rf /".to_owned())); + let s = store.set(SettingsScope::Project, "agents.showCost", json!(true)).unwrap(); + assert!(s.agents.show_cost); + } } diff --git a/docs/en/reference/settings.mdx b/docs/en/reference/settings.mdx index 86a00cc90..bd42c9abb 100644 --- a/docs/en/reference/settings.mdx +++ b/docs/en/reference/settings.mdx @@ -20,6 +20,8 @@ A file holds only the values that differ from the layer below. Tables merge key Some settings are ignored in the project file: `mcpServer` and imported color schemes (`editor.customSchemes`) are global only. Project trust is never read from settings. +A project file can't loosen what coding agents may do: `agentSecurity.allowedCommands` and `agents.defaults` are global only, and `agentSecurity.blockedCommands` in a project adds to the global list rather than replacing it. An MCP server in the project's `agents.mcpServers` stays off until you turn it on in **Settings › Agents › External MCP Servers**, and again after its definition changes. + ## File format The files are TOML. Keys use the camelCase names shown below; sections group them: diff --git a/docs/zh/reference/settings.mdx b/docs/zh/reference/settings.mdx index e605ca1c5..3105de81a 100644 --- a/docs/zh/reference/settings.mdx +++ b/docs/zh/reference/settings.mdx @@ -20,6 +20,8 @@ description: "OpenIDEA 的设置保存在哪里、全局文件与项目文件如 有些设置在项目文件中会被忽略:`mcpServer` 和导入的配色方案(`editor.customSchemes`)只能全局配置。项目信任状态永远不会从设置文件中读取。 +项目文件不能放宽编码智能体的权限:`agentSecurity.allowedCommands` 和 `agents.defaults` 只能全局配置;项目中的 `agentSecurity.blockedCommands` 会追加到全局列表中,而不是替换它。项目 `agents.mcpServers` 中的 MCP 服务器会保持关闭,直到你在 **设置 › 智能体 › 外部 MCP 服务器** 中将其开启;服务器定义改变后也需要重新开启。 + ## 文件格式 文件为 TOML 格式。键名使用下文列出的 camelCase 名称,并按节(section)分组: diff --git a/src/features/agents/McpServers.module.css b/src/features/agents/McpServers.module.css index 1ad3a7eec..837403618 100644 --- a/src/features/agents/McpServers.module.css +++ b/src/features/agents/McpServers.module.css @@ -149,6 +149,14 @@ font-size: var(--fs-small); } +.sectionNote { + margin: 0; + padding: 2px 24px 6px; + color: var(--fg-muted); + font-size: var(--fs-small); + line-height: 1.4; +} + .row { display: flex; align-items: center; diff --git a/src/features/agents/McpServersPage.tsx b/src/features/agents/McpServersPage.tsx index aea46d10f..f2bc31a4f 100644 --- a/src/features/agents/McpServersPage.tsx +++ b/src/features/agents/McpServersPage.tsx @@ -324,6 +324,14 @@ export function McpServersPage({ backend = defaultMcpBackend }: { backend?: McpB /> + {!isCollapsed && scope === "workspace" && ( +

+ + Servers added to the project's settings file outside OpenIDEA stay off until you turn them on here. + +

+ )} + {!isCollapsed && items.length > 0 && (
{items.map((server) => { diff --git a/src/features/agents/agentPolicy.test.tsx b/src/features/agents/agentPolicy.test.tsx index ef15f738a..5961467f4 100644 --- a/src/features/agents/agentPolicy.test.tsx +++ b/src/features/agents/agentPolicy.test.tsx @@ -41,7 +41,8 @@ describe("Command Execution Policy sub-page", () => { expect(agentsGroup).toBeDefined(); const policyNode = agentsGroup?.children.find((n) => n.key === POLICY_PAGE); expect(policyNode?.title).toBe("Command Execution Policy"); - expect(policyNode?.page?.projectScope).toBe(true); + // A project's settings file can't decide what agents run without asking. + expect(policyNode?.page?.projectScope).not.toBe(true); } finally { dispose(); } diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts index 2edc6d359..6a9abf113 100644 --- a/src/features/agents/index.ts +++ b/src/features/agents/index.ts @@ -63,7 +63,6 @@ export function activateSettingsPages(): () => void { group: AGENTS_GROUP, order: 490, keywords: ["agent", "policy", "security", "whitelist", "blacklist", "permission", "command", "exec"], - projectScope: true, component: lazyComponent(() => import("./AgentPolicyPage").then((m) => m.AgentPolicyPage)), }), ]; diff --git a/src/features/agents/mcpServers.test.tsx b/src/features/agents/mcpServers.test.tsx index 2988ec17c..3001360e9 100644 --- a/src/features/agents/mcpServers.test.tsx +++ b/src/features/agents/mcpServers.test.tsx @@ -96,6 +96,8 @@ describe("McpServersPage", () => { expect(screen.getByText("HTTP · https://example.com/mcp (claude)")).toBeInTheDocument(); expect(screen.getByText("azure")).toBeInTheDocument(); expect(screen.getByText("npx -y @azure/mcp@latest server start")).toBeInTheDocument(); + const workspace = screen.getByRole("region", { name: "Workspace" }); + expect(within(workspace).getByText(/stay off until you turn them on here/)).toBeInTheDocument(); }); it("filters servers across sections by search query", async () => { diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 99dad8311..cd9a08927 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1157,6 +1157,7 @@ "No new MCP servers in the project's .mcp.json or .vscode/mcp.json.": "No new MCP servers in the project's .mcp.json or .vscode/mcp.json.", "Imported 1 MCP server from the project.": "Imported 1 MCP server from the project.", "Imported {count} MCP servers from the project.": "Imported {count} MCP servers from the project.", + "Servers added to the project's settings file outside OpenIDEA stay off until you turn them on here.": "Servers added to the project's settings file outside OpenIDEA stay off until you turn them on here.", "Show branch and incoming/outgoing commits in the Project view": "Show branch and incoming/outgoing commits in the Project view", "Up to date with {upstream}": "Up to date with {upstream}", "Highlight occurrences of selected text": "Highlight occurrences of selected text", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 17fbd1272..1ffb8a7df 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -7536,6 +7536,7 @@ "No new MCP servers in the project's .mcp.json or .vscode/mcp.json.": "项目的 .mcp.json 和 .vscode/mcp.json 中没有新的 MCP 服务器。", "Imported 1 MCP server from the project.": "已从项目导入 1 个 MCP 服务器。", "Imported {count} MCP servers from the project.": "已从项目导入 {count} 个 MCP 服务器。", + "Servers added to the project's settings file outside OpenIDEA stay off until you turn them on here.": "在 OpenIDEA 之外添加到项目设置文件中的服务器会保持关闭,直到你在这里将其开启。", "Show branch and incoming/outgoing commits in the Project view": "在项目视图中显示分支及传入/传出提交", "Up to date with {upstream}": "已与 {upstream} 同步", "Highlight occurrences of selected text": "高亮所选文本的匹配项",