Skip to content

fix(sandbox): isolate LLM-generated code execution in a subprocess - #6

Open
pydev42 wants to merge 1 commit into
BioIntelligence-Lab:mainfrom
pydev42:fix/sandbox-process-isolation
Open

fix(sandbox): isolate LLM-generated code execution in a subprocess#6
pydev42 wants to merge 1 commit into
BioIntelligence-Lab:mainfrom
pydev42:fix/sandbox-process-isolation

Conversation

@pydev42

@pydev42 pydev42 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Problem

core/sandbox.py is the execution path for every LLM-generated Python snippet in the app — used by code_gen, radiomics, monai_infer, idc_query, bih_query, and midrc_query. Currently it's a bare exec() with full builtins, running in the same process as the server, with the real os module handed to generated code directly:

def run_user_code_inproc(code: str, local_env: Dict[str, Any]) -> Dict[str, Any]:
    env = {"__builtins__": __builtins__}
    if local_env:
        env.update(local_env)
    exec(code, env, env)
    return {"res_query": env.get("res_query")}

There's an EXECUTION_MODE env var that implies a subprocess isolation mode exists, but run_user_code_subprocess was a dead, commented-out stub — the only mode that actually ran was in-process, unsandboxed.

Impact: anything that influences what code gets generated (a crafted prompt, or content the model reads back from a queried dataset) has a path to full host-level compromise — read/write any file the process user can touch, spawn subprocesses, and read the app's own .env secrets via os.environ. Separately, because run_user_code is called synchronously from inside async def tool handlers, a hung or infinite-looping snippet blocks the server's entire event loop indefinitely — the per-tool asyncio.wait_for timeout can't preempt a blocking synchronous call, so it never actually fires. There's no SECURITY.md, so this is being reported via PR rather than a private channel.

Fix

Implements the subprocess mode the code already gestured at:

  • Runs generated code in a separate spawned process — its own address space, no inherited DB/API client objects reachable via sys.modules the way an in-process/forked child would have.
  • Enforces a real wall-clock timeout via process termination — the only mechanism that can actually stop a runaway/malicious snippet given the sync-call-inside-async-handler shape above.
  • Redacts anything that looks like a credential (KEY/SECRET/TOKEN/PASSWORD/CREDENTIAL/_AUTH) from the child's environment before running user code.
  • Adds optional, off-by-default resource limits (CPU/memory/nproc) for deployments with a known workload profile. Not on by default: the six tools span lightweight pandas queries and GPU torch/MONAI inference, and a strict RLIMIT_AS in particular tends to break CUDA context creation regardless of actual memory used. tools/idc_python_worker.py already sets a precedent for a narrower, CPU-only worker (RLIMIT_CPU + a 4GB RLIMIT_AS) if useful reference.
  • Flips the EXECUTION_MODE default from inproc to subprocess. inproc stays available (opt-in) for trusted, already-sandboxed deployments that want to avoid the subprocess overhead.

local_env dicts pass module objects (pd, os, plt, nib, ...) into generated code, and plain pickle can't serialize module objects — the child re-imports them by name instead (cheap; already loaded). monai_infer.py's two closures (_save_pred_as_nifti, _normalize_to_HWD) are hoisted to module level so they're picklable by reference — neither closes over anything instance-specific, so this is a pure move, no behavior change.

Scope

This closes the two most severe gaps (zero isolation, zero timeout) and the credential-exfiltration-via-os.environ path. It does not add filesystem or network confinement — the six tools intentionally need real file I/O and CLI subprocess access (per prompts/agent_systems/code_gen.txt, e.g. calling TotalSegmentator/dcm2niix), so full containment would need a container/chroot boundary, which is a separate, larger change. Happy to follow up on that if it's wanted.

Tests

tests/test_sandbox.py (stdlib unittest, no new dependency — the project doesn't have a test runner configured yet) covers: basic execution, module/data values in local_env, exception propagation, env secret redaction, and timeout enforcement. All pass locally:

$ python3 tests/test_sandbox.py -v
...
Ran 6 tests in 5.198s
OK

Also manually verified a pandas.DataFrame query and a matplotlib.figure.Figure both round-trip correctly through the subprocess boundary — both are explicitly permitted res_query return types per prompts/agent_systems/shared/output_contract.txt.


Prepared with AI assistance under my review; reproduction, the isolation design, and tests were verified locally before opening this PR.

core/sandbox.py ran every LLM-generated snippet (code_gen, radiomics,
monai_infer, idc_query, bih_query, midrc_query) via a bare exec() with
full builtins and the real os module in scope, in the same process as
the server. No isolation, no timeout, and the EXECUTION_MODE=subprocess
toggle it exposed pointed at a dead, never-implemented stub - the only
mode that actually ran was in-process.

Practical effect: anything that influences what code gets generated has
a path to full host-level compromise - read/write any file the process
user can touch, spawn subprocesses, and read the app's own .env secrets
via os.environ, which the sandbox handed to generated code directly.
Also, because run_user_code is called synchronously from inside async
tool handlers, a hung or infinite-looping snippet blocks the whole
server's event loop indefinitely - asyncio's per-tool timeout can't
preempt a blocking call, so it never actually fires.

This implements the subprocess mode the code already gestured at:
- runs generated code in a separate spawned process (own address
  space, no inherited DB/API client objects reachable via sys.modules)
- enforces a real wall-clock timeout via process termination, the only
  mechanism that can actually stop a runaway/malicious snippet given
  the sync-call-inside-async-handler shape above
- strips anything that looks like a credential (KEY/SECRET/TOKEN/
  PASSWORD/CREDENTIAL/_AUTH) from the child's environment
- keeps optional, off-by-default resource limits (CPU/memory/nproc)
  for deployments with a known workload profile; not on by default
  since the six tools span lightweight pandas queries and GPU
  torch/MONAI inference, and a strict RLIMIT_AS in particular tends to
  break CUDA context creation regardless of actual memory used
- flips the EXECUTION_MODE default from inproc to subprocess; inproc
  stays available for trusted, already-sandboxed deployments

local_env dicts pass module objects (pd, os, plt, nib, ...), which
plain pickle can't serialize - the child re-imports them by name
instead. monai_infer.py's two closures (_save_pred_as_nifti,
_normalize_to_HWD) are hoisted to module level so they're picklable by
reference; neither closes over anything instance-specific, so this is
a pure move with no behavior change.

Scope: this stops the two most severe gaps (zero isolation, zero
timeout) and closes the credential-exfiltration-via-os.environ path.
It does not add filesystem or network confinement - the six tools
intentionally need real file I/O and CLI subprocess access (per
prompts/agent_systems/code_gen.txt), so that would need a container/
chroot boundary, which is a separate, larger change.

Tests: tests/test_sandbox.py (stdlib unittest, no new dependency) -
covers basic execution, module/data values in local_env, exception
propagation, env secret redaction, and timeout enforcement. All pass
locally; also manually verified DataFrame and matplotlib Figure
return values round-trip through the subprocess boundary (both are
explicitly permitted res_query types per
prompts/agent_systems/shared/output_contract.txt).

Prepared with AI assistance under my review; reproduction, the
isolation design, and tests were verified locally before opening
this PR.
@pydev42
pydev42 force-pushed the fix/sandbox-process-isolation branch from 0785aa2 to 6af5aba Compare August 24, 2026 03:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant