Repository navigation
fix(cli): reject a swallowed --json when catalog's string flags have no value - #3992
Conversation
…no value catalog --query --json (missing --query value) parsed as query="--json", json=false, since citty's parser (node:util.parseArgs, strict: false) unconditionally consumes the next token as a string arg's value with no guard against it being another flag -- JSON-mode consumers silently got human-readable stdout instead of a clear error. Added an opt-in rejectSwallowedFlagValues() check (packages/cli/src/utils/ reject-unknown-flags.ts), called from catalog's own run() for its query/type/tag string args. Kept it opt-in rather than wiring it into the global assertKnownFlags pre-parse gate every command goes through: check.ts's --frame-check is a string flag that's documented and tested to work bare or immediately followed by another flag, handled by that command's own raw-args preprocessing before citty parses it -- a blanket pre-parse check can't tell that case apart from this bug, so review caught it as a real regression before it shipped. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
catalog --query --json (round 1's fix) turned out to be one instance of a codebase-wide gap: any command with a type:"string"|"enum" flag followed by --json (or any other declared flag) has its value swallowed by citty's parser (node:util.parseArgs, strict: false unconditionally consumes the next token), confirmed still reproducing on check --layout --json. Round 1's post-parse, catalog-only check also duplicated check.ts's own existing --frame-check raw-args mechanism instead of replacing it, and couldn't distinguish the always-legitimate --query=--json (equals form) from the actual --query --json swallow, since node:util.parseArgs parses both to the same object. Replaced with a single raw-args scan (guardSwallowedFlagValues), wired into the global wrapCommand gate next to assertKnownFlags so every command is covered. Default: throw CliUsageError. Two narrow per-command opt-outs where a command already has its own bespoke handling for this exact bug shape: check's --frame-check (rewritten to --flag=, folding in and removing check.ts's own normalizeFrameCheckRawArgs/swallowedOptionError) and upgrade's --project (left untouched, since it already recovers the swallowed value itself). A third, previously-undiscovered collision surfaced during testing: init.ts had its own bespoke --example dash-value guard that the new global gate now preempts; folded that in too and updated its one affected test's expectation. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
Round 2's guardSwallowedFlagValues threw CliUsageError without presented:true, the first non-presented CliUsageError construction in the CLI -- executeCli's dead stdout-usage-dump branch (every other CliUsageError goes through failUsage(), which always sets presented:true) fired for the first time, leaking 1.4-5.7KB of ANSI usage text onto stdout on top of the correct stderr message, breaking --json callers' clean-stdout contract on exactly the class of error this ticket exists to fix. Split into two functions instead of one, since the two throw sites have genuinely different reach: throwSwallowedFlagError (used by guardSwallowedFlagValues, thrown before any command's own run() executes) prints the message itself and sets presented:true, matching every other print-then-failUsage() usage-error site in this codebase. swallowedFlagUsageError (used by check.ts's parseFrameCheck) is a plain, side-effect-free CliUsageError factory, because that throw is always caught by check.ts's own run() try/catch, which already prints and presents every error uniformly -- using the printing variant there instead produced a real, verified regression (a doubled stderr line in non-JSON mode, an extra stderr line even in --json mode), caught by adversarial review before it shipped. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
…lag error The prior test for this only asserted the internal presented:true flag, with a comment noting the actual 0-byte-stdout behavior was checked by hand against the built CLI. Added an automated spawn-based test (same pattern as init.test.ts's runInit) that runs the real CLI and asserts stdout is literally empty, not just that the presented mechanism is engaged. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
somanshreddy
left a comment
There was a problem hiding this comment.
APPROVE at 3ba621b4 — the mechanism is correct and both opt-out exceptions are right. Two things worth putting on record (neither blocking): the PR description materially under-describes the blast radius, and the completeness of the exception maps is the load-bearing assumption. Independent manual pass (codex disabled per workspace cap — my own source verification, flagged for honesty).
Heads-up: the diff is broader than the summary. The summary says "deliberately opt-in per command, not merged into the global gate … wired into catalog.ts's own run()." The actual change is the opposite shape, and catalog.ts isn't touched at all. guardSwallowedFlagValues is wired into the global wrapCommand gate (command-failure-tracking.ts) right beside assertKnownFlags, so it now runs for every leaf command. The per-command guards it replaces — check's normalizeFrameCheckRawArgs and init's --example requires a value — are removed and folded into it. "Opt-in per command" now refers to the two exception maps, not to where the guard runs. The code is correct; I'm flagging this so the merge isn't decided on a catalog-only mental model of the blast radius.
Mechanism verified at source:
- Triggers only for a command's own
type:"string"|"enum"flags (stringValueFlagOwners) — booleans never swallow, and the test pins that. Fires only when the next token is a recognized flag (looksLikeSwallowedFlag:next === "--"or a known-flag spelling), so an ordinary dash-value like--query -out.mp4is accepted while--query --jsonis rejected.--query=--jsonis always accepted becauseownableFlagSpellingbails on any token containing=— the one case a raw-args scan can distinguish thatnode:util.parseArgscollapses. - Double-print is correctly split:
throwSwallowedFlagError(from insidewrapCommand, before anyrun()try/catch exists) prints and marks the errorpresented:truesoexecuteCliwon't re-dump usage;swallowedFlagUsageError(returned for check'sparseFrameCheck, which is caught by check's ownrun()) is a plain non-printing error so check prints it exactly once. The "prints exactly once, not doubled" test pins this. - Both exceptions are correctly chosen.
SWALLOW_REWRITE_FLAGS = {check: frame-check}rewrites--frame-check --json→--frame-check=(bare = defaults per check's grammar) and the wrapper updates bothctx.rawArgsandctx.args, which matters because check re-derives args fromctx.rawArgs.SWALLOW_IGNORE_FLAGS = {upgrade: project}leaves rawArgs untouched — verified correct againstupgrade.ts:resolveProjectArgs, which deliberately recovers a swallowed--projectvalue (project.startsWith("-")→ treats--checkas the flag, falls back to cwd). Guarding either of those would have been the regression the PR set out to avoid.
The one thing I could not fully close — non-blocking: the exception maps assume frame-check and upgrade/project are the only string/enum flags with legitimate bare-then-flag semantics across the whole CLI. I verified those two are correct and spot-checked, but did not exhaustively enumerate every command's string flags (a startsWith("-") code search came back empty, but that search is unreliable, so I'm not treating it as proof). Resting on the author's stated adversarial iteration (the body notes an earlier global-gate draft where review already caught frame-check, and upgrade/project was found too — evidence of real enumeration, not a one-case patch) plus green CI across all command suites. If a third bare-optional string flag exists without test coverage, --thatflag --knownflag would start erroring; worst case is a clearer error on a rare idiom, not silent breakage.
Tests are thorough and real (throw / accept-equals / accept-dash-nonflag / trailing-bare / boolean-ignored / ---terminator / rewrite / the exact catalog repro, plus check's --layout swallow and the frame-check single-print). All required CI green; BLOCKED is branch protection awaiting the stamp. Home to merge.
Provenance: my own source pass at 3ba621b4 (reject-unknown-flags.ts, command-failure-tracking.ts, check.ts, init.ts, upgrade.ts + tests) + exception-wiring trace; codex disabled (owner cap). Did not run the suite locally (env); resting on source verification + green required checks. Recommend the author sync the PR description to the shipped (global) design.
Keep main's extra-positional check in wrapCommand and run the swallow guard before it. Key the guard's per-command opt-ins by main's full command path instead of the leaf meta.name, so `skills check` no longer shares check's rewrite. Trim long comments.
Edit accuracy: accurate 2061 (base branch 2061), smooth 1599 of thoseThe gate passes. Quarantined, measured but not gated (0) |
…e audit gate Moves the leaf flag checks out of the wrapCommand run arrow, drops ignored args from the owner map, and pulls the per-token decision out of the guard loop. Trims two comments to keep check.ts under its comment share. No behaviour change.
jrusso1020
left a comment
There was a problem hiding this comment.
Approve at 5816dfe6. A string flag that swallows the next option now fails with a clear message. I found no false rejection: a sweep of 1,432 documented invocations (1,170 from tracked .md/.mdx files, 262 from help strings and skills) is never rejected or rewritten. The check and upgrade exceptions keep their behaviour.
Should-fix
auth login --api-keyis a third flag documented to work with no value.commands/auth/login.ts:65-68says to pass--api-keywith no value to read the key from stdin, as inecho "$HEYGEN_API_KEY" | hyperframes auth login --api-key.- Now
auth login --api-key --jsonor--api-key --devicefails with "Missing value for --api-key … pass one: --api-key or --api-key=". - This isn't a regression, since main took the next flag as the key itself. But the hint steers people to put a secret on the command line, where it lands in shell history.
- Add
"auth login": new Set(["api-key"])toSWALLOW_REWRITE_FLAGS(utils/reject-unknown-flags.ts, around line 104). Tested:--api-key --jsonthen reads stdin as documented, and--api-key --devicereaches the command's own "Choose either --device or --api-key" error.
- Now
- One exception table instead of two. Moving
upgrade --projectfromSWALLOW_IGNORE_FLAGSinto the rewrite table behaves identically. I tested--project --check,--json,-y, bare, and--project dir --json. That makes both the skip table andupgrade.ts:175-181's own recovery removable, leaving one list: string flags documented to work with no value.
Nits
- A trailing flag with nothing after it is still silent:
render p --outputfalls back to the default path, andcatalog --querylists everything. The body says so, but "a string flag left without its value now fails" overstates it; it fails only when the next token is a flag or--. - The
enumbranch instringValueFlagOwnersis dead. None of the 381 string args across 62 commands is an enum, and citty validates enums beforerun. - The wording
value "--json" appears to have swallowed the next optionreads backwards: the flag swallowed it. It also always names the long flag when the user typed-oor--out. - Under
--json, a swallowed value gives exit 1 with plain text on stderr and nothing on stdout, which matches unknown-flag errors.checkcallers used to get{"ok":false,…}on stdout in this case. If that matters, one JSON error shape atwrapCommandwould cover every usage error. - Not from this PR: the older unknown-flag check rejects negative values written with a space (
render --fps -1,keyframes --angle -45,check --at -1,render --output -out.mp4), while the=form works. That's worth a follow-up, sincenormalize-audio --lufsdefaults to-16.
Checked (built CLI, head vs main)
- Now rejected (main silently took the next token as the value):
catalog --query --json: main exited 0 withquery="--json";render p --output --json,-o --json,-o -w,--output --no-docker --json(the error names--json);tts -o --voice,media-use resolve -p --json,cloud render --title --json,feedback --search-miss --json.
- Unchanged:
catalog --query=--json --json,--query -,--jsonbefore or after;render p --output --no-docker out.mp4,--output=-out.mp4;- a repeated
--output,--helpon any command,tts -- --hello; upgrade --projectwith--check/--json/-y/bare;check d --frame-check --json.
- Improved:
check d --frame-check --caption-zone … --jsonnow works (main: "unexpected extra argument"), becausectx.argsis re-parsed;render p --output -- xgives a clear missing-value error.
- Tests:
- The focused suites (reject-unknown-flags, command-failure-tracking, check, init, upgrade) pass 169/169.
- The full CLI suite has 4,589 passed. The 2 failures are 30 s browser-launch timeouts under load, and they're green in CI.
- Typecheck, oxlint and oxfmt are clean on the 7 changed files.
- Mutants: all 8 are caught: guard off (10 tests fail),
--no-xlook-past removed (2), check exception (5), upgrade exception (1),ctx.argsnot re-parsed (1),--frame-check --no-xrewrite (1), the already-printed marker (2), and--handling (1). The upgrade exception's test hardcodes the path, so a wrong path key wouldn't be caught. - CI: all 11 required checks pass at this head.
— Rames
Summary
catalog --query --json(a missing--queryvalue) silently parsed asquery: "--json",json: falseinstead of erroring. citty's parser (node:util.parseArgs,strict: false) consumes the next argv token as atype: "string"option's value, even when that token is another declared flag. A consumer piping stdout got a human-readable table instead of JSON, with exit code 0.Control case keeps working:
catalog --query "kinetic type" --jsonstill emits parseable JSON on stdout.Fix
guardSwallowedFlagValues(cmd, path, rawArgs)inpackages/cli/src/utils/reject-unknown-flags.tsscans the raw argv for a string/enum flag followed by--or by a spelling of one of the command's own flags, and fails withMissing value for --<flag>: ... use --<flag>= or move --<flag> to the end. It scans raw argv because the parsed args cannot tell--query --jsonfrom the legitimate--query=--json.The guard is shared:
wrapCommandinpackages/cli/src/utils/command-failure-tracking.tsruns it for every leaf command right after the existing unknown-flag check, so every command's string flags get the protection, not just catalog's. Two commands keep their documented behaviour through per-path exceptions:check --frame-checkis documented to work bare or followed by another flag. The guard rewrites a bare--frame-checkto--frame-check=and re-parses the args, replacing check's own raw-args preprocessing.upgrade --projectalready recovers a swallowed value itself, so the guard skips it.The exceptions are keyed by full command path, so
skills checkdoes not inherit check's rewrite. The error is printed once and marked as presented, so--jsoncallers get an empty stdout instead of a usage dump.init --example's hand-rolled check is removed in favour of the shared one.Behaviour notes:
--no-<flag>before parsing, so the guard looks past them:--output --no-docker --jsonis rejected naming--json, a trailing--output --no-dockeris fine, and check rewrites--frame-check --no-browser-gpu <dir>to--frame-check=as before.--output <value>or--output=<value>); check's--frame-checkkeeps its "use--frame-check=" hint.--json, a swallowed-value error now goes to stderr with empty stdout and exit 1, the same as an unknown flag. Scripts that parsed a JSON error object from stdout for this case (e.g.check --json --layout --strict) get nothing on stdout now.Test plan
reject-unknown-flags.test.ts: the swallow is rejected; the equals form, an unrecognised dash value, a trailing bare flag and boolean args are accepted;--gets its own wording; check's rewrite, upgrade's skip and the path scoping each have a case; end-to-end through citty's realrunCommandand the realwrapCommand(the reportedcatalog --query --jsonrepro, a positional ahead of the flags, nestedskills check); stdout stays empty on the built CLI.check.test.ts: a dash-prefixed--frame-checkvalue fails once, not twice, and--jsonafter a--layoutvalue is no longer swallowed.reject-unknown-flags,command-failure-tracking,checkandinittest files pass 3 runs in a row; breaking the upgrade skip or the re-parse fails the matching tests.