1mod assessment;
9
10use serde::{Deserialize, Serialize};
11
12use super::{
13 EnvironmentSensitivity, ImpactScope, OperationTarget, PermissionChecker, PermissionDecision,
14 Reversibility, ToolRiskAction, ToolRiskAssessment, ToolRiskLevel, ToolRiskReason,
15};
16use assessment::{assess_tool, assessment_permission, critical_assessment, tool_risk_type};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum InteractiveApprovalMode {
22 Default,
24 Plan,
26 Auto,
28}
29
30impl InteractiveApprovalMode {
31 pub fn from_name(value: &str) -> Self {
32 match value.trim().to_ascii_lowercase().as_str() {
33 "plan" => Self::Plan,
34 "auto" => Self::Auto,
35 _ => Self::Default,
36 }
37 }
38
39 pub const fn action_for(self, assessment: &ToolRiskAssessment) -> ToolRiskAction {
47 match (self, assessment.level) {
48 (_, ToolRiskLevel::Routine) => ToolRiskAction::Allow,
49 (Self::Auto, ToolRiskLevel::Bounded) => ToolRiskAction::Allow,
50 (_, ToolRiskLevel::Bounded) => ToolRiskAction::RequireConfirmation,
51 (_, ToolRiskLevel::High) => ToolRiskAction::ReviewByLlm,
52 (_, ToolRiskLevel::Critical) => ToolRiskAction::RuleDeny,
53 }
54 }
55
56 fn apply(self, assessment: &ToolRiskAssessment) -> PermissionDecision {
57 match self.action_for(assessment) {
58 ToolRiskAction::Allow => PermissionDecision::Allow,
59 ToolRiskAction::RequireConfirmation | ToolRiskAction::ReviewByLlm => {
60 PermissionDecision::Ask
64 }
65 ToolRiskAction::RuleDeny => PermissionDecision::Deny,
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct InteractiveToolGuardrail {
73 mode: InteractiveApprovalMode,
74 workspace: Option<std::path::PathBuf>,
75}
76
77impl InteractiveToolGuardrail {
78 pub const fn new(mode: InteractiveApprovalMode) -> Self {
79 Self {
80 mode,
81 workspace: None,
82 }
83 }
84
85 pub fn for_mode(mode: &str) -> Self {
86 Self::new(InteractiveApprovalMode::from_name(mode))
87 }
88
89 pub fn with_workspace(mut self, workspace: impl Into<std::path::PathBuf>) -> Self {
91 self.workspace = Some(workspace.into());
92 self
93 }
94
95 pub fn risk_assessment(tool_name: &str, args: &serde_json::Value) -> ToolRiskAssessment {
97 assess_tool(tool_name, args)
98 }
99
100 pub fn risk_decision(tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
106 assessment_permission(&assess_tool(tool_name, args))
107 }
108
109 pub fn is_catastrophic_bash_command(command: &str) -> bool {
117 is_catastrophic_bash_command(command)
118 }
119
120 pub fn assess(&self, tool_name: &str, args: &serde_json::Value) -> ToolRiskAssessment {
122 if let Some(assessment) = self.workspace_boundary_assessment(tool_name, args) {
123 return assessment;
124 }
125 assess_tool(tool_name, args)
126 }
127
128 pub fn risk_action(&self, tool_name: &str, args: &serde_json::Value) -> ToolRiskAction {
130 self.mode.action_for(&self.assess(tool_name, args))
131 }
132
133 fn workspace_boundary_assessment(
134 &self,
135 tool_name: &str,
136 args: &serde_json::Value,
137 ) -> Option<ToolRiskAssessment> {
138 let root = self.workspace.as_deref()?;
139 invocation_crosses_local_symlink(root, tool_name, args).then(|| {
140 critical_assessment(
141 tool_risk_type(tool_name),
142 OperationTarget::OutsideWorkspace,
143 ImpactScope::Host,
144 Reversibility::Unknown,
145 EnvironmentSensitivity::Host,
146 ToolRiskReason::SymlinkBoundaryEscape,
147 )
148 })
149 }
150}
151
152impl Default for InteractiveToolGuardrail {
153 fn default() -> Self {
154 Self::new(InteractiveApprovalMode::Default)
155 }
156}
157
158impl PermissionChecker for InteractiveToolGuardrail {
159 fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
160 self.mode.apply(&self.assess(tool_name, args))
161 }
162}
163
164fn invocation_crosses_local_symlink(
165 root: &std::path::Path,
166 tool_name: &str,
167 args: &serde_json::Value,
168) -> bool {
169 if tool_name.eq_ignore_ascii_case("batch") {
170 return args
171 .get("invocations")
172 .and_then(serde_json::Value::as_array)
173 .is_some_and(|invocations| {
174 invocations.iter().any(|invocation| {
175 let Some(tool) = invocation.get("tool").and_then(serde_json::Value::as_str)
176 else {
177 return false;
178 };
179 let Some(tool_args) = invocation.get("args") else {
180 return false;
181 };
182 invocation_crosses_local_symlink(root, tool, tool_args)
183 })
184 });
185 }
186
187 let tool = tool_name.to_ascii_lowercase();
188 if tool == "bash" {
189 return shell_path_crosses_symlink(root, args);
190 }
191 let field = match tool.as_str() {
192 "read" | "write" | "edit" | "patch" | "download" => "file_path",
193 "search" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => "path",
194 _ => return false,
195 };
196 let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
197 return false;
198 };
199 local_path_crosses_symlink(root, path)
200}
201
202fn shell_path_crosses_symlink(root: &std::path::Path, args: &serde_json::Value) -> bool {
203 args.get("command")
204 .and_then(serde_json::Value::as_str)
205 .is_some_and(|command| {
206 command
207 .split_whitespace()
208 .map(clean_shell_token)
209 .filter(|token| !token.is_empty() && !token.starts_with('-'))
210 .any(|token| local_path_crosses_symlink(root, token))
211 })
212}
213
214fn local_path_crosses_symlink(root: &std::path::Path, path: &str) -> bool {
215 if path_is_outside_workspace(path) {
216 return false;
217 }
218 let mut current = root.to_path_buf();
219 for component in std::path::Path::new(path).components() {
220 match component {
221 std::path::Component::CurDir => continue,
222 std::path::Component::Normal(component) => current.push(component),
223 _ => return true,
224 }
225 match std::fs::symlink_metadata(¤t) {
226 Ok(metadata) if metadata.file_type().is_symlink() => return true,
227 Ok(_) => {}
228 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
229 Err(_) => return true,
230 }
231 }
232 false
233}
234
235pub(super) fn atomic_tool_is_bounded(tool_name: &str, args: &serde_json::Value) -> bool {
236 match tool_name.to_ascii_lowercase().as_str() {
237 "write" | "edit" | "patch" => bounded_file_target(args),
240 "download" => args.get("file_path").is_none() || bounded_file_target(args),
243 "git" => {
244 classify_git(args) == PermissionDecision::Ask
245 && git_call_is_known_bounded_mutation(args)
246 }
247 _ => false,
250 }
251}
252
253fn bounded_file_target(args: &serde_json::Value) -> bool {
254 args.get("file_path")
255 .and_then(serde_json::Value::as_str)
256 .is_some_and(|path| !path.trim().is_empty() && !path_is_outside_workspace(path))
257}
258
259fn git_call_is_known_bounded_mutation(args: &serde_json::Value) -> bool {
260 if git_requires_explicit_confirmation(args) {
261 return false;
262 }
263 match args.get("command").and_then(serde_json::Value::as_str) {
264 Some("branch") => valid_non_option_string(args, "name"),
265 Some("checkout") => valid_non_option_string(args, "ref"),
266 Some("stash") => {
267 args.get("message")
268 .and_then(serde_json::Value::as_str)
269 .is_some()
270 || args
271 .get("include_untracked")
272 .and_then(serde_json::Value::as_bool)
273 .unwrap_or(false)
274 }
275 Some("remote") => args
276 .get("remote_name")
277 .and_then(serde_json::Value::as_str)
278 .is_some(),
279 Some("worktree") => matches!(
280 args.get("subcommand").and_then(serde_json::Value::as_str),
281 Some("add")
282 ),
283 _ => false,
284 }
285}
286
287fn git_requires_explicit_confirmation(args: &serde_json::Value) -> bool {
288 args.get("force").is_some_and(|value| value != false)
289}
290
291pub(super) fn classify_atomic_tool(
292 tool_name: &str,
293 args: &serde_json::Value,
294) -> PermissionDecision {
295 match tool_name.to_ascii_lowercase().as_str() {
296 "read" => classify_scoped_path(args, "file_path", PermissionDecision::Allow),
297 "search" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
298 classify_scoped_path(args, "path", PermissionDecision::Allow)
299 }
300 "web_search" | "web_fetch" | "search_skills" | "generate_object" => {
301 PermissionDecision::Allow
302 }
303 "write" | "edit" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
304 "download" if args.get("file_path").is_none() => PermissionDecision::Ask,
305 "download" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
306 "patch" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
309 "bash" => classify_bash(args),
310 "git" => classify_git(args),
311 _ => PermissionDecision::Ask,
314 }
315}
316
317fn classify_scoped_path(
318 args: &serde_json::Value,
319 field: &str,
320 safe_decision: PermissionDecision,
321) -> PermissionDecision {
322 let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
323 return if field == "path" {
326 safe_decision
327 } else {
328 PermissionDecision::Ask
329 };
330 };
331 if path.trim().is_empty() {
332 return if field == "path" {
333 safe_decision
334 } else {
335 PermissionDecision::Ask
336 };
337 }
338 if path_is_outside_workspace(path) {
339 PermissionDecision::Deny
340 } else {
341 safe_decision
342 }
343}
344
345fn classify_git(args: &serde_json::Value) -> PermissionDecision {
346 let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
347 return PermissionDecision::Ask;
348 };
349 if args
350 .get("force")
351 .is_some_and(|value| value.as_bool() != Some(false))
352 {
353 return PermissionDecision::Ask;
354 }
355
356 match command {
357 "status" if only_git_keys(args, &["command"]) => PermissionDecision::Allow,
358 "log"
359 if only_git_keys(args, &["command", "limit", "max_count", "cursor"])
360 && valid_optional_positive_integer(args, "limit")
361 && valid_optional_positive_integer(args, "max_count")
362 && valid_optional_string(args, "cursor") =>
363 {
364 PermissionDecision::Allow
365 }
366 "diff"
367 if only_git_keys(args, &["command", "target", "byte_offset", "max_bytes"])
368 && valid_optional_non_option_string(args, "target")
369 && valid_optional_nonnegative_integer(args, "byte_offset")
370 && valid_optional_positive_integer(args, "max_bytes") =>
371 {
372 PermissionDecision::Allow
373 }
374 "remote"
375 if only_git_keys(args, &["command", "remote_name", "cursor"])
376 && valid_optional_string(args, "remote_name")
377 && valid_optional_string(args, "cursor") =>
378 {
379 PermissionDecision::Allow
380 }
381 "branch"
382 if args.get("name").is_none()
383 && only_git_keys(args, &["command", "limit", "max_count", "cursor"])
384 && valid_optional_positive_integer(args, "limit")
385 && valid_optional_positive_integer(args, "max_count")
386 && valid_optional_string(args, "cursor") =>
387 {
388 PermissionDecision::Allow
389 }
390 "stash"
391 if args.get("message").is_none()
392 && args.get("include_untracked").is_none()
393 && only_git_keys(args, &["command", "cursor"])
394 && valid_optional_string(args, "cursor") =>
395 {
396 PermissionDecision::Allow
397 }
398 "worktree"
399 if args
400 .get("subcommand")
401 .and_then(serde_json::Value::as_str)
402 .unwrap_or("list")
403 == "list"
404 && only_git_keys(args, &["command", "subcommand", "cursor"])
405 && valid_optional_string(args, "subcommand")
406 && valid_optional_string(args, "cursor") =>
407 {
408 PermissionDecision::Allow
409 }
410 _ => PermissionDecision::Ask,
411 }
412}
413
414fn only_git_keys(args: &serde_json::Value, allowed: &[&str]) -> bool {
415 args.as_object().is_some_and(|object| {
416 object
417 .keys()
418 .all(|key| allowed.iter().any(|allowed| key == allowed))
419 })
420}
421
422fn valid_non_option_string(args: &serde_json::Value, field: &str) -> bool {
423 args.get(field)
424 .and_then(serde_json::Value::as_str)
425 .is_some_and(|value| {
426 let value = value.trim();
427 !value.is_empty() && !value.starts_with('-')
428 })
429}
430
431fn valid_optional_non_option_string(args: &serde_json::Value, field: &str) -> bool {
432 args.get(field)
433 .is_none_or(|_| valid_non_option_string(args, field))
434}
435
436fn valid_optional_string(args: &serde_json::Value, field: &str) -> bool {
437 args.get(field).is_none_or(serde_json::Value::is_string)
438}
439
440fn valid_optional_positive_integer(args: &serde_json::Value, field: &str) -> bool {
441 args.get(field)
442 .is_none_or(|value| value.as_u64().is_some_and(|number| number > 0))
443}
444
445fn valid_optional_nonnegative_integer(args: &serde_json::Value, field: &str) -> bool {
446 args.get(field).is_none_or(|value| value.as_u64().is_some())
447}
448
449fn classify_bash(args: &serde_json::Value) -> PermissionDecision {
450 let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
451 return PermissionDecision::Ask;
452 };
453 let command = command.trim();
454 if command.is_empty() {
455 return PermissionDecision::Ask;
456 }
457 if is_catastrophic_bash_command(command) {
458 PermissionDecision::Deny
459 } else if is_read_only_bash_command(command) {
460 PermissionDecision::Allow
461 } else {
462 PermissionDecision::Ask
463 }
464}
465
466fn is_catastrophic_bash_command(command: &str) -> bool {
467 let lower = normalize_shell(command).to_ascii_lowercase();
468 if lower == "sudo"
469 || lower.starts_with("sudo ")
470 || lower.starts_with("doas ")
471 || lower == "su"
472 || lower.starts_with("su ")
473 || lower.starts_with("su -")
474 {
475 return true;
476 }
477 if shell_invokes_mkfs(command)
478 || lower.contains("diskutil erase")
479 || lower.contains(":(){")
480 || lower.contains("kill -9 -1")
481 || lower.starts_with("shutdown")
482 || lower.starts_with("reboot")
483 {
484 return true;
485 }
486 if (lower.contains("curl ") || lower.contains("wget "))
487 && ["| sh", "|sh", "| bash", "|bash", "| zsh", "|zsh"]
488 .iter()
489 .any(|pipe| lower.contains(pipe))
490 {
491 return true;
492 }
493 if (lower.starts_with("dd ") || lower.contains(" dd "))
494 && (lower.contains(" of=/dev/") || lower.contains("of=/dev/"))
495 {
496 return true;
497 }
498
499 lower.contains("rm -rf /")
500 || lower.contains("rm -fr /")
501 || lower.contains("rm -rf ~")
502 || lower.contains("rm -fr ~")
503 || lower.contains("rm -rf $home")
504 || lower.contains("rm -fr $home")
505 || lower.contains("rm -rf *")
506 || lower.contains("rm -fr *")
507 || lower == "rm -rf ."
508 || lower == "rm -fr ."
509}
510
511fn shell_invokes_mkfs(command: &str) -> bool {
512 command
513 .split(['|', ';', '&'])
514 .filter_map(|segment| segment.split_whitespace().next())
515 .map(clean_shell_token)
516 .filter_map(|executable| executable.rsplit('/').next())
517 .any(|executable| executable == "mkfs" || executable.starts_with("mkfs."))
518}
519
520fn is_read_only_bash_command(command: &str) -> bool {
521 if command
525 .chars()
526 .any(|character| character.is_whitespace() && character != ' ')
527 || command.contains(['\'', '"', '*', '?', '[', ']', '{', '}'])
528 || contains_unsafe_shell_syntax(command)
529 {
530 return false;
531 }
532 command
533 .split('|')
534 .all(|segment| is_read_only_bash_segment(segment.trim()))
535}
536
537fn contains_unsafe_shell_syntax(command: &str) -> bool {
538 command.contains("&&")
539 || command.contains("||")
540 || command.contains(';')
541 || command.contains('>')
542 || command.contains('<')
543 || command.contains('`')
544 || command.contains("$(")
545 || command.contains('&')
546 || command.contains('\n')
547 || command.contains('\r')
548 || command.contains('$')
549 || has_unscoped_path_token(command)
550}
551
552fn has_unscoped_path_token(command: &str) -> bool {
553 command
554 .split_whitespace()
555 .map(clean_shell_token)
556 .filter(|token| !token.is_empty())
557 .any(path_is_outside_workspace)
558}
559
560fn clean_shell_token(token: &str) -> &str {
561 token.trim_matches(|character: char| {
562 matches!(
563 character,
564 '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ':'
565 )
566 })
567}
568
569fn path_is_outside_workspace(path: &str) -> bool {
570 let normalized = path.replace('\\', "/");
571 let path = normalized.trim();
572 let bytes = path.as_bytes();
573 if (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
574 || path.starts_with("//")
575 || path.starts_with('/')
576 || path.starts_with('~')
577 || path.starts_with("$HOME")
578 || path.starts_with("${HOME}")
579 {
580 return true;
581 }
582
583 let mut depth = 0_i32;
584 for component in path.split('/') {
585 match component {
586 "" | "." => {}
587 ".." if depth == 0 => return true,
588 ".." => depth -= 1,
589 _ => depth += 1,
590 }
591 }
592 false
593}
594
595fn is_read_only_bash_segment(segment: &str) -> bool {
596 let tokens: Vec<&str> = segment.split_whitespace().collect();
597 let Some(command) = tokens.first().copied().map(clean_shell_token) else {
598 return false;
599 };
600 let lower = segment.to_ascii_lowercase();
601
602 match command {
603 "pwd" | "cat" | "head" | "tail" | "wc" | "stat" | "file" | "cut" | "tr" | "whoami" => {
604 tokens
605 .iter()
606 .skip(1)
607 .all(|value| !option_executes_or_writes(value))
608 }
609 "ls" => tokens.iter().skip(1).all(|value| {
610 !option_executes_or_writes(value)
611 && !short_option_contains(value, 'L')
612 && !short_option_contains(value, 'R')
613 && !matches!(*value, "--dereference" | "--recursive")
614 }),
615 "rg" => tokens.iter().skip(1).all(|value| {
616 !option_executes_or_writes(value)
617 && !matches!(*value, "--pre" | "--hostname-bin" | "-L" | "--follow")
618 && !value.starts_with("--pre=")
619 && !value.starts_with("--hostname-bin=")
620 }),
621 "grep" => tokens.iter().skip(1).all(|value| {
622 !option_executes_or_writes(value)
623 && !short_option_contains(value, 'f')
624 && !matches!(
625 *value,
626 "-R" | "-r"
627 | "--recursive"
628 | "--dereference-recursive"
629 | "--include"
630 | "--exclude-from"
631 | "--file"
632 )
633 && !value.starts_with("--exclude-from=")
634 && !value.starts_with("--file=")
635 }),
636 "du" => tokens.iter().skip(1).all(|value| {
637 !short_option_contains(value, 'L')
638 && !matches!(*value, "--dereference")
639 && !option_executes_or_writes(value)
640 }),
641 "df" => tokens
642 .iter()
643 .skip(1)
644 .all(|value| !option_executes_or_writes(value)),
645 "date" => tokens.iter().skip(1).all(|value| {
646 !matches!(*value, "-s" | "--set")
647 && !short_option_contains(value, 's')
648 && !value.starts_with("--set=")
649 && !option_executes_or_writes(value)
650 }),
651 "uname" => tokens
652 .iter()
653 .skip(1)
654 .all(|value| !option_executes_or_writes(value)),
655 "sort" => tokens.iter().skip(1).all(|value| {
656 !matches!(
657 *value,
658 "-o" | "-T" | "--output" | "--temporary-directory" | "--compress-program"
659 ) && !short_option_contains(value, 'o')
660 && !short_option_contains(value, 'T')
661 && !value.starts_with("--output=")
662 && !value.starts_with("--temporary-directory=")
663 && !value.starts_with("--compress-program=")
664 && !option_executes_or_writes(value)
665 }),
666 "uniq" => positional_argument_count(&tokens[1..]) <= 1,
669 "printf" | "echo" => tokens.iter().skip(1).all(|value| !value.starts_with('-')),
672 "find" => {
673 !tokens
674 .iter()
675 .skip(1)
676 .any(|value| matches!(*value, "-L" | "-H"))
677 && ![
678 " -delete",
679 " -exec",
680 " -execdir",
681 " -ok",
682 " -okdir",
683 " -fprint",
684 " -fprint0",
685 " -fprintf",
686 " -fls",
687 " -follow",
688 " -lname",
689 ]
690 .iter()
691 .any(|action| lower.contains(action))
692 }
693 "sed" => false,
697 "git" => is_read_only_git_segment(&tokens),
698 _ => false,
699 }
700}
701
702fn short_option_contains(value: &str, flag: char) -> bool {
703 value.starts_with('-')
704 && !value.starts_with("--")
705 && value.chars().skip(1).any(|candidate| candidate == flag)
706}
707
708fn option_executes_or_writes(value: &str) -> bool {
709 matches!(
710 value,
711 "--output" | "--exec" | "--command" | "--config" | "--files-from"
712 ) || value.starts_with("--output=")
713 || value.starts_with("--exec=")
714 || value.starts_with("--command=")
715 || value.starts_with("--config=")
716 || value.starts_with("--files-from=")
717}
718
719fn positional_argument_count(tokens: &[&str]) -> usize {
720 tokens
721 .iter()
722 .filter(|value| !value.starts_with('-'))
723 .count()
724}
725
726fn is_read_only_git_segment(tokens: &[&str]) -> bool {
727 if tokens.first().copied() != Some("git") {
728 return false;
729 }
730 let mut index = 1;
731 while index < tokens.len() {
732 match tokens[index] {
733 "--no-pager" | "-P" | "--no-optional-locks" => index += 1,
734 value if value.starts_with('-') => return false,
738 _ => break,
739 }
740 }
741
742 let Some(subcommand) = tokens.get(index).copied() else {
743 return false;
744 };
745 let args = &tokens[index + 1..];
746 if args.iter().any(|value| {
747 matches!(
748 *value,
749 "--ext-diff" | "--textconv" | "--exec-path" | "--config-env"
750 ) || value.starts_with("--exec-path=")
751 || value.starts_with("--config-env=")
752 }) {
753 return false;
754 }
755
756 match subcommand {
757 "status" | "diff" | "log" | "show" | "blame" | "grep" | "ls-files" | "rev-parse" => {
758 !args.iter().any(|value| {
759 option_executes_or_writes(value)
760 || matches!(*value, "--paginate" | "-p" | "--ext-diff" | "--textconv")
761 || value.starts_with("--format=") && value.contains("%(rest)")
762 })
763 }
764 "remote" => match args.first() {
765 Some(value) => matches!(*value, "-v" | "show"),
766 None => true,
767 },
768 "branch" => args.iter().all(|value| {
769 matches!(
770 *value,
771 "--all" | "-a" | "--list" | "--show-current" | "--verbose" | "-v" | "-vv"
772 )
773 }),
774 _ => false,
775 }
776}
777
778fn normalize_shell(command: &str) -> String {
779 command.split_whitespace().collect::<Vec<_>>().join(" ")
780}