fix: display assistant content with finish tool call + block shell write redirects
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>
This commit is contained in:
@@ -216,10 +216,12 @@ class AgentLoop:
|
|||||||
# Successful response — reset streak
|
# Successful response — reset streak
|
||||||
reasoning_only_streak = 0
|
reasoning_only_streak = 0
|
||||||
|
|
||||||
|
# Display any assistant text content (even if tool calls follow)
|
||||||
|
if self._display and assistant_msg.content:
|
||||||
|
self._display.write_assistant_message(assistant_msg.content)
|
||||||
|
|
||||||
# No tool calls → task complete (plain text response)
|
# No tool calls → task complete (plain text response)
|
||||||
if not assistant_msg.tool_calls:
|
if not assistant_msg.tool_calls:
|
||||||
if self._display and assistant_msg.content:
|
|
||||||
self._display.write_assistant_message(assistant_msg.content)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
# Execute tool calls
|
# Execute tool calls
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ logger = logging.getLogger(__name__)
|
|||||||
# Type alias for the async prompt callback
|
# Type alias for the async prompt callback
|
||||||
PromptCallback = Callable[[str, str], Awaitable[bool]]
|
PromptCallback = Callable[[str, str], Awaitable[bool]]
|
||||||
|
|
||||||
|
# Detect shell redirects that write to files (>, >>, heredocs)
|
||||||
|
_WRITE_REDIRECT_PATTERN = re.compile(r"(?:>\s*\S|>>|<<)")
|
||||||
|
|
||||||
|
|
||||||
class PermissionDenied(Exception):
|
class PermissionDenied(Exception):
|
||||||
"""Raised when a tool is denied execution by permissions policy."""
|
"""Raised when a tool is denied execution by permissions policy."""
|
||||||
@@ -104,6 +108,11 @@ class PermissionsService:
|
|||||||
logger.info("Shell command '%s' matches denied prefix '%s'", cmd, denied)
|
logger.info("Shell command '%s' matches denied prefix '%s'", cmd, denied)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Detect shell redirects that write to files — require approval
|
||||||
|
if _WRITE_REDIRECT_PATTERN.search(cmd):
|
||||||
|
logger.info("Shell command '%s' contains file-write redirect — requiring approval", cmd)
|
||||||
|
return None # fall through to user prompt
|
||||||
|
|
||||||
# Allowed commands: base executable match
|
# Allowed commands: base executable match
|
||||||
if shell_config.allowed_commands:
|
if shell_config.allowed_commands:
|
||||||
if base_cmd in shell_config.allowed_commands:
|
if base_cmd in shell_config.allowed_commands:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Shell tool: run_command."""
|
"""Shell tool: run_command."""
|
||||||
|
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -11,6 +12,9 @@ from app.tools.base import BaseTool
|
|||||||
|
|
||||||
_DEFAULT_TIMEOUT = 30
|
_DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Detect shell redirects that write to files (>, >>, heredocs)
|
||||||
|
_WRITE_REDIRECT_PATTERN = re.compile(r"(?:>\s*\S|>>|<<)")
|
||||||
|
|
||||||
|
|
||||||
class RunCommandParams(BaseModel):
|
class RunCommandParams(BaseModel):
|
||||||
"""Parameters for the run_command tool."""
|
"""Parameters for the run_command tool."""
|
||||||
@@ -43,6 +47,18 @@ class RunCommandTool(BaseTool):
|
|||||||
error=f"Command denied: matches blocked prefix '{denied}'",
|
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
|
# Allow check: first token must be in allowed_commands
|
||||||
try:
|
try:
|
||||||
tokens = shlex.split(command)
|
tokens = shlex.split(command)
|
||||||
|
|||||||
Reference in New Issue
Block a user