ripperdoc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ripperdoc/__init__.py +3 -0
- ripperdoc/__main__.py +25 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +317 -0
- ripperdoc/cli/commands/__init__.py +76 -0
- ripperdoc/cli/commands/agents_cmd.py +234 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +19 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +114 -0
- ripperdoc/cli/commands/cost_cmd.py +77 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +65 -0
- ripperdoc/cli/commands/models_cmd.py +327 -0
- ripperdoc/cli/commands/resume_cmd.py +97 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +240 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +297 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1010 -0
- ripperdoc/cli/ui/spinner.py +50 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +306 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +382 -0
- ripperdoc/core/default_tools.py +57 -0
- ripperdoc/core/permissions.py +227 -0
- ripperdoc/core/query.py +682 -0
- ripperdoc/core/system_prompt.py +418 -0
- ripperdoc/core/tool.py +214 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +309 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/background_shell.py +291 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +822 -0
- ripperdoc/tools/file_edit_tool.py +281 -0
- ripperdoc/tools/file_read_tool.py +168 -0
- ripperdoc/tools/file_write_tool.py +141 -0
- ripperdoc/tools/glob_tool.py +134 -0
- ripperdoc/tools/grep_tool.py +232 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +298 -0
- ripperdoc/tools/mcp_tools.py +804 -0
- ripperdoc/tools/multi_edit_tool.py +393 -0
- ripperdoc/tools/notebook_edit_tool.py +325 -0
- ripperdoc/tools/task_tool.py +282 -0
- ripperdoc/tools/todo_tool.py +362 -0
- ripperdoc/tools/tool_search_tool.py +366 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/log.py +76 -0
- ripperdoc/utils/mcp.py +427 -0
- ripperdoc/utils/memory.py +239 -0
- ripperdoc/utils/message_compaction.py +640 -0
- ripperdoc/utils/messages.py +399 -0
- ripperdoc/utils/output_utils.py +233 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +21 -0
- ripperdoc/utils/permissions/path_validation_utils.py +165 -0
- ripperdoc/utils/permissions/shell_command_validation.py +74 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/safe_get_cwd.py +24 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +223 -0
- ripperdoc/utils/session_usage.py +110 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/todo.py +199 -0
- ripperdoc-0.1.0.dist-info/METADATA +178 -0
- ripperdoc-0.1.0.dist-info/RECORD +81 -0
- ripperdoc-0.1.0.dist-info/WHEEL +5 -0
- ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
- ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Path validation utilities for shell commands (cd/ls/find)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterable, List, Set
|
|
10
|
+
|
|
11
|
+
from ripperdoc.utils.safe_get_cwd import safe_get_cwd
|
|
12
|
+
from ripperdoc.utils.shell_token_utils import parse_and_clean_shell_tokens
|
|
13
|
+
|
|
14
|
+
_GLOB_PATTERN = re.compile(r"[*?\[\]{}]")
|
|
15
|
+
_MAX_VISIBLE_ITEMS = 5
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ValidationResponse:
|
|
20
|
+
behavior: str # 'passthrough' | 'ask' | 'deny'
|
|
21
|
+
message: str
|
|
22
|
+
rule_suggestions: None | list[str] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _format_allowed_dirs_preview(allowed_dirs: Iterable[str]) -> str:
|
|
26
|
+
dirs = list(allowed_dirs)
|
|
27
|
+
if len(dirs) <= _MAX_VISIBLE_ITEMS:
|
|
28
|
+
return ", ".join(f"'{item}'" for item in dirs)
|
|
29
|
+
return (
|
|
30
|
+
", ".join(f"'{item}'" for item in dirs[:_MAX_VISIBLE_ITEMS])
|
|
31
|
+
+ f", and {len(dirs) - _MAX_VISIBLE_ITEMS} more"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _expand_tilde(path_str: str) -> str:
|
|
36
|
+
if path_str == "~" or path_str.startswith("~/"):
|
|
37
|
+
return os.path.expanduser(path_str)
|
|
38
|
+
return path_str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_path(raw_path: str, cwd: str) -> Path:
|
|
42
|
+
expanded = _expand_tilde(raw_path.strip("'\""))
|
|
43
|
+
candidate = Path(expanded)
|
|
44
|
+
if not candidate.is_absolute():
|
|
45
|
+
candidate = Path(cwd) / candidate
|
|
46
|
+
try:
|
|
47
|
+
return candidate.resolve()
|
|
48
|
+
except Exception:
|
|
49
|
+
return candidate
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _extract_directory_from_glob(glob_pattern: str) -> str:
|
|
53
|
+
match = _GLOB_PATTERN.search(glob_pattern)
|
|
54
|
+
if not match or match.start() == 0:
|
|
55
|
+
return glob_pattern
|
|
56
|
+
prefix = glob_pattern[: match.start()]
|
|
57
|
+
if "/" not in prefix:
|
|
58
|
+
return "."
|
|
59
|
+
return prefix.rsplit("/", 1)[0] or "/"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_path_allowed(resolved_path: Path, allowed_dirs: Set[str]) -> bool:
|
|
63
|
+
resolved_str = str(resolved_path)
|
|
64
|
+
for allowed in allowed_dirs:
|
|
65
|
+
normalized_allowed = os.path.abspath(allowed)
|
|
66
|
+
normalized = os.path.abspath(resolved_str)
|
|
67
|
+
if normalized == normalized_allowed:
|
|
68
|
+
return True
|
|
69
|
+
if normalized.startswith(normalized_allowed.rstrip(os.sep) + os.sep):
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _validate_path(raw_path: str, cwd: str, allowed_dirs: Set[str]) -> tuple[bool, str]:
|
|
75
|
+
expanded = _expand_tilde(raw_path.strip() or ".")
|
|
76
|
+
if _GLOB_PATTERN.search(expanded):
|
|
77
|
+
directory = _extract_directory_from_glob(expanded)
|
|
78
|
+
resolved_dir = _resolve_path(directory, cwd)
|
|
79
|
+
return _is_path_allowed(resolved_dir, allowed_dirs), str(resolved_dir)
|
|
80
|
+
|
|
81
|
+
resolved = _resolve_path(expanded, cwd)
|
|
82
|
+
return _is_path_allowed(resolved, allowed_dirs), str(resolved)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _extract_paths_for_command(
|
|
86
|
+
command: str, args: List[str], cwd: str, allowed_dirs: Set[str]
|
|
87
|
+
) -> ValidationResponse:
|
|
88
|
+
if command == "cd":
|
|
89
|
+
target = args[0] if args else os.path.expanduser("~")
|
|
90
|
+
allowed, resolved = _validate_path(target, cwd, allowed_dirs)
|
|
91
|
+
elif command == "ls":
|
|
92
|
+
candidates = [arg for arg in args if not arg.startswith("-")] or ["."]
|
|
93
|
+
for candidate in candidates:
|
|
94
|
+
allowed, resolved = _validate_path(candidate, cwd, allowed_dirs)
|
|
95
|
+
if not allowed:
|
|
96
|
+
break
|
|
97
|
+
elif command == "find":
|
|
98
|
+
paths: list[str] = []
|
|
99
|
+
for arg in args:
|
|
100
|
+
if arg.startswith("-"):
|
|
101
|
+
continue
|
|
102
|
+
paths.append(arg)
|
|
103
|
+
if not paths:
|
|
104
|
+
paths = ["."]
|
|
105
|
+
allowed = True
|
|
106
|
+
resolved = ""
|
|
107
|
+
for candidate in paths:
|
|
108
|
+
allowed, resolved = _validate_path(candidate, cwd, allowed_dirs)
|
|
109
|
+
if not allowed:
|
|
110
|
+
break
|
|
111
|
+
else:
|
|
112
|
+
return ValidationResponse(
|
|
113
|
+
behavior="passthrough",
|
|
114
|
+
message=f"Command '{command}' is not path-restricted",
|
|
115
|
+
rule_suggestions=None,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if allowed:
|
|
119
|
+
return ValidationResponse(
|
|
120
|
+
behavior="passthrough",
|
|
121
|
+
message="Path validation passed",
|
|
122
|
+
rule_suggestions=None,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
preview = _format_allowed_dirs_preview(sorted(allowed_dirs))
|
|
126
|
+
action = {
|
|
127
|
+
"cd": "change directories to",
|
|
128
|
+
"ls": "list files in",
|
|
129
|
+
"find": "search files in",
|
|
130
|
+
}.get(command, "access")
|
|
131
|
+
return ValidationResponse(
|
|
132
|
+
behavior="ask",
|
|
133
|
+
message=f"{command} in '{resolved}' was blocked. For security, this session may only {action} the allowed working directories: {preview}.",
|
|
134
|
+
rule_suggestions=None,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def validate_shell_command_paths(
|
|
139
|
+
shell_command: str | object, cwd: str | None = None, allowed_dirs: Set[str] | None = None
|
|
140
|
+
) -> ValidationResponse:
|
|
141
|
+
"""Validate path-oriented shell commands against allowed working directories."""
|
|
142
|
+
command_str = shell_command.command if hasattr(shell_command, "command") else str(shell_command)
|
|
143
|
+
cwd = cwd or safe_get_cwd()
|
|
144
|
+
allowed_dirs = allowed_dirs or {cwd}
|
|
145
|
+
|
|
146
|
+
tokens = parse_and_clean_shell_tokens(command_str)
|
|
147
|
+
if not tokens:
|
|
148
|
+
return ValidationResponse(
|
|
149
|
+
behavior="passthrough",
|
|
150
|
+
message="Empty command - no paths to validate",
|
|
151
|
+
rule_suggestions=None,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
first, *rest = tokens
|
|
155
|
+
if first not in {"cd", "ls", "find"}:
|
|
156
|
+
return ValidationResponse(
|
|
157
|
+
behavior="passthrough",
|
|
158
|
+
message=f"Command '{first}' is not a path-restricted command",
|
|
159
|
+
rule_suggestions=None,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return _extract_paths_for_command(first, rest, cwd, allowed_dirs)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = ["ValidationResponse", "validate_shell_command_paths"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Lightweight shell command validation heuristics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ValidationResult:
|
|
12
|
+
behavior: str # 'passthrough' | 'ask' | 'allow' | 'deny'
|
|
13
|
+
message: str
|
|
14
|
+
rule_suggestions: Optional[List[str]] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_DANGEROUS_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
18
|
+
(re.compile(r"[`‵]"), "Command contains backticks for command substitution"),
|
|
19
|
+
(re.compile(r"\$\("), "Command contains $() command substitution"),
|
|
20
|
+
(re.compile(r"\$\{"), "Command contains ${} parameter substitution"),
|
|
21
|
+
(re.compile(r"<\("), "Command contains process substitution <()"),
|
|
22
|
+
(re.compile(r">\("), "Command contains process substitution >()"),
|
|
23
|
+
(re.compile(r"<<<?"), "Command contains heredoc redirection"),
|
|
24
|
+
(re.compile(r"(^|\s)source\s+"), "Command sources another script"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_shell_command(shell_command: str) -> ValidationResult:
|
|
29
|
+
"""Validate a shell command for risky constructs."""
|
|
30
|
+
if not shell_command or not shell_command.strip():
|
|
31
|
+
return ValidationResult(
|
|
32
|
+
behavior="passthrough",
|
|
33
|
+
message="Empty command is safe",
|
|
34
|
+
rule_suggestions=None,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
trimmed = shell_command.strip()
|
|
38
|
+
|
|
39
|
+
if re.search(r"\bjq\b.*\bsystem\s*\(", trimmed):
|
|
40
|
+
return ValidationResult(
|
|
41
|
+
behavior="ask",
|
|
42
|
+
message="jq command contains system() which executes arbitrary commands",
|
|
43
|
+
rule_suggestions=None,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if re.search(r"\b(cat|tee)\s+<<\s*['\"]?EOF", trimmed):
|
|
47
|
+
return ValidationResult(
|
|
48
|
+
behavior="ask",
|
|
49
|
+
message="Command contains heredoc which may run arbitrary content",
|
|
50
|
+
rule_suggestions=None,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if re.search(r"[<>]\s*/dev/null", trimmed):
|
|
54
|
+
# Explicit /dev/null redirection is benign for our purposes.
|
|
55
|
+
sanitized = re.sub(r"\s*[<>]\s*/dev/null", "", trimmed)
|
|
56
|
+
else:
|
|
57
|
+
sanitized = trimmed
|
|
58
|
+
|
|
59
|
+
for pattern, message in _DANGEROUS_PATTERNS:
|
|
60
|
+
if pattern.search(sanitized):
|
|
61
|
+
return ValidationResult(
|
|
62
|
+
behavior="ask",
|
|
63
|
+
message=message,
|
|
64
|
+
rule_suggestions=None,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return ValidationResult(
|
|
68
|
+
behavior="passthrough",
|
|
69
|
+
message="Command passed validation",
|
|
70
|
+
rule_suggestions=None,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["validate_shell_command", "ValidationResult"]
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Permission evaluation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable, Iterable, List, Optional, Set
|
|
7
|
+
|
|
8
|
+
from ripperdoc.utils.permissions.path_validation_utils import validate_shell_command_paths
|
|
9
|
+
from ripperdoc.utils.permissions.shell_command_validation import validate_shell_command
|
|
10
|
+
from ripperdoc.utils.safe_get_cwd import safe_get_cwd
|
|
11
|
+
from ripperdoc.utils.shell_token_utils import parse_and_clean_shell_tokens, parse_shell_tokens
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ToolRule:
|
|
16
|
+
tool_name: str
|
|
17
|
+
rule_content: str
|
|
18
|
+
behavior: str = "allow"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class PermissionDecision:
|
|
23
|
+
behavior: str # 'allow' | 'deny' | 'ask' | 'passthrough'
|
|
24
|
+
message: Optional[str] = None
|
|
25
|
+
updated_input: Optional[object] = None
|
|
26
|
+
decision_reason: Optional[dict] = None
|
|
27
|
+
rule_suggestions: Optional[List[ToolRule] | List[str]] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_wildcard_rule(rule_name: str) -> str:
|
|
31
|
+
"""Create a wildcard/prefix rule string."""
|
|
32
|
+
return f"{rule_name}:*"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_tool_rule(rule_content: str) -> List[ToolRule]:
|
|
36
|
+
return [ToolRule(tool_name="Bash", rule_content=rule_content)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_wildcard_tool_rule(rule_name: str) -> List[ToolRule]:
|
|
40
|
+
return [ToolRule(tool_name="Bash", rule_content=create_wildcard_rule(rule_name))]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_rule_prefix(rule_string: str) -> Optional[str]:
|
|
44
|
+
return rule_string[:-2] if rule_string.endswith(":*") else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def match_rule(command: str, rule: str) -> bool:
|
|
48
|
+
"""Return True if a command matches a rule (exact or wildcard)."""
|
|
49
|
+
command = command.strip()
|
|
50
|
+
if not command:
|
|
51
|
+
return False
|
|
52
|
+
prefix = extract_rule_prefix(rule)
|
|
53
|
+
if prefix is not None:
|
|
54
|
+
return command.startswith(prefix)
|
|
55
|
+
return command == rule
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _merge_rules(*rules: Iterable[str]) -> Set[str]:
|
|
59
|
+
merged: Set[str] = set()
|
|
60
|
+
for collection in rules:
|
|
61
|
+
merged.update(collection)
|
|
62
|
+
return merged
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_command_read_only(
|
|
66
|
+
command: str,
|
|
67
|
+
injection_check: Callable[[str], bool],
|
|
68
|
+
) -> bool:
|
|
69
|
+
"""Heuristic read-only detector mirroring the reference intent."""
|
|
70
|
+
validation = validate_shell_command(command)
|
|
71
|
+
if validation.behavior != "passthrough":
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
cleaned_tokens = parse_and_clean_shell_tokens(command)
|
|
75
|
+
if not cleaned_tokens:
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
# Treat pipelines/compound commands as read-only only if every segment is safe.
|
|
79
|
+
tokens = parse_shell_tokens(command)
|
|
80
|
+
if "|" in tokens:
|
|
81
|
+
parts: list[str] = []
|
|
82
|
+
current: list[str] = []
|
|
83
|
+
for token in tokens:
|
|
84
|
+
if token == "|":
|
|
85
|
+
if current:
|
|
86
|
+
parts.append(" ".join(current))
|
|
87
|
+
current = []
|
|
88
|
+
else:
|
|
89
|
+
current.append(token)
|
|
90
|
+
if current:
|
|
91
|
+
parts.append(" ".join(current))
|
|
92
|
+
return all(_is_command_read_only(part, injection_check) for part in parts)
|
|
93
|
+
|
|
94
|
+
dangerous_prefixes = {
|
|
95
|
+
"rm",
|
|
96
|
+
"mv",
|
|
97
|
+
"chmod",
|
|
98
|
+
"chown",
|
|
99
|
+
"sudo",
|
|
100
|
+
"dd",
|
|
101
|
+
"tee",
|
|
102
|
+
"truncate",
|
|
103
|
+
"kill",
|
|
104
|
+
"pkill",
|
|
105
|
+
"systemctl",
|
|
106
|
+
"service",
|
|
107
|
+
}
|
|
108
|
+
first = cleaned_tokens[0]
|
|
109
|
+
if first in dangerous_prefixes:
|
|
110
|
+
return False
|
|
111
|
+
if first == "git":
|
|
112
|
+
if len(cleaned_tokens) < 2:
|
|
113
|
+
return False
|
|
114
|
+
allowed_git = {
|
|
115
|
+
"status",
|
|
116
|
+
"diff",
|
|
117
|
+
"show",
|
|
118
|
+
"log",
|
|
119
|
+
"rev-parse",
|
|
120
|
+
"ls-files",
|
|
121
|
+
"remote",
|
|
122
|
+
"branch",
|
|
123
|
+
"tag",
|
|
124
|
+
"blame",
|
|
125
|
+
"reflog",
|
|
126
|
+
}
|
|
127
|
+
return cleaned_tokens[1] in allowed_git
|
|
128
|
+
|
|
129
|
+
# If no injection was detected and the command is free of mutations, treat as read-only.
|
|
130
|
+
return not injection_check(command)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _collect_rule_suggestions(command: str) -> List[ToolRule]:
|
|
134
|
+
suggestions: list[ToolRule] = [ToolRule(tool_name="Bash", rule_content=command)]
|
|
135
|
+
tokens = parse_and_clean_shell_tokens(command)
|
|
136
|
+
if tokens:
|
|
137
|
+
suggestions.append(ToolRule(tool_name="Bash", rule_content=create_wildcard_rule(tokens[0])))
|
|
138
|
+
return suggestions
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def evaluate_shell_command_permissions(
|
|
142
|
+
tool_request: object,
|
|
143
|
+
allowed_rules: Iterable[str],
|
|
144
|
+
denied_rules: Iterable[str],
|
|
145
|
+
allowed_working_dirs: Set[str] | None = None,
|
|
146
|
+
*,
|
|
147
|
+
command_injection_detected: bool = False,
|
|
148
|
+
injection_detector: Callable[[str], bool] | None = None,
|
|
149
|
+
read_only_detector: Callable[[str, Callable[[str], bool]], bool] | None = None,
|
|
150
|
+
) -> PermissionDecision:
|
|
151
|
+
"""Evaluate whether a bash command should be allowed."""
|
|
152
|
+
command = tool_request.command if hasattr(tool_request, "command") else str(tool_request)
|
|
153
|
+
trimmed_command = command.strip()
|
|
154
|
+
allowed_working_dirs = allowed_working_dirs or {safe_get_cwd()}
|
|
155
|
+
injection_detector = injection_detector or (
|
|
156
|
+
lambda cmd: validate_shell_command(cmd).behavior != "passthrough"
|
|
157
|
+
)
|
|
158
|
+
read_only_detector = read_only_detector or _is_command_read_only
|
|
159
|
+
|
|
160
|
+
merged_denied = _merge_rules(denied_rules)
|
|
161
|
+
merged_allowed = _merge_rules(allowed_rules)
|
|
162
|
+
|
|
163
|
+
if any(match_rule(trimmed_command, rule) for rule in merged_denied):
|
|
164
|
+
return PermissionDecision(
|
|
165
|
+
behavior="deny",
|
|
166
|
+
message=f"Permission to run '{trimmed_command}' has been denied.",
|
|
167
|
+
decision_reason={"type": "rule"},
|
|
168
|
+
rule_suggestions=None,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if any(match_rule(trimmed_command, rule) for rule in merged_allowed):
|
|
172
|
+
return PermissionDecision(
|
|
173
|
+
behavior="allow",
|
|
174
|
+
updated_input=tool_request,
|
|
175
|
+
decision_reason={"type": "rule"},
|
|
176
|
+
message="Command approved by configured rule.",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
path_result = validate_shell_command_paths(
|
|
180
|
+
trimmed_command, safe_get_cwd(), allowed_working_dirs
|
|
181
|
+
)
|
|
182
|
+
if path_result.behavior != "passthrough":
|
|
183
|
+
return PermissionDecision(
|
|
184
|
+
behavior="ask",
|
|
185
|
+
message=path_result.message,
|
|
186
|
+
decision_reason={"type": "path_validation"},
|
|
187
|
+
rule_suggestions=None,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
validation_result = validate_shell_command(trimmed_command)
|
|
191
|
+
if validation_result.behavior != "passthrough":
|
|
192
|
+
return PermissionDecision(
|
|
193
|
+
behavior="ask",
|
|
194
|
+
message=validation_result.message,
|
|
195
|
+
decision_reason={"type": "validation"},
|
|
196
|
+
rule_suggestions=None,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
tokens = parse_shell_tokens(trimmed_command)
|
|
200
|
+
if "|" in tokens:
|
|
201
|
+
left_tokens = []
|
|
202
|
+
right_tokens = []
|
|
203
|
+
pipe_seen = False
|
|
204
|
+
for token in tokens:
|
|
205
|
+
if token == "|":
|
|
206
|
+
pipe_seen = True
|
|
207
|
+
continue
|
|
208
|
+
if pipe_seen:
|
|
209
|
+
right_tokens.append(token)
|
|
210
|
+
else:
|
|
211
|
+
left_tokens.append(token)
|
|
212
|
+
left_command = " ".join(left_tokens).strip()
|
|
213
|
+
right_command = " ".join(right_tokens).strip()
|
|
214
|
+
|
|
215
|
+
left_result = evaluate_shell_command_permissions(
|
|
216
|
+
type("Cmd", (), {"command": left_command}),
|
|
217
|
+
merged_allowed,
|
|
218
|
+
merged_denied,
|
|
219
|
+
allowed_working_dirs,
|
|
220
|
+
command_injection_detected=command_injection_detected,
|
|
221
|
+
injection_detector=injection_detector,
|
|
222
|
+
read_only_detector=read_only_detector,
|
|
223
|
+
)
|
|
224
|
+
right_read_only = read_only_detector(right_command, injection_detector)
|
|
225
|
+
|
|
226
|
+
if left_result.behavior == "deny":
|
|
227
|
+
return left_result
|
|
228
|
+
if not right_read_only:
|
|
229
|
+
return PermissionDecision(
|
|
230
|
+
behavior="ask",
|
|
231
|
+
message="Pipe right-hand command is not read-only.",
|
|
232
|
+
decision_reason={"type": "subcommand"},
|
|
233
|
+
rule_suggestions=_collect_rule_suggestions(right_command),
|
|
234
|
+
)
|
|
235
|
+
if left_result.behavior == "allow":
|
|
236
|
+
return PermissionDecision(
|
|
237
|
+
behavior="allow",
|
|
238
|
+
updated_input=tool_request,
|
|
239
|
+
decision_reason={"type": "subcommand"},
|
|
240
|
+
)
|
|
241
|
+
return PermissionDecision(
|
|
242
|
+
behavior="ask",
|
|
243
|
+
message="Permission required for piped command.",
|
|
244
|
+
decision_reason={"type": "subcommand"},
|
|
245
|
+
rule_suggestions=_collect_rule_suggestions(trimmed_command),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if read_only_detector(trimmed_command, injection_detector) and not command_injection_detected:
|
|
249
|
+
return PermissionDecision(
|
|
250
|
+
behavior="allow",
|
|
251
|
+
updated_input=tool_request,
|
|
252
|
+
decision_reason={"type": "other", "reason": "Read-only command"},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return PermissionDecision(
|
|
256
|
+
behavior="passthrough",
|
|
257
|
+
message="Command requires permission",
|
|
258
|
+
decision_reason={"type": "default"},
|
|
259
|
+
rule_suggestions=_collect_rule_suggestions(trimmed_command),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def is_command_read_only(command: str) -> bool:
|
|
264
|
+
"""Public wrapper to test if a command is read-only using reference heuristics."""
|
|
265
|
+
return _is_command_read_only(
|
|
266
|
+
command, lambda cmd: validate_shell_command(cmd).behavior != "passthrough"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
__all__ = [
|
|
271
|
+
"PermissionDecision",
|
|
272
|
+
"ToolRule",
|
|
273
|
+
"create_tool_rule",
|
|
274
|
+
"create_wildcard_tool_rule",
|
|
275
|
+
"evaluate_shell_command_permissions",
|
|
276
|
+
"extract_rule_prefix",
|
|
277
|
+
"match_rule",
|
|
278
|
+
"is_command_read_only",
|
|
279
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Safe helpers for tracking and restoring the working directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_ORIGINAL_CWD = Path(os.getcwd()).resolve()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_original_cwd() -> str:
|
|
12
|
+
"""Return the process's initial working directory."""
|
|
13
|
+
return str(_ORIGINAL_CWD)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def safe_get_cwd() -> str:
|
|
17
|
+
"""Return the current working directory, falling back to the original on error."""
|
|
18
|
+
try:
|
|
19
|
+
return str(Path(os.getcwd()).resolve())
|
|
20
|
+
except Exception:
|
|
21
|
+
return get_original_cwd()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ["get_original_cwd", "safe_get_cwd"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Sandbox helpers.
|
|
2
|
+
|
|
3
|
+
The reference uses macOS sandbox-exec profiles; in this environment we
|
|
4
|
+
surface the same API surface but report unavailability by default.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
import shutil
|
|
11
|
+
import shlex
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SandboxWrapper:
|
|
17
|
+
"""Represents a wrapped command plus a cleanup callback."""
|
|
18
|
+
|
|
19
|
+
final_command: str
|
|
20
|
+
cleanup: Callable[[], None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_sandbox_available() -> bool:
|
|
24
|
+
"""Return whether sandboxed execution is available on this host."""
|
|
25
|
+
return shutil.which("srt") is not None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_sandbox_wrapper(command: str) -> SandboxWrapper:
|
|
29
|
+
"""Wrap a command for sandboxed execution or raise if unsupported."""
|
|
30
|
+
if not is_sandbox_available():
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
"Sandbox mode requested but not available (install @anthropic-ai/sandbox-runtime and ensure 'srt' is on PATH)"
|
|
33
|
+
)
|
|
34
|
+
quoted = shlex.quote(command)
|
|
35
|
+
return SandboxWrapper(final_command=f"srt {quoted}", cleanup=lambda: None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["SandboxWrapper", "is_sandbox_available", "create_sandbox_wrapper"]
|