use collections::{HashMap, IndexMap}; use schemars::{JsonSchema, json_schema}; use serde::{Deserialize, Serialize}; use settings_macros::{MergeFrom, with_fallible_options}; use std::sync::Arc; use std::{ borrow::Cow, path::{Path, PathBuf}, }; use crate::ExtendingVec; use crate::DockPosition; /// Where to position the threads sidebar. #[derive( Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom, strum::VariantArray, strum::VariantNames, )] #[serde(rename_all = "snake_case")] pub enum SidebarDockPosition { /// Always show the sidebar on the left side. #[default] Left, /// Always show the sidebar on the right side. Right, } #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub enum SidebarSide { #[default] Left, Right, } /// How thinking blocks should be displayed by default in the agent panel. #[derive( Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom, strum::VariantArray, strum::VariantNames, )] #[serde(rename_all = "snake_case")] pub enum ThinkingBlockDisplay { /// Thinking blocks fully expand during streaming, then auto-collapse /// when the model finishes thinking. Users can re-expand after collapse. #[default] Auto, /// Thinking blocks auto-expand with a height constraint during streaming, /// then remain in their constrained state when complete. Users can click /// to fully expand or collapse. Preview, /// Thinking blocks are always fully expanded by default (no height constraint). AlwaysExpanded, /// Thinking blocks are always collapsed by default. AlwaysCollapsed, } /// Threshold at which agent auto-compaction runs. See /// [`AutoCompactSettingsContent::threshold`] for the accepted formats. /// /// The canonical textual form is stored verbatim so it can round-trip through /// the settings UI; it is serialized back as a JSON string for percentages and /// as a JSON integer for token counts. #[derive(Clone, Debug, PartialEq, Eq, MergeFrom)] pub struct AutoCompactThreshold(pub String); impl From for AutoCompactThreshold { fn from(value: String) -> Self { Self(value) } } impl From for String { fn from(value: AutoCompactThreshold) -> Self { value.0 } } impl AsRef for AutoCompactThreshold { fn as_ref(&self) -> &str { &self.0 } } impl Serialize for AutoCompactThreshold { fn serialize(&self, serializer: S) -> Result { if self.0.ends_with('%') { serializer.serialize_str(&self.0) } else if let Ok(tokens) = self.0.parse::() { serializer.serialize_i64(tokens) } else { serializer.serialize_str(&self.0) } } } impl<'de> Deserialize<'de> for AutoCompactThreshold { fn deserialize>(deserializer: D) -> Result { struct ThresholdVisitor; impl serde::de::Visitor<'_> for ThresholdVisitor { type Value = AutoCompactThreshold; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter .write_str("a percentage string like \"90%\" or an integer number of tokens") } fn visit_str(self, value: &str) -> Result { Ok(AutoCompactThreshold(value.to_owned())) } fn visit_i64(self, value: i64) -> Result { Ok(AutoCompactThreshold(value.to_string())) } fn visit_u64(self, value: u64) -> Result { Ok(AutoCompactThreshold(value.to_string())) } fn visit_f64(self, value: f64) -> Result { Ok(AutoCompactThreshold(value.to_string())) } } deserializer.deserialize_any(ThresholdVisitor) } } impl JsonSchema for AutoCompactThreshold { fn schema_name() -> Cow<'static, str> { "AutoCompactThreshold".into() } fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { json_schema!({ "oneOf": [ { "type": "string", "pattern": "^\\d+(\\.\\d+)?%$" }, { "type": "integer" } ] }) } } #[with_fallible_options] #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] pub struct AutoCompactSettingsContent { /// Whether to automatically compact the agent's context when it grows too /// large, summarizing earlier messages to free up room in the model's /// context window. /// /// Default: true pub enabled: Option, /// The threshold at which auto-compaction runs. This is one of: /// /// - A percentage string ending in `%`, e.g. `"90%"`, measured against the /// model's context window. `"90%"` compacts once the context is 90% full. /// - A positive integer: compaction runs once that many tokens have been /// used. For example, `100000` compacts after 100,000 tokens are used. /// - A negative integer: compaction runs once that many tokens remain in /// the context window. For example, `-20000` compacts once there are /// fewer than 20,000 tokens of headroom left in the context window. /// /// `0` is not a valid threshold. /// /// Default: "90%" pub threshold: Option, } #[with_fallible_options] #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] pub struct AgentSettingsContent { /// Whether the Agent is enabled. /// /// Default: true pub enabled: Option, /// Whether to show the agent panel button in the status bar. /// /// Default: true pub button: Option, /// Where to dock the agent panel. /// /// Default: left (Agentic layout), right (Classic layout) pub dock: Option, /// Whether the agent panel should use flexible (proportional) sizing. /// /// Default: true pub flexible: Option, /// Where to position the threads sidebar. /// /// Default: left pub sidebar_side: Option, /// Default width in pixels when the agent panel is docked to the left or right. /// /// Default: 640 #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub default_width: Option, /// Default height in pixels when the agent panel is docked to the bottom. /// /// Default: 320 #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub default_height: Option, /// Whether to limit the content width in the agent panel. When enabled, /// content will be constrained to `max_content_width` and centered when /// the panel is wider than that value, for optimal readability. /// /// Default: true pub limit_content_width: Option, /// Maximum content width in pixels for the agent panel. Content will be /// centered when the panel is wider than this value. /// /// Default: 850 #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub max_content_width: Option, /// The default model to use when creating new chats and for other features when a specific model is not specified. pub default_model: Option, /// The model to use for subagents spawned via the `spawn_agent` tool. Defaults to the parent agent's model when not specified. pub subagent_model: Option, /// Favorite models to show at the top of the model selector. #[serde(default)] pub favorite_models: Vec, /// Model to use for the inline assistant. Defaults to default_model when not specified. pub inline_assistant_model: Option, /// Model to use for the inline assistant when streaming tools are enabled. /// /// Default: true pub inline_assistant_use_streaming_tools: Option, /// Model to use for generating git commit messages. Defaults to default_model when not specified. pub commit_message_model: Option, /// Whether to include project rules files (AGENTS.md, CLAUDE.md, .rules, etc.) /// in the prompt when generating git commit messages. /// /// Default: true pub commit_message_include_project_rules: Option, /// Custom instructions to include in the prompt when generating git commit messages. /// Applied in addition to any project rules files (such as `.rules` or `AGENTS.md`). pub commit_message_instructions: Option, /// Model to use for generating thread summaries. Defaults to default_model when not specified. pub thread_summary_model: Option, /// Model to use for context compaction (`/compact` and auto-compaction). /// Falls back to the thread's currently selected model when not specified. /// If the configured model is unavailable (provider not registered, model /// not found), the thread's current model is used instead. pub compaction_model: Option, /// Additional models with which to generate alternatives when performing inline assists. pub inline_alternatives: Option>, /// The default profile to use in the Agent. /// /// Default: write pub default_profile: Option>, /// The available agent profiles. pub profiles: Option, AgentProfileContent>>, /// Where to show a popup notification when the agent is waiting for user input. /// /// Default: "primary_screen" pub notify_when_agent_waiting: Option, /// When to play a sound when the agent has either completed its response, or needs user input. /// /// Default: never pub play_sound_when_agent_done: Option, /// Whether to display agent edits in single-file editors in addition to the review multibuffer pane. /// /// Default: false pub single_file_review: Option, /// Additional parameters for language model requests. When making a request /// to a model, parameters will be taken from the last entry in this list /// that matches the model's provider and name. In each entry, both provider /// and model are optional, so that you can specify parameters for either /// one. /// /// Default: [] #[serde(default)] pub model_parameters: Vec, /// Settings for automatic agent context compaction, which summarizes /// earlier messages to free up room in the model's context window once the /// context grows too large. pub auto_compact: Option, /// Whether to show thumb buttons for feedback in the agent panel. /// /// Default: true pub enable_feedback: Option, /// Whether to have edit cards in the agent panel expanded, showing a preview of the full diff. /// /// Default: true pub expand_edit_card: Option, /// Whether to have terminal cards in the agent panel expanded, showing the whole command output. /// /// Default: true pub expand_terminal_card: Option, /// Command to automatically run when Zed creates a Terminal Thread shell in the agent panel. /// The command is sent to the shell as if typed, so it is interpreted by your /// configured shell (including on Windows and remote/WSL projects). /// An empty string disables this behavior. /// /// Default: "" pub terminal_init_command: Option, /// How thinking blocks should be displayed by default in the agent panel. /// /// Default: automatic pub thinking_display: Option, /// Whether clicking the stop button on a running terminal tool should also cancel the agent's generation. /// Note that this only applies to the stop button, not to ctrl+c inside the terminal. /// /// Default: true pub cancel_generation_on_terminal_stop: Option, /// Whether to always use cmd-enter (or ctrl-enter on Linux or Windows) to send messages in the agent panel. /// /// Default: false pub use_modifier_to_send: Option, /// Minimum number of lines of height the agent message editor should have. /// /// Default: 4 pub message_editor_min_lines: Option, /// Whether to show turn statistics (elapsed time during generation, final turn duration). /// /// Default: false pub show_turn_stats: Option, /// Whether to show the merge conflict indicator in the status bar /// that offers to resolve conflicts using the agent. /// /// Default: true pub show_merge_conflict_indicator: Option, /// Per-tool permission rules for granular control over which tool actions /// require confirmation. /// /// The global `default` applies when no tool-specific rules match. /// For external agent servers (e.g. Claude Agent) that define their own /// permission modes, "deny" and "confirm" still take precedence — the /// external agent's permission system is only used when Zed would allow /// the action. Per-tool regex patterns (`always_allow`, `always_deny`, /// `always_confirm`) match against the tool's text input (command, path, /// URL, etc.). pub tool_permissions: Option, /// Persistent sandbox permission grants for agent-run terminal commands. /// These are populated when choosing "Allow always" from a sandbox /// escalation prompt. pub sandbox_permissions: Option, } impl AgentSettingsContent { pub fn set_dock(&mut self, dock: DockPosition) { self.dock = Some(dock); } pub fn set_sidebar_side(&mut self, position: SidebarDockPosition) { self.sidebar_side = Some(position); } pub fn set_flexible_size(&mut self, flexible: bool) { self.flexible = Some(flexible); } pub fn set_model(&mut self, language_model: LanguageModelSelection) { self.default_model = Some(language_model) } pub fn set_inline_assistant_model(&mut self, provider: String, model: String) { self.inline_assistant_model = Some(LanguageModelSelection { provider: provider.into(), model, enable_thinking: false, effort: None, speed: None, }); } pub fn set_profile(&mut self, profile_id: Arc) { self.default_profile = Some(profile_id); } pub fn add_favorite_model(&mut self, model: LanguageModelSelection) { // Note: this is intentional to not compare using `PartialEq`here. // Full equality would treat entries that differ just in thinking/effort/speed // as distinct and silently produce duplicates. if !self .favorite_models .iter() .any(|m| m.provider == model.provider && m.model == model.model) { self.favorite_models.push(model); } } pub fn remove_favorite_model(&mut self, model: &LanguageModelSelection) { self.favorite_models .retain(|m| !(m.provider == model.provider && m.model == model.model)); } pub fn update_favorite_model(&mut self, provider: &str, model: &str, f: F) where F: FnOnce(&mut LanguageModelSelection), { if let Some(entry) = self .favorite_models .iter_mut() .find(|m| m.provider.0 == provider && m.model == model) { f(entry); } } pub fn set_tool_default_permission(&mut self, tool_id: &str, mode: ToolPermissionMode) { let tool_permissions = self.tool_permissions.get_or_insert_default(); let tool_rules = tool_permissions .tools .entry(Arc::from(tool_id)) .or_default(); tool_rules.default = Some(mode); } pub fn add_tool_allow_pattern(&mut self, tool_name: &str, pattern: String) { let tool_permissions = self.tool_permissions.get_or_insert_default(); let tool_rules = tool_permissions .tools .entry(Arc::from(tool_name)) .or_default(); let always_allow = tool_rules.always_allow.get_or_insert_default(); if !always_allow.0.iter().any(|r| r.pattern == pattern) { always_allow.0.push(ToolRegexRule { pattern, case_sensitive: None, }); } } pub fn add_tool_deny_pattern(&mut self, tool_name: &str, pattern: String) { let tool_permissions = self.tool_permissions.get_or_insert_default(); let tool_rules = tool_permissions .tools .entry(Arc::from(tool_name)) .or_default(); let always_deny = tool_rules.always_deny.get_or_insert_default(); if !always_deny.0.iter().any(|r| r.pattern == pattern) { always_deny.0.push(ToolRegexRule { pattern, case_sensitive: None, }); } } pub fn allow_sandbox_all_hosts(&mut self) { self.sandbox_permissions .get_or_insert_default() .allow_all_hosts = Some(true); } /// The persisted sandbox network host patterns, as written (callers own /// parsing/validation). pub fn sandbox_network_hosts(&self) -> &[String] { self.sandbox_permissions .as_ref() .and_then(|permissions| permissions.network_hosts.as_ref()) .map(|hosts| hosts.0.as_slice()) .unwrap_or_default() } /// Replace the persisted sandbox network host patterns. Callers compute /// the new list (typically the old list plus newly granted hosts, pruned /// of entries subsumed by wildcards) rather than appending blindly. pub fn set_sandbox_network_hosts(&mut self, hosts: Vec) { self.sandbox_permissions .get_or_insert_default() .network_hosts = Some(ExtendingVec(hosts)); } pub fn allow_sandbox_fs_write_all(&mut self) { self.sandbox_permissions .get_or_insert_default() .allow_fs_write_all = Some(true); } pub fn allow_sandbox_unsandboxed(&mut self) { self.sandbox_permissions .get_or_insert_default() .allow_unsandboxed = Some(true); } pub fn add_sandbox_write_path(&mut self, granted: GrantedWritePathContent) { let write_paths = &mut self .sandbox_permissions .get_or_insert_default() .write_paths .get_or_insert_default() .0; // Mirror `util::paths::insert_subtree`, keeping the grant set minimal, // but compare by each entry's canonical (resolved) grant path. let canonical = granted.canonical_or_requested().to_path_buf(); if write_paths .iter() .any(|existing| canonical.starts_with(existing.canonical_or_requested())) { return; } write_paths.retain(|existing| !existing.canonical_or_requested().starts_with(&canonical)); write_paths.push(granted); } } #[with_fallible_options] #[derive(Debug, PartialEq, Clone, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct AgentProfileContent { pub name: Arc, #[serde(default)] pub tools: IndexMap, bool>, /// Whether all context servers are enabled by default. pub enable_all_context_servers: Option, #[serde(default)] pub context_servers: IndexMap, ContextServerPresetContent>, /// The default language model selected when using this profile. pub default_model: Option, } #[with_fallible_options] #[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct ContextServerPresetContent { pub tools: IndexMap, bool>, } #[derive( Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, strum::VariantArray, strum::VariantNames, )] #[serde(rename_all = "snake_case")] pub enum NotifyWhenAgentWaiting { #[default] PrimaryScreen, AllScreens, Never, } #[derive( Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, strum::VariantArray, strum::VariantNames, )] #[serde(rename_all = "snake_case")] pub enum PlaySoundWhenAgentDone { #[default] Never, WhenHidden, Always, } impl PlaySoundWhenAgentDone { pub fn should_play(&self, visible: bool) -> bool { match self { PlaySoundWhenAgentDone::Never => false, PlaySoundWhenAgentDone::WhenHidden => !visible, PlaySoundWhenAgentDone::Always => true, } } } #[with_fallible_options] #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] pub struct LanguageModelSelection { pub provider: LanguageModelProviderSetting, pub model: String, #[serde(default)] pub enable_thinking: bool, pub effort: Option, pub speed: Option, } #[with_fallible_options] #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] pub struct LanguageModelParameters { pub provider: Option, pub model: Option, #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub temperature: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, MergeFrom)] pub struct LanguageModelProviderSetting(pub String); impl JsonSchema for LanguageModelProviderSetting { fn schema_name() -> Cow<'static, str> { "LanguageModelProviderSetting".into() } fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { // list the builtin providers as a subset so that we still auto complete them in the settings json_schema!({ "anyOf": [ { "type": "string", "enum": [ "amazon-bedrock", "anthropic", "copilot_chat", "deepseek", "google", "lmstudio", "mistral", "ollama", "openai", "opencode", "openrouter", "vercel_ai_gateway", "x_ai", "zed.dev" ] }, { "type": "string", } ] }) } } impl From for LanguageModelProviderSetting { fn from(provider: String) -> Self { Self(provider) } } impl From<&str> for LanguageModelProviderSetting { fn from(provider: &str) -> Self { Self(provider.to_string()) } } #[with_fallible_options] #[derive(Default, PartialEq, Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug)] #[serde(transparent)] pub struct AllAgentServersSettings(pub HashMap); impl std::ops::Deref for AllAgentServersSettings { type Target = HashMap; fn deref(&self) -> &Self::Target { &self.0 } } impl std::ops::DerefMut for AllAgentServersSettings { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } #[derive(Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq, Eq)] #[serde(untagged)] pub enum AgentConfigOptionValue { ValueId(String), Boolean(bool), } impl AgentConfigOptionValue { pub fn as_value_id(&self) -> Option<&str> { match self { Self::ValueId(value) => Some(value), Self::Boolean(_) => None, } } pub fn as_bool(&self) -> Option { match self { Self::Boolean(value) => Some(*value), Self::ValueId(_) => None, } } } impl std::fmt::Display for AgentConfigOptionValue { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::ValueId(value) => formatter.write_str(value), Self::Boolean(value) => value.fmt(formatter), } } } impl From for AgentConfigOptionValue { fn from(value: String) -> Self { Self::ValueId(value) } } impl From<&str> for AgentConfigOptionValue { fn from(value: &str) -> Self { Self::ValueId(value.to_string()) } } impl From for AgentConfigOptionValue { fn from(value: bool) -> Self { Self::Boolean(value) } } #[with_fallible_options] #[derive(Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum CustomAgentServerSettings { Custom { #[serde(rename = "command")] path: PathBuf, #[serde(default, skip_serializing_if = "Vec::is_empty")] args: Vec, /// Default: {} #[serde(default, skip_serializing_if = "HashMap::is_empty")] env: HashMap, /// The default mode to use for this agent. /// /// Note: Not only all agents support modes. /// /// Default: None default_mode: Option, /// Default values for session config options. /// /// This is a map from config option ID to the default value for that option. /// /// Default: {} #[serde(default, skip_serializing_if = "HashMap::is_empty")] default_config_options: HashMap, /// Favorited values for session config options. /// /// This is a map from config option ID to a list of favorited value IDs. /// /// Default: {} #[serde(default, skip_serializing_if = "HashMap::is_empty")] favorite_config_option_values: HashMap>, }, // Used for the ACP extension migration #[serde(alias = "extension")] Registry { /// Additional environment variables to pass to the agent. /// /// Default: {} #[serde(default, skip_serializing_if = "HashMap::is_empty")] env: HashMap, /// The default mode to use for this agent. /// /// Note: Not only all agents support modes. /// /// Default: None default_mode: Option, /// Default values for session config options. /// /// This is a map from config option ID to the default value for that option. /// /// Default: {} #[serde(default, skip_serializing_if = "HashMap::is_empty")] default_config_options: HashMap, /// Favorited values for session config options. /// /// This is a map from config option ID to a list of favorited value IDs. /// /// Default: {} #[serde(default, skip_serializing_if = "HashMap::is_empty")] favorite_config_option_values: HashMap>, }, } /// A persisted sandbox writable-path grant. Deserializes from either a bare /// path string (`"/tmp/x"`, a legacy/hand-authored entry with no resolved /// target) or an object (`{ "requested": "/tmp/x", "resolved": "/tmp/real" }`). /// Serializes back as a bare string when `resolved` is `None` and as an object /// otherwise, so hand-authored bare strings round-trip and Zed-written grants /// are objects. #[derive(Clone, Debug, Default, PartialEq, MergeFrom)] pub struct GrantedWritePathContent { /// The path exactly as the user/model requested it. pub requested: PathBuf, /// The canonical, symlink-resolved target established when the grant was /// approved. Absent for a bare-string entry. pub resolved: Option, /// Windows/WSL only: whether the canonical target lives on a Windows-hosted /// (DrvFs) filesystem, whose sandbox-integrity guarantees are weaker. Absent /// (false) on other platforms and for bare-string entries. pub on_windows_fs: bool, } impl GrantedWritePathContent { /// The path used for lexical subtree/coverage/dedup logic: the resolved /// canonical target when known, otherwise the requested path. fn canonical_or_requested(&self) -> &Path { self.resolved.as_deref().unwrap_or(&self.requested) } } impl Serialize for GrantedWritePathContent { fn serialize(&self, serializer: S) -> Result { match &self.resolved { None => self.requested.serialize(serializer), Some(resolved) => { use serde::ser::SerializeStruct as _; let field_count = if self.on_windows_fs { 3 } else { 2 }; let mut state = serializer.serialize_struct("GrantedWritePathContent", field_count)?; state.serialize_field("requested", &self.requested)?; state.serialize_field("resolved", resolved)?; if self.on_windows_fs { state.serialize_field("on_windows_fs", &self.on_windows_fs)?; } state.end() } } } } impl<'de> Deserialize<'de> for GrantedWritePathContent { fn deserialize>(deserializer: D) -> Result { #[derive(Deserialize)] struct Object { requested: PathBuf, #[serde(default)] resolved: Option, #[serde(default)] on_windows_fs: bool, } #[derive(Deserialize)] #[serde(untagged)] enum StringOrObject { String(PathBuf), Object(Object), } Ok(match StringOrObject::deserialize(deserializer)? { StringOrObject::String(requested) => Self { requested, resolved: None, on_windows_fs: false, }, StringOrObject::Object(Object { requested, resolved, on_windows_fs, }) => Self { requested, resolved, on_windows_fs, }, }) } } impl JsonSchema for GrantedWritePathContent { fn schema_name() -> Cow<'static, str> { "GrantedWritePathContent".into() } fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { json_schema!({ "oneOf": [ { "type": "string" }, { "type": "object", "properties": { "requested": { "type": "string" }, "resolved": { "type": ["string", "null"] }, "on_windows_fs": { "type": "boolean" } }, "required": ["requested"] } ] }) } } #[with_fallible_options] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct SandboxPermissionsContent { /// Whether sandboxed terminal commands may always reach any host over the /// network without prompting. /// Default: false pub allow_all_hosts: Option, /// Hosts that sandboxed terminal commands may always reach over the /// network without prompting. Each entry is an exact hostname /// (`github.com`) or a leading-`*.` subdomain wildcard (`*.npmjs.org`). /// Default: [] pub network_hosts: Option>, /// Whether sandboxed terminal commands may always write anywhere on the /// filesystem without prompting. /// Default: false pub allow_fs_write_all: Option, /// Whether to persistently run agent terminal commands outside the OS /// sandbox. This is the model-facing "off switch": when true, the sandboxed /// terminal tool is not exposed and the system prompt omits the sandbox /// section, so the model uses the plain `terminal` tool. On Windows, WSL /// sandbox setup is skipped. Distinct from the model-requested /// `unsandboxed: true` escape approved "once" or "for this thread". /// Default: false pub allow_unsandboxed: Option, /// Directory subtrees that sandboxed terminal commands may always write /// to without prompting. Each entry is either a bare path string or an /// object `{requested, resolved}`; Zed writes objects (the canonical, /// symlink-resolved target established at approval time), while /// hand-authored entries may be bare path strings. Paths written by Zed /// are absolute. /// Default: [] pub write_paths: Option>, /// Whether to warn when a sandbox escalation prompt requests a domain or /// write path that contains potentially confusable Unicode characters /// (homoglyphs, invisible characters, or bidirectional overrides). When /// enabled, such prompts show a warning that must be acknowledged before /// the request can be allowed. /// Default: true pub warn_confusable_unicode: Option, /// Whether to warn (Windows/WSL only) when a sandbox grant targets a file on /// a Windows-hosted (DrvFs) filesystem. Such grants are enforced inside WSL /// via a translated path, and their sandbox-integrity guarantees are weaker /// than a distro-native filesystem. When enabled, such grants show a warning /// that must be acknowledged before the command runs. /// Default: true pub warn_ntfs_grants: Option, } #[with_fallible_options] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct ToolPermissionsContent { /// Global default permission when no tool-specific rules match. /// Individual tools can override this with their own default. /// Default: confirm #[serde(alias = "default_mode")] pub default: Option, /// Per-tool permission rules. /// Keys are tool names (e.g. terminal, edit_file, fetch) including MCP /// tools (e.g. mcp:server_name:tool_name). Any tool name is accepted; /// even tools without meaningful text input can have a `default` set. #[serde(default)] pub tools: HashMap, ToolRulesContent>, } #[with_fallible_options] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct ToolRulesContent { /// Default mode when no regex rules match. /// When unset, inherits from the global `tool_permissions.default`. #[serde(alias = "default_mode")] pub default: Option, /// Regexes for inputs to auto-approve. /// For terminal: matches command. For file tools: matches path. For fetch: matches URL. /// For `copy_path` and `move_path`, patterns are matched independently against each /// path (source and destination). /// Patterns accumulate across settings layers (user, project, profile) and cannot be /// removed by a higher-priority layer—only new patterns can be added. /// Default: [] pub always_allow: Option>, /// Regexes for inputs to auto-reject. /// **SECURITY**: These take precedence over ALL other rules, across ALL settings layers. /// For `copy_path` and `move_path`, patterns are matched independently against each /// path (source and destination). /// Patterns accumulate across settings layers (user, project, profile) and cannot be /// removed by a higher-priority layer—only new patterns can be added. /// Default: [] pub always_deny: Option>, /// Regexes for inputs that must always prompt. /// Takes precedence over always_allow but not always_deny. /// For `copy_path` and `move_path`, patterns are matched independently against each /// path (source and destination). /// Patterns accumulate across settings layers (user, project, profile) and cannot be /// removed by a higher-priority layer—only new patterns can be added. /// Default: [] pub always_confirm: Option>, } #[with_fallible_options] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct ToolRegexRule { /// The regex pattern to match. #[serde(default)] pub pattern: String, /// Whether the regex is case-sensitive. /// Default: false (case-insensitive) pub case_sensitive: Option, } #[derive( Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom, )] #[serde(rename_all = "snake_case")] pub enum ToolPermissionMode { /// Auto-approve without prompting. Allow, /// Auto-reject with an error. Deny, /// Always prompt for confirmation (default behavior). #[default] Confirm, } impl std::fmt::Display for ToolPermissionMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ToolPermissionMode::Allow => write!(f, "Allow"), ToolPermissionMode::Deny => write!(f, "Deny"), ToolPermissionMode::Confirm => write!(f, "Confirm"), } } } #[cfg(test)] mod tests { use super::*; #[test] fn agent_config_option_value_serializes_value_id_as_string() { let value = AgentConfigOptionValue::from("manual"); assert_eq!( serde_json::to_value(&value).expect("serialize value id"), serde_json::json!("manual") ); assert_eq!( serde_json::from_value::(serde_json::json!("manual")) .expect("deserialize value id"), AgentConfigOptionValue::ValueId("manual".to_string()) ); } #[test] fn agent_config_option_value_serializes_boolean_as_boolean() { let value = AgentConfigOptionValue::Boolean(true); assert_eq!( serde_json::to_value(&value).expect("serialize boolean"), serde_json::json!(true) ); assert_eq!( serde_json::from_value::(serde_json::json!(true)) .expect("deserialize boolean"), AgentConfigOptionValue::Boolean(true) ); } #[test] fn agent_config_option_value_merge_replaces_existing_value() { use crate::merge_from::MergeFrom as _; let mut value = AgentConfigOptionValue::ValueId("manual".to_string()); value.merge_from(&AgentConfigOptionValue::Boolean(true)); assert_eq!(value, AgentConfigOptionValue::Boolean(true)); } #[test] fn test_set_tool_default_permission_creates_structure() { let mut settings = AgentSettingsContent::default(); assert!(settings.tool_permissions.is_none()); settings.set_tool_default_permission("terminal", ToolPermissionMode::Allow); let tool_permissions = settings.tool_permissions.as_ref().unwrap(); let terminal_rules = tool_permissions.tools.get("terminal").unwrap(); assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow)); } #[test] fn test_set_tool_default_permission_updates_existing() { let mut settings = AgentSettingsContent::default(); settings.set_tool_default_permission("terminal", ToolPermissionMode::Confirm); settings.set_tool_default_permission("terminal", ToolPermissionMode::Allow); let tool_permissions = settings.tool_permissions.as_ref().unwrap(); let terminal_rules = tool_permissions.tools.get("terminal").unwrap(); assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow)); } #[test] fn test_set_tool_default_permission_for_mcp_tool() { let mut settings = AgentSettingsContent::default(); settings.set_tool_default_permission("mcp:github:create_issue", ToolPermissionMode::Allow); let tool_permissions = settings.tool_permissions.as_ref().unwrap(); let mcp_rules = tool_permissions .tools .get("mcp:github:create_issue") .unwrap(); assert_eq!(mcp_rules.default, Some(ToolPermissionMode::Allow)); } #[test] fn test_add_tool_allow_pattern_creates_structure() { let mut settings = AgentSettingsContent::default(); assert!(settings.tool_permissions.is_none()); settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string()); let tool_permissions = settings.tool_permissions.as_ref().unwrap(); let terminal_rules = tool_permissions.tools.get("terminal").unwrap(); let always_allow = terminal_rules.always_allow.as_ref().unwrap(); assert_eq!(always_allow.0.len(), 1); assert_eq!(always_allow.0[0].pattern, "^cargo\\s"); } #[test] fn test_add_tool_allow_pattern_appends_to_existing() { let mut settings = AgentSettingsContent::default(); settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string()); settings.add_tool_allow_pattern("terminal", "^npm\\s".to_string()); let tool_permissions = settings.tool_permissions.as_ref().unwrap(); let terminal_rules = tool_permissions.tools.get("terminal").unwrap(); let always_allow = terminal_rules.always_allow.as_ref().unwrap(); assert_eq!(always_allow.0.len(), 2); assert_eq!(always_allow.0[0].pattern, "^cargo\\s"); assert_eq!(always_allow.0[1].pattern, "^npm\\s"); } #[test] fn test_add_tool_allow_pattern_does_not_duplicate() { let mut settings = AgentSettingsContent::default(); settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string()); settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string()); settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string()); let tool_permissions = settings.tool_permissions.as_ref().unwrap(); let terminal_rules = tool_permissions.tools.get("terminal").unwrap(); let always_allow = terminal_rules.always_allow.as_ref().unwrap(); assert_eq!( always_allow.0.len(), 1, "Duplicate patterns should not be added" ); } #[test] fn test_add_tool_allow_pattern_for_different_tools() { let mut settings = AgentSettingsContent::default(); settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string()); settings.add_tool_allow_pattern("fetch", "^https?://github\\.com".to_string()); let tool_permissions = settings.tool_permissions.as_ref().unwrap(); let terminal_rules = tool_permissions.tools.get("terminal").unwrap(); assert_eq!( terminal_rules.always_allow.as_ref().unwrap().0[0].pattern, "^cargo\\s" ); let fetch_rules = tool_permissions.tools.get("fetch").unwrap(); assert_eq!( fetch_rules.always_allow.as_ref().unwrap().0[0].pattern, "^https?://github\\.com" ); } #[test] fn test_add_tool_deny_pattern_creates_structure() { let mut settings = AgentSettingsContent::default(); assert!(settings.tool_permissions.is_none()); settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string()); let tool_permissions = settings.tool_permissions.as_ref().unwrap(); let terminal_rules = tool_permissions.tools.get("terminal").unwrap(); let always_deny = terminal_rules.always_deny.as_ref().unwrap(); assert_eq!(always_deny.0.len(), 1); assert_eq!(always_deny.0[0].pattern, "^rm\\s"); } #[test] fn test_add_tool_deny_pattern_appends_to_existing() { let mut settings = AgentSettingsContent::default(); settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string()); settings.add_tool_deny_pattern("terminal", "^sudo\\s".to_string()); let tool_permissions = settings.tool_permissions.as_ref().unwrap(); let terminal_rules = tool_permissions.tools.get("terminal").unwrap(); let always_deny = terminal_rules.always_deny.as_ref().unwrap(); assert_eq!(always_deny.0.len(), 2); assert_eq!(always_deny.0[0].pattern, "^rm\\s"); assert_eq!(always_deny.0[1].pattern, "^sudo\\s"); } #[test] fn test_add_tool_deny_pattern_does_not_duplicate() { let mut settings = AgentSettingsContent::default(); settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string()); settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string()); settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string()); let tool_permissions = settings.tool_permissions.as_ref().unwrap(); let terminal_rules = tool_permissions.tools.get("terminal").unwrap(); let always_deny = terminal_rules.always_deny.as_ref().unwrap(); assert_eq!( always_deny.0.len(), 1, "Duplicate patterns should not be added" ); } #[test] fn test_add_tool_deny_and_allow_patterns_separate() { let mut settings = AgentSettingsContent::default(); settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string()); settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string()); let tool_permissions = settings.tool_permissions.as_ref().unwrap(); let terminal_rules = tool_permissions.tools.get("terminal").unwrap(); let always_allow = terminal_rules.always_allow.as_ref().unwrap(); assert_eq!(always_allow.0.len(), 1); assert_eq!(always_allow.0[0].pattern, "^cargo\\s"); let always_deny = terminal_rules.always_deny.as_ref().unwrap(); assert_eq!(always_deny.0.len(), 1); assert_eq!(always_deny.0[0].pattern, "^rm\\s"); } #[test] fn test_allow_sandbox_permissions_create_structure() { let mut settings = AgentSettingsContent::default(); assert!(settings.sandbox_permissions.is_none()); settings.allow_sandbox_all_hosts(); assert_eq!(settings.sandbox_network_hosts(), &[] as &[String]); settings .set_sandbox_network_hosts(vec!["github.com".to_string(), "*.npmjs.org".to_string()]); assert_eq!( settings.sandbox_network_hosts(), &["github.com".to_string(), "*.npmjs.org".to_string()] ); settings.allow_sandbox_fs_write_all(); settings.allow_sandbox_unsandboxed(); settings.add_sandbox_write_path(GrantedWritePathContent { requested: PathBuf::from("/tmp/build"), resolved: None, on_windows_fs: false, }); let sandbox_permissions = settings.sandbox_permissions.as_ref().unwrap(); assert_eq!(sandbox_permissions.allow_all_hosts, Some(true)); assert_eq!( sandbox_permissions .network_hosts .as_ref() .unwrap() .0 .as_slice(), &["github.com".to_string(), "*.npmjs.org".to_string()] ); assert_eq!(sandbox_permissions.allow_fs_write_all, Some(true)); assert_eq!(sandbox_permissions.allow_unsandboxed, Some(true)); assert_eq!( sandbox_permissions .write_paths .as_ref() .unwrap() .0 .as_slice(), &[GrantedWritePathContent { requested: PathBuf::from("/tmp/build"), resolved: None, on_windows_fs: false, }] ); } #[test] fn test_add_sandbox_write_path_prunes_redundant_paths() { let mut settings = AgentSettingsContent::default(); settings.add_sandbox_write_path(GrantedWritePathContent { requested: PathBuf::from("/tmp/build/cache"), resolved: None, on_windows_fs: false, }); settings.add_sandbox_write_path(GrantedWritePathContent { requested: PathBuf::from("/tmp/build"), resolved: None, on_windows_fs: false, }); settings.add_sandbox_write_path(GrantedWritePathContent { requested: PathBuf::from("/tmp/build/output"), resolved: None, on_windows_fs: false, }); let write_paths = settings .sandbox_permissions .as_ref() .unwrap() .write_paths .as_ref() .unwrap() .0 .as_slice(); assert_eq!( write_paths, &[GrantedWritePathContent { requested: PathBuf::from("/tmp/build"), resolved: None, on_windows_fs: false, }] ); } #[test] fn test_granted_write_path_content_deserializes_string_or_object() { let from_string: GrantedWritePathContent = serde_json::from_value(serde_json::json!("/tmp/x")).unwrap(); assert_eq!( from_string, GrantedWritePathContent { requested: PathBuf::from("/tmp/x"), resolved: None, on_windows_fs: false, } ); let from_object: GrantedWritePathContent = serde_json::from_value( serde_json::json!({ "requested": "/tmp/x", "resolved": "/tmp/real" }), ) .unwrap(); assert_eq!( from_object, GrantedWritePathContent { requested: PathBuf::from("/tmp/x"), resolved: Some(PathBuf::from("/tmp/real")), on_windows_fs: false, } ); // `resolved` is optional in the object form. let object_without_resolved: GrantedWritePathContent = serde_json::from_value(serde_json::json!({ "requested": "/tmp/x" })).unwrap(); assert_eq!( object_without_resolved, GrantedWritePathContent { requested: PathBuf::from("/tmp/x"), resolved: None, on_windows_fs: false, } ); } #[test] fn test_granted_write_path_content_serializes_string_or_object() { // No resolved target serializes as a bare string, so hand-authored // bare strings round-trip. let bare = GrantedWritePathContent { requested: PathBuf::from("/tmp/x"), resolved: None, on_windows_fs: false, }; assert_eq!( serde_json::to_value(&bare).unwrap(), serde_json::json!("/tmp/x") ); // A resolved target serializes as an object. let resolved = GrantedWritePathContent { requested: PathBuf::from("/tmp/x"), resolved: Some(PathBuf::from("/tmp/real")), on_windows_fs: false, }; assert_eq!( serde_json::to_value(&resolved).unwrap(), serde_json::json!({ "requested": "/tmp/x", "resolved": "/tmp/real" }) ); } }