mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-21 15:36:48 +00:00
Merge branch 'main' into release/2.0-rc
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
from deerflow.community.aio_sandbox.local_backend import _format_container_mount
|
||||
import logging
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
from deerflow.community.aio_sandbox.local_backend import LocalContainerBackend, _format_container_command_for_log, _format_container_mount, _redact_container_command_for_log
|
||||
|
||||
|
||||
def test_format_container_mount_uses_mount_syntax_for_docker_windows_paths():
|
||||
@@ -26,3 +30,89 @@ def test_format_container_mount_keeps_volume_syntax_for_apple_container():
|
||||
"-v",
|
||||
"/host/path:/mnt/path:ro",
|
||||
]
|
||||
|
||||
|
||||
def test_redact_container_command_for_log_redacts_env_values():
|
||||
redacted = _redact_container_command_for_log(
|
||||
[
|
||||
"docker",
|
||||
"run",
|
||||
"-e",
|
||||
"API_KEY=secret-value",
|
||||
"--env=TOKEN=token-value",
|
||||
"--name",
|
||||
"sandbox",
|
||||
"image",
|
||||
]
|
||||
)
|
||||
|
||||
assert "API_KEY=<redacted>" in redacted
|
||||
assert "--env=TOKEN=<redacted>" in redacted
|
||||
assert "secret-value" not in " ".join(redacted)
|
||||
assert "token-value" not in " ".join(redacted)
|
||||
|
||||
|
||||
def test_redact_container_command_for_log_keeps_inherited_env_names():
|
||||
redacted = _redact_container_command_for_log(
|
||||
[
|
||||
"docker",
|
||||
"run",
|
||||
"-e",
|
||||
"API_KEY",
|
||||
"--env=TOKEN",
|
||||
"--name",
|
||||
"sandbox",
|
||||
"image",
|
||||
]
|
||||
)
|
||||
|
||||
assert redacted == [
|
||||
"docker",
|
||||
"run",
|
||||
"-e",
|
||||
"API_KEY",
|
||||
"--env=TOKEN",
|
||||
"--name",
|
||||
"sandbox",
|
||||
"image",
|
||||
]
|
||||
|
||||
|
||||
def test_format_container_command_for_log_uses_windows_quoting(monkeypatch):
|
||||
monkeypatch.setattr(os, "name", "nt")
|
||||
|
||||
command = _format_container_command_for_log(["docker", "run", "--name", "sandbox one", "image"])
|
||||
|
||||
assert command == 'docker run --name "sandbox one" image'
|
||||
|
||||
|
||||
def test_start_container_logs_redacted_env_values(monkeypatch, caplog):
|
||||
backend = LocalContainerBackend(
|
||||
image="sandbox:latest",
|
||||
base_port=8080,
|
||||
container_prefix="sandbox",
|
||||
config_mounts=[],
|
||||
environment={"API_KEY": "secret-value", "NORMAL": "visible-value"},
|
||||
)
|
||||
monkeypatch.setattr(backend, "_runtime", "docker")
|
||||
|
||||
captured_cmd: list[str] = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured_cmd.extend(cmd)
|
||||
return SimpleNamespace(stdout="container-id\n", stderr="", returncode=0)
|
||||
|
||||
monkeypatch.setattr("subprocess.run", fake_run)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="deerflow.community.aio_sandbox.local_backend"):
|
||||
backend._start_container("sandbox-test", 18080)
|
||||
|
||||
joined_cmd = " ".join(captured_cmd)
|
||||
assert "API_KEY=secret-value" in joined_cmd
|
||||
assert "NORMAL=visible-value" in joined_cmd
|
||||
|
||||
log_output = "\n".join(record.getMessage() for record in caplog.records)
|
||||
assert "API_KEY=<redacted>" in log_output
|
||||
assert "NORMAL=<redacted>" in log_output
|
||||
assert "secret-value" not in log_output
|
||||
assert "visible-value" not in log_output
|
||||
|
||||
@@ -49,6 +49,17 @@ def client(mock_app_config):
|
||||
return DeerFlowClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def allow_skill_security_scan():
|
||||
async def _scan(*args, **kwargs):
|
||||
from deerflow.skills.security_scanner import ScanResult
|
||||
|
||||
return ScanResult(decision="allow", reason="ok")
|
||||
|
||||
with patch("deerflow.skills.installer.scan_skill_content", _scan):
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# __init__
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1195,7 +1206,7 @@ class TestSkillsManagement:
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
client.update_skill("nonexistent", enabled=True)
|
||||
|
||||
def test_install_skill(self, client):
|
||||
def test_install_skill(self, client, allow_skill_security_scan):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
|
||||
@@ -2033,7 +2044,7 @@ class TestScenarioMemoryWorkflow:
|
||||
class TestScenarioSkillInstallAndUse:
|
||||
"""Scenario: Install a skill → verify it appears → toggle it."""
|
||||
|
||||
def test_install_then_toggle(self, client):
|
||||
def test_install_then_toggle(self, client, allow_skill_security_scan):
|
||||
"""Install .skill archive → list to verify → disable → verify disabled."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
@@ -2279,7 +2290,7 @@ class TestGatewayConformance:
|
||||
parsed = SkillResponse(**result)
|
||||
assert parsed.name == "web-search"
|
||||
|
||||
def test_install_skill(self, client, tmp_path):
|
||||
def test_install_skill(self, client, tmp_path, allow_skill_security_scan):
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\nBody\n")
|
||||
@@ -2477,7 +2488,7 @@ class TestInstallSkillSecurity:
|
||||
with pytest.raises(ValueError, match="unsafe"):
|
||||
client.install_skill(archive)
|
||||
|
||||
def test_symlinks_skipped_during_extraction(self, client):
|
||||
def test_symlinks_skipped_during_extraction(self, client, allow_skill_security_scan):
|
||||
"""Symlink entries in the archive are skipped (never written to disk)."""
|
||||
import stat as stat_mod
|
||||
|
||||
|
||||
@@ -525,6 +525,15 @@ class TestArtifactAccess:
|
||||
class TestSkillInstallation:
|
||||
"""install_skill() with real ZIP handling and filesystem."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _allow_skill_security_scan(self, monkeypatch):
|
||||
async def _scan(*args, **kwargs):
|
||||
from deerflow.skills.security_scanner import ScanResult
|
||||
|
||||
return ScanResult(decision="allow", reason="ok")
|
||||
|
||||
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_skills_dir(self, tmp_path, monkeypatch):
|
||||
"""Redirect skill installation to a temp directory."""
|
||||
|
||||
@@ -116,10 +116,22 @@ def test_middleware_and_features_conflict():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Vision feature auto-injects view_image_tool
|
||||
# 7. Vision feature auto-injects view_image_tool when thread data is available
|
||||
# ---------------------------------------------------------------------------
|
||||
@patch("deerflow.agents.factory.create_agent")
|
||||
def test_vision_injects_view_image_tool(mock_create_agent):
|
||||
mock_create_agent.return_value = MagicMock()
|
||||
feat = RuntimeFeatures(vision=True, sandbox=True)
|
||||
|
||||
create_deerflow_agent(_make_mock_model(), features=feat)
|
||||
|
||||
call_kwargs = mock_create_agent.call_args[1]
|
||||
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||
assert "view_image" in tool_names
|
||||
|
||||
|
||||
@patch("deerflow.agents.factory.create_agent")
|
||||
def test_vision_without_sandbox_does_not_inject_view_image_tool(mock_create_agent):
|
||||
mock_create_agent.return_value = MagicMock()
|
||||
feat = RuntimeFeatures(vision=True, sandbox=False)
|
||||
|
||||
@@ -127,7 +139,7 @@ def test_vision_injects_view_image_tool(mock_create_agent):
|
||||
|
||||
call_kwargs = mock_create_agent.call_args[1]
|
||||
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||
assert "view_image" in tool_names
|
||||
assert "view_image" not in tool_names
|
||||
|
||||
|
||||
def test_view_image_middleware_preserves_viewed_images_reducer():
|
||||
@@ -301,11 +313,11 @@ def test_always_on_error_handling(mock_create_agent):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 17. Vision with custom middleware still injects tool
|
||||
# 17. Vision with custom middleware follows thread-data availability
|
||||
# ---------------------------------------------------------------------------
|
||||
@patch("deerflow.agents.factory.create_agent")
|
||||
def test_vision_custom_middleware_still_injects_tool(mock_create_agent):
|
||||
"""Custom vision middleware still gets the view_image_tool auto-injected."""
|
||||
def test_vision_custom_middleware_without_sandbox_does_not_inject_tool(mock_create_agent):
|
||||
"""Custom vision middleware without thread data does not get view_image_tool auto-injected."""
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
|
||||
mock_create_agent.return_value = MagicMock()
|
||||
@@ -319,7 +331,7 @@ def test_vision_custom_middleware_still_injects_tool(mock_create_agent):
|
||||
|
||||
call_kwargs = mock_create_agent.call_args[1]
|
||||
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||
assert "view_image" in tool_names
|
||||
assert "view_image" not in tool_names
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import errno
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -8,6 +9,13 @@ from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
|
||||
from deerflow.sandbox.local.local_sandbox_provider import LocalSandboxProvider
|
||||
|
||||
|
||||
def _symlink_to(target, link, *, target_is_directory=False):
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=target_is_directory)
|
||||
except (NotImplementedError, OSError) as exc:
|
||||
pytest.skip(f"symlinks are not available: {exc}")
|
||||
|
||||
|
||||
class TestPathMapping:
|
||||
def test_path_mapping_dataclass(self):
|
||||
mapping = PathMapping(container_path="/mnt/skills", local_path="/home/user/skills", read_only=True)
|
||||
@@ -29,7 +37,7 @@ class TestLocalSandboxPathResolution:
|
||||
],
|
||||
)
|
||||
resolved = sandbox._resolve_path("/mnt/skills")
|
||||
assert resolved == "/home/user/skills"
|
||||
assert resolved == str(Path("/home/user/skills").resolve())
|
||||
|
||||
def test_resolve_path_nested_path(self):
|
||||
sandbox = LocalSandbox(
|
||||
@@ -39,7 +47,7 @@ class TestLocalSandboxPathResolution:
|
||||
],
|
||||
)
|
||||
resolved = sandbox._resolve_path("/mnt/skills/agent/prompt.py")
|
||||
assert resolved == "/home/user/skills/agent/prompt.py"
|
||||
assert resolved == str(Path("/home/user/skills/agent/prompt.py").resolve())
|
||||
|
||||
def test_resolve_path_no_mapping(self):
|
||||
sandbox = LocalSandbox(
|
||||
@@ -61,7 +69,7 @@ class TestLocalSandboxPathResolution:
|
||||
)
|
||||
resolved = sandbox._resolve_path("/mnt/skills/file.py")
|
||||
# Should match /mnt/skills first (longer prefix)
|
||||
assert resolved == "/home/user/skills/file.py"
|
||||
assert resolved == str(Path("/home/user/skills/file.py").resolve())
|
||||
|
||||
def test_reverse_resolve_path_exact_match(self, tmp_path):
|
||||
skills_dir = tmp_path / "skills"
|
||||
@@ -175,6 +183,157 @@ class TestReadOnlyPath:
|
||||
assert exc_info.value.errno == errno.EROFS
|
||||
|
||||
|
||||
class TestSymlinkEscapes:
|
||||
def test_read_file_blocks_symlink_escape_from_mount(self, tmp_path):
|
||||
mount_dir = tmp_path / "mount"
|
||||
mount_dir.mkdir()
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "secret.txt").write_text("secret")
|
||||
_symlink_to(outside_dir, mount_dir / "escape", target_is_directory=True)
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[
|
||||
PathMapping(container_path="/mnt/data", local_path=str(mount_dir), read_only=False),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError) as exc_info:
|
||||
sandbox.read_file("/mnt/data/escape/secret.txt")
|
||||
|
||||
assert exc_info.value.errno == errno.EACCES
|
||||
|
||||
def test_write_file_blocks_symlink_escape_from_mount(self, tmp_path):
|
||||
mount_dir = tmp_path / "mount"
|
||||
mount_dir.mkdir()
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
victim = outside_dir / "victim.txt"
|
||||
victim.write_text("original")
|
||||
_symlink_to(outside_dir, mount_dir / "escape", target_is_directory=True)
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[
|
||||
PathMapping(container_path="/mnt/data", local_path=str(mount_dir), read_only=False),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError) as exc_info:
|
||||
sandbox.write_file("/mnt/data/escape/victim.txt", "changed")
|
||||
|
||||
assert exc_info.value.errno == errno.EACCES
|
||||
assert victim.read_text() == "original"
|
||||
|
||||
def test_write_file_uses_matched_read_only_mount_for_symlink_target(self, tmp_path):
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
writable_dir = repo_dir / "writable"
|
||||
writable_dir.mkdir()
|
||||
_symlink_to(writable_dir, repo_dir / "link-to-writable", target_is_directory=True)
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[
|
||||
PathMapping(container_path="/mnt/repo", local_path=str(repo_dir), read_only=True),
|
||||
PathMapping(container_path="/mnt/repo/writable", local_path=str(writable_dir), read_only=False),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
sandbox.write_file("/mnt/repo/link-to-writable/file.txt", "bypass")
|
||||
|
||||
assert exc_info.value.errno == errno.EROFS
|
||||
assert not (writable_dir / "file.txt").exists()
|
||||
|
||||
def test_list_dir_does_not_follow_symlink_escape_from_mount(self, tmp_path):
|
||||
mount_dir = tmp_path / "mount"
|
||||
mount_dir.mkdir()
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "secret.txt").write_text("secret")
|
||||
_symlink_to(outside_dir, mount_dir / "escape", target_is_directory=True)
|
||||
(mount_dir / "visible.txt").write_text("visible")
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[
|
||||
PathMapping(container_path="/mnt/data", local_path=str(mount_dir), read_only=False),
|
||||
],
|
||||
)
|
||||
|
||||
entries = sandbox.list_dir("/mnt/data", max_depth=2)
|
||||
|
||||
assert "/mnt/data/visible.txt" in entries
|
||||
assert all("secret.txt" not in entry for entry in entries)
|
||||
assert all("outside" not in entry for entry in entries)
|
||||
|
||||
def test_list_dir_formats_internal_directory_symlink_like_directory(self, tmp_path):
|
||||
mount_dir = tmp_path / "mount"
|
||||
nested_dir = mount_dir / "nested"
|
||||
linked_dir = nested_dir / "linked-dir"
|
||||
linked_dir.mkdir(parents=True)
|
||||
_symlink_to(linked_dir, mount_dir / "dir-link", target_is_directory=True)
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[
|
||||
PathMapping(container_path="/mnt/data", local_path=str(mount_dir), read_only=False),
|
||||
],
|
||||
)
|
||||
|
||||
entries = sandbox.list_dir("/mnt/data", max_depth=1)
|
||||
|
||||
assert "/mnt/data/nested/" in entries
|
||||
assert "/mnt/data/nested/linked-dir/" in entries
|
||||
assert "/mnt/data/dir-link" not in entries
|
||||
|
||||
def test_write_file_blocks_symlink_into_nested_read_only_mount(self, tmp_path):
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
protected_dir = repo_dir / "protected"
|
||||
protected_dir.mkdir()
|
||||
_symlink_to(protected_dir, repo_dir / "link-to-protected", target_is_directory=True)
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[
|
||||
PathMapping(container_path="/mnt/repo", local_path=str(repo_dir), read_only=False),
|
||||
PathMapping(container_path="/mnt/repo/protected", local_path=str(protected_dir), read_only=True),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
sandbox.write_file("/mnt/repo/link-to-protected/file.txt", "bypass")
|
||||
|
||||
assert exc_info.value.errno == errno.EROFS
|
||||
assert not (protected_dir / "file.txt").exists()
|
||||
|
||||
def test_update_file_blocks_symlink_into_nested_read_only_mount(self, tmp_path):
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
protected_dir = repo_dir / "protected"
|
||||
protected_dir.mkdir()
|
||||
existing = protected_dir / "file.txt"
|
||||
existing.write_bytes(b"original")
|
||||
_symlink_to(protected_dir, repo_dir / "link-to-protected", target_is_directory=True)
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[
|
||||
PathMapping(container_path="/mnt/repo", local_path=str(repo_dir), read_only=False),
|
||||
PathMapping(container_path="/mnt/repo/protected", local_path=str(protected_dir), read_only=True),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
sandbox.update_file("/mnt/repo/link-to-protected/file.txt", b"changed")
|
||||
|
||||
assert exc_info.value.errno == errno.EROFS
|
||||
assert existing.read_bytes() == b"original"
|
||||
|
||||
|
||||
class TestMultipleMounts:
|
||||
def test_multiple_read_write_mounts(self, tmp_path):
|
||||
skills_dir = tmp_path / "skills"
|
||||
|
||||
@@ -91,7 +91,7 @@ class TestFixMessages:
|
||||
assert isinstance(out, AIMessage)
|
||||
assert "<tool_call>" in out.content
|
||||
assert "<function=get_weather>" in out.content
|
||||
assert '<parameter=city>"London"</parameter>' in out.content
|
||||
assert "<parameter=city>London</parameter>" in out.content
|
||||
assert not getattr(out, "tool_calls", [])
|
||||
|
||||
def test_ai_message_text_preserved_before_xml(self):
|
||||
@@ -116,6 +116,22 @@ class TestFixMessages:
|
||||
assert "<function=tool_a>" in content
|
||||
assert "<function=tool_b>" in content
|
||||
|
||||
def test_ai_message_tool_args_are_xml_escaped(self):
|
||||
msg = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "fn<&>",
|
||||
"args": {"k<&>": "v<&>"},
|
||||
"id": "id1",
|
||||
}
|
||||
],
|
||||
)
|
||||
result = _fix_messages([msg])
|
||||
content = result[0].content
|
||||
assert "<function=fn<&>>" in content
|
||||
assert "<parameter=k<&>>v<&></parameter>" in content
|
||||
|
||||
# ── ToolMessage → HumanMessage ────────────────────────────────────────────
|
||||
|
||||
def test_tool_message_becomes_human_message(self):
|
||||
@@ -185,6 +201,15 @@ class TestParseXmlToolCalls:
|
||||
assert calls[0]["name"] == "a"
|
||||
assert calls[1]["name"] == "b"
|
||||
|
||||
def test_nested_tool_call_blocks_do_not_break_parsing(self):
|
||||
content = "<tool_call><function=outer><parameter=q>1</parameter><tool_call><function=inner><parameter=x>2</parameter></function></tool_call></function></tool_call>"
|
||||
clean, calls = _parse_xml_tool_call_to_dict(content)
|
||||
assert clean == ""
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["name"] == "outer"
|
||||
assert calls[0]["args"] == {"q": 1}
|
||||
assert "x" not in calls[0]["args"]
|
||||
|
||||
def test_text_before_tool_call_preserved(self):
|
||||
content = "Here is the answer.\n<tool_call><function=f><parameter=k>v</parameter></function></tool_call>"
|
||||
clean, calls = _parse_xml_tool_call_to_dict(content)
|
||||
@@ -226,6 +251,12 @@ class TestParseXmlToolCalls:
|
||||
_, c2 = _parse_xml_tool_call_to_dict(block)
|
||||
assert c1[0]["id"] != c2[0]["id"]
|
||||
|
||||
def test_escaped_entities_are_unescaped(self):
|
||||
content = "<tool_call><function=fn<&>><parameter=k<&>>v<&></parameter></function></tool_call>"
|
||||
_, calls = _parse_xml_tool_call_to_dict(content)
|
||||
assert calls[0]["name"] == "fn<&>"
|
||||
assert calls[0]["args"]["k<&>"] == "v<&>"
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 3. MindIEChatModel._patch_result_with_tools
|
||||
@@ -244,6 +275,12 @@ class TestPatchResult:
|
||||
patched = model._patch_result_with_tools(result)
|
||||
assert patched.generations[0].message.content == "line1\nline2"
|
||||
|
||||
def test_escaped_newlines_inside_code_fence_preserved(self):
|
||||
model = self._model()
|
||||
result = _make_chat_result('text\\n```json\n{"k":"a\\\\nb"}\n```\\nend')
|
||||
patched = model._patch_result_with_tools(result)
|
||||
assert patched.generations[0].message.content == 'text\n```json\n{"k":"a\\\\nb"}\n```\nend'
|
||||
|
||||
def test_xml_tool_calls_extracted(self):
|
||||
model = self._model()
|
||||
content = "<tool_call><function=calc><parameter=expr>1+1</parameter></function></tool_call>"
|
||||
@@ -281,6 +318,50 @@ class TestPatchResult:
|
||||
assert patched is not None
|
||||
|
||||
|
||||
class TestMindIEInit:
|
||||
def test_timeout_kwargs_are_normalized(self):
|
||||
captured = {}
|
||||
|
||||
def fake_init(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
with patch("deerflow.models.mindie_provider.ChatOpenAI.__init__", new=fake_init):
|
||||
MindIEChatModel(
|
||||
model="mindie-test",
|
||||
api_key="test-key",
|
||||
connect_timeout=1.0,
|
||||
read_timeout=2.0,
|
||||
write_timeout=3.0,
|
||||
pool_timeout=4.0,
|
||||
)
|
||||
|
||||
timeout = captured.get("timeout")
|
||||
assert timeout is not None
|
||||
assert timeout.connect == 1.0
|
||||
assert timeout.read == 2.0
|
||||
assert timeout.write == 3.0
|
||||
assert timeout.pool == 4.0
|
||||
|
||||
def test_explicit_timeout_takes_precedence(self):
|
||||
captured = {}
|
||||
|
||||
def fake_init(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
with patch("deerflow.models.mindie_provider.ChatOpenAI.__init__", new=fake_init):
|
||||
MindIEChatModel(
|
||||
model="mindie-test",
|
||||
api_key="test-key",
|
||||
timeout=9.0,
|
||||
connect_timeout=1.0,
|
||||
read_timeout=2.0,
|
||||
write_timeout=3.0,
|
||||
pool_timeout=4.0,
|
||||
)
|
||||
|
||||
assert captured.get("timeout") == 9.0
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 4. MindIEChatModel._generate (sync)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -346,6 +346,104 @@ def test_validate_local_bash_command_paths_blocks_traversal_in_skills() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"cat ../uploads/secret.txt",
|
||||
"cat subdir/../../secret.txt",
|
||||
"python script.py --input=../secret.txt",
|
||||
"echo ok > ../outputs/result.txt",
|
||||
],
|
||||
)
|
||||
def test_validate_local_bash_command_paths_blocks_relative_dotdot_segments(command: str) -> None:
|
||||
with pytest.raises(PermissionError, match="path traversal"):
|
||||
validate_local_bash_command_paths(command, _THREAD_DATA)
|
||||
|
||||
|
||||
def test_validate_local_bash_command_paths_blocks_cd_root_escape() -> None:
|
||||
with pytest.raises(PermissionError, match="Unsafe working directory"):
|
||||
validate_local_bash_command_paths("cd / && cat etc/passwd", _THREAD_DATA)
|
||||
|
||||
|
||||
def test_validate_local_bash_command_paths_blocks_cd_parent_escape() -> None:
|
||||
with pytest.raises(PermissionError, match="path traversal"):
|
||||
validate_local_bash_command_paths("cd .. && cat etc/passwd", _THREAD_DATA)
|
||||
|
||||
|
||||
def test_validate_local_bash_command_paths_blocks_cd_env_var_escape() -> None:
|
||||
with pytest.raises(PermissionError, match="Unsafe working directory"):
|
||||
validate_local_bash_command_paths("cd $HOME && cat .ssh/id_rsa", _THREAD_DATA)
|
||||
|
||||
|
||||
def test_validate_local_bash_command_paths_blocks_multiline_cd_escape() -> None:
|
||||
with pytest.raises(PermissionError, match="Unsafe working directory"):
|
||||
validate_local_bash_command_paths("echo ok\ncd $HOME && cat .ssh/id_rsa", _THREAD_DATA)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"command cd / && cat etc/passwd",
|
||||
"builtin cd $HOME && cat .ssh/id_rsa",
|
||||
"if cd $HOME; then cat .ssh/id_rsa; fi",
|
||||
"{ cd /; cat etc/passwd; }",
|
||||
'echo "$(cd $HOME && cat .ssh/id_rsa)"',
|
||||
],
|
||||
)
|
||||
def test_validate_local_bash_command_paths_blocks_complex_cd_escapes(command: str) -> None:
|
||||
with pytest.raises(PermissionError, match="Unsafe working directory"):
|
||||
validate_local_bash_command_paths(command, _THREAD_DATA)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"ls /",
|
||||
"ln -s / root && cat root/etc/passwd",
|
||||
"command ls /",
|
||||
],
|
||||
)
|
||||
def test_validate_local_bash_command_paths_blocks_bare_root_path(command: str) -> None:
|
||||
with pytest.raises(PermissionError, match="Unsafe absolute paths"):
|
||||
validate_local_bash_command_paths(command, _THREAD_DATA)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"echo cd /",
|
||||
"printf '%s\\n' pushd /",
|
||||
],
|
||||
)
|
||||
def test_validate_local_bash_command_paths_allows_cd_words_as_arguments(command: str) -> None:
|
||||
validate_local_bash_command_paths(command, _THREAD_DATA)
|
||||
|
||||
|
||||
def test_validate_local_bash_command_paths_allows_workspace_relative_paths() -> None:
|
||||
validate_local_bash_command_paths(
|
||||
"mkdir -p reports && python script.py data/input.csv > reports/out.txt",
|
||||
_THREAD_DATA,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_local_bash_command_paths_allows_cd_virtual_workspace_with_relative_paths() -> None:
|
||||
validate_local_bash_command_paths(
|
||||
"cd /mnt/user-data/workspace && cat data/input.csv > reports/out.txt",
|
||||
_THREAD_DATA,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_local_bash_command_paths_allows_http_url_dotdot_segments() -> None:
|
||||
validate_local_bash_command_paths(
|
||||
"curl https://example.com/packages/../archive.tar.gz -o /mnt/user-data/workspace/archive.tar.gz",
|
||||
_THREAD_DATA,
|
||||
)
|
||||
validate_local_bash_command_paths(
|
||||
"curl http://example.com/packages/../archive.tar.gz -o /mnt/user-data/workspace/archive.tar.gz",
|
||||
_THREAD_DATA,
|
||||
)
|
||||
|
||||
|
||||
def test_bash_tool_rejects_host_bash_when_local_sandbox_default(monkeypatch) -> None:
|
||||
runtime = SimpleNamespace(
|
||||
state={"sandbox": {"sandbox_id": "local"}, "thread_data": _THREAD_DATA.copy()},
|
||||
@@ -367,6 +465,28 @@ def test_bash_tool_rejects_host_bash_when_local_sandbox_default(monkeypatch) ->
|
||||
assert "Host bash execution is disabled" in result
|
||||
|
||||
|
||||
def test_bash_tool_blocks_relative_traversal_before_host_execution(monkeypatch) -> None:
|
||||
runtime = SimpleNamespace(
|
||||
state={"sandbox": {"sandbox_id": "local"}, "thread_data": _THREAD_DATA.copy()},
|
||||
context={"thread_id": "thread-1"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deerflow.sandbox.tools.ensure_sandbox_initialized",
|
||||
lambda runtime: SimpleNamespace(execute_command=lambda command: pytest.fail("unsafe command should not execute")),
|
||||
)
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.is_host_bash_allowed", lambda: True)
|
||||
|
||||
result = bash_tool.func(
|
||||
runtime=runtime,
|
||||
description="run command",
|
||||
command="cat ../uploads/secret.txt",
|
||||
)
|
||||
|
||||
assert "path traversal" in result
|
||||
|
||||
|
||||
# ---------- Skills path tests ----------
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import errno
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -40,6 +41,85 @@ def _make_test_app(config) -> FastAPI:
|
||||
app.state.config = config
|
||||
app.include_router(skills_router.router)
|
||||
return app
|
||||
|
||||
|
||||
def _make_skill_archive(tmp_path: Path, name: str, content: str | None = None) -> Path:
|
||||
archive = tmp_path / f"{name}.skill"
|
||||
skill_content = content or _skill_content(name)
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr(f"{name}/SKILL.md", skill_content)
|
||||
return archive
|
||||
|
||||
|
||||
def test_install_skill_archive_runs_security_scan(monkeypatch, tmp_path):
|
||||
skills_root = tmp_path / "skills"
|
||||
(skills_root / "custom").mkdir(parents=True)
|
||||
archive = _make_skill_archive(tmp_path, "archive-skill")
|
||||
scan_calls = []
|
||||
refresh_calls = []
|
||||
|
||||
async def _scan(content, *, executable, location):
|
||||
from deerflow.skills.security_scanner import ScanResult
|
||||
|
||||
scan_calls.append({"content": content, "executable": executable, "location": location})
|
||||
return ScanResult(decision="allow", reason="ok")
|
||||
|
||||
async def _refresh():
|
||||
refresh_calls.append("refresh")
|
||||
|
||||
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
|
||||
monkeypatch.setattr("deerflow.skills.installer.get_skills_root_path", lambda: skills_root)
|
||||
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
||||
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(skills_router.router)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/archive-skill.skill"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["skill_name"] == "archive-skill"
|
||||
assert (skills_root / "custom" / "archive-skill" / "SKILL.md").exists()
|
||||
assert scan_calls == [
|
||||
{
|
||||
"content": _skill_content("archive-skill"),
|
||||
"executable": False,
|
||||
"location": "archive-skill/SKILL.md",
|
||||
}
|
||||
]
|
||||
assert refresh_calls == ["refresh"]
|
||||
|
||||
|
||||
def test_install_skill_archive_security_scan_block_returns_400(monkeypatch, tmp_path):
|
||||
skills_root = tmp_path / "skills"
|
||||
(skills_root / "custom").mkdir(parents=True)
|
||||
archive = _make_skill_archive(tmp_path, "blocked-skill")
|
||||
refresh_calls = []
|
||||
|
||||
async def _scan(*args, **kwargs):
|
||||
from deerflow.skills.security_scanner import ScanResult
|
||||
|
||||
return ScanResult(decision="block", reason="prompt injection")
|
||||
|
||||
async def _refresh():
|
||||
refresh_calls.append("refresh")
|
||||
|
||||
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
|
||||
monkeypatch.setattr("deerflow.skills.installer.get_skills_root_path", lambda: skills_root)
|
||||
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
||||
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(skills_router.router)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/blocked-skill.skill"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Security scan blocked skill 'blocked-skill': prompt injection" in response.json()["detail"]
|
||||
assert not (skills_root / "custom" / "blocked-skill").exists()
|
||||
assert refresh_calls == []
|
||||
|
||||
|
||||
def test_custom_skills_router_lifecycle(monkeypatch, tmp_path):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for deerflow.skills.installer — shared skill installation logic."""
|
||||
|
||||
import shutil
|
||||
import stat
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -7,6 +8,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from deerflow.skills.installer import (
|
||||
SkillSecurityScanError,
|
||||
install_skill_from_archive,
|
||||
is_symlink_member,
|
||||
is_unsafe_zip_member,
|
||||
@@ -14,6 +16,7 @@ from deerflow.skills.installer import (
|
||||
safe_extract_skill_archive,
|
||||
should_ignore_archive_entry,
|
||||
)
|
||||
from deerflow.skills.security_scanner import ScanResult
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_unsafe_zip_member
|
||||
@@ -169,6 +172,13 @@ class TestSafeExtract:
|
||||
|
||||
|
||||
class TestInstallSkillFromArchive:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _allow_security_scan(self, monkeypatch):
|
||||
async def _scan(*args, **kwargs):
|
||||
return ScanResult(decision="allow", reason="ok")
|
||||
|
||||
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
||||
|
||||
def _make_skill_zip(self, tmp_path: Path, skill_name: str = "test-skill") -> Path:
|
||||
"""Create a valid .skill archive."""
|
||||
zip_path = tmp_path / f"{skill_name}.skill"
|
||||
@@ -188,6 +198,178 @@ class TestInstallSkillFromArchive:
|
||||
assert result["skill_name"] == "test-skill"
|
||||
assert (skills_root / "custom" / "test-skill" / "SKILL.md").exists()
|
||||
|
||||
def test_scans_skill_markdown_before_install(self, tmp_path, monkeypatch):
|
||||
zip_path = self._make_skill_zip(tmp_path)
|
||||
skills_root = tmp_path / "skills"
|
||||
skills_root.mkdir()
|
||||
calls = []
|
||||
|
||||
async def _scan(content, *, executable, location):
|
||||
calls.append({"content": content, "executable": executable, "location": location})
|
||||
return ScanResult(decision="allow", reason="ok")
|
||||
|
||||
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
||||
|
||||
install_skill_from_archive(zip_path, skills_root=skills_root)
|
||||
|
||||
assert calls == [
|
||||
{
|
||||
"content": "---\nname: test-skill\ndescription: A test skill\n---\n\n# test-skill\n",
|
||||
"executable": False,
|
||||
"location": "test-skill/SKILL.md",
|
||||
}
|
||||
]
|
||||
|
||||
def test_scans_support_files_and_scripts_before_install(self, tmp_path, monkeypatch):
|
||||
zip_path = tmp_path / "test-skill.skill"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("test-skill/SKILL.md", "---\nname: test-skill\ndescription: A test skill\n---\n\n# test-skill\n")
|
||||
zf.writestr("test-skill/references/guide.md", "# Guide\n")
|
||||
zf.writestr("test-skill/templates/prompt.txt", "Use care.\n")
|
||||
zf.writestr("test-skill/scripts/run.sh", "#!/bin/sh\necho ok\n")
|
||||
zf.writestr("test-skill/assets/logo.png", b"\x89PNG\r\n\x1a\n")
|
||||
zf.writestr("test-skill/references/.env", "TOKEN=secret\n")
|
||||
zf.writestr("test-skill/templates/config.cfg", "TOKEN=secret\n")
|
||||
skills_root = tmp_path / "skills"
|
||||
skills_root.mkdir()
|
||||
calls = []
|
||||
|
||||
async def _scan(content, *, executable, location):
|
||||
calls.append({"content": content, "executable": executable, "location": location})
|
||||
return ScanResult(decision="allow", reason="ok")
|
||||
|
||||
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
||||
|
||||
install_skill_from_archive(zip_path, skills_root=skills_root)
|
||||
|
||||
assert calls == [
|
||||
{
|
||||
"content": "---\nname: test-skill\ndescription: A test skill\n---\n\n# test-skill\n",
|
||||
"executable": False,
|
||||
"location": "test-skill/SKILL.md",
|
||||
},
|
||||
{
|
||||
"content": "# Guide\n",
|
||||
"executable": False,
|
||||
"location": "test-skill/references/guide.md",
|
||||
},
|
||||
{
|
||||
"content": "#!/bin/sh\necho ok\n",
|
||||
"executable": True,
|
||||
"location": "test-skill/scripts/run.sh",
|
||||
},
|
||||
{
|
||||
"content": "Use care.\n",
|
||||
"executable": False,
|
||||
"location": "test-skill/templates/prompt.txt",
|
||||
},
|
||||
]
|
||||
assert all("secret" not in call["content"] for call in calls)
|
||||
|
||||
def test_nested_skill_markdown_prevents_install(self, tmp_path):
|
||||
zip_path = tmp_path / "test-skill.skill"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("test-skill/SKILL.md", "---\nname: test-skill\ndescription: A test skill\n---\n\n# test-skill\n")
|
||||
zf.writestr("test-skill/references/other/SKILL.md", "# Nested skill\n")
|
||||
skills_root = tmp_path / "skills"
|
||||
skills_root.mkdir()
|
||||
|
||||
with pytest.raises(SkillSecurityScanError, match="nested SKILL.md"):
|
||||
install_skill_from_archive(zip_path, skills_root=skills_root)
|
||||
|
||||
assert not (skills_root / "custom" / "test-skill").exists()
|
||||
|
||||
def test_script_warn_prevents_install(self, tmp_path, monkeypatch):
|
||||
zip_path = tmp_path / "test-skill.skill"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("test-skill/SKILL.md", "---\nname: test-skill\ndescription: A test skill\n---\n\n# test-skill\n")
|
||||
zf.writestr("test-skill/scripts/run.sh", "#!/bin/sh\necho ok\n")
|
||||
skills_root = tmp_path / "skills"
|
||||
skills_root.mkdir()
|
||||
|
||||
async def _scan(*args, executable, **kwargs):
|
||||
if executable:
|
||||
return ScanResult(decision="warn", reason="script needs review")
|
||||
return ScanResult(decision="allow", reason="ok")
|
||||
|
||||
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
||||
|
||||
with pytest.raises(SkillSecurityScanError, match="rejected executable.*script needs review"):
|
||||
install_skill_from_archive(zip_path, skills_root=skills_root)
|
||||
|
||||
assert not (skills_root / "custom" / "test-skill").exists()
|
||||
|
||||
def test_security_scan_block_prevents_install(self, tmp_path, monkeypatch):
|
||||
zip_path = self._make_skill_zip(tmp_path, skill_name="blocked-skill")
|
||||
skills_root = tmp_path / "skills"
|
||||
skills_root.mkdir()
|
||||
|
||||
async def _scan(*args, **kwargs):
|
||||
return ScanResult(decision="block", reason="prompt injection")
|
||||
|
||||
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
||||
|
||||
with pytest.raises(SkillSecurityScanError, match="Security scan blocked.*prompt injection"):
|
||||
install_skill_from_archive(zip_path, skills_root=skills_root)
|
||||
|
||||
assert not (skills_root / "custom" / "blocked-skill").exists()
|
||||
|
||||
def test_copy_failure_does_not_leave_partial_install(self, tmp_path, monkeypatch):
|
||||
zip_path = self._make_skill_zip(tmp_path)
|
||||
skills_root = tmp_path / "skills"
|
||||
skills_root.mkdir()
|
||||
|
||||
def _copytree(src, dst):
|
||||
partial = Path(dst)
|
||||
partial.mkdir(parents=True)
|
||||
(partial / "partial.txt").write_text("partial", encoding="utf-8")
|
||||
raise OSError("copy failed")
|
||||
|
||||
monkeypatch.setattr("deerflow.skills.installer.shutil.copytree", _copytree)
|
||||
|
||||
with pytest.raises(OSError, match="copy failed"):
|
||||
install_skill_from_archive(zip_path, skills_root=skills_root)
|
||||
|
||||
custom_dir = skills_root / "custom"
|
||||
assert not (custom_dir / "test-skill").exists()
|
||||
assert not [path for path in custom_dir.iterdir() if path.name.startswith(".installing-test-skill-")]
|
||||
|
||||
def test_concurrent_target_creation_does_not_get_clobbered(self, tmp_path, monkeypatch):
|
||||
zip_path = self._make_skill_zip(tmp_path)
|
||||
skills_root = tmp_path / "skills"
|
||||
skills_root.mkdir()
|
||||
target = skills_root / "custom" / "test-skill"
|
||||
original_copytree = shutil.copytree
|
||||
|
||||
def _copytree(src, dst):
|
||||
target.mkdir(parents=True)
|
||||
(target / "marker.txt").write_text("external", encoding="utf-8")
|
||||
return original_copytree(src, dst)
|
||||
|
||||
monkeypatch.setattr("deerflow.skills.installer.shutil.copytree", _copytree)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
install_skill_from_archive(zip_path, skills_root=skills_root)
|
||||
|
||||
assert (target / "marker.txt").read_text(encoding="utf-8") == "external"
|
||||
assert not (target / "SKILL.md").exists()
|
||||
|
||||
def test_move_failure_cleans_reserved_target(self, tmp_path, monkeypatch):
|
||||
zip_path = self._make_skill_zip(tmp_path)
|
||||
skills_root = tmp_path / "skills"
|
||||
skills_root.mkdir()
|
||||
|
||||
def _move(src, dst):
|
||||
Path(dst).write_text("partial", encoding="utf-8")
|
||||
raise OSError("move failed")
|
||||
|
||||
monkeypatch.setattr("deerflow.skills.installer.shutil.move", _move)
|
||||
|
||||
with pytest.raises(OSError, match="move failed"):
|
||||
install_skill_from_archive(zip_path, skills_root=skills_root)
|
||||
|
||||
assert not (skills_root / "custom" / "test-skill").exists()
|
||||
|
||||
def test_duplicate_raises(self, tmp_path):
|
||||
zip_path = self._make_skill_zip(tmp_path)
|
||||
skills_root = tmp_path / "skills"
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import base64
|
||||
import importlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.tools.builtins.view_image_tool import view_image_tool
|
||||
|
||||
view_image_module = importlib.import_module("deerflow.tools.builtins.view_image_tool")
|
||||
|
||||
PNG_BYTES = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
|
||||
|
||||
|
||||
def _make_thread_data(tmp_path: Path) -> dict[str, str]:
|
||||
user_data = tmp_path / "threads" / "thread-1" / "user-data"
|
||||
workspace = user_data / "workspace"
|
||||
uploads = user_data / "uploads"
|
||||
outputs = user_data / "outputs"
|
||||
for directory in (workspace, uploads, outputs):
|
||||
directory.mkdir(parents=True)
|
||||
|
||||
return {
|
||||
"workspace_path": str(workspace),
|
||||
"uploads_path": str(uploads),
|
||||
"outputs_path": str(outputs),
|
||||
}
|
||||
|
||||
|
||||
def _make_runtime(thread_data: dict[str, str]) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
state={"thread_data": thread_data},
|
||||
context={"thread_id": "thread-1"},
|
||||
config={},
|
||||
)
|
||||
|
||||
|
||||
def _message_content(result) -> str:
|
||||
return result.update["messages"][0].content
|
||||
|
||||
|
||||
def test_view_image_rejects_external_absolute_path(tmp_path: Path) -> None:
|
||||
thread_data = _make_thread_data(tmp_path)
|
||||
outside_image = tmp_path / "outside.png"
|
||||
outside_image.write_bytes(PNG_BYTES)
|
||||
|
||||
result = view_image_tool.func(
|
||||
runtime=_make_runtime(thread_data),
|
||||
image_path=str(outside_image),
|
||||
tool_call_id="tc-external",
|
||||
)
|
||||
|
||||
assert "Only image paths under /mnt/user-data" in _message_content(result)
|
||||
assert "viewed_images" not in result.update
|
||||
|
||||
|
||||
def test_view_image_reads_virtual_uploads_path(tmp_path: Path) -> None:
|
||||
thread_data = _make_thread_data(tmp_path)
|
||||
image_path = Path(thread_data["uploads_path"]) / "sample.png"
|
||||
image_path.write_bytes(PNG_BYTES)
|
||||
|
||||
result = view_image_tool.func(
|
||||
runtime=_make_runtime(thread_data),
|
||||
image_path="/mnt/user-data/uploads/sample.png",
|
||||
tool_call_id="tc-uploads",
|
||||
)
|
||||
|
||||
assert _message_content(result) == "Successfully read image"
|
||||
viewed_image = result.update["viewed_images"]["/mnt/user-data/uploads/sample.png"]
|
||||
assert viewed_image["base64"] == base64.b64encode(PNG_BYTES).decode("utf-8")
|
||||
assert viewed_image["mime_type"] == "image/png"
|
||||
|
||||
|
||||
def test_view_image_rejects_spoofed_extension(tmp_path: Path) -> None:
|
||||
thread_data = _make_thread_data(tmp_path)
|
||||
image_path = Path(thread_data["uploads_path"]) / "not-really.png"
|
||||
image_path.write_bytes(b"not an image")
|
||||
|
||||
result = view_image_tool.func(
|
||||
runtime=_make_runtime(thread_data),
|
||||
image_path="/mnt/user-data/uploads/not-really.png",
|
||||
tool_call_id="tc-spoofed",
|
||||
)
|
||||
|
||||
assert "contents do not match" in _message_content(result)
|
||||
assert "viewed_images" not in result.update
|
||||
|
||||
|
||||
def test_view_image_rejects_mismatched_magic_bytes(tmp_path: Path) -> None:
|
||||
thread_data = _make_thread_data(tmp_path)
|
||||
image_path = Path(thread_data["uploads_path"]) / "jpeg-named-png.png"
|
||||
image_path.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg")
|
||||
|
||||
result = view_image_tool.func(
|
||||
runtime=_make_runtime(thread_data),
|
||||
image_path="/mnt/user-data/uploads/jpeg-named-png.png",
|
||||
tool_call_id="tc-mismatch",
|
||||
)
|
||||
|
||||
assert "file extension indicates image/png" in _message_content(result)
|
||||
assert "viewed_images" not in result.update
|
||||
|
||||
|
||||
def test_view_image_rejects_oversized_image(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
thread_data = _make_thread_data(tmp_path)
|
||||
image_path = Path(thread_data["uploads_path"]) / "sample.png"
|
||||
image_path.write_bytes(PNG_BYTES)
|
||||
monkeypatch.setattr(view_image_module, "_MAX_IMAGE_BYTES", len(PNG_BYTES) - 1)
|
||||
|
||||
result = view_image_tool.func(
|
||||
runtime=_make_runtime(thread_data),
|
||||
image_path="/mnt/user-data/uploads/sample.png",
|
||||
tool_call_id="tc-oversized",
|
||||
)
|
||||
|
||||
assert "Image file is too large" in _message_content(result)
|
||||
assert "viewed_images" not in result.update
|
||||
|
||||
|
||||
def test_view_image_sanitizes_read_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
thread_data = _make_thread_data(tmp_path)
|
||||
image_path = Path(thread_data["uploads_path"]) / "sample.png"
|
||||
image_path.write_bytes(PNG_BYTES)
|
||||
|
||||
def _open(*args, **kwargs):
|
||||
raise PermissionError(f"permission denied: {image_path}")
|
||||
|
||||
monkeypatch.setattr("builtins.open", _open)
|
||||
|
||||
result = view_image_tool.func(
|
||||
runtime=_make_runtime(thread_data),
|
||||
image_path="/mnt/user-data/uploads/sample.png",
|
||||
tool_call_id="tc-read-error",
|
||||
)
|
||||
|
||||
message = _message_content(result)
|
||||
assert "Error reading image file" in message
|
||||
assert str(image_path) not in message
|
||||
assert str(Path(thread_data["uploads_path"])) not in message
|
||||
assert "/mnt/user-data/uploads/sample.png" in message
|
||||
assert "viewed_images" not in result.update
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
|
||||
def test_view_image_rejects_uploads_symlink_escape(tmp_path: Path) -> None:
|
||||
thread_data = _make_thread_data(tmp_path)
|
||||
outside_image = tmp_path / "outside-target.png"
|
||||
outside_image.write_bytes(PNG_BYTES)
|
||||
|
||||
link_path = Path(thread_data["uploads_path"]) / "escape.png"
|
||||
try:
|
||||
link_path.symlink_to(outside_image)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlink creation failed: {exc}")
|
||||
|
||||
result = view_image_tool.func(
|
||||
runtime=_make_runtime(thread_data),
|
||||
image_path="/mnt/user-data/uploads/escape.png",
|
||||
tool_call_id="tc-symlink",
|
||||
)
|
||||
|
||||
assert "path traversal" in _message_content(result)
|
||||
assert "viewed_images" not in result.update
|
||||
Reference in New Issue
Block a user