mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
fix(agents): keep agent permissions out of project settings (#3000)
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>
This commit is contained in:
co-authored by
rohitdanda
parent
104986ead4
commit
da412f14be
@@ -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<trust::McpServerApprovals>,
|
||||
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)),
|
||||
|
||||
@@ -381,7 +381,7 @@ fn persist_mcp_map(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_mcp_maps<F>(host: &Host, window: &str, f: F) -> IdeResult<Vec<McpServer>>
|
||||
fn update_mcp_maps<F>(host: &Host, window: &str, approve: Option<&str>, f: F) -> IdeResult<Vec<McpServer>>
|
||||
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<crate::Project>>,
|
||||
user: &McpServerMap,
|
||||
workspace: McpServerMap,
|
||||
) -> Vec<McpServer> {
|
||||
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<McpServer> {
|
||||
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<Vec<McpServer>> {
|
||||
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<Vec<McpServer>> {
|
||||
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<Vec<McpServer>> {
|
||||
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<Vec<McpServer>> {
|
||||
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<Vec<McpServer>> {
|
||||
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<McpServer>| -> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -142,6 +142,61 @@ fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type McpServers = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
|
||||
|
||||
/// 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<std::collections::BTreeSet<String>>,
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
@@ -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<Host>, 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"));
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<Settings> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -20,6 +20,8 @@ description: "OpenIDEA 的设置保存在哪里、全局文件与项目文件如
|
||||
|
||||
有些设置在项目文件中会被忽略:`mcpServer` 和导入的配色方案(`editor.customSchemes`)只能全局配置。项目信任状态永远不会从设置文件中读取。
|
||||
|
||||
项目文件不能放宽编码智能体的权限:`agentSecurity.allowedCommands` 和 `agents.defaults` 只能全局配置;项目中的 `agentSecurity.blockedCommands` 会追加到全局列表中,而不是替换它。项目 `agents.mcpServers` 中的 MCP 服务器会保持关闭,直到你在 **设置 › 智能体 › 外部 MCP 服务器** 中将其开启;服务器定义改变后也需要重新开启。
|
||||
|
||||
## 文件格式
|
||||
|
||||
文件为 TOML 格式。键名使用下文列出的 camelCase 名称,并按节(section)分组:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -324,6 +324,14 @@ export function McpServersPage({ backend = defaultMcpBackend }: { backend?: McpB
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && scope === "workspace" && (
|
||||
<p className={styles.sectionNote}>
|
||||
<T>
|
||||
Servers added to the project's settings file outside OpenIDEA stay off until you turn them on here.
|
||||
</T>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isCollapsed && items.length > 0 && (
|
||||
<div className={styles.sectionBody} role="list">
|
||||
{items.map((server) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "高亮所选文本的匹配项",
|
||||
|
||||
Reference in New Issue
Block a user