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: