Skip to content

run_python sandbox FRS guard bypass via unwrapped single_person return #160

Description

@anth-volk

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions