Summary
The run_python sandbox (backend/engine/sandbox.py) guards FRS row-level microdata: SafeSimulation.run_microdata raises PermissionError when dataset == "frs" and the simulation is not synthetic. That guard only holds while sandboxed code can only ever hold SafeSimulation-wrapped objects and never the raw compiled Simulation class.
SafeSimulation.single_person (a factory classmethod) forwards straight to the raw class:
@classmethod
def single_person(cls, *args, **kwargs):
return simulation_cls.single_person(*args, **kwargs)
If a factory classmethod returns a raw, unwrapped simulation_cls instance, sandboxed code can recover the unguarded class and defeat the guard. type, getattr, and dir are all in the sandbox safe builtins, so:
RawSim = type(Simulation.single_person(age=30)) # unwrapped raw class
RawSim(dataset="frs").run_microdata() # bypasses the FRS guard
Impact
Recovering the raw Simulation class lets sandboxed model-authored code construct an FRS-backed simulation and read row-level FRS microdata, directly defeating the intended row-level guard and the runtime rule that run_python must never expose real-household / row-level survey data.
Scope / contract note
Under the currently pinned compiled contract (policyengine-uk-compiled >= 0.44), single_person (and couple) are staticmethods that return (persons, benunits, households) DataFrame frames, so type(...) yields tuple and the literal exploit above is presently latent. The fix is defense-in-depth: any factory classmethod must wrap a raw simulation_cls instance it might return so the invariant ("sandboxed code never holds the raw class") holds regardless of contract drift.
This is distinct from #136 (run_python is a broad, risky surface). This issue is specifically about the intended FRS row-level guard being defeatable by a factory classmethod handing back an unwrapped raw instance. General CPython sandbox-escape vectors (e.g. ().__class__.__bases__[0].__subclasses__(), bound-method __self__ walks) remain tracked under #136.
Proposed fix
Make factory classmethods on the wrapped class wrap any raw simulation_cls instance in a SafeSimulation before returning it (via an internal __new__ + object.__setattr__ helper), so the raw class can never be recovered inside the sandbox. single_person is synthetic by construction, so its wrapped object keeps run_microdata available. Add a regression test demonstrating the escape is closed and that the documented Simulation.single_person() synthetic-household use still works.
Summary
The
run_pythonsandbox (backend/engine/sandbox.py) guards FRS row-level microdata:SafeSimulation.run_microdataraisesPermissionErrorwhendataset == "frs"and the simulation is not synthetic. That guard only holds while sandboxed code can only ever holdSafeSimulation-wrapped objects and never the raw compiledSimulationclass.SafeSimulation.single_person(a factory classmethod) forwards straight to the raw class:If a factory classmethod returns a raw, unwrapped
simulation_clsinstance, sandboxed code can recover the unguarded class and defeat the guard.type,getattr, anddirare all in the sandbox safe builtins, so:Impact
Recovering the raw
Simulationclass lets sandboxed model-authored code construct an FRS-backed simulation and read row-level FRS microdata, directly defeating the intended row-level guard and the runtime rule thatrun_pythonmust never expose real-household / row-level survey data.Scope / contract note
Under the currently pinned compiled contract (
policyengine-uk-compiled >= 0.44),single_person(andcouple) are staticmethods that return(persons, benunits, households)DataFrame frames, sotype(...)yieldstupleand the literal exploit above is presently latent. The fix is defense-in-depth: any factory classmethod must wrap a rawsimulation_clsinstance it might return so the invariant ("sandboxed code never holds the raw class") holds regardless of contract drift.This is distinct from #136 (run_python is a broad, risky surface). This issue is specifically about the intended FRS row-level guard being defeatable by a factory classmethod handing back an unwrapped raw instance. General CPython sandbox-escape vectors (e.g.
().__class__.__bases__[0].__subclasses__(), bound-method__self__walks) remain tracked under #136.Proposed fix
Make factory classmethods on the wrapped class wrap any raw
simulation_clsinstance in aSafeSimulationbefore returning it (via an internal__new__+object.__setattr__helper), so the raw class can never be recovered inside the sandbox.single_personis synthetic by construction, so its wrapped object keepsrun_microdataavailable. Add a regression test demonstrating the escape is closed and that the documentedSimulation.single_person()synthetic-household use still works.