use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
use std::path::PathBuf;
use tokio::runtime::Runtime;
use crate::session::{ToolCall, ToolExecutionResult};
use super::App;
#[derive(Clone, Debug)]
pub(crate) struct PendingWorkspaceBoundaryCheck {
pub tool_call: ToolCall,
pub requested_path: PathBuf,
pub workspace_root: PathBuf,
}
#[derive(Clone, Debug)]
pub(crate) struct WorkspaceBoundaryDialogState {
pub pending: PendingWorkspaceBoundaryCheck,
pub current_index: usize,
pub total: usize,
}
impl WorkspaceBoundaryDialogState {
pub(crate) fn title(&self) -> String {
format!(
"Security Warning {} of {}",
self.current_index, self.total
)
}
pub(crate) fn path_display(&self) -> String {
self.pending.requested_path.display().to_string()
}
pub(crate) fn workspace_display(&self) -> String {
self.pending.workspace_root.display().to_string()
}
pub(crate) fn dialog_height(&self, _width: u16) -> u16 {
8
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum BoundaryDecision {
AllowOnce,
AllowUntilExit,
DenyOnce,
DenyUntilExit,
}
pub(crate) fn extract_boundary_violation_path(
workspace_root: &std::path::Path,
tool_call: &ToolCall,
) -> Option<PathBuf> {
let args: serde_json::Value = serde_json::from_str(&tool_call.arguments).ok()?;
let canonical_name = crate::tooling::canonical_tool_name(&tool_call.name)?;
match canonical_name {
"read" | "write" | "edit" | "list" | "glob" => {
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
let path_buf = std::path::Path::new(path);
if crate::tooling::builtin::utils::is_path_outside_workspace(workspace_root, path_buf) {
return Some(path_buf.to_path_buf());
}
}
}
"apply_patch" => {
if let Some(patch) = args.get("patch").and_then(|v| v.as_str()) {
if let Some(file_path) = crate::tooling::extract_file_path_from_patch(patch) {
let path_buf = std::path::Path::new(&file_path);
if crate::tooling::builtin::utils::is_path_outside_workspace(workspace_root, path_buf) {
return Some(path_buf.to_path_buf());
}
}
}
}
"grep" => {
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
let path_buf = std::path::Path::new(path);
if crate::tooling::builtin::utils::is_path_outside_workspace(workspace_root, path_buf) {
return Some(path_buf.to_path_buf());
}
}
}
"bash" => {
return None;
}
_ => {
return None;
}
}
None
}
impl App {
pub(crate) fn is_workspace_boundary_allowed(&self, path: &str) -> Option<bool> {
self.workspace_boundary_permissions.get(path).copied()
}
pub(crate) fn remember_workspace_boundary_permission(&mut self, path: String, allowed: bool) {
self.workspace_boundary_permissions.insert(path, allowed);
}
fn execute_boundary_allowed_tool(
&mut self,
tool_call: ToolCall,
runtime: &Runtime,
) -> Result<()> {
if tool_call.name == "question" {
let args = match serde_json::from_str::<crate::tooling::QuestionArgs>(&tool_call.arguments) {
Ok(args) => args,
Err(error) => {
self.record_tool_result(
tool_call,
ToolExecutionResult::new(format!(
"Tool failed: failed to decode question arguments: {error}"
)),
)?;
self.advance_pending_tool_execution();
return self.process_pending_tool_execution(runtime);
}
};
if args.questions.is_empty() {
self.record_tool_result(
tool_call,
ToolExecutionResult::new(
"Tool failed: question tool requires at least one question",
),
)?;
self.advance_pending_tool_execution();
return self.process_pending_tool_execution(runtime);
}
self.begin_question_dialog(tool_call, args)?;
return Ok(());
}
let mut result = self
.tools
.execute_call(
runtime.handle(),
&self.store,
self.conversation.session_id,
&tool_call,
self.mode,
true, )
.unwrap_or_else(|error| ToolExecutionResult::new(format!("Tool failed: {error}")));
if !result.output.starts_with("Tool failed:") {
result
.output
.push_str("\n\n[User approved access to path outside the workspace]");
}
self.record_tool_result(tool_call, result)?;
self.advance_pending_tool_execution();
self.process_pending_tool_execution(runtime)
}
pub(crate) fn handle_workspace_boundary_dialog_key(
&mut self,
key: KeyEvent,
runtime: &Runtime,
) -> Result<()> {
let decision = match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => Some(BoundaryDecision::AllowOnce),
KeyCode::Char('a') | KeyCode::Char('A') => Some(BoundaryDecision::AllowUntilExit),
KeyCode::Char('n') | KeyCode::Char('N') => Some(BoundaryDecision::DenyOnce),
KeyCode::Char('d') | KeyCode::Char('D') => Some(BoundaryDecision::DenyUntilExit),
KeyCode::Esc => Some(BoundaryDecision::DenyOnce),
_ => None,
};
if let Some(decision) = decision {
self.resolve_workspace_boundary_dialog(decision, runtime)?;
}
Ok(())
}
fn resolve_workspace_boundary_dialog(
&mut self,
decision: BoundaryDecision,
runtime: &Runtime,
) -> Result<()> {
let Some(dialog) = self.workspace_boundary_dialog.take() else {
return Ok(());
};
let allowed = matches!(decision, BoundaryDecision::AllowOnce | BoundaryDecision::AllowUntilExit);
let remember = matches!(decision, BoundaryDecision::AllowUntilExit | BoundaryDecision::DenyUntilExit);
if remember {
let path_pattern = dialog.pending.requested_path.display().to_string();
self.remember_workspace_boundary_permission(path_pattern, allowed);
}
if allowed {
if Self::is_readonly_tool(&dialog.pending.tool_call.name) {
if dialog.pending.tool_call.name != "question" {
self.workspace_boundary_approved
.insert(dialog.pending.tool_call.id.clone(), true);
self.pending_tool_execution
.as_mut()
.unwrap()
.add_ready(dialog.pending.tool_call);
self.advance_pending_tool_execution();
return self.process_pending_tool_execution(runtime);
}
}
self.execute_boundary_allowed_tool(dialog.pending.tool_call, runtime)?;
return Ok(());
} else {
let output = format!(
"[User denied access] The path '{}' is outside the workspace.",
dialog.pending.requested_path.display()
);
self.record_tool_result(dialog.pending.tool_call, ToolExecutionResult::new(output))?;
self.advance_pending_tool_execution();
}
self.process_pending_tool_execution(runtime)
}
}