فهرست منبع

Enforce tool permissions before execution

The Rust CLI/runtime now models permissions as ordered access levels, derives tool requirements from the shared tool specs, and prompts REPL users before one-off danger-full-access escalations from workspace-write sessions. This also wires explicit --permission-mode parsing and makes /permissions operate on the live session state instead of an implicit env-derived default.

Constraint: Must preserve the existing three user-facing modes read-only, workspace-write, and danger-full-access

Constraint: Must avoid new dependencies and keep enforcement inside the existing runtime/tool plumbing

Rejected: Keep the old Allow/Deny/Prompt policy model | could not represent ordered tool requirements across the CLI surface

Rejected: Continue sourcing live session mode solely from RUSTY_CLAUDE_PERMISSION_MODE | /permissions would not reliably reflect the current session state

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Add required_permission entries for new tools before exposing them to the runtime

Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test -q

Not-tested: Manual interactive REPL approval flow in a live Anthropic session
Yeachan-Heo 2 ماه پیش
والد
کامیت
3efb38cf99
4فایلهای تغییر یافته به همراه330 افزوده شده و 100 حذف شده
  1. 6 4
      rust/crates/runtime/src/conversation.rs
  2. 146 36
      rust/crates/runtime/src/permissions.rs
  3. 157 59
      rust/crates/rusty-claude-cli/src/main.rs
  4. 21 1
      rust/crates/tools/src/lib.rs

+ 6 - 4
rust/crates/runtime/src/conversation.rs

@@ -408,7 +408,8 @@ mod tests {
                 .sum::<i32>();
             Ok(total.to_string())
         });
-        let permission_policy = PermissionPolicy::new(PermissionMode::Prompt);
+        let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
+            .with_tool_requirement("add", PermissionMode::DangerFullAccess);
         let system_prompt = SystemPromptBuilder::new()
             .with_project_context(ProjectContext {
                 cwd: PathBuf::from("/tmp/project"),
@@ -487,7 +488,8 @@ mod tests {
             Session::new(),
             SingleCallApiClient,
             StaticToolExecutor::new(),
-            PermissionPolicy::new(PermissionMode::Prompt),
+            PermissionPolicy::new(PermissionMode::WorkspaceWrite)
+                .with_tool_requirement("blocked", PermissionMode::DangerFullAccess),
             vec!["system".to_string()],
         );
 
@@ -536,7 +538,7 @@ mod tests {
             session,
             SimpleApi,
             StaticToolExecutor::new(),
-            PermissionPolicy::new(PermissionMode::Allow),
+            PermissionPolicy::new(PermissionMode::ReadOnly),
             vec!["system".to_string()],
         );
 
@@ -563,7 +565,7 @@ mod tests {
             Session::new(),
             SimpleApi,
             StaticToolExecutor::new(),
-            PermissionPolicy::new(PermissionMode::Allow),
+            PermissionPolicy::new(PermissionMode::ReadOnly),
             vec!["system".to_string()],
         );
         runtime.run_turn("a", None).expect("turn a");

+ 146 - 36
rust/crates/runtime/src/permissions.rs

@@ -1,16 +1,29 @@
 use std::collections::BTreeMap;
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
 pub enum PermissionMode {
-    Allow,
-    Deny,
-    Prompt,
+    ReadOnly,
+    WorkspaceWrite,
+    DangerFullAccess,
+}
+
+impl PermissionMode {
+    #[must_use]
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Self::ReadOnly => "read-only",
+            Self::WorkspaceWrite => "workspace-write",
+            Self::DangerFullAccess => "danger-full-access",
+        }
+    }
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct PermissionRequest {
     pub tool_name: String,
     pub input: String,
+    pub current_mode: PermissionMode,
+    pub required_mode: PermissionMode,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -31,31 +44,41 @@ pub enum PermissionOutcome {
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct PermissionPolicy {
-    default_mode: PermissionMode,
-    tool_modes: BTreeMap<String, PermissionMode>,
+    active_mode: PermissionMode,
+    tool_requirements: BTreeMap<String, PermissionMode>,
 }
 
 impl PermissionPolicy {
     #[must_use]
-    pub fn new(default_mode: PermissionMode) -> Self {
+    pub fn new(active_mode: PermissionMode) -> Self {
         Self {
-            default_mode,
-            tool_modes: BTreeMap::new(),
+            active_mode,
+            tool_requirements: BTreeMap::new(),
         }
     }
 
     #[must_use]
-    pub fn with_tool_mode(mut self, tool_name: impl Into<String>, mode: PermissionMode) -> Self {
-        self.tool_modes.insert(tool_name.into(), mode);
+    pub fn with_tool_requirement(
+        mut self,
+        tool_name: impl Into<String>,
+        required_mode: PermissionMode,
+    ) -> Self {
+        self.tool_requirements
+            .insert(tool_name.into(), required_mode);
         self
     }
 
     #[must_use]
-    pub fn mode_for(&self, tool_name: &str) -> PermissionMode {
-        self.tool_modes
+    pub fn active_mode(&self) -> PermissionMode {
+        self.active_mode
+    }
+
+    #[must_use]
+    pub fn required_mode_for(&self, tool_name: &str) -> PermissionMode {
+        self.tool_requirements
             .get(tool_name)
             .copied()
-            .unwrap_or(self.default_mode)
+            .unwrap_or(PermissionMode::DangerFullAccess)
     }
 
     #[must_use]
@@ -65,23 +88,43 @@ impl PermissionPolicy {
         input: &str,
         mut prompter: Option<&mut dyn PermissionPrompter>,
     ) -> PermissionOutcome {
-        match self.mode_for(tool_name) {
-            PermissionMode::Allow => PermissionOutcome::Allow,
-            PermissionMode::Deny => PermissionOutcome::Deny {
-                reason: format!("tool '{tool_name}' denied by permission policy"),
-            },
-            PermissionMode::Prompt => match prompter.as_mut() {
-                Some(prompter) => match prompter.decide(&PermissionRequest {
-                    tool_name: tool_name.to_string(),
-                    input: input.to_string(),
-                }) {
+        let current_mode = self.active_mode();
+        let required_mode = self.required_mode_for(tool_name);
+        if current_mode >= required_mode {
+            return PermissionOutcome::Allow;
+        }
+
+        let request = PermissionRequest {
+            tool_name: tool_name.to_string(),
+            input: input.to_string(),
+            current_mode,
+            required_mode,
+        };
+
+        if current_mode == PermissionMode::WorkspaceWrite
+            && required_mode == PermissionMode::DangerFullAccess
+        {
+            return match prompter.as_mut() {
+                Some(prompter) => match prompter.decide(&request) {
                     PermissionPromptDecision::Allow => PermissionOutcome::Allow,
                     PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
                 },
                 None => PermissionOutcome::Deny {
-                    reason: format!("tool '{tool_name}' requires interactive approval"),
+                    reason: format!(
+                        "tool '{tool_name}' requires approval to escalate from {} to {}",
+                        current_mode.as_str(),
+                        required_mode.as_str()
+                    ),
                 },
-            },
+            };
+        }
+
+        PermissionOutcome::Deny {
+            reason: format!(
+                "tool '{tool_name}' requires {} permission; current mode is {}",
+                required_mode.as_str(),
+                current_mode.as_str()
+            ),
         }
     }
 }
@@ -93,25 +136,92 @@ mod tests {
         PermissionPrompter, PermissionRequest,
     };
 
-    struct AllowPrompter;
+    struct RecordingPrompter {
+        seen: Vec<PermissionRequest>,
+        allow: bool,
+    }
 
-    impl PermissionPrompter for AllowPrompter {
+    impl PermissionPrompter for RecordingPrompter {
         fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
-            assert_eq!(request.tool_name, "bash");
-            PermissionPromptDecision::Allow
+            self.seen.push(request.clone());
+            if self.allow {
+                PermissionPromptDecision::Allow
+            } else {
+                PermissionPromptDecision::Deny {
+                    reason: "not now".to_string(),
+                }
+            }
         }
     }
 
     #[test]
-    fn uses_tool_specific_overrides() {
-        let policy = PermissionPolicy::new(PermissionMode::Deny)
-            .with_tool_mode("bash", PermissionMode::Prompt);
+    fn allows_tools_when_active_mode_meets_requirement() {
+        let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
+            .with_tool_requirement("read_file", PermissionMode::ReadOnly)
+            .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
+
+        assert_eq!(
+            policy.authorize("read_file", "{}", None),
+            PermissionOutcome::Allow
+        );
+        assert_eq!(
+            policy.authorize("write_file", "{}", None),
+            PermissionOutcome::Allow
+        );
+    }
+
+    #[test]
+    fn denies_read_only_escalations_without_prompt() {
+        let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
+            .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite)
+            .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
+
+        assert!(matches!(
+            policy.authorize("write_file", "{}", None),
+            PermissionOutcome::Deny { reason } if reason.contains("requires workspace-write permission")
+        ));
+        assert!(matches!(
+            policy.authorize("bash", "{}", None),
+            PermissionOutcome::Deny { reason } if reason.contains("requires danger-full-access permission")
+        ));
+    }
+
+    #[test]
+    fn prompts_for_workspace_write_to_danger_full_access_escalation() {
+        let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
+            .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
+        let mut prompter = RecordingPrompter {
+            seen: Vec::new(),
+            allow: true,
+        };
+
+        let outcome = policy.authorize("bash", "echo hi", Some(&mut prompter));
 
-        let outcome = policy.authorize("bash", "echo hi", Some(&mut AllowPrompter));
         assert_eq!(outcome, PermissionOutcome::Allow);
+        assert_eq!(prompter.seen.len(), 1);
+        assert_eq!(prompter.seen[0].tool_name, "bash");
+        assert_eq!(
+            prompter.seen[0].current_mode,
+            PermissionMode::WorkspaceWrite
+        );
+        assert_eq!(
+            prompter.seen[0].required_mode,
+            PermissionMode::DangerFullAccess
+        );
+    }
+
+    #[test]
+    fn honors_prompt_rejection_reason() {
+        let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
+            .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
+        let mut prompter = RecordingPrompter {
+            seen: Vec::new(),
+            allow: false,
+        };
+
         assert!(matches!(
-            policy.authorize("edit", "x", None),
-            PermissionOutcome::Deny { .. }
+            policy.authorize("bash", "echo hi", Some(&mut prompter)),
+            PermissionOutcome::Deny { reason } if reason == "not now"
         ));
     }
 }

+ 157 - 59
rust/crates/rusty-claude-cli/src/main.rs

@@ -28,7 +28,7 @@ use runtime::{
     Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
 };
 use serde_json::json;
-use tools::{execute_tool, mvp_tool_specs};
+use tools::{execute_tool, mvp_tool_specs, ToolSpec};
 
 const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
 const DEFAULT_MAX_TOKENS: u32 = 32;
@@ -67,14 +67,16 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
             model,
             output_format,
             allowed_tools,
-        } => LiveCli::new(model, false, allowed_tools)?
+            permission_mode,
+        } => LiveCli::new(model, false, allowed_tools, permission_mode)?
             .run_turn_with_output(&prompt, output_format)?,
         CliAction::Login => run_login()?,
         CliAction::Logout => run_logout()?,
         CliAction::Repl {
             model,
             allowed_tools,
-        } => run_repl(model, allowed_tools)?,
+            permission_mode,
+        } => run_repl(model, allowed_tools, permission_mode)?,
         CliAction::Help => print_help(),
     }
     Ok(())
@@ -98,12 +100,14 @@ enum CliAction {
         model: String,
         output_format: CliOutputFormat,
         allowed_tools: Option<AllowedToolSet>,
+        permission_mode: PermissionMode,
     },
     Login,
     Logout,
     Repl {
         model: String,
         allowed_tools: Option<AllowedToolSet>,
+        permission_mode: PermissionMode,
     },
     // prompt-mode formatting is only supported for non-interactive runs
     Help,
@@ -127,9 +131,11 @@ impl CliOutputFormat {
     }
 }
 
+#[allow(clippy::too_many_lines)]
 fn parse_args(args: &[String]) -> Result<CliAction, String> {
     let mut model = DEFAULT_MODEL.to_string();
     let mut output_format = CliOutputFormat::Text;
+    let mut permission_mode = default_permission_mode();
     let mut wants_version = false;
     let mut allowed_tool_values = Vec::new();
     let mut rest = Vec::new();
@@ -159,10 +165,21 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
                 output_format = CliOutputFormat::parse(value)?;
                 index += 2;
             }
+            "--permission-mode" => {
+                let value = args
+                    .get(index + 1)
+                    .ok_or_else(|| "missing value for --permission-mode".to_string())?;
+                permission_mode = parse_permission_mode_arg(value)?;
+                index += 2;
+            }
             flag if flag.starts_with("--output-format=") => {
                 output_format = CliOutputFormat::parse(&flag[16..])?;
                 index += 1;
             }
+            flag if flag.starts_with("--permission-mode=") => {
+                permission_mode = parse_permission_mode_arg(&flag[18..])?;
+                index += 1;
+            }
             "--allowedTools" | "--allowed-tools" => {
                 let value = args
                     .get(index + 1)
@@ -195,6 +212,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
         return Ok(CliAction::Repl {
             model,
             allowed_tools,
+            permission_mode,
         });
     }
     if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
@@ -220,6 +238,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
                 model,
                 output_format,
                 allowed_tools,
+                permission_mode,
             })
         }
         other if !other.starts_with('/') => Ok(CliAction::Prompt {
@@ -227,6 +246,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
             model,
             output_format,
             allowed_tools,
+            permission_mode,
         }),
         other => Err(format!("unknown subcommand: {other}")),
     }
@@ -280,6 +300,33 @@ fn normalize_tool_name(value: &str) -> String {
     value.trim().replace('-', "_").to_ascii_lowercase()
 }
 
+fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
+    normalize_permission_mode(value)
+        .ok_or_else(|| {
+            format!(
+                "unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access."
+            )
+        })
+        .map(permission_mode_from_label)
+}
+
+fn permission_mode_from_label(mode: &str) -> PermissionMode {
+    match mode {
+        "read-only" => PermissionMode::ReadOnly,
+        "workspace-write" => PermissionMode::WorkspaceWrite,
+        "danger-full-access" => PermissionMode::DangerFullAccess,
+        other => panic!("unsupported permission mode label: {other}"),
+    }
+}
+
+fn default_permission_mode() -> PermissionMode {
+    env::var("RUSTY_CLAUDE_PERMISSION_MODE")
+        .ok()
+        .as_deref()
+        .and_then(normalize_permission_mode)
+        .map_or(PermissionMode::WorkspaceWrite, permission_mode_from_label)
+}
+
 fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec<tools::ToolSpec> {
     mvp_tool_specs()
         .into_iter()
@@ -786,7 +833,7 @@ fn run_resume_command(
                         cumulative: usage,
                         estimated_tokens: 0,
                     },
-                    permission_mode_label(),
+                    default_permission_mode().as_str(),
                     &status_context(Some(session_path))?,
                 )),
             })
@@ -841,8 +888,9 @@ fn run_resume_command(
 fn run_repl(
     model: String,
     allowed_tools: Option<AllowedToolSet>,
+    permission_mode: PermissionMode,
 ) -> Result<(), Box<dyn std::error::Error>> {
-    let mut cli = LiveCli::new(model, true, allowed_tools)?;
+    let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
     let editor = input::LineEditor::new("› ");
     println!("{}", cli.startup_banner());
 
@@ -881,6 +929,7 @@ struct ManagedSessionSummary {
 struct LiveCli {
     model: String,
     allowed_tools: Option<AllowedToolSet>,
+    permission_mode: PermissionMode,
     system_prompt: Vec<String>,
     runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
     session: SessionHandle,
@@ -891,6 +940,7 @@ impl LiveCli {
         model: String,
         enable_tools: bool,
         allowed_tools: Option<AllowedToolSet>,
+        permission_mode: PermissionMode,
     ) -> Result<Self, Box<dyn std::error::Error>> {
         let system_prompt = build_system_prompt()?;
         let session = create_managed_session_handle()?;
@@ -900,10 +950,12 @@ impl LiveCli {
             system_prompt.clone(),
             enable_tools,
             allowed_tools.clone(),
+            permission_mode,
         )?;
         let cli = Self {
             model,
             allowed_tools,
+            permission_mode,
             system_prompt,
             runtime,
             session,
@@ -914,8 +966,9 @@ impl LiveCli {
 
     fn startup_banner(&self) -> String {
         format!(
-            "Rusty Claude CLI\n  Model            {}\n  Working directory {}\n  Session          {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.",
+            "Rusty Claude CLI\n  Model            {}\n  Permission mode  {}\n  Working directory {}\n  Session          {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.",
             self.model,
+            self.permission_mode.as_str(),
             env::current_dir().map_or_else(
                 |_| "<unknown>".to_string(),
                 |path| path.display().to_string(),
@@ -932,7 +985,8 @@ impl LiveCli {
             TerminalRenderer::new().color_theme(),
             &mut stdout,
         )?;
-        let result = self.runtime.run_turn(input, None);
+        let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
+        let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
         match result {
             Ok(_) => {
                 spinner.finish(
@@ -1055,7 +1109,7 @@ impl LiveCli {
                     cumulative,
                     estimated_tokens: self.runtime.estimated_tokens(),
                 },
-                permission_mode_label(),
+                self.permission_mode.as_str(),
                 &status_context(Some(&self.session.path)).expect("status context should load"),
             )
         );
@@ -1095,6 +1149,7 @@ impl LiveCli {
             self.system_prompt.clone(),
             true,
             self.allowed_tools.clone(),
+            self.permission_mode,
         )?;
         self.model.clone_from(&model);
         self.persist_session()?;
@@ -1107,7 +1162,10 @@ impl LiveCli {
 
     fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
         let Some(mode) = mode else {
-            println!("{}", format_permissions_report(permission_mode_label()));
+            println!(
+                "{}",
+                format_permissions_report(self.permission_mode.as_str())
+            );
             return Ok(());
         };
 
@@ -1117,20 +1175,21 @@ impl LiveCli {
             )
         })?;
 
-        if normalized == permission_mode_label() {
+        if normalized == self.permission_mode.as_str() {
             println!("{}", format_permissions_report(normalized));
             return Ok(());
         }
 
-        let previous = permission_mode_label().to_string();
+        let previous = self.permission_mode.as_str().to_string();
         let session = self.runtime.session().clone();
-        self.runtime = build_runtime_with_permission_mode(
+        self.permission_mode = permission_mode_from_label(normalized);
+        self.runtime = build_runtime(
             session,
             self.model.clone(),
             self.system_prompt.clone(),
             true,
             self.allowed_tools.clone(),
-            normalized,
+            self.permission_mode,
         )?;
         self.persist_session()?;
         println!(
@@ -1149,19 +1208,19 @@ impl LiveCli {
         }
 
         self.session = create_managed_session_handle()?;
-        self.runtime = build_runtime_with_permission_mode(
+        self.runtime = build_runtime(
             Session::new(),
             self.model.clone(),
             self.system_prompt.clone(),
             true,
             self.allowed_tools.clone(),
-            permission_mode_label(),
+            self.permission_mode,
         )?;
         self.persist_session()?;
         println!(
             "Session cleared\n  Mode             fresh session\n  Preserved model  {}\n  Permission mode  {}\n  Session          {}",
             self.model,
-            permission_mode_label(),
+            self.permission_mode.as_str(),
             self.session.id,
         );
         Ok(())
@@ -1184,13 +1243,13 @@ impl LiveCli {
         let handle = resolve_session_reference(&session_ref)?;
         let session = Session::load_from_path(&handle.path)?;
         let message_count = session.messages.len();
-        self.runtime = build_runtime_with_permission_mode(
+        self.runtime = build_runtime(
             session,
             self.model.clone(),
             self.system_prompt.clone(),
             true,
             self.allowed_tools.clone(),
-            permission_mode_label(),
+            self.permission_mode,
         )?;
         self.session = handle;
         self.persist_session()?;
@@ -1261,13 +1320,13 @@ impl LiveCli {
                 let handle = resolve_session_reference(target)?;
                 let session = Session::load_from_path(&handle.path)?;
                 let message_count = session.messages.len();
-                self.runtime = build_runtime_with_permission_mode(
+                self.runtime = build_runtime(
                     session,
                     self.model.clone(),
                     self.system_prompt.clone(),
                     true,
                     self.allowed_tools.clone(),
-                    permission_mode_label(),
+                    self.permission_mode,
                 )?;
                 self.session = handle;
                 self.persist_session()?;
@@ -1291,13 +1350,13 @@ impl LiveCli {
         let removed = result.removed_message_count;
         let kept = result.compacted_session.messages.len();
         let skipped = removed == 0;
-        self.runtime = build_runtime_with_permission_mode(
+        self.runtime = build_runtime(
             result.compacted_session,
             self.model.clone(),
             self.system_prompt.clone(),
             true,
             self.allowed_tools.clone(),
-            permission_mode_label(),
+            self.permission_mode,
         )?;
         self.persist_session()?;
         println!("{}", format_compact_report(removed, kept, skipped));
@@ -1686,14 +1745,6 @@ fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
     }
 }
 
-fn permission_mode_label() -> &'static str {
-    match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
-        Ok(value) if value == "read-only" => "read-only",
-        Ok(value) if value == "danger-full-access" => "danger-full-access",
-        _ => "workspace-write",
-    }
-}
-
 fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
     let output = std::process::Command::new("git")
         .args(["diff", "--", ":(exclude).omx"])
@@ -1823,25 +1874,7 @@ fn build_runtime(
     system_prompt: Vec<String>,
     enable_tools: bool,
     allowed_tools: Option<AllowedToolSet>,
-) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
-{
-    build_runtime_with_permission_mode(
-        session,
-        model,
-        system_prompt,
-        enable_tools,
-        allowed_tools,
-        permission_mode_label(),
-    )
-}
-
-fn build_runtime_with_permission_mode(
-    session: Session,
-    model: String,
-    system_prompt: Vec<String>,
-    enable_tools: bool,
-    allowed_tools: Option<AllowedToolSet>,
-    permission_mode: &str,
+    permission_mode: PermissionMode,
 ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
 {
     Ok(ConversationRuntime::new(
@@ -1853,6 +1886,52 @@ fn build_runtime_with_permission_mode(
     ))
 }
 
+struct CliPermissionPrompter {
+    current_mode: PermissionMode,
+}
+
+impl CliPermissionPrompter {
+    fn new(current_mode: PermissionMode) -> Self {
+        Self { current_mode }
+    }
+}
+
+impl runtime::PermissionPrompter for CliPermissionPrompter {
+    fn decide(
+        &mut self,
+        request: &runtime::PermissionRequest,
+    ) -> runtime::PermissionPromptDecision {
+        println!();
+        println!("Permission approval required");
+        println!("  Tool             {}", request.tool_name);
+        println!("  Current mode     {}", self.current_mode.as_str());
+        println!("  Required mode    {}", request.required_mode.as_str());
+        println!("  Input            {}", request.input);
+        print!("Approve this tool call? [y/N]: ");
+        let _ = io::stdout().flush();
+
+        let mut response = String::new();
+        match io::stdin().read_line(&mut response) {
+            Ok(_) => {
+                let normalized = response.trim().to_ascii_lowercase();
+                if matches!(normalized.as_str(), "y" | "yes") {
+                    runtime::PermissionPromptDecision::Allow
+                } else {
+                    runtime::PermissionPromptDecision::Deny {
+                        reason: format!(
+                            "tool '{}' denied by user approval prompt",
+                            request.tool_name
+                        ),
+                    }
+                }
+            }
+            Err(error) => runtime::PermissionPromptDecision::Deny {
+                reason: format!("permission approval failed: {error}"),
+            },
+        }
+    }
+}
+
 struct AnthropicRuntimeClient {
     runtime: tokio::runtime::Runtime,
     client: AnthropicClient,
@@ -2096,15 +2175,16 @@ impl ToolExecutor for CliToolExecutor {
     }
 }
 
-fn permission_policy(mode: &str) -> PermissionPolicy {
-    if normalize_permission_mode(mode) == Some("read-only") {
-        PermissionPolicy::new(PermissionMode::Deny)
-            .with_tool_mode("read_file", PermissionMode::Allow)
-            .with_tool_mode("glob_search", PermissionMode::Allow)
-            .with_tool_mode("grep_search", PermissionMode::Allow)
-    } else {
-        PermissionPolicy::new(PermissionMode::Allow)
-    }
+fn permission_policy(mode: PermissionMode) -> PermissionPolicy {
+    tool_permission_specs()
+        .into_iter()
+        .fold(PermissionPolicy::new(mode), |policy, spec| {
+            policy.with_tool_requirement(spec.name, spec.required_permission)
+        })
+}
+
+fn tool_permission_specs() -> Vec<ToolSpec> {
+    mvp_tool_specs()
 }
 
 fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
@@ -2169,6 +2249,7 @@ fn print_help() {
     println!("Flags:");
     println!("  --model MODEL              Override the active model");
     println!("  --output-format FORMAT     Non-interactive output format: text or json");
+    println!("  --permission-mode MODE     Set read-only, workspace-write, or danger-full-access");
     println!("  --allowedTools TOOLS       Restrict enabled tools (repeatable; comma-separated aliases supported)");
     println!("  --version, -V              Print version and build information locally");
     println!();
@@ -2203,7 +2284,7 @@ mod tests {
         resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand,
         StatusUsage, DEFAULT_MODEL,
     };
-    use runtime::{ContentBlock, ConversationMessage, MessageRole};
+    use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
     use std::path::{Path, PathBuf};
 
     #[test]
@@ -2213,6 +2294,7 @@ mod tests {
             CliAction::Repl {
                 model: DEFAULT_MODEL.to_string(),
                 allowed_tools: None,
+                permission_mode: PermissionMode::WorkspaceWrite,
             }
         );
     }
@@ -2231,6 +2313,7 @@ mod tests {
                 model: DEFAULT_MODEL.to_string(),
                 output_format: CliOutputFormat::Text,
                 allowed_tools: None,
+                permission_mode: PermissionMode::WorkspaceWrite,
             }
         );
     }
@@ -2251,6 +2334,7 @@ mod tests {
                 model: "claude-opus".to_string(),
                 output_format: CliOutputFormat::Json,
                 allowed_tools: None,
+                permission_mode: PermissionMode::WorkspaceWrite,
             }
         );
     }
@@ -2267,6 +2351,19 @@ mod tests {
         );
     }
 
+    #[test]
+    fn parses_permission_mode_flag() {
+        let args = vec!["--permission-mode=read-only".to_string()];
+        assert_eq!(
+            parse_args(&args).expect("args should parse"),
+            CliAction::Repl {
+                model: DEFAULT_MODEL.to_string(),
+                allowed_tools: None,
+                permission_mode: PermissionMode::ReadOnly,
+            }
+        );
+    }
+
     #[test]
     fn parses_allowed_tools_flags_with_aliases_and_lists() {
         let args = vec![
@@ -2284,6 +2381,7 @@ mod tests {
                         .map(str::to_string)
                         .collect()
                 ),
+                permission_mode: PermissionMode::WorkspaceWrite,
             }
         );
     }

+ 21 - 1
rust/crates/tools/src/lib.rs

@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
 use reqwest::blocking::Client;
 use runtime::{
     edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
-    GrepSearchInput,
+    GrepSearchInput, PermissionMode,
 };
 use serde::{Deserialize, Serialize};
 use serde_json::{json, Value};
@@ -45,6 +45,7 @@ pub struct ToolSpec {
     pub name: &'static str,
     pub description: &'static str,
     pub input_schema: Value,
+    pub required_permission: PermissionMode,
 }
 
 #[must_use]
@@ -66,6 +67,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["command"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::DangerFullAccess,
         },
         ToolSpec {
             name: "read_file",
@@ -80,6 +82,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["path"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::ReadOnly,
         },
         ToolSpec {
             name: "write_file",
@@ -93,6 +96,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["path", "content"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::WorkspaceWrite,
         },
         ToolSpec {
             name: "edit_file",
@@ -108,6 +112,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["path", "old_string", "new_string"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::WorkspaceWrite,
         },
         ToolSpec {
             name: "glob_search",
@@ -121,6 +126,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["pattern"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::ReadOnly,
         },
         ToolSpec {
             name: "grep_search",
@@ -146,6 +152,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["pattern"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::ReadOnly,
         },
         ToolSpec {
             name: "WebFetch",
@@ -160,6 +167,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["url", "prompt"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::ReadOnly,
         },
         ToolSpec {
             name: "WebSearch",
@@ -180,6 +188,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["query"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::ReadOnly,
         },
         ToolSpec {
             name: "TodoWrite",
@@ -207,6 +216,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["todos"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::WorkspaceWrite,
         },
         ToolSpec {
             name: "Skill",
@@ -220,6 +230,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["skill"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::ReadOnly,
         },
         ToolSpec {
             name: "Agent",
@@ -236,6 +247,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["description", "prompt"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::DangerFullAccess,
         },
         ToolSpec {
             name: "ToolSearch",
@@ -249,6 +261,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["query"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::ReadOnly,
         },
         ToolSpec {
             name: "NotebookEdit",
@@ -265,6 +278,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["notebook_path"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::WorkspaceWrite,
         },
         ToolSpec {
             name: "Sleep",
@@ -277,6 +291,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["duration_ms"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::ReadOnly,
         },
         ToolSpec {
             name: "SendUserMessage",
@@ -297,6 +312,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["message", "status"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::ReadOnly,
         },
         ToolSpec {
             name: "Config",
@@ -312,6 +328,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["setting"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::WorkspaceWrite,
         },
         ToolSpec {
             name: "StructuredOutput",
@@ -320,6 +337,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "type": "object",
                 "additionalProperties": true
             }),
+            required_permission: PermissionMode::ReadOnly,
         },
         ToolSpec {
             name: "REPL",
@@ -334,6 +352,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["code", "language"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::DangerFullAccess,
         },
         ToolSpec {
             name: "PowerShell",
@@ -349,6 +368,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
                 "required": ["command"],
                 "additionalProperties": false
             }),
+            required_permission: PermissionMode::DangerFullAccess,
         },
     ]
 }