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
This commit is contained in:
Yeachan-Heo
2026-04-01 00:06:15 +00:00
parent c139fe9bee
commit e2f061fd08
4 changed files with 331 additions and 101 deletions

View File

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

View File

@@ -1,16 +1,29 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionMode { pub enum PermissionMode {
Allow, ReadOnly,
Deny, WorkspaceWrite,
Prompt, 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionRequest { pub struct PermissionRequest {
pub tool_name: String, pub tool_name: String,
pub input: String, pub input: String,
pub current_mode: PermissionMode,
pub required_mode: PermissionMode,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -31,31 +44,41 @@ pub enum PermissionOutcome {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionPolicy { pub struct PermissionPolicy {
default_mode: PermissionMode, active_mode: PermissionMode,
tool_modes: BTreeMap<String, PermissionMode>, tool_requirements: BTreeMap<String, PermissionMode>,
} }
impl PermissionPolicy { impl PermissionPolicy {
#[must_use] #[must_use]
pub fn new(default_mode: PermissionMode) -> Self { pub fn new(active_mode: PermissionMode) -> Self {
Self { Self {
default_mode, active_mode,
tool_modes: BTreeMap::new(), tool_requirements: BTreeMap::new(),
} }
} }
#[must_use] #[must_use]
pub fn with_tool_mode(mut self, tool_name: impl Into<String>, mode: PermissionMode) -> Self { pub fn with_tool_requirement(
self.tool_modes.insert(tool_name.into(), mode); mut self,
tool_name: impl Into<String>,
required_mode: PermissionMode,
) -> Self {
self.tool_requirements
.insert(tool_name.into(), required_mode);
self self
} }
#[must_use] #[must_use]
pub fn mode_for(&self, tool_name: &str) -> PermissionMode { pub fn active_mode(&self) -> PermissionMode {
self.tool_modes self.active_mode
}
#[must_use]
pub fn required_mode_for(&self, tool_name: &str) -> PermissionMode {
self.tool_requirements
.get(tool_name) .get(tool_name)
.copied() .copied()
.unwrap_or(self.default_mode) .unwrap_or(PermissionMode::DangerFullAccess)
} }
#[must_use] #[must_use]
@@ -65,23 +88,43 @@ impl PermissionPolicy {
input: &str, input: &str,
mut prompter: Option<&mut dyn PermissionPrompter>, mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome { ) -> PermissionOutcome {
match self.mode_for(tool_name) { let current_mode = self.active_mode();
PermissionMode::Allow => PermissionOutcome::Allow, let required_mode = self.required_mode_for(tool_name);
PermissionMode::Deny => PermissionOutcome::Deny { if current_mode >= required_mode {
reason: format!("tool '{tool_name}' denied by permission policy"), return PermissionOutcome::Allow;
}, }
PermissionMode::Prompt => match prompter.as_mut() {
Some(prompter) => match prompter.decide(&PermissionRequest { let request = PermissionRequest {
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
input: input.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::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason }, PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
}, },
None => PermissionOutcome::Deny { 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, 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 { fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
assert_eq!(request.tool_name, "bash"); self.seen.push(request.clone());
if self.allow {
PermissionPromptDecision::Allow PermissionPromptDecision::Allow
} else {
PermissionPromptDecision::Deny {
reason: "not now".to_string(),
}
}
} }
} }
#[test] #[test]
fn uses_tool_specific_overrides() { fn allows_tools_when_active_mode_meets_requirement() {
let policy = PermissionPolicy::new(PermissionMode::Deny) let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
.with_tool_mode("bash", PermissionMode::Prompt); .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);
let outcome = policy.authorize("bash", "echo hi", Some(&mut AllowPrompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert!(matches!( assert!(matches!(
policy.authorize("edit", "x", None), policy.authorize("write_file", "{}", None),
PermissionOutcome::Deny { .. } 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));
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("bash", "echo hi", Some(&mut prompter)),
PermissionOutcome::Deny { reason } if reason == "not now"
)); ));
} }
} }

View File

@@ -28,7 +28,7 @@ use runtime::{
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
}; };
use serde_json::json; 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_MODEL: &str = "claude-sonnet-4-20250514";
const DEFAULT_MAX_TOKENS: u32 = 32; const DEFAULT_MAX_TOKENS: u32 = 32;
@@ -67,14 +67,16 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
model, model,
output_format, output_format,
allowed_tools, 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)?, .run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?, CliAction::Login => run_login()?,
CliAction::Logout => run_logout()?, CliAction::Logout => run_logout()?,
CliAction::Repl { CliAction::Repl {
model, model,
allowed_tools, allowed_tools,
} => run_repl(model, allowed_tools)?, permission_mode,
} => run_repl(model, allowed_tools, permission_mode)?,
CliAction::Help => print_help(), CliAction::Help => print_help(),
} }
Ok(()) Ok(())
@@ -98,12 +100,14 @@ enum CliAction {
model: String, model: String,
output_format: CliOutputFormat, output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
}, },
Login, Login,
Logout, Logout,
Repl { Repl {
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
}, },
// prompt-mode formatting is only supported for non-interactive runs // prompt-mode formatting is only supported for non-interactive runs
Help, Help,
@@ -127,9 +131,11 @@ impl CliOutputFormat {
} }
} }
#[allow(clippy::too_many_lines)]
fn parse_args(args: &[String]) -> Result<CliAction, String> { fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut model = DEFAULT_MODEL.to_string(); let mut model = DEFAULT_MODEL.to_string();
let mut output_format = CliOutputFormat::Text; let mut output_format = CliOutputFormat::Text;
let mut permission_mode = default_permission_mode();
let mut wants_version = false; let mut wants_version = false;
let mut allowed_tool_values = Vec::new(); let mut allowed_tool_values = Vec::new();
let mut rest = Vec::new(); let mut rest = Vec::new();
@@ -159,10 +165,21 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format = CliOutputFormat::parse(value)?; output_format = CliOutputFormat::parse(value)?;
index += 2; 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=") => { flag if flag.starts_with("--output-format=") => {
output_format = CliOutputFormat::parse(&flag[16..])?; output_format = CliOutputFormat::parse(&flag[16..])?;
index += 1; index += 1;
} }
flag if flag.starts_with("--permission-mode=") => {
permission_mode = parse_permission_mode_arg(&flag[18..])?;
index += 1;
}
"--allowedTools" | "--allowed-tools" => { "--allowedTools" | "--allowed-tools" => {
let value = args let value = args
.get(index + 1) .get(index + 1)
@@ -195,6 +212,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
return Ok(CliAction::Repl { return Ok(CliAction::Repl {
model, model,
allowed_tools, allowed_tools,
permission_mode,
}); });
} }
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
@@ -220,6 +238,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model, model,
output_format, output_format,
allowed_tools, allowed_tools,
permission_mode,
}) })
} }
other if !other.starts_with('/') => Ok(CliAction::Prompt { other if !other.starts_with('/') => Ok(CliAction::Prompt {
@@ -227,6 +246,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model, model,
output_format, output_format,
allowed_tools, allowed_tools,
permission_mode,
}), }),
other => Err(format!("unknown subcommand: {other}")), other => Err(format!("unknown subcommand: {other}")),
} }
@@ -280,6 +300,33 @@ fn normalize_tool_name(value: &str) -> String {
value.trim().replace('-', "_").to_ascii_lowercase() 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> { fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec<tools::ToolSpec> {
mvp_tool_specs() mvp_tool_specs()
.into_iter() .into_iter()
@@ -786,7 +833,7 @@ fn run_resume_command(
cumulative: usage, cumulative: usage,
estimated_tokens: 0, estimated_tokens: 0,
}, },
permission_mode_label(), default_permission_mode().as_str(),
&status_context(Some(session_path))?, &status_context(Some(session_path))?,
)), )),
}) })
@@ -841,8 +888,9 @@ fn run_resume_command(
fn run_repl( fn run_repl(
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<(), Box<dyn std::error::Error>> { ) -> 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(" "); let editor = input::LineEditor::new(" ");
println!("{}", cli.startup_banner()); println!("{}", cli.startup_banner());
@@ -881,6 +929,7 @@ struct ManagedSessionSummary {
struct LiveCli { struct LiveCli {
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
system_prompt: Vec<String>, system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
session: SessionHandle, session: SessionHandle,
@@ -891,6 +940,7 @@ impl LiveCli {
model: String, model: String,
enable_tools: bool, enable_tools: bool,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?; let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?; let session = create_managed_session_handle()?;
@@ -900,10 +950,12 @@ impl LiveCli {
system_prompt.clone(), system_prompt.clone(),
enable_tools, enable_tools,
allowed_tools.clone(), allowed_tools.clone(),
permission_mode,
)?; )?;
let cli = Self { let cli = Self {
model, model,
allowed_tools, allowed_tools,
permission_mode,
system_prompt, system_prompt,
runtime, runtime,
session, session,
@@ -914,8 +966,9 @@ impl LiveCli {
fn startup_banner(&self) -> String { fn startup_banner(&self) -> String {
format!( 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.model,
self.permission_mode.as_str(),
env::current_dir().map_or_else( env::current_dir().map_or_else(
|_| "<unknown>".to_string(), |_| "<unknown>".to_string(),
|path| path.display().to_string(), |path| path.display().to_string(),
@@ -932,7 +985,8 @@ impl LiveCli {
TerminalRenderer::new().color_theme(), TerminalRenderer::new().color_theme(),
&mut stdout, &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 { match result {
Ok(_) => { Ok(_) => {
spinner.finish( spinner.finish(
@@ -1055,7 +1109,7 @@ impl LiveCli {
cumulative, cumulative,
estimated_tokens: self.runtime.estimated_tokens(), 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"), &status_context(Some(&self.session.path)).expect("status context should load"),
) )
); );
@@ -1095,6 +1149,7 @@ impl LiveCli {
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
self.permission_mode,
)?; )?;
self.model.clone_from(&model); self.model.clone_from(&model);
self.persist_session()?; self.persist_session()?;
@@ -1107,7 +1162,10 @@ impl LiveCli {
fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> { fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
let Some(mode) = mode else { let Some(mode) = mode else {
println!("{}", format_permissions_report(permission_mode_label())); println!(
"{}",
format_permissions_report(self.permission_mode.as_str())
);
return Ok(()); 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)); println!("{}", format_permissions_report(normalized));
return Ok(()); return Ok(());
} }
let previous = permission_mode_label().to_string(); let previous = self.permission_mode.as_str().to_string();
let session = self.runtime.session().clone(); 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, session,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
normalized, self.permission_mode,
)?; )?;
self.persist_session()?; self.persist_session()?;
println!( println!(
@@ -1149,19 +1208,19 @@ impl LiveCli {
} }
self.session = create_managed_session_handle()?; self.session = create_managed_session_handle()?;
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime(
Session::new(), Session::new(),
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
permission_mode_label(), self.permission_mode,
)?; )?;
self.persist_session()?; self.persist_session()?;
println!( println!(
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
self.model, self.model,
permission_mode_label(), self.permission_mode.as_str(),
self.session.id, self.session.id,
); );
Ok(()) Ok(())
@@ -1184,13 +1243,13 @@ impl LiveCli {
let handle = resolve_session_reference(&session_ref)?; let handle = resolve_session_reference(&session_ref)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); let message_count = session.messages.len();
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
permission_mode_label(), self.permission_mode,
)?; )?;
self.session = handle; self.session = handle;
self.persist_session()?; self.persist_session()?;
@@ -1261,13 +1320,13 @@ impl LiveCli {
let handle = resolve_session_reference(target)?; let handle = resolve_session_reference(target)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); let message_count = session.messages.len();
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
permission_mode_label(), self.permission_mode,
)?; )?;
self.session = handle; self.session = handle;
self.persist_session()?; self.persist_session()?;
@@ -1291,13 +1350,13 @@ impl LiveCli {
let removed = result.removed_message_count; let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len(); let kept = result.compacted_session.messages.len();
let skipped = removed == 0; let skipped = removed == 0;
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime(
result.compacted_session, result.compacted_session,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
permission_mode_label(), self.permission_mode,
)?; )?;
self.persist_session()?; self.persist_session()?;
println!("{}", format_compact_report(removed, kept, skipped)); 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>> { fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git") let output = std::process::Command::new("git")
.args(["diff", "--", ":(exclude).omx"]) .args(["diff", "--", ":(exclude).omx"])
@@ -1823,25 +1874,7 @@ fn build_runtime(
system_prompt: Vec<String>, system_prompt: Vec<String>,
enable_tools: bool, enable_tools: bool,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> permission_mode: PermissionMode,
{
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,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{ {
Ok(ConversationRuntime::new( 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 { struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime, runtime: tokio::runtime::Runtime,
client: AnthropicClient, client: AnthropicClient,
@@ -2096,15 +2175,16 @@ impl ToolExecutor for CliToolExecutor {
} }
} }
fn permission_policy(mode: &str) -> PermissionPolicy { fn permission_policy(mode: PermissionMode) -> PermissionPolicy {
if normalize_permission_mode(mode) == Some("read-only") { tool_permission_specs()
PermissionPolicy::new(PermissionMode::Deny) .into_iter()
.with_tool_mode("read_file", PermissionMode::Allow) .fold(PermissionPolicy::new(mode), |policy, spec| {
.with_tool_mode("glob_search", PermissionMode::Allow) policy.with_tool_requirement(spec.name, spec.required_permission)
.with_tool_mode("grep_search", PermissionMode::Allow) })
} else {
PermissionPolicy::new(PermissionMode::Allow)
} }
fn tool_permission_specs() -> Vec<ToolSpec> {
mvp_tool_specs()
} }
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> { fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
@@ -2169,6 +2249,7 @@ fn print_help() {
println!("Flags:"); println!("Flags:");
println!(" --model MODEL Override the active model"); println!(" --model MODEL Override the active model");
println!(" --output-format FORMAT Non-interactive output format: text or json"); 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!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)");
println!(" --version, -V Print version and build information locally"); println!(" --version, -V Print version and build information locally");
println!(); println!();
@@ -2203,7 +2284,7 @@ mod tests {
resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand,
StatusUsage, DEFAULT_MODEL, StatusUsage, DEFAULT_MODEL,
}; };
use runtime::{ContentBlock, ConversationMessage, MessageRole}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[test] #[test]
@@ -2213,6 +2294,7 @@ mod tests {
CliAction::Repl { CliAction::Repl {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
} }
); );
} }
@@ -2231,6 +2313,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
} }
); );
} }
@@ -2251,6 +2334,7 @@ mod tests {
model: "claude-opus".to_string(), model: "claude-opus".to_string(),
output_format: CliOutputFormat::Json, output_format: CliOutputFormat::Json,
allowed_tools: None, 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] #[test]
fn parses_allowed_tools_flags_with_aliases_and_lists() { fn parses_allowed_tools_flags_with_aliases_and_lists() {
let args = vec![ let args = vec![
@@ -2284,6 +2381,7 @@ mod tests {
.map(str::to_string) .map(str::to_string)
.collect() .collect()
), ),
permission_mode: PermissionMode::WorkspaceWrite,
} }
); );
} }

View File

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