242
/// string, consulted by rule patterns and granular session grants. See the
243
/// module docs for the layering.
244
pub fn decision_for(session: &str, tool_name: &str, arguments: &str) -> Decision {
245
- if classify(tool_name) == ToolRisk::ReadOnly {
245
+ let read_only = classify(tool_name) == ToolRisk::ReadOnly;
246
+ let argument = matchable_argument(tool_name, arguments);
247
+ let rules = settings::permission_rules();
248
+
249
+ // An explicit deny applies to every tool, read-only ones included. This is
250
+ // what lets a user still switch off a first-party query tool that
251
+ // `classify` now treats as read-only (`web_search`, `list_issues`, …);
252
+ // without it, the reclassification would silently stop honoring a deny that
253
+ // was enforced when those names counted as mutating.
254
+ if let Some(rule) = rules
255
+ .deny
256
+ .iter()
257
+ .find(|rule| rule_matches(rule, tool_name, argument.as_deref()))
258
+ {
259
+ return Decision::Deny(format!(
260
+ "`{tool_name}` is denied by the permission rule `{rule}` in settings.toml. \
261
+ Do not retry it; work without this tool or ask the user to change \
262
+ the policy."
263
+ ));
264
+ }
265
+
266
+ if read_only {
267
+ // Read-only tools never prompt and are never plan-mode blocked, but an
268
+ // explicit per-tool `deny` still turns one off. Crucially we consult
269
+ // the *explicit* override, not `permission_mode_for`, so the default
270
+ // `ask`/`deny` mode never starts prompting on every read_file.
271
+ if settings::permission_mode_explicit(tool_name) == Some(PermissionMode::Deny) {
272
+ return Decision::Deny(format!(
273
+ "`{tool_name}` is denied by the permission policy in settings.toml. \
274
+ Do not retry it; work without this tool or ask the user to change \
275
+ the policy."
276
+ ));
277
+ }
278
return Decision::Allow;
279
}
280
249
- let argument = matchable_argument(tool_name, arguments);
281
let (plan_mode, granted) = with_session(session, |s| {
282
(
283
s.plan_mode,
294
return Decision::Allow;
295
}
296
266
- let rules = settings::permission_rules();
267
- if let Some(rule) = rules
268
- .deny
269
- .iter()
270
- .find(|rule| rule_matches(rule, tool_name, argument.as_deref()))
271
- {
272
- return Decision::Deny(format!(
273
- "`{tool_name}` is denied by the permission rule `{rule}` in settings.toml. \
274
- Do not retry it; work without this tool or ask the user to change \
275
- the policy."
276
- ));
277
- }
297
if rules
298
.allow
299
.iter()
505
}
506
}
507
508
+ #[test]
509
+ fn read_only_tool_still_honors_an_explicit_deny() {
510
+ let _guard = env_guard();
511
+ // A deny rule listing a read-only first-party query tool is enforced,
512
+ // even though the tool never prompts otherwise.
513
+ store_rules(&[], &["mcp__sigit__web_search"]);
514
+ assert!(matches!(
515
+ decision_for("t", "mcp__sigit__web_search", "{}"),
516
+ Decision::Deny(_)
517
+ ));
518
+ // A tool with no deny rule is unaffected.
519
+ assert_eq!(
520
+ decision_for("t", "mcp__sigit__list_issues", "{}"),
521
+ Decision::Allow
522
+ );
523
+
524
+ // An explicit per-tool `deny` mode also turns a read-only tool off,
525
+ // while the default mode never makes read-only tools prompt.
526
+ let mut settings = settings::load();
527
+ settings.permissions.rules.deny.clear();
528
+ settings.permissions.default = PermissionMode::Ask;
529
+ settings
530
+ .permissions
531
+ .tools
532
+ .insert("mcp__sigit__web_search".to_string(), PermissionMode::Deny);
533
+ settings::store(&settings).unwrap();
534
+ assert!(matches!(
535
+ decision_for("t", "mcp__sigit__web_search", "{}"),
536
+ Decision::Deny(_)
537
+ ));
538
+ assert_eq!(decision_for("t", "read_file", "{}"), Decision::Allow);
539
+ }
540
+
541
#[test]
542
fn official_mcp_mutating_and_foreign_servers_stay_gated() {
543
for tool in [