Skip to content

Commit ee7b2ad

Browse files
chore: Update utils.py support chaining (#3261)
Co-authored-by: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com> Co-authored-by: Wendong-Fan <w3ndong.fan@gmail.com>
1 parent a4765fc commit ee7b2ad

File tree

10 files changed

+534
-531
lines changed

10 files changed

+534
-531
lines changed

.github/ISSUE_TEMPLATE/bug_report.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ body:
2626
attributes:
2727
label: What version of camel are you using?
2828
description: Run command `python3 -c 'print(__import__("camel").__version__)'` in your shell and paste the output here.
29-
placeholder: E.g., 0.2.76a14
29+
placeholder: E.g., 0.2.76
3030
validations:
3131
required: true
3232

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ tags
415415

416416
# Camel
417417
logs/
418+
tool_cache/
418419

419420
# Download Configuration
420421
cookies.txt

camel/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from camel.logger import disable_logging, enable_logging, set_log_level
1616

17-
__version__ = '0.2.76a14'
17+
__version__ = '0.2.76'
1818

1919
__all__ = [
2020
'__version__',

camel/data_collectors/alpaca_collector.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,25 @@ def convert(self) -> Dict[str, Any]:
7070
if not history:
7171
raise ValueError("No data collected.")
7272

73-
# Validate and process history
74-
if len(history) == 3 and history[0].role == "system":
75-
history = history[1:] # Ignore the system message.
76-
elif len(history) != 2:
73+
# Filter out system and tool-related messages
74+
# Keep only user and final assistant messages
75+
filtered_history = []
76+
for msg in history:
77+
if msg.role == "user":
78+
filtered_history.append(msg)
79+
elif msg.role == "assistant" and msg.message:
80+
# Keep assistant messages with actual content
81+
# (skip empty ones that only contain tool calls)
82+
filtered_history.append(msg)
83+
84+
# Validate filtered history
85+
if len(filtered_history) != 2:
7786
raise ValueError(
7887
f"AlpacaDataCollector only supports one message pair, but "
79-
f"got {len(history)}"
88+
f"got {len(filtered_history)} after filtering tool messages"
8089
)
8190

82-
input_message, output_message = history
91+
input_message, output_message = filtered_history
8392
instruction = (
8493
self.system_message.content if self.system_message else ""
8594
) + str(input_message.message)

camel/toolkits/terminal_toolkit/utils.py

Lines changed: 106 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import os
1616
import platform
1717
import re
18-
import shlex
1918
import shutil
2019
import subprocess
2120
import sys
@@ -27,38 +26,99 @@
2726
logger = get_logger(__name__)
2827

2928

30-
def contains_command_chaining(command: str) -> bool:
31-
r"""Check if command contains chaining operators that could be used to
32-
bypass security.
29+
def check_command_safety(
30+
command: str,
31+
allowed_commands: Optional[Set[str]] = None,
32+
) -> Tuple[bool, str]:
33+
r"""Check if a command (potentially with chaining) is safe to execute.
34+
35+
Args:
36+
command (str): The command string to check
37+
allowed_commands (Optional[Set[str]]): Set of allowed commands
38+
(whitelist mode)
39+
40+
Returns:
41+
Tuple[bool, str]: (is_safe, reason)
3342
"""
34-
# Pattern to match command chaining operators: ;, &&, ||, |
35-
# But exclude cases where they are inside quotes or escaped
36-
chaining_pattern = r'''
37-
(?<!\\) # Not preceded by backslash (not escaped)
38-
(?: # Group for alternation
39-
; # Semicolon
40-
| # OR
41-
\|\| # Logical OR
42-
| # OR
43-
&& # Logical AND
44-
| # OR
45-
(?<!\|) # Not preceded by pipe (to avoid matching ||)
46-
\| # Single pipe
47-
(?!\|) # Not followed by pipe (to avoid matching ||)
48-
)
49-
(?= # Positive lookahead
50-
(?: # Group
51-
[^"'] # Not a quote
52-
| # OR
53-
"[^"]*" # Content in double quotes
54-
| # OR
55-
'[^']*' # Content in single quotes
56-
)* # Zero or more times
57-
$ # End of string
58-
)
59-
'''
43+
if not command.strip():
44+
return False, "Empty command is not allowed."
6045

61-
return bool(re.search(chaining_pattern, command, re.VERBOSE))
46+
# Dangerous commands list - including ALL rm operations
47+
dangerous_commands = [
48+
# System administration
49+
'sudo',
50+
'su',
51+
'reboot',
52+
'shutdown',
53+
'halt',
54+
'poweroff',
55+
'init',
56+
# File system manipulation
57+
'rm',
58+
'chown',
59+
'chgrp',
60+
'umount',
61+
'mount',
62+
# Disk operations
63+
'dd',
64+
'mkfs',
65+
'fdisk',
66+
'parted',
67+
'fsck',
68+
'mkswap',
69+
'swapon',
70+
'swapoff',
71+
# Process management
72+
'service',
73+
'systemctl',
74+
'systemd',
75+
# Network configuration
76+
'iptables',
77+
'ip6tables',
78+
'ifconfig',
79+
'route',
80+
'iptables-save',
81+
# Cron and scheduling
82+
'crontab',
83+
'at',
84+
'batch',
85+
# User management
86+
'useradd',
87+
'userdel',
88+
'usermod',
89+
'passwd',
90+
'chpasswd',
91+
'newgrp',
92+
# Kernel modules
93+
'modprobe',
94+
'rmmod',
95+
'insmod',
96+
'lsmod',
97+
]
98+
99+
# Remove quoted strings to avoid false positives
100+
clean_command = re.sub(r'''["'][^"']*["']''', ' ', command)
101+
102+
# If whitelist mode, check ALL commands against the whitelist
103+
if allowed_commands is not None:
104+
# Extract all command words (at start or after operators)
105+
cmd_pattern = r'(?:^|;|\||&&)\s*\b([a-zA-Z_/][\w\-/]*)'
106+
found_commands = re.findall(cmd_pattern, clean_command, re.IGNORECASE)
107+
for cmd in found_commands:
108+
if cmd.lower() not in allowed_commands:
109+
return (
110+
False,
111+
f"Command '{cmd}' is not in the allowed commands list.",
112+
)
113+
return True, ""
114+
115+
# Check for dangerous commands
116+
for cmd in dangerous_commands:
117+
pattern = rf'(?:^|;|\||&&)\s*\b{re.escape(cmd)}\b'
118+
if re.search(pattern, clean_command, re.IGNORECASE):
119+
return False, f"Command '{cmd}' is blocked for safety."
120+
121+
return True, ""
62122

63123

64124
def sanitize_command(
@@ -80,133 +140,25 @@ def sanitize_command(
80140
Returns:
81141
Tuple[bool, str]: (is_safe, message_or_command)
82142
"""
83-
# Apply security checks to both backends - security should be consistent
84143
if not safe_mode:
85144
return True, command # Skip all checks if safe_mode is disabled
86145

87-
# First check for command chaining and pipes
88-
if contains_command_chaining(command):
89-
return (
90-
False,
91-
"Command chaining (;, &&, ||, |) is not allowed "
92-
"for security reasons.",
93-
)
94-
95-
parts = shlex.split(command)
96-
if not parts:
97-
return False, "Empty command is not allowed."
98-
base_cmd = parts[0].lower()
99-
100-
# If whitelist is defined, only allow whitelisted commands
101-
if allowed_commands is not None:
102-
if base_cmd not in allowed_commands:
103-
return (
104-
False,
105-
f"Command '{base_cmd}' is not in the allowed commands list.",
146+
# Use safety checker
147+
is_safe, reason = check_command_safety(command, allowed_commands)
148+
if not is_safe:
149+
return False, reason
150+
151+
# Additional check for Docker backend: prevent cd outside working directory
152+
if not use_docker_backend and working_dir and 'cd ' in command:
153+
# Extract cd commands and check their targets
154+
cd_pattern = r'\bcd\s+([^\s;|&]+)'
155+
for match in re.finditer(cd_pattern, command):
156+
target_path = match.group(1).strip('\'"')
157+
target_dir = os.path.abspath(
158+
os.path.join(working_dir, target_path)
106159
)
107-
# If command is whitelisted, skip the dangerous commands check
108-
# but still apply other safety checks
109-
else:
110-
# Block dangerous commands (only when no whitelist is defined)
111-
dangerous_commands = [
112-
# System administration
113-
'sudo',
114-
'su',
115-
'reboot',
116-
'shutdown',
117-
'halt',
118-
'poweroff',
119-
'init',
120-
# File system manipulation
121-
'rm',
122-
'mv',
123-
'chmod',
124-
'chown',
125-
'chgrp',
126-
'umount',
127-
'mount',
128-
# Disk operations
129-
'dd',
130-
'mkfs',
131-
'fdisk',
132-
'parted',
133-
'fsck',
134-
'mkswap',
135-
'swapon',
136-
'swapoff',
137-
# Process management
138-
'kill',
139-
'killall',
140-
'pkill',
141-
'service',
142-
'systemctl',
143-
'systemd',
144-
# Network configuration
145-
'iptables',
146-
'ip6tables',
147-
'ifconfig',
148-
'route',
149-
'iptables-save',
150-
# Cron and scheduling
151-
'crontab',
152-
'at',
153-
'batch',
154-
# User management
155-
'useradd',
156-
'userdel',
157-
'usermod',
158-
'passwd',
159-
'chpasswd',
160-
'newgrp',
161-
# Kernel modules
162-
'modprobe',
163-
'rmmod',
164-
'insmod',
165-
'lsmod',
166-
# System information that could leak sensitive data
167-
'dmesg',
168-
'last',
169-
'lastlog',
170-
'who',
171-
'w',
172-
]
173-
if base_cmd in dangerous_commands:
174-
# Special handling for rm command - use regex for precise checking
175-
if base_cmd == 'rm':
176-
# Check for dangerous rm options using regex
177-
dangerous_rm_pattern = (
178-
r'\s-[^-\s]*[rf][^-\s]*\s|\s--force\s|'
179-
r'\s--recursive\s|\s-rf\s|\s-fr\s'
180-
)
181-
if re.search(dangerous_rm_pattern, command, re.IGNORECASE):
182-
return (
183-
False,
184-
f"Command '{base_cmd}' with forceful or "
185-
f"recursive options is blocked for safety.",
186-
)
187-
# Also block rm without any target (could be dangerous)
188-
if len(parts) < 2:
189-
return (
190-
False,
191-
"rm command requires target "
192-
"file/directory specification.",
193-
)
194-
else:
195-
return False, f"Command '{base_cmd}' is blocked for safety."
196-
197-
# For local backend only: prevent changing
198-
# directory outside the workspace
199-
# Docker containers are already sandboxed,
200-
# so this check is not needed there
201-
if (
202-
not use_docker_backend
203-
and base_cmd == 'cd'
204-
and len(parts) > 1
205-
and working_dir
206-
):
207-
target_dir = os.path.abspath(os.path.join(working_dir, parts[1]))
208-
if not target_dir.startswith(working_dir):
209-
return False, "Cannot 'cd' outside of the working directory."
160+
if not target_dir.startswith(working_dir):
161+
return False, "Cannot 'cd' outside of the working directory."
210162

211163
return True, command
212164

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
project = 'CAMEL'
2828
copyright = '2024, CAMEL-AI.org'
2929
author = 'CAMEL-AI.org'
30-
release = '0.2.76a14'
30+
release = '0.2.76'
3131

3232
html_favicon = (
3333
'https://raw.githubusercontent.com/camel-ai/camel/master/misc/favicon.png'

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "camel-ai"
7-
version = "0.2.76a14"
7+
version = "0.2.76"
88
description = "Communicative Agents for AI Society Study"
99
authors = [{ name = "CAMEL-AI.org" }]
1010
requires-python = ">=3.10,<3.13"

test/agents/test_chat_agent.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,7 @@ def test_tool_calling_sync(step_call_count=3):
880880
system_message=system_message,
881881
model=model,
882882
tools=MathToolkit().get_tools(),
883+
enable_tool_output_cache=False,
883884
)
884885

885886
ref_funcs = MathToolkit().get_tools()
@@ -1052,6 +1053,7 @@ async def test_tool_calling_math_async(step_call_count=3):
10521053
system_message=system_message,
10531054
model=model,
10541055
tools=math_funcs,
1056+
enable_tool_output_cache=False,
10551057
)
10561058

10571059
ref_funcs = math_funcs
@@ -1213,6 +1215,7 @@ def mock_run_tool_calling_async(*args, **kwargs):
12131215
system_message=system_message,
12141216
model=model,
12151217
tools=[FunctionTool(async_sleep)],
1218+
enable_tool_output_cache=False,
12161219
)
12171220

12181221
assert len(agent.tool_dict) == 1

0 commit comments

Comments
 (0)