Make tool approvals and summaries easier to understand

This adds a prompt-mode permission flow for the Rust CLI, surfaces permission policy details in the REPL, and improves tool output rendering with concise human-readable summaries before the raw JSON payload.

The goal is to make tool execution feel safer and more legible without changing the underlying runtime loop or adding a heavyweight UI layer.

Constraint: Keep the permission UX terminal-native and incremental

Constraint: Preserve existing allow and read-only behavior while adding prompt mode

Rejected: Build a full-screen interactive approval UI now | unnecessary complexity for this parity slice

Confidence: high

Scope-risk: narrow

Reversibility: clean

Directive: Keep raw tool JSON available even when adding richer summaries so debugging fidelity remains intact

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

Not-tested: Manual prompt-mode approvals against live API-driven tool calls
This commit is contained in:
Yeachan-Heo
2026-03-31 19:28:07 +00:00
parent d6a814258c
commit cb24430c56

View File

@@ -19,7 +19,8 @@ use render::{Spinner, TerminalRenderer};
use runtime::{ use runtime::{
estimate_session_tokens, load_system_prompt, ApiClient, ApiRequest, AssistantEvent, estimate_session_tokens, load_system_prompt, ApiClient, ApiRequest, AssistantEvent,
CompactionConfig, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, CompactionConfig, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
PermissionMode, PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
PermissionRequest, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
}; };
use tools::{execute_tool, mvp_tool_specs}; use tools::{execute_tool, mvp_tool_specs};
@@ -347,12 +348,16 @@ fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
"/exit" | "/quit" => break, "/exit" | "/quit" => break,
"/help" => { "/help" => {
println!("Available commands:"); println!("Available commands:");
println!(" /help Show help"); println!(" /help Show help");
println!(" /status Show session status"); println!(" /status Show session status");
println!(" /compact Compact session history"); println!(" /tools Show tool catalog and permission policy");
println!(" /exit Quit the REPL"); println!(" /permissions Show permission mode details");
println!(" /compact Compact session history");
println!(" /exit Quit the REPL");
} }
"/status" => cli.print_status(), "/status" => cli.print_status(),
"/tools" => cli.print_tools(),
"/permissions" => cli.print_permissions(),
"/compact" => cli.compact()?, "/compact" => cli.compact()?,
_ => cli.run_turn(trimmed)?, _ => cli.run_turn(trimmed)?,
} }
@@ -366,23 +371,27 @@ struct LiveCli {
system_prompt: Vec<String>, system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
session_path: PathBuf, session_path: PathBuf,
permission_policy: PermissionPolicy,
} }
impl LiveCli { impl LiveCli {
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> { fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?; let system_prompt = build_system_prompt()?;
let session_path = new_session_path()?; let session_path = new_session_path()?;
let permission_policy = permission_policy_from_env();
let runtime = build_runtime( let runtime = build_runtime(
Session::new(), Session::new(),
model.clone(), model.clone(),
system_prompt.clone(), system_prompt.clone(),
enable_tools, enable_tools,
permission_policy.clone(),
)?; )?;
Ok(Self { Ok(Self {
model, model,
system_prompt, system_prompt,
runtime, runtime,
session_path, session_path,
permission_policy,
}) })
} }
@@ -394,7 +403,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();
let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
match result { match result {
Ok(turn) => { Ok(turn) => {
spinner.finish( spinner.finish(
@@ -443,6 +453,37 @@ impl LiveCli {
} }
} }
fn print_permissions(&self) {
let mode = env::var("RUSTY_CLAUDE_PERMISSION_MODE")
.unwrap_or_else(|_| "workspace-write".to_string());
println!("Permission mode: {mode}");
println!(
"Default policy: {}",
permission_mode_label(self.permission_policy.mode_for("bash"))
);
println!("Read-only safe tools stay auto-allowed when read-only mode is active.");
println!("Interactive approvals appear when permission mode is set to prompt.");
}
fn print_tools(&self) {
println!("Tool catalog:");
for spec in mvp_tool_specs() {
let mode = self.permission_policy.mode_for(spec.name);
let summary = summarize_tool_schema(&spec.input_schema);
println!(
"- {} [{}] — {}{}",
spec.name,
permission_mode_label(mode),
spec.description,
if summary.is_empty() {
String::new()
} else {
format!(" | args: {summary}")
}
);
}
}
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> { fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let estimated_before = self.runtime.estimated_tokens(); let estimated_before = self.runtime.estimated_tokens();
let result = self.runtime.compact(CompactionConfig::default()); let result = self.runtime.compact(CompactionConfig::default());
@@ -456,6 +497,7 @@ impl LiveCli {
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.permission_policy.clone(),
)?; )?;
if removed == 0 { if removed == 0 {
@@ -628,13 +670,14 @@ fn build_runtime(
model: String, model: String,
system_prompt: Vec<String>, system_prompt: Vec<String>,
enable_tools: bool, enable_tools: bool,
permission_policy: PermissionPolicy,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{ {
Ok(ConversationRuntime::new( Ok(ConversationRuntime::new(
session, session,
AnthropicRuntimeClient::new(model, enable_tools)?, AnthropicRuntimeClient::new(model, enable_tools)?,
CliToolExecutor::new(), CliToolExecutor::new(),
permission_policy_from_env(), permission_policy,
system_prompt, system_prompt,
)) ))
} }
@@ -819,6 +862,77 @@ fn response_to_events(
Ok(events) Ok(events)
} }
fn permission_mode_label(mode: PermissionMode) -> &'static str {
match mode {
PermissionMode::Allow => "allow",
PermissionMode::Deny => "deny",
PermissionMode::Prompt => "prompt",
}
}
fn summarize_tool_schema(schema: &serde_json::Value) -> String {
let Some(properties) = schema
.get("properties")
.and_then(serde_json::Value::as_object)
else {
return String::new();
};
let mut keys = properties.keys().cloned().collect::<Vec<_>>();
keys.sort();
keys.join(", ")
}
fn summarize_tool_output(tool_name: &str, output: &str) -> String {
let compact = output.replace('\n', " ");
let preview = truncate_preview(compact.trim(), 120);
if preview.is_empty() {
format!("{tool_name} completed with no textual output")
} else {
format!("{tool_name}{preview}")
}
}
struct CliPermissionPrompter {
prompt: String,
}
impl CliPermissionPrompter {
fn new() -> Self {
Self {
prompt: "Allow tool? [y]es / [n]o / [a]lways deny this run: ".to_string(),
}
}
}
impl PermissionPrompter for CliPermissionPrompter {
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
println!(
"
Tool permission request:"
);
println!("- tool: {}", request.tool_name);
println!("- input: {}", truncate_preview(request.input.trim(), 200));
print!("{}", self.prompt);
let _ = io::stdout().flush();
let mut response = String::new();
match io::stdin().read_line(&mut response) {
Ok(_) => match response.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => PermissionPromptDecision::Allow,
"a" | "always" => PermissionPromptDecision::Deny {
reason: "tool denied for this run by user".to_string(),
},
_ => PermissionPromptDecision::Deny {
reason: "tool denied by user".to_string(),
},
},
Err(error) => PermissionPromptDecision::Deny {
reason: format!("tool approval failed: {error}"),
},
}
}
}
struct CliToolExecutor { struct CliToolExecutor {
renderer: TerminalRenderer, renderer: TerminalRenderer,
} }
@@ -837,7 +951,10 @@ impl ToolExecutor for CliToolExecutor {
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
match execute_tool(tool_name, &value) { match execute_tool(tool_name, &value) {
Ok(output) => { Ok(output) => {
let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n"); let summary = summarize_tool_output(tool_name, &output);
let markdown = format!(
"### Tool `{tool_name}`\n\n- Summary: {summary}\n\n```json\n{output}\n```\n"
);
self.renderer self.renderer
.stream_markdown(&markdown, &mut io::stdout()) .stream_markdown(&markdown, &mut io::stdout())
.map_err(|error| ToolError::new(error.to_string()))?; .map_err(|error| ToolError::new(error.to_string()))?;
@@ -856,6 +973,10 @@ fn permission_policy_from_env() -> PermissionPolicy {
.with_tool_mode("read_file", PermissionMode::Allow) .with_tool_mode("read_file", PermissionMode::Allow)
.with_tool_mode("glob_search", PermissionMode::Allow) .with_tool_mode("glob_search", PermissionMode::Allow)
.with_tool_mode("grep_search", PermissionMode::Allow), .with_tool_mode("grep_search", PermissionMode::Allow),
"prompt" => PermissionPolicy::new(PermissionMode::Prompt)
.with_tool_mode("read_file", PermissionMode::Allow)
.with_tool_mode("glob_search", PermissionMode::Allow)
.with_tool_mode("grep_search", PermissionMode::Allow),
_ => PermissionPolicy::new(PermissionMode::Allow), _ => PermissionPolicy::new(PermissionMode::Allow),
} }
} }
@@ -913,6 +1034,7 @@ fn print_help() {
println!(" rusty-claude-cli bootstrap-plan"); println!(" rusty-claude-cli bootstrap-plan");
println!(" rusty-claude-cli sessions [--query TEXT] [--limit N]"); println!(" rusty-claude-cli sessions [--query TEXT] [--limit N]");
println!(" rusty-claude-cli resume <latest|SESSION|PATH> [/compact]"); println!(" rusty-claude-cli resume <latest|SESSION|PATH> [/compact]");
println!(" env RUSTY_CLAUDE_PERMISSION_MODE=prompt enables interactive tool approval");
println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]"); println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
println!(" rusty-claude-cli --resume SESSION.json [/compact]"); println!(" rusty-claude-cli --resume SESSION.json [/compact]");
} }