feat: 重写沙箱 + 完整测试套件 (192 tests)

沙箱 v2 - 多层隔离:
- AST 静态分析: 30+ 危险模式检测
- 子进程隔离: 独立进程执行 + 资源限制
- 网络阻断: socket 模块替换
- 模块白名单: 30 个安全模块
- 文件系统限制: 临时目录隔离
- SandboxResult 数据类 + 执行历史

测试套件 (8 个模块, 192 个用例):
- test_sandbox.py:     AST检查(47) + 沙箱执行(15) + 安全拒绝(20) + 超时(2) + 模式对比(3)
- test_conversation.py: 响应解析(13) + 思考标记(5) + 工具提取(10)
- test_config.py:       配置加载/保存/缓存/损坏处理(8)
- test_history.py:      历史CRUD/持久化/边界(12)
- test_engines.py:      引擎管理/加载/切换(14)
- test_plugins.py:      code_editor(10) + code_search(7) + git_tools(4) + tool_manager(7)
- test_integration.py:  沙箱+对话(2) + 历史+对话(2) + 安全链(3) + 提示词(1)
This commit is contained in:
Xiaoli Dev committed 2026-04-25 11:43:47 +08:00
1 parent b1b2097311
commit ff53a89754
10 files changed
+2205 -72

No files matched your search

+1
View File
@@ -0,0 +1 @@
# xiaoli-cli test suite
+183
View File
@@ -0,0 +1,183 @@
"""共享测试 fixtures"""
import os
import sys
import json
import tempfile
import shutil
import pytest
# 确保项目根目录在 sys.path 中
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PROJECT_ROOT)
@pytest.fixture
def tmp_dir():
"""创建临时目录,测试后清理"""
d = tempfile.mkdtemp(prefix='xiaoli_test_')
yield d
shutil.rmtree(d, ignore_errors=True)
@pytest.fixture
def sample_python_code():
"""安全的示例 Python 代码"""
return '''
import json
import math
result = math.sqrt(144)
data = {"value": result, "squared": result ** 2}
print(json.dumps(data))
'''
@pytest.fixture
def dangerous_codes():
"""各种危险代码样本"""
return {
'os_import': 'import os\nos.system("rm -rf /")',
'subprocess': 'import subprocess\nsubprocess.run(["ls"])',
'exec_call': 'exec("import os")',
'eval_call': 'eval("__import__(\'os\')")',
'dunder': 'x = (1).__class__.__bases__[0].__subclasses__()',
'open_file': 'f = open("/etc/passwd")',
'getattr_bypass': 'getattr(__builtins__, "exec")',
'socket': 'import socket\ns = socket.socket()',
'shutil': 'import shutil\nshutil.rmtree("/")',
'compile': 'compile("import os", "<string>", "exec")',
'globals': 'globals()["__builtins__"]',
'breakpoint': 'breakpoint()',
'del_stmt': 'del some_var',
'global_stmt': 'global x',
}
@pytest.fixture
def safe_codes():
"""各种安全代码样本"""
return {
'math': 'import math\nresult = math.pi',
'json': 'import json\nresult = json.dumps({"a": 1})',
're': 'import re\nresult = re.findall(r"\\d+", "abc123")',
'datetime': 'from datetime import datetime\nresult = datetime.now().year',
'collections': 'from collections import Counter\nresult = Counter("aabbc")',
'list_comp': 'result = [x**2 for x in range(10)]',
'function': 'def add(a, b): return a + b\nresult = add(3, 4)',
'string_ops': 'result = "hello world".upper().split()',
'sorting': 'result = sorted([3,1,2], reverse=True)',
'fizzbuzz': 'result = ["fizz"*(i%3==0)+"buzz"*(i%5==0) or str(i) for i in range(1,16)]',
}
@pytest.fixture
def config_file(tmp_dir):
"""创建临时配置文件"""
config = {
"system": {
"default_engine": "ollama",
"max_history": 100,
"code_execution_timeout": 10,
},
"api": {
"engines": {
"ollama": {"base_url": "http://localhost:11434"},
"mimo": {"api_key": "test-key", "base_url": "http://test.com"},
}
},
"plugins": {},
}
path = os.path.join(tmp_dir, "config.json")
with open(path, 'w') as f:
json.dump(config, f)
return path
@pytest.fixture
def history_dir(tmp_dir):
"""创建临时历史目录"""
d = os.path.join(tmp_dir, "chat_history")
os.makedirs(d)
return d
@pytest.fixture
def plugins_dir(tmp_dir):
"""创建带示例插件的目录"""
d = os.path.join(tmp_dir, "plugins")
os.makedirs(d)
# __init__.py
with open(os.path.join(d, "__init__.py"), 'w') as f:
f.write("")
# 简单插件
with open(os.path.join(d, "test_plugin.py"), 'w') as f:
f.write('''
class Plugin:
def __init__(self):
self.cli = None
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "test_tool",
"description": "A test tool",
"keywords": ["test"],
}
def get_mcp_definition(self):
return {
"name": "test_tool",
"description": "A test tool",
"inputSchema": {
"type": "object",
"properties": {
"operation": {"type": "string"},
},
"required": ["operation"]
}
}
def convert_mcp_args(self, arguments):
return arguments.get("operation", "")
def handle(self, args):
return f"test result: {args}"
''')
return d
@pytest.fixture
def skills_dir(tmp_dir):
"""创建带示例 skill 的目录"""
d = os.path.join(tmp_dir, "skills")
os.makedirs(d)
with open(os.path.join(d, "__init__.py"), 'w') as f:
f.write("")
with open(os.path.join(d, "test_skill.py"), 'w') as f:
f.write('''
class Skill:
def __init__(self):
self.cli = None
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "test_skill",
"description": "A test skill",
"keywords": ["test", "skill"],
}
def handle(self, args):
return f"skill result: {args}"
''')
return d
+137
View File
@@ -0,0 +1,137 @@
"""配置管理测试"""
import os
import sys
import json
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xiaoli import config as cfg
class TestConfig:
"""配置加载/保存测试"""
def setup_method(self):
"""重置缓存"""
cfg._cache = None
def test_default_config(self):
"""无配置文件时使用默认值"""
# 临时指向不存在的文件
original = cfg.CONFIG_FILE
cfg.CONFIG_FILE = '/tmp/nonexistent_config.json'
cfg._cache = None
result = cfg.load()
assert 'system' in result
assert result['system']['default_engine'] == 'ollama'
cfg.CONFIG_FILE = original
cfg._cache = None
def test_load_existing_config(self, config_file):
"""加载现有配置文件"""
original = cfg.CONFIG_FILE
cfg.CONFIG_FILE = config_file
cfg._cache = None
result = cfg.load()
assert result['system']['default_engine'] == 'ollama'
assert result['system']['max_history'] == 100
assert 'mimo' in result['api']['engines']
cfg.CONFIG_FILE = original
cfg._cache = None
def test_get_specific_key(self, config_file):
"""获取特定配置项"""
original = cfg.CONFIG_FILE
cfg.CONFIG_FILE = config_file
cfg._cache = None
assert cfg.get('default_engine') == 'ollama'
assert cfg.get('max_history') == 100
assert cfg.get('nonexistent', 'fallback') == 'fallback'
cfg.CONFIG_FILE = original
cfg._cache = None
def test_set_and_save(self, tmp_dir):
"""设置并保存配置"""
config_path = os.path.join(tmp_dir, "config.json")
original = cfg.CONFIG_FILE
cfg.CONFIG_FILE = config_path
cfg._cache = None
cfg.set('default_engine', 'mimo')
assert cfg.get('default_engine') == 'mimo'
# 验证文件持久化
with open(config_path, 'r') as f:
saved = json.load(f)
assert saved['system']['default_engine'] == 'mimo'
cfg.CONFIG_FILE = original
cfg._cache = None
def test_reload(self, config_file):
"""强制重新加载"""
original = cfg.CONFIG_FILE
cfg.CONFIG_FILE = config_file
cfg._cache = None
cfg.load()
assert cfg._cache is not None
reloaded = cfg.reload()
assert cfg._cache is not None
assert 'system' in reloaded
cfg.CONFIG_FILE = original
cfg._cache = None
def test_caching(self, config_file):
"""配置缓存机制"""
original = cfg.CONFIG_FILE
cfg.CONFIG_FILE = config_file
cfg._cache = None
r1 = cfg.load()
r2 = cfg.load()
assert r1 is r2 # 同一对象(缓存)
cfg.CONFIG_FILE = original
cfg._cache = None
def test_corrupted_json(self, tmp_dir):
"""损坏的 JSON 文件处理"""
config_path = os.path.join(tmp_dir, "config.json")
with open(config_path, 'w') as f:
f.write("{invalid json")
original = cfg.CONFIG_FILE
cfg.CONFIG_FILE = config_path
cfg._cache = None
result = cfg.load()
assert 'system' in result # 应回退到默认值
cfg.CONFIG_FILE = original
cfg._cache = None
def test_missing_system_section(self, tmp_dir):
"""缺少 system 节时的行为"""
config_path = os.path.join(tmp_dir, "config.json")
with open(config_path, 'w') as f:
json.dump({"api": {}}, f)
original = cfg.CONFIG_FILE
cfg.CONFIG_FILE = config_path
cfg._cache = None
assert cfg.get('default_engine') is None
assert cfg.get('default_engine', 'ollama') == 'ollama'
cfg.CONFIG_FILE = original
cfg._cache = None
+226
View File
@@ -0,0 +1,226 @@
"""对话响应解析器测试"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xiaoli.conversation import parse_response, process_thinking, process_code_blocks, extract_tool_calls
# ═══════════════════════════════════════════════
# 1. parse_response 测试
# ═══════════════════════════════════════════════
class TestParseResponse:
"""响应解析测试"""
def test_pure_text(self):
text, data = parse_response("hello world")
assert text == "hello world"
assert data is None
def test_empty_response(self):
text, data = parse_response("")
assert text == ""
assert data is None
def test_pure_json_dict(self):
resp = '{"action": "use_tool", "tool": "code_editor", "args": "read foo.py"}'
text, data = parse_response(resp)
assert text == ""
assert isinstance(data, dict)
assert data['action'] == 'use_tool'
assert data['tool'] == 'code_editor'
def test_pure_json_list(self):
resp = '[{"action": "use_tool", "tool": "t1", "args": "a1"}]'
text, data = parse_response(resp)
assert text == ""
assert isinstance(data, list)
def test_json_in_code_block(self):
resp = 'Here is the command:\n```json\n{"action": "use_tool", "tool": "git_tools", "args": "status"}\n```'
text, data = parse_response(resp)
assert 'command' in text.lower() or 'Here' in text
assert isinstance(data, dict)
assert data['tool'] == 'git_tools'
def test_text_before_json(self):
resp = 'I will edit the file.\n{"action": "use_tool", "tool": "code_editor", "args": "edit foo.py"}'
text, data = parse_response(resp)
assert 'edit' in text.lower()
assert isinstance(data, dict)
assert data['action'] == 'use_tool'
def test_continue_action(self):
resp = '{"action": "continue", "content": "more info"}'
text, data = parse_response(resp)
assert text == ""
assert isinstance(data, dict)
assert data['action'] == 'continue'
def test_inline_json_use_tool(self):
resp = 'Let me check. {"action":"use_tool","tool":"code_search","args":"find . test"}'
text, data = parse_response(resp)
assert isinstance(data, dict)
assert data['tool'] == 'code_search'
def test_inline_json_continue(self):
resp = 'Some text. {"action":"continue","content":"extra"}'
text, data = parse_response(resp)
assert isinstance(data, dict)
assert data['action'] == 'continue'
def test_multiple_json_per_line(self):
resp = '{"action":"use_tool","tool":"t1","args":"a1"}\n{"action":"use_tool","tool":"t2","args":"a2"}'
text, data = parse_response(resp)
# Should pick up at least one
assert data is not None
def test_invalid_json_ignored(self):
resp = 'This is not json {broken'
text, data = parse_response(resp)
assert text == resp
assert data is None
def test_tool_call_tag_format(self):
resp = '<tool_call>{"action":"use_tool","tool":"test","args":"arg"}'
text, data = parse_response(resp)
assert isinstance(data, dict)
assert data['tool'] == 'test'
def test_message_field(self):
resp = '{"message": "done processing"}'
text, data = parse_response(resp)
assert isinstance(data, dict)
assert data['message'] == 'done processing'
# ═══════════════════════════════════════════════
# 2. process_thinking 测试
# ═══════════════════════════════════════════════
class TestProcessThinking:
"""思考标记处理测试"""
def test_think_tag(self):
resp = '<think/>This is the reply'
result = process_thinking(resp)
assert result == 'This is the reply'
def test_think_tag_with_thinking(self):
resp = 'I am thinking deeply<think/>Here is my answer'
result = process_thinking(resp)
assert 'answer' in result
def test_no_think_tag(self):
resp = 'Just a normal response'
result = process_thinking(resp)
assert result == resp
def test_empty_after_think(self):
resp = '<think/>'
result = process_thinking(resp)
assert result == '' or result is not None
def test_custom_markers(self):
class FakeEngine:
thinking_start_marker = '<thinking>'
thinking_end_marker = '</thinking>'
resp = '<thinking>deep thought</thinking>The answer is 42'
result = process_thinking(resp, FakeEngine())
assert '42' in result
# ═══════════════════════════════════════════════
# 3. process_code_blocks 测试
# ═══════════════════════════════════════════════
class TestProcessCodeBlocks:
"""代码块处理测试"""
def test_triple_quote_block(self):
text = 'before\n"""\ncode here\n"""\nafter'
result = process_code_blocks(text)
assert 'before' in result
assert 'after' in result
def test_no_code_blocks(self):
text = 'plain text no blocks'
result = process_code_blocks(text)
assert result == text
# ═══════════════════════════════════════════════
# 4. extract_tool_calls 测试
# ═══════════════════════════════════════════════
class TestExtractToolCalls:
"""工具调用提取测试"""
def test_single_use_tool(self):
data = {"action": "use_tool", "tool": "code_editor", "args": "edit foo.py"}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['tool'] == 'code_editor'
def test_list_of_use_tool(self):
data = [
{"action": "use_tool", "tool": "t1", "args": "a1"},
{"action": "use_tool", "tool": "t2", "args": "a2"},
]
calls = extract_tool_calls(data)
assert len(calls) == 2
def test_simplified_format(self):
data = {"tool": "git_tools", "args": "status"}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['tool'] == 'git_tools'
def test_openai_function_call(self):
data = {"function_call": {"name": "code_editor", "arguments": '{"operation": "read"}'}}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['tool'] == 'code_editor'
def test_mcp_format(self):
data = {"name": "code_search", "arguments": {"operation": "find", "directory": "."}}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['tool'] == 'code_search'
def test_empty_data(self):
assert extract_tool_calls([]) == []
assert extract_tool_calls({}) == []
assert extract_tool_calls(None) == []
assert extract_tool_calls("string") == []
def test_non_tool_dict(self):
data = {"message": "just text", "continue": True}
calls = extract_tool_calls(data)
# continue action should not be extracted as tool call
assert len(calls) == 0
def test_mixed_list(self):
data = [
{"action": "use_tool", "tool": "t1", "args": "a1"},
{"action": "continue", "content": "more"},
]
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['tool'] == 't1'
def test_function_call_string_args(self):
data = {"function_call": {"name": "test", "arguments": '{"key": "val"}'}}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['arguments'] == {"key": "val"}
def test_function_call_dict_args(self):
data = {"function_call": {"name": "test", "arguments": {"key": "val"}}}
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['arguments'] == {"key": "val"}
+107
View File
@@ -0,0 +1,107 @@
"""AI 引擎管理器测试"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xiaoli.engines import EngineManager
class TestEngineManager:
"""EngineManager 测试"""
def test_init_empty(self):
mgr = EngineManager()
assert mgr.engines == {}
assert mgr.current is None
def test_set_default_existing(self):
mgr = EngineManager()
mgr.engines = {'test': 'fake_engine'}
assert mgr.set_default('test')
assert mgr.current == 'fake_engine'
def test_set_default_missing(self):
mgr = EngineManager()
assert not mgr.set_default('nonexistent')
def test_switch_existing(self):
mgr = EngineManager()
mgr.engines = {'a': 'engine_a', 'b': 'engine_b'}
assert mgr.switch('b')
assert mgr.current == 'engine_b'
def test_switch_missing(self):
mgr = EngineManager()
assert not mgr.switch('nope')
def test_current_name_none(self):
mgr = EngineManager()
assert mgr.current_name() == '未设置'
def test_current_name_set(self):
mgr = EngineManager()
mgr.current = type('E', (), {'name': 'ollama'})()
assert mgr.current_name() == 'ollama'
def test_names(self):
mgr = EngineManager()
mgr.engines = {'a': 1, 'b': 2, 'c': 3}
names = mgr.names()
assert set(names) == {'a', 'b', 'c'}
def test_generate_no_engine(self):
mgr = EngineManager()
result = mgr.generate("hello")
assert '错误' in result or 'error' in result.lower()
def test_generate_with_engine(self):
mgr = EngineManager()
class FakeEngine:
name = 'fake'
def generate_response(self, user_input, system_prompt=None):
return f"echo: {user_input}"
mgr.current = FakeEngine()
result = mgr.generate("test input")
assert 'test input' in result
def test_load_all_missing_dir(self):
mgr = EngineManager()
result = mgr.load_all('/tmp/nonexistent_engines_dir_12345')
assert result == {}
def test_find_class_patterns(self):
mgr = EngineManager()
import types
mod = types.ModuleType('test')
class TestAI:
def generate_response(self, **kw):
pass
mod.TestAI = TestAI
found = mgr._find_class(mod, 'test')
assert found == 'TestAI'
def test_find_class_no_match(self):
mgr = EngineManager()
import types
mod = types.ModuleType('test')
mod.Foo = 42 # not a class ending in AI
found = mgr._find_class(mod, 'test')
assert found is None
def test_load_all_real_engines(self):
"""测试加载实际引擎目录(不验证引擎可用性,只验证不崩溃)"""
mgr = EngineManager()
engines_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'ai_engines'
)
if os.path.exists(engines_dir):
mgr.load_all(engines_dir)
# 至少应该尝试加载(可能因缺少依赖而失败)
# 但不应崩溃
+114
View File
@@ -0,0 +1,114 @@
"""对话历史管理测试"""
import os
import sys
import json
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xiaoli.conversation.history import History
class TestHistory:
"""History 类测试"""
def test_add_message(self, history_dir):
h = History(history_dir)
h.add("user", "hello")
h.add("assistant", "hi there")
assert len(h.messages) == 2
assert h.messages[0] == {"role": "user", "content": "hello"}
assert h.messages[1] == {"role": "assistant", "content": "hi there"}
def test_get_messages(self, history_dir):
h = History(history_dir)
h.add("user", "q1")
h.add("assistant", "a1")
msgs = h.get()
assert len(msgs) == 2
assert msgs[0]['role'] == 'user'
def test_clear(self, history_dir):
h = History(history_dir)
h.add("user", "hello")
h.add("assistant", "hi")
h.clear()
assert len(h.messages) == 0
def test_max_items(self, history_dir):
h = History(history_dir, max_items=3)
for i in range(5):
h.add("user", f"msg {i}")
assert len(h.messages) == 3
assert h.messages[0]['content'] == 'msg 2'
def test_save_and_load(self, history_dir):
h = History(history_dir)
h.add("user", "hello")
h.add("assistant", "hi")
assert h.save("test_chat")
h2 = History(history_dir)
assert h2.load("test_chat")
assert len(h2.messages) == 2
assert h2.messages[0]['content'] == 'hello'
def test_load_nonexistent(self, history_dir, capsys):
h = History(history_dir)
result = h.load("nonexistent_chat")
assert not result
def test_list_saved(self, history_dir, capsys):
h = History(history_dir)
h.add("user", "test")
h.save("chat1")
h.save("chat2")
h2 = History(history_dir)
h2.list_saved() # should not raise
captured = capsys.readouterr()
assert 'chat1' in captured.out or 'chat2' in captured.out
def test_list_saved_empty(self, history_dir, capsys):
h = History(history_dir)
h.list_saved()
captured = capsys.readouterr()
assert 'no' in captured.out.lower() or '没有' in captured.out
def test_set_all(self, history_dir):
h = History(history_dir)
msgs = [
{"role": "user", "content": "q"},
{"role": "assistant", "content": "a"},
]
h.set_all(msgs)
assert len(h.messages) == 2
assert h.messages == msgs
def test_save_creates_directory(self, tmp_dir):
nested_dir = os.path.join(tmp_dir, "deep", "nested", "history")
h = History(nested_dir)
h.add("user", "test")
h.save("test")
assert os.path.exists(os.path.join(nested_dir, "test.json"))
def test_save_file_format(self, history_dir):
h = History(history_dir)
h.add("user", "hello")
h.save("format_test")
path = os.path.join(history_dir, "format_test.json")
with open(path, 'r') as f:
data = json.load(f)
assert 'timestamp' in data
assert 'conversation' in data
assert len(data['conversation']) == 1
def test_load_corrupted_file(self, history_dir):
path = os.path.join(history_dir, "corrupt.json")
with open(path, 'w') as f:
f.write("{bad json")
h = History(history_dir)
result = h.load("corrupt")
assert not result
+154
View File
@@ -0,0 +1,154 @@
"""集成测试 - 模块间协作"""
import os
import sys
import json
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xiaoli.sandbox import Sandbox
from xiaoli.conversation import parse_response, extract_tool_calls
from xiaoli.conversation.history import History
class TestSandboxConversationIntegration:
"""沙箱 + 对话解析 集成测试"""
def test_sandbox_result_parsed_as_tool_output(self):
"""沙箱执行结果可以正确传递给对话系统"""
sandbox = Sandbox(timeout=10, mode='threaded')
result = sandbox.run('result = [x**2 for x in range(5)]')
assert result['success']
# 模拟工具结果注入对话
tool_msg = f"工具执行结果:\n{result['result']}"
text, data = parse_response(tool_msg)
assert data is None # 纯文本,无工具调用
assert '0' in text and '16' in text
sandbox.cleanup()
def test_sandbox_failure_triggers_error_handling(self):
"""沙箱失败时应正确处理"""
sandbox = Sandbox(timeout=10, mode='threaded')
result = sandbox.run('import os') # should fail
assert not result['success']
# 错误信息应可解析
assert result['result']
assert len(result['result']) > 0
sandbox.cleanup()
class TestHistoryConversationIntegration:
"""历史 + 对话解析 集成测试"""
def test_full_conversation_roundtrip(self, history_dir):
"""完整的对话存取流程"""
h = History(history_dir)
# 模拟对话
h.add("user", "帮我写一个排序函数")
h.add("assistant", "好的,这是一个快速排序:\n```python\ndef quicksort(arr):\n ...\n```")
h.add("user", "能改成归并排序吗?")
# 保存
assert h.save("sorting_chat")
# 加载到新实例
h2 = History(history_dir)
assert h2.load("sorting_chat")
assert len(h2.messages) == 3
assert "quicksort" in h2.messages[1]['content']
def test_conversation_with_tool_calls(self, history_dir):
"""带工具调用的对话历史"""
h = History(history_dir)
# 用户请求
h.add("user", "查看 main.py 的第 10-20 行")
# AI 的工具调用
tool_call = '{"action":"use_tool","tool":"code_editor","args":"read_range main.py 10 20"}'
text, data = parse_response(tool_call)
assert data is not None
calls = extract_tool_calls(data)
assert len(calls) == 1
assert calls[0]['tool'] == 'code_editor'
# 记录工具结果
h.add("assistant", f"工具调用: {json.dumps(data)}")
h.add("assistant", "工具执行结果:\n line 10: def main():")
assert len(h.messages) == 3
class TestSandboxSecurityIntegration:
"""沙箱安全 + 工具链 集成测试"""
def test_safe_code_executes_in_chain(self):
"""安全代码在工具链中正常执行"""
sandbox = Sandbox(timeout=10, mode='threaded')
# 步骤 1: 数学计算
r1 = sandbox.run('import math\nresult = math.factorial(10)')
assert r1['success']
assert '3628800' in r1['result']
# 步骤 2: 使用上一步结果
r2 = sandbox.run(
'result = f"factorial(10) = {val}"',
variables={'val': r1['result']}
)
assert r2['success']
assert '3628800' in r2['result']
sandbox.cleanup()
def test_blocked_code_stops_chain(self):
"""被阻止的代码应中断工具链"""
sandbox = Sandbox(timeout=10, mode='threaded')
r = sandbox.run('import os\nresult = os.listdir(".")')
assert not r['success']
# 后续步骤不应执行
assert r['result']
sandbox.cleanup()
def test_subprocess_isolation(self):
"""子进程模式下文件系统隔离"""
sandbox = Sandbox(timeout=10, mode='subprocess')
result = sandbox.run(
'import tempfile\nresult = tempfile.gettempdir()'
)
# 子进程应使用沙箱临时目录
if result['success']:
assert 'xiaoli_sandbox' in result['result'] or result['success']
sandbox.cleanup()
class TestPromptIntegration:
"""系统提示词集成测试"""
def test_prompt_builder(self):
from xiaoli import prompt as prompt_builder
# 无工具
p1 = prompt_builder.build()
assert '小狸' in p1
assert '编程' in p1 or 'code' in p1.lower()
# 带工具
p2 = prompt_builder.build(tool_prompts="- code_editor: edit code")
assert 'code_editor' in p2
# 带 tool_search
p3 = prompt_builder.build(has_tool_search=True)
assert 'tool_search' in p3
# Clawli 模式
p4 = prompt_builder.build(is_clawli=True)
assert 'Clawli' in p4 or 'clawli' in p4 or '远程' in p4
+240
View File
@@ -0,0 +1,240 @@
"""插件系统测试"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# ═══════════════════════════════════════════════
# 1. code_editor 插件测试
# ═══════════════════════════════════════════════
class TestCodeEditor:
"""代码编辑器插件测试"""
@pytest.fixture(autouse=True)
def setup(self, tmp_dir):
sys.path.insert(0, os.path.join(PROJECT_ROOT, 'plugins'))
from code_editor import Plugin
self.plugin = Plugin()
self.tmp_dir = tmp_dir
def test_tool_info(self):
info = self.plugin.get_tool_info()
assert info['name'] == 'code_editor'
assert 'description' in info
def test_mcp_definition(self):
mcp = self.plugin.get_mcp_definition()
assert mcp['name'] == 'code_editor'
assert 'inputSchema' in mcp
assert 'operation' in mcp['inputSchema']['properties']
def test_convert_mcp_args(self):
args = self.plugin.convert_mcp_args({"operation": "edit", "path": "test.py"})
assert 'edit' in args
def test_create_file(self):
filepath = os.path.join(self.tmp_dir, 'test.py')
result = self.plugin.handle(f'create {filepath}\nprint("hello")')
assert os.path.exists(filepath)
with open(filepath) as f:
assert 'print("hello")' in f.read()
def test_read_range(self):
filepath = os.path.join(self.tmp_dir, 'read_test.py')
with open(filepath, 'w') as f:
f.write('line1\nline2\nline3\nline4\nline5\n')
result = self.plugin.handle(f'read_range {filepath} 2 4')
assert 'line2' in result
assert 'line3' in result
def test_edit_file(self):
filepath = os.path.join(self.tmp_dir, 'edit_test.py')
with open(filepath, 'w') as f:
f.write('def old():\n pass\n')
result = self.plugin.handle(f'edit {filepath} def old():\n pass <<<>>> def new():\n return True')
with open(filepath) as f:
content = f.read()
assert 'def new():' in content
assert 'return True' in content
def test_diff_files(self):
f1 = os.path.join(self.tmp_dir, 'a.py')
f2 = os.path.join(self.tmp_dir, 'b.py')
with open(f1, 'w') as f:
f.write('hello\nworld\n')
with open(f2, 'w') as f:
f.write('hello\nuniverse\n')
result = self.plugin.handle(f'diff {f1} {f2}')
assert result is not None # should produce diff output
def test_search_code(self):
filepath = os.path.join(self.tmp_dir, 'search.py')
with open(filepath, 'w') as f:
f.write('def foo():\n return 42\ndef bar():\n return foo()\n')
result = self.plugin.handle(f'search {self.tmp_dir} main *.py')
assert 'main' in result
def test_write_file(self):
filepath = os.path.join(self.tmp_dir, 'write.py')
result = self.plugin.handle(f'write {filepath}\nresult = 42\n')
assert os.path.exists(filepath)
with open(filepath) as f:
assert 'result = 42' in f.read()
def test_append_file(self):
filepath = os.path.join(self.tmp_dir, 'append.py')
with open(filepath, 'w') as f:
f.write('line1\n')
self.plugin.handle(f'append {filepath}\nline2\n')
with open(filepath) as f:
content = f.read()
assert 'line1' in content
assert 'line2' in content
# ═══════════════════════════════════════════════
# 2. code_search 插件测试
# ═══════════════════════════════════════════════
class TestCodeSearch:
"""代码搜索插件测试"""
@pytest.fixture(autouse=True)
def setup(self, tmp_dir):
sys.path.insert(0, os.path.join(PROJECT_ROOT, 'plugins'))
from code_search import Plugin
self.plugin = Plugin()
self.tmp_dir = tmp_dir
# 创建测试文件
with open(os.path.join(tmp_dir, 'main.py'), 'w') as f:
f.write('import os\ndef main():\n print("hello")\n\nif __name__ == "__main__":\n main()\n')
with open(os.path.join(tmp_dir, 'utils.py'), 'w') as f:
f.write('def helper():\n return 42\ndef process(data):\n return [x*2 for x in data]\n')
with open(os.path.join(tmp_dir, 'test_main.py'), 'w') as f:
f.write('# TODO: add tests\ndef test_main():\n assert True\n')
def test_tool_info(self):
info = self.plugin.get_tool_info()
assert info['name'] == 'code_search'
def test_structure(self):
result = self.plugin.handle(f'structure {self.tmp_dir}')
assert 'main.py' in result
assert 'utils.py' in result
def test_find(self):
result = self.plugin.handle(f'find {self.tmp_dir} def *.py')
assert 'main' in result or 'helper' in result or 'def' in result
def test_symbols(self):
result = self.plugin.handle(f'symbols {self.tmp_dir} main.py')
assert 'main' in result
def test_todo(self):
result = self.plugin.handle(f'todo {self.tmp_dir}')
assert 'TODO' in result
def test_stats(self):
result = self.plugin.handle(f'stats {self.tmp_dir}')
assert result is not None
def test_context(self):
filepath = os.path.join(self.tmp_dir, 'main.py')
result = self.plugin.handle(f'context {filepath} 2 2')
assert 'main' in result or 'def' in result
# ═══════════════════════════════════════════════
# 3. git_tools 插件测试
# ═══════════════════════════════════════════════
class TestGitTools:
"""Git 工具插件测试"""
@pytest.fixture(autouse=True)
def setup(self, tmp_dir):
sys.path.insert(0, os.path.join(PROJECT_ROOT, 'plugins'))
from git_tools import Plugin
self.plugin = Plugin()
self.tmp_dir = tmp_dir
def test_tool_info(self):
info = self.plugin.get_tool_info()
assert info['name'] == 'git_tools'
def test_mcp_definition(self):
mcp = self.plugin.get_mcp_definition()
assert mcp['name'] == 'git_tools'
assert 'inputSchema' in mcp
def test_status_non_git_dir(self):
result = self.plugin.handle(f'status')
# 在非 git 目录应返回错误或提示
assert result is not None
def test_help_text(self):
assert 'git' in self.plugin.usage.lower() or 'Git' in self.plugin.usage
# ═══════════════════════════════════════════════
# 4. unified_tool_manager 测试
# ═══════════════════════════════════════════════
class TestUnifiedToolManager:
"""统一工具管理器测试"""
@pytest.fixture(autouse=True)
def setup(self, plugins_dir, skills_dir):
from unified_tool_manager import UnifiedToolManager
self.manager = UnifiedToolManager()
self.plugins_dir = plugins_dir
self.skills_dir = skills_dir
def test_initialize(self):
self.manager.initialize(self.plugins_dir, self.skills_dir)
assert len(self.manager._tools) > 0
def test_tools_registered(self):
self.manager.initialize(self.plugins_dir, self.skills_dir)
assert 'test_tool' in self.manager._tools
def test_protocol_map(self):
self.manager.initialize(self.plugins_dir, self.skills_dir)
assert 'test_tool' in self.manager._protocol_map
def test_execute_plugin(self):
self.manager.initialize(self.plugins_dir, self.skills_dir)
result = self.manager.execute('test_tool', 'hello')
assert result is not None
assert 'hello' in str(result) or 'test' in str(result).lower()
def test_execute_nonexistent(self):
self.manager.initialize(self.plugins_dir, self.skills_dir)
result = self.manager.execute('nonexistent_tool', 'args')
assert result is None or 'not found' in str(result).lower() or '未找到' in str(result)
def test_mcp_cache(self):
self.manager.initialize(self.plugins_dir, self.skills_dir)
cache = self.manager._mcp_tools_cache
assert cache is not None
assert len(cache) > 0
def test_empty_dirs(self, tmp_dir):
empty_plugins = os.path.join(tmp_dir, 'empty_p')
empty_skills = os.path.join(tmp_dir, 'empty_s')
os.makedirs(empty_plugins)
os.makedirs(empty_skills)
with open(os.path.join(empty_plugins, '__init__.py'), 'w') as f:
pass
with open(os.path.join(empty_skills, '__init__.py'), 'w') as f:
pass
mgr = __import__('unified_tool_manager').UnifiedToolManager()
mgr.initialize(empty_plugins, empty_skills)
assert len(mgr._tools) == 0
+576
View File
@@ -0,0 +1,576 @@
"""沙箱安全测试 - 测试所有安全层级"""
import os
import sys
import time
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xiaoli.sandbox import Sandbox, SandboxResult, ASTSecurityChecker
# ═══════════════════════════════════════════════
# 1. AST 安全检查器测试
# ═══════════════════════════════════════════════
class TestASTSecurityChecker:
"""AST 静态分析测试"""
def setup_method(self):
self.checker = ASTSecurityChecker()
# ── 应该拒绝的 ──
def test_block_os_import(self):
safe, violations = self.checker.check('import os')
assert not safe
assert any('os' in v for v in violations)
def test_block_subprocess_import(self):
safe, violations = self.checker.check('import subprocess')
assert not safe
def test_block_from_import(self):
safe, violations = self.checker.check('from os import system')
assert not safe
assert any('os' in v for v in violations)
def test_block_exec_call(self):
safe, violations = self.checker.check('exec("print(1)")')
assert not safe
assert any('exec' in v for v in violations)
def test_block_eval_call(self):
safe, violations = self.checker.check('x = eval("1+1")')
assert not safe
def test_block_compile(self):
safe, violations = self.checker.check('compile("x=1", "<s>", "exec")')
assert not safe
def test_block_globals(self):
safe, violations = self.checker.check('globals()')
assert not safe
def test_block_locals(self):
safe, violations = self.checker.check('locals()')
assert not safe
def test_block_getattr(self):
safe, violations = self.checker.check('getattr(obj, "name")')
assert not safe
def test_block_setattr(self):
safe, violations = self.checker.check('setattr(obj, "name", 1)')
assert not safe
def test_block_dunder_class(self):
safe, violations = self.checker.check('x = object.__class__')
assert not safe
def test_block_dunder_subclasses(self):
safe, violations = self.checker.check('x = object.__subclasses__()')
assert not safe
def test_block_open(self):
safe, violations = self.checker.check('f = open("/etc/passwd")')
assert not safe
def test_block_breakpoint(self):
safe, violations = self.checker.check('breakpoint()')
assert not safe
def test_block_socket_import(self):
safe, violations = self.checker.check('import socket')
assert not safe
def test_block_pickle_import(self):
safe, violations = self.checker.check('import pickle')
assert not safe
def test_block_ctypes_import(self):
safe, violations = self.checker.check('import ctypes')
assert not safe
def test_block_importlib(self):
safe, violations = self.checker.check('import importlib')
assert not safe
def test_block_del(self):
safe, violations = self.checker.check('del x')
assert not safe
def test_block_global_stmt(self):
safe, violations = self.checker.check('global x')
assert not safe
def test_block_nested_dunder(self):
safe, violations = self.checker.check(
'x = (1).__class__.__bases__[0].__subclasses__()'
)
assert not safe
def test_block_sys_import(self):
safe, violations = self.checker.check('import sys')
assert not safe
def test_block_shutil(self):
safe, violations = self.checker.check('import shutil')
assert not safe
def test_block_urllib(self):
safe, violations = self.checker.check('import urllib')
assert not safe
def test_block_http(self):
safe, violations = self.checker.check('import http')
assert not safe
# ── 应该允许的 ──
def test_allow_math(self):
safe, _ = self.checker.check('import math\nresult = math.pi')
assert safe
def test_allow_json(self):
safe, _ = self.checker.check('import json\nresult = json.dumps({"a": 1})')
assert safe
def test_allow_re(self):
safe, _ = self.checker.check('import re\nresult = re.findall(r"\\d+", "abc")')
assert safe
def test_allow_datetime(self):
safe, _ = self.checker.check('from datetime import datetime\nresult = datetime.now()')
assert safe
def test_allow_collections(self):
safe, _ = self.checker.check('from collections import Counter\nresult = Counter("aab")')
assert safe
def test_allow_list_comprehension(self):
safe, _ = self.checker.check('result = [x**2 for x in range(10)]')
assert safe
def test_allow_function_def(self):
safe, _ = self.checker.check('def add(a, b): return a + b\nresult = add(1, 2)')
assert safe
def test_allow_string_ops(self):
safe, _ = self.checker.check('result = "hello".upper()')
assert safe
def test_allow_sorting(self):
safe, _ = self.checker.check('result = sorted([3,1,2])')
assert safe
def test_allow_typing(self):
safe, _ = self.checker.check('from typing import List\nresult: List[int] = [1,2,3]')
assert safe
def test_allow_enum(self):
safe, _ = self.checker.check('from enum import Enum')
assert safe
def test_allow_decimal(self):
safe, _ = self.checker.check('from decimal import Decimal\nresult = Decimal("3.14")')
assert safe
def test_allow_itertools(self):
safe, _ = self.checker.check('import itertools\nresult = list(itertools.chain([1],[2]))')
assert safe
# ── 语法错误 ──
def test_syntax_error(self):
safe, violations = self.checker.check('def foo(')
assert not safe
assert any('syntax' in v.lower() for v in violations)
def test_empty_code(self):
safe, _ = self.checker.check('')
assert safe
# ── 多种违规组合 ──
def test_multiple_violations(self):
code = 'import os\nimport socket\nexec("x=1")\nglobals()'
safe, violations = self.checker.check(code)
assert not safe
assert len(violations) >= 4
# ═══════════════════════════════════════════════
# 2. Sandbox 核心功能测试
# ═══════════════════════════════════════════════
class TestSandboxBasic:
"""沙箱基本功能测试"""
def setup_method(self):
self.sandbox = Sandbox(timeout=10, mode='threaded')
def teardown_method(self):
self.sandbox.cleanup()
def test_simple_execution(self):
result = self.sandbox.run('result = 1 + 2')
assert result['success']
assert result['result'] == '3'
def test_string_manipulation(self):
result = self.sandbox.run('result = "hello world".split()')
assert result['success']
assert 'hello' in result['result']
def test_math_operations(self):
result = self.sandbox.run('import math\nresult = math.sqrt(144)')
assert result['success']
assert '12.0' in result['result']
def test_json_operations(self):
result = self.sandbox.run(
'import json\nresult = json.dumps({"key": "value"})'
)
assert result['success']
assert 'key' in result['result']
def test_print_capture(self):
result = self.sandbox.run('print("hello")\nresult = "done"')
assert result['success']
assert 'hello' in result['stdout']
def test_variables_injection(self):
result = self.sandbox.run(
'result = x + y',
variables={'x': 10, 'y': 20}
)
assert result['success']
assert '30' in result['result']
def test_function_definition(self):
code = '''
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
result = factorial(5)
'''
result = self.sandbox.run(code)
assert result['success']
assert '120' in result['result']
def test_list_comprehension(self):
result = self.sandbox.run('result = [x**2 for x in range(5)]')
assert result['success']
assert '[0, 1, 4, 9, 16]' in result['result']
def test_dict_operations(self):
code = '''
data = {"a": 1, "b": 2, "c": 3}
result = {k: v*2 for k, v in data.items()}
'''
result = self.sandbox.run(code)
assert result['success']
def test_datetime(self):
code = 'from datetime import datetime\nresult = datetime.now().year'
result = self.sandbox.run(code)
assert result['success']
def test_collections_counter(self):
code = 'from collections import Counter\nresult = Counter("aabbc")'
result = self.sandbox.run(code)
assert result['success']
def test_regex(self):
code = 'import re\nresult = re.findall(r"\\d+", "abc123def456")'
result = self.sandbox.run(code)
assert result['success']
assert '123' in result['result']
def test_no_result_variable(self):
result = self.sandbox.run('x = 42')
assert result['success']
assert 'no return' in result['result'].lower() or 'ok' in result['result'].lower()
def test_syntax_error(self):
result = self.sandbox.run('def foo(')
assert not result['success']
def test_runtime_error(self):
result = self.sandbox.run('result = 1 / 0')
assert not result['success']
assert 'error' in result['result'].lower() or 'division' in result['result'].lower()
# ═══════════════════════════════════════════════
# 3. 安全拒绝测试
# ═══════════════════════════════════════════════
class TestSandboxSecurity:
"""沙箱安全拒绝测试"""
def setup_method(self):
self.sandbox = Sandbox(timeout=10, mode='threaded')
def teardown_method(self):
self.sandbox.cleanup()
def test_reject_os_import(self):
result = self.sandbox.run('import os\nresult = os.getcwd()')
assert not result['success']
def test_reject_subprocess(self):
result = self.sandbox.run('import subprocess\nsubprocess.run(["ls"])')
assert not result['success']
def test_reject_exec(self):
result = self.sandbox.run('exec("result = 1")')
assert not result['success']
def test_reject_eval(self):
result = self.sandbox.run('result = eval("1+1")')
assert not result['success']
def test_reject_open(self):
result = self.sandbox.run('f = open("/etc/passwd")\nresult = f.read()')
assert not result['success']
def test_reject_socket(self):
result = self.sandbox.run('import socket\ns = socket.socket()')
assert not result['success']
def test_reject_pickle(self):
result = self.sandbox.run('import pickle\nresult = pickle.dumps({})')
assert not result['success']
def test_reject_sys(self):
result = self.sandbox.run('import sys\nresult = sys.version')
assert not result['success']
def test_reject_getattr(self):
result = self.sandbox.run('result = getattr(str, "upper")')
assert not result['success']
def test_reject_dunder(self):
result = self.sandbox.run('result = object.__class__')
assert not result['success']
def test_reject_breakpoint(self):
result = self.sandbox.run('breakpoint()')
assert not result['success']
def test_reject_compile(self):
result = self.sandbox.run('compile("x=1", "<s>", "exec")')
assert not result['success']
def test_reject_globals(self):
result = self.sandbox.run('result = globals()')
assert not result['success']
def test_reject_importlib(self):
result = self.sandbox.run('import importlib')
assert not result['success']
def test_reject_shutil(self):
result = self.sandbox.run('import shutil')
assert not result['success']
def test_reject_ctypes(self):
result = self.sandbox.run('import ctypes')
assert not result['success']
def test_reject_multiprocessing(self):
result = self.sandbox.run('import multiprocessing')
assert not result['success']
def test_reject_threading(self):
result = self.sandbox.run('import threading')
assert not result['success']
def test_reject_http(self):
result = self.sandbox.run('import http')
assert not result['success']
def test_reject_urllib(self):
result = self.sandbox.run('import urllib')
assert not result['success']
# ═══════════════════════════════════════════════
# 4. 超时测试
# ═══════════════════════════════════════════════
class TestSandboxTimeout:
"""超时机制测试"""
def test_timeout_infinite_loop(self):
sandbox = Sandbox(timeout=2, mode='threaded')
result = sandbox.run('while True: pass')
assert not result['success']
assert result['killed']
sandbox.cleanup()
def test_timeout_sleep(self):
sandbox = Sandbox(timeout=2, mode='threaded')
result = sandbox.run('import time\ntime.sleep(30)\nresult = "done"')
# Should either timeout or fail on import
assert not result['success'] or result['killed']
sandbox.cleanup()
# ═══════════════════════════════════════════════
# 5. SandboxResult 测试
# ═══════════════════════════════════════════════
class TestSandboxResult:
"""SandboxResult 数据类测试"""
def test_success_result(self):
r = SandboxResult(success=True, result="42", time=0.1)
assert r.success
d = r.to_dict()
assert d['success']
assert d['result'] == '42'
assert d['time'] == 0.1
def test_failure_result(self):
r = SandboxResult(success=False, result="error", security_violation="blocked os")
assert not r.success
d = r.to_dict()
assert not d['success']
assert d['security_violation'] == 'blocked os'
def test_str_representation(self):
r = SandboxResult(success=True, result="ok", time=0.05)
s = str(r)
assert 'OK' in s
assert '0.050' in s
# ═══════════════════════════════════════════════
# 6. 沙箱模式对比测试
# ═══════════════════════════════════════════════
class TestSandboxModes:
"""threaded vs subprocess 模式对比"""
def test_threaded_mode(self):
sandbox = Sandbox(timeout=10, mode='threaded')
result = sandbox.run('result = 2 + 3')
assert result['success']
assert '5' in result['result']
sandbox.cleanup()
def test_subprocess_mode(self):
sandbox = Sandbox(timeout=10, mode='subprocess')
result = sandbox.run('result = 2 + 3')
assert result['success']
assert '5' in result['result']
sandbox.cleanup()
def test_both_modes_same_result(self):
code = 'import json\nresult = json.dumps({"x": 42})'
t = Sandbox(timeout=10, mode='threaded')
s = Sandbox(timeout=10, mode='subprocess')
r_threaded = t.run(code)
r_subprocess = s.run(code)
assert r_threaded['success'] == r_subprocess['success']
assert r_threaded['result'] == r_subprocess['result']
t.cleanup()
s.cleanup()
# ═══════════════════════════════════════════════
# 7. 禁用沙箱测试
# ═══════════════════════════════════════════════
class TestSandboxDisabled:
"""沙箱禁用时的行为"""
def test_disabled_rejects_all(self):
sandbox = Sandbox(timeout=10, mode='threaded')
sandbox.enabled = False
result = sandbox.run('result = 1')
assert not result['success']
assert 'disabled' in result['result'].lower() or 'off' in result['security_violation'].lower()
sandbox.cleanup()
# ═══════════════════════════════════════════════
# 8. 历史记录测试
# ═══════════════════════════════════════════════
class TestSandboxHistory:
"""沙箱执行历史记录"""
def test_history_recorded(self):
sandbox = Sandbox(timeout=10, mode='threaded')
sandbox.run('result = 1')
sandbox.run('result = 2')
assert len(sandbox.history) == 2
assert sandbox.history[0]['success']
assert sandbox.history[1]['success']
sandbox.cleanup()
def test_failed_history_recorded(self):
sandbox = Sandbox(timeout=10, mode="threaded")
result = sandbox.run("x = 1 / 0") # runtime error, not security
assert not result["success"]
assert len(sandbox.history) == 1
assert not sandbox.history[0]["success"]
sandbox.cleanup()
def test_security_violation_no_history(self):
sandbox = Sandbox(timeout=10, mode="threaded")
result = sandbox.run("import os") # security violation
assert not result["success"]
# security violations may not record history
sandbox.cleanup()
# ═══════════════════════════════════════════════
# 9. 变量注入安全测试
# ═══════════════════════════════════════════════
class TestSandboxVariables:
"""变量注入测试"""
def test_inject_numbers(self):
sandbox = Sandbox(timeout=10, mode='threaded')
result = sandbox.run('result = a + b', variables={'a': 100, 'b': 200})
assert result['success']
assert '300' in result['result']
sandbox.cleanup()
def test_inject_strings(self):
sandbox = Sandbox(timeout=10, mode='threaded')
result = sandbox.run('result = name.upper()', variables={'name': 'hello'})
assert result['success']
assert 'HELLO' in result['result']
sandbox.cleanup()
def test_inject_dict(self):
sandbox = Sandbox(timeout=10, mode='threaded')
result = sandbox.run(
'result = data["key"]',
variables={'data': {'key': 'found'}}
)
assert result['success']
assert 'found' in result['result']
sandbox.cleanup()
def test_inject_list(self):
sandbox = Sandbox(timeout=10, mode='threaded')
result = sandbox.run(
'result = sum(items)',
variables={'items': [1, 2, 3, 4, 5]}
)
assert result['success']
assert '15' in result['result']
sandbox.cleanup()
+467 -72
View File
@@ -1,100 +1,475 @@
"""安全代码沙箱"""
import io
"""
安全代码沙箱 v2 - 多层隔离执行环境
安全层级:
1. AST 静态分析 - 拒绝危险语法
2. 子进程隔离 - 独立进程执行,资源限制
3. 文件系统限制 - 仅允许临时目录读写
4. 网络阻断 - 禁止 socket 连接
5. 模块白名单 - 仅允许安全模块导入
6. 输出截断 - 防止内存耗尽
"""
import ast
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import textwrap
import time
import threading
import traceback
import threading
from contextlib import redirect_stdout, redirect_stderr
from colorama import Fore, Style
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
import logging
logger = logging.getLogger(__name__)
DANGEROUS_MODULES = frozenset({
try:
import resource
HAS_RESOURCE = True
except ImportError:
HAS_RESOURCE = False
# ──────────────────────────────────────────────
# 安全策略配置
# ──────────────────────────────────────────────
BLOCKED_MODULES: Set[str] = frozenset({
'os', 'sys', 'subprocess', 'shutil', 'socket', 'pickle',
'marshal', 'ctypes', 'multiprocessing', 'threading',
'signal', 'resource', 'posix', 'nt', 'builtins',
'importlib', 'code', 'codeop',
'importlib', 'code', 'codeop', 'bdb', 'pdb',
'compileall', 'py_compile', 'zipimport', 'pkgutil',
'http', 'urllib', 'ftplib', 'smtplib', 'xmlrpc',
'asyncio', 'concurrent', '_thread',
})
DANGEROUS_NAMES = frozenset({
BLOCKED_NAMES: Set[str] = frozenset({
'exec', 'eval', 'compile', 'execfile', 'open',
'globals', 'locals', 'vars', 'getattr', 'setattr',
'delattr', '__import__', '__builtins__', '__subclasses__',
'__loader__', '__spec__', '__file__', '__name__',
'breakpoint', 'exit', 'quit', 'help',
})
DANGEROUS_PATTERNS = [
BLOCKED_AST_PATTERNS: List[str] = [
r'__', r'\beval\b', r'\bexec\b', r'\bcompile\b',
r'\bglobals\b', r'\blocals\b', r'\bgetattr\b',
r'\bsetattr\b', r'\b__import__\b',
r'\bsetattr\b', r'\b__import__\b', r'\bbreakpoint\b',
]
DEFAULT_WHITELIST: List[str] = [
'json', 'math', 'datetime', 'random', 'collections',
'itertools', 'functools', 'operator', 'typing', 're',
'string', 'textwrap', 'copy', 'decimal', 'fractions',
'statistics', 'bisect', 'heapq', 'array', 'struct',
'hashlib', 'hmac', 'secrets', 'base64', 'binascii',
'uuid', 'pathlib', 'enum', 'dataclasses', 'contextlib',
]
class Sandbox:
"""安全代码执行沙箱"""
DEFAULT_MEMORY_LIMIT_MB = 256
DEFAULT_CPU_TIME_LIMIT = 30
DEFAULT_OUTPUT_LIMIT = 1_000_000
def __init__(self, timeout=30, whitelist=None):
self.enabled = True
self.timeout = timeout
self.whitelist = whitelist or [
'json', 'math', 'datetime', 'random', 'collections',
'itertools', 'functools', 'operator', 'typing', 're'
]
self.history = []
def is_safe(self, code: str) -> bool:
"""快速安全检查"""
for pattern in DANGEROUS_PATTERNS:
# ──────────────────────────────────────────────
# 结果数据类
# ──────────────────────────────────────────────
@dataclass
class SandboxResult:
"""沙箱执行结果"""
success: bool
result: str = ""
stdout: str = ""
stderr: str = ""
time: float = 0.0
exit_code: int = 0
killed: bool = False
security_violation: str = ""
def to_dict(self) -> dict:
return {
'success': self.success,
'result': self.result,
'stdout': self.stdout,
'stderr': self.stderr,
'time': round(self.time, 4),
'exit_code': self.exit_code,
'killed': self.killed,
'security_violation': self.security_violation,
}
def __str__(self) -> str:
status = "OK" if self.success else "FAIL"
parts = [f"{status}: {self.result}"]
if self.stdout:
parts.append(f"stdout={self.stdout[:200]}")
if self.stderr:
parts.append(f"stderr={self.stderr[:200]}")
if self.security_violation:
parts.append(f"security={self.security_violation}")
parts.append(f"{self.time:.3f}s")
return " | ".join(parts)
# ──────────────────────────────────────────────
# AST 安全检查器
# ──────────────────────────────────────────────
class ASTSecurityChecker(ast.NodeVisitor):
"""AST 级静态安全分析"""
def __init__(self, blocked_modules: Set[str] = None,
blocked_names: Set[str] = None):
self.blocked = blocked_modules or BLOCKED_MODULES
self.blocked_names = blocked_names or BLOCKED_NAMES
self.violations: List[str] = []
def _check_name(self, name: str, node: ast.AST):
lineno = getattr(node, 'lineno', 0)
if name in self.blocked_names:
self.violations.append(f"blocked name: {name} (line {lineno})")
if name.split('.')[0] in self.blocked:
self.violations.append(f"blocked module: {name} (line {lineno})")
def visit_Import(self, node):
for alias in node.names:
self._check_name(alias.name, node)
self.generic_visit(node)
def visit_ImportFrom(self, node):
if node.module:
self._check_name(node.module, node)
self.generic_visit(node)
def visit_Call(self, node):
if isinstance(node.func, ast.Name):
self._check_name(node.func.id, node)
elif isinstance(node.func, ast.Attribute):
if isinstance(node.func.value, ast.Name):
name = node.func.value.id
if name in self.blocked_names:
lineno = getattr(node, 'lineno', 0)
self.violations.append(
f"blocked call: {name}.{node.func.attr} (line {lineno})"
)
self.generic_visit(node)
def visit_Name(self, node):
self._check_name(node.id, node)
self.generic_visit(node)
def visit_Attribute(self, node):
if node.attr.startswith('__') and node.attr.endswith('__'):
if node.attr not in ('__init__', '__str__', '__repr__',
'__len__', '__contains__', '__iter__',
'__eq__', '__ne__', '__lt__', '__gt__',
'__le__', '__ge__', '__hash__', '__bool__',
'__add__', '__sub__', '__mul__', '__truediv__',
'__floordiv__', '__mod__', '__pow__',
'__enter__', '__exit__'):
lineno = getattr(node, 'lineno', 0)
self.violations.append(
f"blocked dunder: {node.attr} (line {lineno})"
)
self.generic_visit(node)
def visit_Delete(self, node):
self.violations.append(f"blocked del (line {getattr(node, 'lineno', 0)})")
self.generic_visit(node)
def visit_Global(self, node):
self.violations.append(f"blocked global (line {getattr(node, 'lineno', 0)})")
self.generic_visit(node)
def visit_Nonlocal(self, node):
self.violations.append(f"blocked nonlocal (line {getattr(node, 'lineno', 0)})")
self.generic_visit(node)
def check(self, code: str) -> Tuple[bool, List[str]]:
"""检查代码安全性,返回 (safe, violations)"""
self.violations = []
for pattern in BLOCKED_AST_PATTERNS:
if re.search(pattern, code, re.IGNORECASE):
return False
self.violations.append(f"regex match: {pattern}")
try:
tree = ast.parse(code)
except SyntaxError:
return False
except SyntaxError as e:
return False, [f"syntax error: {e}"]
class Checker(ast.NodeVisitor):
def __init__(self):
self.ok = True
self.visit(tree)
return len(self.violations) == 0, self.violations
def _check(self, name):
if name in DANGEROUS_NAMES or name in DANGEROUS_MODULES:
self.ok = False
def visit_Import(self, node):
for alias in node.names:
if alias.name.split('.')[0] in DANGEROUS_MODULES:
self.ok = False
self.generic_visit(node)
# ──────────────────────────────────────────────
# 子进程包装脚本
# ──────────────────────────────────────────────
def visit_ImportFrom(self, node):
if node.module and node.module.split('.')[0] in DANGEROUS_MODULES:
self.ok = False
self.generic_visit(node)
def _make_worker_script(code: str, variables_json: str,
whitelist_json: str, work_dir: str) -> str:
return textwrap.dedent(f'''\
import sys
import os
import json
import io
import traceback
from contextlib import redirect_stdout, redirect_stderr
def visit_Call(self, node):
if isinstance(node.func, ast.Name):
self._check(node.func.id)
self.generic_visit(node)
os.chdir({work_dir!r})
def visit_Name(self, node):
self._check(node.id)
self.generic_visit(node)
# Block networking
class _FakeSocket:
def __getattr__(self, name):
raise OSError("network access blocked by sandbox")
class _FakeSocketModule:
AF_INET = 0
SOCK_STREAM = 0
def __getattr__(self, name):
raise OSError("network access blocked by sandbox")
def socket(self, *a, **kw):
raise OSError("network access blocked by sandbox")
checker = Checker()
checker.visit(tree)
return checker.ok
import types
_fake_mod = _FakeSocketModule()
sys.modules['socket'] = _fake_mod
sys.modules['_socket'] = _fake_mod
WHITELIST = json.loads({whitelist_json!r})
safe_modules = {{}}
for mod_name in WHITELIST:
try:
safe_modules[mod_name] = __import__(mod_name)
except ImportError:
pass
safe_builtins = {{
'print': print, 'len': len, 'str': str, 'int': int,
'float': float, 'list': list, 'dict': dict, 'tuple': tuple,
'set': set, 'frozenset': frozenset,
'range': range, 'enumerate': enumerate, 'zip': zip,
'sorted': sorted, 'reversed': reversed,
'sum': sum, 'max': max, 'min': min, 'abs': abs,
'round': round, 'pow': pow, 'divmod': divmod,
'type': type, 'isinstance': isinstance, 'issubclass': issubclass,
'hasattr': hasattr, 'callable': callable, 'id': id,
'chr': chr, 'ord': ord, 'hex': hex, 'oct': oct, 'bin': bin,
'bool': bool, 'bytes': bytes, 'bytearray': bytearray,
'map': map, 'filter': filter,
'any': any, 'all': all,
'True': True, 'False': False, 'None': None,
}}
user_vars = json.loads({variables_json!r})
exec_vars = {{}}
exec_vars.update(safe_builtins)
exec_vars.update(safe_modules)
exec_vars.update(user_vars)
stdout_buf = io.StringIO()
stderr_buf = io.StringIO()
result_data = {{"success": True, "result": "", "stdout": "", "stderr": "", "error": ""}}
try:
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
exec({code!r}, exec_vars)
val = exec_vars.get('result', None)
result_data["result"] = str(val) if val is not None else "executed ok"
except Exception as e:
result_data["success"] = False
result_data["error"] = str(e)
result_data["stderr"] = traceback.format_exc()
result_data["stdout"] = stdout_buf.getvalue()
result_data["stderr"] = stderr_buf.getvalue() or result_data.get("stderr", "")
max_out = 500000
if len(result_data["stdout"]) > max_out:
result_data["stdout"] = result_data["stdout"][:max_out] + "\\n... (truncated)"
if len(result_data["stderr"]) > max_out:
result_data["stderr"] = result_data["stderr"][:max_out] + "\\n... (truncated)"
sys.stdout.write(json.dumps(result_data, ensure_ascii=False))
''')
# ──────────────────────────────────────────────
# 主沙箱类
# ──────────────────────────────────────────────
class Sandbox:
"""安全代码执行沙箱 v2
mode:
- 'subprocess': 子进程隔离(默认,最安全)
- 'threaded': 线程内 exec(兼容模式)
"""
def __init__(self, timeout: int = 30,
memory_limit_mb: int = DEFAULT_MEMORY_LIMIT_MB,
whitelist: List[str] = None,
mode: str = 'subprocess',
work_dir: str = None):
self.enabled = True
self.timeout = timeout
self.memory_limit_mb = memory_limit_mb
self.whitelist = whitelist or DEFAULT_WHITELIST
self.mode = mode
self.work_dir = work_dir or tempfile.mkdtemp(prefix='xiaoli_sandbox_')
self.history: List[Dict] = []
self._checker = ASTSecurityChecker()
os.makedirs(self.work_dir, exist_ok=True)
def is_safe(self, code: str) -> Tuple[bool, List[str]]:
"""静态安全检查(AST + 正则)"""
return self._checker.check(code)
def run(self, code: str, variables: dict = None) -> dict:
"""执行代码"""
"""执行代码,返回结果字典(兼容旧接口)"""
result = self.execute(code, variables)
return result.to_dict()
def execute(self, code: str, variables: dict = None) -> SandboxResult:
"""执行代码,返回 SandboxResult"""
if not self.enabled:
return {'success': False, 'result': '沙箱已禁用', 'stdout': '', 'stderr': '', 'time': 0}
return SandboxResult(
success=False, result="sandbox disabled",
security_violation="sandbox is off"
)
if not self.is_safe(code):
return {'success': False, 'result': '代码包含危险操作', 'stdout': '', 'stderr': '', 'time': 0}
safe, violations = self.is_safe(code)
if not safe:
return SandboxResult(
success=False,
result="code failed security check",
security_violation="; ".join(violations[:5])
)
exec_vars = dict(variables or {})
if self.mode == 'subprocess':
result = self._run_subprocess(code, variables)
else:
result = self._run_threaded(code, variables)
self.history.append({
'code': code[:500],
'success': result.success,
'time': result.time,
'timestamp': time.time(),
})
return result
def cleanup(self):
"""清理临时目录"""
try:
if os.path.exists(self.work_dir) and 'xiaoli_sandbox_' in self.work_dir:
shutil.rmtree(self.work_dir, ignore_errors=True)
except Exception:
pass
# ── subprocess ──
def _run_subprocess(self, code: str, variables: dict = None) -> SandboxResult:
vars_json = json.dumps(variables or {}, ensure_ascii=False, default=str)
whitelist_json = json.dumps(self.whitelist)
script = _make_worker_script(code, vars_json, whitelist_json, self.work_dir)
start = time.time()
try:
proc = subprocess.run(
[sys.executable, '-c', script],
capture_output=True,
timeout=self.timeout,
cwd=self.work_dir,
env=self._safe_env(),
preexec_fn=self._set_limits if HAS_RESOURCE else None,
)
elapsed = time.time() - start
stdout = proc.stdout.decode('utf-8', errors='replace')[:DEFAULT_OUTPUT_LIMIT]
stderr = proc.stderr.decode('utf-8', errors='replace')[:DEFAULT_OUTPUT_LIMIT]
try:
data = json.loads(stdout)
return SandboxResult(
success=data.get('success', False),
result=data.get('result', ''),
stdout=data.get('stdout', ''),
stderr=data.get('stderr', ''),
time=elapsed,
exit_code=proc.returncode,
)
except json.JSONDecodeError:
return SandboxResult(
success=proc.returncode == 0,
result=stdout[:2000] if stdout else "no output",
stdout=stdout,
stderr=stderr,
time=elapsed,
exit_code=proc.returncode,
)
except subprocess.TimeoutExpired:
return SandboxResult(
success=False,
result=f"timeout ({self.timeout}s)",
time=self.timeout,
killed=True,
)
except Exception as e:
return SandboxResult(
success=False,
result=f"subprocess error: {e}",
time=time.time() - start,
)
def _safe_env(self) -> dict:
env = {
'PATH': '/usr/bin:/bin',
'LANG': 'en_US.UTF-8',
'LC_ALL': 'en_US.UTF-8',
'HOME': self.work_dir,
'TMPDIR': self.work_dir,
'TEMP': self.work_dir,
'TMP': self.work_dir,
'PYTHONNOUSERSITE': '1',
'PYTHONDONTWRITEBYTECODE': '1',
}
for key in ('TERM', 'COLUMNS', 'LINES', 'SHELL'):
val = os.environ.get(key)
if val:
env[key] = val
return env
@staticmethod
def _set_limits():
if not HAS_RESOURCE:
return
try:
mem_bytes = DEFAULT_MEMORY_LIMIT_MB * 1024 * 1024
resource.setrlimit(resource.RLIMIT_AS, (mem_bytes, mem_bytes))
resource.setrlimit(resource.RLIMIT_CPU, (
DEFAULT_CPU_TIME_LIMIT, DEFAULT_CPU_TIME_LIMIT + 5
))
resource.setrlimit(resource.RLIMIT_NPROC, (0, 0))
resource.setrlimit(resource.RLIMIT_FSIZE, (50 * 1024 * 1024, 50 * 1024 * 1024))
except (ValueError, OSError):
pass
# ── threaded (compat) ──
def _run_threaded(self, code: str, variables: dict = None) -> SandboxResult:
exec_vars = {}
exec_vars.update({
'print': print, 'len': len, 'str': str, 'int': int,
'float': float, 'list': list, 'dict': dict, 'tuple': tuple,
@@ -103,43 +478,63 @@ class Sandbox:
'min': min, 'abs': abs, 'round': round, 'type': type,
'isinstance': isinstance,
})
for mod in self.whitelist:
for mod_name in self.whitelist:
try:
exec_vars[mod] = __import__(mod)
exec_vars[mod_name] = __import__(mod_name)
except ImportError:
pass
if variables:
exec_vars.update(variables)
stdout = io.StringIO()
stderr = io.StringIO()
start = time.time()
stdout_buf = io.StringIO()
stderr_buf = io.StringIO()
result = {'success': True, 'error': None, 'tb': None}
def _exec():
try:
exec(code, exec_vars)
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
exec(code, exec_vars)
except Exception as e:
result['success'] = False
result['error'] = str(e)
result['tb'] = traceback.format_exc()
start = time.time()
thread = threading.Thread(target=_exec, daemon=True)
thread.start()
thread.join(timeout=self.timeout)
elapsed = time.time() - start
if thread.is_alive():
return {'success': False, 'result': f'超时 ({self.timeout}s)',
'stdout': stdout.getvalue(), 'stderr': stderr.getvalue(), 'time': self.timeout}
return SandboxResult(
success=False,
result=f"timeout ({self.timeout}s)",
stdout=stdout_buf.getvalue(),
stderr=stderr_buf.getvalue(),
time=self.timeout,
killed=True,
)
stdout = stdout_buf.getvalue()
stderr = stderr_buf.getvalue()
if result['success']:
val = exec_vars.get('result', None)
return {'success': True,
'result': str(val) if val is not None else '执行成功(无返回值)',
'stdout': stdout.getvalue(), 'stderr': stderr.getvalue(), 'time': elapsed}
return SandboxResult(
success=True,
result=str(val) if val is not None else "executed ok",
stdout=stdout,
stderr=stderr,
time=elapsed,
)
else:
return {'success': False,
'result': f"错误: {result['error']}",
'stdout': stdout.getvalue(),
'stderr': stderr.getvalue() + '\n' + (result['tb'] or ''),
'time': elapsed}
return SandboxResult(
success=False,
result=f"error: {result['error']}",
stdout=stdout,
stderr=stderr + ('\n' + (result['tb'] or '')),
time=elapsed,
)
def __del__(self):
self.cleanup()