Evaluation: hand-rolled scanner vs ast walk for the Python env-read gate¶
Status: decided — keep the hand-rolled scanner and fix gaps as they are found. Revisit if a real false negative escapes the gate.
The gate lives in
rust/crates/foundation/degenbot-config/tests/no_stray_env_reads.rs and
enforces ADR-062 D7: degenbot-config is the one env-reading owner, so the
Python companion may read os.environ / os.getenv / os.environb only at
the sites the test’s allowlist enumerates. The single env-reading owner this
gate polices is one half of the contract recorded in
ADR-065: the
verdict is the single configuration authority. The Python half of the gate is a
hand-rolled lexer (PythonWalk plus its name-resolution helpers) rather than
a parser. This note records an evaluation of replacing it with a Python ast
walk: the verdict match, the implementation cost, and the reason the
hand-rolled scanner is retained for now. It does not change the gate.
The alternative that was built¶
A working replacement was written and run against the repository. It walks
the companion tree with Python’s ast module, finds the same call and
subscript nodes, and resolves the read’s name the way the hand-rolled scanner
does:
ast.Constant→ the string literal value,ast.Name→ the identifier,anything else →
<computed>.
It reproduced the gate’s verdict exactly at the time of measurement: the same
reported sites with the same names, including the computed name read and the
INJECT_EXECUTOR_CODE membership test, and it agreed with every shape-table
case then present (13 of 13). It also handles the two aliasing shapes listed
below, which the hand-rolled lexer does not.
Size¶
implementation |
scanner logic |
|---|---|
current hand-rolled lexer |
~310 lines |
|
~81 lines |
The ast walk is smaller because the interpreter owns tokenisation, string
and f-string handling, and syntax; the replacement only classifies the nodes
the interpreter produces.
parents bookkeeping is required¶
A mapping read through a call — os.environ.get("NAME") — is both a
Subscript on os.environ and the argument of a Call. A naive walk reports
it twice: once for the subscript node and once for the mapping node reached
through the call. The working version carried a parents map so a subscript
that is already the argument of a recognised call is not reported again. A
first pass omitted it and double-reported; a replacement must pin the
single-report-per-site behaviour with a test.
The cost: an interpreter on PATH¶
The hand-rolled scanner is self-contained Rust: cargo test runs it with no
external process. The ast walk needs a Python interpreter on PATH and a
subprocess to run it. That makes the gate’s own execution depend on the
environment it polices, and a missing or mis-versioned interpreter becomes a
new failure mode — or, with loose wiring, a silently skipped gate. The gate’s
value is that it fails closed in CI, so a new process dependency has to
preserve that.
Measured residual limits of the hand-rolled scanner¶
shape |
behaviour |
severity |
|---|---|---|
a file opens a triple-quoted literal and never closes it |
every later read in that file is silenced — fails open |
the one that matters |
|
not detected — module aliasing is invisible |
naming limit |
|
not detected |
naming limit |
PEP 701 nested same-quote f-string |
handled |
— |
a read split across lines |
reported as |
loud, wrong name |
a decorator-position read |
handled |
— |
The fail-open row is a limit of a gate whose silence must be understood: a
green run means “no read was recognised”, not “no read exists”. The shape
table in the gate pins the unterminated-literal behaviour so the limit is
visible at the scanner. The aliasing rows are a naming limit, not a lexing
one — the read is recognised, but the scanner matches the literal
os.environ / os.getenv spellings, so a module or name alias is invisible.
Decision and revisit triggers¶
Keep the hand-rolled scanner. The ast walk is smaller and strictly more
correct, but it trades a self-contained gate for an interpreter dependency the
current scanner does not need, and the known gaps do not affect the sanctioned
companion sites. The scanner’s limits are documented and pinned rather than
silent.
Revisit if any of these hold:
A real read slips through the aliasing or fail-open gaps, or the gate’s silence is mistaken for a clean tree.
A replacement can obtain the
astverdict without adding a process dependency to the gate — e.g. an in-process parser or a Rust parser with the same fidelity.The gate moves to a harness that already runs a Python interpreter, so the dependency is not incremental.