tensor-grep: argv normalization and shadowing
Two failure classes live around argv in this repo, both "quiet" (no crash, wrong behavior):
- Rewriting shadowing (A83, #979): a normalizer that rewrites one CLI shape into another
(
SEARCH_OPTION_FIRST_FLAGS→tg search …) redirects a "positional" validator's coverage —tg PAT --gpu-device-ids 0 --count-matchesnever reachesrun_positional_clibecause it becomes the search form, and the search path can silently dropgpu_device_ids(RipgrepSearchArgs has no gpu field) while rg-passthroughing. A fix is only closed when it guards EVERY door the rewritten argv can reach. - Flag injection (CWE-88 / MCP-276 class): a list-argv
subprocess(shell=False) stops SHELL injection but NOT flag injection — a value beginning with-is parsed by the child's own option parser as a flag. Insert a--end-of-options sentinel BEFORE user positionals (POSIX Guideline 10).
Both are registration-completeness problems wearing argv clothes: enumerate the N places the argv (or its rewrite) can travel, miss one, and the defect is silent.
Part 1 — The front-door normalizer topology (bootstrap)
main_entry (src/tensor_grep/cli/bootstrap.py, def main_entry) is the Python front door that
runs BEFORE the Typer app. Its shape-rewriting joints:
_normalize_search_invocation(bootstrap.py,def _normalize_search_invocation) — strips a leadingsearchsubcommand, passes through everything else; this is where an option-first invocation may become the search form._requires_full_cli(bootstrap.py,def _requires_full_cli) vs_requires_full_cli_ignoring_rg_json— the routing predicates that decide whether an argv goes to the full Typer CLI or to_run_rg_passthrough. The_TG_ONLY_SEARCH_FLAGSset (bootstrap.py:50) is the allowlist of what MUST route to the full CLI; the attached-value short-flag walk (bundled-g*.py/-tpy/-itpy) is the sibling that a bare-token check misses._run_rg_passthrough(bootstrap.py,def _run_rg_passthrough) — forwards the plain-addressed text search straight to ripgrep.
The invariant to preserve (routing_policy.md's one-directional-verdict paragraph — grep
stricter, never looser in docs/routing_policy.md; the earlier anchor grep monoton returns ZERO
hits at this SHA — the invariant lives under this wording, not the word "monotonic"): the front
door's verdict must be a superset-monotone
refinement of the clap path's — the front door may be stricter, never looser; attached-value short
spellings (-eneedle) are a known deliberate asymmetry in the SAFE direction. When you loosen a
front-door predicate, you widen every path that falls through it.
Part 2 — The A83 census: every door the rewritten argv can reach
Before claiming a front-door/argv fix is closed, enumerate the doors mechanically:
- List every flag the normalizer REWRITES (e.g.
SEARCH_OPTION_FIRST_FLAGSincludes--count-matches, sotg PAT --count-matchesbecomes the search subcommand form). - Know the SIBLING list:
SEARCH_PYTHON_PASSTHROUGH_FLAGS(rust_core/src/main.rs, grepconst SEARCH_PYTHON_PASSTHROUGH_FLAGS) is a co-trigger of the SAME rewrite —normalize_top_level_search_argsrewrites to the search form when EITHER list matches (grepraw_args_contain_any_flagin the same file). It carries, among others,-f/--file. - Door-parity example (
-f/--file):-f/--fileis inSEARCH_PYTHON_PASSTHROUGH_FLAGS(native front door routes it to the Python passthrough), and the Python front door's_can_delegate_to_native_tg_search(bootstrap.py, grep that symbol) EXCLUDES-e/-ffrom native delegation because the separately-compiled standalone native binary silently never reads a-fpattern file at all (audit #69 receipt — grepsilently never readinbootstrap.py). Two front doors, one argv: both must refuse/drop the same shapes, or one door silently misroutes what the other guards. - List every parser that can receive the REWRITTEN form: the search subcommand handler, the
positional (
run_positional_cli) handler, and the rg-passthrough path. - For each (rewritten flag, sub-parser) pair, ask: does that parser honor the flag, refuse it,
or silently drop it? NOTE
--gpu-device-idsis NOT itself aSEARCH_OPTION_FIRST_FLAGSmember (verify:const SEARCH_OPTION_FIRST_FLAGSinrust_core/src/main.rs); the rewrite is TRIGGERED by a member riding in the same argv (--count-matches), with--gpu-device-idsriding along into the search form, where the structured args struct has NO gpu field on the rg-passthrough route — the drop is structural, not a bug someone typed. The tree's own receipt says exactly this: greprg_passthrough_gpu_dropped_search_flagsinrust_core/src/main.rs("SEARCH_OPTION_FIRST_FLAGSincludes--count-matches, sotg PAT . --gpu-device-ids 0 --count-matchesnormalizes intotg search ..."). - Guard EVERY door you enumerate; a guard added only to the door you thought you were fixing is a no-op against the other doors (the H2 receipt: the rg-passthrough early-return was where the explicit request was silently dropped; the guard had to be placed BEFORE that early return, first-gate-in-BOTH-environments).
- A compiled ratchet is the only non-silent form: the H2 fix used a compile-exhaustive field destructure (a new field fails COMPILE, cannot be satisfied by a comment).
The registration-completeness law applies verbatim (see AGENTS.md "Adding a Command or Flag": census ALL N sites, never just the one you edited). The front-door rewrite is the same law applied to argv shapes.
Part 3 — -- end-of-options hygiene (CWE-88)
The behavioural census: tests/unit/test_argv_sentinel_covers_every_builder.py asserts every argv
builder that hands a CALLER-INFLUENCED positional to a flag-parsing child places -- before
it. The scope is stated deliberately: the property is NOT "every subprocess list ends its
options" — it is that a value the CALLER can influence never reaches a child parser in flag
position. Measured on the shipped binary:
-e NEEDLE "-i" -> "path":"", total_files:0, total_matches:0, exit 0 (false empty)
-e NEEDLE -- "-i" -> "path":"-i", total_files:1 (correct)
Checked list for a builder that appends user/LLM-controlled values:
- Use a list argv (
shell=False) — this stops shell injection (baseline). - Insert a
--sentinel immediately BEFORE the user positionals; the sentinel is UNCONDITIONAL (a guarded "only when the value starts with-" form reads as equivalent and leaves the silent case open — the_agent_gpu_evidencereceipt). - Know the sentinel's limits:
--protects only what comes AFTER it (a user positional before--is still injectable); it does not gate--flag=VALUE; not every binary honors it — dogfood the real binary (tg search -- --weirdmatches;tg search --weirderrors). - Uniformity is the security property: even a builder whose positionals are ALL tg-generated carries the sentinel (the doctor GPU probe), because a sweep whose members each carry a private risk assessment is a sweep nobody can check.
- Assert on POSITION, not presence: a sentinel sitting BETWEEN two positionals protects nothing
(the census found one exactly like that — present, and useless). Behavioral capture at the
seam the argv CROSSES (
run_subprocess), with anassert capturedguard so an inert capture fails rather than returning an empty value that passes everything.
Part 4 — Shared-builder consumers: stream vs parse (-q receipt)
When adding a flag to a SHARED argv builder, enumerate its consumers and ask which of them CONSUME the thing the flag changes (AGENTS.md "The check and the defect AGREED" — #876/#880):
-
RipgrepBackend._build_cmd's-qreceipt: the builder has FOUR consumers; only ONE streams. The other three PARSE rg's stdout — and-qmakes rg print nothing, sotg search -q --counton a MATCHING file reportedtotal_matches=0, exit 1: a false no-match AND an exit-contract violation. Measured:rg --count-matches needle f.txt -> "2", with-q -> "";rg -l -> f.txt, with-q -> "";rg --json -> 5 lines, with-q -> 1. - A flag that ALTERS OUTPUT belongs to the consumers that stream, not the ones that parse. Put it on the streaming path only.
- When writing the control arm, state what the CONSUMER does with the value, not what the
callee accepts: "rg accepts
-q" is true of rg and irrelevant to tg, which consumes the stdout-qsuppresses.
External anchors (Exa research, 2026-08-09)
| Anchor | Their point | This skill's mapping |
|---|---|---|
| POSIX Utility Syntax Guideline 10 (pubs.opengroup.org) | "The first -- argument that is not an option-argument should be accepted as a delimiter indicating the end of options" — the canonical -- contract every child parser implements. |
Part 3's sentinel-before-positionals: -- is how a callee's parser distinguishes a literal value from a flag. |
| OWASP Command Injection (owasp.org, command-injection section) | Injection is about the value being interpreted as syntax (shell or flag), not just shell metacharacters | The CWE-88 "flag injection into the CALLEE's own parser" framing in Part 3's scope statement. |
| CWE-88 "Improper Neutralization of Argument Delimiters in a Command" (cwe.mitre.org) | Delimiter/argument injection is its own class, distinct from OS command injection | The -e NEEDLE "-i" false-empty receipt: the delimiter (-i) is swallowed as a flag, not as a path. |
| SonarSource "Preventing command injection" (sonarsource.com) | Whitelist/allowlist expected values, prefer explicit allowlists over blacklists | _TG_ONLY_SEARCH_FLAGS is an allowlist that forces full-CLI routing; the allowlist-parity invariant (every --x= has --x registered value-taking) is the model-the-class gate. |
| CPython gh-90259 / click #2748 / click #2790 (upstream) | Root-option shadowing: an option shared between a top-level and a subcommand parses for the WRONG context, so a value meant for one context is "consumed" by the other | Part 1/2's shadow-class: SEARCH_OPTION_FIRST_FLAGS's flag being consumed by the search-form parser instead of the positional context. |
| shrpx/secure-argv receipts (repo) | See Part 4's -q table from tests/... rg_parity / comment receipts in RipgrepBackend._build_cmd |
Consumers-parse-vs-stream enumeration. |
Repo receipts to cite by symbol, not line: main_entry, _normalize_search_invocation,
_requires_full_cli, _can_delegate_to_native_tg_search (all src/tensor_grep/cli/bootstrap.py);
SEARCH_OPTION_FIRST_FLAGS, SEARCH_PYTHON_PASSTHROUGH_FLAGS, and
normalize_top_level_search_args (rust_core/src/main.rs); _build_cmd and its consumers
(src/tensor_grep/backends/ripgrep_backend.py); tests/unit/test_argv_sentinel_covers_every_builder.py
(the behavioural census — read its docstring for WHY the source-scan form was retired); AGENTS.md A83.
Quick reference
[1] topology enumerate the normalizer joints + the predicates that route each resulting shape
[2] A83 census every door the REWRITTEN argv can reach; guard all N, ratchet by compile
[3] sentinel `--` UNCONDITIONALLY before caller-influenced positionals; assert POSITION
[4] consumers a flag that alters output belongs on the streaming path, never a parse path
[5] allowlist `_TG_ONLY_SEARCH_FLAGS` is an allowlist; keep the monotone (stricter-never-looser)
The endpoint: a rewritten argv whose every resulting door is either honored or refused loudly, a sentinel no caller-influenced value can get around, and a shared builder whose consumers are enumerated before the flag is added.