Adds buttons for confirming (and optionally persisting) granular permissions on tool calls: <img width="688" height="302" alt="Screenshot 2026-01-14 at 1 58 40 PM" src="https://github.com/user-attachments/assets/3228cc39-efd5-4a73-989f-ddb28969847f" /> <img width="690" height="282" alt="Screenshot 2026-01-14 at 1 58 31 PM" src="https://github.com/user-attachments/assets/8367a888-5728-4877-a502-1ff20ac929ec" /> ## Features ### Tool Permission Settings - **Per-tool rules** in `agent.tool_permissions.tools.<tool_name>`: - `default_mode`: `"allow"`, `"deny"`, or `"confirm"` (default) - `always_allow`: Array of regex patterns to auto-approve - `always_deny`: Array of regex patterns to block - `always_confirm`: Array of regex patterns requiring confirmation - **Supported tools**: `terminal`, `edit_file`, `delete_path`, `move_path`, `create_directory`, `save_file`, `copy_path`, `fetch`, `web_search` - **MCP tool support**: Third-party tools from context servers with `mcp:<server>:<tool>` naming ### Smart Permission Buttons When a tool requires confirmation, the dialog shows contextual buttons: - **"Always allow \`<tool>\`"** - Sets `default_mode = "allow"` for the tool - **"Always allow \`<pattern>\`"** - Adds a pattern to `always_allow`: - Terminal: Command name (e.g., `cargo`, `npm`, `git`) - File tools: Parent directory path - URL tools: Domain name - **"Allow"** / **"Deny"** - One-time decision ### Pattern Extraction Automatically extracts meaningful patterns from tool inputs: - Terminal commands → `^cargo\s`, `^npm\s`, etc. - File paths → `^/Users/alice/project/src/` - URLs → `^https?://github\.com` ### Invalid Pattern Handling - Malformed regex patterns are detected at settings load time - Tools with invalid patterns show an error and block execution - Clear error messages identify which pattern failed ## Changes ### New Files - `crates/agent/src/tool_permissions.rs` - Permission evaluation logic - `crates/agent/src/pattern_extraction.rs` - Pattern extraction functions ### Modified Files - `crates/agent_settings/src/agent_settings.rs` - `ToolPermissions`, `ToolRules` structs - `crates/settings/src/settings_content/agent.rs` - Settings schema and serialization - `crates/settings/src/settings_file.rs` - Helper methods for modifying settings - `crates/agent/src/thread.rs` - `authorize_with_context()`, `authorize_third_party_tool()` methods - `crates/agent/src/tools/*.rs` - Updated all granular tools to use permission system - `crates/agent/src/tools/context_server_registry.rs` - MCP tool authorization - `crates/agent_ui/src/acp/thread_view.rs` - Permission button UI, removed Keep/Reject buttons ## Example Configuration ```json { "agent": { "tool_permissions": { "tools": { "terminal": { "default_mode": "confirm", "always_allow": ["^cargo\\s", "^npm\\s", "^git\\s"], "always_deny": ["^rm\\s+-rf", "^sudo\\s"] }, "edit_file": { "default_mode": "allow", "always_deny": ["^\\.env", "^/etc/"] }, "mcp:filesystem:write_file": { "default_mode": "confirm" } } } } } ``` Release Notes: - You can now set per-tool permissions when confirming/denying tool use --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Co-authored-by: Michael Benfield <mbenfield@zed.dev>
167 lines
5.2 KiB
Rust
167 lines
5.2 KiB
Rust
use url::Url;
|
|
|
|
/// Extracts a regex pattern from a terminal command based on the first token (command name).
|
|
///
|
|
/// Returns `None` for commands starting with `./`, `/`, or other path-like prefixes.
|
|
/// This is a deliberate security decision: we only allow pattern-based "always allow"
|
|
/// rules for well-known command names (like `cargo`, `npm`, `git`), not for arbitrary
|
|
/// scripts or absolute paths which could be manipulated by an attacker.
|
|
pub fn extract_terminal_pattern(command: &str) -> Option<String> {
|
|
let first_token = command.split_whitespace().next()?;
|
|
// Only allow alphanumeric commands with hyphens/underscores.
|
|
// Reject paths like "./script.sh" or "/usr/bin/python" to prevent
|
|
// users from accidentally allowing arbitrary script execution.
|
|
if first_token
|
|
.chars()
|
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
|
{
|
|
Some(format!("^{}\\s", regex::escape(first_token)))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn extract_terminal_pattern_display(command: &str) -> Option<String> {
|
|
let first_token = command.split_whitespace().next()?;
|
|
if first_token
|
|
.chars()
|
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
|
{
|
|
Some(first_token.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn extract_path_pattern(path: &str) -> Option<String> {
|
|
let parent = std::path::Path::new(path).parent()?;
|
|
let parent_str = parent.to_str()?;
|
|
if parent_str.is_empty() || parent_str == "/" {
|
|
return None;
|
|
}
|
|
Some(format!("^{}/", regex::escape(parent_str)))
|
|
}
|
|
|
|
pub fn extract_path_pattern_display(path: &str) -> Option<String> {
|
|
let parent = std::path::Path::new(path).parent()?;
|
|
let parent_str = parent.to_str()?;
|
|
if parent_str.is_empty() || parent_str == "/" {
|
|
return None;
|
|
}
|
|
Some(format!("{}/", parent_str))
|
|
}
|
|
|
|
pub fn extract_url_pattern(url: &str) -> Option<String> {
|
|
let parsed = Url::parse(url).ok()?;
|
|
let domain = parsed.host_str()?;
|
|
Some(format!("^https?://{}", regex::escape(domain)))
|
|
}
|
|
|
|
pub fn extract_url_pattern_display(url: &str) -> Option<String> {
|
|
let parsed = Url::parse(url).ok()?;
|
|
let domain = parsed.host_str()?;
|
|
Some(domain.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_extract_terminal_pattern() {
|
|
assert_eq!(
|
|
extract_terminal_pattern("cargo build --release"),
|
|
Some("^cargo\\s".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_terminal_pattern("npm install"),
|
|
Some("^npm\\s".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_terminal_pattern("git-lfs pull"),
|
|
Some("^git\\-lfs\\s".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_terminal_pattern("my_script arg"),
|
|
Some("^my_script\\s".to_string())
|
|
);
|
|
assert_eq!(extract_terminal_pattern("./script.sh arg"), None);
|
|
assert_eq!(extract_terminal_pattern("/usr/bin/python arg"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_terminal_pattern_display() {
|
|
assert_eq!(
|
|
extract_terminal_pattern_display("cargo build --release"),
|
|
Some("cargo".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_terminal_pattern_display("npm install"),
|
|
Some("npm".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_path_pattern() {
|
|
assert_eq!(
|
|
extract_path_pattern("/Users/alice/project/src/main.rs"),
|
|
Some("^/Users/alice/project/src/".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_path_pattern("src/lib.rs"),
|
|
Some("^src/".to_string())
|
|
);
|
|
assert_eq!(extract_path_pattern("file.txt"), None);
|
|
assert_eq!(extract_path_pattern("/file.txt"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_path_pattern_display() {
|
|
assert_eq!(
|
|
extract_path_pattern_display("/Users/alice/project/src/main.rs"),
|
|
Some("/Users/alice/project/src/".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_path_pattern_display("src/lib.rs"),
|
|
Some("src/".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_url_pattern() {
|
|
assert_eq!(
|
|
extract_url_pattern("https://github.com/user/repo"),
|
|
Some("^https?://github\\.com".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_url_pattern("http://example.com/path?query=1"),
|
|
Some("^https?://example\\.com".to_string())
|
|
);
|
|
assert_eq!(extract_url_pattern("not a url"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_url_pattern_display() {
|
|
assert_eq!(
|
|
extract_url_pattern_display("https://github.com/user/repo"),
|
|
Some("github.com".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_url_pattern_display("http://api.example.com/v1/users"),
|
|
Some("api.example.com".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_special_chars_are_escaped() {
|
|
assert_eq!(
|
|
extract_path_pattern("/path/with (parens)/file.txt"),
|
|
Some("^/path/with \\(parens\\)/".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_url_pattern("https://test.example.com/path"),
|
|
Some("^https?://test\\.example\\.com".to_string())
|
|
);
|
|
}
|
|
}
|