mod assessment;
use serde::{Deserialize, Serialize};
use super::{
EnvironmentSensitivity, ImpactScope, OperationTarget, PermissionChecker, PermissionDecision,
Reversibility, ToolRiskAction, ToolRiskAssessment, ToolRiskLevel, ToolRiskReason,
};
use assessment::{assess_tool, assessment_permission, critical_assessment, tool_risk_type};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InteractiveApprovalMode {
Default,
Plan,
Auto,
}
impl InteractiveApprovalMode {
pub fn from_name(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"plan" => Self::Plan,
"auto" => Self::Auto,
_ => Self::Default,
}
}
pub const fn action_for(self, assessment: &ToolRiskAssessment) -> ToolRiskAction {
match (self, assessment.level) {
(_, ToolRiskLevel::Routine) => ToolRiskAction::Allow,
(Self::Auto, ToolRiskLevel::Bounded) => ToolRiskAction::Allow,
(_, ToolRiskLevel::Bounded) => ToolRiskAction::RequireConfirmation,
(_, ToolRiskLevel::High) => ToolRiskAction::ReviewByLlm,
(_, ToolRiskLevel::Critical) => ToolRiskAction::RuleDeny,
}
}
fn apply(self, assessment: &ToolRiskAssessment) -> PermissionDecision {
match self.action_for(assessment) {
ToolRiskAction::Allow => PermissionDecision::Allow,
ToolRiskAction::RequireConfirmation | ToolRiskAction::ReviewByLlm => {
PermissionDecision::Ask
}
ToolRiskAction::RuleDeny => PermissionDecision::Deny,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InteractiveToolGuardrail {
mode: InteractiveApprovalMode,
workspace: Option<std::path::PathBuf>,
}
impl InteractiveToolGuardrail {
pub const fn new(mode: InteractiveApprovalMode) -> Self {
Self {
mode,
workspace: None,
}
}
pub fn for_mode(mode: &str) -> Self {
Self::new(InteractiveApprovalMode::from_name(mode))
}
pub fn with_workspace(mut self, workspace: impl Into<std::path::PathBuf>) -> Self {
self.workspace = Some(workspace.into());
self
}
pub fn risk_assessment(tool_name: &str, args: &serde_json::Value) -> ToolRiskAssessment {
assess_tool(tool_name, args)
}
pub fn risk_decision(tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
assessment_permission(&assess_tool(tool_name, args))
}
pub fn assess(&self, tool_name: &str, args: &serde_json::Value) -> ToolRiskAssessment {
if let Some(assessment) = self.workspace_boundary_assessment(tool_name, args) {
return assessment;
}
assess_tool(tool_name, args)
}
pub fn risk_action(&self, tool_name: &str, args: &serde_json::Value) -> ToolRiskAction {
self.mode.action_for(&self.assess(tool_name, args))
}
fn workspace_boundary_assessment(
&self,
tool_name: &str,
args: &serde_json::Value,
) -> Option<ToolRiskAssessment> {
let root = self.workspace.as_deref()?;
invocation_crosses_local_symlink(root, tool_name, args).then(|| {
critical_assessment(
tool_risk_type(tool_name),
OperationTarget::OutsideWorkspace,
ImpactScope::Host,
Reversibility::Unknown,
EnvironmentSensitivity::Host,
ToolRiskReason::SymlinkBoundaryEscape,
)
})
}
}
impl Default for InteractiveToolGuardrail {
fn default() -> Self {
Self::new(InteractiveApprovalMode::Default)
}
}
impl PermissionChecker for InteractiveToolGuardrail {
fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
self.mode.apply(&self.assess(tool_name, args))
}
}
fn invocation_crosses_local_symlink(
root: &std::path::Path,
tool_name: &str,
args: &serde_json::Value,
) -> bool {
if tool_name.eq_ignore_ascii_case("batch") {
return args
.get("invocations")
.and_then(serde_json::Value::as_array)
.is_some_and(|invocations| {
invocations.iter().any(|invocation| {
let Some(tool) = invocation.get("tool").and_then(serde_json::Value::as_str)
else {
return false;
};
let Some(tool_args) = invocation.get("args") else {
return false;
};
invocation_crosses_local_symlink(root, tool, tool_args)
})
});
}
let tool = tool_name.to_ascii_lowercase();
if tool == "bash" {
return shell_path_crosses_symlink(root, args);
}
let field = match tool.as_str() {
"read" | "write" | "edit" | "patch" => "file_path",
"grep" | "glob" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => "path",
_ => return false,
};
let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
return false;
};
local_path_crosses_symlink(root, path)
}
fn shell_path_crosses_symlink(root: &std::path::Path, args: &serde_json::Value) -> bool {
args.get("command")
.and_then(serde_json::Value::as_str)
.is_some_and(|command| {
command
.split_whitespace()
.map(clean_shell_token)
.filter(|token| !token.is_empty() && !token.starts_with('-'))
.any(|token| local_path_crosses_symlink(root, token))
})
}
fn local_path_crosses_symlink(root: &std::path::Path, path: &str) -> bool {
if path_is_outside_workspace(path) {
return false;
}
let mut current = root.to_path_buf();
for component in std::path::Path::new(path).components() {
match component {
std::path::Component::CurDir => continue,
std::path::Component::Normal(component) => current.push(component),
_ => return true,
}
match std::fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.file_type().is_symlink() => return true,
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
Err(_) => return true,
}
}
false
}
pub(super) fn atomic_tool_is_bounded(tool_name: &str, args: &serde_json::Value) -> bool {
match tool_name.to_ascii_lowercase().as_str() {
"write" | "edit" | "patch" => bounded_file_target(args),
"git" => {
classify_git(args) == PermissionDecision::Ask
&& git_call_is_known_bounded_mutation(args)
}
_ => false,
}
}
fn bounded_file_target(args: &serde_json::Value) -> bool {
args.get("file_path")
.and_then(serde_json::Value::as_str)
.is_some_and(|path| !path.trim().is_empty() && !path_is_outside_workspace(path))
}
fn git_call_is_known_bounded_mutation(args: &serde_json::Value) -> bool {
if git_requires_explicit_confirmation(args) {
return false;
}
match args.get("command").and_then(serde_json::Value::as_str) {
Some("branch") => valid_non_option_string(args, "name"),
Some("checkout") => valid_non_option_string(args, "ref"),
Some("stash") => {
args.get("message")
.and_then(serde_json::Value::as_str)
.is_some()
|| args
.get("include_untracked")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
Some("remote") => args
.get("remote_name")
.and_then(serde_json::Value::as_str)
.is_some(),
Some("worktree") => matches!(
args.get("subcommand").and_then(serde_json::Value::as_str),
Some("add")
),
_ => false,
}
}
fn git_requires_explicit_confirmation(args: &serde_json::Value) -> bool {
args.get("force").is_some_and(|value| value != false)
}
pub(super) fn classify_atomic_tool(
tool_name: &str,
args: &serde_json::Value,
) -> PermissionDecision {
match tool_name.to_ascii_lowercase().as_str() {
"read" => classify_scoped_path(args, "file_path", PermissionDecision::Allow),
"grep" | "glob" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
classify_scoped_path(args, "path", PermissionDecision::Allow)
}
"web_search" | "web_fetch" | "search_skills" | "generate_object" => {
PermissionDecision::Allow
}
"write" | "edit" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
"patch" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
"bash" => classify_bash(args),
"git" => classify_git(args),
_ => PermissionDecision::Ask,
}
}
fn classify_scoped_path(
args: &serde_json::Value,
field: &str,
safe_decision: PermissionDecision,
) -> PermissionDecision {
let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
return if field == "path" {
safe_decision
} else {
PermissionDecision::Ask
};
};
if path.trim().is_empty() {
return if field == "path" {
safe_decision
} else {
PermissionDecision::Ask
};
}
if path_is_outside_workspace(path) {
PermissionDecision::Deny
} else {
safe_decision
}
}
fn classify_git(args: &serde_json::Value) -> PermissionDecision {
let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
return PermissionDecision::Ask;
};
if args
.get("force")
.is_some_and(|value| value.as_bool() != Some(false))
{
return PermissionDecision::Ask;
}
match command {
"status" if only_git_keys(args, &["command"]) => PermissionDecision::Allow,
"log"
if only_git_keys(args, &["command", "limit", "max_count", "cursor"])
&& valid_optional_positive_integer(args, "limit")
&& valid_optional_positive_integer(args, "max_count")
&& valid_optional_string(args, "cursor") =>
{
PermissionDecision::Allow
}
"diff"
if only_git_keys(args, &["command", "target", "byte_offset", "max_bytes"])
&& valid_optional_non_option_string(args, "target")
&& valid_optional_nonnegative_integer(args, "byte_offset")
&& valid_optional_positive_integer(args, "max_bytes") =>
{
PermissionDecision::Allow
}
"remote"
if only_git_keys(args, &["command", "remote_name", "cursor"])
&& valid_optional_string(args, "remote_name")
&& valid_optional_string(args, "cursor") =>
{
PermissionDecision::Allow
}
"branch"
if args.get("name").is_none()
&& only_git_keys(args, &["command", "limit", "max_count", "cursor"])
&& valid_optional_positive_integer(args, "limit")
&& valid_optional_positive_integer(args, "max_count")
&& valid_optional_string(args, "cursor") =>
{
PermissionDecision::Allow
}
"stash"
if args.get("message").is_none()
&& args.get("include_untracked").is_none()
&& only_git_keys(args, &["command", "cursor"])
&& valid_optional_string(args, "cursor") =>
{
PermissionDecision::Allow
}
"worktree"
if args
.get("subcommand")
.and_then(serde_json::Value::as_str)
.unwrap_or("list")
== "list"
&& only_git_keys(args, &["command", "subcommand", "cursor"])
&& valid_optional_string(args, "subcommand")
&& valid_optional_string(args, "cursor") =>
{
PermissionDecision::Allow
}
_ => PermissionDecision::Ask,
}
}
fn only_git_keys(args: &serde_json::Value, allowed: &[&str]) -> bool {
args.as_object().is_some_and(|object| {
object
.keys()
.all(|key| allowed.iter().any(|allowed| key == allowed))
})
}
fn valid_non_option_string(args: &serde_json::Value, field: &str) -> bool {
args.get(field)
.and_then(serde_json::Value::as_str)
.is_some_and(|value| {
let value = value.trim();
!value.is_empty() && !value.starts_with('-')
})
}
fn valid_optional_non_option_string(args: &serde_json::Value, field: &str) -> bool {
args.get(field)
.is_none_or(|_| valid_non_option_string(args, field))
}
fn valid_optional_string(args: &serde_json::Value, field: &str) -> bool {
args.get(field).is_none_or(serde_json::Value::is_string)
}
fn valid_optional_positive_integer(args: &serde_json::Value, field: &str) -> bool {
args.get(field)
.is_none_or(|value| value.as_u64().is_some_and(|number| number > 0))
}
fn valid_optional_nonnegative_integer(args: &serde_json::Value, field: &str) -> bool {
args.get(field).is_none_or(|value| value.as_u64().is_some())
}
fn classify_bash(args: &serde_json::Value) -> PermissionDecision {
let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
return PermissionDecision::Ask;
};
let command = command.trim();
if command.is_empty() {
return PermissionDecision::Ask;
}
if is_catastrophic_bash_command(command) {
PermissionDecision::Deny
} else if is_read_only_bash_command(command) {
PermissionDecision::Allow
} else {
PermissionDecision::Ask
}
}
fn is_catastrophic_bash_command(command: &str) -> bool {
let lower = normalize_shell(command).to_ascii_lowercase();
if lower == "sudo"
|| lower.starts_with("sudo ")
|| lower.starts_with("doas ")
|| lower == "su"
|| lower.starts_with("su ")
|| lower.starts_with("su -")
{
return true;
}
if shell_invokes_mkfs(command)
|| lower.contains("diskutil erase")
|| lower.contains(":(){")
|| lower.contains("kill -9 -1")
|| lower.starts_with("shutdown")
|| lower.starts_with("reboot")
{
return true;
}
if (lower.contains("curl ") || lower.contains("wget "))
&& ["| sh", "|sh", "| bash", "|bash", "| zsh", "|zsh"]
.iter()
.any(|pipe| lower.contains(pipe))
{
return true;
}
if (lower.starts_with("dd ") || lower.contains(" dd "))
&& (lower.contains(" of=/dev/") || lower.contains("of=/dev/"))
{
return true;
}
lower.contains("rm -rf /")
|| lower.contains("rm -fr /")
|| lower.contains("rm -rf ~")
|| lower.contains("rm -fr ~")
|| lower.contains("rm -rf $home")
|| lower.contains("rm -fr $home")
|| lower.contains("rm -rf *")
|| lower.contains("rm -fr *")
|| lower == "rm -rf ."
|| lower == "rm -fr ."
}
fn shell_invokes_mkfs(command: &str) -> bool {
command
.split(['|', ';', '&'])
.filter_map(|segment| segment.split_whitespace().next())
.map(clean_shell_token)
.filter_map(|executable| executable.rsplit('/').next())
.any(|executable| executable == "mkfs" || executable.starts_with("mkfs."))
}
fn is_read_only_bash_command(command: &str) -> bool {
if command
.chars()
.any(|character| character.is_whitespace() && character != ' ')
|| command.contains(['\'', '"', '*', '?', '[', ']', '{', '}'])
|| contains_unsafe_shell_syntax(command)
{
return false;
}
command
.split('|')
.all(|segment| is_read_only_bash_segment(segment.trim()))
}
fn contains_unsafe_shell_syntax(command: &str) -> bool {
command.contains("&&")
|| command.contains("||")
|| command.contains(';')
|| command.contains('>')
|| command.contains('<')
|| command.contains('`')
|| command.contains("$(")
|| command.contains('&')
|| command.contains('\n')
|| command.contains('\r')
|| command.contains('$')
|| has_unscoped_path_token(command)
}
fn has_unscoped_path_token(command: &str) -> bool {
command
.split_whitespace()
.map(clean_shell_token)
.filter(|token| !token.is_empty())
.any(path_is_outside_workspace)
}
fn clean_shell_token(token: &str) -> &str {
token.trim_matches(|character: char| {
matches!(
character,
'\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ':'
)
})
}
fn path_is_outside_workspace(path: &str) -> bool {
let normalized = path.replace('\\', "/");
let path = normalized.trim();
let bytes = path.as_bytes();
if (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
|| path.starts_with("//")
|| path.starts_with('/')
|| path.starts_with('~')
|| path.starts_with("$HOME")
|| path.starts_with("${HOME}")
{
return true;
}
let mut depth = 0_i32;
for component in path.split('/') {
match component {
"" | "." => {}
".." if depth == 0 => return true,
".." => depth -= 1,
_ => depth += 1,
}
}
false
}
fn is_read_only_bash_segment(segment: &str) -> bool {
let tokens: Vec<&str> = segment.split_whitespace().collect();
let Some(command) = tokens.first().copied().map(clean_shell_token) else {
return false;
};
let lower = segment.to_ascii_lowercase();
match command {
"pwd" | "cat" | "head" | "tail" | "wc" | "stat" | "file" | "cut" | "tr" | "whoami" => {
tokens
.iter()
.skip(1)
.all(|value| !option_executes_or_writes(value))
}
"ls" => tokens.iter().skip(1).all(|value| {
!option_executes_or_writes(value)
&& !short_option_contains(value, 'L')
&& !short_option_contains(value, 'R')
&& !matches!(*value, "--dereference" | "--recursive")
}),
"rg" => tokens.iter().skip(1).all(|value| {
!option_executes_or_writes(value)
&& !matches!(*value, "--pre" | "--hostname-bin" | "-L" | "--follow")
&& !value.starts_with("--pre=")
&& !value.starts_with("--hostname-bin=")
}),
"grep" => tokens.iter().skip(1).all(|value| {
!option_executes_or_writes(value)
&& !short_option_contains(value, 'f')
&& !matches!(
*value,
"-R" | "-r"
| "--recursive"
| "--dereference-recursive"
| "--include"
| "--exclude-from"
| "--file"
)
&& !value.starts_with("--exclude-from=")
&& !value.starts_with("--file=")
}),
"du" => tokens.iter().skip(1).all(|value| {
!short_option_contains(value, 'L')
&& !matches!(*value, "--dereference")
&& !option_executes_or_writes(value)
}),
"df" => tokens
.iter()
.skip(1)
.all(|value| !option_executes_or_writes(value)),
"date" => tokens.iter().skip(1).all(|value| {
!matches!(*value, "-s" | "--set")
&& !short_option_contains(value, 's')
&& !value.starts_with("--set=")
&& !option_executes_or_writes(value)
}),
"uname" => tokens
.iter()
.skip(1)
.all(|value| !option_executes_or_writes(value)),
"sort" => tokens.iter().skip(1).all(|value| {
!matches!(
*value,
"-o" | "-T" | "--output" | "--temporary-directory" | "--compress-program"
) && !short_option_contains(value, 'o')
&& !short_option_contains(value, 'T')
&& !value.starts_with("--output=")
&& !value.starts_with("--temporary-directory=")
&& !value.starts_with("--compress-program=")
&& !option_executes_or_writes(value)
}),
"uniq" => positional_argument_count(&tokens[1..]) <= 1,
"printf" | "echo" => tokens.iter().skip(1).all(|value| !value.starts_with('-')),
"find" => {
!tokens
.iter()
.skip(1)
.any(|value| matches!(*value, "-L" | "-H"))
&& ![
" -delete",
" -exec",
" -execdir",
" -ok",
" -okdir",
" -fprint",
" -fprint0",
" -fprintf",
" -fls",
" -follow",
" -lname",
]
.iter()
.any(|action| lower.contains(action))
}
"sed" => false,
"git" => is_read_only_git_segment(&tokens),
_ => false,
}
}
fn short_option_contains(value: &str, flag: char) -> bool {
value.starts_with('-')
&& !value.starts_with("--")
&& value.chars().skip(1).any(|candidate| candidate == flag)
}
fn option_executes_or_writes(value: &str) -> bool {
matches!(
value,
"--output" | "--exec" | "--command" | "--config" | "--files-from"
) || value.starts_with("--output=")
|| value.starts_with("--exec=")
|| value.starts_with("--command=")
|| value.starts_with("--config=")
|| value.starts_with("--files-from=")
}
fn positional_argument_count(tokens: &[&str]) -> usize {
tokens
.iter()
.filter(|value| !value.starts_with('-'))
.count()
}
fn is_read_only_git_segment(tokens: &[&str]) -> bool {
if tokens.first().copied() != Some("git") {
return false;
}
let mut index = 1;
while index < tokens.len() {
match tokens[index] {
"--no-pager" | "-P" | "--no-optional-locks" => index += 1,
value if value.starts_with('-') => return false,
_ => break,
}
}
let Some(subcommand) = tokens.get(index).copied() else {
return false;
};
let args = &tokens[index + 1..];
if args.iter().any(|value| {
matches!(
*value,
"--ext-diff" | "--textconv" | "--exec-path" | "--config-env"
) || value.starts_with("--exec-path=")
|| value.starts_with("--config-env=")
}) {
return false;
}
match subcommand {
"status" | "diff" | "log" | "show" | "blame" | "grep" | "ls-files" | "rev-parse" => {
!args.iter().any(|value| {
option_executes_or_writes(value)
|| matches!(*value, "--paginate" | "-p" | "--ext-diff" | "--textconv")
|| value.starts_with("--format=") && value.contains("%(rest)")
})
}
"remote" => match args.first() {
Some(value) => matches!(*value, "-v" | "show"),
None => true,
},
"branch" => args.iter().all(|value| {
matches!(
*value,
"--all" | "-a" | "--list" | "--show-current" | "--verbose" | "-v" | "-vv"
)
}),
_ => false,
}
}
fn normalize_shell(command: &str) -> String {
command.split_whitespace().collect::<Vec<_>>().join(" ")
}