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.
Files changed (81) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +25 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +317 -0
  5. ripperdoc/cli/commands/__init__.py +76 -0
  6. ripperdoc/cli/commands/agents_cmd.py +234 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +19 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +114 -0
  12. ripperdoc/cli/commands/cost_cmd.py +77 -0
  13. ripperdoc/cli/commands/exit_cmd.py +19 -0
  14. ripperdoc/cli/commands/help_cmd.py +20 -0
  15. ripperdoc/cli/commands/mcp_cmd.py +65 -0
  16. ripperdoc/cli/commands/models_cmd.py +327 -0
  17. ripperdoc/cli/commands/resume_cmd.py +97 -0
  18. ripperdoc/cli/commands/status_cmd.py +167 -0
  19. ripperdoc/cli/commands/tasks_cmd.py +240 -0
  20. ripperdoc/cli/commands/todos_cmd.py +69 -0
  21. ripperdoc/cli/commands/tools_cmd.py +19 -0
  22. ripperdoc/cli/ui/__init__.py +1 -0
  23. ripperdoc/cli/ui/context_display.py +297 -0
  24. ripperdoc/cli/ui/helpers.py +22 -0
  25. ripperdoc/cli/ui/rich_ui.py +1010 -0
  26. ripperdoc/cli/ui/spinner.py +50 -0
  27. ripperdoc/core/__init__.py +1 -0
  28. ripperdoc/core/agents.py +306 -0
  29. ripperdoc/core/commands.py +33 -0
  30. ripperdoc/core/config.py +382 -0
  31. ripperdoc/core/default_tools.py +57 -0
  32. ripperdoc/core/permissions.py +227 -0
  33. ripperdoc/core/query.py +682 -0
  34. ripperdoc/core/system_prompt.py +418 -0
  35. ripperdoc/core/tool.py +214 -0
  36. ripperdoc/sdk/__init__.py +9 -0
  37. ripperdoc/sdk/client.py +309 -0
  38. ripperdoc/tools/__init__.py +1 -0
  39. ripperdoc/tools/background_shell.py +291 -0
  40. ripperdoc/tools/bash_output_tool.py +98 -0
  41. ripperdoc/tools/bash_tool.py +822 -0
  42. ripperdoc/tools/file_edit_tool.py +281 -0
  43. ripperdoc/tools/file_read_tool.py +168 -0
  44. ripperdoc/tools/file_write_tool.py +141 -0
  45. ripperdoc/tools/glob_tool.py +134 -0
  46. ripperdoc/tools/grep_tool.py +232 -0
  47. ripperdoc/tools/kill_bash_tool.py +136 -0
  48. ripperdoc/tools/ls_tool.py +298 -0
  49. ripperdoc/tools/mcp_tools.py +804 -0
  50. ripperdoc/tools/multi_edit_tool.py +393 -0
  51. ripperdoc/tools/notebook_edit_tool.py +325 -0
  52. ripperdoc/tools/task_tool.py +282 -0
  53. ripperdoc/tools/todo_tool.py +362 -0
  54. ripperdoc/tools/tool_search_tool.py +366 -0
  55. ripperdoc/utils/__init__.py +1 -0
  56. ripperdoc/utils/bash_constants.py +51 -0
  57. ripperdoc/utils/bash_output_utils.py +43 -0
  58. ripperdoc/utils/exit_code_handlers.py +241 -0
  59. ripperdoc/utils/log.py +76 -0
  60. ripperdoc/utils/mcp.py +427 -0
  61. ripperdoc/utils/memory.py +239 -0
  62. ripperdoc/utils/message_compaction.py +640 -0
  63. ripperdoc/utils/messages.py +399 -0
  64. ripperdoc/utils/output_utils.py +233 -0
  65. ripperdoc/utils/path_utils.py +46 -0
  66. ripperdoc/utils/permissions/__init__.py +21 -0
  67. ripperdoc/utils/permissions/path_validation_utils.py +165 -0
  68. ripperdoc/utils/permissions/shell_command_validation.py +74 -0
  69. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  70. ripperdoc/utils/safe_get_cwd.py +24 -0
  71. ripperdoc/utils/sandbox_utils.py +38 -0
  72. ripperdoc/utils/session_history.py +223 -0
  73. ripperdoc/utils/session_usage.py +110 -0
  74. ripperdoc/utils/shell_token_utils.py +95 -0
  75. ripperdoc/utils/todo.py +199 -0
  76. ripperdoc-0.1.0.dist-info/METADATA +178 -0
  77. ripperdoc-0.1.0.dist-info/RECORD +81 -0
  78. ripperdoc-0.1.0.dist-info/WHEEL +5 -0
  79. ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
  80. ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
  81. 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"]