| 232 | |
| 233 | |
| 234 | def _bash_call(tool_input: dict[str, Any], context: ToolContext) -> ToolResult: |
| 235 | command = tool_input["command"] |
| 236 | if not isinstance(command, str) or not command.strip(): |
| 237 | raise ToolInputError("command must be a non-empty string") |
| 238 | if "\x00" in command: |
| 239 | raise ToolInputError("command contains NUL byte") |
| 240 | |
| 241 | # Defense-in-depth: block obviously dangerous commands even when called |
| 242 | # directly (bypassing the registry's check_permissions flow). |
| 243 | for pat in _HARDCODED_DANGEROUS_PATTERNS: |
| 244 | if pat.search(command): |
| 245 | raise ToolPermissionError("refusing to run potentially dangerous command") |
| 246 | |
| 247 | explicit_cwd = tool_input.get("cwd") |
| 248 | if explicit_cwd is not None: |
| 249 | if not isinstance(explicit_cwd, str) or not explicit_cwd.startswith("/"): |
| 250 | raise ToolInputError("cwd must be an absolute path when provided") |
| 251 | cwd = context.ensure_allowed_path(explicit_cwd) |
| 252 | else: |
| 253 | cwd = context.cwd or context.workspace_root |
| 254 | |
| 255 | # ``run_in_background: true`` detaches the command so the agent can keep |
| 256 | # coordinating while a long-running job (dev server, build, long test |
| 257 | # suite, ...) makes progress. Mirrors |
| 258 | # ``typescript/src/tools/BashTool/BashTool.tsx`` ``spawnBackgroundTask`` |
| 259 | # behaviour: we return immediately with a task id and let the model poll |
| 260 | # the output via ``TaskOutput``. |
| 261 | if tool_input.get("run_in_background"): |
| 262 | bg_output = spawn_background_bash( |
| 263 | command=command, |
| 264 | cwd=cwd, |
| 265 | description=tool_input.get("description"), |
| 266 | context=context, |
| 267 | ) |
| 268 | return ToolResult(name=BASH_TOOL_NAME, output=bg_output) |
| 269 | |
| 270 | cd_target = _try_extract_cd(command) |
| 271 | if ( |
| 272 | cd_target is not None |
| 273 | and command.strip().startswith("cd ") |
| 274 | and len(command.strip().splitlines()) == 1 |
| 275 | ): |
| 276 | if not cd_target.is_absolute(): |
| 277 | next_dir = (cwd / cd_target).expanduser().resolve() |
| 278 | else: |
| 279 | next_dir = cd_target.expanduser().resolve() |
| 280 | next_dir = context.ensure_allowed_path(next_dir) |
| 281 | if not next_dir.exists() or not next_dir.is_dir(): |
| 282 | return ToolResult( |
| 283 | name=BASH_TOOL_NAME, |
| 284 | output={"error": f"directory does not exist: {next_dir}"}, |
| 285 | is_error=True, |
| 286 | ) |
| 287 | context.cwd = next_dir |
| 288 | return ToolResult( |
| 289 | name=BASH_TOOL_NAME, |
| 290 | output={"cwd": str(context.cwd), "stdout": "", "stderr": ""}, |
| 291 | ) |