Files
arc53--docsgpt/tests/sandbox/test_jupyter_gateway_isolation.py
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

598 lines
22 KiB
Python

"""Per-session isolation hardening for the Jupyter gateway sandbox.
Covers the env-scrubbing kernel launcher (`deployment/sandbox/kernel-launch.sh`)
and the `0700` per-session workspace perms applied by `_prime`. Hermetic: the
launcher test runs the wrapper with a fake `python` on PATH, and the `_prime`
test executes the wrapper's setup code against a real temp directory by
stubbing `_run` -- no gateway / kernel process required.
"""
import json
import os
import shutil
import stat
import subprocess
import textwrap
from pathlib import Path
import pytest
from application.sandbox import jupyter_gateway
from application.sandbox.base import ExecResult
from application.sandbox.jupyter_gateway import JupyterKernelGatewaySandbox, _Kernel
_SANDBOX_DIR = Path(__file__).resolve().parents[2] / "deployment" / "sandbox"
_WRAPPER = _SANDBOX_DIR / "kernel-launch.sh"
_KERNEL_NAME = "docsgpt-python"
_KERNELSPEC = _SANDBOX_DIR / "kernels" / _KERNEL_NAME / "kernel.json"
# -- Env-scrubbing kernel launcher ---------------------------------------------
@pytest.mark.skipif(shutil.which("sh") is None, reason="POSIX sh not available")
def test_kernel_launch_scrubs_secrets_keeps_runtime_env(tmp_path):
"""The wrapper drops *_API_KEY/*_TOKEN but keeps PATH/HOME/JUPYTER_* for ipykernel."""
# Fake `python` on PATH: ignore `-m ipykernel_launcher` and dump the env it was given.
fake_python = tmp_path / "python"
fake_python.write_text(
textwrap.dedent(
"""\
#!/bin/sh
env
"""
)
)
fake_python.chmod(0o755)
env = {
"PATH": f"{tmp_path}:{os.environ.get('PATH', '')}",
"HOME": str(tmp_path),
"LANG": "C.UTF-8",
"JUPYTER_RUNTIME_DIR": str(tmp_path / "runtime"),
"JUPYTER_DATA_DIR": str(tmp_path / "data"),
# Secrets that must NOT reach the kernel.
"OPENAI_API_KEY": "sk-super-secret",
"SANDBOX_GATEWAY_AUTH_TOKEN": "gateway-token",
"POSTGRES_URI": "postgresql://u:p@h/db",
}
proc = subprocess.run(
["sh", str(_WRAPPER), "-f", "/tmp/conn.json"],
env=env,
capture_output=True,
text=True,
timeout=15,
)
assert proc.returncode == 0, proc.stderr
out = proc.stdout
# Secrets stripped.
assert "OPENAI_API_KEY" not in out
assert "sk-super-secret" not in out
assert "SANDBOX_GATEWAY_AUTH_TOKEN" not in out
assert "POSTGRES_URI" not in out
# Allowlisted runtime env kept.
assert "PATH=" in out
assert f"HOME={tmp_path}" in out
assert f"JUPYTER_RUNTIME_DIR={tmp_path / 'runtime'}" in out
assert f"JUPYTER_DATA_DIR={tmp_path / 'data'}" in out
# The connection-file args were forwarded to ipykernel (reachability preserved).
# The fake python prints env only, so just assert it was invoked with no crash above.
@pytest.mark.skipif(shutil.which("sh") is None, reason="POSIX sh not available")
def test_kernel_launch_is_valid_sh():
"""The wrapper parses under POSIX sh (`sh -n`)."""
proc = subprocess.run(["sh", "-n", str(_WRAPPER)], capture_output=True, text=True)
assert proc.returncode == 0, proc.stderr
# -- Scrubbing kernelspec is selectable ----------------------------------------
def test_kernelspec_argv_points_at_scrubbing_wrapper():
"""The shipped kernel.json launches the env-scrubbing wrapper, not bare ipykernel."""
spec = json.loads(_KERNELSPEC.read_text())
argv = spec["argv"]
assert argv[0].endswith("kernel-launch.sh")
assert "{connection_file}" in argv
def test_distinct_kernel_name_resolves_to_scrubbing_spec(tmp_path, monkeypatch):
"""A distinct kernel name resolves to the scrubbing wrapper (never the stock python3 spec)."""
kernelspec = pytest.importorskip("jupyter_client.kernelspec")
# Seed a Jupyter data dir with the custom spec under its distinct name.
data_dir = tmp_path / "jupyter"
spec_dir = data_dir / "kernels" / _KERNEL_NAME
spec_dir.mkdir(parents=True)
shutil.copy(_KERNELSPEC, spec_dir / "kernel.json")
monkeypatch.setenv("JUPYTER_PATH", str(data_dir))
manager = kernelspec.KernelSpecManager()
resolved = manager.get_kernel_spec(_KERNEL_NAME)
assert resolved.argv[0].endswith("kernel-launch.sh")
assert "{connection_file}" in resolved.argv
# -- Per-session workspace perms (0700) ----------------------------------------
def _exec_setup_in_tmp(code: str) -> None:
"""Run the kernel-side setup snippet in-process (it is plain os.* calls)."""
exec(compile(code, "<setup>", "exec"), {})
def test_prime_creates_workspace_mode_0700(tmp_path, monkeypatch):
"""`_prime` creates the workspace root and per-session dir at mode 0700."""
root = tmp_path / "docsgpt-sandbox"
monkeypatch.setattr(jupyter_gateway, "_WORKSPACE_ROOT", str(root))
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
workspace = f"{root}/conv-perms"
kernel = _Kernel("kid", workspace)
captured = {}
def fake_run(_kernel, code, _timeout):
captured["code"] = code
_exec_setup_in_tmp(code)
return ExecResult(status="ok", exit_code=0)
monkeypatch.setattr(sb, "_run", fake_run)
monkeypatch.chdir(tmp_path) # _prime's os.chdir must land somewhere harmless
sb._prime(kernel)
assert kernel.initialized
assert stat.S_IMODE(os.stat(root).st_mode) == 0o700
assert stat.S_IMODE(os.stat(workspace).st_mode) == 0o700
# -- Output cap: rich outputs count against the byte budget --------------------
def test_rich_payload_bytes_sums_all_data():
content = {"data": {"text/html": "x" * 100, "text/plain": "y" * 50, "application/json": {"a": 1}}}
n = JupyterKernelGatewaySandbox._rich_payload_bytes(content)
assert n >= 150 # html + plain + serialized json all counted
def _frame(msg_id, msg_type, content):
return json.dumps({"parent_header": {"msg_id": msg_id}, "msg_type": msg_type, "content": content})
class _FakeWS:
def __init__(self, frames):
self._frames = list(frames)
def settimeout(self, _t):
pass
def recv(self):
if self._frames:
return self._frames.pop(0)
import websocket
raise websocket.WebSocketConnectionClosedException()
class _TimeoutWS:
"""A channel that never yields another frame, even after an interrupt."""
def settimeout(self, _t):
pass
def recv(self):
import websocket
raise websocket.WebSocketTimeoutException()
class _TimeoutThenIdleWS:
"""Reach the exec deadline, then report matching idle after the interrupt."""
def __init__(self, msg_id):
self._msg_id = msg_id
self._calls = 0
def settimeout(self, _t):
pass
def recv(self):
import websocket
self._calls += 1
if self._calls == 1:
raise websocket.WebSocketTimeoutException()
return _frame(self._msg_id, "status", {"execution_state": "idle"})
def test_timeout_deletes_and_invalidates_kernel_when_interrupt_never_idles(monkeypatch):
"""A kernel that catches the interrupt cannot outlive the execution deadline."""
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
kernel = _Kernel("stuck-kernel", "/tmp/stuck")
sb._kernels["session-stuck"] = kernel
interrupted = []
deleted = []
monkeypatch.setattr(sb, "_interrupt", lambda kernel_id: interrupted.append(kernel_id))
monkeypatch.setattr(sb, "_delete_kernel", lambda kernel_id: deleted.append(kernel_id) or True)
result = sb._collect(
_TimeoutWS(),
"timed-out-message",
timeout=5,
kernel_id=kernel.kernel_id,
)
assert result.error_name == "TimeoutError"
assert result.runtime_invalidated is True
assert interrupted == [kernel.kernel_id]
assert deleted == [kernel.kernel_id]
assert "session-stuck" not in sb._kernels
def test_timeout_keeps_kernel_when_interrupt_reaches_matching_idle(monkeypatch):
"""A confirmed-idle interrupted kernel remains reusable and is not deleted."""
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
kernel = _Kernel("reusable-kernel", "/tmp/reusable")
sb._kernels["session-reusable"] = kernel
interrupted = []
deleted = []
monkeypatch.setattr(sb, "_interrupt", lambda kernel_id: interrupted.append(kernel_id))
monkeypatch.setattr(sb, "_delete_kernel", lambda kernel_id: deleted.append(kernel_id) or True)
msg_id = "timed-out-message"
result = sb._collect(
_TimeoutThenIdleWS(msg_id),
msg_id,
timeout=5,
kernel_id=kernel.kernel_id,
)
assert result.error_name == "TimeoutError"
assert result.runtime_invalidated is False
assert interrupted == [kernel.kernel_id]
assert deleted == []
assert sb._kernels["session-reusable"] is kernel
def test_timeout_invalidation_does_not_remove_replacement_kernel(monkeypatch):
"""Late cleanup of an old timeout must leave a newly opened generation registered."""
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
old = _Kernel("old-kernel", "/tmp/old")
replacement = _Kernel("new-kernel", "/tmp/new")
sb._kernels["session-race"] = old
deleted = []
def replace_during_drain(*_args):
sb._kernels["session-race"] = replacement
return False
monkeypatch.setattr(sb, "_interrupt_and_drain", replace_during_drain)
monkeypatch.setattr(sb, "_delete_kernel", lambda kernel_id: deleted.append(kernel_id) or True)
result = sb._collect(
_TimeoutWS(),
"timed-out-message",
timeout=5,
kernel_id=old.kernel_id,
)
assert result.runtime_invalidated is True
assert deleted == [old.kernel_id]
assert sb._kernels["session-race"] is replacement
def test_failed_replacement_race_quarantine_does_not_block_unrelated_open(monkeypatch):
"""A failed old-id DELETE remains scoped to its original session."""
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
old = _Kernel("old-kernel", "/tmp/old", "session-race")
replacement = _Kernel("new-kernel", "/tmp/new", "session-race")
def replace_during_drain(*_args):
sb._kernels["session-race"] = replacement
return False
class _Response:
status_code = 201
def raise_for_status(self):
pass
def json(self):
return {"id": "unrelated-kernel"}
monkeypatch.setattr(sb, "_interrupt_and_drain", replace_during_drain)
monkeypatch.setattr(sb, "_delete_kernel", lambda _kernel_id: False)
monkeypatch.setattr(jupyter_gateway.requests, "post", lambda *args, **kwargs: _Response())
monkeypatch.setattr(sb, "_prime", lambda _kernel: None)
result = sb._collect(
_TimeoutWS(),
"timed-out-message",
timeout=5,
kernel_id=old.kernel_id,
session_id=old.session_id,
)
assert result.runtime_invalidated is True
assert sb._quarantined_kernels == {old.kernel_id: "session-race"}
assert sb.open("unrelated") == "unrelated-kernel"
assert sb._kernels["session-race"] is replacement
assert sb._quarantined_kernels == {old.kernel_id: "session-race"}
def test_delete_kernel_retries_transient_http_failure(monkeypatch):
"""A transient gateway failure gets one more termination attempt."""
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
statuses = iter((500, 204))
calls = []
class _Response:
def __init__(self, status_code):
self.status_code = status_code
def fake_delete(url, **_kwargs):
calls.append(url)
return _Response(next(statuses))
monkeypatch.setattr(jupyter_gateway.requests, "delete", fake_delete)
assert sb._delete_kernel("stuck-kernel") is True
assert calls == [
"http://unused/api/kernels/stuck-kernel",
"http://unused/api/kernels/stuck-kernel",
]
def test_delete_kernel_reports_failure_after_both_attempts(monkeypatch, caplog):
"""Two non-success responses leave termination explicitly unconfirmed."""
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
statuses = iter((500, 503))
calls = []
class _Response:
def __init__(self, status_code):
self.status_code = status_code
def fake_delete(url, **_kwargs):
calls.append(url)
return _Response(next(statuses))
monkeypatch.setattr(jupyter_gateway.requests, "delete", fake_delete)
assert sb._delete_kernel("stuck-kernel") is False
assert len(calls) == 2
assert "Failed to delete kernel stuck-kernel after retry: HTTP 503" in caplog.text
def test_failed_timeout_delete_is_quarantined_and_cold_open_fails_closed(monkeypatch):
"""An undeletable runaway remains retryable and blocks a duplicate cold kernel."""
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
kernel = _Kernel("stuck-kernel", "/tmp/stuck")
sb._kernels["session-stuck"] = kernel
delete_calls = []
post_calls = []
monkeypatch.setattr(sb, "_interrupt", lambda _kernel_id: None)
monkeypatch.setattr(
sb,
"_delete_kernel",
lambda kernel_id: (delete_calls.append(kernel_id), False)[1],
)
monkeypatch.setattr(
jupyter_gateway.requests,
"post",
lambda *args, **kwargs: post_calls.append((args, kwargs)),
)
result = sb._collect(
_TimeoutWS(), "timed-out-message", timeout=5, kernel_id=kernel.kernel_id
)
assert result.runtime_invalidated is True
assert "session-stuck" not in sb._kernels
assert sb._quarantined_kernels == {kernel.kernel_id: "session-stuck"}
with pytest.raises(RuntimeError, match="could not be terminated"):
sb.open("session-stuck")
assert delete_calls == [kernel.kernel_id, kernel.kernel_id]
assert post_calls == []
assert sb._quarantined_kernels == {kernel.kernel_id: "session-stuck"}
def test_cached_replacement_retries_old_quarantine_without_touching_replacement(monkeypatch):
"""Cached-session traffic retires only the old immutable kernel id."""
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
replacement = _Kernel("replacement-kernel", "/tmp/replacement")
sb._kernels["session-race"] = replacement
sb._quarantined_kernels["old-kernel"] = "session-race"
deleted = []
monkeypatch.setattr(
sb,
"_delete_kernel",
lambda kernel_id: (deleted.append(kernel_id), True)[1],
)
assert sb._get_kernel("session-race") is replacement
assert deleted == ["old-kernel"]
assert sb._quarantined_kernels == {}
assert sb._kernels["session-race"] is replacement
def test_collect_caps_oversize_rich_output(monkeypatch):
# A huge execute_result must NOT be buffered: once it would exceed the byte
# budget the bundle is dropped and the result is marked truncated.
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused", max_output_bytes=1000)
monkeypatch.setattr(sb, "_interrupt_and_drain", lambda *a, **k: None)
msg_id = "m1"
frames = [_frame(msg_id, "execute_result", {"data": {"text/html": "H" * 5000}})]
result = sb._collect(_FakeWS(frames), msg_id, timeout=5, kernel_id="k1")
assert result.results == [] # over-budget bundle dropped, not materialized
assert "[output truncated" in result.stderr
assert result.truncated is True # truncation is surfaced explicitly, not hidden behind "ok"
def test_collect_keeps_small_rich_output(monkeypatch):
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused", max_output_bytes=10000)
monkeypatch.setattr(sb, "_interrupt_and_drain", lambda *a, **k: None)
msg_id = "m2"
frames = [
_frame(msg_id, "execute_result", {"data": {"text/plain": "small"}}),
_frame(msg_id, "execute_reply", {"status": "ok", "execution_count": 1}),
_frame(msg_id, "status", {"execution_state": "idle"}),
]
result = sb._collect(_FakeWS(frames), msg_id, timeout=5, kernel_id="k2")
assert len(result.results) == 1
assert "[output truncated" not in (result.stderr or "")
assert result.truncated is False # nothing was cut
# -- open() is idempotent under concurrency -----------------------------------
def test_concurrent_open_creates_one_kernel(monkeypatch):
# Two threads opening the same session must POST exactly one kernel; a second
# would orphan on the gateway. The CV guard serializes per-session creation.
import threading
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
posts = {"n": 0}
post_lock = threading.Lock()
start = threading.Event()
class _Resp:
def raise_for_status(self):
pass
def json(self):
return {"id": f"kernel-{posts['n']}"}
def _fake_post(url, **kwargs):
with post_lock:
posts["n"] += 1
start.wait(timeout=2) # hold both threads at the POST to force the race
return _Resp()
monkeypatch.setattr(jupyter_gateway.requests, "post", _fake_post)
monkeypatch.setattr(sb, "_prime", lambda kernel: None)
results = {}
def _open(i):
results[i] = sb.open("session-x")
threads = [threading.Thread(target=_open, args=(i,)) for i in range(2)]
for t in threads:
t.start()
start.set()
for t in threads:
t.join(timeout=5)
assert posts["n"] == 1, "concurrent open() created more than one kernel"
assert results[0] == results[1] # both callers got the same kernel id
# -- Chunked put_file + large get_file round-trip ------------------------------
def _inprocess_run(calls):
"""A ``_run`` stand-in that execs the kernel program in-process and captures its stdout."""
import io as _io
from contextlib import redirect_stdout
def fake_run(_kernel, code, _timeout, max_output_bytes=None):
calls.append(code)
buf = _io.StringIO()
try:
with redirect_stdout(buf):
exec(compile(code, "<kernel>", "exec"), {})
except Exception as exc: # surface as an error ExecResult, like the real gateway
return ExecResult(status="error", error_name=type(exc).__name__, error_value=str(exc), exit_code=-1)
return ExecResult(status="ok", stdout=buf.getvalue(), exit_code=0)
return fake_run
def test_put_file_chunks_large_upload_and_get_file_round_trips(tmp_path, monkeypatch):
"""A >4 MB file is staged in multiple chunks and read back byte-for-byte.
A 7 MB file's base64 (~9.3 MB) exceeds the default 8 MB output cap, so the
file-transfer budget on get_file is what keeps it from truncating.
"""
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused", max_file_bytes=10 * 1024 * 1024)
workspace = tmp_path / "ws"
workspace.mkdir()
sb._kernels["sid"] = _Kernel("kid", str(workspace))
put_calls: list = []
monkeypatch.setattr(sb, "_run", _inprocess_run(put_calls))
data = os.urandom(7 * 1024 * 1024 + 123) # 7 MB -> 3 chunks (3 + 3 + ~1)
sb.put_file("sid", "big.bin", data)
# Chunking actually happened (one execute_request could not carry this).
assert len(put_calls) == 3
assert (workspace / "big.bin").read_bytes() == data
# get_file reassembles the same bytes and passes its length+sha256 integrity check.
assert sb.get_file("sid", "big.bin") == data
def test_put_file_writes_empty_file(tmp_path, monkeypatch):
"""An empty upload still creates the file with a single wb write."""
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
workspace = tmp_path / "ws"
workspace.mkdir()
sb._kernels["sid"] = _Kernel("kid", str(workspace))
calls: list = []
monkeypatch.setattr(sb, "_run", _inprocess_run(calls))
sb.put_file("sid", "empty.bin", b"")
assert len(calls) == 1
assert (workspace / "empty.bin").read_bytes() == b""
assert sb.get_file("sid", "empty.bin") == b""
def test_collect_file_transfer_budget_prevents_truncation(monkeypatch):
"""A payload over the default output cap but under the file-transfer budget is not truncated."""
sb = JupyterKernelGatewaySandbox(
gateway_url="http://unused", max_output_bytes=1000, max_file_bytes=5000
)
monkeypatch.setattr(sb, "_interrupt_and_drain", lambda *a, **k: None)
big = "Z" * 4000 # > max_output_bytes (1000), < _file_transfer_budget()
assert 1000 < len(big) < sb._file_transfer_budget()
# Default budget -> truncated (the get_file END marker would be dropped here).
default = sb._collect(
_FakeWS([_frame("m", "stream", {"name": "stdout", "text": big})]),
"m", timeout=5, kernel_id="k",
)
assert "[output truncated" in default.stderr
# Raised file-transfer budget -> the full payload is kept intact.
ok = sb._collect(
_FakeWS([
_frame("m", "stream", {"name": "stdout", "text": big}),
_frame("m", "execute_reply", {"status": "ok"}),
_frame("m", "status", {"execution_state": "idle"}),
]),
"m", timeout=5, kernel_id="k", max_output_bytes=sb._file_transfer_budget(),
)
assert "[output truncated" not in (ok.stderr or "")
assert ok.stdout == big
def test_prime_sweeps_stale_workspace_before_recreate(tmp_path, monkeypatch):
"""A new kernel's _prime rmtrees any stale per-session dir so old files never carry over."""
root = tmp_path / "docsgpt-sandbox"
monkeypatch.setattr(jupyter_gateway, "_WORKSPACE_ROOT", str(root))
workspace = root / "conv-stale"
workspace.mkdir(parents=True)
(workspace / "stale.txt").write_text("old")
sb = JupyterKernelGatewaySandbox(gateway_url="http://unused")
kernel = _Kernel("kid", str(workspace))
monkeypatch.setattr(sb, "_run", lambda _k, code, _t: (_exec_setup_in_tmp(code), ExecResult(status="ok"))[1])
monkeypatch.chdir(tmp_path)
sb._prime(kernel)
assert kernel.initialized
assert not (workspace / "stale.txt").exists() # stale file swept on re-open