Bug 1: Assistant text content was silently dropped when the LLM response included both content and tool calls (e.g. finish with a summary). Now content is displayed before tool call execution regardless. Bug 2: Shell redirect operators (>, >>, <<) allowed bypassing file-write permissions when the base command (e.g. cat) was in the allowed list. Redirects now require explicit user approval in permissions, and the shell tool itself blocks them as defense-in-depth. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
130 lines
4.4 KiB
Python
130 lines
4.4 KiB
Python
"""Shell tool: run_command."""
|
|
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.models.tool_call import ToolResult, ToolResultStatus
|
|
from app.tools.base import BaseTool
|
|
|
|
_DEFAULT_TIMEOUT = 30
|
|
|
|
# Detect shell redirects that write to files (>, >>, heredocs)
|
|
_WRITE_REDIRECT_PATTERN = re.compile(r"(?:>\s*\S|>>|<<)")
|
|
|
|
|
|
class RunCommandParams(BaseModel):
|
|
"""Parameters for the run_command tool."""
|
|
|
|
command: str = Field(description="Shell command to execute")
|
|
timeout: int | None = Field(default=None, description="Timeout in seconds (default: 30)")
|
|
|
|
|
|
class RunCommandTool(BaseTool):
|
|
"""Execute a shell command within the workspace."""
|
|
|
|
name = "run_command"
|
|
description = (
|
|
"Run a shell command in the workspace directory. "
|
|
"Only allowed commands may be executed; dangerous commands are blocked."
|
|
)
|
|
params_model = RunCommandParams
|
|
|
|
def execute(self, *, tool_call_id: str, command: str, timeout: int | None = None, **kwargs: Any) -> ToolResult:
|
|
shell_config = self.config.tools.shell
|
|
effective_timeout = timeout if timeout is not None else _DEFAULT_TIMEOUT
|
|
|
|
# Deny check: prefix match against full command string
|
|
for denied in shell_config.denied_commands:
|
|
if command.startswith(denied):
|
|
return ToolResult(
|
|
tool_call_id=tool_call_id,
|
|
tool_name=self.name,
|
|
status=ToolResultStatus.ERROR,
|
|
error=f"Command denied: matches blocked prefix '{denied}'",
|
|
)
|
|
|
|
# Defense-in-depth: flag file-write redirects in tool result
|
|
if _WRITE_REDIRECT_PATTERN.search(command):
|
|
return ToolResult(
|
|
tool_call_id=tool_call_id,
|
|
tool_name=self.name,
|
|
status=ToolResultStatus.ERROR,
|
|
error=(
|
|
f"Command contains file-write redirect (>, >>, or <<) "
|
|
f"which bypasses file-write permissions. Use write_file instead."
|
|
),
|
|
)
|
|
|
|
# Allow check: first token must be in allowed_commands
|
|
try:
|
|
tokens = shlex.split(command)
|
|
except ValueError as exc:
|
|
return ToolResult(
|
|
tool_call_id=tool_call_id,
|
|
tool_name=self.name,
|
|
status=ToolResultStatus.ERROR,
|
|
error=f"Failed to parse command: {exc}",
|
|
)
|
|
|
|
if not tokens:
|
|
return ToolResult(
|
|
tool_call_id=tool_call_id,
|
|
tool_name=self.name,
|
|
status=ToolResultStatus.ERROR,
|
|
error="Empty command",
|
|
)
|
|
|
|
base_cmd = tokens[0]
|
|
if shell_config.allowed_commands and base_cmd not in shell_config.allowed_commands:
|
|
return ToolResult(
|
|
tool_call_id=tool_call_id,
|
|
tool_name=self.name,
|
|
status=ToolResultStatus.ERROR,
|
|
error=f"Command '{base_cmd}' is not in the allowed commands list",
|
|
)
|
|
|
|
# Execute
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
shell=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=effective_timeout,
|
|
cwd=self.workspace_root,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return ToolResult(
|
|
tool_call_id=tool_call_id,
|
|
tool_name=self.name,
|
|
status=ToolResultStatus.ERROR,
|
|
error=f"Command timed out after {effective_timeout}s",
|
|
)
|
|
except OSError as exc:
|
|
return ToolResult(
|
|
tool_call_id=tool_call_id,
|
|
tool_name=self.name,
|
|
status=ToolResultStatus.ERROR,
|
|
error=f"Failed to execute command: {exc}",
|
|
)
|
|
|
|
# Combine output and truncate
|
|
output = result.stdout + result.stderr
|
|
max_bytes = shell_config.max_output_bytes
|
|
if len(output.encode("utf-8", errors="replace")) > max_bytes:
|
|
output = output[:max_bytes] + "\n... (output truncated)"
|
|
|
|
if result.returncode != 0:
|
|
output = f"Exit code: {result.returncode}\n{output}"
|
|
|
|
return ToolResult(
|
|
tool_call_id=tool_call_id,
|
|
tool_name=self.name,
|
|
status=ToolResultStatus.SUCCESS,
|
|
output=output,
|
|
)
|