✨ 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
This commit is contained in:
chenxin committed 2026-09-12 13:38:41 +08:00
1 parent 21eacc8c75
commit 99a7789432
8 files changed
+389

No files matched your search

+111
View File
@@ -1805,3 +1805,114 @@ class TestOps:
assert "hunjing_runs_total" in metrics.text
ready = await client.get("/v1/readyz")
assert ready.json()["status"] == "ready"
@pytest.mark.usefixtures("client")
class TestSandbox:
ISOLATED_MANIFEST = {
"permissions": {"data": ["self_input"]},
"code": 'result = {"echo": input.get("text", "")}\noutput = result',
}
async def _setup_isolated(self, client, publisher, user) -> str:
pub = await client.post(
"/v1/plugins/versions",
json={"name": "example.worker", "kind": "isolated_process",
"version": "1.0.0", "manifest": self.ISOLATED_MANIFEST},
headers=publisher,
)
assert pub.status_code == 201, pub.text
installation = await client.post(
"/v1/plugins/example.worker/installations",
json={"version": "1.0.0"}, headers=user,
)
iid = installation.json()["id"]
enabled = await client.post(
f"/v1/plugin-installations/{iid}/enablings",
json={"granted_permissions": ["data:self_input"]}, headers=user,
)
assert enabled.status_code == 201
return iid
async def test_execution_fails_closed_without_sandbox(self, client) -> None:
publisher = await login(client, "sb-pub@example.com")
user = await login(client, "sb-user@example.com")
iid = await self._setup_isolated(client, publisher, user)
resp = await client.post(
f"/v1/plugin-installations/{iid}/executions",
json={"input": {"text": "hi"}}, headers=user,
)
assert resp.status_code == 503 # fail-closed:未启用沙箱拒绝执行
assert "fail-closed" in resp.json()["detail"]
async def test_sandboxed_execution_success_timeout_and_failure(self, client) -> None:
from app.core.config import get_settings
get_settings.cache_clear()
import os
os.environ["HUNJING_SANDBOX_ENABLED"] = "true"
get_settings.cache_clear()
try:
publisher = await login(client, "sb2-pub@example.com")
user = await login(client, "sb2-user@example.com")
iid = await self._setup_isolated(client, publisher, user)
# 成功执行:echo 回填
ok = await client.post(
f"/v1/plugin-installations/{iid}/executions",
json={"input": {"text": "魂镜"}}, headers=user,
)
assert ok.status_code == 201, ok.text
assert ok.json()["status"] == "succeeded", ok.text
assert ok.json()["output"]["echo"] == "魂镜"
assert ok.json()["duration_ms"] is not None
# 死循环:墙钟超时被强杀
loop_pub = await client.post(
"/v1/plugins/versions",
json={"name": "example.loop", "kind": "isolated_process",
"version": "1.0.0",
"manifest": {"permissions": {}, "code": "while True: pass"}},
headers=publisher,
)
assert loop_pub.status_code == 201
install_loop = await client.post(
"/v1/plugins/example.loop/installations",
json={"version": "1.0.0"}, headers=user,
)
lid = install_loop.json()["id"]
await client.post(
f"/v1/plugin-installations/{lid}/enablings",
json={"granted_permissions": []}, headers=user,
)
timeout = await client.post(
f"/v1/plugin-installations/{lid}/executions",
json={"input": {}}, headers=user,
)
assert timeout.json()["status"] == "timeout"
assert "wall clock" in timeout.json()["error"]
# 抛异常:failed 且含 stderr 摘要
await client.post(
"/v1/plugins/versions",
json={"name": "example.boom", "kind": "isolated_process",
"version": "1.0.0",
"manifest": {"permissions": {}, "code": "raise RuntimeError('炸了')"}},
headers=publisher,
)
install_boom = await client.post(
"/v1/plugins/example.boom/installations",
json={"version": "1.0.0"}, headers=user,
)
bid = install_boom.json()["id"]
await client.post(
f"/v1/plugin-installations/{bid}/enablings",
json={"granted_permissions": []}, headers=user,
)
failed = await client.post(
f"/v1/plugin-installations/{bid}/executions",
json={"input": {}}, headers=user,
)
assert failed.json()["status"] == "failed"
assert "炸了" in failed.json()["error"]
finally:
os.environ.pop("HUNJING_SANDBOX_ENABLED", None)
get_settings.cache_clear()