Repository navigation
eval
py-sandboxes guards imports, files, sockets, environment variables and a
registry of sensitive functions. None of those layers looks at code that
arrives as a string at runtime.
This is common when a tool evaluates model-generated code. For example:
result = eval(expression, {"__builtins__": {}}, {})is not protected by the emptied __builtins__: the payload below reaches
Popen without naming a single builtin.
"[c for c in ().__class__.__base__.__subclasses__() if c.__name__=='Popen'][0](['/bin/sh'])"The eval-* rules define a sub-language. Sources passed to eval, exec
or compile are parsed, checked, rewritten, then run with configured budgets
and a timeout.
Only code reaching those three builtins is transformed. Your application's own modules are untouched, and import time is unaffected.
- The three states of the guard
- Deny-all, and the minimal core
- How the rules combine
- The five list keys
- The eight scalar keys
- Profiles
- Error reporting
- Learning mode
- Example profiles
- What this does not cover
eval, exec and compile are in the dynamic-code sensitive-function
category. Without an eval-* rule, they are denied unless that category is
explicitly allowed.
| Configuration |
eval() behaviour |
|---|---|
no eval-* key, no python-api=ALLOW:dynamic-code
|
refused, RuleApiPermissionError
|
at least one eval-* key |
guarded: parsed, validated, rewritten, run under budget |
python-api=ALLOW:dynamic-code |
unguarded, an explicit escape hatch, warned like process-exec
|
Both at once are refused when the profile loads: the grant would run the code
unguarded and leave the eval-* keys unused. Each one is reported, to comment
out, or remove the grant.
ast.literal_eval is not concerned: it evaluates literals only and constructs
no code object.
Runtime code generation in the standard library and installed site-packages
is exempt; application call sites are guarded.
The accepted language is a whitelist. With an eval-* key present, only
the minimal core and constructs opened by the rules are allowed.
The minimal core is not expressible and not removable:
| Construct | Why it cannot be optional |
|---|---|
Module, Expression, Interactive
|
the parse root |
Constant |
a literal is the smallest useful expression |
Name read |
reading a variable you provided |
Tuple, List, Dict, Set literals |
container literals |
Expr |
an expression used as a statement |
So 1 + 1 is refused until eval-syntax=arith is present. That inversion
is what the whole design rests on.
# Valid: a profile that evaluates literals and nothing else
eval-timeout=2seval("[1, 2, 3]") # OK — a container literal is in the core
eval("1 + 1") # REFUSED — 'BinOp' is not allowedList keys accumulate into unordered allow and deny sets. Repeated keys union
their values, so order and include placement do not change the result.
One resolution rule: DENY: wins, always, wherever it appears. No
specificity comparison, no precedence table, no last-one-wins.
# Valid — these two lines mean the same thing in either order
eval-attribute=get*, is*
eval-attribute=DENY:get_secret# Invalid — DENY: and patterns apply to list keys only
eval-timeout=DENY:5s
eval-namespace=adaptive*Patterns use the single-* glob from env= on list keys other than
eval-syntax, whose vocabulary is finite.
# Valid
eval-attribute=get*, to_*
eval-import=json.*# Invalid — a pattern on a closed vocabulary only obscures it
eval-syntax=Bin*Patterns with a fixed part shorter than three characters, including *, emit
a warning listing their expansion on the current interpreter:
eval-attribute=ge* expands to 47 names on this interpreter, including
'get_secret' — narrow the pattern or add a DENY
Which Python constructs the source may use. Accepts a group or the exact
name of an ast node class, mixed freely — the same shape as
python-api=ALLOW:<category> versus ALLOW:os.system.
| Group | Nodes |
|---|---|
arith |
BinOp, UnaryOp
|
compare |
Compare, BoolOp
|
conditional |
If, IfExp
|
loop |
For, While, Break, Continue
|
comprehension |
ListComp, SetComp, DictComp, GeneratorExp, comprehension
|
assign |
Assign, AugAssign, AnnAssign, NamedExpr
|
func |
FunctionDef, Return, arguments, arg, Lambda
|
async |
AsyncFunctionDef, Await, AsyncFor, AsyncWith
|
class |
ClassDef |
exception |
Try, TryStar, Raise, ExceptHandler
|
context |
With, withitem
|
import |
Import, ImportFrom, alias
|
subscript |
Subscript, Slice, Starred
|
fstring |
JoinedStr, FormattedValue
|
tstring |
TemplateStr, Interpolation (Python 3.14 and later) |
yield |
Yield, YieldFrom
|
Async is neither privileged nor special-cased: eval-syntax=async opens it,
its absence closes it, exactly like loop.
Some statements belong to no group and are granted only by their node name:
match (Match, match_case and the Match* patterns), type aliases and
parameters (TypeAlias, TypeVar, ParamSpec, TypeVarTuple), Global,
Nonlocal, Delete and Assert. Starred sits in subscript, so granting
that group also grants unpacking (f(*args), a, *b = c).
Writing never reaches the data the application hands in. obj.x = v and del obj.x are refused whatever the
rules. obj[k] = v, obj[k] += v and del obj[k] are refused at runtime on an object passed through names=
or present in the namespace when the evaluation starts; a list or dict the evaluated code builds itself stays
writable. An object nested in a supplied one (data["rows"][0] = v) is not covered: hand in a copy when that
matters.
A new Python release that adds a node makes that node refused by default — the safe direction.
# Valid — a group, and one extra node by name
eval-syntax=arith, compare, Lambdaeval("(1 + 2) < 4") # OK
eval("[x for x in (1, 2)]") # REFUSED — 'ListComp' is not allowed# Invalid — 'lop' is not a group and not an ast node
eval-syntax=lop
# error: unknown syntax token 'lop', closest known token is 'loop'Which names the source may call, and what the namespace's __builtins__
is built from. The two are the same list on purpose: a name the code cannot
reach is a name it cannot call, whatever spelling it invents.
An empty eval-call is legal and coherent. CPython does not repopulate a
__builtins__ key that is present but empty, so a call-free profile evaluates
arithmetic and comparison over the data you passed in — exactly the calculator
tool the samples ship.
# Valid
eval-syntax=arith, comprehension
eval-call=len, range, sumeval("sum([i for i in range(3)])") # OK
eval("open('/etc/passwd')") # REFUSED — call to 'open' is not allowed# Invalid — eval-call names functions, never categories
eval-call=process-execWhich ordinary attribute names the source may read. Deny-all applies here
too: without the key, "ab".split() fails until split is declared.
Enforced at runtime, not only statically: whatever syntax led to the attribute, the read is checked as it happens.
| Group | Content |
|---|---|
str-methods |
public names of str, minus format and format_map
|
list-methods, dict-methods, set-methods, tuple-methods
|
public names of the type |
num-methods |
public names of int and float
|
bytes-methods |
public names of bytes
|
date-methods |
public names of datetime.date, .time, .datetime, .timedelta
|
Groups are computed from the running interpreter, not hardcoded.
format and format_map are curated out of str-methods deliberately.
Why they are excluded. str.format resolves fields such as
{0.__class__} inside C, so the AST rewriter cannot see those attribute reads.
When explicitly allowed, the guard wraps format and format_map, parses
template fields, and checks each attribute through the normal runtime gate.
string.Formatter fields receive the same check. Index fields remain data;
nested format fields are checked recursively. Outside guarded evaluation these
methods are unchanged.
Granting format requires an exact name; wildcard patterns do not enable
format or format_map. A regular f-string such as f"{o.__class__}" is
rewritten as a normal attribute access and follows the same gate.
Recommendation: write an f-string, not str.format(). An f-string needs
eval-syntax=fstring and no format grant, and each attribute it reads is
checked statically, where a format template is only checked at runtime. Keep
eval-attribute=format for code that cannot be changed. When learning mode
observes format or format_map, it writes this advice as a comment above
the generated eval-attribute line.
# Valid
eval-syntax=arith
eval-attribute=str-methods
eval-attribute=DENY:encodeDENY: prefixes a whole line: eval-attribute=str-methods, DENY:encode is
rejected at load, because the guard cannot tell a refusal from a name there.
eval("'a b'.split()") # OK
eval("'ab'.encode()") # REFUSED — DENY wins
eval("x.__class__") # REFUSED — a dunder needs eval-magic# Invalid in practice, though it parses: this disarms the layer
eval-attribute=*
# warning: expands to every name on this interpreterWriting that list by hand is impractical, and nobody should: learning mode writes it, from the names the code actually touched.
Which modules the source may import. Requires eval-syntax=import to be able
to write an import statement at all.
# Valid
eval-syntax=arith, import
eval-import=json, matheval("__import__('math')") # REFUSED — '__import__' is not in eval-call
exec("import math") # OK
exec("import os") # REFUSED — import 'os' is not allowed
# ⚠ os exposes the process environment# Invalid — a module list, not a path list
eval-import=/usr/lib/python3.13/jsonWhich dunder names — __name__, __doc__, … — the source may use, as an
attribute or as an identifier. Everything matching __*__ is refused unless
listed.
This key is ergonomics, not a barrier. The barrier is the runtime
attribute check, which refuses a dunder reached by any means. Do not assume
that because eval-magic exists, the runtime guard is redundant.
A closed set of frame-capture attributes is refused even when
eval-attribute or eval-magic would allow them: gi_frame, gi_code,
gi_yieldfrom, cr_frame, cr_code, cr_await, ag_frame, ag_code,
f_globals, f_locals, f_builtins, f_back, f_trace, tb_frame,
tb_next. A generator reaches the real globals through gi_frame.f_globals
without writing a single dunder, and no configuration should be able to open
that by accident. This is the one implicit, non-overridable DENY.
# Valid
eval-magic=__name__, __doc__eval("x.__name__") # OK
eval("x.__class__") # REFUSED — add eval-magic=__class__
# ⚠ opens the whole type hierarchy# Invalid — the __sb_ prefix is reserved for the guard's own helpers
eval-magic=__sb_getattr__Any source mentioning __sb_ is refused outright. Without that check,
__sb_tick__ = lambda: None disarms the interruption guard in one line.
Scalar values accept _ as a digit separator, a decimal size suffix
(KB/MB/GB) and a duration suffix (ms/s/m). A repeated scalar key in
the same profile is a configuration error rather than a silent last-wins:
"the rules are a set" carries no meaning for a scalar, and a duplicate is
almost always a mistake.
Default adaptive. What happens to a context the application itself passed.
| Value | Behaviour |
|---|---|
adaptive |
honour a caller-supplied context, but never CPython's automatic builtin injection. Default |
closed |
ignore the caller's globals; the namespace comes from eval-call only. Use for model output
|
caller |
honour everything the caller passed, injection included, and skip the rewrite. Debugging only |
Under adaptive:
| Call | Namespace used | Warning |
|---|---|---|
eval(e, {"__builtins__": {}}, {}) |
the caller's, plus the guard's helpers | none |
eval(e, {"helper": f}, {}) |
the caller's, plus __builtins__ from eval-call and the guard's helpers |
graded |
eval(e) |
built entirely from eval-call
|
none |
The helpers, every name starting with __sb_, stay in the caller's dict after
the call: a function the evaluated code defines calls them each time it runs,
long after eval or exec returned. The evaluated code cannot name them.
If supplied globals omit __builtins__, CPython normally injects builtins.
adaptive and closed replace that implicit namespace with the names in
eval-call, so the caller does not accidentally expose open or
__import__.
This is a deliberate behavior change: a bare eval(e) no longer inherits the
caller's globals and locals.
SECRET = "sensitive"
def caller():
eval("SECRET") # plain Python: "sensitive". Guarded: NameErrorPass required values explicitly, for example eval(e, {"SECRET": SECRET}, {});
adaptive honours that supplied context.
Each graded name in a supplied context emits one log record on the
pysandboxes.guard_eval logger, once per call site:
| What is passed | Level |
|---|---|
a module (os, sys, …) |
strong |
a capability builtin: getattr, setattr, open, eval, exec, compile, __import__, type, vars, globals, dir, breakpoint
|
strong |
a callable whose module is os, subprocess, importlib, ctypes, socket (or an implementation module such as posix) |
strong |
| any other callable, or any other instance | weak |
| scalars and containers of scalars | none |
WARNING pysandboxes.guard_eval: eval() context provides module 'os' at tools.py:66
the evaluated code can reach everything it exposes
acknowledge with: eval-call=os (or use eval-namespace=closed)
It is a logger.warning, not a warnings.warn: every guard in the package
reports on the module logger, so your existing logging configuration sees this
finding too. Your project decides what to do with it -- route or silence the
pysandboxes.guard_eval logger, or assert on it with caplog.
# Valid — the recommended setting for a tool handling model output
eval-namespace=closed
eval-syntax=arith, compare# Invalid
eval-namespace=open
# error: unknown mode 'open', expected one of ['adaptive', 'closed', 'caller']The price of adaptive, stated: the configuration file does not
describe the whole reachable surface on its own — an audit must also read the
call sites. That is why the mode is a named key rather than implicit
behaviour, and why a profile handling model output should set closed.
Default 5s. Wall-clock time before the evaluation is interrupted. The code
runs in a dedicated thread; the interruption reaches the caller as
EvalInterrupted.
EvalInterrupted derives from BaseException only, and the exclusion is
load-bearing. Every other sandbox exception is a RuntimeError. A bare
except Exception: inside the evaluated code would therefore swallow its own
interruption, and it is reachable as soon as eval-syntax=exception opens try.
Consequence for your code: except SandBoxError: does not catch a timeout.
# Valid
eval-timeout=2s
eval-timeout=250msfrom pysandboxes import EvalInterrupted
try:
exec("while True: pass", {"__builtins__": {}}, {})
except EvalInterrupted as err:
print("interrupted:", err.reason)# Invalid — a size suffix on a duration
eval-timeout=10MBDefault 1_000_000. How many loop and comprehension iterations the source may
consume. The name says what it measures: straight-line code and a lone
10**9 consume none — those are the job of the allocation check and of the
timeout.
# Valid
eval-max-iterations=100_000exec("while True:\n pass") # EvalInterrupted: eval-max-iterations=100000# Invalid — a duration suffix on a count
eval-max-iterations=5sDefault 20. Runtime call depth of functions defined inside the evaluated
code.
It exists because the iteration budget does not bound recursion: a
recursive function consumes no iteration. Without this key, it would run to
CPython's own RecursionError. That is an Exception, and therefore
catchable by the evaluated code itself.
# Valid
eval-syntax=func
eval-max-call-depth=10exec("def f(n):\n return f(n + 1)\nf(0)")
# EvalInterrupted: eval-max-call-depth=10# Invalid — this is the runtime depth, not the static one
eval-max-call-depth=20KBDefault 20. Static AST nesting accepted before the code runs. Distinct
from eval-max-call-depth: this one bounds a deeply nested expression before
the guard walks it. CPython's parser refuses the deepest ones itself, with a
SyntaxError (more than 200 nested brackets, for instance).
# Valid
eval-max-depth=20eval("-" * 3000 + "1") # refused before execution, by eval-max-depthDefault 5_000. Static node count of the parsed source. A cheap ceiling on
how much code a single string may carry.
# Valid
eval-max-nodes=500eval("[" + ", ".join("1" for _ in range(10_000)) + "]")
# refused: the source holds 10002 nodesDefault 10MB. Bound on what the rewritten operators may allocate. **, *,
+ and << are checked before the operation, because the damage is done
inside C otherwise. So is the width or precision of a format spec, which pads
the result from a few characters: %, f-strings, format and str.format.
# Valid
eval-max-alloc=1MBeval("10 ** 10**9") # refused before computing — would hang CPython in C
eval("[0] * 10**10") # refused before allocating
eval("2 ** 8") # OKThis bounds the operators, not the process: a native library allocating internally passes through no check.
Default 4. How many uncooperative workers are tolerated before new
evaluations are refused.
A blocking C call — catastrophic backtracking in re, a native library —
never returns to a check point. The caller gets its EvalInterrupted and
the thread keeps burning CPU, cumulatively across calls. Counting them and
refusing beyond a threshold is a mitigation; the real guarantee is the OS
layer, where a timeout is a process kill. A worker whose call finally returns
no longer counts.
# Valid
eval-max-leaked-threads=2A :name suffix defines a complete, independent profile. There is no
inheritance from the default one.
# Valid
eval-syntax=arith, compare, loop
eval-timeout=5s
eval-syntax:llm=arith
eval-timeout:llm=2s
eval-namespace:llm=closedfrom pysandboxes import guarded_eval
result = guarded_eval(expression, profile="llm", names={"data": rows})Independence is what lets a call site restrict. Under inheritance a profile could only widen the default — a union of sets admits no subtraction — and hardening at the call site would be impossible. The cost is duplication between similar profiles, accepted.
A profile named in a call but absent from the configuration is an error at call time, never a silent fallback to the default.
guarded_eval is a different entry point from the patched builtin:
eval-namespace does not apply to it, because choosing it is itself the
statement of intent. Its names argument is data merged into a namespace
built from eval-call, never used in place of one.
One report, every violation, each naming the rule that would allow it.
EvalSyntaxRejected: 3 rules violated in <eval:llm>
line 2, col 4: 'While' is not allowed
while True:
^^^^^^^^^^^
add: eval-syntax=loop (or, narrower: eval-syntax=While)
line 3, col 8: import 'os' is not allowed
import os
^^^^^^^^^
add: eval-import=os ⚠ os exposes the process environment
line 5, col 12: attribute '__class__' is not allowed
return x.__class__
^^^^^^^^^^^^^^^^^^
add: eval-magic=__class__ ⚠ opens the whole type hierarchy
Three properties:
- exhaustive — a model correcting its own code converges in one round
-
actionable — the exact line to paste into
.py-sandboxes - honest — a warning where granting the rule is a real widening
EvalSyntaxRejected is both a SandBoxError and a SyntaxError, so editors
and tracebacks render its lineno, offset and text the way they already
do.
Learning happens at eval() invocation, like every other guard. Violations
are recorded instead of raised, the code runs with the widened set, and the
lines land in the <learning_guard_eval> block:
# <learning_guard_eval>
# Add rules (2026/09/10 at 14:22)
eval-syntax=arith, compare, loop, comprehension
eval-call=len, range, sum
eval-attribute=split, join, strip # ⚠ runtime-observed
eval-magic=__name__
# </learning_guard_eval>Two emission rules worth knowing:
- a group replaces its nodes only when every node of that group was observed, so learning never grants more than it saw;
- never a pattern. Generalising from a sample is precisely what learning must not do.
Learn only over trusted, representative input. Learning grants whatever the evaluated code used, so a learning run fed untrusted input writes into the profile what that input asked for.
When adaptive honours a caller-supplied context, eval-call is not
consulted for the namespace, so emitting it would produce rules that are at
once unused and misleading. The block carries a comment instead:
# eval-call not emitted: the namespace came from the call site
# (tools.py:66). Set eval-namespace=closed to declare it here.That comment doubles as the migration prompt towards closed.
Eight starting points, ordered from the narrowest to the widest. Each is a
complete profile: paste it into .py-sandboxes, then narrow it with learning
mode against your own corpus.
All eight set eval-namespace=closed. Anything that evaluates a model's
answer should: adaptive exists so an existing caller keeps working, not
because it is the right posture for model output.
The tool every agent framework ships. The model returns "40 + 2" or
"(3 + 4) * 2", and nothing else should ever run.
eval-namespace=closed
eval-syntax=arith, compare
eval-max-iterations=100_000
eval-timeout=2sNo eval-call, and that is not an oversight: arithmetic over literals needs
no function at all, so __builtins__ stays empty and no spelling reaches
open or __import__.
guarded_eval("(3 + 4) * 2") # 14
guarded_eval("2 ** 10") # 1024
guarded_eval("len([1, 2])") # REFUSED — eval-call=len
guarded_eval("().__class__.__base__") # REFUSED — eval-magic=__class__
guarded_eval("10 ** 10**9") # REFUSED — eval-max-allocAdd eval-call=round, abs, min, max the day the model starts writing
round(1/3, 2). Learning mode will tell you.
The common "ask the model to compute a metric" case. The host fetches the data, hands it over as plain Python, and the model writes the aggregation.
rows = [{"region": "EU", "amount": 120.0}, {"region": "US", "amount": 80.0}]
answer = guarded_eval(model_expression, profile="analysis", names={"rows": rows})eval-namespace:analysis=closed
eval-syntax:analysis=arith, compare, comprehension, conditional, subscript
eval-call:analysis=len, sum, min, max, abs, round, sorted, any, all, set, list, dict
eval-max-iterations:analysis=1_000_000
eval-max-alloc:analysis=50MB
eval-timeout:analysis=5s# OK
"sum(r['amount'] for r in rows if r['region'] == 'EU')"
"round(sum(r['amount'] for r in rows) / len(rows), 2)"
"sorted({r['region'] for r in rows})"
"max(r['amount'] for r in rows) if rows else 0"
# REFUSED
"[r for r in rows if r.get('region') == 'EU']" # eval-attribute=get
"__import__('os').listdir('.')" # '__import__' not callable
"open('/etc/passwd').read()" # 'open' not callable
"[0] * 10**10" # eval-max-allocNote what the third refusal teaches: the moment the model writes .get(...)
instead of [...], you need eval-attribute=get. Two spellings of the same
intent, one of which costs a rule. Run learning mode over a representative
sample of model answers rather than guessing which one it prefers — and grant
the method names one by one:
eval-attribute:analysis=get, items, keys, valuesDeny-all on attributes is what makes this profile safe to hand a live object
instead of a copy. Without it, conn.execute("DROP TABLE") would traverse the
guard untouched.
Same shape, but the object you pass carries hundreds of methods, so
eval-attribute becomes the load-bearing key rather than an afterthought.
answer = guarded_eval(model_expression, profile="frame", names={"df": df})eval-namespace:frame=closed
eval-syntax:frame=arith, compare, subscript, conditional
eval-call:frame=len, round, min, max, sum, list
# A list key may be repeated: the values are unioned, so a long allow list
# splits across as many lines as it needs and their order carries no meaning.
eval-attribute:frame=groupby, agg, mean, median, sum, count, nunique
eval-attribute:frame=min, max, std, describe, head, tail, sort_values
eval-attribute:frame=value_counts, dropna, fillna, columns, shape, loc, iloc
eval-attribute:frame=DENY:to_*, read_*, apply, pipe, eval, query
eval-max-alloc:frame=200MB
eval-timeout:frame=10s# OK
"df[df['region'] == 'EU']['amount'].sum()"
"df.groupby('region')['amount'].mean()"
"round(df['amount'].std(), 2)"
# REFUSED
"df.to_csv('/tmp/leak.csv')" # DENY:to_* — writing is not analysing
"df.apply(lambda r: __import__)" # DENY:apply — it runs arbitrary callables
"df.query('amount > 0')" # DENY:query — a second expression engine,
# outside this guard entirelyThree things worth knowing before shipping this one:
-
Pass the object, never the module.
names={"pd": pandas}underadaptivelogs a strong finding, and rightly:pd.read_csvreaches the filesystem. Underclosedthe module simply is not there. -
DENY:earns its keep here. The allow list is long enough that an exhaustive one is unmaintainable; denying theto_*andread_*families and the three callable-taking methods is shorter and ages better. -
eval-max-allocdoes not bound pandas. It checks the+,*and**the evaluated source writes; a join performed inside the library allocates through no check of ours. The OS layer is what bounds that.
A model turning "customers in France over 18" into a boolean expression, one row at a time. The narrowest useful profile there is.
eval-namespace:filter=closed
eval-syntax:filter=compare, subscript
eval-max-nodes:filter=200
eval-timeout:filter=1sEmpty eval-call, empty eval-attribute, no arithmetic: a predicate that
needs to call something is a predicate that has grown into a program.
# OK
"row['age'] >= 18 and row['country'] in ('FR', 'BE')"
"row['status'] != 'closed'"
# REFUSED
"len(row['tags']) > 0" # eval-call=len
"row['name'].startswith('A')" # eval-attribute=startswith
"row['a'] + row['b'] > 10" # eval-syntax=arithEach refusal is a deliberate design question, not an obstacle: if the model
keeps reaching for startswith, granting it is one line, and that grant
is visible in the profile instead of buried in a helper.
Pricing, scoring, eligibility: rules a business user writes and a model may draft, evaluated against named values the host supplies.
eval-namespace:rules=closed
eval-syntax:rules=arith, compare, conditional
eval-call:rules=min, max, round, abs
eval-max-nodes:rules=500
eval-timeout:rules=1sguarded_eval(rule, profile="rules", names={"amount": 250.0, "tier": "gold", "years": 4})
# OK
"round(amount * (0.15 if tier == 'gold' else 0.05), 2)"
"min(amount * 0.2, 100.0)"
"0 if years < 1 else min(years * 5, 25)"
# REFUSED
"[amount for _ in range(10**9)]" # eval-syntax=comprehension
"amount if amount else exit()" # 'exit' not callableNo loops, no comprehensions, no function definitions: a rule that needs them is a program, not a rule. Keeping the vocabulary this small is also what makes the profile readable to the non-programmer who owns the rules.
A model composing a sentence from values you supply.
eval-namespace:template=closed
eval-syntax:template=fstring, arith, compare, conditional
eval-call:template=round, len, str, int, float
eval-attribute:template=upper, lower, title, strip, replace, split, join
eval-timeout:template=1s# OK
'f"{name.title()}: {total:.2f} EUR"'
'f"{len(items)} item" + ("s" if len(items) > 1 else "")'
# REFUSED
'"{0.__class__}".format(obj)' # 'format' is not in str-methodsThat last refusal is the point of this profile's shape. str.format resolves
attributes in C, from the contents of the string, so no node exists to
validate statically. It is curated out of str-methods for that reason, and
the f-string spelling, which compiles to a real attribute access and is
checked, is the one to use. See eval-attribute.
Granting eval-attribute:template=format works and emits a warning. The
template is then validated at runtime: each {0.name} field goes through the
same attribute check as a dotted read, so '{0.__class__}'.format(obj) is
still refused.
The widest profile that still deserves the name. An agent writes a few lines of real Python — loops, a helper function, a try block — over data you supplied.
eval-namespace:agent=closed
eval-syntax:agent=arith, compare, conditional, loop, comprehension
eval-syntax:agent=assign, func, subscript, fstring, exception
eval-call:agent=len, sum, min, max, abs, round, sorted, enumerate, zip, range
eval-call:agent=any, all, list, dict, set, tuple, str, int, float, bool
eval-attribute:agent=str-methods, list-methods, dict-methods
eval-max-iterations:agent=500_000
eval-max-call-depth:agent=15
eval-max-nodes:agent=2_000
eval-max-alloc:agent=20MB
eval-timeout:agent=10s# OK
"""
totals = {}
for r in rows:
totals[r['region']] = totals.get(r['region'], 0) + r['amount']
result = sorted(totals.items())
"""
# REFUSED
"import os" # eval-syntax=import, then eval-import=os
"class C: pass" # eval-syntax=class
"def f(n): return f(n+1)" # runs, then eval-max-call-depth=15
"while True: pass" # runs, then eval-max-iterationsTwo notes on the budgets, which matter more here than anywhere else:
-
eval-max-call-depthis what stops runaway recursion. The iteration budget does not see it, and CPython's ownRecursionErroris anExceptionthat the snippet can catch itself onceexceptionis open. -
eval-timeoutreaches the caller asEvalInterrupted, which derives fromBaseExceptiononly — so a bareexcept Exception:in the snippet cannot swallow its own interruption, and yourexcept SandBoxError:will not catch it either.
If a snippet at this width needs import, prefer granting the module through
names= as an already-configured object over opening eval-syntax=import.
You keep control of what the object is.
For a debugging session, and for nothing that ships.
# Unguarded: the source is not parsed, not checked, not rewritten.
python-api=ALLOW:dynamic-codeor, to keep the guard registered but honour whatever the call site passes, injection included:
eval-namespace=caller
eval-syntax=arithBoth are warned at load like process-exec. caller also skips the rewrite —
honouring CPython's automatic builtin injection and running the guarded
helpers are mutually exclusive — so nothing in this row protects anything.
Reach for it when you are diagnosing the guard itself, never to make a failing
profile pass.
| Profile | eval-call |
eval-attribute |
Functions & loops | Typical timeout |
|---|---|---|---|---|
| 1. Calculator | — | — | no | 2s |
| 2. Rows | a dozen builtins | a few, learned | no | 5s |
| 3. DataFrame | a few builtins | long, with DENY: families |
no | 10s |
| 4. Predicate | — | — | no | 1s |
| 5. Rules | 4 numeric builtins | — | no | 1s |
| 6. Templating | 5 conversions |
str methods |
no | 1s |
| 7. Agent snippet | ~20 builtins | 3 type groups | yes | 10s |
| 8. Escape hatch | unguarded | unguarded | unguarded | none |
Start one row narrower than you think you need. A refusal names the exact line to add, so widening costs one paste; discovering months later that a profile was wider than the task required costs an audit.
Stated plainly so no one reads more into the guard than it offers.
-
Blocking C calls. Catastrophic backtracking in
re, a native library: none returns to a check point. Mitigated byeval-max-leaked-threads, guaranteed only by the OS layer. -
Memory outside the rewritten operators.
eval-max-allocbounds+,*,**,<<and format widths, not the process. - CPython bugs. A segfault or an interpreter escape is out of reach of any AST-level guard.
-
Side effects of allowed calls. If
eval-callgrants a function that opens files, the file rules apply — but this guard adds nothing. - Timing and side channels.
-
A formatter the guard does not know.
str.format,format_mapandstring.Formatterfields are checked (seeeval-attribute). A native or third-party callable that walks attributes from a string on its own is not: it reaches the evaluated code only if the application passes it in, and is then trusted like any callable innames. -
Audit from the configuration alone, under
eval-namespace=adaptive: the reachable surface is the configuration plus what each call site passes.closedrestores the property, at the cost of declaring every name. -
Code generation by a third-party library outside
site-packages— a vendoredattrs, a source checkout onPYTHONPATH. The ambient exemption is path-based, so such a library is guarded like application code and its generated function will be refused.python-api=ALLOW:dynamic-codeis the escape hatch.
See also: weaknesses · implementation · roadmap