Files
chenxin 99a7789432 ✨ feat: Sandbox Runtime——isolated 插件子进程隔离执行(WP-06/07 收口)
- 迁移 17:plugin_execution(五态、退出码、时长、stdout 摘要、错误留痕)
- 沙箱执行器:独立子进程(代码经 stdin JSON 安全传输,不进命令行)、CPU rlimit + RLIMIT_AS(平台不支持时尽力而为并注明)、墙钟超时强杀、输出限额;runner 仅暴露 input/output 作用域
- 执行端点:isolated_process 形态、已安装且启用后可执行;**未启用沙箱一律 503 fail-closed**(HUNJING_SANDBOX_ENABLED 显式开启,审计记录拒绝原因)——AGENTS 第 6 节"无符合策略的 Sandbox 时拒绝执行"落地
- 每次执行落 plugin_execution + 审计(状态/时长);进程级隔离的边界如实注明(网络/文件系统最小化在 Linux 容器档交付)
- 测试 96 项全绿(+2):echo 真实执行回填、死循环 5s 超时强杀、异常 failed 含 stderr、无沙箱 503
2026-09-12 13:38:41 +08:00

110 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""沙箱执行器(WP-06/07 Sandbox):子进程隔离 + 资源限制 + 超时。
隔离边界(按部署档):
- 本执行器提供:独立进程、CPU/内存 rlimit、墙钟超时与强杀、输出限额。
- 进程级隔离不是完整安全边界;网络隔离与文件系统最小化在 Linux 容器档
(container 形态)交付。部署者必须显式启用沙箱(HUNJING_SANDBOX_ENABLED),
未启用时一律拒绝执行(fail-closed,AGENTS 第 6 节)。
"""
import asyncio
import json
import resource
import sys
import time
from dataclasses import dataclass
from typing import Any
# 用户代码经 stdin JSON 传输,不进命令行(避免注入命令行参数)
_RUNNER = r'''
import json, sys
def _main():
payload = json.loads(sys.stdin.read())
scope = {"input": payload.get("input", {})}
exec(compile(payload["code"], "<plugin>", "exec"), scope)
result = scope.get("output")
sys.stdout.write(json.dumps({"output": result}, ensure_ascii=False))
_main()
'''
@dataclass(frozen=True)
class SandboxPolicy:
max_runtime_ms: int = 5_000
max_memory_mb: int = 128
max_output_chars: int = 10_000
class SandboxUnavailable(Exception):
"""未启用沙箱:调用方必须显式失败,不得退化为宿主执行。"""
def _apply_limits(policy: SandboxPolicy) -> None: # 在子进程内执行(preexec_fn)
# RLIMIT_AS 在部分平台(macOS)不可设:尽力而为,失败不阻断进程隔离
cpu_seconds = max(1, policy.max_runtime_ms // 1000 + 1)
resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds))
if hasattr(resource, "RLIMIT_AS"):
as_bytes = policy.max_memory_mb * 1024 * 1024
try:
resource.setrlimit(resource.RLIMIT_AS, (as_bytes, as_bytes))
except (ValueError, OSError):
pass
async def run_sandboxed(
code: str, input_: dict[str, Any], policy: SandboxPolicy | None = None
) -> dict[str, Any]:
"""在受限子进程中执行插件代码;返回执行结果(不抛业务异常以外的错误)。"""
pol = policy or SandboxPolicy()
stdin_payload = json.dumps({"code": code, "input": input_}, ensure_ascii=False)
started = time.monotonic()
try:
proc = await asyncio.create_subprocess_exec(
sys.executable, "-c", _RUNNER,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
preexec_fn=lambda: _apply_limits(pol),
)
except Exception as exc: # 进程无法启动:如实失败
return {
"status": "failed", "exit_code": None,
"duration_ms": int((time.monotonic() - started) * 1000),
"output": None, "error": f"sandbox_spawn_failed: {exc}",
}
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(stdin_payload.encode("utf-8")),
timeout=pol.max_runtime_ms / 1000,
)
except TimeoutError:
proc.kill()
await proc.wait()
return {
"status": "timeout", "exit_code": None,
"duration_ms": int((time.monotonic() - started) * 1000),
"output": None, "error": f"exceeded {pol.max_runtime_ms}ms wall clock",
}
duration_ms = int((time.monotonic() - started) * 1000)
out_text = stdout.decode("utf-8", "replace")[: pol.max_output_chars]
err_text = stderr.decode("utf-8", "replace")[: pol.max_output_chars]
if proc.returncode == 0:
try:
parsed = json.loads(out_text)
except json.JSONDecodeError:
return {
"status": "failed", "exit_code": proc.returncode,
"duration_ms": duration_ms, "output": None,
"error": "plugin output is not JSON",
}
return {
"status": "succeeded", "exit_code": 0,
"duration_ms": duration_ms, "output": parsed.get("output"),
"stdout_excerpt": out_text, "error": None,
}
return {
"status": "failed", "exit_code": proc.returncode,
"duration_ms": duration_ms, "output": None,
"stdout_excerpt": out_text, "error": err_text or f"exit {proc.returncode}",
}