feat: #147 — reject empty / whitespace-only prompts at CLI fallthrough

## 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.
This commit is contained in:
YeonGyu-Kim 2026-04-21 20:35:17 +09:00
parent f877acacbf
commit 4cb8fa059a
2 changed files with 90 additions and 1 deletions

View File

@ -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.

View File

@ -824,8 +824,21 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
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]