mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-04-24 05:00:25 +08:00
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:
parent
f877acacbf
commit
4cb8fa059a
46
ROADMAP.md
46
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.
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user