fix(scanners): the command layer's argv is now checked against the CLI that receives it

A command template is a caller with no compiler behind it. It names a scanner and
an argv; nothing checked that the scanner still accepts them. M-BUG-45 measured
what that costs: `--stale-after` arrived malformed, was ignored, and the command
reported "all 14 entries re-verified within the last 90 days" about a threshold
the user had just overridden.

The new guard builds the argv from each template's OWN text (#63 — a hand-typed
call is a path no user takes), reading all three forms a flag appears in,
including the comment-only `GLOBAL_FLAG=""  # --global`; that third form is the
one that dies unobserved, since the default path leaves the variable empty.
Measured: 38 invocations, 54 (CLI, flag) pairs, 15 CLIs, 0 dead scanner paths.

Two premises in the plan text were falsified by measuring:

  - "the flag exists in the CLI's BOOL_FLAGS/VALUE_FLAGS" — only 3 of 34 scanner
    files declare such a surface. The contract is checked on BEHAVIOUR instead:
    run the CLI, ask whether it calls the flag unknown.
  - `--full-machine` was predicted dead on `posture`. It is live. The fasit was
    wrong, not the code.

What the measurement found instead: `campaign-export-cli` was the only one of the
fifteen without the shared `requireValidArgs` gate. Its hand-rolled chain guards
every value branch with `argv[i + 1] !== undefined`, so a trailing `--repo` fell
past all of them to the `startsWith('--')` catch-all and was reported as an
unknown flag — about the flag the CLI itself requires. Classification of "value
flag, no value" across all fifteen: 14 correct, 1 wrong. It now uses ARG_SPEC +
requireValidArgs like the other twelve; valid argv reaches the existing loop
byte-for-byte unchanged. Special-casing it in the test would have rebuilt, in
test code, the prose exception Q1 deleted.

And what the guard itself got wrong, which is worse than what it was looking for:
probing a flag means RUNNING the CLI, and some flags are writers. Its first run
let `drift-cli --save` default its target to the working directory and overwrite
the operator's real ~/.config-audit/baselines/default.json — an ungated write
outside the repo, produced by the guard whose whole subject is ungated writes
outside the repo. Every probe now runs under hermeticEnv() with its own empty
cwd, and the cwd is asserted empty afterwards. Isolation that is only a
convention is not isolation. Side effect: 65s -> 13s, because a hermetic HOME
stops every probe from enumerating ~/.claude.

All six arms seen RED against their own defect, twice — including the ORIGINAL
class (remove --approve-scope from fix-cli) and the plan's own verification
(delete the write-scope-cli line from a template). The non-emptiness arm is
derived from the tree, not pinned to a count that would only be a drift point.

Suite 1707 -> 1724, frozen v5.0.0 + default-output snapshots 0 changed files.

Not fixed here, found while verifying and pre-existing at 749b710: the suite was
NOT green on HEAD. output-file-robustness fails on drift-cli, root cause
diff-engine.mjs:194 — `m.from.severity` where `m.from` is undefined in the moved
section of the drift report. It crashes after the scan, in formatting, so the
CLI exits 3 with no output file. Its own chunk, not this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pj6UoTi6iPsAB2B2j6EZ1k
This commit is contained in:
Kjell Tore Guttormsen 2026-08-12 21:58:38 +02:00
commit 30c78aeda0
4 changed files with 400 additions and 1 deletions

View file

@ -119,6 +119,33 @@ lines is the drift shape `SCOPE_CLASSES` exists to prevent one level down. Appro
`--approve-scope`, and **classifying is not approving**: a template that sets the flag because it
already ran `write-scope-cli` has rebuilt the prose contract this guard replaced.
**Command→CLI flag contract (invariant).** A command template is a caller with no compiler
behind it: it names a scanner and an argv, and nothing used to check that the scanner still
accepts them. The measured cost is M-BUG-45 — `--stale-after` reached its CLI malformed, was
ignored, and the command reported "✓ all 14 entries re-verified within the last 90 days" about
a threshold the user had just overridden. `tests/commands/command-cli-contract.test.mjs` closes
that seam, and four properties are load-bearing. (1) **The argv is built from the template's own
text** (`tests/helpers/command-invocations.mjs`), never hand-typed — a hand-written call is a
path no user takes (#63). Flags appear in *three* forms and all three are read: literal,
`if …; then RAW_FLAG="--raw"; fi`, and **comment-only** (`GLOBAL_FLAG="" # --global`); the third
is the class that dies unobserved, because the default path leaves the variable empty. (2) **The
probe proves itself per CLI** — each must first be seen rejecting a flag that certainly does not
exist, or a CLI that exits on a required-arg check before reaching flag parsing passes every pair
vacuously. Measured 15/15 report the unknown flag first, so no prefix-argv table is needed, and
the second copy of `cli-unknown-flag-rejection`'s `GUARDED` table was therefore never created.
(3) **"Unknown" is told from "needs a value" by the CLI's own words**, which is only sound because
every CLI classifies the two correctly — measured 14/15, and the fifteenth
(`campaign-export-cli`, the last hand-rolled parser, whose `argv[i+1] !== undefined` guards let a
trailing `--repo` fall through to the catch-all and be reported as an unknown flag) was moved onto
the shared `requireValidArgs` gate rather than special-cased in the test. (4) **Probing a flag
runs the CLI, and some flags are writers** — the first run of this guard let `drift-cli --save`
default its target to the cwd and overwrite the operator's real
`~/.config-audit/baselines/default.json`. Every probe now runs under `hermeticEnv()` with its own
empty cwd, and the cwd is *asserted* empty afterwards: isolation that is only a convention is not
isolation. Not asserted here: that a template calling a gated writer also calls `write-scope-cli`
— measured false-red (`discover`/`config-audit` invoke `scan-orchestrator` without reaching its
`--save-baseline` write), so that arm stays in `write-scope-gate-shape.test.mjs`.
**Dead-prose-reference silence list (invariant).** `CA-CML-013` is a precision-first check, so its
design lives in what it *declines* to flag, and that list is measured (407 real CLAUDE.md files),
never argued. Three rules are load-bearing and each has a guard seen red against its own defect.