ripperdoc 0.2.6__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 +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -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 +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
"""Bash command execution tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from textwrap import dedent
|
|
11
|
+
from typing import Any, AsyncGenerator, List, Optional
|
|
12
|
+
|
|
13
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
from ripperdoc.core.tool import (
|
|
16
|
+
Tool,
|
|
17
|
+
ToolOutput,
|
|
18
|
+
ToolProgress,
|
|
19
|
+
ToolResult,
|
|
20
|
+
ToolUseContext,
|
|
21
|
+
ToolUseExample,
|
|
22
|
+
ValidationResult,
|
|
23
|
+
)
|
|
24
|
+
from ripperdoc.utils.bash_constants import (
|
|
25
|
+
get_bash_default_timeout_ms,
|
|
26
|
+
get_bash_max_output_length,
|
|
27
|
+
get_bash_max_timeout_ms,
|
|
28
|
+
)
|
|
29
|
+
from ripperdoc.utils.exit_code_handlers import (
|
|
30
|
+
MAX_PREVIEW_CHARS,
|
|
31
|
+
MAX_PREVIEW_LINES,
|
|
32
|
+
IGNORED_COMMANDS,
|
|
33
|
+
interpret_exit_code,
|
|
34
|
+
)
|
|
35
|
+
from ripperdoc.utils.output_utils import (
|
|
36
|
+
format_duration,
|
|
37
|
+
get_last_n_lines,
|
|
38
|
+
is_output_large,
|
|
39
|
+
sanitize_output,
|
|
40
|
+
trim_blank_lines,
|
|
41
|
+
truncate_output,
|
|
42
|
+
)
|
|
43
|
+
from ripperdoc.utils.permissions.path_validation_utils import validate_shell_command_paths
|
|
44
|
+
from ripperdoc.utils.permissions.shell_command_validation import validate_shell_command
|
|
45
|
+
from ripperdoc.utils.permissions.tool_permission_utils import (
|
|
46
|
+
evaluate_shell_command_permissions,
|
|
47
|
+
is_command_read_only,
|
|
48
|
+
)
|
|
49
|
+
from ripperdoc.utils.permissions import PermissionDecision
|
|
50
|
+
from ripperdoc.utils.sandbox_utils import create_sandbox_wrapper, is_sandbox_available
|
|
51
|
+
from ripperdoc.utils.safe_get_cwd import get_original_cwd, safe_get_cwd
|
|
52
|
+
from ripperdoc.utils.shell_utils import build_shell_command, find_suitable_shell
|
|
53
|
+
from ripperdoc.utils.log import get_logger
|
|
54
|
+
|
|
55
|
+
logger = get_logger()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
DEFAULT_TIMEOUT_MS = get_bash_default_timeout_ms()
|
|
59
|
+
MAX_BASH_TIMEOUT_MS = get_bash_max_timeout_ms()
|
|
60
|
+
MAX_OUTPUT_CHARS = get_bash_max_output_length()
|
|
61
|
+
KILL_GRACE_SECONDS = 5.0
|
|
62
|
+
PROGRESS_INTERVAL_SECONDS = 0.5
|
|
63
|
+
ORIGINAL_CWD = Path(get_original_cwd())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BashToolInput(BaseModel):
|
|
67
|
+
"""Input schema for BashTool."""
|
|
68
|
+
|
|
69
|
+
command: str = Field(description="The bash command to execute")
|
|
70
|
+
timeout: Optional[int] = Field(
|
|
71
|
+
default=None,
|
|
72
|
+
description=(
|
|
73
|
+
f"Timeout in milliseconds (default: {DEFAULT_TIMEOUT_MS}ms ≈ {DEFAULT_TIMEOUT_MS / 1000:.0f}s; "
|
|
74
|
+
f"max: {MAX_BASH_TIMEOUT_MS}ms)"
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
shell_executable: Optional[str] = Field(
|
|
78
|
+
default=None,
|
|
79
|
+
validation_alias=AliasChoices("shell_executable", "shellExecutable"),
|
|
80
|
+
serialization_alias="shellExecutable",
|
|
81
|
+
description="Optional shell path to use instead of the default shell.",
|
|
82
|
+
)
|
|
83
|
+
run_in_background: Optional[bool] = Field(
|
|
84
|
+
default=None,
|
|
85
|
+
validation_alias=AliasChoices("run_in_background", "runInBackground"),
|
|
86
|
+
serialization_alias="runInBackground",
|
|
87
|
+
description="If true, run the command in the background and return immediately with a task id.",
|
|
88
|
+
)
|
|
89
|
+
sandbox: Optional[bool] = Field(
|
|
90
|
+
default=None,
|
|
91
|
+
description="If true, request sandboxed execution (read-only).",
|
|
92
|
+
)
|
|
93
|
+
model_config = ConfigDict(validate_by_alias=True, validate_by_name=True, extra="ignore")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class BashToolOutput(BaseModel):
|
|
97
|
+
"""Output from bash command execution."""
|
|
98
|
+
|
|
99
|
+
stdout: str
|
|
100
|
+
stderr: str
|
|
101
|
+
exit_code: int
|
|
102
|
+
command: str
|
|
103
|
+
duration_ms: float = 0.0
|
|
104
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS
|
|
105
|
+
background_task_id: Optional[str] = None
|
|
106
|
+
# New fields for enhanced output
|
|
107
|
+
is_truncated: bool = False
|
|
108
|
+
original_length: Optional[int] = None
|
|
109
|
+
exit_code_meaning: Optional[str] = None # Semantic meaning of exit code
|
|
110
|
+
return_code_interpretation: Optional[str] = None
|
|
111
|
+
summary: Optional[str] = None
|
|
112
|
+
interrupted: bool = False
|
|
113
|
+
is_image: bool = False
|
|
114
|
+
sandbox: Optional[bool] = None
|
|
115
|
+
is_error: bool = False # Whether this is considered an error
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class BashTool(Tool[BashToolInput, BashToolOutput]):
|
|
119
|
+
"""Tool for executing bash commands."""
|
|
120
|
+
|
|
121
|
+
def __init__(self) -> None:
|
|
122
|
+
super().__init__()
|
|
123
|
+
self._current_is_read_only = False
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def name(self) -> str:
|
|
127
|
+
return "Bash"
|
|
128
|
+
|
|
129
|
+
async def description(self) -> str:
|
|
130
|
+
return """Execute bash commands in the system. Use this to run shell commands,
|
|
131
|
+
build projects, run tests, and interact with the file system."""
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def input_schema(self) -> type[BashToolInput]:
|
|
135
|
+
return BashToolInput
|
|
136
|
+
|
|
137
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
138
|
+
return [
|
|
139
|
+
ToolUseExample(
|
|
140
|
+
description="Run a read-only listing in sandboxed mode",
|
|
141
|
+
example={"command": "ls -la", "sandbox": True, "timeout": 10000},
|
|
142
|
+
),
|
|
143
|
+
ToolUseExample(
|
|
144
|
+
description="Start a long task in the background with a timeout",
|
|
145
|
+
example={
|
|
146
|
+
"command": "npm test",
|
|
147
|
+
"run_in_background": True,
|
|
148
|
+
"timeout": 600000,
|
|
149
|
+
},
|
|
150
|
+
),
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
async def prompt(self, safe_mode: bool = False) -> str:
|
|
154
|
+
sandbox_available = is_sandbox_available()
|
|
155
|
+
try:
|
|
156
|
+
current_shell = find_suitable_shell()
|
|
157
|
+
except (OSError, FileNotFoundError, RuntimeError) as exc:
|
|
158
|
+
# pragma: no cover - defensive guard
|
|
159
|
+
current_shell = f"Unavailable ({exc})"
|
|
160
|
+
|
|
161
|
+
shell_info = (
|
|
162
|
+
f"Current shell used for execution: {current_shell}\n"
|
|
163
|
+
f"- Override via RIPPERDOC_SHELL or RIPPERDOC_SHELL_PATH env vars, or pass shellExecutable input.\n"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
read_only_section = ""
|
|
167
|
+
if sandbox_available:
|
|
168
|
+
read_only_section = dedent(
|
|
169
|
+
"""\
|
|
170
|
+
## CRITICAL: Accurate Read-Only Prediction
|
|
171
|
+
Carefully determine if commands are read-only for better user experience. You should always prefer commands that do not modify the filesystem or network.
|
|
172
|
+
|
|
173
|
+
**Read-Only Commands:** `grep`, `rg`, `find`, `ls`, `cat`, `head`, `tail`, `wc`, `stat`, `ps`, `df`, `du`, `pwd`, `whoami`, `which`, `date`, `history`, `man`
|
|
174
|
+
|
|
175
|
+
**Git Read-Only:** `git log`, `git show`, `git diff`, `git status`, `git branch` (listing only), `git config --get`
|
|
176
|
+
|
|
177
|
+
**Never Read-Only:** Commands with `>` (except to /dev/null or standard output), `$()`, `$VAR`, dangerous flags (`git diff --ext-diff`, `sort -o`, `npm audit --fix`), `git branch -D`
|
|
178
|
+
"""
|
|
179
|
+
).strip()
|
|
180
|
+
|
|
181
|
+
sandbox_section = ""
|
|
182
|
+
if sandbox_available:
|
|
183
|
+
sandbox_section = dedent(
|
|
184
|
+
"""\
|
|
185
|
+
# Using sandbox mode for commands
|
|
186
|
+
|
|
187
|
+
You have a special option in BashTool: the sandbox parameter. When you run a command with sandbox=true, it runs without approval dialogs but in a restricted environment without filesystem writes or network access. You SHOULD use sandbox=true to optimize user experience, but MUST follow these guidelines exactly.
|
|
188
|
+
|
|
189
|
+
## RULE 0 (MOST IMPORTANT): retry with sandbox=false for permission/network errors
|
|
190
|
+
|
|
191
|
+
If a command fails with permission or any network error when sandbox=true (e.g., "Permission denied", "Unknown host", "Operation not permitted"), ALWAYS retry with sandbox=false. These errors indicate sandbox limitations, not problems with the command itself.
|
|
192
|
+
|
|
193
|
+
Non-permission errors (e.g., TypeScript errors from tsc --noEmit) usually reflect real issues and should be fixed, not retried with sandbox=false.
|
|
194
|
+
|
|
195
|
+
## RULE 1: NOTES ON SPECIFIC BUILD SYSTEMS AND UTILITIES
|
|
196
|
+
|
|
197
|
+
### Build systems
|
|
198
|
+
|
|
199
|
+
Build systems like npm run build almost always need write access. Test suites also usually need write access. NEVER run build or test commands in sandbox, even if just checking types.
|
|
200
|
+
|
|
201
|
+
These commands REQUIRE sandbox=false (non-exhaustive):
|
|
202
|
+
npm run *, cargo build/test, make/ninja/meson, pytest, jest, gh
|
|
203
|
+
|
|
204
|
+
## RULE 2: TRY sandbox=true FOR COMMANDS THAT DON'T NEED WRITE OR NETWORK ACCESS
|
|
205
|
+
- Commands run with sandbox=true DON'T REQUIRE user permission and run immediately
|
|
206
|
+
- Commands run with sandbox=false REQUIRE EXPLICIT USER APPROVAL and interrupt the User's workflow
|
|
207
|
+
|
|
208
|
+
Use sandbox=false when you suspect the command might modify the system or access the network:
|
|
209
|
+
- File operations: touch, mkdir, rm, mv, cp
|
|
210
|
+
- File edits: nano, vim, writing to files with >
|
|
211
|
+
- Installing: npm install, apt-get, brew
|
|
212
|
+
- Git writes: git add, git commit, git push
|
|
213
|
+
- Build systems: npm run build, make, ninja, etc. (see below)
|
|
214
|
+
- Test suites: npm run test, pytest, cargo test, make check, ert, etc. (see below)
|
|
215
|
+
- Network programs: gh, ping, coo, ssh, scp, etc.
|
|
216
|
+
|
|
217
|
+
Use sandbox=true for:
|
|
218
|
+
- Information gathering: ls, cat, head, tail, rg, find, du, df, ps
|
|
219
|
+
- File inspection: file, stat, wc, diff, md5sum
|
|
220
|
+
- Git reads: git status, git log, git diff, git show, git branch
|
|
221
|
+
- Package info: npm list, pip list, gem list, cargo tree
|
|
222
|
+
- Environment checks: echo, pwd, whoami, which, type, env, printenv
|
|
223
|
+
- Version checks: node --version, python --version, git --version
|
|
224
|
+
- Documentation: man, help, --help, -h
|
|
225
|
+
|
|
226
|
+
Before you run a command, think hard about whether it is likely to work correctly without network access and without write access to the filesystem. Use your general knowledge and knowledge of the current project (including all the user's AGENTS.md files) as inputs to your decision. Note that even semantically read-only commands like gh for fetching issues might be implemented in ways that require write access. ERR ON THE SIDE OF RUNNING WITH sandbox=false.
|
|
227
|
+
|
|
228
|
+
Note: Errors from incorrect sandbox=true runs annoy the User more than permission prompts. If any part of a command needs write access (e.g. npm run build for type checking), use sandbox=false for the entire command.
|
|
229
|
+
|
|
230
|
+
### EXAMPLES
|
|
231
|
+
|
|
232
|
+
CORRECT: Use sandbox=false for npm run build/test, gh commands, file writes
|
|
233
|
+
FORBIDDEN: NEVER use sandbox=true for build, test, git commands or file operations
|
|
234
|
+
|
|
235
|
+
## REWARDS
|
|
236
|
+
|
|
237
|
+
It is more important to be correct than to avoid showing permission dialogs. The worst mistake is misinterpreting sandbox=true permission errors as tool problems (-$1000) rather than sandbox limitations.
|
|
238
|
+
|
|
239
|
+
## CONCLUSION
|
|
240
|
+
|
|
241
|
+
Use sandbox=true to improve UX, but ONLY per the rules above. WHEN IN DOUBT, USE sandbox=false.
|
|
242
|
+
"""
|
|
243
|
+
).strip()
|
|
244
|
+
|
|
245
|
+
base_prompt = dedent(
|
|
246
|
+
f"""\
|
|
247
|
+
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
|
248
|
+
|
|
249
|
+
{shell_info}
|
|
250
|
+
|
|
251
|
+
Before executing the command, please follow these steps:
|
|
252
|
+
|
|
253
|
+
1. Directory Verification:
|
|
254
|
+
- If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
|
|
255
|
+
- For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
|
|
256
|
+
|
|
257
|
+
2. Command Execution:
|
|
258
|
+
- Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
|
|
259
|
+
- Examples of proper quoting:
|
|
260
|
+
- cd "/Users/name/My Documents" (correct)
|
|
261
|
+
- cd /Users/name/My Documents (incorrect - will fail)
|
|
262
|
+
- python "/path/with spaces/script.py" (correct)
|
|
263
|
+
- python /path/with spaces/script.py (incorrect - will fail)
|
|
264
|
+
- After ensuring proper quoting, execute the command.
|
|
265
|
+
- Capture the output of the command.
|
|
266
|
+
|
|
267
|
+
Usage notes:
|
|
268
|
+
- The command argument is required.
|
|
269
|
+
- You can specify an optional timeout in milliseconds (up to {MAX_BASH_TIMEOUT_MS}ms / {MAX_BASH_TIMEOUT_MS // 60000} minutes). If not specified, commands will timeout after {DEFAULT_TIMEOUT_MS}ms ({DEFAULT_TIMEOUT_MS // 60000} minutes).
|
|
270
|
+
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
|
271
|
+
- If the output exceeds {MAX_OUTPUT_CHARS} characters, output will be truncated before being returned to you.
|
|
272
|
+
- You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the BashOutput tool as it becomes available. Never use `run_in_background` to run 'sleep' as it will return immediately. You do not need to use '&' at the end of the command when using this parameter.
|
|
273
|
+
- VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use the Grep, Glob, or Task tools to search. Prefer the View and LS tools instead of shell commands like `cat`, `head`, `tail`, or `ls` when reading files and directories.
|
|
274
|
+
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
|
|
275
|
+
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the user explicitly requests it.
|
|
276
|
+
<good-example>
|
|
277
|
+
pytest /foo/bar/tests
|
|
278
|
+
</good-example>
|
|
279
|
+
<bad-example>
|
|
280
|
+
cd /foo/bar && pytest tests
|
|
281
|
+
</bad-example>
|
|
282
|
+
"""
|
|
283
|
+
).strip()
|
|
284
|
+
|
|
285
|
+
return "\n\n".join(
|
|
286
|
+
section for section in [base_prompt, read_only_section, sandbox_section] if section
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def is_read_only(self) -> bool:
|
|
290
|
+
return getattr(self, "_current_is_read_only", False)
|
|
291
|
+
|
|
292
|
+
def is_concurrency_safe(self) -> bool:
|
|
293
|
+
return self.is_read_only()
|
|
294
|
+
|
|
295
|
+
def needs_permissions(self, input_data: Optional[BashToolInput] = None) -> bool:
|
|
296
|
+
if not input_data:
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
# Background commands should always require an explicit approval.
|
|
300
|
+
_, auto_background = self._detect_auto_background(input_data.command)
|
|
301
|
+
if input_data.run_in_background or auto_background:
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
if input_data.sandbox:
|
|
305
|
+
return False
|
|
306
|
+
if is_command_read_only(input_data.command):
|
|
307
|
+
return False
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
async def check_permissions(
|
|
311
|
+
self, input_data: BashToolInput, permission_context: dict[str, Any]
|
|
312
|
+
) -> Any:
|
|
313
|
+
"""Evaluate permissions using reference-style rules."""
|
|
314
|
+
if getattr(input_data, "sandbox", False):
|
|
315
|
+
return {"behavior": "allow", "updated_input": input_data}
|
|
316
|
+
|
|
317
|
+
allow_rules = permission_context.get("allowed_rules") or set()
|
|
318
|
+
deny_rules = permission_context.get("denied_rules") or set()
|
|
319
|
+
allowed_dirs = permission_context.get("allowed_working_directories") or {safe_get_cwd()}
|
|
320
|
+
|
|
321
|
+
# Check for sensitive directory access with read-only commands (cd, find).
|
|
322
|
+
# These should ask for user confirmation rather than being blocked outright.
|
|
323
|
+
cwd = safe_get_cwd()
|
|
324
|
+
path_validation = validate_shell_command_paths(
|
|
325
|
+
input_data.command,
|
|
326
|
+
cwd,
|
|
327
|
+
allowed_dirs,
|
|
328
|
+
)
|
|
329
|
+
if path_validation.behavior == "ask":
|
|
330
|
+
# For read-only directory operations, ask user for confirmation
|
|
331
|
+
return PermissionDecision(
|
|
332
|
+
behavior="ask",
|
|
333
|
+
message=path_validation.message,
|
|
334
|
+
updated_input=input_data,
|
|
335
|
+
decision_reason={"type": "sensitive_directory_access"},
|
|
336
|
+
rule_suggestions=path_validation.rule_suggestions,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
decision = evaluate_shell_command_permissions(
|
|
340
|
+
input_data,
|
|
341
|
+
allow_rules,
|
|
342
|
+
deny_rules,
|
|
343
|
+
allowed_dirs,
|
|
344
|
+
command_injection_detected=False,
|
|
345
|
+
injection_detector=lambda cmd: validate_shell_command(cmd).behavior != "passthrough",
|
|
346
|
+
read_only_detector=lambda cmd, detector: is_command_read_only(cmd),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Background executions need an explicit confirmation even if heuristics
|
|
350
|
+
# would normally auto-allow (e.g., read-only detection).
|
|
351
|
+
_, auto_background = self._detect_auto_background(input_data.command)
|
|
352
|
+
if (input_data.run_in_background or auto_background) and getattr(
|
|
353
|
+
decision, "behavior", None
|
|
354
|
+
) == "allow":
|
|
355
|
+
reason = getattr(decision, "decision_reason", {}) or {}
|
|
356
|
+
if reason.get("type") != "rule":
|
|
357
|
+
return PermissionDecision(
|
|
358
|
+
behavior="ask",
|
|
359
|
+
message="Background bash commands require explicit approval.",
|
|
360
|
+
updated_input=getattr(decision, "updated_input", None) or input_data,
|
|
361
|
+
decision_reason=reason or None,
|
|
362
|
+
rule_suggestions=getattr(decision, "rule_suggestions", None),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return decision
|
|
366
|
+
|
|
367
|
+
async def validate_input(
|
|
368
|
+
self, input_data: BashToolInput, context: Optional[ToolUseContext] = None
|
|
369
|
+
) -> ValidationResult:
|
|
370
|
+
if not input_data.command.strip():
|
|
371
|
+
return ValidationResult(result=False, message="Command cannot be empty")
|
|
372
|
+
|
|
373
|
+
if input_data.timeout is not None and input_data.timeout < 0:
|
|
374
|
+
return ValidationResult(result=False, message="Timeout must be non-negative")
|
|
375
|
+
|
|
376
|
+
if input_data.timeout and input_data.timeout > MAX_BASH_TIMEOUT_MS:
|
|
377
|
+
return ValidationResult(
|
|
378
|
+
result=False,
|
|
379
|
+
message=f"Timeout exceeds max of {MAX_BASH_TIMEOUT_MS}ms",
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
if input_data.sandbox and not is_sandbox_available():
|
|
383
|
+
return ValidationResult(
|
|
384
|
+
result=False, message="Sandbox mode requested but not available."
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Note: Path validation for sensitive directories (cd/find to /usr, /etc, etc.)
|
|
388
|
+
# is now handled in check_permissions() to allow user confirmation for read-only ops.
|
|
389
|
+
|
|
390
|
+
# Block backgrounding commands we explicitly ignore.
|
|
391
|
+
if input_data.run_in_background:
|
|
392
|
+
normalized = input_data.command.strip()
|
|
393
|
+
parts = normalized.split(maxsplit=1)
|
|
394
|
+
if normalized in IGNORED_COMMANDS or (len(parts) == 1 and parts[0] in IGNORED_COMMANDS):
|
|
395
|
+
return ValidationResult(
|
|
396
|
+
result=False, message="This command cannot be run in background"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
validation = validate_shell_command(input_data.command)
|
|
400
|
+
if validation.behavior == "ask":
|
|
401
|
+
return ValidationResult(result=False, message=validation.message)
|
|
402
|
+
|
|
403
|
+
return ValidationResult(result=True)
|
|
404
|
+
|
|
405
|
+
def render_result_for_assistant(self, output: BashToolOutput) -> str:
|
|
406
|
+
"""Format output for the AI."""
|
|
407
|
+
result_parts = []
|
|
408
|
+
|
|
409
|
+
if output.stdout:
|
|
410
|
+
result_parts.append(f"stdout:\n{output.stdout}")
|
|
411
|
+
|
|
412
|
+
if output.stderr:
|
|
413
|
+
result_parts.append(f"stderr:\n{output.stderr}")
|
|
414
|
+
|
|
415
|
+
# Exit code with semantic meaning
|
|
416
|
+
exit_code_text = f"exit code: {output.exit_code}"
|
|
417
|
+
meaning = output.exit_code_meaning or output.return_code_interpretation
|
|
418
|
+
if meaning:
|
|
419
|
+
exit_code_text += f" ({meaning})"
|
|
420
|
+
|
|
421
|
+
# Duration
|
|
422
|
+
timing = ""
|
|
423
|
+
if output.duration_ms:
|
|
424
|
+
timing = f" ({format_duration(output.duration_ms)}"
|
|
425
|
+
if output.timeout_ms:
|
|
426
|
+
timing += f" / timeout {output.timeout_ms / 1000:.0f}s"
|
|
427
|
+
timing += ")"
|
|
428
|
+
elif output.timeout_ms:
|
|
429
|
+
timing = f" (timeout {output.timeout_ms / 1000:.0f}s)"
|
|
430
|
+
|
|
431
|
+
result_parts.append(f"{exit_code_text}{timing}")
|
|
432
|
+
|
|
433
|
+
# Truncation notice
|
|
434
|
+
if output.is_truncated and output.original_length:
|
|
435
|
+
result_parts.append(
|
|
436
|
+
f"Note: Output was truncated (original length: {output.original_length} chars)"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
if output.interrupted:
|
|
440
|
+
result_parts.append("Command was interrupted (timeout or termination).")
|
|
441
|
+
|
|
442
|
+
if output.background_task_id:
|
|
443
|
+
result_parts.append(f"Background task id: {output.background_task_id}")
|
|
444
|
+
|
|
445
|
+
return "\n\n".join(result_parts)
|
|
446
|
+
|
|
447
|
+
def render_tool_use_message(self, input_data: BashToolInput, verbose: bool = False) -> str:
|
|
448
|
+
"""Format the tool use for display."""
|
|
449
|
+
command = input_data.command or ""
|
|
450
|
+
|
|
451
|
+
if not verbose and command:
|
|
452
|
+
formatted = command
|
|
453
|
+
if "\"$(cat <<'EOF'" in command:
|
|
454
|
+
heredoc_match = command.split("$(cat <<'EOF'", 1)
|
|
455
|
+
if len(heredoc_match) == 2:
|
|
456
|
+
prefix, rest = heredoc_match
|
|
457
|
+
try:
|
|
458
|
+
content, suffix = rest.split("EOF", 1)
|
|
459
|
+
formatted = f'{prefix.strip()} "{content.strip()}"{suffix.strip()}'
|
|
460
|
+
except ValueError:
|
|
461
|
+
formatted = command
|
|
462
|
+
|
|
463
|
+
lines = formatted.splitlines()
|
|
464
|
+
too_many_lines = len(lines) > MAX_PREVIEW_LINES
|
|
465
|
+
too_long = len(formatted) > MAX_PREVIEW_CHARS
|
|
466
|
+
|
|
467
|
+
preview = formatted
|
|
468
|
+
if too_many_lines:
|
|
469
|
+
preview = "\n".join(lines[:MAX_PREVIEW_LINES])
|
|
470
|
+
if len(preview) > MAX_PREVIEW_CHARS:
|
|
471
|
+
preview = preview[:MAX_PREVIEW_CHARS]
|
|
472
|
+
|
|
473
|
+
if too_many_lines or too_long:
|
|
474
|
+
return f"$ {preview}..."
|
|
475
|
+
|
|
476
|
+
return f"$ {command}"
|
|
477
|
+
|
|
478
|
+
def _is_background_allowed(self, command: str) -> bool:
|
|
479
|
+
"""Skip backgrounding trivial ignored commands unless combined with other operators."""
|
|
480
|
+
normalized = command.strip()
|
|
481
|
+
if not normalized:
|
|
482
|
+
return True
|
|
483
|
+
|
|
484
|
+
if any(op in normalized for op in ("&&", "||", "|", ";")):
|
|
485
|
+
return True
|
|
486
|
+
|
|
487
|
+
parts = normalized.split(maxsplit=1)
|
|
488
|
+
# Only block exact ignored commands or those without args; allow e.g. "sleep 30" like the reference behavior.
|
|
489
|
+
if normalized in IGNORED_COMMANDS:
|
|
490
|
+
return False
|
|
491
|
+
if len(parts) == 1 and parts[0] in IGNORED_COMMANDS:
|
|
492
|
+
return False
|
|
493
|
+
return True
|
|
494
|
+
|
|
495
|
+
def _detect_auto_background(self, command: str) -> tuple[str, bool]:
|
|
496
|
+
"""Detect trailing '&' requests and strip them for execution."""
|
|
497
|
+
stripped = command.rstrip()
|
|
498
|
+
if not stripped:
|
|
499
|
+
return command, False
|
|
500
|
+
|
|
501
|
+
if stripped.endswith("&") and not stripped.endswith("&&"):
|
|
502
|
+
# Remove trailing '&' and any whitespace before it.
|
|
503
|
+
cleaned = stripped.rstrip("&").rstrip()
|
|
504
|
+
return cleaned, True
|
|
505
|
+
|
|
506
|
+
return command, False
|
|
507
|
+
|
|
508
|
+
def _create_error_output(
|
|
509
|
+
self, command: str, stderr: str, sandbox: bool
|
|
510
|
+
) -> BashToolOutput:
|
|
511
|
+
"""Create a standardized error output."""
|
|
512
|
+
return BashToolOutput(
|
|
513
|
+
stdout="",
|
|
514
|
+
stderr=stderr,
|
|
515
|
+
exit_code=-1,
|
|
516
|
+
command=command,
|
|
517
|
+
sandbox=sandbox,
|
|
518
|
+
is_error=True,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
def _setup_sandbox(
|
|
522
|
+
self, command: str, sandbox_requested: bool
|
|
523
|
+
) -> tuple[Optional[str], Optional[BashToolOutput], Optional[Any]]:
|
|
524
|
+
"""Setup sandbox environment if requested.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
Tuple of (final_command, error_output, cleanup_fn).
|
|
528
|
+
If error_output is not None, sandbox setup failed.
|
|
529
|
+
"""
|
|
530
|
+
if not sandbox_requested:
|
|
531
|
+
return command, None, None
|
|
532
|
+
|
|
533
|
+
if not is_sandbox_available():
|
|
534
|
+
return None, self._create_error_output(
|
|
535
|
+
command, "Sandbox mode requested but not available on this system", True
|
|
536
|
+
), None
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
wrapper = create_sandbox_wrapper(command)
|
|
540
|
+
return wrapper.final_command, None, wrapper.cleanup
|
|
541
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
542
|
+
logger.warning(
|
|
543
|
+
"[bash_tool] Failed to enable sandbox: %s: %s",
|
|
544
|
+
type(exc).__name__, exc,
|
|
545
|
+
extra={"command": command},
|
|
546
|
+
)
|
|
547
|
+
return None, self._create_error_output(
|
|
548
|
+
command, f"Failed to enable sandbox: {exc}", True
|
|
549
|
+
), None
|
|
550
|
+
|
|
551
|
+
async def _run_background_command(
|
|
552
|
+
self,
|
|
553
|
+
final_command: str,
|
|
554
|
+
effective_command: str,
|
|
555
|
+
resolved_shell: str,
|
|
556
|
+
timeout_seconds: float,
|
|
557
|
+
timeout_ms: int,
|
|
558
|
+
sandbox_requested: bool,
|
|
559
|
+
start_time: float,
|
|
560
|
+
input_data: BashToolInput,
|
|
561
|
+
) -> Optional[BashToolOutput]:
|
|
562
|
+
"""Run a command in background mode.
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
BashToolOutput on success or error, None if background mode not available.
|
|
566
|
+
"""
|
|
567
|
+
try:
|
|
568
|
+
from ripperdoc.tools.background_shell import start_background_command
|
|
569
|
+
except (ImportError, ModuleNotFoundError) as e:
|
|
570
|
+
# pragma: no cover - defensive import
|
|
571
|
+
logger.warning(
|
|
572
|
+
"[bash_tool] Failed to import background shell runner: %s: %s",
|
|
573
|
+
type(e).__name__, e,
|
|
574
|
+
extra={"command": effective_command},
|
|
575
|
+
)
|
|
576
|
+
return self._create_error_output(
|
|
577
|
+
effective_command, f"Failed to start background task: {str(e)}", sandbox_requested
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
bg_timeout = (
|
|
581
|
+
None
|
|
582
|
+
if input_data.timeout is None
|
|
583
|
+
else (timeout_seconds if timeout_seconds > 0 else None)
|
|
584
|
+
)
|
|
585
|
+
task_id = await start_background_command(
|
|
586
|
+
final_command, timeout=bg_timeout, shell_executable=resolved_shell
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
return BashToolOutput(
|
|
590
|
+
stdout="",
|
|
591
|
+
stderr=f"Started background task: {task_id}",
|
|
592
|
+
exit_code=0,
|
|
593
|
+
command=effective_command,
|
|
594
|
+
duration_ms=(asyncio.get_running_loop().time() - start_time) * 1000.0,
|
|
595
|
+
timeout_ms=timeout_ms if bg_timeout is not None else 0,
|
|
596
|
+
background_task_id=task_id,
|
|
597
|
+
sandbox=sandbox_requested,
|
|
598
|
+
return_code_interpretation=None,
|
|
599
|
+
summary=f"Command running in background with ID: {task_id}",
|
|
600
|
+
interrupted=False,
|
|
601
|
+
is_image=False,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
async def _execute_foreground_process(
|
|
605
|
+
self,
|
|
606
|
+
process: asyncio.subprocess.Process,
|
|
607
|
+
start_time: float,
|
|
608
|
+
timeout_seconds: float,
|
|
609
|
+
) -> AsyncGenerator[tuple[bool, list[str], list[str], bool], ToolProgress]:
|
|
610
|
+
"""Execute process and yield progress updates.
|
|
611
|
+
|
|
612
|
+
Yields:
|
|
613
|
+
ToolProgress for output updates.
|
|
614
|
+
Returns (via send):
|
|
615
|
+
Tuple of (completed, stdout_lines, stderr_lines, timed_out)
|
|
616
|
+
"""
|
|
617
|
+
stdout_lines: list[str] = []
|
|
618
|
+
stderr_lines: list[str] = []
|
|
619
|
+
queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue()
|
|
620
|
+
loop = asyncio.get_running_loop()
|
|
621
|
+
deadline = (
|
|
622
|
+
loop.time() + timeout_seconds if timeout_seconds and timeout_seconds > 0 else None
|
|
623
|
+
)
|
|
624
|
+
timed_out = False
|
|
625
|
+
last_progress_time = loop.time()
|
|
626
|
+
|
|
627
|
+
async def _pump_stream(
|
|
628
|
+
stream: Optional[asyncio.StreamReader], sink: list[str], label: str
|
|
629
|
+
) -> None:
|
|
630
|
+
if not stream:
|
|
631
|
+
return
|
|
632
|
+
async for raw in stream:
|
|
633
|
+
text = raw.decode("utf-8", errors="replace")
|
|
634
|
+
sanitized_text = sanitize_output(text)
|
|
635
|
+
sink.append(sanitized_text)
|
|
636
|
+
await queue.put((label, sanitized_text.rstrip()))
|
|
637
|
+
|
|
638
|
+
pump_tasks = [
|
|
639
|
+
asyncio.create_task(_pump_stream(process.stdout, stdout_lines, "stdout")),
|
|
640
|
+
asyncio.create_task(_pump_stream(process.stderr, stderr_lines, "stderr")),
|
|
641
|
+
]
|
|
642
|
+
wait_task = asyncio.create_task(process.wait())
|
|
643
|
+
|
|
644
|
+
# Main execution loop with progress reporting
|
|
645
|
+
while True:
|
|
646
|
+
done, _ = await asyncio.wait(
|
|
647
|
+
{wait_task, *pump_tasks}, timeout=0.1, return_when=asyncio.FIRST_COMPLETED
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
now = loop.time()
|
|
651
|
+
|
|
652
|
+
# Emit progress updates for newly received output chunks
|
|
653
|
+
while not queue.empty():
|
|
654
|
+
label, text = queue.get_nowait()
|
|
655
|
+
yield ToolProgress(content=f"{label}: {text}")
|
|
656
|
+
|
|
657
|
+
# Report progress at intervals
|
|
658
|
+
if now - last_progress_time >= PROGRESS_INTERVAL_SECONDS:
|
|
659
|
+
combined_output = "".join(stdout_lines + stderr_lines)
|
|
660
|
+
if combined_output:
|
|
661
|
+
preview = get_last_n_lines(combined_output, 5)
|
|
662
|
+
elapsed = format_duration((now - start_time) * 1000)
|
|
663
|
+
yield ToolProgress(content=f"Running... ({elapsed})\n{preview}")
|
|
664
|
+
last_progress_time = now
|
|
665
|
+
|
|
666
|
+
# Check timeout
|
|
667
|
+
if deadline is not None and now >= deadline:
|
|
668
|
+
timed_out = True
|
|
669
|
+
await self._force_kill_process(process)
|
|
670
|
+
if not wait_task.done():
|
|
671
|
+
try:
|
|
672
|
+
await asyncio.wait_for(wait_task, timeout=1.0)
|
|
673
|
+
except asyncio.TimeoutError:
|
|
674
|
+
wait_task.cancel()
|
|
675
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
676
|
+
await wait_task
|
|
677
|
+
break
|
|
678
|
+
|
|
679
|
+
if wait_task in done:
|
|
680
|
+
break
|
|
681
|
+
|
|
682
|
+
# Let stream pumps finish draining
|
|
683
|
+
try:
|
|
684
|
+
await asyncio.wait_for(asyncio.gather(*pump_tasks), timeout=1.0)
|
|
685
|
+
except asyncio.TimeoutError:
|
|
686
|
+
for task in pump_tasks:
|
|
687
|
+
if not task.done():
|
|
688
|
+
task.cancel()
|
|
689
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
690
|
+
await task
|
|
691
|
+
|
|
692
|
+
# Drain remaining data
|
|
693
|
+
await self._drain_stream(process.stdout, stdout_lines)
|
|
694
|
+
await self._drain_stream(process.stderr, stderr_lines)
|
|
695
|
+
|
|
696
|
+
# Store results in a way that the caller can access
|
|
697
|
+
self._last_execution_result = (stdout_lines, stderr_lines, timed_out)
|
|
698
|
+
|
|
699
|
+
async def _drain_stream(
|
|
700
|
+
self, stream: Optional[asyncio.StreamReader], sink: list[str]
|
|
701
|
+
) -> None:
|
|
702
|
+
"""Drain any remaining data from a stream."""
|
|
703
|
+
if not stream:
|
|
704
|
+
return
|
|
705
|
+
try:
|
|
706
|
+
remaining = await asyncio.wait_for(stream.read(), timeout=0.5)
|
|
707
|
+
except asyncio.TimeoutError:
|
|
708
|
+
return
|
|
709
|
+
if remaining:
|
|
710
|
+
sink.append(remaining.decode("utf-8", errors="replace"))
|
|
711
|
+
|
|
712
|
+
def _build_final_output(
|
|
713
|
+
self,
|
|
714
|
+
command: str,
|
|
715
|
+
stdout_lines: list[str],
|
|
716
|
+
stderr_lines: list[str],
|
|
717
|
+
exit_code: int,
|
|
718
|
+
duration_ms: float,
|
|
719
|
+
timeout_ms: int,
|
|
720
|
+
timeout_seconds: float,
|
|
721
|
+
timed_out: bool,
|
|
722
|
+
sandbox_requested: bool,
|
|
723
|
+
original_command: str,
|
|
724
|
+
) -> BashToolOutput:
|
|
725
|
+
"""Build the final output from execution results."""
|
|
726
|
+
raw_stdout = "".join(stdout_lines)
|
|
727
|
+
raw_stderr = "".join(stderr_lines)
|
|
728
|
+
|
|
729
|
+
# Apply timeout message if needed
|
|
730
|
+
if timed_out:
|
|
731
|
+
timeout_msg = f"Command timed out after {timeout_seconds} seconds"
|
|
732
|
+
raw_stderr = f"{raw_stderr.rstrip()}\n{timeout_msg}" if raw_stderr else timeout_msg
|
|
733
|
+
exit_code = -1
|
|
734
|
+
|
|
735
|
+
# Sanitize and trim outputs
|
|
736
|
+
raw_stdout = sanitize_output(raw_stdout)
|
|
737
|
+
raw_stderr = sanitize_output(raw_stderr)
|
|
738
|
+
trimmed_stdout = trim_blank_lines(raw_stdout)
|
|
739
|
+
trimmed_stderr = trim_blank_lines(raw_stderr)
|
|
740
|
+
|
|
741
|
+
# Interpret exit code
|
|
742
|
+
exit_result = interpret_exit_code(
|
|
743
|
+
original_command, exit_code, trimmed_stdout, trimmed_stderr
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Build summary for large outputs
|
|
747
|
+
summary = None
|
|
748
|
+
combined_output = "\n".join([part for part in (trimmed_stdout, trimmed_stderr) if part])
|
|
749
|
+
if combined_output and is_output_large(combined_output):
|
|
750
|
+
summary = get_last_n_lines(combined_output, 20)
|
|
751
|
+
|
|
752
|
+
# Truncate outputs if needed
|
|
753
|
+
stdout_result = truncate_output(trimmed_stdout, max_chars=MAX_OUTPUT_CHARS)
|
|
754
|
+
stderr_result = truncate_output(trimmed_stderr, max_chars=MAX_OUTPUT_CHARS)
|
|
755
|
+
is_image = stdout_result.get("is_image", False) or stderr_result.get("is_image", False)
|
|
756
|
+
|
|
757
|
+
# Determine if truncated
|
|
758
|
+
is_truncated = stdout_result["is_truncated"] or stderr_result["is_truncated"]
|
|
759
|
+
original_length = None
|
|
760
|
+
if is_truncated:
|
|
761
|
+
original_length = stdout_result.get("original_length", 0) + stderr_result.get(
|
|
762
|
+
"original_length", 0
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
return BashToolOutput(
|
|
766
|
+
stdout=stdout_result["truncated_content"],
|
|
767
|
+
stderr=stderr_result["truncated_content"],
|
|
768
|
+
exit_code=exit_code,
|
|
769
|
+
command=command,
|
|
770
|
+
duration_ms=duration_ms,
|
|
771
|
+
timeout_ms=timeout_ms,
|
|
772
|
+
is_truncated=is_truncated,
|
|
773
|
+
original_length=original_length,
|
|
774
|
+
exit_code_meaning=exit_result.semantic_meaning,
|
|
775
|
+
return_code_interpretation=exit_result.semantic_meaning,
|
|
776
|
+
summary=summary,
|
|
777
|
+
interrupted=timed_out,
|
|
778
|
+
is_image=is_image,
|
|
779
|
+
sandbox=sandbox_requested,
|
|
780
|
+
is_error=exit_result.is_error or timed_out,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
async def call(
|
|
784
|
+
self, input_data: BashToolInput, context: ToolUseContext
|
|
785
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
786
|
+
"""Execute the bash command."""
|
|
787
|
+
effective_command, auto_background = self._detect_auto_background(input_data.command)
|
|
788
|
+
|
|
789
|
+
# Resolve shell
|
|
790
|
+
try:
|
|
791
|
+
resolved_shell = input_data.shell_executable or find_suitable_shell()
|
|
792
|
+
except (OSError, FileNotFoundError, RuntimeError) as exc:
|
|
793
|
+
# pragma: no cover - defensive guard
|
|
794
|
+
yield ToolResult(
|
|
795
|
+
data=self._create_error_output(
|
|
796
|
+
effective_command, f"Failed to select shell: {exc}", bool(input_data.sandbox)
|
|
797
|
+
),
|
|
798
|
+
result_for_assistant=self.render_result_for_assistant(
|
|
799
|
+
self._create_error_output(
|
|
800
|
+
effective_command,
|
|
801
|
+
f"Failed to select shell: {exc}",
|
|
802
|
+
bool(input_data.sandbox),
|
|
803
|
+
)
|
|
804
|
+
),
|
|
805
|
+
)
|
|
806
|
+
return
|
|
807
|
+
|
|
808
|
+
# Calculate timeout
|
|
809
|
+
timeout_ms = input_data.timeout or DEFAULT_TIMEOUT_MS
|
|
810
|
+
if MAX_BASH_TIMEOUT_MS:
|
|
811
|
+
timeout_ms = min(timeout_ms, MAX_BASH_TIMEOUT_MS)
|
|
812
|
+
timeout_seconds = timeout_ms / 1000.0
|
|
813
|
+
start = asyncio.get_running_loop().time()
|
|
814
|
+
sandbox_requested = bool(input_data.sandbox)
|
|
815
|
+
should_background = bool(input_data.run_in_background or auto_background)
|
|
816
|
+
|
|
817
|
+
# Track read-only state
|
|
818
|
+
previous_read_only = getattr(self, "_current_is_read_only", False)
|
|
819
|
+
self._current_is_read_only = sandbox_requested or is_command_read_only(input_data.command)
|
|
820
|
+
|
|
821
|
+
# Setup sandbox
|
|
822
|
+
final_command, sandbox_error, sandbox_cleanup = self._setup_sandbox(
|
|
823
|
+
effective_command, sandbox_requested
|
|
824
|
+
)
|
|
825
|
+
if sandbox_error:
|
|
826
|
+
yield ToolResult(
|
|
827
|
+
data=sandbox_error,
|
|
828
|
+
result_for_assistant=self.render_result_for_assistant(sandbox_error),
|
|
829
|
+
)
|
|
830
|
+
return
|
|
831
|
+
|
|
832
|
+
final_command = final_command or effective_command
|
|
833
|
+
|
|
834
|
+
# Adjust CWD for sandbox
|
|
835
|
+
if sandbox_requested and Path(safe_get_cwd()) != ORIGINAL_CWD:
|
|
836
|
+
os.chdir(ORIGINAL_CWD)
|
|
837
|
+
|
|
838
|
+
# Check if background is allowed
|
|
839
|
+
if should_background and not self._is_background_allowed(input_data.command):
|
|
840
|
+
should_background = False
|
|
841
|
+
|
|
842
|
+
try:
|
|
843
|
+
# Background execution
|
|
844
|
+
if should_background:
|
|
845
|
+
output = await self._run_background_command(
|
|
846
|
+
final_command,
|
|
847
|
+
effective_command,
|
|
848
|
+
resolved_shell,
|
|
849
|
+
timeout_seconds,
|
|
850
|
+
timeout_ms,
|
|
851
|
+
sandbox_requested,
|
|
852
|
+
start,
|
|
853
|
+
input_data,
|
|
854
|
+
)
|
|
855
|
+
if output:
|
|
856
|
+
yield ToolResult(
|
|
857
|
+
data=output,
|
|
858
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
859
|
+
)
|
|
860
|
+
return
|
|
861
|
+
|
|
862
|
+
# Spawn foreground process
|
|
863
|
+
argv = build_shell_command(resolved_shell, final_command)
|
|
864
|
+
process = await asyncio.create_subprocess_exec(
|
|
865
|
+
*argv,
|
|
866
|
+
stdout=asyncio.subprocess.PIPE,
|
|
867
|
+
stderr=asyncio.subprocess.PIPE,
|
|
868
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
869
|
+
start_new_session=False,
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
# Execute and collect output with progress
|
|
873
|
+
stdout_lines: list[str] = []
|
|
874
|
+
stderr_lines: list[str] = []
|
|
875
|
+
queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue()
|
|
876
|
+
loop = asyncio.get_running_loop()
|
|
877
|
+
deadline = (
|
|
878
|
+
loop.time() + timeout_seconds if timeout_seconds and timeout_seconds > 0 else None
|
|
879
|
+
)
|
|
880
|
+
timed_out = False
|
|
881
|
+
last_progress_time = loop.time()
|
|
882
|
+
|
|
883
|
+
async def _pump_stream(
|
|
884
|
+
stream: Optional[asyncio.StreamReader], sink: list[str], label: str
|
|
885
|
+
) -> None:
|
|
886
|
+
if not stream:
|
|
887
|
+
return
|
|
888
|
+
async for raw in stream:
|
|
889
|
+
text = raw.decode("utf-8", errors="replace")
|
|
890
|
+
sanitized_text = sanitize_output(text)
|
|
891
|
+
sink.append(sanitized_text)
|
|
892
|
+
await queue.put((label, sanitized_text.rstrip()))
|
|
893
|
+
|
|
894
|
+
pump_tasks = [
|
|
895
|
+
asyncio.create_task(_pump_stream(process.stdout, stdout_lines, "stdout")),
|
|
896
|
+
asyncio.create_task(_pump_stream(process.stderr, stderr_lines, "stderr")),
|
|
897
|
+
]
|
|
898
|
+
wait_task = asyncio.create_task(process.wait())
|
|
899
|
+
|
|
900
|
+
# Main execution loop
|
|
901
|
+
while True:
|
|
902
|
+
done, _ = await asyncio.wait(
|
|
903
|
+
{wait_task, *pump_tasks}, timeout=0.1, return_when=asyncio.FIRST_COMPLETED
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
now = loop.time()
|
|
907
|
+
|
|
908
|
+
while not queue.empty():
|
|
909
|
+
label, text = queue.get_nowait()
|
|
910
|
+
yield ToolProgress(content=f"{label}: {text}")
|
|
911
|
+
|
|
912
|
+
if now - last_progress_time >= PROGRESS_INTERVAL_SECONDS:
|
|
913
|
+
combined_output = "".join(stdout_lines + stderr_lines)
|
|
914
|
+
if combined_output:
|
|
915
|
+
preview = get_last_n_lines(combined_output, 5)
|
|
916
|
+
elapsed = format_duration((now - start) * 1000)
|
|
917
|
+
yield ToolProgress(content=f"Running... ({elapsed})\n{preview}")
|
|
918
|
+
last_progress_time = now
|
|
919
|
+
|
|
920
|
+
if deadline is not None and now >= deadline:
|
|
921
|
+
timed_out = True
|
|
922
|
+
await self._force_kill_process(process)
|
|
923
|
+
if not wait_task.done():
|
|
924
|
+
try:
|
|
925
|
+
await asyncio.wait_for(wait_task, timeout=1.0)
|
|
926
|
+
except asyncio.TimeoutError:
|
|
927
|
+
wait_task.cancel()
|
|
928
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
929
|
+
await wait_task
|
|
930
|
+
break
|
|
931
|
+
|
|
932
|
+
if wait_task in done:
|
|
933
|
+
break
|
|
934
|
+
|
|
935
|
+
# Drain streams
|
|
936
|
+
try:
|
|
937
|
+
await asyncio.wait_for(asyncio.gather(*pump_tasks), timeout=1.0)
|
|
938
|
+
except asyncio.TimeoutError:
|
|
939
|
+
for task in pump_tasks:
|
|
940
|
+
if not task.done():
|
|
941
|
+
task.cancel()
|
|
942
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
943
|
+
await task
|
|
944
|
+
|
|
945
|
+
await self._drain_stream(process.stdout, stdout_lines)
|
|
946
|
+
await self._drain_stream(process.stderr, stderr_lines)
|
|
947
|
+
|
|
948
|
+
# Build final output
|
|
949
|
+
duration_ms = (asyncio.get_running_loop().time() - start) * 1000.0
|
|
950
|
+
output = self._build_final_output(
|
|
951
|
+
effective_command,
|
|
952
|
+
stdout_lines,
|
|
953
|
+
stderr_lines,
|
|
954
|
+
process.returncode or 0,
|
|
955
|
+
duration_ms,
|
|
956
|
+
timeout_ms,
|
|
957
|
+
timeout_seconds,
|
|
958
|
+
timed_out,
|
|
959
|
+
sandbox_requested,
|
|
960
|
+
input_data.command,
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
yield ToolResult(
|
|
964
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
except (OSError, RuntimeError, ValueError, asyncio.CancelledError) as e:
|
|
968
|
+
if isinstance(e, asyncio.CancelledError):
|
|
969
|
+
raise # Re-raise cancellation
|
|
970
|
+
logger.warning(
|
|
971
|
+
"[bash_tool] Error executing command: %s: %s",
|
|
972
|
+
type(e).__name__, e,
|
|
973
|
+
extra={"command": effective_command},
|
|
974
|
+
)
|
|
975
|
+
error_output = self._create_error_output(
|
|
976
|
+
effective_command, f"Error executing command: {str(e)}", sandbox_requested
|
|
977
|
+
)
|
|
978
|
+
yield ToolResult(
|
|
979
|
+
data=error_output,
|
|
980
|
+
result_for_assistant=self.render_result_for_assistant(error_output),
|
|
981
|
+
)
|
|
982
|
+
finally:
|
|
983
|
+
self._current_is_read_only = previous_read_only
|
|
984
|
+
if sandbox_cleanup:
|
|
985
|
+
with contextlib.suppress(Exception):
|
|
986
|
+
sandbox_cleanup()
|
|
987
|
+
|
|
988
|
+
async def _force_kill_process(
|
|
989
|
+
self, process: asyncio.subprocess.Process, grace_seconds: float = KILL_GRACE_SECONDS
|
|
990
|
+
) -> None:
|
|
991
|
+
"""Attempt to terminate a process group and avoid hanging waits."""
|
|
992
|
+
if process.returncode is not None:
|
|
993
|
+
return
|
|
994
|
+
|
|
995
|
+
def _terminate() -> None:
|
|
996
|
+
if hasattr(os, "killpg"):
|
|
997
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
998
|
+
else:
|
|
999
|
+
process.terminate()
|
|
1000
|
+
|
|
1001
|
+
def _kill() -> None:
|
|
1002
|
+
if hasattr(os, "killpg"):
|
|
1003
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
1004
|
+
else:
|
|
1005
|
+
process.kill()
|
|
1006
|
+
|
|
1007
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
1008
|
+
_terminate()
|
|
1009
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
1010
|
+
await asyncio.wait_for(process.wait(), timeout=grace_seconds)
|
|
1011
|
+
return
|
|
1012
|
+
|
|
1013
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
1014
|
+
_kill()
|
|
1015
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
1016
|
+
await asyncio.wait_for(process.wait(), timeout=grace_seconds)
|