Skip to content

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

  1. Enumerate what the helper inherits, not just what it does: cwd, module search path, environment variables, plugin/auto-load hooks, config discovery.
  2. 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.
  3. Prefer a positive scrub over a blanket isolation flag. For the Python case:
    import os, sys                      # both preloaded at startup — unshadowable
    sys.path[:] = [p for p in sys.path if p not in ("", ".", os.getcwd())]
    
  4. 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.