|
22 | 22 |
|
23 | 23 | _ABSOLUTE_PATH_PATTERN = re.compile(r"(?<![:\w])(?<!:/)/(?:[^\s\"'`;&|<>()]+)") |
24 | 24 | _FILE_URL_PATTERN = re.compile(r"\bfile://\S+", re.IGNORECASE) |
| 25 | +_URL_WITH_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE) |
| 26 | +_URL_IN_COMMAND_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s\"'`;&|<>()]+", re.IGNORECASE) |
| 27 | +_DOTDOT_PATH_SEGMENT_PATTERN = re.compile(r"(?:^|[/\\=])\.\.(?:$|[/\\])") |
25 | 28 | _LOCAL_BASH_SYSTEM_PATH_PREFIXES = ( |
26 | 29 | "/bin/", |
27 | 30 | "/usr/bin/", |
|
37 | 40 | _MAX_GLOB_MAX_RESULTS = 1000 |
38 | 41 | _DEFAULT_GREP_MAX_RESULTS = 100 |
39 | 42 | _MAX_GREP_MAX_RESULTS = 500 |
| 43 | +_LOCAL_BASH_CWD_COMMANDS = {"cd", "pushd"} |
| 44 | +_LOCAL_BASH_COMMAND_WRAPPERS = {"command", "builtin"} |
| 45 | +_LOCAL_BASH_COMMAND_PREFIX_KEYWORDS = {"!", "{", "case", "do", "elif", "else", "for", "if", "select", "then", "time", "until", "while"} |
| 46 | +_LOCAL_BASH_COMMAND_END_KEYWORDS = {"}", "done", "esac", "fi"} |
| 47 | +_LOCAL_BASH_ROOT_PATH_COMMANDS = { |
| 48 | + "awk", |
| 49 | + "cat", |
| 50 | + "cp", |
| 51 | + "du", |
| 52 | + "find", |
| 53 | + "grep", |
| 54 | + "head", |
| 55 | + "less", |
| 56 | + "ln", |
| 57 | + "ls", |
| 58 | + "more", |
| 59 | + "mv", |
| 60 | + "rm", |
| 61 | + "sed", |
| 62 | + "tail", |
| 63 | + "tar", |
| 64 | +} |
| 65 | +_SHELL_COMMAND_SEPARATORS = {";", "&&", "||", "|", "|&", "&", "(", ")"} |
| 66 | +_SHELL_REDIRECTION_OPERATORS = { |
| 67 | + "<", |
| 68 | + ">", |
| 69 | + "<<", |
| 70 | + ">>", |
| 71 | + "<<<", |
| 72 | + "<>", |
| 73 | + ">&", |
| 74 | + "<&", |
| 75 | + "&>", |
| 76 | + "&>>", |
| 77 | + ">|", |
| 78 | +} |
40 | 79 |
|
41 | 80 |
|
42 | 81 | def _get_skills_container_path() -> str: |
@@ -635,6 +674,214 @@ def _resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState |
635 | 674 | return str(resolved) |
636 | 675 |
|
637 | 676 |
|
| 677 | +def _is_non_file_url_token(token: str) -> bool: |
| 678 | + """Return True for URL tokens that should not be interpreted as paths.""" |
| 679 | + values = [token] |
| 680 | + if "=" in token: |
| 681 | + values.append(token.split("=", 1)[1]) |
| 682 | + |
| 683 | + for value in values: |
| 684 | + match = _URL_WITH_SCHEME_PATTERN.match(value) |
| 685 | + if match and not value.lower().startswith("file://"): |
| 686 | + return True |
| 687 | + return False |
| 688 | + |
| 689 | + |
| 690 | +def _non_file_url_spans(command: str) -> list[tuple[int, int]]: |
| 691 | + spans = [] |
| 692 | + for match in _URL_IN_COMMAND_PATTERN.finditer(command): |
| 693 | + if not match.group().lower().startswith("file://"): |
| 694 | + spans.append(match.span()) |
| 695 | + return spans |
| 696 | + |
| 697 | + |
| 698 | +def _is_in_spans(position: int, spans: list[tuple[int, int]]) -> bool: |
| 699 | + return any(start <= position < end for start, end in spans) |
| 700 | + |
| 701 | + |
| 702 | +def _has_dotdot_path_segment(token: str) -> bool: |
| 703 | + if _is_non_file_url_token(token): |
| 704 | + return False |
| 705 | + return bool(_DOTDOT_PATH_SEGMENT_PATTERN.search(token)) |
| 706 | + |
| 707 | + |
| 708 | +def _split_shell_tokens(command: str) -> list[str]: |
| 709 | + try: |
| 710 | + normalized = command.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ; ") |
| 711 | + lexer = shlex.shlex(normalized, posix=True, punctuation_chars=True) |
| 712 | + lexer.whitespace_split = True |
| 713 | + lexer.commenters = "" |
| 714 | + return list(lexer) |
| 715 | + except ValueError: |
| 716 | + # The shell will reject malformed quoting later; keep validation |
| 717 | + # best-effort instead of turning syntax errors into security messages. |
| 718 | + return command.split() |
| 719 | + |
| 720 | + |
| 721 | +def _is_shell_command_separator(token: str) -> bool: |
| 722 | + return token in _SHELL_COMMAND_SEPARATORS |
| 723 | + |
| 724 | + |
| 725 | +def _is_shell_redirection_operator(token: str) -> bool: |
| 726 | + return token in _SHELL_REDIRECTION_OPERATORS |
| 727 | + |
| 728 | + |
| 729 | +def _is_shell_assignment(token: str) -> bool: |
| 730 | + name, separator, _ = token.partition("=") |
| 731 | + if not separator or not name: |
| 732 | + return False |
| 733 | + return bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name)) |
| 734 | + |
| 735 | + |
| 736 | +def _is_allowed_local_bash_absolute_path(path: str, allowed_paths: list[str], *, allow_system_paths: bool) -> bool: |
| 737 | + # Check for MCP filesystem server allowed paths |
| 738 | + if any(path.startswith(allowed_path) or path == allowed_path.rstrip("/") for allowed_path in allowed_paths): |
| 739 | + _reject_path_traversal(path) |
| 740 | + return True |
| 741 | + |
| 742 | + if path == VIRTUAL_PATH_PREFIX or path.startswith(f"{VIRTUAL_PATH_PREFIX}/"): |
| 743 | + _reject_path_traversal(path) |
| 744 | + return True |
| 745 | + |
| 746 | + # Allow skills container path (resolved by tools.py before passing to sandbox) |
| 747 | + if _is_skills_path(path): |
| 748 | + _reject_path_traversal(path) |
| 749 | + return True |
| 750 | + |
| 751 | + # Allow ACP workspace path (path-traversal check only) |
| 752 | + if _is_acp_workspace_path(path): |
| 753 | + _reject_path_traversal(path) |
| 754 | + return True |
| 755 | + |
| 756 | + # Allow custom mount container paths |
| 757 | + if _is_custom_mount_path(path): |
| 758 | + _reject_path_traversal(path) |
| 759 | + return True |
| 760 | + |
| 761 | + if allow_system_paths and any(path == prefix.rstrip("/") or path.startswith(prefix) for prefix in _LOCAL_BASH_SYSTEM_PATH_PREFIXES): |
| 762 | + return True |
| 763 | + |
| 764 | + return False |
| 765 | + |
| 766 | + |
| 767 | +def _next_cd_target(tokens: list[str], start_index: int) -> tuple[str | None, int]: |
| 768 | + index = start_index |
| 769 | + while index < len(tokens): |
| 770 | + token = tokens[index] |
| 771 | + if _is_shell_command_separator(token): |
| 772 | + return None, index |
| 773 | + if _is_shell_redirection_operator(token): |
| 774 | + index += 2 |
| 775 | + continue |
| 776 | + if token == "--": |
| 777 | + index += 1 |
| 778 | + continue |
| 779 | + if token in {"-L", "-P", "-e", "-@"}: |
| 780 | + index += 1 |
| 781 | + continue |
| 782 | + if token.startswith("-") and token != "-": |
| 783 | + index += 1 |
| 784 | + continue |
| 785 | + return token, index + 1 |
| 786 | + return None, index |
| 787 | + |
| 788 | + |
| 789 | +def _validate_local_bash_cwd_target(command_name: str, target: str | None, allowed_paths: list[str]) -> None: |
| 790 | + if target is None or target == "-": |
| 791 | + raise PermissionError(f"Unsafe working directory change in command: {command_name}. Use paths under {VIRTUAL_PATH_PREFIX}") |
| 792 | + if target.startswith(("$", "`")): |
| 793 | + raise PermissionError(f"Unsafe working directory change in command: {command_name} {target}. Use paths under {VIRTUAL_PATH_PREFIX}") |
| 794 | + if target.startswith("~"): |
| 795 | + raise PermissionError(f"Unsafe working directory change in command: {command_name} {target}. Use paths under {VIRTUAL_PATH_PREFIX}") |
| 796 | + if target.startswith("/"): |
| 797 | + _reject_path_traversal(target) |
| 798 | + if not _is_allowed_local_bash_absolute_path(target, allowed_paths, allow_system_paths=False): |
| 799 | + raise PermissionError(f"Unsafe working directory change in command: {command_name} {target}. Use paths under {VIRTUAL_PATH_PREFIX}") |
| 800 | + |
| 801 | + |
| 802 | +def _looks_like_unsafe_cwd_target(target: str | None) -> bool: |
| 803 | + if target is None: |
| 804 | + return False |
| 805 | + return target == "-" or target.startswith(("$", "`", "~", "/", "..")) or _has_dotdot_path_segment(target) |
| 806 | + |
| 807 | + |
| 808 | +def _validate_local_bash_root_path_args(command_name: str, tokens: list[str], start_index: int) -> None: |
| 809 | + if command_name not in _LOCAL_BASH_ROOT_PATH_COMMANDS: |
| 810 | + return |
| 811 | + |
| 812 | + index = start_index |
| 813 | + while index < len(tokens): |
| 814 | + token = tokens[index] |
| 815 | + if _is_shell_command_separator(token): |
| 816 | + return |
| 817 | + if _is_shell_redirection_operator(token): |
| 818 | + index += 2 |
| 819 | + continue |
| 820 | + if token == "/" and not _is_non_file_url_token(token): |
| 821 | + raise PermissionError(f"Unsafe absolute paths in command: /. Use paths under {VIRTUAL_PATH_PREFIX}") |
| 822 | + index += 1 |
| 823 | + |
| 824 | + |
| 825 | +def _validate_local_bash_shell_tokens(command: str, allowed_paths: list[str]) -> None: |
| 826 | + """Conservatively reject relative path escapes missed by absolute-path scanning.""" |
| 827 | + if re.search(r"\$\([^)]*\b(?:cd|pushd)\b", command): |
| 828 | + raise PermissionError(f"Unsafe working directory change in command substitution. Use paths under {VIRTUAL_PATH_PREFIX}") |
| 829 | + |
| 830 | + tokens = _split_shell_tokens(command) |
| 831 | + |
| 832 | + for token in tokens: |
| 833 | + if _is_shell_command_separator(token) or _is_shell_redirection_operator(token): |
| 834 | + continue |
| 835 | + if _has_dotdot_path_segment(token): |
| 836 | + raise PermissionError("Access denied: path traversal detected") |
| 837 | + |
| 838 | + at_command_start = True |
| 839 | + index = 0 |
| 840 | + while index < len(tokens): |
| 841 | + token = tokens[index] |
| 842 | + |
| 843 | + if _is_shell_command_separator(token): |
| 844 | + at_command_start = True |
| 845 | + index += 1 |
| 846 | + continue |
| 847 | + |
| 848 | + if _is_shell_redirection_operator(token): |
| 849 | + index += 1 |
| 850 | + continue |
| 851 | + |
| 852 | + if at_command_start and _is_shell_assignment(token): |
| 853 | + index += 1 |
| 854 | + continue |
| 855 | + |
| 856 | + command_name = token.rsplit("/", 1)[-1] |
| 857 | + if at_command_start and command_name in _LOCAL_BASH_COMMAND_PREFIX_KEYWORDS | _LOCAL_BASH_COMMAND_END_KEYWORDS: |
| 858 | + index += 1 |
| 859 | + continue |
| 860 | + |
| 861 | + if not at_command_start: |
| 862 | + index += 1 |
| 863 | + continue |
| 864 | + |
| 865 | + at_command_start = False |
| 866 | + if command_name in _LOCAL_BASH_COMMAND_WRAPPERS and index + 1 < len(tokens): |
| 867 | + wrapped_name = tokens[index + 1].rsplit("/", 1)[-1] |
| 868 | + if wrapped_name in _LOCAL_BASH_CWD_COMMANDS: |
| 869 | + target, next_index = _next_cd_target(tokens, index + 2) |
| 870 | + _validate_local_bash_cwd_target(wrapped_name, target, allowed_paths) |
| 871 | + index = next_index |
| 872 | + continue |
| 873 | + _validate_local_bash_root_path_args(wrapped_name, tokens, index + 2) |
| 874 | + |
| 875 | + if command_name not in _LOCAL_BASH_CWD_COMMANDS: |
| 876 | + _validate_local_bash_root_path_args(command_name, tokens, index + 1) |
| 877 | + index += 1 |
| 878 | + continue |
| 879 | + |
| 880 | + target, next_index = _next_cd_target(tokens, index + 1) |
| 881 | + _validate_local_bash_cwd_target(command_name, target, allowed_paths) |
| 882 | + index = next_index |
| 883 | + |
| 884 | + |
638 | 885 | def resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState) -> str: |
639 | 886 | """Resolve a /mnt/user-data virtual path and validate it stays in bounds.""" |
640 | 887 | return _resolve_and_validate_user_data_path(path, thread_data) |
@@ -665,33 +912,14 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState |
665 | 912 |
|
666 | 913 | unsafe_paths: list[str] = [] |
667 | 914 | allowed_paths = _get_mcp_allowed_paths() |
| 915 | + _validate_local_bash_shell_tokens(command, allowed_paths) |
| 916 | + url_spans = _non_file_url_spans(command) |
668 | 917 |
|
669 | | - for absolute_path in _ABSOLUTE_PATH_PATTERN.findall(command): |
670 | | - # Check for MCP filesystem server allowed paths |
671 | | - if any(absolute_path.startswith(path) or absolute_path == path.rstrip("/") for path in allowed_paths): |
672 | | - _reject_path_traversal(absolute_path) |
673 | | - continue |
674 | | - |
675 | | - if absolute_path == VIRTUAL_PATH_PREFIX or absolute_path.startswith(f"{VIRTUAL_PATH_PREFIX}/"): |
676 | | - _reject_path_traversal(absolute_path) |
677 | | - continue |
678 | | - |
679 | | - # Allow skills container path (resolved by tools.py before passing to sandbox) |
680 | | - if _is_skills_path(absolute_path): |
681 | | - _reject_path_traversal(absolute_path) |
| 918 | + for match in _ABSOLUTE_PATH_PATTERN.finditer(command): |
| 919 | + if _is_in_spans(match.start(), url_spans): |
682 | 920 | continue |
683 | | - |
684 | | - # Allow ACP workspace path (path-traversal check only) |
685 | | - if _is_acp_workspace_path(absolute_path): |
686 | | - _reject_path_traversal(absolute_path) |
687 | | - continue |
688 | | - |
689 | | - # Allow custom mount container paths |
690 | | - if _is_custom_mount_path(absolute_path): |
691 | | - _reject_path_traversal(absolute_path) |
692 | | - continue |
693 | | - |
694 | | - if any(absolute_path == prefix.rstrip("/") or absolute_path.startswith(prefix) for prefix in _LOCAL_BASH_SYSTEM_PATH_PREFIXES): |
| 921 | + absolute_path = match.group() |
| 922 | + if _is_allowed_local_bash_absolute_path(absolute_path, allowed_paths, allow_system_paths=True): |
695 | 923 | continue |
696 | 924 |
|
697 | 925 | unsafe_paths.append(absolute_path) |
|
0 commit comments