gridcalc Security Plan¶
Problem Statement¶
gridcalc evaluates user-provided formulas with Python's eval() and executes code blocks with exec(). This is the source of its power -- users get the full expressiveness of Python expressions, including list comprehensions, math functions, and (with this plan) third-party libraries like numpy. But eval() and exec() are also the primary attack surface if gridcalc ever loads untrusted content.
Python was never designed to be sandboxed. Every few years someone discovers a new escape through __class__.__subclasses__() or similar introspection chains. Restricting builtins alone is insufficient. Full Python library access and secure sandboxing are fundamentally in tension -- if numpy is in the eval namespace, a formula can call np.save(). You cannot expose a library's power while blocking all its side effects.
This plan takes a layered approach: no single layer is sufficient, but together they provide meaningful defense against the real threat (untrusted files) without sacrificing power for the interactive user.
Threat Model¶
| Scenario | Threat | Real? |
|---|---|---|
| User types formulas at keyboard | None -- they are attacking themselves | No |
User types code in :e editor |
None -- intentional | No |
| User loads untrusted .json file | Malicious formulas + code blocks | Yes |
| User shares a spreadsheet | Accidental code exposure | Low |
User loads a .json with no or unknown mode |
Loads as PYTHON mode; formulas eval() with no prompt |
Yes |
User starts gridcalc in a directory holding a gridcalc.toml |
sandbox = false there turns off validation and the trust prompt |
Yes |
The only real threat is loading a file from an untrusted source. The user sitting at the keyboard is the trust boundary. A config file in the working directory is untrusted content too (see "Known gaps").
Architecture: Four Layers¶
Layer 1: Module Registry¶
Users declare which third-party libraries are available, either per-spreadsheet (via a "requires" field in the JSON file) or globally. gridcalc imports approved modules and injects them into the formula eval namespace.
Modules are classified into three categories:
-
Safe (compute-only, no meaningful side effects):
numpy,scipy,sympy,decimal,fractions,statistics,cmath,itertools,functools,operator,collections. These are injected directly (e.g.,npfor numpy). -
Side-effect (can do I/O but are commonly needed):
matplotlib,matplotlib.pyplot,pandas,csv,xlsxwriter. These are injected but flagged at the trust prompt. -
Blocked (filesystem, network, process control):
os,sys,subprocess,shutil,pathlib,socket,http,importlib,ctypes,pickle, etc. Never injected, even if requested. -
Unknown (everything else): refused unless the user approves them as a separate, deliberate answer at the trust prompt. The blocklist names the dangers known when it was written, so "not blocked" was never the same claim as "safe" --
runpyruns a Python file andsqlite3writes one, and neither appears above. The refusal precedes the import, so a module with an import-time side effect does not get to run before being turned down.
Common aliases are applied automatically: numpy -> np, pandas -> pd, matplotlib.pyplot -> plt.
Layer 2: AST Validation (Defense-in-Depth)¶
Before eval(), every formula is parsed with ast.parse() and the AST is walked to block dangerous patterns:
-
Dunder attribute access is blocked:
x.__class__,x.__subclasses__(),x.__globals__, etc. This prevents the known class of Python sandbox escapes that crawl the object graph from any object toobject.__subclasses__()and from there toos,subprocess, etc. -
Dangerous names are blocked:
__import__,eval,exec,compile,getattr,setattr,delattr,globals,locals,type,super,open,breakpoint, etc. -
Dangerous internal attributes are blocked:
func_globals,f_globals,co_consts,tb_frame,gi_frame, etc.
What remains allowed:
-
Arithmetic, comparisons, boolean logic
-
Function calls (
SUM(A1:A3),np.mean(x)) -
Attribute access on non-dunder names (
np.array,df.groupby) -
List/set/dict comprehensions and generator expressions
-
Lambda expressions
-
Subscript and slice access (
vals[0],arr[1:3])
This is not airtight -- new escape vectors get discovered. ~~But it blocks all known Python sandbox escapes while preserving full library access.~~ It does not: the check is syntactic, and string-borne attribute access passes it (see "Known gaps"). It is defense-in-depth, not the primary security boundary.
Layer 3: Trust Gate on File Load¶
When loading a .json spreadsheet that contains code blocks or module requirements, the user is prompted before anything executes:
Terminal prompt (startup):
Loading: budget.json
Cells: 47 (12 formulas)
Requires: numpy, matplotlib [side_effect]
Code: 8 lines
[a]pprove [f]ormulas only [v]iew code [c]ancel:
Curses prompt (:o command): Same information, rendered in the TUI.
Options:
-
Approve -- load everything (code block, modules, formulas)
-
Formulas only -- load cell data and formulas, skip code block and modules. This is not a code-free load: a PYTHON-mode file's formulas are still
eval()ed at load. -
View code -- display the code block for review
-
Cancel -- abort the load
Files with no code block and no requires field load silently (backward compatible). AST validation still applies to all formulas. The trust gate covers the code block and modules, not formulas.
This is the same trust model browsers use for Office macros: the file format can carry executable content, but loading it requires explicit consent.
Layer 4: Restricted Builtins (Existing)¶
The eval globals dict restricts __builtins__ to a curated set: abs, min, max, sum, len, int, float, round, range, enumerate, zip, map, filter, list, tuple, True, False, None, isinstance. This blocks __import__, open, exec, eval, getattr, and other dangerous builtins at the Python level. Combined with AST validation, this provides two independent barriers.
Layer Summary¶
| Layer | What it does | What it catches |
|---|---|---|
| Module registry | Controls what is in the namespace | Blocks os/subprocess/socket entirely |
| AST validation | Blocks dunder introspection chains | Privilege escalation from objects |
| Trust gate on load | User approves before code/modules execute | Malicious code blocks in .json files |
| Restricted builtins | Limits Python builtins in eval | Direct access to dangerous functions |
JSON File Format Extension¶
The requires field declares module dependencies:
{
"requires": ["numpy", "matplotlib"],
"code": "def custom_func(x): return np.mean(x)",
"cells": [
[10, 20, 30],
["=custom_func(A1:A3)"]
]
}
Files without requires are fully backward compatible.
Caveats¶
-
If the user approves a file and numpy is available, a formula could still call
np.save('/tmp/exfil', data). The trust gate is the real security boundary; AST validation reduces blast radius but cannot prevent all side effects of approved libraries. -
There is no resource boundary. Workbook code runs in the application's own process, and AST validation permits loops, comprehensions, and large allocations, because none of those is distinguishable from legitimate computation by inspecting the syntax tree.
while True: passhangs the application and an unbounded allocation exhausts its memory. Containing this needs a worker process with wall-clock and memory limits, not a stricter validator: the four layers above address what untrusted code can reach, not how long it may run. Until that exists, an approved workbook is trusted with the session's availability as well as its data. -
Code blocks (
:eeditor) run unrestricted. This is intentional -- the user is the trust boundary. Code blocks from JSON files require explicit approval. -
Named ranges that shadow module aliases (e.g., a range named
np) will override the module in the eval namespace. This is documented behavior. -
The
exec()of the code block runs on every recalc pass. Functions defined in code blocks are redefined each time. This is harmless but wasteful.
Known gaps¶
Open findings from REVIEW.md section 5.1, verified against the code. None is fixed.
-
Formula validation misses string-borne attribute access (S1).
validate_formulawalksast.Attributeandast.Namenodes only. A format string can name dunder attributes and globals of a callable in the namespace.str.format,%, f-string format specs andformat_mapall work. The demonstrated effect is read-only disclosure, such as environment variables. No call primitive has been shown. -
Formulas-only still evaluates formulas (S2).
LoadPolicy.formulas_only()gates the code block and modules.recalc()stilleval()s every PYTHON-mode formula at load.needs_trustasks only about code andrequires, so S1 runs on the default load path with no prompt. -
A missing or unknown
modeloads as PYTHON (S3).jsonloaddefaults toMode.PYTHONwhenmodeis absent or unparseable. A file that omitsmodegetseval()semantics. v1 files predate modes, so changing the default needs a migration decision. -
A working-directory
gridcalc.tomlcan disable the sandbox (S4). Config lookup checks the CWD before$XDG_CONFIG_HOME. The TUI and the headless CLI apply itssandboxkey. Withsandbox = false, validation and the startup trust prompt are both off.GRIDCALC_SANDBOXoverrides config when set. -
The startup trust prompt prints the code preview unescaped (S5). Terminal escape sequences in a code comment can hide lines on screen.
-
Module classification uses the top-level name (S6).
classify_module("numpy.ctypeslib")returnssafe. -
No limit on formula depth or run time (S7). Neither needs approval. A deeply nested formula raises
RecursionError, which the web and CLI load paths now catch. A formula that builds a 10^8-element array hangs the session.
Implementation¶
-
gridcalc/sandbox.py-- module classification, AST validation, FileInfo, LoadPolicy -
gridcalc/engine.py-- integration (validate_formula in recalc, requires in Grid, jsoninspect, policy-aware jsonload) -
gridcalc/tui/commands.py-- trust gate prompt (curses) -
gridcalc/web/frontend/src/components/TrustDialog.tsx-- the same gate in the desktop app;gridcalc/loader.py:needs_trustdecides when either frontend must ask -
tests/test_sandbox.py-- comprehensive tests