Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 64 additions & 4 deletions src/copaw/agents/react_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
This module provides the main CoPawAgent class built on ReActAgent,
with integrated tools, skills, and memory management.
"""

import asyncio
import functools
import logging
import os
import re
import sys
from typing import Any, List, Literal, Optional, Type

from agentscope.agent import ReActAgent
Expand Down Expand Up @@ -153,6 +157,58 @@ def __init__(
# Register hooks
self._register_hooks()

def _wrap_shell_tool(self, tool_func):
"""Wrap shell command tool to inject request_context as environment variables.

Security: COPAW_ prefix + readonly bash variables prevent tampering.
"""

@functools.wraps(tool_func)
async def wrapper(command: str, **kwargs):
# Block dangerous env manipulation commands
for pattern in [
r"\bexport\s+\w+\s*=",
r"\bunset\s+\w+",
r"\bdeclare\s+-x\s+\w+",
r"\benv\s+\w+=",
]:
if re.search(pattern, command):
logger.warning("Blocked env manipulation: %s", command[:100])
from agentscope.tool import ToolResponse
from agentscope.message import TextBlock

return ToolResponse(
content=[
TextBlock(
type="text",
text=f"❌ Environment manipulation blocked\n工具环境变量篡改已被阻止\nCommand: {command[:80]}",
)
]
)

# Inject context as readonly environment variables
env = kwargs.pop("env", os.environ.copy())
readonly_vars = []
for key, value in self._request_context.items():
safe_key = f"COPAW_{key.upper().replace('-', '_')}"
if value is not None:
env[safe_key] = str(value)
readonly_vars.append(safe_key)

# Wrap with readonly declarations to prevent tampering
if (
readonly_vars
and not command.startswith("bash -c ")
and sys.platform != "win32"
):
escaped_cmd = command.replace('"', '\\"')
readonly_decl = "; ".join(f"readonly {v}" for v in readonly_vars)
command = f'bash -c "{readonly_decl}; {escaped_cmd}"'

return await tool_func(command, env=env, **kwargs)

return wrapper

def _create_toolkit(
self,
namesake_strategy: NamesakeStrategy = "skip",
Expand Down Expand Up @@ -195,8 +251,14 @@ def _create_toolkit(
for tool_name, tool_func in tool_functions.items():
# If tool not in config, enable by default (backward compatibility)
if enabled_tools.get(tool_name, True):
# Wrap shell command tool with context injection
if tool_name == "execute_shell_command":
wrapped_func = self._wrap_shell_tool(tool_func)
else:
wrapped_func = tool_func

toolkit.register_tool_function(
tool_func,
wrapped_func,
namesake_strategy=namesake_strategy,
)
logger.debug("Registered tool: %s", tool_name)
Expand Down Expand Up @@ -515,9 +577,7 @@ async def reply(

# Check if message is a system command
last_msg = msg[-1] if isinstance(msg, list) else msg
query = (
last_msg.get_text_content() if isinstance(last_msg, Msg) else None
)
query = last_msg.get_text_content() if isinstance(last_msg, Msg) else None

if self.command_handler.is_command(query):
logger.info(f"Received command: {query}")
Expand Down
28 changes: 21 additions & 7 deletions src/copaw/agents/tools/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ def _execute_subprocess_sync(
except subprocess.TimeoutExpired:
pass

timeout_msg = f"Command execution exceeded the timeout of {timeout} seconds."
timeout_msg = (
f"Command execution exceeded the timeout of {timeout} seconds."
)
if stderr_str:
stderr_str = f"{stderr_str}\n{timeout_msg}"
else:
Expand All @@ -126,6 +128,7 @@ async def execute_shell_command(
command: str,
timeout: int = 60,
cwd: Optional[Path] = None,
env: Optional[dict] = None,
) -> ToolResponse:
"""Execute given command and return the return code, standard output and
error within <returncode></returncode>, <stdout></stdout> and
Expand All @@ -140,6 +143,10 @@ async def execute_shell_command(
cwd (`Optional[Path]`, defaults to `None`):
The working directory for the command execution.
If None, defaults to WORKING_DIR.
env (`Optional[dict]`, defaults to `None`):
Environment variables for the subprocess. If provided, these
variables will be merged with the current environment. If None,
uses the current process environment.

Returns:
`ToolResponse`:
Expand All @@ -153,14 +160,21 @@ async def execute_shell_command(
# Set working directory
working_dir = cwd if cwd is not None else WORKING_DIR

# Prepare environment variables
# If env is provided, merge it with current environment;
# otherwise use current process environment
exec_env = os.environ.copy()
if env is not None:
# Start with current environment and update with provided vars
exec_env.update(env)

# Ensure the venv Python is on PATH for subprocesses
env = os.environ.copy()
python_bin_dir = str(Path(sys.executable).parent)
existing_path = env.get("PATH", "")
existing_path = exec_env.get("PATH", "")
if existing_path:
env["PATH"] = python_bin_dir + os.pathsep + existing_path
exec_env["PATH"] = python_bin_dir + os.pathsep + existing_path
else:
env["PATH"] = python_bin_dir
exec_env["PATH"] = python_bin_dir

try:
if sys.platform == "win32":
Expand All @@ -170,7 +184,7 @@ async def execute_shell_command(
cmd,
str(working_dir),
timeout,
env,
exec_env,
)
else:
proc = await asyncio.create_subprocess_shell(
Expand All @@ -179,7 +193,7 @@ async def execute_shell_command(
stderr=asyncio.subprocess.PIPE,
bufsize=0,
cwd=str(working_dir),
env=env,
env=exec_env,
)

try:
Expand Down
Loading