Reproduction steps:
- Extract a clean station-v0.69 tarball, sys.path.insert(0, "workbench").
- from module_sandbox import install_restrictions, scoped_handler, SandboxViolation
- install_restrictions({}, {"network": ["api.example.com"]}, slug="testmod")
- Define a handler that calls urllib.request.urlopen("http://127.0.0.1:1/")
directly -> wrap with scoped_handler("testmod", handler) -> call it.
=> SandboxViolation raised correctly.
- Define a second handler that does the SAME urlopen call, but from inside
a spawned threading.Thread (t = threading.Thread(target=worker); t.start(); t.join()).
Wrap with scoped_handler and call it.
Expected: SandboxViolation, same as step 4 — the handler is still "a module
handler on the call stack" regardless of which OS thread does the I/O.
Actual: no SandboxViolation. The real urlopen() runs — request reached the
network layer (only failed with ConnectionRefused because nothing was
listening on the test port; against a real off-allowlist host it succeeds).
Root cause: workbench/module_sandbox.py — the new scoping uses a bare
contextvars.ContextVar (_ACTIVE_SANDBOX, set in sandbox_active() /
scoped_handler(), read in _wrapped_urlopen / _wrapped_httpconn_init /
_wrapped_socket_connect at lines ~265, ~291, ~302). A ContextVar's value is
NOT inherited by a new OS thread started with threading.Thread — Python only
copies context automatically for asyncio tasks, not raw threads. A thread
spawned by handler code gets .get() == None, which every wrap treats as
"no active sandbox — this is station code, pass through." So the exact
"is this station or module code?" check the fix relies on returns the wrong
answer for any module that offloads its I/O to a thread — including the
common concurrent.futures.ThreadPoolExecutor pattern used for parallel API
calls, since ThreadPoolExecutor doesn't copy context either.
Suggested fix: don't rely on ambient ContextVar propagation across threads
you don't control. Mirror the pattern module_sandbox.py already uses for the
subprocess/filesystem gates (ns-scoped proxies) — inject a ns-scopedthreading proxy whose Thread wraps target withcontextvars.copy_context().run(target, *args), so any thread the module
spawns carries the sandbox forward. (concurrent.futures would need the same
treatment, or ban it from the handler namespace outright.)
Station version (railcall version): station-v0.69
Module slug + version: Not module-specific; workbench/module_sandbox.py
network gate (contextvar scoping introduced in the v0.69 fix for the
Teams-relay leak)