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