diff --git a/src/copaw/agents/react_agent.py b/src/copaw/agents/react_agent.py index b9b9ed2fa..907247397 100644 --- a/src/copaw/agents/react_agent.py +++ b/src/copaw/agents/react_agent.py @@ -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 @@ -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", @@ -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) @@ -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}") diff --git a/src/copaw/agents/tools/shell.py b/src/copaw/agents/tools/shell.py index dc1a4f111..759a3f480 100644 --- a/src/copaw/agents/tools/shell.py +++ b/src/copaw/agents/tools/shell.py @@ -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: @@ -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 , and @@ -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`: @@ -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": @@ -170,7 +184,7 @@ async def execute_shell_command( cmd, str(working_dir), timeout, - env, + exec_env, ) else: proc = await asyncio.create_subprocess_shell( @@ -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: