A Verifier That Shells Out Inherits the Environment It Is Judging
Problem¶
A gate that answers "may this untrusted code run?" often needs a helper to answer it — an interpreter, a parser, a linter. The moment it shells out, it inherits an environment shaped by the thing it is judging.
The concrete instance: scripts/run-tests.sh fed a Python program on stdin to ask pytest which
config applied. python3 - makes CPython set sys.path[0] = '' — the cwd — and the gate ran with
the repository root as cwd. Importing _pytest.config.findpaths pulls in shadowable top-level
names (iniconfig among them), so an untracked iniconfig.py at the repo root executed inside
the gate, before any verdict.
The verdict was irrelevant. Measured: the payload ran even on runs where the gate then refused every directory.
Why it is easy to miss¶
The reasoning that produced it was sound. The refactor replaced a hand-rolled config parser with a delegation to the format's owner — the right move — on the argument that reading a config is not collecting it, and reading executes no repo code. That argument is correct about the parser and says nothing about the delivery mechanism. Correct choice, defeated by how it was invoked.
The pattern¶
- Enumerate what the helper inherits, not just what it does: cwd, module search path, environment variables, plugin/auto-load hooks, config discovery.
- Neutralise each before the first untrusted-shadowable import, as the helper's first statement — not via a wrapper flag, and not after other imports have already run.
- Prefer a positive scrub over a blanket isolation flag. For the Python case:
- Verify the helper still works after hardening. This is where the trap is.
⚠ The hardening that makes it worse¶
python3 -I and -E -s suppress the shadow — and also drop user site-packages. Where the
dependency lives there, the helper becomes unimportable, the gate silently concludes "nothing to
gate", and a Critical becomes a fail-open. Measured, in this instance.
Any isolation flag that can make the helper unavailable must be paired with an explicit
availability probe, and "helper unavailable" must be a REFUSAL, never an empty result — see the
sibling failure in the same bug, where except Exception: print("") treated any import failure as
"the tool is absent" and turned the gate off on a routine upgrade.
How to test it¶
The probe must be a capture-proof side effect (write a file), not a print: helpers frequently capture stdout/stderr, and a stderr probe reports zero executions while the payload runs. And it must reproduce against the unfixed code first — see probe-must-reproduce-against-unfixed-code.md.
Generalises to¶
Any linter, formatter, type-checker, config reader or test-collector invoked by a security gate;
node -e/-p, ruby -e, sh -c with a payload on stdin; and any tool whose plugin system
auto-loads from the working directory.