1use serde::{Deserialize, Serialize};
9
10use super::{PermissionChecker, PermissionDecision};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum InteractiveApprovalMode {
16 Default,
18 Plan,
20 Auto,
22}
23
24impl InteractiveApprovalMode {
25 pub fn from_name(value: &str) -> Self {
26 match value.trim().to_ascii_lowercase().as_str() {
27 "plan" => Self::Plan,
28 "auto" => Self::Auto,
29 _ => Self::Default,
30 }
31 }
32
33 fn apply(
34 self,
35 tool_name: &str,
36 args: &serde_json::Value,
37 decision: PermissionDecision,
38 ) -> PermissionDecision {
39 match (self, decision) {
40 (_, PermissionDecision::Deny) => PermissionDecision::Deny,
41 (Self::Plan, PermissionDecision::Ask) => PermissionDecision::Ask,
44 (Self::Auto, PermissionDecision::Ask) if auto_mode_may_approve(tool_name, args) => {
45 PermissionDecision::Allow
46 }
47 (_, decision) => decision,
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct InteractiveToolGuardrail {
55 mode: InteractiveApprovalMode,
56 workspace: Option<std::path::PathBuf>,
57}
58
59impl InteractiveToolGuardrail {
60 pub const fn new(mode: InteractiveApprovalMode) -> Self {
61 Self {
62 mode,
63 workspace: None,
64 }
65 }
66
67 pub fn for_mode(mode: &str) -> Self {
68 Self::new(InteractiveApprovalMode::from_name(mode))
69 }
70
71 pub fn with_workspace(mut self, workspace: impl Into<std::path::PathBuf>) -> Self {
73 self.workspace = Some(workspace.into());
74 self
75 }
76
77 pub fn risk_decision(tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
79 classify_tool(tool_name, args)
80 }
81
82 fn workspace_boundary_decision(
83 &self,
84 tool_name: &str,
85 args: &serde_json::Value,
86 ) -> Option<PermissionDecision> {
87 let root = self.workspace.as_deref()?;
88 invocation_crosses_local_symlink(root, tool_name, args).then_some(PermissionDecision::Deny)
89 }
90}
91
92impl Default for InteractiveToolGuardrail {
93 fn default() -> Self {
94 Self::new(InteractiveApprovalMode::Default)
95 }
96}
97
98impl PermissionChecker for InteractiveToolGuardrail {
99 fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
100 if let Some(decision) = self.workspace_boundary_decision(tool_name, args) {
101 return decision;
102 }
103 self.mode
104 .apply(tool_name, args, classify_tool(tool_name, args))
105 }
106}
107
108fn invocation_crosses_local_symlink(
109 root: &std::path::Path,
110 tool_name: &str,
111 args: &serde_json::Value,
112) -> bool {
113 if tool_name.eq_ignore_ascii_case("batch") {
114 return args
115 .get("invocations")
116 .and_then(serde_json::Value::as_array)
117 .is_some_and(|invocations| {
118 invocations.iter().any(|invocation| {
119 let Some(tool) = invocation.get("tool").and_then(serde_json::Value::as_str)
120 else {
121 return false;
122 };
123 let Some(tool_args) = invocation.get("args") else {
124 return false;
125 };
126 invocation_crosses_local_symlink(root, tool, tool_args)
127 })
128 });
129 }
130
131 let tool = tool_name.to_ascii_lowercase();
132 if tool == "bash" {
133 return shell_path_crosses_symlink(root, args);
134 }
135 let field = match tool.as_str() {
136 "read" | "write" | "edit" | "patch" => "file_path",
137 "grep" | "glob" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => "path",
138 _ => return false,
139 };
140 let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
141 return false;
142 };
143 local_path_crosses_symlink(root, path)
144}
145
146fn shell_path_crosses_symlink(root: &std::path::Path, args: &serde_json::Value) -> bool {
147 args.get("command")
148 .and_then(serde_json::Value::as_str)
149 .is_some_and(|command| {
150 command
151 .split_whitespace()
152 .map(clean_shell_token)
153 .filter(|token| !token.is_empty() && !token.starts_with('-'))
154 .any(|token| local_path_crosses_symlink(root, token))
155 })
156}
157
158fn local_path_crosses_symlink(root: &std::path::Path, path: &str) -> bool {
159 if path_is_outside_workspace(path) {
160 return false;
161 }
162 let mut current = root.to_path_buf();
163 for component in std::path::Path::new(path).components() {
164 match component {
165 std::path::Component::CurDir => continue,
166 std::path::Component::Normal(component) => current.push(component),
167 _ => return true,
168 }
169 match std::fs::symlink_metadata(¤t) {
170 Ok(metadata) if metadata.file_type().is_symlink() => return true,
171 Ok(_) => {}
172 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
173 Err(_) => return true,
174 }
175 }
176 false
177}
178
179fn auto_mode_may_approve(tool_name: &str, args: &serde_json::Value) -> bool {
180 match tool_name.to_ascii_lowercase().as_str() {
181 "write" | "edit" => bounded_file_target(args),
184 "patch" => bounded_file_target(args),
185 "git" => {
186 classify_git(args) == PermissionDecision::Ask
187 && git_call_is_known_bounded_mutation(args)
188 }
189 "batch" => batch_auto_approvable(args),
190 _ => false,
193 }
194}
195
196fn bounded_file_target(args: &serde_json::Value) -> bool {
197 args.get("file_path")
198 .and_then(serde_json::Value::as_str)
199 .is_some_and(|path| !path.trim().is_empty() && !path_is_outside_workspace(path))
200}
201
202fn batch_auto_approvable(args: &serde_json::Value) -> bool {
203 let Some(invocations) = args
204 .get("invocations")
205 .and_then(serde_json::Value::as_array)
206 else {
207 return false;
208 };
209 !invocations.is_empty()
210 && invocations.iter().all(|invocation| {
211 let Some(tool) = invocation.get("tool").and_then(serde_json::Value::as_str) else {
212 return false;
213 };
214 let Some(tool_args) = invocation.get("args") else {
215 return false;
216 };
217 match classify_tool(tool, tool_args) {
218 PermissionDecision::Deny => false,
219 PermissionDecision::Allow => true,
220 PermissionDecision::Ask => auto_mode_may_approve(tool, tool_args),
221 }
222 })
223}
224
225fn git_call_is_known_bounded_mutation(args: &serde_json::Value) -> bool {
226 if git_requires_explicit_confirmation(args) {
227 return false;
228 }
229 match args.get("command").and_then(serde_json::Value::as_str) {
230 Some("branch") => valid_non_option_string(args, "name"),
231 Some("checkout") => valid_non_option_string(args, "ref"),
232 Some("stash") => {
233 args.get("message")
234 .and_then(serde_json::Value::as_str)
235 .is_some()
236 || args
237 .get("include_untracked")
238 .and_then(serde_json::Value::as_bool)
239 .unwrap_or(false)
240 }
241 Some("remote") => args
242 .get("remote_name")
243 .and_then(serde_json::Value::as_str)
244 .is_some(),
245 Some("worktree") => matches!(
246 args.get("subcommand").and_then(serde_json::Value::as_str),
247 Some("add")
248 ),
249 _ => false,
250 }
251}
252
253fn git_requires_explicit_confirmation(args: &serde_json::Value) -> bool {
254 args.get("force").is_some_and(|value| value != false)
255}
256
257fn classify_tool(tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
258 match tool_name.to_ascii_lowercase().as_str() {
259 "read" => classify_scoped_path(args, "file_path", PermissionDecision::Allow),
260 "grep" | "glob" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
261 classify_scoped_path(args, "path", PermissionDecision::Allow)
262 }
263 "web_search" | "web_fetch" | "search_skills" | "generate_object" => {
264 PermissionDecision::Allow
265 }
266 "write" | "edit" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
267 "patch" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
270 "bash" => classify_bash(args),
271 "git" => classify_git(args),
272 "batch" => classify_batch(args),
273 _ => PermissionDecision::Ask,
276 }
277}
278
279fn classify_scoped_path(
280 args: &serde_json::Value,
281 field: &str,
282 safe_decision: PermissionDecision,
283) -> PermissionDecision {
284 let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
285 return if field == "path" {
288 safe_decision
289 } else {
290 PermissionDecision::Ask
291 };
292 };
293 if path.trim().is_empty() {
294 return if field == "path" {
295 safe_decision
296 } else {
297 PermissionDecision::Ask
298 };
299 }
300 if path_is_outside_workspace(path) {
301 PermissionDecision::Deny
302 } else {
303 safe_decision
304 }
305}
306
307fn classify_batch(args: &serde_json::Value) -> PermissionDecision {
308 let Some(invocations) = args
309 .get("invocations")
310 .and_then(serde_json::Value::as_array)
311 else {
312 return PermissionDecision::Ask;
313 };
314 if invocations.is_empty() {
315 return PermissionDecision::Ask;
316 }
317
318 let mut aggregate = PermissionDecision::Allow;
319 for invocation in invocations {
320 let Some(tool) = invocation.get("tool").and_then(serde_json::Value::as_str) else {
321 return PermissionDecision::Ask;
322 };
323 let Some(tool_args) = invocation.get("args") else {
324 return PermissionDecision::Ask;
325 };
326 if !tool_args.is_object() {
327 return PermissionDecision::Ask;
328 }
329 let decision = if tool.eq_ignore_ascii_case("batch") {
330 classify_batch(tool_args)
331 } else {
332 classify_tool(tool, tool_args)
333 };
334 match decision {
335 PermissionDecision::Deny => return PermissionDecision::Deny,
336 PermissionDecision::Ask => aggregate = PermissionDecision::Ask,
337 PermissionDecision::Allow => {}
338 }
339 }
340 aggregate
341}
342
343fn classify_git(args: &serde_json::Value) -> PermissionDecision {
344 let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
345 return PermissionDecision::Ask;
346 };
347 if args
348 .get("force")
349 .is_some_and(|value| value.as_bool() != Some(false))
350 {
351 return PermissionDecision::Ask;
352 }
353
354 match command {
355 "status" if only_git_keys(args, &["command"]) => PermissionDecision::Allow,
356 "log"
357 if only_git_keys(args, &["command", "limit", "max_count", "cursor"])
358 && valid_optional_positive_integer(args, "limit")
359 && valid_optional_positive_integer(args, "max_count")
360 && valid_optional_string(args, "cursor") =>
361 {
362 PermissionDecision::Allow
363 }
364 "diff"
365 if only_git_keys(args, &["command", "target", "byte_offset", "max_bytes"])
366 && valid_optional_non_option_string(args, "target")
367 && valid_optional_nonnegative_integer(args, "byte_offset")
368 && valid_optional_positive_integer(args, "max_bytes") =>
369 {
370 PermissionDecision::Allow
371 }
372 "remote"
373 if only_git_keys(args, &["command", "remote_name", "cursor"])
374 && valid_optional_string(args, "remote_name")
375 && valid_optional_string(args, "cursor") =>
376 {
377 PermissionDecision::Allow
378 }
379 "branch"
380 if args.get("name").is_none()
381 && only_git_keys(args, &["command", "limit", "max_count", "cursor"])
382 && valid_optional_positive_integer(args, "limit")
383 && valid_optional_positive_integer(args, "max_count")
384 && valid_optional_string(args, "cursor") =>
385 {
386 PermissionDecision::Allow
387 }
388 "stash"
389 if args.get("message").is_none()
390 && args.get("include_untracked").is_none()
391 && only_git_keys(args, &["command", "cursor"])
392 && valid_optional_string(args, "cursor") =>
393 {
394 PermissionDecision::Allow
395 }
396 "worktree"
397 if args
398 .get("subcommand")
399 .and_then(serde_json::Value::as_str)
400 .unwrap_or("list")
401 == "list"
402 && only_git_keys(args, &["command", "subcommand", "cursor"])
403 && valid_optional_string(args, "subcommand")
404 && valid_optional_string(args, "cursor") =>
405 {
406 PermissionDecision::Allow
407 }
408 _ => PermissionDecision::Ask,
409 }
410}
411
412fn only_git_keys(args: &serde_json::Value, allowed: &[&str]) -> bool {
413 args.as_object().is_some_and(|object| {
414 object
415 .keys()
416 .all(|key| allowed.iter().any(|allowed| key == allowed))
417 })
418}
419
420fn valid_non_option_string(args: &serde_json::Value, field: &str) -> bool {
421 args.get(field)
422 .and_then(serde_json::Value::as_str)
423 .is_some_and(|value| {
424 let value = value.trim();
425 !value.is_empty() && !value.starts_with('-')
426 })
427}
428
429fn valid_optional_non_option_string(args: &serde_json::Value, field: &str) -> bool {
430 args.get(field)
431 .is_none_or(|_| valid_non_option_string(args, field))
432}
433
434fn valid_optional_string(args: &serde_json::Value, field: &str) -> bool {
435 args.get(field).is_none_or(serde_json::Value::is_string)
436}
437
438fn valid_optional_positive_integer(args: &serde_json::Value, field: &str) -> bool {
439 args.get(field)
440 .is_none_or(|value| value.as_u64().is_some_and(|number| number > 0))
441}
442
443fn valid_optional_nonnegative_integer(args: &serde_json::Value, field: &str) -> bool {
444 args.get(field).is_none_or(|value| value.as_u64().is_some())
445}
446
447fn classify_bash(args: &serde_json::Value) -> PermissionDecision {
448 let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
449 return PermissionDecision::Ask;
450 };
451 let command = command.trim();
452 if command.is_empty() {
453 return PermissionDecision::Ask;
454 }
455 if is_catastrophic_bash_command(command) {
456 PermissionDecision::Deny
457 } else if is_read_only_bash_command(command) {
458 PermissionDecision::Allow
459 } else {
460 PermissionDecision::Ask
461 }
462}
463
464fn is_catastrophic_bash_command(command: &str) -> bool {
465 let lower = normalize_shell(command).to_ascii_lowercase();
466 if lower == "sudo"
467 || lower.starts_with("sudo ")
468 || lower.starts_with("doas ")
469 || lower == "su"
470 || lower.starts_with("su ")
471 || lower.starts_with("su -")
472 {
473 return true;
474 }
475 if lower.contains("mkfs")
476 || lower.contains("diskutil erase")
477 || lower.contains(":(){")
478 || lower.contains("kill -9 -1")
479 || lower.starts_with("shutdown")
480 || lower.starts_with("reboot")
481 {
482 return true;
483 }
484 if (lower.contains("curl ") || lower.contains("wget "))
485 && ["| sh", "|sh", "| bash", "|bash", "| zsh", "|zsh"]
486 .iter()
487 .any(|pipe| lower.contains(pipe))
488 {
489 return true;
490 }
491 if (lower.starts_with("dd ") || lower.contains(" dd "))
492 && (lower.contains(" of=/dev/") || lower.contains("of=/dev/"))
493 {
494 return true;
495 }
496
497 lower.contains("rm -rf /")
498 || lower.contains("rm -fr /")
499 || lower.contains("rm -rf ~")
500 || lower.contains("rm -fr ~")
501 || lower.contains("rm -rf $home")
502 || lower.contains("rm -fr $home")
503 || lower.contains("rm -rf *")
504 || lower.contains("rm -fr *")
505 || lower == "rm -rf ."
506 || lower == "rm -fr ."
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 && !matches!(
613 *value,
614 "-R" | "-r"
615 | "--recursive"
616 | "--dereference-recursive"
617 | "--include"
618 | "--exclude-from"
619 )
620 }),
621 "du" => tokens.iter().skip(1).all(|value| {
622 !short_option_contains(value, 'L')
623 && !matches!(*value, "--dereference")
624 && !option_executes_or_writes(value)
625 }),
626 "df" => tokens
627 .iter()
628 .skip(1)
629 .all(|value| !option_executes_or_writes(value)),
630 "date" => tokens.iter().skip(1).all(|value| {
631 !matches!(*value, "-s" | "--set")
632 && !value.starts_with("--set=")
633 && !option_executes_or_writes(value)
634 }),
635 "uname" => tokens
636 .iter()
637 .skip(1)
638 .all(|value| !option_executes_or_writes(value)),
639 "sort" => tokens.iter().skip(1).all(|value| {
640 !matches!(*value, "-o" | "--output" | "--compress-program")
641 && !value.starts_with("--output=")
642 && !value.starts_with("--compress-program=")
643 && !option_executes_or_writes(value)
644 }),
645 "uniq" => positional_argument_count(&tokens[1..]) <= 1,
648 "printf" | "echo" => tokens.iter().skip(1).all(|value| !value.starts_with('-')),
651 "find" => {
652 !tokens
653 .iter()
654 .skip(1)
655 .any(|value| matches!(*value, "-L" | "-H"))
656 && ![
657 " -delete",
658 " -exec",
659 " -execdir",
660 " -ok",
661 " -okdir",
662 " -fprint",
663 " -fprint0",
664 " -fprintf",
665 " -follow",
666 " -lname",
667 ]
668 .iter()
669 .any(|action| lower.contains(action))
670 }
671 "sed" => !tokens.iter().skip(1).any(|value| {
672 *value == "-i"
673 || value.starts_with("-i")
674 || value.starts_with("--in-place")
675 || option_executes_or_writes(value)
676 }),
677 "git" => is_read_only_git_segment(&tokens),
678 _ => false,
679 }
680}
681
682fn short_option_contains(value: &str, flag: char) -> bool {
683 value.starts_with('-')
684 && !value.starts_with("--")
685 && value.chars().skip(1).any(|candidate| candidate == flag)
686}
687
688fn option_executes_or_writes(value: &str) -> bool {
689 matches!(
690 value,
691 "--output" | "--exec" | "--command" | "--config" | "--files-from"
692 ) || value.starts_with("--output=")
693 || value.starts_with("--exec=")
694 || value.starts_with("--command=")
695 || value.starts_with("--config=")
696 || value.starts_with("--files-from=")
697}
698
699fn positional_argument_count(tokens: &[&str]) -> usize {
700 tokens
701 .iter()
702 .filter(|value| !value.starts_with('-'))
703 .count()
704}
705
706fn is_read_only_git_segment(tokens: &[&str]) -> bool {
707 if tokens.first().copied() != Some("git") {
708 return false;
709 }
710 let mut index = 1;
711 while index < tokens.len() {
712 match tokens[index] {
713 "--no-pager" | "-P" | "--no-optional-locks" => index += 1,
714 value if value.starts_with('-') => return false,
718 _ => break,
719 }
720 }
721
722 let Some(subcommand) = tokens.get(index).copied() else {
723 return false;
724 };
725 let args = &tokens[index + 1..];
726 if args.iter().any(|value| {
727 matches!(
728 *value,
729 "--ext-diff" | "--textconv" | "--exec-path" | "--config-env"
730 ) || value.starts_with("--exec-path=")
731 || value.starts_with("--config-env=")
732 }) {
733 return false;
734 }
735
736 match subcommand {
737 "status" | "diff" | "log" | "show" | "blame" | "grep" | "ls-files" | "rev-parse" => {
738 !args.iter().any(|value| {
739 option_executes_or_writes(value)
740 || matches!(*value, "--paginate" | "-p" | "--ext-diff" | "--textconv")
741 || value.starts_with("--format=") && value.contains("%(rest)")
742 })
743 }
744 "remote" => match args.first() {
745 Some(value) => matches!(*value, "-v" | "show"),
746 None => true,
747 },
748 "branch" => args.iter().all(|value| {
749 matches!(
750 *value,
751 "--all" | "-a" | "--list" | "--show-current" | "--verbose" | "-v" | "-vv"
752 )
753 }),
754 _ => false,
755 }
756}
757
758fn normalize_shell(command: &str) -> String {
759 command.split_whitespace().collect::<Vec<_>>().join(" ")
760}