初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

@@ -0,0 +1,177 @@
"""AuditEventSink / CompositeEventSink 单元测试。
审计 sink 把沙箱 tool.completed / tool.failed 转写为 AuditEvent:completed →
tool.executed / allow,failed → tool.failed / deny;argv(来自 started)作为
input_summary 交 record_audit_event 脱敏。record_audit_event 的数据访问已被 mock,
本测试聚焦 sink 的「事件 → 审计字段」映射与脱敏入口。
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, patch
from app.integrations.sandbox.audit_sink import AuditEventSink
from app.integrations.sandbox.events import (
CompositeEventSink,
SandboxEvent,
completed_event,
failed_event,
started_event,
)
@asynccontextmanager
async def _fake_session():
yield AsyncMock()
def _sink() -> AuditEventSink:
return AuditEventSink(_fake_session, run_id="run_1", character_id="char_1")
async def test_completed_writes_executed_allow() -> None:
sink = _sink()
await sink.emit(
started_event(
tool="shell", argv=["ls", "-la"], risk_level="L2", trace_id="t1", run_id="run_1"
)
)
with patch(
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
) as rec:
await sink.emit(
completed_event(
tool="shell",
trace_id="t1",
run_id="run_1",
exit_code=0,
duration_ms=42,
timeout=False,
truncated=False,
stdout_len=10,
stderr_len=0,
)
)
rec.assert_awaited_once()
kw = rec.call_args.kwargs
assert kw["action"] == "tool.executed"
assert kw["decision"] == "allow"
assert kw["pivot"] == "tool_runtime"
assert kw["risk_level"] == "L2"
assert kw["input_summary"] == "ls -la"
assert kw["resource_id"] == "shell"
assert kw["trace_id"] == "t1"
assert kw["character_id"] == "char_1"
assert "exit=0" in kw["output_summary"]
async def test_failed_writes_failed_deny() -> None:
sink = _sink()
await sink.emit(
started_event(
tool="shell", argv=["sh", "-c", "bad"], risk_level="L3", trace_id="t1", run_id="run_1"
)
)
with patch(
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
) as rec:
await sink.emit(
failed_event(
tool="shell",
trace_id="t1",
run_id="run_1",
error_code="exec_timeout",
error_message="执行超时",
)
)
rec.assert_awaited_once()
kw = rec.call_args.kwargs
assert kw["action"] == "tool.failed"
assert kw["decision"] == "deny"
assert kw["risk_level"] == "L3"
assert kw["reason"] == "exec_timeout"
assert kw["output_summary"] == "执行超时"
async def test_started_does_not_write_audit() -> None:
"""started 只缓存上下文,不写审计。"""
sink = _sink()
with patch(
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
) as rec:
await sink.emit(
started_event(tool="shell", argv=["ls"], risk_level="L1", trace_id="t1", run_id="run_1")
)
rec.assert_not_awaited()
async def test_completed_summary_marks_timeout_and_truncation() -> None:
sink = _sink()
await sink.emit(
started_event(tool="shell", argv=["ls"], risk_level="L1", trace_id="t1", run_id="run_1")
)
with patch(
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
) as rec:
await sink.emit(
completed_event(
tool="shell",
trace_id="t1",
run_id="run_1",
exit_code=None,
duration_ms=5000,
timeout=True,
truncated=True,
stdout_len=0,
stderr_len=0,
)
)
summary = rec.call_args.kwargs["output_summary"]
assert "timeout" in summary
assert "truncated" in summary
async def test_empty_argv_yields_none_input_summary() -> None:
"""executor 路径(fetch_url 等)started argv 为空,input_summary 应为 None。"""
sink = _sink()
await sink.emit(
started_event(tool="fetch_url", argv=[], risk_level="L2", trace_id="t1", run_id="run_1")
)
with patch(
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
) as rec:
await sink.emit(
completed_event(
tool="fetch_url",
trace_id="t1",
run_id="run_1",
exit_code=0,
duration_ms=100,
timeout=False,
truncated=False,
stdout_len=5,
stderr_len=0,
)
)
assert rec.call_args.kwargs["input_summary"] is None
async def test_unknown_event_type_ignored() -> None:
"""非 started/completed/failed 的事件不触发审计写入。"""
sink = _sink()
with patch(
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
) as rec:
await sink.emit(SandboxEvent(type="tool.stdout", payload={"tool": "shell"}))
rec.assert_not_awaited()
async def test_composite_sink_fans_out_to_all() -> None:
a = AsyncMock()
b = AsyncMock()
composite = CompositeEventSink([a, b])
ev = SandboxEvent(type="tool.started", payload={"tool": "x"})
await composite.emit(ev)
a.emit.assert_awaited_once_with(ev)
b.emit.assert_awaited_once_with(ev)
@@ -0,0 +1,206 @@
"""授权凭证签发 / 校验单元测试。"""
from __future__ import annotations
import pytest
from app.integrations.sandbox.auth import (
AuthorizationContext,
compute_body_hash,
compute_file_op_hash,
sign_authorization,
verify_authorization,
)
SECRET = "python-rust-shared-secret"
# 固定向量,与 Rust sandbox/src/auth.rs 的 body_hash_known_vector_matches_python 共享
ARGV = ["sh", "-c", "echo hi"]
STDIN: str | None = None
def _ctx() -> AuthorizationContext:
return AuthorizationContext(
trace_id="trace_1",
risk_level="L1",
tool="shell",
character_id="character_01H",
run_id="run_01H",
)
def test_sign_then_verify_roundtrip() -> None:
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN, ttl_seconds=60)
payload = verify_authorization(SECRET, token)
assert payload["trace_id"] == "trace_1"
assert payload["risk_level"] == "L1"
assert payload["tool"] == "shell"
def test_verify_rejects_wrong_secret() -> None:
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
with pytest.raises(ValueError):
verify_authorization("other-secret", token)
def test_verify_rejects_tampered_signature() -> None:
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
tampered = token[:-2] + ("AA" if token[-2:] != "AA" else "BB")
with pytest.raises(ValueError):
verify_authorization(SECRET, tampered)
def test_verify_rejects_expired() -> None:
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN, ttl_seconds=-10)
with pytest.raises(ValueError):
verify_authorization(SECRET, token)
def test_verify_rejects_malformed() -> None:
with pytest.raises(ValueError):
verify_authorization(SECRET, "not-a-valid-token")
def test_sign_requires_secret() -> None:
with pytest.raises(ValueError):
sign_authorization("", _ctx(), ARGV, STDIN)
def test_payload_carries_body_hash() -> None:
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
payload = verify_authorization(SECRET, token)
assert payload["body_hash"] == compute_body_hash(ARGV, STDIN)
def test_body_hash_differs_per_argv() -> None:
"""凭证绑死具体执行内容:不同 argv 产生不同 body_hash,杜绝重放任意命令。"""
t1 = sign_authorization(SECRET, _ctx(), ["sh", "-c", "echo a"], None)
t2 = sign_authorization(SECRET, _ctx(), ["sh", "-c", "echo b"], None)
p1 = verify_authorization(SECRET, t1)
p2 = verify_authorization(SECRET, t2)
assert p1["body_hash"] != p2["body_hash"]
def test_compute_body_hash_known_vector() -> None:
"""与 Rust compute_body_hash 共享固定向量,保证跨端字节级一致。"""
assert (
compute_body_hash(ARGV, STDIN)
== "f5fa97ea2ee92c505174672da52c59c952e49c32fa56a1db5bd983c10a829540"
)
def test_compute_body_hash_none_vs_empty_stdin() -> None:
assert compute_body_hash(["sh"], None) != compute_body_hash(["sh"], "")
def test_compute_file_op_hash_known_vector() -> None:
"""与 Rust compute_file_op_hash 共享固定向量,保证跨端字节级一致。"""
assert (
compute_file_op_hash("read", "data/note.txt")
== "717f67252c61cf1681cb3c6018d3801b79a511564664567b57d12d775bb8fa53"
)
def test_compute_file_op_hash_differs_per_op_and_path() -> None:
"""凭证绑死具体文件操作:op 或 path 不同则摘要不同。"""
assert compute_file_op_hash("read", "a.txt") != compute_file_op_hash("write", "a.txt")
assert compute_file_op_hash("read", "a.txt") != compute_file_op_hash("read", "b.txt")
def test_sign_carries_file_op_hash_only_for_file_ops() -> None:
"""exec 凭证 file_op_hash 为 None;文件凭证携带 {op, path} 摘要。"""
# exec 路径:argv 非 None → body_hash 绑定,file_op_hash 为 None
exec_token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
exec_payload = verify_authorization(SECRET, exec_token)
assert exec_payload["file_op_hash"] is None
assert exec_payload["body_hash"] == compute_body_hash(ARGV, STDIN)
# 文件路径:argv=None → body_hash 为 None,file_op_hash 绑定 {op, path}
file_token = sign_authorization(SECRET, _ctx(), None, None, file_op=("read", "data/note.txt"))
file_payload = verify_authorization(SECRET, file_token)
assert file_payload["body_hash"] is None
assert file_payload["file_op_hash"] == compute_file_op_hash("read", "data/note.txt")
def test_sign_carries_sorted_rlimit_overrides() -> None:
"""rlimit_overrides 随凭证下发,且按 key 排序(对齐 Rust serde BTreeMap 字母序)。"""
ctx = AuthorizationContext(
trace_id="trace_1",
risk_level="L1",
tool="python_runner",
# 反序插入,验证 sign 时被排序
rlimit_overrides={"fsize": 67_108_864, "as": 536_870_912},
)
token = sign_authorization(SECRET, ctx, ARGV, STDIN)
payload = verify_authorization(SECRET, token)
# 排序后 as 在 fsize 之前(字母序),与 Rust BTreeMap 序列化一致
assert payload["rlimit_overrides"] == {"as": 536_870_912, "fsize": 67_108_864}
# rlimit_overrides 属授权属性,不进 body_hash
assert payload["body_hash"] == compute_body_hash(ARGV, STDIN)
def test_sign_without_rlimit_overrides_is_null() -> None:
"""无 rlimit_overrides 时 payload 字段为 null(对齐 Rust Option None → serde null)。"""
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
payload = verify_authorization(SECRET, token)
assert payload["rlimit_overrides"] is None
def test_compute_body_hash_code_matches_known_vectors() -> None:
"""与 Rust compute_body_hash_code 共享固定向量,保证跨端字节级一致。"""
from app.integrations.sandbox.auth import compute_body_hash_code
assert (
compute_body_hash_code("python", "print(1+1)", None)
== "e7d633605676c3b19c642ee2437d91f9fb22562aaffad40f217d84c587322b69"
)
assert (
compute_body_hash_code("python", "print(1+1)", "42")
== "f933764421a59d1578697189fb76374511c5d7e4f1ad03ad37da3241568c35d5"
)
assert (
compute_body_hash_code("node", "print(1+1)", None)
== "a0440aef4bbcf83d89127ddf89949019b6be939a1c02702d22b35b40b21862aa"
)
def test_sign_authorization_code_mode_carries_isolation_mode() -> None:
"""code 模式:body_hash 按 {language,code,stdin} 算;payload 末尾含 isolation_mode。"""
from app.integrations.sandbox.auth import (
AuthorizationContext,
compute_body_hash_code,
sign_authorization,
verify_authorization,
)
ctx = AuthorizationContext(
trace_id="trace_1",
risk_level="L3",
tool="shell",
isolation_mode="container",
)
token = sign_authorization(
"shared-secret",
ctx,
code="print(1+1)",
language="python",
stdin=None,
)
payload = verify_authorization("shared-secret", token)
assert list(payload)[-1] == "isolation_mode"
assert payload["isolation_mode"] == "container"
assert payload["body_hash"] == compute_body_hash_code("python", "print(1+1)", None)
def test_sign_authorization_argv_mode_backward_compatible_positional() -> None:
"""既有位置参数调用不受影响:argv/stdin 位置传、ttl_seconds 关键字传,仍可验签。"""
import base64
from app.integrations.sandbox.auth import AuthorizationContext, sign_authorization
ctx = AuthorizationContext(trace_id="t", risk_level="L1", tool="shell")
# 既有调用形式:argv/stdin 位置参数(与仓库现有 test_auth.py 一致)
token = sign_authorization("s", ctx, ["python3", "-c", "print(1)"], None)
payload_b64 = token.split(".", 1)[0]
pad = "=" * (-len(payload_b64) % 4)
payload_json = base64.urlsafe_b64decode(payload_b64 + pad).decode("utf-8")
# 末尾追加 isolation_mode(旧字段顺序不变),None 时为 null
assert payload_json.endswith('"isolation_mode":null}')
@@ -0,0 +1,338 @@
"""沙箱客户端测试:用 respx 模拟 httpx,验证授权头注入、事件回写与取消。"""
from __future__ import annotations
import base64
import httpx
import pytest
import respx
from app.core.config import SandboxSettings
from app.integrations.sandbox.auth import AuthorizationContext
from app.integrations.sandbox.client import SandboxClient
from app.integrations.sandbox.events import SandboxEvent
from app.integrations.sandbox.exceptions import (
SandboxBadRequest,
SandboxNotFound,
SandboxUnauthorized,
SandboxUnavailable,
)
from app.integrations.sandbox.models import (
ExecRequest,
FileListRequest,
FileReadRequest,
FileStatRequest,
FileWriteRequest,
)
def _settings(auth_enabled: bool = True) -> SandboxSettings:
return SandboxSettings(
rust_sandbox_url="http://sandbox.test",
sandbox_auth_enabled=auth_enabled,
sandbox_auth_secret="shared-secret",
sandbox_default_timeout_ms=30000,
sandbox_auth_ttl_seconds=30,
)
def _ctx() -> AuthorizationContext:
return AuthorizationContext(trace_id="trace_1", risk_level="L1", tool="shell")
class _RecordingSink:
def __init__(self) -> None:
self.events: list[SandboxEvent] = []
async def emit(self, event: SandboxEvent) -> None:
self.events.append(event)
@respx.mock
async def test_exec_success_sends_bearer_and_emits_events() -> None:
route = respx.post("http://sandbox.test/v1/exec").mock(
return_value=httpx.Response(
200,
json={
"ok": True,
"exit_code": 0,
"stdout": "42\n",
"stderr": "",
"duration_ms": 4,
"timeout": False,
"truncated": False,
"trace_id": "trace_1",
},
)
)
sink = _RecordingSink()
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings(), sink)
resp = await sandbox.exec(ExecRequest(argv=["sh", "-c", "echo $((6*7))"]), _ctx())
assert route.called
assert resp.ok
assert resp.stdout == "42\n"
auth_header = route.calls.last.request.headers.get("authorization", "")
assert auth_header.startswith("Bearer ")
assert [e.type for e in sink.events] == ["tool.started", "tool.completed"]
@respx.mock
async def test_exec_unauthorized_emits_failed_event() -> None:
respx.post("http://sandbox.test/v1/exec").mock(
return_value=httpx.Response(
401,
json={"error": {"code": "sandbox_unauthorized", "message": "凭证无效"}},
)
)
sink = _RecordingSink()
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings(), sink)
with pytest.raises(SandboxUnauthorized):
await sandbox.exec(ExecRequest(argv=["sh", "-c", "x"]), _ctx())
assert sink.events[-1].type == "tool.failed"
assert sink.events[-1].payload["error_code"] == "sandbox_unauthorized"
@respx.mock
async def test_exec_network_error_raises_unavailable() -> None:
respx.post("http://sandbox.test/v1/exec").mock(side_effect=httpx.ConnectError("boom"))
sink = _RecordingSink()
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings(), sink)
with pytest.raises(SandboxUnavailable):
await sandbox.exec(ExecRequest(argv=["sh", "-c", "x"]), _ctx())
assert sink.events[-1].type == "tool.failed"
assert sink.events[-1].payload["error_code"] == "sandbox_unavailable"
@respx.mock
async def test_cancel_returns_true_on_hit() -> None:
respx.post("http://sandbox.test/v1/cancel").mock(
return_value=httpx.Response(200, json={"cancelled": True, "trace_id": "trace_1"})
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
assert await sandbox.cancel("trace_1", _ctx()) is True
@respx.mock
async def test_cancel_raises_not_found() -> None:
respx.post("http://sandbox.test/v1/cancel").mock(
return_value=httpx.Response(
404,
json={"error": {"code": "not_found", "message": "任务不存在"}},
)
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
with pytest.raises(SandboxNotFound):
await sandbox.cancel("trace_1", _ctx())
@respx.mock
async def test_exec_stream_yields_stdout_then_done() -> None:
sse = (
b"event: stdout\n"
b"data: aGVsbG8K\n" # base64(urlsafe-no-pad) 片段
b"\n"
b"event: done\n"
b'data: {"exit_code":0,"duration_ms":5}\n'
b"\n"
)
respx.post("http://sandbox.test/v1/exec/stream").mock(
return_value=httpx.Response(200, content=sse)
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
events = [
ev
async for ev in sandbox.exec_stream(ExecRequest(argv=["sh", "-c", "echo hi"]), _ctx())
]
assert [e.kind for e in events] == ["stdout", "done"]
assert events[0].payload == "aGVsbG8K"
assert events[1].payload["exit_code"] == 0
@respx.mock
async def test_exec_stream_propagates_unauthorized() -> None:
respx.post("http://sandbox.test/v1/exec/stream").mock(
return_value=httpx.Response(
401,
json={"error": {"code": "sandbox_unauthorized", "message": "凭证无效"}},
)
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
with pytest.raises(SandboxUnauthorized):
async for _ in sandbox.exec_stream(ExecRequest(argv=["sh", "-c", "x"]), _ctx()):
pass
async def test_auth_enabled_requires_secret() -> None:
settings = SandboxSettings(
rust_sandbox_url="http://sandbox.test",
sandbox_auth_enabled=True,
sandbox_auth_secret="",
sandbox_default_timeout_ms=30000,
sandbox_auth_ttl_seconds=30,
)
with pytest.raises(ValueError):
SandboxClient(httpx.AsyncClient(), settings)
# ---- 文件 IO(file_op 凭证注入 + 事件回写 + 错误映射) ----
def _b64url(content: bytes) -> str:
return base64.urlsafe_b64encode(content).rstrip(b"=").decode("ascii")
@respx.mock
async def test_file_read_sends_credential_and_decodes_content() -> None:
"""read:注入 file_op 凭证 + started/completed 事件 + base64 解码回原内容。"""
content = b"hello-sandbox"
route = respx.post("http://sandbox.test/v1/file/read").mock(
return_value=httpx.Response(
200,
json={
"content_b64": _b64url(content),
"size": len(content),
"offset": 0,
"returned": len(content),
"truncated": False,
},
)
)
sink = _RecordingSink()
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings(), sink)
resp = await sandbox.file_read(FileReadRequest(path="data/note.txt"), _ctx())
assert route.called
assert resp.returned == len(content)
assert base64.urlsafe_b64decode(resp.content_b64 + "==") == content
assert route.calls.last.request.headers.get("authorization", "").startswith("Bearer ")
assert [e.type for e in sink.events] == ["tool.started", "tool.completed"]
# argv 记 [file:read, path],供审计 input_summary
assert sink.events[0].payload["argv"] == ["file:read", "data/note.txt"]
@respx.mock
async def test_file_write_returns_bytes_written() -> None:
"""write:二进制安全(含 UTF-8 多字节),返回写入字节数。"""
content = "乾坤一炁".encode()
respx.post("http://sandbox.test/v1/file/write").mock(
return_value=httpx.Response(200, json={"bytes_written": len(content)})
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
resp = await sandbox.file_write(
FileWriteRequest(path="out.txt", content_b64=_b64url(content)), _ctx()
)
assert resp.bytes_written == len(content)
@respx.mock
async def test_file_list_returns_entries() -> None:
respx.post("http://sandbox.test/v1/file/list").mock(
return_value=httpx.Response(
200,
json={"entries": [{"name": "note.txt", "kind": "file", "size": 12}]},
)
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
resp = await sandbox.file_list(FileListRequest(), _ctx())
assert resp.entries[0].name == "note.txt"
assert resp.entries[0].kind == "file"
@respx.mock
async def test_file_stat_returns_metadata() -> None:
respx.post("http://sandbox.test/v1/file/stat").mock(
return_value=httpx.Response(
200, json={"kind": "file", "size": 42, "modified_unix": 1_700_000_000}
)
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
resp = await sandbox.file_stat(FileStatRequest(path="data/note.txt"), _ctx())
assert resp.kind == "file"
assert resp.size == 42
@respx.mock
async def test_file_read_maps_bad_request_to_exception() -> None:
"""路径越界/类型不符 → 400 → SandboxBadRequest + failed 事件。"""
respx.post("http://sandbox.test/v1/file/read").mock(
return_value=httpx.Response(
400, json={"error": {"code": "bad_request", "message": "路径越界"}}
)
)
sink = _RecordingSink()
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings(), sink)
with pytest.raises(SandboxBadRequest):
await sandbox.file_read(FileReadRequest(path="../../etc/passwd"), _ctx())
assert sink.events[-1].type == "tool.failed"
@respx.mock
async def test_exec_code_mode_signs_with_body_hash_code() -> None:
"""code 模式:授权凭证 body_hash 按 {language,code,stdin} 算,与 argv 模式不同。"""
from app.integrations.sandbox.auth import compute_body_hash_code, verify_authorization
route = respx.post("http://sandbox.test/v1/exec").mock(
return_value=httpx.Response(
200,
json={
"ok": True,
"exit_code": 0,
"stdout": "2\n",
"stderr": "",
"duration_ms": 4,
"timeout": False,
"truncated": False,
"trace_id": "trace_1",
"isolation_mode": "process",
},
)
)
async with httpx.AsyncClient() as client:
sc = SandboxClient(client, _settings(), None)
await sc.exec(ExecRequest(code="print(1+1)", language="python"), _ctx())
assert route.called
token = route.calls[0].request.headers["Authorization"].removeprefix("Bearer ")
payload = verify_authorization("shared-secret", token)
assert payload["body_hash"] == compute_body_hash_code("python", "print(1+1)", None)
@respx.mock
async def test_exec_code_mode_response_carries_isolation_mode() -> None:
respx.post("http://sandbox.test/v1/exec").mock(
return_value=httpx.Response(
200,
json={
"ok": True,
"exit_code": 0,
"stdout": "",
"stderr": "",
"duration_ms": 1,
"timeout": False,
"truncated": False,
"trace_id": "t",
"isolation_mode": "process",
},
)
)
async with httpx.AsyncClient() as client:
sc = SandboxClient(client, _settings(), None)
resp = await sc.exec(ExecRequest(code="print(1)"), _ctx())
assert resp.isolation_mode == "process"
@@ -0,0 +1,312 @@
"""跨端集成测试:起真实 Rust 沙箱进程,验证后端 SandboxClient 全链路。
覆盖 mock(respx)测试无法触及的真实跨语言契约:HMAC 授权(body_hash 两端一致)、
同步 exec、超时 SIGKILL 进程组、流式 SSE(exec_stream)、cancel。
前置:沙箱二进制需存在;fixture 检测不到时自动 ``cargo build``,失败则 skip。
fixture 为 session 级,多测试共享一个沙箱进程(各用独立 trace_id / 临时工作目录)。
"""
from __future__ import annotations
import asyncio
import base64
import os
import secrets
import shutil
import socket
import subprocess
import tempfile
import time
from pathlib import Path
import httpx
import pytest
from app.core.config import SandboxSettings
from app.integrations.sandbox.auth import AuthorizationContext
from app.integrations.sandbox.client import SandboxClient
from app.integrations.sandbox.exceptions import SandboxBadRequest
from app.integrations.sandbox.models import (
ExecRequest,
FileListRequest,
FileReadRequest,
FileStatRequest,
FileWriteRequest,
)
# backend/tests/integrations/sandbox/test_e2e.py → parents[4] = 项目根
PROJECT_ROOT = Path(__file__).resolve().parents[4]
SANDBOX_BIN = PROJECT_ROOT / "sandbox" / "target" / "debug" / "hunjing-sandbox"
def _free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def _wait_health(base: str, timeout: float = 20.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
if httpx.get(f"{base}/healthz", timeout=1).status_code == 200:
return True
except httpx.HTTPError:
pass
time.sleep(0.2)
return False
@pytest.fixture(scope="session")
def sandbox_server():
"""编译(若需)并启动真实沙箱进程,yield (base_url, secret)。"""
if not SANDBOX_BIN.exists():
try:
subprocess.run(
["cargo", "build"],
cwd=PROJECT_ROOT / "sandbox",
check=True,
timeout=180,
capture_output=True,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
pytest.skip("沙箱二进制不可用且 cargo build 失败,跳过跨端 E2E")
secret = secrets.token_hex(16)
port = _free_port()
workdir = tempfile.mkdtemp(prefix="sbx-e2e-")
env = {
**os.environ,
"SANDBOX_AUTH_ENABLED": "true",
"SANDBOX_AUTH_SECRET": secret,
"SANDBOX_PORT": str(port),
"SANDBOX_HOST": "127.0.0.1",
"SANDBOX_BASE_WORKDIR": workdir,
"RUST_LOG": "warn",
}
proc = subprocess.Popen([str(SANDBOX_BIN)], env=env)
base = f"http://127.0.0.1:{port}"
try:
if not _wait_health(base):
pytest.skip("沙箱启动超时,跳过跨端 E2E")
yield base, secret
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
shutil.rmtree(workdir, ignore_errors=True)
def _settings(base: str, secret: str) -> SandboxSettings:
return SandboxSettings(
rust_sandbox_url=base,
sandbox_auth_enabled=True,
sandbox_auth_secret=secret,
sandbox_default_timeout_ms=30000,
sandbox_auth_ttl_seconds=30,
)
def _ctx(trace_id: str = "trace_e2e", tool: str = "shell") -> AuthorizationContext:
return AuthorizationContext(trace_id=trace_id, risk_level="L1", tool=tool)
async def test_e2e_exec_echo(sandbox_server: tuple[str, str]) -> None:
"""同步 exec:HMAC + body_hash 授权链路,echo 输出正确捕获。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
resp = await client.exec(
ExecRequest(argv=["sh", "-c", "echo hello-e2e"], trace_id="t-echo"),
_ctx("t-echo", "echo"),
)
assert resp.ok
assert resp.exit_code == 0
assert resp.stdout.strip() == "hello-e2e"
async def test_e2e_exec_timeout(sandbox_server: tuple[str, str]) -> None:
"""超时:到点 SIGKILL 进程组,timeout=True 且快速返回。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
resp = await client.exec(
ExecRequest(argv=["sh", "-c", "sleep 30"], timeout_ms=200, trace_id="t-to"),
_ctx("t-to"),
)
assert resp.timeout
assert resp.duration_ms < 5000
async def test_e2e_exec_stream(sandbox_server: tuple[str, str]) -> None:
"""流式 SSE:真实 stdout 增量 + done 结束事件。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
events = [
ev
async for ev in client.exec_stream(
ExecRequest(argv=["sh", "-c", "echo line-a; echo line-b"], trace_id="t-stream"),
_ctx("t-stream"),
)
]
kinds = [e.kind for e in events]
assert "stdout" in kinds
assert kinds[-1] == "done"
assert events[-1].payload["exit_code"] == 0
async def test_e2e_cancel_running_exec(sandbox_server: tuple[str, str]) -> None:
"""取消:并发 cancel 命中运行中任务,exec 以 cancelled=True 返回。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
async def run() -> object:
return await client.exec(
ExecRequest(argv=["sh", "-c", "sleep 30"], trace_id="t-cancel"),
_ctx("t-cancel"),
)
task = asyncio.create_task(run())
await asyncio.sleep(0.6) # 等任务注册到沙箱 running 表
cancelled = await client.cancel("t-cancel", _ctx("t-cancel", "cancel"))
assert cancelled
result = await task
assert result.cancelled # type: ignore[attr-defined]
assert result.duration_ms < 5000 # type: ignore[attr-defined]
# ---- 文件 IO 跨端(真起 Rust 沙箱:file_op_hash HMAC 一致 + 路径校验 + 反重放) ----
def _b64url(content: bytes) -> str:
return base64.urlsafe_b64encode(content).rstrip(b"=").decode("ascii")
def _b64url_decode(text: str) -> bytes:
pad = "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(text + pad)
async def test_e2e_file_write_then_read(sandbox_server: tuple[str, str]) -> None:
"""文件读写往返:write → read 回原内容,验证 file_op_hash 两端字节级一致。"""
base, secret = sandbox_server
content = "乾坤一炁\n".encode()
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
w = await client.file_write(
FileWriteRequest(path="notes/draft.md", content_b64=_b64url(content)),
_ctx("t-fw", "file_write"),
)
assert w.bytes_written == len(content)
r = await client.file_read(
FileReadRequest(path="notes/draft.md"), _ctx("t-fr", "file_read")
)
assert r.size == len(content)
assert r.returned == len(content)
assert _b64url_decode(r.content_b64) == content
async def test_e2e_file_list_and_stat(sandbox_server: tuple[str, str]) -> None:
"""list 列出目录条目,stat 返回文件元信息。"""
base, secret = sandbox_server
content = b"stat-target"
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
await client.file_write(
FileWriteRequest(path="lsbox/target.txt", content_b64=_b64url(content)),
_ctx("t-fp", "file_write"),
)
listing = await client.file_list(FileListRequest(path="lsbox"), _ctx("t-fl", "file_list"))
stat = await client.file_stat(
FileStatRequest(path="lsbox/target.txt"), _ctx("t-fs", "file_stat")
)
assert "target.txt" in [e.name for e in listing.entries]
assert stat.kind == "file"
assert stat.size == len(content)
async def test_e2e_file_read_rejects_traversal(sandbox_server: tuple[str, str]) -> None:
"""路径越界被拒:file_op_hash 跨端一致(凭证通过)+ 词法预检查先行 → 400。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
with pytest.raises(SandboxBadRequest):
await client.file_read(
FileReadRequest(path="../../etc/passwd"), _ctx("t-trav", "file_read")
)
async def test_e2e_file_op_hash_mismatch_rejected(sandbox_server: tuple[str, str]) -> None:
"""反重放:read a.txt 的凭证不能用于读 b.txt → 401(file_op_hash 校验优先于文件存在性)。"""
from app.integrations.sandbox.auth import sign_authorization
base, secret = sandbox_server
ctx = _ctx("t-mismatch", "file_read")
# 签 read a.txt 的凭证,却请求读 b.txt
token = sign_authorization(secret, ctx, None, None, file_op=("read", "a.txt"))
async with httpx.AsyncClient() as http:
resp = await http.post(
f"{base}/v1/file/read",
json={"path": "b.txt"},
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
)
assert resp.status_code == 401
# ---- code 模式跨端(P0:转译为 argv 走进程路径 + body_hash_code 字节级一致) ----
async def test_e2e_code_mode_executes_python(sandbox_server: tuple[str, str]) -> None:
"""code 模式跨端:Rust 与 Python body_hash 字节级一致(401 即不一致),python3 实际执行。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
resp = await client.exec(
ExecRequest(code="import sys\nprint(sys.version_info[0])"),
_ctx("trace_code_1", "python"),
)
assert resp.ok, f"code 模式执行失败: {resp.stderr}"
assert resp.stdout.strip() == "3"
assert resp.isolation_mode == "process"
async def test_e2e_code_mode_rejects_when_argv_also_given(sandbox_server: tuple[str, str]) -> None:
"""互斥校验在沙箱端兜底:argv+code 同时给出应非 200(client 模型已挡,e2e 兜底)。"""
base, _ = sandbox_server
async with httpx.AsyncClient() as http:
# 绕过 client 模型校验,直接裸 POST 验证沙箱端拒绝
resp = await http.post(
f"{base}/v1/exec",
json={"argv": ["python3"], "code": "print(1)"},
timeout=10.0,
)
# 未带授权头 → 401 先于 400;此处断言非 200,证明沙箱拒绝
assert resp.status_code in (400, 401)
async def test_e2e_isolation_mode_container_downgrades_to_process(
sandbox_server: tuple[str, str],
) -> None:
"""P0 过渡态:司命签发 container,沙箱降级 process 并在响应回显 process。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
resp = await client.exec(
ExecRequest(code="print(1)"),
AuthorizationContext(
trace_id="trace_iso",
risk_level="L3",
tool="shell",
isolation_mode="container",
),
)
assert resp.ok
assert resp.isolation_mode == "process"
@@ -0,0 +1,36 @@
"""沙箱请求/响应模型测试:code/argv 互斥、language 默认、isolation_mode 回显。"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.integrations.sandbox.models import ExecRequest, ExecResponse
def test_exec_request_code_mode_defaults_language_to_python() -> None:
req = ExecRequest(code="print(1)")
assert req.code == "print(1)"
assert req.language == "python"
assert req.argv is None
def test_exec_request_argv_mode_still_works() -> None:
req = ExecRequest(argv=["python3", "-c", "print(1)"])
assert req.argv == ["python3", "-c", "print(1)"]
assert req.code is None
def test_exec_request_rejects_both_argv_and_code() -> None:
with pytest.raises(ValidationError):
ExecRequest(argv=["python3"], code="print(1)")
def test_exec_request_rejects_neither_argv_nor_code() -> None:
with pytest.raises(ValidationError):
ExecRequest()
def test_exec_response_defaults_isolation_mode() -> None:
resp = ExecResponse(ok=True, trace_id="t1")
assert resp.isolation_mode == "process"