Skip to content

sandbox

The security layer: AST validation of formulas and code blocks, module classification, and inspection of a workbook file without executing anything in it. The threat model is in Security plan.

sandbox

Security sandbox for formula evaluation and module loading.

FileInfo dataclass

FileInfo(
    has_code: bool = False,
    code_preview: str = "",
    code_lines: int = 0,
    requires: list[str] = list(),
    formula_count: int = 0,
    cell_count: int = 0,
    blocked_modules: list[str] = list(),
    side_effect_modules: list[str] = list(),
    unknown_modules: list[str] = list(),
)

Metadata extracted from a spreadsheet file without executing it.

LoadPolicy dataclass

LoadPolicy(
    load_code: bool = False,
    approved_modules: list[str] = list(),
    allow_unknown: bool = False,
)

Controls what gets loaded from a spreadsheet file.

trust_all staticmethod
trust_all(requires: list[str] | None = None) -> LoadPolicy

Approve everything -- code block and all requested modules.

Source code in src/gridcalc/sandbox.py
@staticmethod
def trust_all(requires: list[str] | None = None) -> LoadPolicy:
    """Approve everything -- code block and all requested modules."""
    return LoadPolicy(load_code=True, approved_modules=list(requires or []), allow_unknown=True)
formulas_only staticmethod
formulas_only() -> LoadPolicy

Load cell data and formulas only, skip code and modules.

Source code in src/gridcalc/sandbox.py
@staticmethod
def formulas_only() -> LoadPolicy:
    """Load cell data and formulas only, skip code and modules."""
    return LoadPolicy(load_code=False, approved_modules=[])

configure_sandbox

configure_sandbox(enabled: bool) -> None

Set sandbox state from config. Env var GRIDCALC_SANDBOX takes precedence.

Source code in src/gridcalc/sandbox.py
def configure_sandbox(enabled: bool) -> None:
    """Set sandbox state from config. Env var GRIDCALC_SANDBOX takes precedence."""
    global SANDBOX_ENABLED
    if _SANDBOX_ENV is None:
        SANDBOX_ENABLED = enabled

validate_formula

validate_formula(source: str) -> tuple[bool, str]

Validate a formula expression against security rules.

Returns (is_valid, error_message). Blocks dunder attribute access, dangerous names, and known internal attributes used in sandbox escapes.

Source code in src/gridcalc/sandbox.py
def validate_formula(source: str) -> tuple[bool, str]:
    """Validate a formula expression against security rules.

    Returns (is_valid, error_message). Blocks dunder attribute access,
    dangerous names, and known internal attributes used in sandbox escapes.
    """
    if not SANDBOX_ENABLED:
        return True, ""

    try:
        tree = ast.parse(source, mode="eval")
    except SyntaxError as e:
        return False, f"syntax error: {e}"

    for node in ast.walk(tree):
        if isinstance(node, ast.Attribute):
            attr = node.attr
            if attr.startswith("__") and attr.endswith("__"):
                return False, f"dunder attribute '{attr}' is not allowed"
            if attr in _DANGEROUS_ATTRS:
                return False, f"attribute '{attr}' is not allowed"
        elif isinstance(node, ast.Name):
            name = node.id
            if name in _BLOCKED_NAMES:
                return False, f"name '{name}' is not allowed"
            if name.startswith("__") and name.endswith("__"):
                return False, f"dunder name '{name}' is not allowed"

    return True, ""

validate_code

validate_code(source: str) -> tuple[bool, str]

Validate a code block (statements) against security rules.

Applies the same AST checks as validate_formula (dunder access, dangerous names/attrs) plus blocks import of blocked modules and dangerous builtins used as statements (eval/exec/open calls).

Source code in src/gridcalc/sandbox.py
def validate_code(source: str) -> tuple[bool, str]:
    """Validate a code block (statements) against security rules.

    Applies the same AST checks as validate_formula (dunder access,
    dangerous names/attrs) plus blocks import of blocked modules and
    dangerous builtins used as statements (eval/exec/open calls).
    """
    if not SANDBOX_ENABLED:
        return True, ""

    if not source or not source.strip():
        return True, ""

    try:
        tree = ast.parse(source, mode="exec")
    except SyntaxError as e:
        return False, f"syntax error: {e}"

    for node in ast.walk(tree):
        # Block imports of blocked modules
        if isinstance(node, ast.Import):
            for alias in node.names:
                base = alias.name.split(".")[0]
                if alias.name in BLOCKED_MODULES or base in BLOCKED_MODULES:
                    return False, f"import of '{alias.name}' is blocked"
        elif isinstance(node, ast.ImportFrom):
            if node.module:
                base = node.module.split(".")[0]
                if node.module in BLOCKED_MODULES or base in BLOCKED_MODULES:
                    return False, f"import from '{node.module}' is blocked"
        # Same attribute checks as formulas
        elif isinstance(node, ast.Attribute):
            attr = node.attr
            if attr.startswith("__") and attr.endswith("__"):
                return False, f"dunder attribute '{attr}' is not allowed"
            if attr in _DANGEROUS_ATTRS:
                return False, f"attribute '{attr}' is not allowed"
        # Same name checks as formulas
        elif isinstance(node, ast.Name):
            name = node.id
            if name in _BLOCKED_NAMES:
                return False, f"name '{name}' is not allowed"
            if name.startswith("__") and name.endswith("__"):
                return False, f"dunder name '{name}' is not allowed"

    return True, ""

classify_module

classify_module(name: str) -> str

Classify a module as 'safe', 'side_effect', 'blocked', or 'unknown'.

Source code in src/gridcalc/sandbox.py
def classify_module(name: str) -> str:
    """Classify a module as 'safe', 'side_effect', 'blocked', or 'unknown'."""
    base = name.split(".")[0]
    if name in BLOCKED_MODULES or base in BLOCKED_MODULES:
        return "blocked"
    if name in SAFE_MODULES or base in SAFE_MODULES:
        return "safe"
    if name in SIDE_EFFECT_MODULES or base in SIDE_EFFECT_MODULES:
        return "side_effect"
    return "unknown"

load_modules

load_modules(
    specs: list[str], allow_unknown: bool = False
) -> tuple[dict[str, object], list[str]]

Import modules by spec. Returns (alias_to_module, error_messages).

Each spec is either a bare module name (numpy) or a name with a version specifier (numpy>=1.24, pandas==2.0.3). Supported operators: ==, >=, <=, >, <, ~=.

A module that no list classifies is refused unless allow_unknown. The blocklist cannot be the only gate: it names the dangerous modules known when it was written, so anything omitted -- runpy, which runs a Python file, or sqlite3, which writes one -- was loaded on a workbook's say-so. Refusing happens before the import, so a module with import-time side effects does not get to run either.

Source code in src/gridcalc/sandbox.py
def load_modules(
    specs: list[str], allow_unknown: bool = False
) -> tuple[dict[str, object], list[str]]:
    """Import modules by spec. Returns (alias_to_module, error_messages).

    Each spec is either a bare module name (``numpy``) or a name with a
    version specifier (``numpy>=1.24``, ``pandas==2.0.3``). Supported
    operators: ``==``, ``>=``, ``<=``, ``>``, ``<``, ``~=``.

    A module that no list classifies is refused unless ``allow_unknown``.
    The blocklist cannot be the only gate: it names the dangerous modules
    known when it was written, so anything omitted -- ``runpy``, which runs
    a Python file, or ``sqlite3``, which writes one -- was loaded on a
    workbook's say-so. Refusing happens before the import, so a module with
    import-time side effects does not get to run either.
    """
    result: dict[str, object] = {}
    errors: list[str] = []
    for spec in specs:
        name, op, ver = _parse_requirement(spec)
        cls = classify_module(name)
        if cls == "blocked":
            errors.append(f"'{name}' is blocked (security)")
            continue
        if cls == "unknown" and not allow_unknown:
            errors.append(f"'{name}' is not a recognised module and was not approved")
            continue
        try:
            mod = importlib.import_module(name)
        except ImportError:
            errors.append(f"'{name}' is not installed")
            continue
        if op is not None and ver is not None:
            try:
                installed = importlib.metadata.version(name.split(".")[0])
            except importlib.metadata.PackageNotFoundError:
                errors.append(f"'{name}': installed but version metadata not found")
                continue
            if not _check_version(installed, op, ver):
                errors.append(f"'{name}': installed {installed} does not satisfy {op}{ver}")
                continue
        alias = MODULE_ALIASES.get(name, name.split(".")[-1])
        result[alias] = mod
    return result, errors

inspect_file

inspect_file(filename: str) -> FileInfo | None

Inspect a spreadsheet file without executing anything.

Returns a FileInfo with metadata about code blocks, required modules, and cell/formula counts, or None if the file cannot be parsed.

Source code in src/gridcalc/sandbox.py
def inspect_file(filename: str) -> FileInfo | None:
    """Inspect a spreadsheet file without executing anything.

    Returns a FileInfo with metadata about code blocks, required modules,
    and cell/formula counts, or None if the file cannot be parsed.
    """
    try:
        with open(filename) as f:
            d = json.load(f)
    except (OSError, json.JSONDecodeError):
        return None

    # This runs on a file chosen precisely because it is not yet trusted, so
    # every field it reads is checked before use. `[]` and `42` are valid JSON
    # and decode without error; a number in `code` or `requires` reaches
    # `.strip()` and the requirement regex. Either one raised out of a
    # function whose contract is to report failure by returning None -- and it
    # raised inside `:open`, before the load, taking curses down with it.
    if not isinstance(d, dict):
        return None

    info = FileInfo()

    code = d.get("code", "")
    if not isinstance(code, str):
        return None
    if code.strip():
        info.has_code = True
        info.code_lines = len(code.strip().splitlines())
        info.code_preview = code.strip()

    requires = d.get("requires", [])
    if isinstance(requires, list):
        if not all(isinstance(m, str) for m in requires):
            return None
        info.requires = list(requires)
        info.blocked_modules = [
            m for m in requires if classify_module(_parse_requirement(m)[0]) == "blocked"
        ]
        info.side_effect_modules = [
            m for m in requires if classify_module(_parse_requirement(m)[0]) == "side_effect"
        ]
        info.unknown_modules = [
            m for m in requires if classify_module(_parse_requirement(m)[0]) == "unknown"
        ]

    # v2 nests cells under `sheets[].cells`; v1 has them at top level.
    # Count across every sheet -- a prompt that under-reports cells on
    # multi-sheet files trains the user to ignore it.
    sheets = d.get("sheets")
    if isinstance(sheets, list) and sheets:
        for entry in sheets:
            if isinstance(entry, dict):
                _count_cells(entry.get("cells", []), info)
    else:
        _count_cells(d.get("cells", []), info)

    return info