diff --git a/backend/app/sandbox/bubblewrap_sandbox.py b/backend/app/sandbox/bubblewrap_sandbox.py index 56ca78ce..09f13be8 100644 --- a/backend/app/sandbox/bubblewrap_sandbox.py +++ b/backend/app/sandbox/bubblewrap_sandbox.py @@ -132,9 +132,14 @@ class BubblewrapSandbox(Sandbox): return _apply - def _argv(self, workdir: Path, limits: SandboxLimits, cmd: list[str]) -> list[str]: + def _argv(self, workdir: Path, limits: SandboxLimits, cmd: list[str], + env: dict[str, str] | None = None) -> list[str]: argv = [ self._bwrap or "bwrap", + # 先清空环境变量,再一条条设:bwrap 按参数的先后处理,--clearenv 要在所有 --setenv 前面。以前不清,被执行的 + # 代码看得到后端进程的全部环境变量(模型的 API Key、带密码的 AGENTLAB_DATABASE_URL);local、seatbelt 一直只给 + # 最小的一套 + "--clearenv", "--unshare-all", # mount/pid/ipc/uts/cgroup/net 全部隔离 *(["--share-net"] if limits.network else []), "--die-with-parent", # 父进程没了,沙箱跟着走 @@ -147,8 +152,11 @@ class BubblewrapSandbox(Sandbox): "--setenv", "HOME", "/work", "--setenv", "TMPDIR", "/tmp", "--setenv", "PATH", "/usr/bin:/bin", + "--setenv", "LANG", "C.UTF-8", "--setenv", "PYTHONDONTWRITEBYTECODE", "1", ] + for key, value in (env or {}).items(): + argv += ["--setenv", key, value] for path in _RO_BINDS: if Path(path).exists(): argv += ["--ro-bind", path, path] @@ -185,14 +193,14 @@ class BubblewrapSandbox(Sandbox): target.parent.mkdir(parents=True, exist_ok=True) atomic.write_text(target, content) - argv = self._argv(workdir, limits, cmd) - for key, value in (env or {}).items(): - argv[1:1] = ["--setenv", key, value] + argv = self._argv(workdir, limits, cmd, env) started = time.perf_counter() try: proc = await asyncio.create_subprocess_exec( *argv, + # bwrap 自己也只拿最小的环境:沙箱里的进程靠上面的 --setenv,不从这里继承 + env={"PATH": os.environ.get("PATH", "/usr/bin:/bin")}, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, preexec_fn=self._preexec(limits), diff --git a/backend/tests/test_bubblewrap_probe.py b/backend/tests/test_bubblewrap_probe.py index 81b1aaf8..55fa91d3 100644 --- a/backend/tests/test_bubblewrap_probe.py +++ b/backend/tests/test_bubblewrap_probe.py @@ -103,3 +103,58 @@ def test_auto_falls_back_to_local_and_says_why(monkeypatch: pytest.MonkeyPatch) # 节点上选 fast 档也不能点到一个起不来的 bubblewrap monkeypatch.setattr(mgr.sys, "platform", "linux") assert manager._resolve_named("fast") is None + + +# -------------------------------------------------------------------------- +# 环境变量:沙箱里的代码看不到后端进程的(模型的 API Key、带密码的 AGENTLAB_DATABASE_URL) +# -------------------------------------------------------------------------- + +_SECRETS = {"AGENTLAB_DATABASE_URL": "postgresql+asyncpg://u:not-a-real-password@h/db", + "ANTHROPIC_API_KEY": "sk-ant-not-real", "OPENAI_API_KEY": "sk-not-real"} + + +def _setenvs(argv: list[str]) -> dict[str, str]: + return {argv[i + 1]: argv[i + 2] for i, a in enumerate(argv) if a == "--setenv"} + + +def test_the_sandbox_starts_from_a_cleared_environment(monkeypatch, tmp_path): + from app.sandbox.base import SandboxLimits + + for key, value in _SECRETS.items(): + monkeypatch.setenv(key, value) + argv = bw.BubblewrapSandbox()._argv(tmp_path, SandboxLimits(), ["/usr/bin/python3", "/work/x.py"], + {"RUN_FLAG": "1", "HOME": "/work/custom"}) + # --clearenv 在所有 --setenv 前面:bwrap 按先后处理,放后面就把设好的也清掉了 + assert argv[1] == "--clearenv" and argv.index("--clearenv") < argv.index("--setenv") + env = _setenvs(argv) + assert env["RUN_FLAG"] == "1" and env["HOME"] == "/work/custom", "调用方给的照旧生效,能盖过缺省的" + assert env["PATH"] == "/usr/bin:/bin" + assert not any(k.startswith("AGENTLAB_") or k.endswith("_API_KEY") for k in env) + assert not any(v in " ".join(argv) for v in _SECRETS.values()) + + +async def test_bwrap_itself_gets_only_a_minimal_environment(monkeypatch, tmp_path): + for key, value in _SECRETS.items(): + monkeypatch.setenv(key, value) + seen: dict[str, Any] = {} + + class Done: + returncode = 0 + pid = 1 + + async def communicate(self): + return b"", b"" + + async def fake_exec(*argv, **kwargs): + seen["argv"], seen["env"] = list(argv), kwargs.get("env") + return Done() + + box = bw.BubblewrapSandbox() + monkeypatch.setattr(bw.BubblewrapSandbox, "available", staticmethod(lambda: True)) + monkeypatch.setattr(box, "_session_dir", lambda _sid: tmp_path) + monkeypatch.setattr(bw.asyncio, "create_subprocess_exec", fake_exec) + result = await box.run("print(1)", env={"RUN_FLAG": "1"}) + assert result.ok, result.error + assert seen["env"] is not None and set(seen["env"]) == {"PATH"}, "bwrap 进程本身不继承后端的环境变量" + assert not any(v in " ".join(seen["argv"]) for v in _SECRETS.values()) + assert _setenvs(seen["argv"])["RUN_FLAG"] == "1"