Skip to content

CLI: arguments are tokenized twice (router → handler), silently corrupting structural payloads #77

Description

@spouletmathis

Summary

Command arguments are tokenized twice on their way to a handler: once by the router, once by the handler itself. Every structural payload (JSON objects, quoted values, comma-separated lists) therefore has to survive a second de-quoting pass it was never meant to go through.

This is not a hypothetical. It has produced at least two distinct user-visible bugs, and the current fixes patch the symptom rather than the shape:

Filing this so the class of bug is tracked, not just its instances.

The mechanism

argv  →  cli_execution.dart   re-quotes structural args  (quoteIfNeeded)
      →  command_router.dart  tokenizes, strips quotes, re-joins to a String
      →  handler              tokenizes AGAIN, strips quotes again
      →  jsonDecode / validation

Step 2 is the problem: the router flattens List<String> back into a String, so anything the shell already unquoted must be re-protected — by hand, correctly, at every hop. Miss it once and the payload is silently corrupted rather than rejected.

The --json regression is exactly that miss. On the real binary:

DBG input=mcp chain.add --json {"chain_id":100} --timeout 20
DBG tokens=[mcp, chain.add, --json, {chain_id:100}, --timeout, 20]
Error: invalid --json: Unexpected character

{"chain_id":100} reached jsonDecode as {chain_id:100}. Bisected to 16b841339, which rewrote GrammarParser.tokenize to handle quotes — a correct change in isolation that broke a caller relying on the old behaviour.

Both fixes so far (#57, bbdad4a5) add a compensating re-quote at the point where breakage was observed. That works, but the next flag carrying structural data starts from the same footing.

Why it matters beyond the two known cases

The failure mode is quiet. A corrupted payload does not look like a parsing bug at the call site — it surfaces as invalid --json, or as a validation error on an address that looks fine in the shell history. For a script-based caller there is no way to tell "you passed bad JSON" from "the CLI mangled your JSON".

Suggested direction

Not prescriptive, but the options seem to be:

  1. Keep tokens as List<String> end to end — pass the parsed argv to handlers instead of re-serializing to a String. Removes the second tokenization entirely, so no re-quoting is needed anywhere.
  2. Single quoting authority — if the string round-trip must stay, make re-quoting a property of the router's serialization step rather than something each caller remembers, so a new flag is protected by default.
  3. Guard the invariant with a test that reproduces the real handler contract (see below) — cheapest, but only catches known paths.

Option 1 also retires quoteIfNeeded / _requoteIfNeeded / the handlers' private _tokenize copies, which currently duplicate near-identical quote logic in three places.

Acceptance tests

Verifiable criteria for whichever direction is chosen:

  1. alter-cli mcp chain.add --json '{"chain_id":100}' returns a decodable envelope, not invalid --json.
  2. Nested structures and spaces survive: --json '{"a":[1,2],"b":"x y"}' reaches the handler byte-identical.
  3. lending positions-batch --users 0xAAA,0xBBB — first address arrives unmodified (lending positions-batch --users a,b prepends a stray quote to the first address, always failing validation #57 stays fixed).
  4. Assertions are made on the payload as received by the handler, not on router output.

Criterion 4 is the one that bites. A mock that does not re-tokenize passes against broken code — verified while writing the regression test for --json: the naive version was green with the bug present.

Regression test available

VISIALIS/phoenix_0#1002 adds the --json case, with a mock replicating McpProxyCommandHandler._tokenize. Fails without the fix (rawJson={chain_id:100}), passes with it, 29/29 in the file.

Environment

  • macOS 26.6.1, built from source (dart compile exe bin/cli.dart), CLI + MCP server
  • Released alter-cli 1.25.0 unaffected by the --json case

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions