Skip to content

fix(cli): harden SQL argument handling - #110

Merged
hellozepp merged 1 commit into
mainfrom
sql-argument-fixes
Sep 23, 2026
Merged

hellozepp merged 1 commit into
mainfrom
sql-argument-fixes

Conversation

@hellozepp

Copy link
Copy Markdown
Collaborator

Summary

  • fix readonly classification for SHOW CREATE ..., including wrapped EXPLAIN introspection
  • detect shell-split SQL arguments and provide --file/stdin recovery commands
  • reject ambiguous SQL split between a positional argument and -- operands instead of silently truncating it
  • add regression coverage for CZECO-343 and CZECO-344

Validation

  • packages/clickzetta-sdk: readonly tests passed, typecheck passed
  • packages/cz-cli: SQL readonly, execute, and shell-split unit tests passed
  • full CLI typecheck remains blocked by the pre-existing packages/opencode/src/bus/global.ts:14 TS2416 error

Fixes CZECO-343
Fixes CZECO-344

Comment on lines +112 to +136
/** Globals that take no value, so the token after them is a positional, not their value. */
const VALUELESS_GLOBAL_FLAGS = new Set(["debug", "d", "help", "h", "version", "v"])

/**
* Index of the top-level command token, or -1.
*
* `args.find((a) => !a.startsWith("-"))` reads as "the first positional" and is
* wrong twice over: it returns a global's VALUE (`--schema sql task list` hands
* back "sql", `--schema tabel bogus` hands back "tabel"), and it returns an
* empty-string token, which forward.ts passes through verbatim by design. Only
* globals can precede the command, so KNOWN_GLOBAL_FLAGS is the complete set of
* value-takers to step over here. Mirrors run-cli.ts's subcommandIndex.
*/
function commandTokenIndex(args: string[]): number {
for (let index = 0; index < args.length; index++) {
const value = args[index]
if (!value) continue
if (value === "--") return -1
if (!value.startsWith("-")) return index
const flag = value.replace(/^-+/, "").split("=")[0]
if (!flag || value.includes("=") || VALUELESS_GLOBAL_FLAGS.has(flag)) continue
if (KNOWN_GLOBAL_FLAGS.includes(flag)) index++
}
return -1
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (confidence: high) — third copy of this walk, and the flag table it reuses is the wrong one.

const VALUELESS_GLOBAL_FLAGS = new Set(["debug", "d", "help", "h", "version", "v"])
...
    if (!flag || value.includes("=") || VALUELESS_GLOBAL_FLAGS.has(flag)) continue
    if (KNOWN_GLOBAL_FLAGS.includes(flag)) index++

run-cli.ts already has both halves of this:

  • packages/cz-cli/src/run-cli.ts:137const GLOBAL_FLAGS = new Set(["debug", "d", "help", "h", "version", "v"]), character-for-character the same set as the new VALUELESS_GLOBAL_FLAGS.
  • packages/cz-cli/src/run-cli.ts:138GLOBAL_FLAGS_WITH_VALUES, the curated list of globals that consume a following token.
  • packages/cz-cli/src/run-cli.ts:457-469 — the identical scan (if (!value) continue / if (value === "--") break / strip -+, split on =, index++ for value-takers), plus subcommandIndex at :403 as a second copy. The docstring here even says "Mirrors run-cli.ts's subcommandIndex".

So this is the third instance of the walk and the second copy of the valueless set, which is what KNOWN_GLOBAL_FLAGS's own comment at cli.ts:36-38 ("Kept here as the single source of truth to avoid drift") was trying to prevent.

The drift is already present, not hypothetical. KNOWN_GLOBAL_FLAGS is the did-you-mean candidate list, not a list of globals: it carries "target" and "t", which are declared as subcommand options (commands/update.ts:307, commands/task.ts:1574, :1736, :2391) and appear nowhere in GLOBAL_FLAGS_WITH_VALUES. Using it as the value-taker set means commandTokenIndex(["-t", "sql", ...]) consumes sql as -t's value and reports the wrong command token (or -1). The docstring's claim that "KNOWN_GLOBAL_FLAGS is the complete set of value-takers to step over here" is therefore inaccurate — it is a superset that includes two non-globals.

A second consequence of the same choice: any flag not in that list is assumed valueless, so a misspelled valued global before the command swallows the command position — cz-cli --formt json sql SELECT CASE … resolves the command token to "json", and looksLikeShellSplitSql returns false, silently losing the split advice in exactly the mixed typo+split case the handler at :410-415 argues it must not lose.

Smaller correct change: lift GLOBAL_FLAGS / GLOBAL_FLAGS_WITH_VALUES and the scan into one module both files import (they can't live in run-cli.ts since it imports createCli from here), and have commandTokenIndex use GLOBAL_FLAGS_WITH_VALUES rather than KNOWN_GLOBAL_FLAGS.

Comment on lines +418 to +421
suggestion ? `. Did you mean '${suggestion}'?` : "",
shellSplitSql
? ". The SQL appears to have been split by the shell — quote the whole statement, or put it in a file and run `cz-cli sql -f /tmp/query.sql` (or `cz-cli sql --stdin < /tmp/query.sql`)."
: "",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW (confidence: high) — when both parts fire the human message gets ?. run together.

        suggestion ? `. Did you mean '${suggestion}'?` : "",
        shellSplitSql
          ? ". The SQL appears to have been split by the shell — quote the whole statement, ..."

The new execute.test.ts case "reports a flag suggestion and the split advice together" exercises exactly this combination, so the emitted error.message is:

Unknown arguments: CASE, WHEN, …. Did you mean '--format'?. The SQL appears to have been split by the shell — …

?. reads as a typo to a human and the two sentences are the primary output surface for this failure. The test only asserts toContain on each half, so it passes either way. Dropping the leading . from the third element when the second one ended in ? (or joining the non-empty parts with " " after normalising terminal punctuation) fixes it.

Comment on lines +96 to 105
} else if ((cmdMatch?.[1] ?? argMatch?.[1] ?? "").includes(",")) {
// Several unknown tokens at once and nothing close enough to suggest: the
// usual cause is a quoted value (DDL, a SQL predicate, a JSON blob) that the
// shell tore apart before cz-cli saw it. Say so rather than only listing the
// debris — these commands are quote-sensitive and normal shell quoting and
// escaping rules apply to their arguments. Kept as a note on the existing
// message, not a second copy of cli.ts's detector.
finalAi =
`${finalAi} Arguments here are quote-sensitive and go through the shell first: if a quoted value was split into several tokens, re-quote it as one argument (escaping any inner quotes) or pass it through a file option instead of inline.`
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (confidence: high) — three separate concerns with this branch.

      } else if ((cmdMatch?.[1] ?? argMatch?.[1] ?? "").includes(",")) {

1. No test covers it. rg "quote-sensitive" matches only these two source lines — nothing in packages/cz-cli/test. The tests added in this PR (execute.test.ts, parameter-hardening.test.ts) all go through cli.ts's top-level fail handler via the sql leaf command; none reaches a commandGroup. So the one branch in this file has no regression guard, while the cli.ts detector has nine cases.

2. The gating is the opposite of the decision made in cli.ts in this same PR. cli.ts:410-415 argues, in a comment, that suppressing the split advice when a suggestion exists "costs the caller a second round trip on the same split", and deliberately emits both. Here the else if chain suppresses the quote note whenever suggestion && (badSubcommand || badFlag) — i.e. cz-cli task save-cron --cron 0 30 9 * * ? * --nam x (typo + shell-split value) gets only the typo. Either the cli.ts reasoning applies here too, or it doesn't apply there; right now the two handlers disagree.

3. .includes(",") also fires on a single unknown token that merely contains a comma. cmdMatch[1] / argMatch[1] is the raw tail of yargs' message, so cz-cli schema "a,b" yields Unknown command: a,b → one token, comma inside it → the shell-splitting note is appended to a failure that had nothing to do with quoting. Counting tokens (.split(",").length > 1) rather than testing for the character avoids that.

Also worth noting: unlike cli.ts, this branch changes only finalAi, never displayMsg, so a human running the command sees no hint at all. If that asymmetry is intentional the comment should say so.

Comment on lines +250 to +256
// Both at once is contradictory input, and returning the positional silently threw
// the operands away: `cz-cli sql "SELECT 1 AS a" -- FROM t` submitted
// `SELECT 1 AS a` and exited 0, and `cz-cli sql SELECT -- 1 AS x FROM t` submitted
// bare `SELECT`. Either way the user got results for a statement they never wrote,
// with nothing on stdout to say so. `--` exists for a statement that STARTS with
// `-`, so it is the whole statement or none of it.
if (argv.statement && operands.length > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (confidence: high) — sql --help still documents the combination this now rejects.

  if (argv.statement && operands.length > 0) {

The command's own epilogue at packages/cz-cli/src/commands/sql.ts:871 reads:

"SQL input priority: positional > `--` operands > -e/--execute > -f/--file > --stdin",

A documented priority between "positional" and "-- operands" means both may be supplied and the positional wins — which is precisely the behavior this PR removes. After this change that line tells the user to do something that exits 2. The comment at :240-241 ("Priority is the one this command's own --help documents") makes the epilogue the stated source of truth, so it should be updated in the same commit — e.g. positional | -- operands > -e/--execute > -f/--file > --stdin, plus a note that the two positional forms are mutually exclusive.

(I checked for other callers of the old behavior: rg "operands" finds no test or doc that passes a positional and -- operands, and parameter-hardening.test.ts:263 / :708 only use the bare sql -- <statement> form, which is unaffected. So the epilogue looks like the only stale reference.)

Comment on lines +261 to +263
`Got a SQL positional ("${argv.statement}") and operands after '--' ("${
operands.join(" ")
}"). Pass the statement once: quote it as a single argument, or put everything after '--'.`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (confidence: medium) — this message carries raw SQL text into telemetry, bypassing redactSql.

      `Got a SQL positional ("${argv.statement}") and operands after '--' ("${
        operands.join(" ")
      }"). Pass the statement once: ...`,

The path: handledErrorerror()output/index.ts:135 sets process.lastError = messagerun-cli.ts:865-867 passes it to track(..., lastError)trackCommand({ ..., error }). Nothing on that path redacts.

That matters because the repo deliberately redacts the same text everywhere else it reaches telemetry: telemetry.ts:141 is args["_positional"] = redactSql(positional.slice(2).join(" ")), and logger.ts:69 is entry.sql = redactSql(opts.sql). The -- operands are exactly positional[2..] — the tokens redactSql is applied to on the _positional path — and they are now interpolated verbatim into the telemetry error field. cz-cli sql "CREATE USER u IDENTIFIED BY" -- 's3cret' is the shape that leaks, and it is a plausible way for a statement to end up split across -- in the first place.

Caveat so you can judge severity: run-cli.ts:855 already records subcommand: positional[1] un-redacted, so the positional half of this message adds no new exposure. The operand half does.

Smallest fix: wrap both interpolations in redactSql (already exported from src/logger.ts), or drop the echoed text and name the counts instead ("a SQL positional and 4 operands after --") — the caller can see their own argv, so the echo is for readability rather than diagnosis.

Comment on lines +63 to +66
// EXPLAIN returns a plan and executes nothing, so unwrap it before any other rule
// looks at the statement. This used to happen after the WRITE scan, which left
// `EXPLAIN SHOW CREATE VIEW v` demanding write approval — CREATE was still in the
// string WRITE read — while plain `SHOW CREATE VIEW v` was readonly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW (confidence: high) — the example in this comment describes a pre-state that never existed.

  // `EXPLAIN SHOW CREATE VIEW v` demanding write approval — CREATE was still in the
  // string WRITE read — while plain `SHOW CREATE VIEW v` was readonly.

Before this PR the strip was anchored on ^SHOW\s+CREATE\s+TABLE\b, so plain SHOW CREATE VIEW v did not match it, CREATE survived into inspected, and it classified as write — not readonly. The asymmetry the comment describes (wrapped demands approval, unwrapped does not) only exists once the prefix is broadened to ^SHOW\s+CREATE\b further down in this same diff.

SHOW CREATE TABLE t is the statement that actually had that asymmetry before the change, and it makes the rationale hold on its own rather than depending on another edit in the same commit. This file's comments are the documentation for the classifier, and the readonly.test.ts cases are organised around them, so it's worth keeping the example exact.

Comment on lines +254 to +258
// `--limit 0` means "no limit" (rowLimit Infinity), which by design also skips the
// LIMIT probe injection in sql.ts. So this loop asserts one thing: introspection
// whose text contains a write keyword clears the approval gate and returns rows.
// The default-limit path, where these same statements get ` LIMIT <n+1>` appended,
// is a separate concern and is not covered here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (confidence: medium) — please confirm the intent: the path this comment excludes is the one the default invocation takes.

// The default-limit path, where these same statements get ` LIMIT <n+1>` appended,
// is a separate concern and is not covered here.

--limit defaults to 100 (commands/sql.ts:860), so cz-cli sql "SHOW CREATE VIEW v" — no flags — is the default-limit path, not the --limit 0 path these tests pin. Tracing it in commands/sql.ts:

  • isSelect true and isShow true (SHOW_RE = /^\s*SHOW\b/i, :15)
  • hasLimit false (LIMIT_RE = /\bLIMIT\s+\d+/i)
  • canRewrite true — newly true, because analysis.kind flipped from write to readonly in this PR
  • so :398 fires and :403 submits SHOW CREATE VIEW v LIMIT 101

Pre-PR the same command needed --write, and --write does not change analysis.kind, so canRewrite stayed false and the statement went to the server verbatim. Post-PR it goes with LIMIT 101 appended. Recovery rests on the retry at :406, which needs the server's error text to match both /syntax/i and /LIMIT/i; if ClickZetta words it differently (e.g. "Parse error near 'LIMIT'", "mismatched input"), the user gets a hard failure on a statement that worked before via --write.

SHOW CREATE TABLE t was already readonly before this PR, so whatever the server does with the appended LIMIT there is already the status quo — that's the reason I think this is probably fine. But VIEW / MATERIALIZED VIEW / PIPE / SEMANTIC VIEW are newly on this path, and nothing in the PR exercises it: every case in this loop passes --limit 0 specifically to skip it.

Could you confirm SHOW CREATE <object> LIMIT 101 is accepted by the engine (or that its rejection matches the :406 heuristic)? If neither is certain, gating the injection on !isShow || !/^\s*SHOW\s+CREATE\b/i.test(text) is cheaper than depending on error-string matching.

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

A. Upstream invasiveness — no issues found. All eight changed files are in packages/cz-cli and packages/clickzetta-sdk. Nothing under packages/opencode, packages/tui, packages/core or packages/schema is touched, so no banner and no new INTRUSIVE ledger entry are required. The cz_change: comments in the surrounding files are in the cz layer and are ordinary comments.

B. Clean fix vs. hole drilled around it — mostly the right fix, two findings.

The three substantive changes are each aimed at their cause rather than a symptom: the EXPLAIN unwrap moves before the WRITE scan instead of special-casing the wrapped forms; INTROSPECTION_PREFIXES replaces the chained .replace() calls whose output fed each other's anchors; and positional + -- operands becomes an explicit AMBIGUOUS_SQL rather than the previous silent truncation. looksLikeShellSplitSql is advice for a cause that is genuinely caller-side (shell quoting) and is honest about being a heuristic, which reads as the right call.

Findings:

  • cli.ts:112-136 — MEDIUM. Third copy of the global-flag walk and second copy of the valueless-flag set; run-cli.ts:137/138/457-469 already has both. It also reuses KNOWN_GLOBAL_FLAGS (the did-you-mean candidate list, which carries the non-global target/t) as the value-taker set, so the two tables have already diverged.
  • command-group.ts:96-105 — MEDIUM. Untested branch, gating that contradicts the reasoning cli.ts:410-415 states for the same situation, and a .includes(",") test that fires on a single token containing a comma.

Also flagged: raw SQL and operands into the telemetry error field without redactSql, unlike telemetry.ts:141 and logger.ts:69 (MEDIUM); a stale --help epilogue still documenting the priority this PR turns into an error (MEDIUM); an inaccurate rationale comment in readonly.ts (LOW); and ?. double punctuation when both message halves fire (LOW). No dead code, leftover logging, or unrelated drive-by edits.

C. Regression risk — enumerated below; one item raised as a separate question.

  1. SHOW CREATE <anything> is now readonly instead of write. Before, only SHOW CREATE TABLE was; VIEW / MATERIALIZED VIEW / DYNAMIC TABLE / EXTERNAL TABLE / PIPE / SEMANTIC VIEW all required --write. This is the intended CZECO-343 fix and is the widest behavior change here: the :772 approval gate in commands/sql.ts no longer stops these, so agents reach them without approval. Covered by readonly.test.ts:13-19 and the fail-closed cases at :74-81. I traced the WRITE scan and found no bypass — only the literal leading SHOW CREATE is consumed, SHOW CREATE OR REPLACE VIEW v still classifies write, and multi-statement input is split by scanSql before classify runs.
  2. Consequence of (1) that no test covers: these statements now take the LIMIT-probe path in commands/sql.ts:398-403. Raised as its own question on sql-readonly.test.ts:254-258 rather than asserted as a bug.
  3. EXPLAIN unwrapping before the WRITE scan: SET/USE interception and the UNSUPPORTED / END-without-CASE / QUERY checks all still read the variables they read before (text and query respectively), and EXPLAIN/EXTENDED are not WRITE keywords, so the reorder cannot un-flag a write. EXPLAIN ANALYZE still fails closed. Covered by readonly.test.ts:34-37.
  4. cz-cli sql "<stmt>" -- <more> goes from exit 0 (truncated statement, silently) to exit 2 AMBIGUOUS_SQL. New coverage at sql-readonly.test.ts:329-342. I grepped for existing callers of the old shape — parameter-hardening.test.ts:263 and :708 use only bare sql -- <statement>, which still works (:345). The stale epilogue is the one doc reference, flagged inline.
  5. did_you_mean for top-level commands changes when a valued global precedes the command: --schema tabel bogus used to suggest table for tabel (a flag value), now resolves bogus and suggests nothing; conversely --profile p tabel list now correctly suggests table where it previously scanned p. Both directions look like fixes. Existing assertions in robustness.test.ts:85-113 and parameter-hardening.test.ts:284-303 take the unchanged paths (no valued global before the command).
  6. Command output shape: error.message and ai_message gain a sentence for sql usage errors that trip the heuristic. The no-suggestion/no-split default strings are byte-identical to before (aiParts reassembles "… Did you mean …? Run cz-cli --help to see all available commands." exactly), so scripts matching the old text only see a change on invocations that were already failing. parameter-hardening.test.ts:290 and :295 assert full-string equality on commandGroup paths and are unaffected.
  7. No tests deleted, skipped, or loosened. sql-readonly.test.ts:259 gains a case; readonly.test.ts gains 18. No exported API, config key, or on-disk path changes; looksLikeShellSplitSql is a new export from cli.ts, consumed only by cli.ts itself and parameter-hardening.test.ts. No new cross-package dependency edges.

I have not run anything — the PR body's own note that full CLI typecheck is blocked by a pre-existing packages/opencode/src/bus/global.ts:14 error stands as the author's report, not something I verified.

@hellozepp
hellozepp merged commit 7d86116 into main Sep 23, 2026
2 checks passed
@hellozepp
hellozepp deleted the sql-argument-fixes branch September 23, 2026 02:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants