Buddy Lab

field note #6

1 · name + claim

pre-expansion-guard-post-expansion-shell

Your command filter reads the string; the shell runs something else.

2 · The failure it offsets

A guard inspects command text and returns a verdict. The shell then expands that text — variables, substitutions, globs, quoting, all of it — and runs the result. The guard ruled on one program. The executor ran a different one. The gap is invisible in every log, because the log records what was typed, which is precisely the artifact that was never executed.

This is not a bug in one filter. It is a seam. Anything checking before expansion is checking a template; anything executing after expansion is running an instance. A deny-list over templates is the worst case — there are unbounded ways to write a template that expands into a denied instance, and no filter can enumerate them — but an allowlist over templates is reading the wrong artifact too. What keeps this quiet for years is that the two readings agree on nearly every command anyone actually types, so the filter looks correct right up until the moment it matters.

3 · The rule

Filter what will execute, not what was typed. Resolve first, then decide.

Then accept the corollary, because the corollary is the whole discipline: some commands cannot be resolved without running something. A substitution has to execute to have a value. An externally supplied variable has no value at decision time. A glob’s match set is decided by the directory as it stands at execution, not as it stood at inspection. For these there is no correct verdict — so do not manufacture one. Refuse anything you cannot fully resolve, and refuse it as a class, by construction, rather than parsing your way toward a safe subset. Parsing toward a safe subset is how you end up with a filter that is confident and wrong.

4 · Adopt in an afternoon

Four steps, in this order. The ordering is the design: refusal-for-being- unresolvable comes before any judgment about danger.

steal this
# the guard decides on the RESOLVED program, never on the
# typed string. Four steps, in this order, no exceptions.
#
# 1  RESOLVE using the same expander the executor uses, with
#    side effects disabled. Do not write a second parser: two
#    readings of one grammar is the defect you are fixing.
#
# 2  REFUSE THE UNRESOLVABLE AS A CLASS, before any judgment:
#      - a substitution whose value requires running something
#      - a variable with no value at decision time
#      - a glob whose match set is decided at execution
#      - quoting that changes the argument count once expanded
#    Refused for being unresolvable, NOT for looking dangerous.
#    A harmless-looking unresolvable is refused just the same.
#
# 3  DECIDE on the resolved argument vector. Nothing else.
#
# 4  LOG BOTH FORMS on every decision, pass or refusal:
#      typed:    deploy $TARGET --all
#      resolved: deploy staging --all   # the verdict is about
#                                       # THIS line, only
#
# If step 1 fails for any reason: refuse, exit nonzero. A guard
# that cannot see the program it is judging has one honest
# verdict left, and it is no.

Step 4 is what makes the seam auditable afterwards. A log holding only the typed form cannot answer the one question a reviewer will have — what did this actually run — and cannot be diffed against the executor’s own record to prove the two agreed.

5 · The refusal test

Submit a command whose resolved form differs from its typed form in a way you can predict: the typed line names a variable, the resolved line names its value. The guard must refuse it as unresolvable rather than render a verdict on the typed version — including when the typed version obviously looks fine. Then read the log and confirm both forms appear on every decision, not only on refusals.

Describe this class in your own runbook; do not publish a working example. A note that hands a reader a string which gets past a real guard has stopped being a field note.

6 · Retire trigger

Retire the text filter as a control when the executor itself enforces the policy — when the decision is made on the resolved argument vector inside the thing that runs it, leaving no window between verdict and execution for anything to expand. Keep the text filter afterwards if you like it, for the message it prints. It is advisory from that day on, and the runbook should say so.

captured 2026-08-06 · v1 · stolen from Buddy Lab · CC BY 4.0, snippets MIT