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 Force,
31}
32
33impl InteractiveApprovalMode {
34 pub fn from_name(value: &str) -> Self {
35 match value.trim().to_ascii_lowercase().as_str() {
36 "plan" => Self::Plan,
37 "auto" => Self::Auto,
38 "force" | "yolo" => Self::Force,
39 _ => Self::Default,
40 }
41 }
42
43 pub const fn action_for(self, assessment: &ToolRiskAssessment) -> ToolRiskAction {
50 match (self, assessment.level) {
51 (_, ToolRiskLevel::Routine) => ToolRiskAction::Allow,
52 (Self::Plan, ToolRiskLevel::Bounded | ToolRiskLevel::High) => ToolRiskAction::RuleDeny,
53 (Self::Auto | Self::Force, ToolRiskLevel::Bounded) => ToolRiskAction::Allow,
54 (_, ToolRiskLevel::Bounded) => ToolRiskAction::RequireConfirmation,
55 (Self::Force, ToolRiskLevel::High) => ToolRiskAction::Allow,
56 (_, ToolRiskLevel::High) => ToolRiskAction::ReviewByLlm,
57 (_, ToolRiskLevel::Critical) => ToolRiskAction::RuleDeny,
58 }
59 }
60
61 fn apply(self, assessment: &ToolRiskAssessment) -> PermissionDecision {
62 match self.action_for(assessment) {
63 ToolRiskAction::Allow => PermissionDecision::Allow,
64 ToolRiskAction::RequireConfirmation | ToolRiskAction::ReviewByLlm => {
65 PermissionDecision::Ask
69 }
70 ToolRiskAction::RuleDeny => PermissionDecision::Deny,
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct InteractiveToolGuardrail {
78 mode: InteractiveApprovalMode,
79 workspace: Option<std::path::PathBuf>,
80}
81
82impl InteractiveToolGuardrail {
83 pub const fn new(mode: InteractiveApprovalMode) -> Self {
84 Self {
85 mode,
86 workspace: None,
87 }
88 }
89
90 pub fn for_mode(mode: &str) -> Self {
91 Self::new(InteractiveApprovalMode::from_name(mode))
92 }
93
94 pub fn with_workspace(mut self, workspace: impl Into<std::path::PathBuf>) -> Self {
97 self.workspace = Some(workspace.into());
98 self
99 }
100
101 pub fn risk_assessment(tool_name: &str, args: &serde_json::Value) -> ToolRiskAssessment {
106 assess_tool(tool_name, args, None)
107 }
108
109 pub fn risk_decision(tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
118 assessment_permission(&assess_tool(tool_name, args, None))
119 }
120
121 pub fn is_catastrophic_bash_command(command: &str) -> bool {
129 is_catastrophic_bash_command(command)
130 }
131
132 pub fn assess(&self, tool_name: &str, args: &serde_json::Value) -> ToolRiskAssessment {
134 if let Some(assessment) = self.workspace_boundary_assessment(tool_name, args) {
135 return assessment;
136 }
137 assess_tool(tool_name, args, self.workspace.as_deref())
138 }
139
140 pub fn risk_action(&self, tool_name: &str, args: &serde_json::Value) -> ToolRiskAction {
142 self.mode.action_for(&self.assess(tool_name, args))
143 }
144
145 fn workspace_boundary_assessment(
146 &self,
147 tool_name: &str,
148 args: &serde_json::Value,
149 ) -> Option<ToolRiskAssessment> {
150 let root = self.workspace.as_deref()?;
151 invocation_crosses_local_symlink(root, tool_name, args).then(|| {
152 critical_assessment(
153 tool_risk_type(tool_name),
154 OperationTarget::OutsideWorkspace,
155 ImpactScope::Host,
156 Reversibility::Unknown,
157 EnvironmentSensitivity::Host,
158 ToolRiskReason::SymlinkBoundaryEscape,
159 )
160 })
161 }
162}
163
164impl Default for InteractiveToolGuardrail {
165 fn default() -> Self {
166 Self::new(InteractiveApprovalMode::Default)
167 }
168}
169
170impl PermissionChecker for InteractiveToolGuardrail {
171 fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
172 self.mode.apply(&self.assess(tool_name, args))
173 }
174}
175
176fn invocation_crosses_local_symlink(
177 root: &std::path::Path,
178 tool_name: &str,
179 args: &serde_json::Value,
180) -> bool {
181 if tool_name.eq_ignore_ascii_case("batch") {
182 return args
183 .get("invocations")
184 .and_then(serde_json::Value::as_array)
185 .is_some_and(|invocations| {
186 invocations.iter().any(|invocation| {
187 let Some(tool) = invocation.get("tool").and_then(serde_json::Value::as_str)
188 else {
189 return false;
190 };
191 let Some(tool_args) = invocation.get("args") else {
192 return false;
193 };
194 invocation_crosses_local_symlink(root, tool, tool_args)
195 })
196 });
197 }
198
199 let tool = tool_name.to_ascii_lowercase();
200 if tool == "bash" {
201 return shell_path_crosses_symlink(root, args);
202 }
203 if tool == "read" {
204 if let Some(path) = args.get("file_path").and_then(serde_json::Value::as_str) {
205 return local_path_crosses_symlink(root, path);
206 }
207 return args
208 .get("files")
209 .and_then(serde_json::Value::as_array)
210 .is_some_and(|files| {
211 files.iter().any(|entry| {
212 entry
213 .get("path")
214 .and_then(serde_json::Value::as_str)
215 .is_some_and(|path| local_path_crosses_symlink(root, path))
216 })
217 });
218 }
219 let field = match tool.as_str() {
220 "write" | "edit" | "patch" | "download" => "file_path",
221 "search" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => "path",
222 _ => return false,
223 };
224 let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
225 return false;
226 };
227 local_path_crosses_symlink(root, path)
228}
229
230fn shell_path_crosses_symlink(root: &std::path::Path, args: &serde_json::Value) -> bool {
231 args.get("command")
232 .and_then(serde_json::Value::as_str)
233 .is_some_and(|command| {
234 command
235 .split_whitespace()
236 .map(clean_shell_token)
237 .filter(|token| !token.is_empty() && !token.starts_with('-'))
238 .any(|token| shell_token_path_crosses_symlink(root, token))
239 })
240}
241
242fn local_path_crosses_symlink(root: &std::path::Path, path: &str) -> bool {
243 path_crosses_symlink(root, path, false)
244}
245
246fn shell_token_path_crosses_symlink(root: &std::path::Path, path: &str) -> bool {
247 path_crosses_symlink(root, path, true)
248}
249
250fn path_crosses_symlink(root: &std::path::Path, path: &str, stop_at_shell_glob: bool) -> bool {
251 if path_is_outside_workspace(path, Some(root)) {
252 return false;
253 }
254 let Some(relative) = workspace_relative_path(root, path) else {
255 return true;
256 };
257 let mut current = root.to_path_buf();
258 for component in std::path::Path::new(&relative).components() {
259 match component {
260 std::path::Component::CurDir => continue,
261 std::path::Component::Normal(component) => {
262 if stop_at_shell_glob
263 && component
264 .to_string_lossy()
265 .contains(['*', '?', '[', ']', '{', '}'])
266 {
267 return false;
271 }
272 current.push(component);
273 }
274 _ => return true,
275 }
276 match std::fs::symlink_metadata(¤t) {
277 Ok(metadata) if metadata.file_type().is_symlink() => return true,
278 Ok(_) => {}
279 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
280 Err(_) => return true,
281 }
282 }
283 false
284}
285
286pub(super) fn atomic_tool_is_bounded(
287 tool_name: &str,
288 args: &serde_json::Value,
289 workspace: Option<&std::path::Path>,
290) -> bool {
291 match tool_name.to_ascii_lowercase().as_str() {
292 "write" | "edit" | "patch" => bounded_file_target(args, workspace),
295 "download" => args.get("file_path").is_none() || bounded_file_target(args, workspace),
298 "git" => {
299 classify_git(args) == PermissionDecision::Ask
300 && git_call_is_known_bounded_mutation(args)
301 }
302 _ => false,
305 }
306}
307
308fn bounded_file_target(args: &serde_json::Value, workspace: Option<&std::path::Path>) -> bool {
309 args.get("file_path")
310 .and_then(serde_json::Value::as_str)
311 .is_some_and(|path| !path.trim().is_empty() && !path_is_outside_workspace(path, workspace))
312}
313
314fn git_call_is_known_bounded_mutation(args: &serde_json::Value) -> bool {
315 if git_requires_explicit_confirmation(args) {
316 return false;
317 }
318 match args.get("command").and_then(serde_json::Value::as_str) {
319 Some("branch") => valid_non_option_string(args, "name"),
320 Some("checkout") => valid_non_option_string(args, "ref"),
321 Some("stash") => {
322 args.get("message")
323 .and_then(serde_json::Value::as_str)
324 .is_some()
325 || args
326 .get("include_untracked")
327 .and_then(serde_json::Value::as_bool)
328 .unwrap_or(false)
329 }
330 Some("remote") => args
331 .get("remote_name")
332 .and_then(serde_json::Value::as_str)
333 .is_some(),
334 Some("worktree") => matches!(
335 args.get("subcommand").and_then(serde_json::Value::as_str),
336 Some("add")
337 ),
338 _ => false,
339 }
340}
341
342fn git_requires_explicit_confirmation(args: &serde_json::Value) -> bool {
343 args.get("force").is_some_and(|value| value != false)
344}
345
346pub(super) fn classify_atomic_tool(
347 tool_name: &str,
348 args: &serde_json::Value,
349 workspace: Option<&std::path::Path>,
350) -> PermissionDecision {
351 match tool_name.to_ascii_lowercase().as_str() {
352 "read" => classify_read(args, workspace),
353 "search" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
354 classify_scoped_path(args, "path", PermissionDecision::Allow, workspace)
355 }
356 "web_search" | "web_fetch" | "search_skills" | "generate_object" | "update_plan"
357 | "ask_user" => PermissionDecision::Allow,
358 "write" | "edit" => {
359 classify_scoped_path(args, "file_path", PermissionDecision::Ask, workspace)
360 }
361 "download" if args.get("file_path").is_none() => PermissionDecision::Ask,
362 "download" => classify_scoped_path(args, "file_path", PermissionDecision::Ask, workspace),
363 "patch" => classify_scoped_path(args, "file_path", PermissionDecision::Ask, workspace),
366 "bash" => classify_bash(args, workspace),
367 "git" => classify_git(args),
368 _ => PermissionDecision::Ask,
371 }
372}
373
374fn classify_read(
375 args: &serde_json::Value,
376 workspace: Option<&std::path::Path>,
377) -> PermissionDecision {
378 if let Some(path) = args.get("file_path").and_then(serde_json::Value::as_str) {
379 return classify_path_value(path, PermissionDecision::Allow, workspace);
380 }
381 let Some(files) = args.get("files").and_then(serde_json::Value::as_array) else {
382 return PermissionDecision::Ask;
383 };
384 if files.is_empty() {
385 return PermissionDecision::Ask;
386 }
387 let mut decision = PermissionDecision::Allow;
388 for entry in files {
389 let Some(path) = entry.get("path").and_then(serde_json::Value::as_str) else {
390 return PermissionDecision::Ask;
391 };
392 decision = stricter_permission(
393 decision,
394 classify_path_value(path, PermissionDecision::Allow, workspace),
395 );
396 }
397 decision
398}
399
400fn stricter_permission(left: PermissionDecision, right: PermissionDecision) -> PermissionDecision {
401 use PermissionDecision::*;
402 match (left, right) {
403 (Deny, _) | (_, Deny) => Deny,
404 (Ask, _) | (_, Ask) => Ask,
405 (Allow, Allow) => Allow,
406 }
407}
408
409fn classify_scoped_path(
410 args: &serde_json::Value,
411 field: &str,
412 safe_decision: PermissionDecision,
413 workspace: Option<&std::path::Path>,
414) -> PermissionDecision {
415 let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
416 return if field == "path" {
419 safe_decision
420 } else {
421 PermissionDecision::Ask
422 };
423 };
424 if path.trim().is_empty() {
425 return if field == "path" {
426 safe_decision
427 } else {
428 PermissionDecision::Ask
429 };
430 }
431 classify_path_value(path, safe_decision, workspace)
432}
433
434fn classify_path_value(
435 path: &str,
436 safe_decision: PermissionDecision,
437 workspace: Option<&std::path::Path>,
438) -> PermissionDecision {
439 if path.trim().is_empty() {
440 return PermissionDecision::Ask;
441 }
442 if path_is_outside_workspace(path, workspace) {
443 PermissionDecision::Deny
444 } else {
445 safe_decision
446 }
447}
448
449fn classify_git(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 if args
454 .get("force")
455 .is_some_and(|value| value.as_bool() != Some(false))
456 {
457 return PermissionDecision::Ask;
458 }
459
460 match command {
461 "status" if only_git_keys(args, &["command"]) => PermissionDecision::Allow,
462 "log"
463 if only_git_keys(args, &["command", "limit", "max_count", "cursor"])
464 && valid_optional_positive_integer(args, "limit")
465 && valid_optional_positive_integer(args, "max_count")
466 && valid_optional_string(args, "cursor") =>
467 {
468 PermissionDecision::Allow
469 }
470 "diff"
471 if only_git_keys(args, &["command", "target", "byte_offset", "max_bytes"])
472 && valid_optional_non_option_string(args, "target")
473 && valid_optional_nonnegative_integer(args, "byte_offset")
474 && valid_optional_positive_integer(args, "max_bytes") =>
475 {
476 PermissionDecision::Allow
477 }
478 "remote"
479 if only_git_keys(args, &["command", "remote_name", "cursor"])
480 && valid_optional_string(args, "remote_name")
481 && valid_optional_string(args, "cursor") =>
482 {
483 PermissionDecision::Allow
484 }
485 "branch"
486 if args.get("name").is_none()
487 && only_git_keys(args, &["command", "limit", "max_count", "cursor"])
488 && valid_optional_positive_integer(args, "limit")
489 && valid_optional_positive_integer(args, "max_count")
490 && valid_optional_string(args, "cursor") =>
491 {
492 PermissionDecision::Allow
493 }
494 "stash"
495 if args.get("message").is_none()
496 && args.get("include_untracked").is_none()
497 && only_git_keys(args, &["command", "cursor"])
498 && valid_optional_string(args, "cursor") =>
499 {
500 PermissionDecision::Allow
501 }
502 "worktree"
503 if args
504 .get("subcommand")
505 .and_then(serde_json::Value::as_str)
506 .unwrap_or("list")
507 == "list"
508 && only_git_keys(args, &["command", "subcommand", "cursor"])
509 && valid_optional_string(args, "subcommand")
510 && valid_optional_string(args, "cursor") =>
511 {
512 PermissionDecision::Allow
513 }
514 _ => PermissionDecision::Ask,
515 }
516}
517
518fn only_git_keys(args: &serde_json::Value, allowed: &[&str]) -> bool {
519 args.as_object().is_some_and(|object| {
520 object
521 .keys()
522 .all(|key| allowed.iter().any(|allowed| key == allowed))
523 })
524}
525
526fn valid_non_option_string(args: &serde_json::Value, field: &str) -> bool {
527 args.get(field)
528 .and_then(serde_json::Value::as_str)
529 .is_some_and(|value| {
530 let value = value.trim();
531 !value.is_empty() && !value.starts_with('-')
532 })
533}
534
535fn valid_optional_non_option_string(args: &serde_json::Value, field: &str) -> bool {
536 args.get(field)
537 .is_none_or(|_| valid_non_option_string(args, field))
538}
539
540fn valid_optional_string(args: &serde_json::Value, field: &str) -> bool {
541 args.get(field).is_none_or(serde_json::Value::is_string)
542}
543
544fn valid_optional_positive_integer(args: &serde_json::Value, field: &str) -> bool {
545 args.get(field)
546 .is_none_or(|value| value.as_u64().is_some_and(|number| number > 0))
547}
548
549fn valid_optional_nonnegative_integer(args: &serde_json::Value, field: &str) -> bool {
550 args.get(field).is_none_or(|value| value.as_u64().is_some())
551}
552
553fn classify_bash(
554 args: &serde_json::Value,
555 workspace: Option<&std::path::Path>,
556) -> PermissionDecision {
557 let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
558 return PermissionDecision::Ask;
559 };
560 let command = command.trim();
561 if command.is_empty() {
562 return PermissionDecision::Ask;
563 }
564 if is_catastrophic_bash_command(command) {
565 PermissionDecision::Deny
566 } else if is_read_only_bash_command(command, workspace) {
567 PermissionDecision::Allow
568 } else {
569 PermissionDecision::Ask
570 }
571}
572
573fn is_catastrophic_bash_command(command: &str) -> bool {
574 let lower = normalize_shell(command).to_ascii_lowercase();
575 if lower == "sudo"
576 || lower.starts_with("sudo ")
577 || lower.starts_with("doas ")
578 || lower == "su"
579 || lower.starts_with("su ")
580 || lower.starts_with("su -")
581 {
582 return true;
583 }
584 if shell_invokes_mkfs(command)
585 || lower.contains("diskutil erase")
586 || lower.contains(":(){")
587 || lower.contains("kill -9 -1")
588 || lower.starts_with("shutdown")
589 || lower.starts_with("reboot")
590 {
591 return true;
592 }
593 if (lower.contains("curl ") || lower.contains("wget "))
594 && ["| sh", "|sh", "| bash", "|bash", "| zsh", "|zsh"]
595 .iter()
596 .any(|pipe| lower.contains(pipe))
597 {
598 return true;
599 }
600 if (lower.starts_with("dd ") || lower.contains(" dd "))
601 && (lower.contains(" of=/dev/") || lower.contains("of=/dev/"))
602 {
603 return true;
604 }
605
606 lower.contains("rm -rf /")
607 || lower.contains("rm -fr /")
608 || lower.contains("rm -rf ~")
609 || lower.contains("rm -fr ~")
610 || lower.contains("rm -rf $home")
611 || lower.contains("rm -fr $home")
612 || lower.contains("rm -rf *")
613 || lower.contains("rm -fr *")
614 || lower == "rm -rf ."
615 || lower == "rm -fr ."
616}
617
618fn shell_invokes_mkfs(command: &str) -> bool {
619 command
620 .split(['|', ';', '&'])
621 .filter_map(|segment| segment.split_whitespace().next())
622 .map(clean_shell_token)
623 .filter_map(|executable| executable.rsplit('/').next())
624 .any(|executable| executable == "mkfs" || executable.starts_with("mkfs."))
625}
626
627fn is_read_only_bash_command(command: &str, workspace: Option<&std::path::Path>) -> bool {
628 if command
632 .chars()
633 .any(|character| character.is_whitespace() && character != ' ')
634 || command.contains(['\'', '"', '*', '?', '[', ']', '{', '}'])
635 || contains_unsafe_shell_syntax(command, workspace)
636 {
637 return false;
638 }
639 command
640 .split('|')
641 .all(|segment| is_read_only_bash_segment(segment.trim()))
642}
643
644fn contains_unsafe_shell_syntax(command: &str, workspace: Option<&std::path::Path>) -> bool {
645 command.contains("&&")
646 || command.contains("||")
647 || command.contains(';')
648 || command.contains('>')
649 || command.contains('<')
650 || command.contains('`')
651 || command.contains("$(")
652 || command.contains('&')
653 || command.contains('\n')
654 || command.contains('\r')
655 || command.contains('$')
656 || has_unscoped_path_token(command, workspace)
657}
658
659fn has_unscoped_path_token(command: &str, workspace: Option<&std::path::Path>) -> bool {
660 command
661 .split_whitespace()
662 .map(clean_shell_token)
663 .filter(|token| !token.is_empty())
664 .any(|token| path_is_outside_workspace(token, workspace))
665}
666
667fn clean_shell_token(token: &str) -> &str {
668 token.trim_matches(|character: char| {
669 matches!(
670 character,
671 '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ':'
672 )
673 })
674}
675
676fn path_is_outside_workspace(path: &str, workspace: Option<&std::path::Path>) -> bool {
681 let normalized = path.replace('\\', "/");
682 let path = normalized.trim();
683 if path.is_empty() {
684 return false;
685 }
686 if path.starts_with('~') || path.starts_with("$HOME") || path.starts_with("${HOME}") {
687 return true;
688 }
689
690 let Some(root) = workspace else {
691 return path_is_lexically_absolute(path) || relative_path_escapes(path);
692 };
693 path_escapes_workspace_root(root, path)
694}
695
696fn path_is_lexically_absolute(path: &str) -> bool {
697 let bytes = path.as_bytes();
698 (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
699 || path.starts_with("//")
700 || path.starts_with('/')
701}
702
703fn relative_path_escapes(path: &str) -> bool {
704 let mut depth = 0_i32;
705 for component in path.split('/') {
706 match component {
707 "" | "." => {}
708 ".." if depth == 0 => return true,
709 ".." => depth -= 1,
710 _ => depth += 1,
711 }
712 }
713 false
714}
715
716fn path_escapes_workspace_root(root: &std::path::Path, input: &str) -> bool {
717 let candidate = std::path::Path::new(input);
718 if candidate.is_absolute() || path_is_lexically_absolute(input) {
719 match (
720 normalize_abs_for_compare(root),
721 normalize_abs_for_compare(candidate),
722 ) {
723 (Ok(root_cmp), Ok(target_cmp)) => !target_cmp.starts_with(&root_cmp),
724 _ => true,
725 }
726 } else {
727 relative_path_escapes(input)
728 }
729}
730
731fn workspace_relative_path(root: &std::path::Path, path: &str) -> Option<String> {
732 let candidate = std::path::Path::new(path);
733 if !candidate.is_absolute() && !path_is_lexically_absolute(path) {
734 return Some(path.replace('\\', "/"));
735 }
736 let root_cmp = normalize_abs_for_compare(root).ok()?;
737 let target_cmp = normalize_abs_for_compare(candidate).ok()?;
738 let relative = target_cmp.strip_prefix(&root_cmp).ok()?;
739 Some(relative.to_string_lossy().replace('\\', "/"))
740}
741
742fn normalize_abs_for_compare(path: &std::path::Path) -> Result<std::path::PathBuf, ()> {
746 let lexical = normalize_abs_lexical(path)?;
747 if let Ok(canonical) = lexical.canonicalize() {
748 return Ok(canonical);
749 }
750
751 let mut current = lexical.as_path();
752 let mut suffix = Vec::new();
753 while !current.exists() {
754 let Some(file_name) = current.file_name() else {
755 return Ok(lexical);
756 };
757 suffix.push(file_name.to_os_string());
758 let Some(parent) = current.parent() else {
759 return Ok(lexical);
760 };
761 current = parent;
762 }
763
764 let mut normalized = current
765 .canonicalize()
766 .unwrap_or_else(|_| current.to_path_buf());
767 for part in suffix.iter().rev() {
768 normalized.push(part);
769 }
770 Ok(normalized)
771}
772
773fn normalize_abs_lexical(path: &std::path::Path) -> Result<std::path::PathBuf, ()> {
774 let mut out = std::path::PathBuf::new();
775 for component in path.components() {
776 match component {
777 std::path::Component::Prefix(prefix) => out.push(prefix.as_os_str()),
778 std::path::Component::RootDir => {
779 out.push(std::path::Path::new(std::path::MAIN_SEPARATOR_STR));
780 }
781 std::path::Component::CurDir => {}
782 std::path::Component::Normal(part) => out.push(part),
783 std::path::Component::ParentDir => {
784 if !out.pop() {
785 return Err(());
786 }
787 }
788 }
789 }
790 if out.as_os_str().is_empty() {
791 Err(())
792 } else {
793 Ok(out)
794 }
795}
796
797fn is_read_only_bash_segment(segment: &str) -> bool {
798 let tokens: Vec<&str> = segment.split_whitespace().collect();
799 let Some(command) = tokens.first().copied().map(clean_shell_token) else {
800 return false;
801 };
802 let lower = segment.to_ascii_lowercase();
803
804 match command {
805 "pwd" | "cat" | "head" | "tail" | "wc" | "stat" | "file" | "cut" | "tr" | "whoami" => {
806 tokens
807 .iter()
808 .skip(1)
809 .all(|value| !option_executes_or_writes(value))
810 }
811 "ls" => tokens.iter().skip(1).all(|value| {
812 !option_executes_or_writes(value)
813 && !short_option_contains(value, 'L')
814 && !short_option_contains(value, 'R')
815 && !matches!(*value, "--dereference" | "--recursive")
816 }),
817 "rg" => tokens.iter().skip(1).all(|value| {
818 !option_executes_or_writes(value)
819 && !matches!(*value, "--pre" | "--hostname-bin" | "-L" | "--follow")
820 && !value.starts_with("--pre=")
821 && !value.starts_with("--hostname-bin=")
822 }),
823 "grep" => tokens.iter().skip(1).all(|value| {
824 !option_executes_or_writes(value)
825 && !short_option_contains(value, 'f')
826 && !matches!(
827 *value,
828 "-R" | "-r"
829 | "--recursive"
830 | "--dereference-recursive"
831 | "--include"
832 | "--exclude-from"
833 | "--file"
834 )
835 && !value.starts_with("--exclude-from=")
836 && !value.starts_with("--file=")
837 }),
838 "du" => tokens.iter().skip(1).all(|value| {
839 !short_option_contains(value, 'L')
840 && !matches!(*value, "--dereference")
841 && !option_executes_or_writes(value)
842 }),
843 "df" => tokens
844 .iter()
845 .skip(1)
846 .all(|value| !option_executes_or_writes(value)),
847 "date" => tokens.iter().skip(1).all(|value| {
848 !matches!(*value, "-s" | "--set")
849 && !short_option_contains(value, 's')
850 && !value.starts_with("--set=")
851 && !option_executes_or_writes(value)
852 }),
853 "uname" => tokens
854 .iter()
855 .skip(1)
856 .all(|value| !option_executes_or_writes(value)),
857 "sort" => tokens.iter().skip(1).all(|value| {
858 !matches!(
859 *value,
860 "-o" | "-T" | "--output" | "--temporary-directory" | "--compress-program"
861 ) && !short_option_contains(value, 'o')
862 && !short_option_contains(value, 'T')
863 && !value.starts_with("--output=")
864 && !value.starts_with("--temporary-directory=")
865 && !value.starts_with("--compress-program=")
866 && !option_executes_or_writes(value)
867 }),
868 "uniq" => positional_argument_count(&tokens[1..]) <= 1,
871 "printf" | "echo" => tokens.iter().skip(1).all(|value| !value.starts_with('-')),
874 "find" => {
875 !tokens
876 .iter()
877 .skip(1)
878 .any(|value| matches!(*value, "-L" | "-H"))
879 && ![
880 " -delete",
881 " -exec",
882 " -execdir",
883 " -ok",
884 " -okdir",
885 " -fprint",
886 " -fprint0",
887 " -fprintf",
888 " -fls",
889 " -follow",
890 " -lname",
891 ]
892 .iter()
893 .any(|action| lower.contains(action))
894 }
895 "sed" => false,
899 "git" => is_read_only_git_segment(&tokens),
900 _ => false,
901 }
902}
903
904fn short_option_contains(value: &str, flag: char) -> bool {
905 value.starts_with('-')
906 && !value.starts_with("--")
907 && value.chars().skip(1).any(|candidate| candidate == flag)
908}
909
910fn option_executes_or_writes(value: &str) -> bool {
911 matches!(
912 value,
913 "--output" | "--exec" | "--command" | "--config" | "--files-from"
914 ) || value.starts_with("--output=")
915 || value.starts_with("--exec=")
916 || value.starts_with("--command=")
917 || value.starts_with("--config=")
918 || value.starts_with("--files-from=")
919}
920
921fn positional_argument_count(tokens: &[&str]) -> usize {
922 tokens
923 .iter()
924 .filter(|value| !value.starts_with('-'))
925 .count()
926}
927
928fn is_read_only_git_segment(tokens: &[&str]) -> bool {
929 if tokens.first().copied() != Some("git") {
930 return false;
931 }
932 let mut index = 1;
933 while index < tokens.len() {
934 match tokens[index] {
935 "--no-pager" | "-P" | "--no-optional-locks" => index += 1,
936 value if value.starts_with('-') => return false,
940 _ => break,
941 }
942 }
943
944 let Some(subcommand) = tokens.get(index).copied() else {
945 return false;
946 };
947 let args = &tokens[index + 1..];
948 if args.iter().any(|value| {
949 matches!(
950 *value,
951 "--ext-diff" | "--textconv" | "--exec-path" | "--config-env"
952 ) || value.starts_with("--exec-path=")
953 || value.starts_with("--config-env=")
954 }) {
955 return false;
956 }
957
958 match subcommand {
959 "status" | "diff" | "log" | "show" | "blame" | "grep" | "ls-files" | "rev-parse" => {
960 !args.iter().any(|value| {
961 option_executes_or_writes(value)
962 || matches!(*value, "--paginate" | "-p" | "--ext-diff" | "--textconv")
963 || value.starts_with("--format=") && value.contains("%(rest)")
964 })
965 }
966 "remote" => match args.first() {
967 Some(value) => matches!(*value, "-v" | "show"),
968 None => true,
969 },
970 "branch" => args.iter().all(|value| {
971 matches!(
972 *value,
973 "--all" | "-a" | "--list" | "--show-current" | "--verbose" | "-v" | "-vv"
974 )
975 }),
976 _ => false,
977 }
978}
979
980fn normalize_shell(command: &str) -> String {
981 command.split_whitespace().collect::<Vec<_>>().join(" ")
982}
983
984#[cfg(test)]
985mod coverage_tests {
986 use super::*;
987 use crate::permissions::PermissionDecision;
988
989 #[test]
990 fn stricter_permission_and_bash_segment_helpers_cover_near_miss_branches() {
991 assert_eq!(
992 stricter_permission(PermissionDecision::Deny, PermissionDecision::Ask),
993 PermissionDecision::Deny
994 );
995 assert_eq!(
996 stricter_permission(PermissionDecision::Ask, PermissionDecision::Allow),
997 PermissionDecision::Ask
998 );
999
1000 assert!(!is_read_only_bash_segment(""));
1001 assert!(!is_read_only_bash_segment("ls -R"));
1002 assert!(!is_read_only_bash_segment("ls --recursive"));
1003 assert!(!is_read_only_bash_segment("ls --dereference"));
1004 assert!(!is_read_only_bash_segment("grep --exclude-from=x pattern"));
1005 assert!(!is_read_only_bash_segment("grep --file=x pattern"));
1006 assert!(!is_read_only_bash_segment("grep --recursive pattern"));
1007 assert!(!is_read_only_bash_segment("du -L ."));
1008 assert!(!is_read_only_bash_segment("du --dereference ."));
1009 assert!(is_read_only_bash_segment("df -h"));
1010 assert!(is_read_only_bash_segment("uname -a"));
1011 assert!(!is_read_only_bash_segment("cat --output=x"));
1012 assert!(option_executes_or_writes("--files-from"));
1013 assert!(option_executes_or_writes("--output=/tmp/x"));
1014
1015 assert!(!is_read_only_git_segment(&["status"]));
1016 assert!(!is_read_only_git_segment(&["git"]));
1017 assert!(is_read_only_git_segment(&["git", "--no-pager", "status"]));
1018 assert!(!is_read_only_git_segment(&["git", "-C", "/tmp", "status"]));
1019 assert!(!is_read_only_git_segment(&["git", "diff", "--ext-diff"]));
1020 assert!(is_read_only_git_segment(&["git", "remote", "-v"]));
1021 assert!(is_read_only_git_segment(&["git", "remote"]));
1022 assert!(is_read_only_git_segment(&[
1023 "git",
1024 "branch",
1025 "--show-current"
1026 ]));
1027 assert!(!is_read_only_git_segment(&[
1028 "git", "branch", "--delete", "x"
1029 ]));
1030 }
1031
1032 #[test]
1033 fn normalize_abs_lexical_handles_dot_dot_and_curdir() {
1034 let rooted = normalize_abs_lexical(std::path::Path::new("/tmp/a/../b/./c")).unwrap();
1035 assert!(rooted.ends_with("b/c") || rooted.ends_with("b\\c"));
1036
1037 assert!(normalize_abs_lexical(std::path::Path::new("/../")).is_err());
1038 assert!(!path_is_outside_workspace(
1039 "",
1040 Some(std::path::Path::new("/tmp"))
1041 ));
1042 assert!(path_is_outside_workspace(
1043 "/etc/passwd",
1044 Some(std::path::Path::new("/tmp"))
1045 ));
1046 assert!(!path_is_outside_workspace(
1047 "relative/path",
1048 Some(std::path::Path::new("/tmp"))
1049 ));
1050 assert!(relative_path_escapes("../escape"));
1051 assert!(!relative_path_escapes("safe/path"));
1052 }
1053
1054 #[test]
1055 fn invocation_crosses_local_symlink_skips_malformed_batch_entries() {
1056 let root = tempfile::tempdir().unwrap();
1057 assert!(!invocation_crosses_local_symlink(
1058 root.path(),
1059 "batch",
1060 &serde_json::json!({
1061 "invocations": [
1062 {"args": {"file_path": "x"}},
1063 {"tool": "read"},
1064 {"tool": "unknown_tool", "args": {"file_path": "x"}}
1065 ]
1066 })
1067 ));
1068 assert!(!invocation_crosses_local_symlink(
1069 root.path(),
1070 "unknown",
1071 &serde_json::json!({})
1072 ));
1073 }
1074}