Skip to content

Commit 4cb8fa0

Browse files
committed
feat: ultraworkers#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 ultraworkers#145. ## Root cause In `parse_subcommand()`, the final `other =>` arm in the top-level match only guards against typos (ultraworkers#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 ultraworkers#108 typo guard still takes precedence] ### ROADMAP.md Added Pinpoint ultraworkers#147 documenting the gap, verification, root cause, fix shape, and acceptance. Joins the prompt-misdelivery cluster alongside ultraworkers#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 ultraworkers#147.
1 parent f877aca commit 4cb8fa0

2 files changed

Lines changed: 90 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5619,3 +5619,49 @@ Meanwhile `agents`, `mcp`, `skills`, `status`, `doctor`, `sandbox`, `plugins` (a
56195619
**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.
56205620

56215621
**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.
5622+
5623+
## Pinpoint #147. `claw ""` / `claw " "` silently fall through to prompt-execution path; empty-prompt guard is subcommand-only
5624+
5625+
**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.
5626+
5627+
**Verified on main HEAD `f877aca` (2026-04-21 20:32 KST):**
5628+
5629+
```
5630+
$ claw prompt ""
5631+
error: prompt subcommand requires a prompt string
5632+
5633+
$ claw ""
5634+
error: missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY ...
5635+
5636+
$ claw " "
5637+
error: missing Anthropic credentials; ...
5638+
5639+
$ claw "" ""
5640+
error: missing Anthropic credentials; ...
5641+
5642+
$ claw --output-format json ""
5643+
{"error":"missing Anthropic credentials; ...","type":"error"}
5644+
```
5645+
5646+
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.
5647+
5648+
**Why this is a clawability gap.**
5649+
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.
5650+
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.
5651+
3. **Misleading error surface**: user sees `missing Anthropic credentials` for a request that should never have reached the API layer at all.
5652+
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.
5653+
5654+
**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.
5655+
5656+
**Acceptance.**
5657+
- `claw ""` exits 1 with a clear "empty prompt" error, no credential check.
5658+
- `claw " "` exits 1 with the same error.
5659+
- `claw "" ""` exits 1 with the same error.
5660+
- `claw --output-format json ""` emits the error in typed envelope, exit 1.
5661+
- `claw hello` still reaches the typo guard (#108), not the empty guard.
5662+
- `claw prompt ""` still emits its own specific error.
5663+
- Regression test: `parse_args([""])` → Err, `parse_args([" "])` → Err.
5664+
5665+
**Blocker.** None. 5-line change in `parse_subcommand()`.
5666+
5667+
**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.

rust/crates/rusty-claude-cli/src/main.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -824,8 +824,21 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
824824
return Err(message);
825825
}
826826
}
827+
// #147: guard empty/whitespace-only prompts at the fallthrough
828+
// path the same way `"prompt"` arm above does. Without this,
829+
// `claw ""`, `claw " "`, and `claw "" ""` silently route to
830+
// the Anthropic call and surface a misleading
831+
// `missing Anthropic credentials` error (or burn API tokens on
832+
// an empty prompt when credentials are present).
833+
let joined = rest.join(" ");
834+
if joined.trim().is_empty() {
835+
return Err(
836+
"empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string"
837+
.to_string(),
838+
);
839+
}
827840
Ok(CliAction::Prompt {
828-
prompt: rest.join(" "),
841+
prompt: joined,
829842
model,
830843
output_format,
831844
allowed_tools,
@@ -9743,6 +9756,36 @@ mod tests {
97439756
output_format: CliOutputFormat::Json,
97449757
}
97459758
);
9759+
// #147: empty / whitespace-only positional args must be rejected
9760+
// with a specific error instead of falling through to the prompt
9761+
// path (where they surface a misleading "missing Anthropic
9762+
// credentials" error or burn API tokens on an empty prompt).
9763+
let empty_err = parse_args(&["".to_string()])
9764+
.expect_err("empty positional arg should be rejected");
9765+
assert!(
9766+
empty_err.starts_with("empty prompt:"),
9767+
"empty-arg error should be specific, got: {empty_err}"
9768+
);
9769+
let whitespace_err = parse_args(&[" ".to_string()])
9770+
.expect_err("whitespace-only positional arg should be rejected");
9771+
assert!(
9772+
whitespace_err.starts_with("empty prompt:"),
9773+
"whitespace-only error should be specific, got: {whitespace_err}"
9774+
);
9775+
let multi_empty_err = parse_args(&["".to_string(), "".to_string()])
9776+
.expect_err("multiple empty positional args should be rejected");
9777+
assert!(
9778+
multi_empty_err.starts_with("empty prompt:"),
9779+
"multi-empty error should be specific, got: {multi_empty_err}"
9780+
);
9781+
// Typo guard from #108 must still take precedence for non-empty
9782+
// single-word non-prompt-looking inputs.
9783+
let typo_err = parse_args(&["sttaus".to_string()])
9784+
.expect_err("typo'd subcommand should be caught by #108 guard");
9785+
assert!(
9786+
typo_err.starts_with("unknown subcommand:"),
9787+
"typo guard should fire for 'sttaus', got: {typo_err}"
9788+
);
97469789
}
97479790

97489791
#[test]

0 commit comments

Comments
 (0)