Follow-up to https://github.com/zed-industries/zed/pull/48209 - those hardcoded rules are replacing these default settings, which will make the rules clearer by removing the "override" scenario. (No release notes because granular tool permissions are still behind a feature flag.) Release Notes: - N/A
754 lines
26 KiB
Rust
754 lines
26 KiB
Rust
mod agent_profile;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use agent_client_protocol::ModelId;
|
|
use collections::{HashSet, IndexMap};
|
|
use gpui::{App, Pixels, px};
|
|
use language_model::LanguageModel;
|
|
use project::DisableAiSettings;
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use settings::{
|
|
DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection,
|
|
NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode,
|
|
};
|
|
|
|
pub use crate::agent_profile::*;
|
|
|
|
pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread_prompt.txt");
|
|
pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str =
|
|
include_str!("prompts/summarize_thread_detailed_prompt.txt");
|
|
|
|
#[derive(Clone, Debug, RegisterSetting)]
|
|
pub struct AgentSettings {
|
|
pub enabled: bool,
|
|
pub button: bool,
|
|
pub dock: DockPosition,
|
|
pub agents_panel_dock: DockSide,
|
|
pub default_width: Pixels,
|
|
pub default_height: Pixels,
|
|
pub default_model: Option<LanguageModelSelection>,
|
|
pub inline_assistant_model: Option<LanguageModelSelection>,
|
|
pub inline_assistant_use_streaming_tools: bool,
|
|
pub commit_message_model: Option<LanguageModelSelection>,
|
|
pub thread_summary_model: Option<LanguageModelSelection>,
|
|
pub inline_alternatives: Vec<LanguageModelSelection>,
|
|
pub favorite_models: Vec<LanguageModelSelection>,
|
|
pub default_profile: AgentProfileId,
|
|
pub default_view: DefaultAgentView,
|
|
pub profiles: IndexMap<AgentProfileId, AgentProfileSettings>,
|
|
pub always_allow_tool_actions: bool,
|
|
pub notify_when_agent_waiting: NotifyWhenAgentWaiting,
|
|
pub play_sound_when_agent_done: bool,
|
|
pub single_file_review: bool,
|
|
pub model_parameters: Vec<LanguageModelParameters>,
|
|
pub enable_feedback: bool,
|
|
pub expand_edit_card: bool,
|
|
pub expand_terminal_card: bool,
|
|
pub cancel_generation_on_terminal_stop: bool,
|
|
pub use_modifier_to_send: bool,
|
|
pub message_editor_min_lines: usize,
|
|
pub show_turn_stats: bool,
|
|
pub tool_permissions: ToolPermissions,
|
|
}
|
|
|
|
impl AgentSettings {
|
|
pub fn enabled(&self, cx: &App) -> bool {
|
|
self.enabled && !DisableAiSettings::get_global(cx).disable_ai
|
|
}
|
|
|
|
pub fn temperature_for_model(model: &Arc<dyn LanguageModel>, cx: &App) -> Option<f32> {
|
|
let settings = Self::get_global(cx);
|
|
for setting in settings.model_parameters.iter().rev() {
|
|
if let Some(provider) = &setting.provider
|
|
&& provider.0 != model.provider_id().0
|
|
{
|
|
continue;
|
|
}
|
|
if let Some(setting_model) = &setting.model
|
|
&& *setting_model != model.id().0
|
|
{
|
|
continue;
|
|
}
|
|
return setting.temperature;
|
|
}
|
|
return None;
|
|
}
|
|
|
|
pub fn set_inline_assistant_model(&mut self, provider: String, model: String) {
|
|
self.inline_assistant_model = Some(LanguageModelSelection {
|
|
provider: provider.into(),
|
|
model,
|
|
});
|
|
}
|
|
|
|
pub fn set_commit_message_model(&mut self, provider: String, model: String) {
|
|
self.commit_message_model = Some(LanguageModelSelection {
|
|
provider: provider.into(),
|
|
model,
|
|
});
|
|
}
|
|
|
|
pub fn set_thread_summary_model(&mut self, provider: String, model: String) {
|
|
self.thread_summary_model = Some(LanguageModelSelection {
|
|
provider: provider.into(),
|
|
model,
|
|
});
|
|
}
|
|
|
|
pub fn set_message_editor_max_lines(&self) -> usize {
|
|
self.message_editor_min_lines * 2
|
|
}
|
|
|
|
pub fn favorite_model_ids(&self) -> HashSet<ModelId> {
|
|
self.favorite_models
|
|
.iter()
|
|
.map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model)))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)]
|
|
pub struct AgentProfileId(pub Arc<str>);
|
|
|
|
impl AgentProfileId {
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for AgentProfileId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
impl Default for AgentProfileId {
|
|
fn default() -> Self {
|
|
Self("write".into())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct ToolPermissions {
|
|
pub tools: collections::HashMap<Arc<str>, ToolRules>,
|
|
}
|
|
|
|
impl ToolPermissions {
|
|
/// Returns all invalid regex patterns across all tools.
|
|
pub fn invalid_patterns(&self) -> Vec<&InvalidRegexPattern> {
|
|
self.tools
|
|
.values()
|
|
.flat_map(|rules| rules.invalid_patterns.iter())
|
|
.collect()
|
|
}
|
|
|
|
/// Returns true if any tool has invalid regex patterns.
|
|
pub fn has_invalid_patterns(&self) -> bool {
|
|
self.tools
|
|
.values()
|
|
.any(|rules| !rules.invalid_patterns.is_empty())
|
|
}
|
|
}
|
|
|
|
/// Represents a regex pattern that failed to compile.
|
|
#[derive(Clone, Debug)]
|
|
pub struct InvalidRegexPattern {
|
|
/// The pattern string that failed to compile.
|
|
pub pattern: String,
|
|
/// Which rule list this pattern was in (e.g., "always_deny", "always_allow", "always_confirm").
|
|
pub rule_type: String,
|
|
/// The error message from the regex compiler.
|
|
pub error: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct ToolRules {
|
|
pub default_mode: ToolPermissionMode,
|
|
pub always_allow: Vec<CompiledRegex>,
|
|
pub always_deny: Vec<CompiledRegex>,
|
|
pub always_confirm: Vec<CompiledRegex>,
|
|
/// Patterns that failed to compile. If non-empty, tool calls should be blocked.
|
|
pub invalid_patterns: Vec<InvalidRegexPattern>,
|
|
}
|
|
|
|
impl Default for ToolRules {
|
|
fn default() -> Self {
|
|
Self {
|
|
default_mode: ToolPermissionMode::Confirm,
|
|
always_allow: Vec::new(),
|
|
always_deny: Vec::new(),
|
|
always_confirm: Vec::new(),
|
|
invalid_patterns: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct CompiledRegex {
|
|
pub pattern: String,
|
|
pub case_sensitive: bool,
|
|
pub regex: regex::Regex,
|
|
}
|
|
|
|
impl std::fmt::Debug for CompiledRegex {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("CompiledRegex")
|
|
.field("pattern", &self.pattern)
|
|
.field("case_sensitive", &self.case_sensitive)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl CompiledRegex {
|
|
pub fn new(pattern: &str, case_sensitive: bool) -> Option<Self> {
|
|
Self::try_new(pattern, case_sensitive).ok()
|
|
}
|
|
|
|
pub fn try_new(pattern: &str, case_sensitive: bool) -> Result<Self, regex::Error> {
|
|
let regex = regex::RegexBuilder::new(pattern)
|
|
.case_insensitive(!case_sensitive)
|
|
.build()?;
|
|
Ok(Self {
|
|
pattern: pattern.to_string(),
|
|
case_sensitive,
|
|
regex,
|
|
})
|
|
}
|
|
|
|
pub fn is_match(&self, input: &str) -> bool {
|
|
self.regex.is_match(input)
|
|
}
|
|
}
|
|
|
|
impl Settings for AgentSettings {
|
|
fn from_settings(content: &settings::SettingsContent) -> Self {
|
|
let agent = content.agent.clone().unwrap();
|
|
Self {
|
|
enabled: agent.enabled.unwrap(),
|
|
button: agent.button.unwrap(),
|
|
dock: agent.dock.unwrap(),
|
|
agents_panel_dock: agent.agents_panel_dock.unwrap(),
|
|
default_width: px(agent.default_width.unwrap()),
|
|
default_height: px(agent.default_height.unwrap()),
|
|
default_model: Some(agent.default_model.unwrap()),
|
|
inline_assistant_model: agent.inline_assistant_model,
|
|
inline_assistant_use_streaming_tools: agent
|
|
.inline_assistant_use_streaming_tools
|
|
.unwrap_or(true),
|
|
commit_message_model: agent.commit_message_model,
|
|
thread_summary_model: agent.thread_summary_model,
|
|
inline_alternatives: agent.inline_alternatives.unwrap_or_default(),
|
|
favorite_models: agent.favorite_models,
|
|
default_profile: AgentProfileId(agent.default_profile.unwrap()),
|
|
default_view: agent.default_view.unwrap(),
|
|
profiles: agent
|
|
.profiles
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|(key, val)| (AgentProfileId(key), val.into()))
|
|
.collect(),
|
|
always_allow_tool_actions: agent.always_allow_tool_actions.unwrap(),
|
|
notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(),
|
|
play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap(),
|
|
single_file_review: agent.single_file_review.unwrap(),
|
|
model_parameters: agent.model_parameters,
|
|
enable_feedback: agent.enable_feedback.unwrap(),
|
|
expand_edit_card: agent.expand_edit_card.unwrap(),
|
|
expand_terminal_card: agent.expand_terminal_card.unwrap(),
|
|
cancel_generation_on_terminal_stop: agent.cancel_generation_on_terminal_stop.unwrap(),
|
|
use_modifier_to_send: agent.use_modifier_to_send.unwrap(),
|
|
message_editor_min_lines: agent.message_editor_min_lines.unwrap(),
|
|
show_turn_stats: agent.show_turn_stats.unwrap(),
|
|
tool_permissions: compile_tool_permissions(agent.tool_permissions),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn compile_tool_permissions(content: Option<settings::ToolPermissionsContent>) -> ToolPermissions {
|
|
let Some(content) = content else {
|
|
return ToolPermissions::default();
|
|
};
|
|
|
|
let tools = content
|
|
.tools
|
|
.into_iter()
|
|
.map(|(tool_name, rules_content)| {
|
|
let mut invalid_patterns = Vec::new();
|
|
|
|
let (always_allow, allow_errors) = compile_regex_rules(
|
|
rules_content.always_allow.map(|v| v.0).unwrap_or_default(),
|
|
"always_allow",
|
|
);
|
|
invalid_patterns.extend(allow_errors);
|
|
|
|
let (always_deny, deny_errors) = compile_regex_rules(
|
|
rules_content.always_deny.map(|v| v.0).unwrap_or_default(),
|
|
"always_deny",
|
|
);
|
|
invalid_patterns.extend(deny_errors);
|
|
|
|
let (always_confirm, confirm_errors) = compile_regex_rules(
|
|
rules_content
|
|
.always_confirm
|
|
.map(|v| v.0)
|
|
.unwrap_or_default(),
|
|
"always_confirm",
|
|
);
|
|
invalid_patterns.extend(confirm_errors);
|
|
|
|
// Log invalid patterns for debugging. Users will see an error when they
|
|
// attempt to use a tool with invalid patterns in their settings.
|
|
for invalid in &invalid_patterns {
|
|
log::error!(
|
|
"Invalid regex pattern in tool_permissions for '{}' tool ({}): '{}' - {}",
|
|
tool_name,
|
|
invalid.rule_type,
|
|
invalid.pattern,
|
|
invalid.error,
|
|
);
|
|
}
|
|
|
|
let rules = ToolRules {
|
|
default_mode: rules_content.default_mode.unwrap_or_default(),
|
|
always_allow,
|
|
always_deny,
|
|
always_confirm,
|
|
invalid_patterns,
|
|
};
|
|
(tool_name, rules)
|
|
})
|
|
.collect();
|
|
|
|
ToolPermissions { tools }
|
|
}
|
|
|
|
fn compile_regex_rules(
|
|
rules: Vec<settings::ToolRegexRule>,
|
|
rule_type: &str,
|
|
) -> (Vec<CompiledRegex>, Vec<InvalidRegexPattern>) {
|
|
let mut compiled = Vec::new();
|
|
let mut errors = Vec::new();
|
|
|
|
for rule in rules {
|
|
let case_sensitive = rule.case_sensitive.unwrap_or(false);
|
|
match CompiledRegex::try_new(&rule.pattern, case_sensitive) {
|
|
Ok(regex) => compiled.push(regex),
|
|
Err(error) => {
|
|
errors.push(InvalidRegexPattern {
|
|
pattern: rule.pattern,
|
|
rule_type: rule_type.to_string(),
|
|
error: error.to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
(compiled, errors)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
use settings::ToolPermissionsContent;
|
|
|
|
#[test]
|
|
fn test_compiled_regex_case_insensitive() {
|
|
let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap();
|
|
assert!(regex.is_match("rm -rf /"));
|
|
assert!(regex.is_match("RM -RF /"));
|
|
assert!(regex.is_match("Rm -Rf /"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_compiled_regex_case_sensitive() {
|
|
let regex = CompiledRegex::new("DROP\\s+TABLE", true).unwrap();
|
|
assert!(regex.is_match("DROP TABLE users"));
|
|
assert!(!regex.is_match("drop table users"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_regex_returns_none() {
|
|
let result = CompiledRegex::new("[invalid(regex", false);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_permissions_parsing() {
|
|
let json = json!({
|
|
"tools": {
|
|
"terminal": {
|
|
"default_mode": "allow",
|
|
"always_deny": [
|
|
{ "pattern": "rm\\s+-rf" }
|
|
],
|
|
"always_allow": [
|
|
{ "pattern": "^git\\s" }
|
|
]
|
|
}
|
|
}
|
|
});
|
|
|
|
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
|
|
let permissions = compile_tool_permissions(Some(content));
|
|
|
|
let terminal_rules = permissions.tools.get("terminal").unwrap();
|
|
assert_eq!(terminal_rules.default_mode, ToolPermissionMode::Allow);
|
|
assert_eq!(terminal_rules.always_deny.len(), 1);
|
|
assert_eq!(terminal_rules.always_allow.len(), 1);
|
|
assert!(terminal_rules.always_deny[0].is_match("rm -rf /"));
|
|
assert!(terminal_rules.always_allow[0].is_match("git status"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_rules_default_mode() {
|
|
let json = json!({
|
|
"tools": {
|
|
"edit_file": {
|
|
"default_mode": "deny"
|
|
}
|
|
}
|
|
});
|
|
|
|
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
|
|
let permissions = compile_tool_permissions(Some(content));
|
|
|
|
let rules = permissions.tools.get("edit_file").unwrap();
|
|
assert_eq!(rules.default_mode, ToolPermissionMode::Deny);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_permissions_empty() {
|
|
let permissions = compile_tool_permissions(None);
|
|
assert!(permissions.tools.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_rules_default_returns_confirm() {
|
|
let default_rules = ToolRules::default();
|
|
assert_eq!(default_rules.default_mode, ToolPermissionMode::Confirm);
|
|
assert!(default_rules.always_allow.is_empty());
|
|
assert!(default_rules.always_deny.is_empty());
|
|
assert!(default_rules.always_confirm.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_permissions_with_multiple_tools() {
|
|
let json = json!({
|
|
"tools": {
|
|
"terminal": {
|
|
"default_mode": "allow",
|
|
"always_deny": [{ "pattern": "rm\\s+-rf" }]
|
|
},
|
|
"edit_file": {
|
|
"default_mode": "confirm",
|
|
"always_deny": [{ "pattern": "\\.env$" }]
|
|
},
|
|
"delete_path": {
|
|
"default_mode": "deny"
|
|
}
|
|
}
|
|
});
|
|
|
|
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
|
|
let permissions = compile_tool_permissions(Some(content));
|
|
|
|
assert_eq!(permissions.tools.len(), 3);
|
|
|
|
let terminal = permissions.tools.get("terminal").unwrap();
|
|
assert_eq!(terminal.default_mode, ToolPermissionMode::Allow);
|
|
assert_eq!(terminal.always_deny.len(), 1);
|
|
|
|
let edit_file = permissions.tools.get("edit_file").unwrap();
|
|
assert_eq!(edit_file.default_mode, ToolPermissionMode::Confirm);
|
|
assert!(edit_file.always_deny[0].is_match("secrets.env"));
|
|
|
|
let delete_path = permissions.tools.get("delete_path").unwrap();
|
|
assert_eq!(delete_path.default_mode, ToolPermissionMode::Deny);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_permissions_with_all_rule_types() {
|
|
let json = json!({
|
|
"tools": {
|
|
"terminal": {
|
|
"always_deny": [{ "pattern": "rm\\s+-rf" }],
|
|
"always_confirm": [{ "pattern": "sudo\\s" }],
|
|
"always_allow": [{ "pattern": "^git\\s+status" }]
|
|
}
|
|
}
|
|
});
|
|
|
|
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
|
|
let permissions = compile_tool_permissions(Some(content));
|
|
|
|
let terminal = permissions.tools.get("terminal").unwrap();
|
|
assert_eq!(terminal.always_deny.len(), 1);
|
|
assert_eq!(terminal.always_confirm.len(), 1);
|
|
assert_eq!(terminal.always_allow.len(), 1);
|
|
|
|
assert!(terminal.always_deny[0].is_match("rm -rf /"));
|
|
assert!(terminal.always_confirm[0].is_match("sudo apt install"));
|
|
assert!(terminal.always_allow[0].is_match("git status"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_regex_is_tracked_and_valid_ones_still_compile() {
|
|
let json = json!({
|
|
"tools": {
|
|
"terminal": {
|
|
"always_deny": [
|
|
{ "pattern": "[invalid(regex" },
|
|
{ "pattern": "valid_pattern" }
|
|
],
|
|
"always_allow": [
|
|
{ "pattern": "[another_bad" }
|
|
]
|
|
}
|
|
}
|
|
});
|
|
|
|
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
|
|
let permissions = compile_tool_permissions(Some(content));
|
|
|
|
let terminal = permissions.tools.get("terminal").unwrap();
|
|
|
|
// Valid patterns should still be compiled
|
|
assert_eq!(terminal.always_deny.len(), 1);
|
|
assert!(terminal.always_deny[0].is_match("valid_pattern"));
|
|
|
|
// Invalid patterns should be tracked (order depends on processing order)
|
|
assert_eq!(terminal.invalid_patterns.len(), 2);
|
|
|
|
let deny_invalid = terminal
|
|
.invalid_patterns
|
|
.iter()
|
|
.find(|p| p.rule_type == "always_deny")
|
|
.expect("should have invalid pattern from always_deny");
|
|
assert_eq!(deny_invalid.pattern, "[invalid(regex");
|
|
assert!(!deny_invalid.error.is_empty());
|
|
|
|
let allow_invalid = terminal
|
|
.invalid_patterns
|
|
.iter()
|
|
.find(|p| p.rule_type == "always_allow")
|
|
.expect("should have invalid pattern from always_allow");
|
|
assert_eq!(allow_invalid.pattern, "[another_bad");
|
|
|
|
// ToolPermissions helper methods should work
|
|
assert!(permissions.has_invalid_patterns());
|
|
assert_eq!(permissions.invalid_patterns().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_deny_takes_precedence_over_allow_and_confirm() {
|
|
let json = json!({
|
|
"tools": {
|
|
"terminal": {
|
|
"default_mode": "allow",
|
|
"always_deny": [{ "pattern": "dangerous" }],
|
|
"always_confirm": [{ "pattern": "dangerous" }],
|
|
"always_allow": [{ "pattern": "dangerous" }]
|
|
}
|
|
}
|
|
});
|
|
|
|
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
|
|
let permissions = compile_tool_permissions(Some(content));
|
|
let terminal = permissions.tools.get("terminal").unwrap();
|
|
|
|
assert!(
|
|
terminal.always_deny[0].is_match("run dangerous command"),
|
|
"Deny rule should match"
|
|
);
|
|
assert!(
|
|
terminal.always_allow[0].is_match("run dangerous command"),
|
|
"Allow rule should also match (but deny takes precedence at evaluation time)"
|
|
);
|
|
assert!(
|
|
terminal.always_confirm[0].is_match("run dangerous command"),
|
|
"Confirm rule should also match (but deny takes precedence at evaluation time)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_confirm_takes_precedence_over_allow() {
|
|
let json = json!({
|
|
"tools": {
|
|
"terminal": {
|
|
"default_mode": "allow",
|
|
"always_confirm": [{ "pattern": "risky" }],
|
|
"always_allow": [{ "pattern": "risky" }]
|
|
}
|
|
}
|
|
});
|
|
|
|
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
|
|
let permissions = compile_tool_permissions(Some(content));
|
|
let terminal = permissions.tools.get("terminal").unwrap();
|
|
|
|
assert!(
|
|
terminal.always_confirm[0].is_match("do risky thing"),
|
|
"Confirm rule should match"
|
|
);
|
|
assert!(
|
|
terminal.always_allow[0].is_match("do risky thing"),
|
|
"Allow rule should also match (but confirm takes precedence at evaluation time)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_regex_matches_anywhere_in_string_not_just_anchored() {
|
|
let json = json!({
|
|
"tools": {
|
|
"terminal": {
|
|
"always_deny": [
|
|
{ "pattern": "rm\\s+-rf" },
|
|
{ "pattern": "/etc/passwd" }
|
|
]
|
|
}
|
|
}
|
|
});
|
|
|
|
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
|
|
let permissions = compile_tool_permissions(Some(content));
|
|
let terminal = permissions.tools.get("terminal").unwrap();
|
|
|
|
assert!(
|
|
terminal.always_deny[0].is_match("echo hello && rm -rf /"),
|
|
"Should match rm -rf in the middle of a command chain"
|
|
);
|
|
assert!(
|
|
terminal.always_deny[0].is_match("cd /tmp; rm -rf *"),
|
|
"Should match rm -rf after semicolon"
|
|
);
|
|
assert!(
|
|
terminal.always_deny[1].is_match("cat /etc/passwd | grep root"),
|
|
"Should match /etc/passwd in a pipeline"
|
|
);
|
|
assert!(
|
|
terminal.always_deny[1].is_match("vim /etc/passwd"),
|
|
"Should match /etc/passwd as argument"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fork_bomb_pattern_matches() {
|
|
let fork_bomb_regex = CompiledRegex::new(r":\(\)\{\s*:\|:&\s*\};:", false).unwrap();
|
|
assert!(
|
|
fork_bomb_regex.is_match(":(){ :|:& };:"),
|
|
"Should match the classic fork bomb"
|
|
);
|
|
assert!(
|
|
fork_bomb_regex.is_match(":(){ :|:&};:"),
|
|
"Should match fork bomb without spaces"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_compiled_regex_stores_case_sensitivity() {
|
|
let case_sensitive = CompiledRegex::new("test", true).unwrap();
|
|
let case_insensitive = CompiledRegex::new("test", false).unwrap();
|
|
|
|
assert!(case_sensitive.case_sensitive);
|
|
assert!(!case_insensitive.case_sensitive);
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_regex_is_skipped_not_fail() {
|
|
let json = json!({
|
|
"tools": {
|
|
"terminal": {
|
|
"always_deny": [
|
|
{ "pattern": "[invalid(regex" },
|
|
{ "pattern": "valid_pattern" }
|
|
]
|
|
}
|
|
}
|
|
});
|
|
|
|
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
|
|
let permissions = compile_tool_permissions(Some(content));
|
|
|
|
let terminal = permissions.tools.get("terminal").unwrap();
|
|
assert_eq!(terminal.always_deny.len(), 1);
|
|
assert!(terminal.always_deny[0].is_match("valid_pattern"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_unconfigured_tool_not_in_permissions() {
|
|
let json = json!({
|
|
"tools": {
|
|
"terminal": {
|
|
"default_mode": "allow"
|
|
}
|
|
}
|
|
});
|
|
|
|
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
|
|
let permissions = compile_tool_permissions(Some(content));
|
|
|
|
assert!(permissions.tools.contains_key("terminal"));
|
|
assert!(!permissions.tools.contains_key("edit_file"));
|
|
assert!(!permissions.tools.contains_key("fetch"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_always_allow_pattern_only_matches_specified_commands() {
|
|
// Reproduces user-reported bug: when always_allow has pattern "^echo\s",
|
|
// only "echo hello" should be allowed, not "git status".
|
|
//
|
|
// User config:
|
|
// always_allow_tool_actions: false
|
|
// tool_permissions.tools.terminal.always_allow: [{ pattern: "^echo\\s" }]
|
|
let json = json!({
|
|
"tools": {
|
|
"terminal": {
|
|
"always_allow": [
|
|
{ "pattern": "^echo\\s" }
|
|
]
|
|
}
|
|
}
|
|
});
|
|
|
|
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
|
|
let permissions = compile_tool_permissions(Some(content));
|
|
|
|
let terminal = permissions.tools.get("terminal").unwrap();
|
|
|
|
// Verify the pattern was compiled
|
|
assert_eq!(
|
|
terminal.always_allow.len(),
|
|
1,
|
|
"Should have one always_allow pattern"
|
|
);
|
|
|
|
// Verify the pattern matches "echo hello"
|
|
assert!(
|
|
terminal.always_allow[0].is_match("echo hello"),
|
|
"Pattern ^echo\\s should match 'echo hello'"
|
|
);
|
|
|
|
// Verify the pattern does NOT match "git status"
|
|
assert!(
|
|
!terminal.always_allow[0].is_match("git status"),
|
|
"Pattern ^echo\\s should NOT match 'git status'"
|
|
);
|
|
|
|
// Verify the pattern does NOT match "echoHello" (no space)
|
|
assert!(
|
|
!terminal.always_allow[0].is_match("echoHello"),
|
|
"Pattern ^echo\\s should NOT match 'echoHello' (requires whitespace)"
|
|
);
|
|
|
|
// Verify default_mode is Confirm (the default)
|
|
assert_eq!(
|
|
terminal.default_mode,
|
|
settings::ToolPermissionMode::Confirm,
|
|
"default_mode should be Confirm when not specified"
|
|
);
|
|
}
|
|
}
|