- 迁移 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
110 lines
4.2 KiB
Python
110 lines
4.2 KiB
Python
"""沙箱执行器(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}",
|
||
}
|