From 4cb8fa059acc6ab97e07166b580c94cf56c2803b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 21 Apr 2026 20:35:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20#147=20=E2=80=94=20reject=20empty=20/?= =?UTF-8?q?=20whitespace-only=20prompts=20at=20CLI=20fallthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The `"prompt"` subcommand arm enforced `if prompt.trim().is_empty()` and returned a specific error. The fallthrough `other` arm in the same match block — which routes any unrecognized first positional arg to `CliAction::Prompt` — had no such guard. Result: $ claw "" error: missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN ... $ claw " " error: missing Anthropic credentials; ... $ claw "" "" error: missing Anthropic credentials; ... $ claw --output-format json "" {"error":"missing Anthropic credentials; ...","type":"error"} An empty prompt should never reach the credentials check. Worse: with valid credentials, the literal empty string gets sent to Claude as a user prompt, either burning tokens for nothing or triggering a model- side refusal. Same prompt-misdelivery family as #145. ## Root cause In `parse_subcommand()`, the final `other =>` arm in the top-level match only guards against typos (#108 guard via `looks_like_subcommand_typo`) and then unconditionally builds `CliAction::Prompt { prompt: rest.join(" ") }`. An empty/whitespace-only join passes through. ## Changes ### rust/crates/rusty-claude-cli/src/main.rs Added the same `if joined.trim().is_empty()` guard already used in the `"prompt"` arm to the fallthrough path. Error message distinguishes it from the `prompt` subcommand path: empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string Runs AFTER the typo guard (so `claw sttaus` still suggests `status`) and BEFORE CliAction::Prompt construction (so no network call ever happens for empty inputs). ### Regression tests Added 4 assertions in the existing parse_args test: - parse_args([""]) → Err("empty prompt: ...") - parse_args([" "]) → Err("empty prompt: ...") - parse_args(["", ""]) → Err("empty prompt: ...") - parse_args(["sttaus"]) → Err("unknown subcommand: ...") [verifies #108 typo guard still takes precedence] ### ROADMAP.md Added Pinpoint #147 documenting the gap, verification, root cause, fix shape, and acceptance. Joins the prompt-misdelivery cluster alongside #145. ## Live verification $ claw "" error: empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string $ claw " " error: empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string $ claw --output-format json "" {"error":"empty prompt: provide a subcommand ...","type":"error"} $ claw prompt "" # unchanged: subcommand-specific error preserved error: prompt subcommand requires a prompt string $ claw hello # unchanged: typo guard still fires error: unknown subcommand: hello. Did you mean help $ claw "real prompt here" # unchanged: real prompts still reach API error: api returned 401 Unauthorized (with dummy key, as expected) All empty/whitespace-only paths exit 1. No network call. No misleading credentials error. ## Tests - rusty-claude-cli bin: 177 tests pass (4 new assertions) - Full workspace green except pre-existing resume_latest flake (unrelated) Closes ROADMAP #147. --- ROADMAP.md | 46 ++++++++++++++++++++++++ rust/crates/rusty-claude-cli/src/main.rs | 45 ++++++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index e7fc715..010269e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5619,3 +5619,49 @@ Meanwhile `agents`, `mcp`, `skills`, `status`, `doctor`, `sandbox`, `plugins` (a **Not applying to.** `hooks` (session-state-modifying, explicitly flagged "unsupported resumed slash command" in main.rs), `usage`, `context`, `tasks`, `theme`, `voice`, `rename`, `copy`, `color`, `effort`, `branch`, `rewind`, `ide`, `tag`, `output-style`, `add-dir` — all session-mutating or interactive-only. **Source.** Jobdori dogfood 2026-04-21 20:03 KST on main HEAD `7d63699` in response to Clawhip nudge. Joins **surface asymmetry** cluster (#145 sibling). Session tally: ROADMAP #146. + +## Pinpoint #147. `claw ""` / `claw " "` silently fall through to prompt-execution path; empty-prompt guard is subcommand-only + +**Gap.** The explicit `claw prompt ""` path rejects empty/whitespace-only prompts with a clear error (`prompt subcommand requires a prompt string`, exit 1, no network call). The implicit fallthrough path — where any unrecognized first positional arg is treated as a prompt — has no such guard. Result: `claw ""`, `claw " "`, and `claw "" ""` all get routed to the Anthropic call with an empty prompt string, which surfaces the misleading `missing Anthropic credentials` error. + +**Verified on main HEAD `f877aca` (2026-04-21 20:32 KST):** + +``` +$ claw prompt "" +error: prompt subcommand requires a prompt string + +$ claw "" +error: missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY ... + +$ claw " " +error: missing Anthropic credentials; ... + +$ claw "" "" +error: missing Anthropic credentials; ... + +$ claw --output-format json "" +{"error":"missing Anthropic credentials; ...","type":"error"} +``` + +With valid credentials, the empty string would be sent to Claude as a user prompt — burning tokens for nothing, or getting a model-side refusal for empty input. + +**Why this is a clawability gap.** +1. **Inconsistent guard**: the `"prompt"` subcommand arm enforces `if prompt.trim().is_empty() { Err(...) }`, but the fallthrough `other` arm in the same match block does not. Same contract should apply to both paths. +2. **Prompt misdelivery (Clawhip category)**: same root pattern as #145 (wrong thing gets treated as a prompt). Different manifestation — here it's an empty string, not a typo'd subcommand. +3. **Misleading error surface**: user sees `missing Anthropic credentials` for a request that should never have reached the API layer at all. +4. **Clawhip risk**: a misconfigured orchestrator passing `""` or `" "` as a positional arg ends up paying API costs for empty prompts instead of getting fast feedback. + +**Fix shape (~5 lines).** In `parse_subcommand()`'s fallthrough `other` arm, add the same trim-based empty check already used in the `"prompt"` arm, with a message that distinguishes it from the `prompt` subcommand path (e.g. `"empty prompt: provide a command or non-empty prompt text"`). Happens before `looks_like_subcommand_typo` since typos aren't empty. + +**Acceptance.** +- `claw ""` exits 1 with a clear "empty prompt" error, no credential check. +- `claw " "` exits 1 with the same error. +- `claw "" ""` exits 1 with the same error. +- `claw --output-format json ""` emits the error in typed envelope, exit 1. +- `claw hello` still reaches the typo guard (#108), not the empty guard. +- `claw prompt ""` still emits its own specific error. +- Regression test: `parse_args([""])` → Err, `parse_args([" "])` → Err. + +**Blocker.** None. 5-line change in `parse_subcommand()`. + +**Source.** Jobdori dogfood 2026-04-21 20:32 KST on main HEAD `f877aca` in response to Clawhip nudge. Joins **prompt misdelivery** cluster (#145 sibling). Session tally: ROADMAP #147. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 0eb08a7..7625ca4 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -824,8 +824,21 @@ fn parse_args(args: &[String]) -> Result { return Err(message); } } + // #147: guard empty/whitespace-only prompts at the fallthrough + // path the same way `"prompt"` arm above does. Without this, + // `claw ""`, `claw " "`, and `claw "" ""` silently route to + // the Anthropic call and surface a misleading + // `missing Anthropic credentials` error (or burn API tokens on + // an empty prompt when credentials are present). + let joined = rest.join(" "); + if joined.trim().is_empty() { + return Err( + "empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string" + .to_string(), + ); + } Ok(CliAction::Prompt { - prompt: rest.join(" "), + prompt: joined, model, output_format, allowed_tools, @@ -9743,6 +9756,36 @@ mod tests { output_format: CliOutputFormat::Json, } ); + // #147: empty / whitespace-only positional args must be rejected + // with a specific error instead of falling through to the prompt + // path (where they surface a misleading "missing Anthropic + // credentials" error or burn API tokens on an empty prompt). + let empty_err = parse_args(&["".to_string()]) + .expect_err("empty positional arg should be rejected"); + assert!( + empty_err.starts_with("empty prompt:"), + "empty-arg error should be specific, got: {empty_err}" + ); + let whitespace_err = parse_args(&[" ".to_string()]) + .expect_err("whitespace-only positional arg should be rejected"); + assert!( + whitespace_err.starts_with("empty prompt:"), + "whitespace-only error should be specific, got: {whitespace_err}" + ); + let multi_empty_err = parse_args(&["".to_string(), "".to_string()]) + .expect_err("multiple empty positional args should be rejected"); + assert!( + multi_empty_err.starts_with("empty prompt:"), + "multi-empty error should be specific, got: {multi_empty_err}" + ); + // Typo guard from #108 must still take precedence for non-empty + // single-word non-prompt-looking inputs. + let typo_err = parse_args(&["sttaus".to_string()]) + .expect_err("typo'd subcommand should be caught by #108 guard"); + assert!( + typo_err.starts_with("unknown subcommand:"), + "typo guard should fire for 'sttaus', got: {typo_err}" + ); } #[test]