feat(ui): add autocomplete, command help, and streaming improvements
TUI Enhancements: - Add autocomplete dropdown with fuzzy filtering for slash commands - Fix autocomplete: Tab confirms selection, Enter submits message - Add command help overlay with scroll support (j/k, arrows, Page Up/Down) - Brighten Tokyo Night theme colors for better readability - Add todo panel component for task display - Add rich command output formatting (tables, trees, lists) Streaming Fixes: - Refactor to non-blocking background streaming with channel events - Add StreamStart/StreamEnd/StreamError events - Fix LlmChunk to append instead of creating new messages - Display user message immediately before LLM call New Components: - completions.rs: Command completion engine with fuzzy matching - autocomplete.rs: Inline autocomplete dropdown - command_help.rs: Modal help overlay with scrolling - todo_panel.rs: Todo list display panel - output.rs: Rich formatted output (tables, trees, code blocks) - commands.rs: Built-in command implementations Planning Mode Groundwork: - Add EnterPlanMode/ExitPlanMode tools scaffolding - Add Skill tool for plugin skill invocation - Extend permissions with planning mode support - Add compact.rs stub for context compaction 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,19 @@ pub struct Settings {
|
||||
// Permission mode
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String, // "plan" | "acceptEdits" | "code"
|
||||
|
||||
// Tool permission lists
|
||||
/// Tools that are always allowed without prompting
|
||||
/// Format: "tool_name" or "tool_name:pattern"
|
||||
/// Example: ["bash:npm test:*", "bash:cargo test:*", "mcp:filesystem__*"]
|
||||
#[serde(default)]
|
||||
pub allowed_tools: Vec<String>,
|
||||
|
||||
/// Tools that are always denied (blocked)
|
||||
/// Format: "tool_name" or "tool_name:pattern"
|
||||
/// Example: ["bash:rm -rf*", "bash:sudo*"]
|
||||
#[serde(default)]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_provider() -> String {
|
||||
@@ -65,15 +78,30 @@ impl Default for Settings {
|
||||
anthropic_api_key: None,
|
||||
openai_api_key: None,
|
||||
mode: default_mode(),
|
||||
allowed_tools: Vec::new(),
|
||||
disallowed_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Create a PermissionManager based on the configured mode
|
||||
/// Create a PermissionManager based on the configured mode and tool lists
|
||||
///
|
||||
/// Tool lists are applied in order:
|
||||
/// 1. Disallowed tools (highest priority - blocked first)
|
||||
/// 2. Allowed tools
|
||||
/// 3. Mode-based defaults
|
||||
pub fn create_permission_manager(&self) -> PermissionManager {
|
||||
let mode = Mode::from_str(&self.mode).unwrap_or(Mode::Plan);
|
||||
PermissionManager::new(mode)
|
||||
let mut pm = PermissionManager::new(mode);
|
||||
|
||||
// Add disallowed tools first (deny rules take precedence)
|
||||
pm.add_disallowed_tools(&self.disallowed_tools);
|
||||
|
||||
// Then add allowed tools
|
||||
pm.add_allowed_tools(&self.allowed_tools);
|
||||
|
||||
pm
|
||||
}
|
||||
|
||||
/// Get the Mode enum from the mode string
|
||||
|
||||
@@ -34,6 +34,34 @@ pub enum HookEvent {
|
||||
prompt: String,
|
||||
},
|
||||
PreCompact,
|
||||
/// Called before the agent stops - allows validation of completion
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Stop {
|
||||
/// Reason for stopping (e.g., "task_complete", "max_iterations", "user_interrupt")
|
||||
reason: String,
|
||||
/// Number of messages in conversation
|
||||
num_messages: usize,
|
||||
/// Number of tool calls made
|
||||
num_tool_calls: usize,
|
||||
},
|
||||
/// Called before a subagent stops
|
||||
#[serde(rename_all = "camelCase")]
|
||||
SubagentStop {
|
||||
/// Unique identifier for the subagent
|
||||
agent_id: String,
|
||||
/// Type of subagent (e.g., "explore", "code-reviewer")
|
||||
agent_type: String,
|
||||
/// Reason for stopping
|
||||
reason: String,
|
||||
},
|
||||
/// Called when a notification is sent to the user
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Notification {
|
||||
/// Notification message
|
||||
message: String,
|
||||
/// Notification type (e.g., "info", "warning", "error")
|
||||
notification_type: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
@@ -46,16 +74,105 @@ impl HookEvent {
|
||||
HookEvent::SessionEnd { .. } => "SessionEnd",
|
||||
HookEvent::UserPromptSubmit { .. } => "UserPromptSubmit",
|
||||
HookEvent::PreCompact => "PreCompact",
|
||||
HookEvent::Stop { .. } => "Stop",
|
||||
HookEvent::SubagentStop { .. } => "SubagentStop",
|
||||
HookEvent::Notification { .. } => "Notification",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple hook result for backwards compatibility
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HookResult {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
/// Extended hook output with additional control options
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HookOutput {
|
||||
/// Whether to continue execution (default: true if exit code 0)
|
||||
#[serde(default = "default_continue")]
|
||||
pub continue_execution: bool,
|
||||
/// Whether to suppress showing the result to the user
|
||||
#[serde(default)]
|
||||
pub suppress_output: bool,
|
||||
/// System message to inject into the conversation
|
||||
#[serde(default)]
|
||||
pub system_message: Option<String>,
|
||||
/// Permission decision override
|
||||
#[serde(default)]
|
||||
pub permission_decision: Option<HookPermission>,
|
||||
/// Modified input/args for the tool (PreToolUse only)
|
||||
#[serde(default)]
|
||||
pub updated_input: Option<Value>,
|
||||
}
|
||||
|
||||
impl Default for HookOutput {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
continue_execution: true,
|
||||
suppress_output: false,
|
||||
system_message: None,
|
||||
permission_decision: None,
|
||||
updated_input: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_continue() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Permission decision from a hook
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HookPermission {
|
||||
Allow,
|
||||
Deny,
|
||||
Ask,
|
||||
}
|
||||
|
||||
impl HookOutput {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn allow() -> Self {
|
||||
Self {
|
||||
continue_execution: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deny() -> Self {
|
||||
Self {
|
||||
continue_execution: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_system_message(mut self, message: impl Into<String>) -> Self {
|
||||
self.system_message = Some(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_permission(mut self, permission: HookPermission) -> Self {
|
||||
self.permission_decision = Some(permission);
|
||||
self
|
||||
}
|
||||
|
||||
/// Convert to simple HookResult for backwards compatibility
|
||||
pub fn to_result(&self) -> HookResult {
|
||||
if self.continue_execution {
|
||||
HookResult::Allow
|
||||
} else {
|
||||
HookResult::Deny
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered hook that can be executed
|
||||
#[derive(Debug, Clone)]
|
||||
struct Hook {
|
||||
@@ -195,6 +312,131 @@ impl HookManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a hook and return extended output
|
||||
///
|
||||
/// This method parses JSON output from stdout if the hook provides it,
|
||||
/// otherwise falls back to exit code interpretation.
|
||||
pub async fn execute_extended(&self, event: &HookEvent, timeout_ms: Option<u64>) -> Result<HookOutput> {
|
||||
// First check for legacy file-based hooks
|
||||
let hook_path = self.get_hook_path(event);
|
||||
let has_file_hook = hook_path.exists();
|
||||
|
||||
// Get registered hooks for this event
|
||||
let event_name = event.hook_name();
|
||||
let mut matching_hooks: Vec<&Hook> = self.hooks.iter()
|
||||
.filter(|h| h.event == event_name)
|
||||
.collect();
|
||||
|
||||
// If we need to filter by pattern (for PreToolUse events)
|
||||
if let HookEvent::PreToolUse { tool, .. } = event {
|
||||
matching_hooks.retain(|h| {
|
||||
if let Some(pattern) = &h.pattern {
|
||||
if let Ok(re) = regex::Regex::new(pattern) {
|
||||
re.is_match(tool)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If no hooks at all, allow by default
|
||||
if !has_file_hook && matching_hooks.is_empty() {
|
||||
return Ok(HookOutput::allow());
|
||||
}
|
||||
|
||||
let mut combined_output = HookOutput::allow();
|
||||
|
||||
// Execute file-based hook first (if exists)
|
||||
if has_file_hook {
|
||||
let output = self.execute_hook_extended(&hook_path.to_string_lossy(), event, timeout_ms).await?;
|
||||
combined_output = Self::merge_outputs(combined_output, output);
|
||||
if !combined_output.continue_execution {
|
||||
return Ok(combined_output);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute registered hooks
|
||||
for hook in matching_hooks {
|
||||
let hook_timeout = hook.timeout.or(timeout_ms);
|
||||
let output = self.execute_hook_extended(&hook.command, event, hook_timeout).await?;
|
||||
combined_output = Self::merge_outputs(combined_output, output);
|
||||
if !combined_output.continue_execution {
|
||||
return Ok(combined_output);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(combined_output)
|
||||
}
|
||||
|
||||
/// Execute a single hook command and return extended output
|
||||
async fn execute_hook_extended(&self, command: &str, event: &HookEvent, timeout_ms: Option<u64>) -> Result<HookOutput> {
|
||||
let input_json = serde_json::to_string(event)?;
|
||||
|
||||
let mut child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(&self.project_root)
|
||||
.spawn()?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(input_json.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
drop(stdin);
|
||||
}
|
||||
|
||||
let result = if let Some(ms) = timeout_ms {
|
||||
timeout(Duration::from_millis(ms), child.wait_with_output()).await
|
||||
} else {
|
||||
Ok(child.wait_with_output().await)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) => {
|
||||
let exit_code = output.status.code();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Try to parse JSON output from stdout
|
||||
if !stdout.trim().is_empty() {
|
||||
if let Ok(hook_output) = serde_json::from_str::<HookOutput>(stdout.trim()) {
|
||||
return Ok(hook_output);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to exit code interpretation
|
||||
match exit_code {
|
||||
Some(0) => Ok(HookOutput::allow()),
|
||||
Some(2) => Ok(HookOutput::deny()),
|
||||
Some(code) => Err(eyre!(
|
||||
"Hook {} failed with exit code {}: {}",
|
||||
event.hook_name(),
|
||||
code,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)),
|
||||
None => Err(eyre!("Hook {} terminated by signal", event.hook_name())),
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Err(eyre!("Failed to execute hook {}: {}", event.hook_name(), e)),
|
||||
Err(_) => Err(eyre!("Hook {} timed out", event.hook_name())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge two hook outputs, with the second taking precedence
|
||||
fn merge_outputs(base: HookOutput, new: HookOutput) -> HookOutput {
|
||||
HookOutput {
|
||||
continue_execution: base.continue_execution && new.continue_execution,
|
||||
suppress_output: base.suppress_output || new.suppress_output,
|
||||
system_message: new.system_message.or(base.system_message),
|
||||
permission_decision: new.permission_decision.or(base.permission_decision),
|
||||
updated_input: new.updated_input.or(base.updated_input),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_hook_path(&self, event: &HookEvent) -> PathBuf {
|
||||
self.project_root
|
||||
.join(".owlen")
|
||||
@@ -236,5 +478,76 @@ mod tests {
|
||||
.hook_name(),
|
||||
"SessionStart"
|
||||
);
|
||||
assert_eq!(
|
||||
HookEvent::Stop {
|
||||
reason: "task_complete".to_string(),
|
||||
num_messages: 10,
|
||||
num_tool_calls: 5,
|
||||
}
|
||||
.hook_name(),
|
||||
"Stop"
|
||||
);
|
||||
assert_eq!(
|
||||
HookEvent::SubagentStop {
|
||||
agent_id: "abc123".to_string(),
|
||||
agent_type: "explore".to_string(),
|
||||
reason: "completed".to_string(),
|
||||
}
|
||||
.hook_name(),
|
||||
"SubagentStop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_event_serializes_correctly() {
|
||||
let event = HookEvent::Stop {
|
||||
reason: "task_complete".to_string(),
|
||||
num_messages: 10,
|
||||
num_tool_calls: 5,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"event\":\"stop\""));
|
||||
assert!(json.contains("\"reason\":\"task_complete\""));
|
||||
assert!(json.contains("\"numMessages\":10"));
|
||||
assert!(json.contains("\"numToolCalls\":5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_output_defaults() {
|
||||
let output = HookOutput::default();
|
||||
assert!(output.continue_execution);
|
||||
assert!(!output.suppress_output);
|
||||
assert!(output.system_message.is_none());
|
||||
assert!(output.permission_decision.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_output_builders() {
|
||||
let output = HookOutput::allow()
|
||||
.with_system_message("Test message")
|
||||
.with_permission(HookPermission::Allow);
|
||||
|
||||
assert!(output.continue_execution);
|
||||
assert_eq!(output.system_message, Some("Test message".to_string()));
|
||||
assert_eq!(output.permission_decision, Some(HookPermission::Allow));
|
||||
|
||||
let deny = HookOutput::deny();
|
||||
assert!(!deny.continue_execution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_output_deserializes() {
|
||||
let json = r#"{"continueExecution": true, "suppressOutput": false, "systemMessage": "Hello"}"#;
|
||||
let output: HookOutput = serde_json::from_str(json).unwrap();
|
||||
assert!(output.continue_execution);
|
||||
assert!(!output.suppress_output);
|
||||
assert_eq!(output.system_message, Some("Hello".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_output_to_result() {
|
||||
assert_eq!(HookOutput::allow().to_result(), HookResult::Allow);
|
||||
assert_eq!(HookOutput::deny().to_result(), HookResult::Deny);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,69 @@ pub enum Tool {
|
||||
AskUserQuestion,
|
||||
BashOutput,
|
||||
KillShell,
|
||||
// Planning mode tools
|
||||
EnterPlanMode,
|
||||
ExitPlanMode,
|
||||
Skill,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
/// Parse a tool name from string (case-insensitive)
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"read" => Some(Tool::Read),
|
||||
"write" => Some(Tool::Write),
|
||||
"edit" => Some(Tool::Edit),
|
||||
"bash" => Some(Tool::Bash),
|
||||
"grep" => Some(Tool::Grep),
|
||||
"glob" => Some(Tool::Glob),
|
||||
"webfetch" | "web_fetch" => Some(Tool::WebFetch),
|
||||
"websearch" | "web_search" => Some(Tool::WebSearch),
|
||||
"notebookread" | "notebook_read" => Some(Tool::NotebookRead),
|
||||
"notebookedit" | "notebook_edit" => Some(Tool::NotebookEdit),
|
||||
"slashcommand" | "slash_command" => Some(Tool::SlashCommand),
|
||||
"task" => Some(Tool::Task),
|
||||
"todowrite" | "todo_write" | "todo" => Some(Tool::TodoWrite),
|
||||
"mcp" => Some(Tool::Mcp),
|
||||
"multiedit" | "multi_edit" => Some(Tool::MultiEdit),
|
||||
"ls" => Some(Tool::LS),
|
||||
"askuserquestion" | "ask_user_question" | "ask" => Some(Tool::AskUserQuestion),
|
||||
"bashoutput" | "bash_output" => Some(Tool::BashOutput),
|
||||
"killshell" | "kill_shell" => Some(Tool::KillShell),
|
||||
"enterplanmode" | "enter_plan_mode" => Some(Tool::EnterPlanMode),
|
||||
"exitplanmode" | "exit_plan_mode" => Some(Tool::ExitPlanMode),
|
||||
"skill" => Some(Tool::Skill),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the string name of this tool
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Tool::Read => "read",
|
||||
Tool::Write => "write",
|
||||
Tool::Edit => "edit",
|
||||
Tool::Bash => "bash",
|
||||
Tool::Grep => "grep",
|
||||
Tool::Glob => "glob",
|
||||
Tool::WebFetch => "web_fetch",
|
||||
Tool::WebSearch => "web_search",
|
||||
Tool::NotebookRead => "notebook_read",
|
||||
Tool::NotebookEdit => "notebook_edit",
|
||||
Tool::SlashCommand => "slash_command",
|
||||
Tool::Task => "task",
|
||||
Tool::TodoWrite => "todo_write",
|
||||
Tool::Mcp => "mcp",
|
||||
Tool::MultiEdit => "multi_edit",
|
||||
Tool::LS => "ls",
|
||||
Tool::AskUserQuestion => "ask_user_question",
|
||||
Tool::BashOutput => "bash_output",
|
||||
Tool::KillShell => "kill_shell",
|
||||
Tool::EnterPlanMode => "enter_plan_mode",
|
||||
Tool::ExitPlanMode => "exit_plan_mode",
|
||||
Tool::Skill => "skill",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -134,6 +197,11 @@ impl PermissionManager {
|
||||
}
|
||||
// User interaction and session state tools allowed
|
||||
Tool::AskUserQuestion | Tool::TodoWrite => PermissionDecision::Allow,
|
||||
// Planning mode tools - EnterPlanMode asks, ExitPlanMode allowed
|
||||
Tool::EnterPlanMode => PermissionDecision::Ask,
|
||||
Tool::ExitPlanMode => PermissionDecision::Allow,
|
||||
// Skill tool allowed (read-only skill injection)
|
||||
Tool::Skill => PermissionDecision::Allow,
|
||||
// Everything else requires asking
|
||||
_ => PermissionDecision::Ask,
|
||||
},
|
||||
@@ -150,6 +218,8 @@ impl PermissionManager {
|
||||
Tool::BashOutput | Tool::KillShell => PermissionDecision::Ask,
|
||||
// Utility tools allowed
|
||||
Tool::TodoWrite | Tool::SlashCommand | Tool::Task | Tool::AskUserQuestion => PermissionDecision::Allow,
|
||||
// Planning mode tools allowed
|
||||
Tool::EnterPlanMode | Tool::ExitPlanMode | Tool::Skill => PermissionDecision::Allow,
|
||||
},
|
||||
Mode::Code => {
|
||||
// Everything allowed in code mode
|
||||
@@ -165,6 +235,41 @@ impl PermissionManager {
|
||||
pub fn mode(&self) -> Mode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Add allowed tools from a list of tool names (with optional patterns)
|
||||
///
|
||||
/// Format: "tool_name" or "tool_name:pattern"
|
||||
/// Example: "bash", "bash:npm test:*", "mcp:filesystem__*"
|
||||
pub fn add_allowed_tools(&mut self, tools: &[String]) {
|
||||
for spec in tools {
|
||||
if let Some((tool, pattern)) = Self::parse_tool_spec(spec) {
|
||||
self.add_rule(tool, pattern, Action::Allow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add disallowed tools from a list of tool names (with optional patterns)
|
||||
///
|
||||
/// Format: "tool_name" or "tool_name:pattern"
|
||||
/// Example: "bash", "bash:rm -rf*"
|
||||
pub fn add_disallowed_tools(&mut self, tools: &[String]) {
|
||||
for spec in tools {
|
||||
if let Some((tool, pattern)) = Self::parse_tool_spec(spec) {
|
||||
self.add_rule(tool, pattern, Action::Deny);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a tool specification into (Tool, Option<pattern>)
|
||||
///
|
||||
/// Format: "tool_name" or "tool_name:pattern"
|
||||
fn parse_tool_spec(spec: &str) -> Option<(Tool, Option<String>)> {
|
||||
let parts: Vec<&str> = spec.splitn(2, ':').collect();
|
||||
let tool_name = parts[0].trim();
|
||||
let pattern = parts.get(1).map(|s| s.trim().to_string());
|
||||
|
||||
Tool::from_str(tool_name).map(|tool| (tool, pattern))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -247,4 +352,78 @@ mod tests {
|
||||
assert!(rule.matches(Tool::Mcp, Some("filesystem__read_file")));
|
||||
assert!(!rule.matches(Tool::Mcp, Some("filesystem__write_file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_from_str() {
|
||||
assert_eq!(Tool::from_str("bash"), Some(Tool::Bash));
|
||||
assert_eq!(Tool::from_str("BASH"), Some(Tool::Bash));
|
||||
assert_eq!(Tool::from_str("Bash"), Some(Tool::Bash));
|
||||
assert_eq!(Tool::from_str("web_fetch"), Some(Tool::WebFetch));
|
||||
assert_eq!(Tool::from_str("webfetch"), Some(Tool::WebFetch));
|
||||
assert_eq!(Tool::from_str("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_spec() {
|
||||
let (tool, pattern) = PermissionManager::parse_tool_spec("bash").unwrap();
|
||||
assert_eq!(tool, Tool::Bash);
|
||||
assert_eq!(pattern, None);
|
||||
|
||||
let (tool, pattern) = PermissionManager::parse_tool_spec("bash:npm test*").unwrap();
|
||||
assert_eq!(tool, Tool::Bash);
|
||||
assert_eq!(pattern, Some("npm test*".to_string()));
|
||||
|
||||
let (tool, pattern) = PermissionManager::parse_tool_spec("mcp:filesystem__*").unwrap();
|
||||
assert_eq!(tool, Tool::Mcp);
|
||||
assert_eq!(pattern, Some("filesystem__*".to_string()));
|
||||
|
||||
assert!(PermissionManager::parse_tool_spec("invalid_tool").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_list() {
|
||||
let mut pm = PermissionManager::new(Mode::Plan);
|
||||
|
||||
pm.add_allowed_tools(&[
|
||||
"bash:npm test:*".to_string(),
|
||||
"bash:cargo test".to_string(),
|
||||
]);
|
||||
|
||||
// Allowed by rule
|
||||
assert_eq!(pm.check(Tool::Bash, Some("npm test:unit")), PermissionDecision::Allow);
|
||||
assert_eq!(pm.check(Tool::Bash, Some("cargo test")), PermissionDecision::Allow);
|
||||
|
||||
// Not matched by any rule, falls back to mode default (Ask for bash in plan mode)
|
||||
assert_eq!(pm.check(Tool::Bash, Some("rm -rf")), PermissionDecision::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disallowed_tools_list() {
|
||||
let mut pm = PermissionManager::new(Mode::Code);
|
||||
|
||||
pm.add_disallowed_tools(&[
|
||||
"bash:rm -rf*".to_string(),
|
||||
"bash:sudo*".to_string(),
|
||||
]);
|
||||
|
||||
// Denied by rule
|
||||
assert_eq!(pm.check(Tool::Bash, Some("rm -rf /")), PermissionDecision::Deny);
|
||||
assert_eq!(pm.check(Tool::Bash, Some("sudo apt install")), PermissionDecision::Deny);
|
||||
|
||||
// Not matched by deny rule, allowed by Code mode
|
||||
assert_eq!(pm.check(Tool::Bash, Some("npm test")), PermissionDecision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_takes_precedence() {
|
||||
let mut pm = PermissionManager::new(Mode::Code);
|
||||
|
||||
// Add both allow and deny for similar patterns
|
||||
pm.add_disallowed_tools(&["bash:rm*".to_string()]);
|
||||
pm.add_allowed_tools(&["bash".to_string()]);
|
||||
|
||||
// Deny rule was added first, so it takes precedence when matched
|
||||
assert_eq!(pm.check(Tool::Bash, Some("rm -rf")), PermissionDecision::Deny);
|
||||
assert_eq!(pm.check(Tool::Bash, Some("ls -la")), PermissionDecision::Allow);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user