← board

exec ignores a caller-supplied __builtins__ mapping

Repro

d = {"__builtins__": {}}
exec("n = len([1,2,3])", d, d)
print(d.get("n"))
result
CPython 3 NameError: name 'len' is not defined
pxx (stable_linux_amd64/default/pinned) 3 — resolved anyway

Why this is a bug and not a divergence

NilPy's contract is upward compatibility: if code works on CPython, it must work on NilPy. Here a program that runs on CPython runs on NilPy and does something else — it resolves names the author explicitly scoped away. That is the "silent wrong behaviour" case the compat escape rule promotes to a bug in the owning lane, not parity work to be parked.

It is also not the laxer-than-CPython case that devdocs/dev/nilpy-semantics-divergences.md correctly files as a feature. Being lax about a restriction is only harmless when no working program observes it; here the whole point of the construct is the restriction.

What CPython actually does (measured, all four contexts)

pxx today does none of the three: no key, g comes back empty, and a supplied mapping is ignored.

Scope — deliberately the cheap half

This ticket is only "consult the mapping the caller supplied". It does NOT require building a builtins dict when the caller supplied none — that is the cosmetic half, it needs an enumerable builtin table, and it is parked on the decide ticket with a live-alias footgun of its own (CPython's value is builtins.__dict__ by identity, so a faithful implementation would let d["__builtins__"]["len"] = ... mutate the builtin namespace program-wide).

So the change is at exec's name-resolution path: when globals['__builtins__'] is present, builtin-name lookup goes through that mapping, and a miss is a NameError rather than a fallback to the native builtin. Absent the key, behaviour is exactly as today.

Note that pxx's exec is a genuine interpreter, not a stub — len, str, range, and for loops all work inside it (measured) — so there is a real lookup path to change rather than a feature to invent.

Not a security claim

CPython's restricted exec is not a sandbox (().__class__.__bases__ and friends escape it), so this is not "pxx has a sandbox hole". The claim is narrower and stands on its own: the name-resolution behaviour is well-defined, observable, and used to evaluate config/template expressions against a controlled namespace.

Gate

make compiler/pascal26 + the repro raising NameError, plus a positive case (a supplied mapping containing len resolves it), then tools/gate.sh quick. Touches compiler/builtin/**, so it carries the stabilize-fast + make pin obligation.