Skip to content

Commit 77afde7

Browse files
committed
Clarify allowed tool status handling
Reject empty --allowedTools inputs instead of treating them as an empty restriction, and surface status JSON metadata that distinguishes default unrestricted tools from flag-provided allow lists. Confidence: high Scope-risk: narrow Tested: cargo test -p rusty-claude-cli rejects_empty_allowed_tools_flag -- --nocapture Tested: cargo test -p tools allowed_tools_rejects_empty_token_lists -- --nocapture Tested: cargo check -p rusty-claude-cli -p tools Tested: cargo test -p rusty-claude-cli -p tools Not-tested: full workspace cargo fmt --check is blocked by pre-existing unrelated formatting drift
1 parent 6db68a2 commit 77afde7

2 files changed

Lines changed: 118 additions & 7 deletions

File tree

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

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,14 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
372372
model_flag_raw,
373373
permission_mode,
374374
output_format,
375-
} => print_status_snapshot(&model, model_flag_raw.as_deref(), permission_mode, output_format)?,
375+
allowed_tools,
376+
} => print_status_snapshot(
377+
&model,
378+
model_flag_raw.as_deref(),
379+
permission_mode,
380+
output_format,
381+
allowed_tools.as_ref(),
382+
)?,
376383
CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?,
377384
CliAction::Prompt {
378385
prompt,
@@ -510,6 +517,7 @@ enum CliAction {
510517
model_flag_raw: Option<String>,
511518
permission_mode: PermissionMode,
512519
output_format: CliOutputFormat,
520+
allowed_tools: Option<AllowedToolSet>,
513521
},
514522
Sandbox {
515523
output_format: CliOutputFormat,
@@ -844,9 +852,14 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
844852
if let Some(action) = parse_local_help_action(&rest) {
845853
return action;
846854
}
847-
if let Some(action) =
848-
parse_single_word_command_alias(&rest, &model, model_flag_raw.as_deref(), permission_mode_override, output_format)
849-
{
855+
if let Some(action) = parse_single_word_command_alias(
856+
&rest,
857+
&model,
858+
model_flag_raw.as_deref(),
859+
permission_mode_override,
860+
output_format,
861+
allowed_tools.clone(),
862+
) {
850863
return action;
851864
}
852865

@@ -1051,6 +1064,7 @@ fn parse_single_word_command_alias(
10511064
model_flag_raw: Option<&str>,
10521065
permission_mode_override: Option<PermissionMode>,
10531066
output_format: CliOutputFormat,
1067+
allowed_tools: Option<AllowedToolSet>,
10541068
) -> Option<Result<CliAction, String>> {
10551069
if rest.is_empty() {
10561070
return None;
@@ -1095,6 +1109,7 @@ fn parse_single_word_command_alias(
10951109
model_flag_raw: model_flag_raw.map(str::to_string), // #148
10961110
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
10971111
output_format,
1112+
allowed_tools,
10981113
})),
10991114
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
11001115
"doctor" => Some(Ok(CliAction::Doctor { output_format })),
@@ -3226,6 +3241,7 @@ fn run_resume_command(
32263241
default_permission_mode().as_str(),
32273242
&context,
32283243
None, // #148: resumed sessions don't have flag provenance
3244+
None,
32293245
)),
32303246
})
32313247
}
@@ -5417,6 +5433,7 @@ fn print_status_snapshot(
54175433
model_flag_raw: Option<&str>,
54185434
permission_mode: PermissionMode,
54195435
output_format: CliOutputFormat,
5436+
allowed_tools: Option<&AllowedToolSet>,
54205437
) -> Result<(), Box<dyn std::error::Error>> {
54215438
let usage = StatusUsage {
54225439
message_count: 0,
@@ -5450,6 +5467,7 @@ fn print_status_snapshot(
54505467
permission_mode.as_str(),
54515468
&context,
54525469
Some(&provenance),
5470+
allowed_tools,
54535471
))?
54545472
),
54555473
}
@@ -5467,6 +5485,7 @@ fn status_json_value(
54675485
// that don't have provenance (legacy resume paths) pass None, in which
54685486
// case both new fields are omitted.
54695487
provenance: Option<&ModelProvenance>,
5488+
allowed_tools: Option<&AllowedToolSet>,
54705489
) -> serde_json::Value {
54715490
// #143: top-level `status` marker so claws can distinguish
54725491
// a clean run from a degraded run (config parse failed but other fields
@@ -5476,6 +5495,7 @@ fn status_json_value(
54765495
let degraded = context.config_load_error.is_some();
54775496
let model_source = provenance.map(|p| p.source.as_str());
54785497
let model_raw = provenance.and_then(|p| p.raw.clone());
5498+
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
54795499
json!({
54805500
"kind": "status",
54815501
"status": if degraded { "degraded" } else { "ok" },
@@ -5484,6 +5504,11 @@ fn status_json_value(
54845504
"model_source": model_source,
54855505
"model_raw": model_raw,
54865506
"permission_mode": permission_mode,
5507+
"allowed_tools": {
5508+
"source": if allowed_tools.is_some() { "flag" } else { "default" },
5509+
"restricted": allowed_tools.is_some(),
5510+
"entries": allowed_tool_entries,
5511+
},
54875512
"usage": {
54885513
"messages": usage.message_count,
54895514
"turns": usage.turns,
@@ -9807,6 +9832,18 @@ mod tests {
98079832
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
98089833
}
98099834

9835+
#[test]
9836+
fn rejects_empty_allowed_tools_flag() {
9837+
for raw in ["", ",,"] {
9838+
let error = parse_args(&["--allowedTools".to_string(), raw.to_string()])
9839+
.expect_err("empty allowedTools should be rejected");
9840+
assert!(
9841+
error.contains("--allowedTools was provided with no usable tool names"),
9842+
"unexpected error for {raw:?}: {error}"
9843+
);
9844+
}
9845+
}
9846+
98109847
#[test]
98119848
fn parses_system_prompt_options() {
98129849
let args = vec![
@@ -10261,7 +10298,14 @@ mod tests {
1026110298
cumulative: runtime::TokenUsage::default(),
1026210299
estimated_tokens: 0,
1026310300
};
10264-
let json = super::status_json_value(Some("test-model"), usage, "workspace-write", &context, None);
10301+
let json = super::status_json_value(
10302+
Some("test-model"),
10303+
usage,
10304+
"workspace-write",
10305+
&context,
10306+
None,
10307+
None,
10308+
);
1026510309
assert_eq!(
1026610310
json.get("status").and_then(|v| v.as_str()),
1026710311
Some("degraded"),
@@ -10280,6 +10324,44 @@ mod tests {
1028010324
);
1028110325
assert!(json.get("workspace").is_some(), "workspace field still reported");
1028210326
assert!(json.get("sandbox").is_some(), "sandbox field still reported");
10327+
assert_eq!(
10328+
json.pointer("/allowed_tools/source").and_then(|v| v.as_str()),
10329+
Some("default"),
10330+
"default status should expose unrestricted tool source: {json}"
10331+
);
10332+
assert_eq!(
10333+
json.pointer("/allowed_tools/restricted").and_then(|v| v.as_bool()),
10334+
Some(false),
10335+
"default status should expose unrestricted tool state: {json}"
10336+
);
10337+
10338+
let allowed: super::AllowedToolSet = ["read_file", "grep_search"]
10339+
.into_iter()
10340+
.map(str::to_string)
10341+
.collect();
10342+
let restricted_json = super::status_json_value(
10343+
Some("test-model"),
10344+
usage,
10345+
"workspace-write",
10346+
&context,
10347+
None,
10348+
Some(&allowed),
10349+
);
10350+
assert_eq!(
10351+
restricted_json
10352+
.pointer("/allowed_tools/source")
10353+
.and_then(|v| v.as_str()),
10354+
Some("flag"),
10355+
"flag status should expose allow-list source: {restricted_json}"
10356+
);
10357+
assert_eq!(
10358+
restricted_json
10359+
.pointer("/allowed_tools/entries")
10360+
.and_then(|v| v.as_array())
10361+
.map(Vec::len),
10362+
Some(2),
10363+
"flag status should expose allow-list entries: {restricted_json}"
10364+
);
1028310365

1028410366
// Clean path: no config error → status: "ok", config_load_error: null.
1028510367
let clean_cwd = root.join("project-with-clean-config");
@@ -10288,8 +10370,14 @@ mod tests {
1028810370
super::status_context(None).expect("clean status_context should succeed")
1028910371
});
1029010372
assert!(clean_context.config_load_error.is_none());
10291-
let clean_json =
10292-
super::status_json_value(Some("test-model"), usage, "workspace-write", &clean_context, None);
10373+
let clean_json = super::status_json_value(
10374+
Some("test-model"),
10375+
usage,
10376+
"workspace-write",
10377+
&clean_context,
10378+
None,
10379+
None,
10380+
);
1029310381
assert_eq!(
1029410382
clean_json.get("status").and_then(|v| v.as_str()),
1029510383
Some("ok"),
@@ -10366,6 +10454,7 @@ mod tests {
1036610454
model_flag_raw: None, // #148: no --model flag passed
1036710455
permission_mode: PermissionMode::DangerFullAccess,
1036810456
output_format: CliOutputFormat::Text,
10457+
allowed_tools: None,
1036910458
}
1037010459
);
1037110460
assert_eq!(

rust/crates/tools/src/lib.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,13 @@ impl GlobalToolRegistry {
240240
}
241241
}
242242

243+
if allowed.is_empty() {
244+
return Err(format!(
245+
"--allowedTools was provided with no usable tool names (got `{}`). Omit the flag to allow all tools.",
246+
values.join(" ")
247+
));
248+
}
249+
243250
Ok(Some(allowed))
244251
}
245252

@@ -6883,6 +6890,21 @@ mod tests {
68836890
assert!(empty_permission.contains("unsupported plugin permission: "));
68846891
}
68856892

6893+
#[test]
6894+
fn allowed_tools_rejects_empty_token_lists() {
6895+
let registry = GlobalToolRegistry::builtin();
6896+
6897+
for raw in ["", ",,", " "] {
6898+
let err = registry
6899+
.normalize_allowed_tools(&[raw.to_string()])
6900+
.expect_err("empty allow-list input should be rejected");
6901+
assert!(
6902+
err.contains("--allowedTools was provided with no usable tool names"),
6903+
"unexpected error for {raw:?}: {err}"
6904+
);
6905+
}
6906+
}
6907+
68866908
#[test]
68876909
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
68886910
let registry = GlobalToolRegistry::builtin()

0 commit comments

Comments
 (0)