feat: exempt smbCloud read tools from permission prompts

The official server's query tools already run without prompting; give the baked-in smbCloud CLI server the same treatment for its read-only tools (me, deployments, project_list, project_show). Unlike the official server's list_/get_ name-shape check, this is an explicit allow-list, since smb's tool names follow no such convention. A name the list doesn't know (say, from a newer smb release) fails safe to mutating, and the exemption only applies when smbcloud is the whole server name, so a foreign server can't borrow it.

paydii committed Jul 19, 2026 at 14:00 UTC e928a35a8e009696986ae82ba1d109d1a7e13fc0
2 files changed +73 -6
src/mcp.rs
+21
@@ -110,6 +110,14 @@ const SMBCLOUD_SERVER_NAME: &str = "smbcloud";
110 /// The smbCloud CLI binary the baked-in stdio entry spawns (`smb --mcp`).
111 const SMBCLOUD_COMMAND: &str = "smb";
112
113 +/// The bare tool name when `name` belongs to the smbCloud server
114 +/// (`mcp__smbcloud__project_list` → `Some("project_list")`), else `None`.
115 +pub fn smbcloud_tool_suffix(name: &str) -> Option<&str> {
116 + name.strip_prefix(MCP_PREFIX)?
117 + .strip_prefix(SMBCLOUD_SERVER_NAME)?
118 + .strip_prefix("__")
119 +}
120 +
121 /// JSON-RPC / MCP protocol version we advertise in the handshake.
122 const PROTOCOL_VERSION: &str = "2025-06-18";
123
@@ -1371,6 +1379,19 @@ mod tests {
1379 assert_eq!(official_tool_suffix("mcp__sigit__"), Some(""));
1380 }
1381
1382 + #[test]
1383 + fn smbcloud_tool_suffix_strips_only_the_smbcloud_namespace() {
1384 + assert_eq!(
1385 + smbcloud_tool_suffix("mcp__smbcloud__project_list"),
1386 + Some("project_list")
1387 + );
1388 + assert_eq!(smbcloud_tool_suffix("mcp__sigit__project_list"), None);
1389 + // `smbcloud` must be the whole server name, not a prefix of it.
1390 + assert_eq!(smbcloud_tool_suffix("mcp__smbcloudx__project_list"), None);
1391 + assert_eq!(smbcloud_tool_suffix("project_list"), None);
1392 + assert_eq!(smbcloud_tool_suffix("mcp__smbcloud__"), Some(""));
1393 + }
1394 +
1395 #[test]
1396 fn sanitize_collapses_invalid_chars() {
1397 assert_eq!(sanitize("github"), "github");
src/permissions.rs
+52 -6
@@ -40,8 +40,9 @@
40 //!
41 //! Tools discovered from MCP servers (`mcp__*`) and any unknown tool name are
42 //! treated as mutating: external tools can have arbitrary side effects, so the
43 -//! safe assumption is to gate them. The one exception is the official
44 -//! sigit.si server's query tools (see [`classify`]), which are read-only.
43 +//! safe assumption is to gate them. The exceptions are the query tools of the
44 +//! two baked-in servers — the official sigit.si server and the smbCloud CLI —
45 +//! which are read-only (see [`classify`]).
46 //!
47 //! Session state (grants + plan mode) lives in a process-global keyed by
48 //! session id — the same pattern as `mcp.rs`'s server cache — so the ACP
@@ -94,10 +95,14 @@ pub enum Decision {
95 /// `tools.rs`'s `web_search` module doc for why it isn't classified by that
96 /// `mcp__` prefix).
97 ///
97 -/// One MCP exception: the *official* sigit.si server (`mcp__sigit__*`) is
98 -/// first-party, so its query tools — names starting `list_` or `get_`, plus
99 -/// `search_code` and `web_search` — are read-only and never prompt (the TUI's
100 -/// Repo tab depends on this). Every other `mcp__*` tool stays mutating.
98 +/// Two MCP exceptions, both baked-in first-party servers. The *official*
99 +/// sigit.si server's (`mcp__sigit__*`) query tools — names starting `list_`
100 +/// or `get_`, plus `search_code` and `web_search` — are read-only and never
101 +/// prompt (the TUI's Repo tab depends on this). The smbCloud CLI server's
102 +/// (`mcp__smbcloud__*`) read tools are exempted by an explicit allow-list
103 +/// rather than a name shape, since its names don't follow the `list_`/`get_`
104 +/// convention — a tool the list doesn't know (e.g. from a newer smb) fails
105 +/// safe to mutating. Every other `mcp__*` tool stays mutating.
106 pub fn classify(tool_name: &str) -> ToolRisk {
107 match tool_name {
108 "read_file" | "list_directory" | "search_files" | "glob" | "read_website"
@@ -111,6 +116,11 @@ pub fn classify(tool_name: &str) -> ToolRisk {
116 {
117 return ToolRisk::ReadOnly;
118 }
119 + if let Some(bare) = crate::mcp::smbcloud_tool_suffix(tool_name)
120 + && matches!(bare, "me" | "deployments" | "project_list" | "project_show")
121 + {
122 + return ToolRisk::ReadOnly;
123 + }
124 ToolRisk::Mutating
125 }
126 }
@@ -505,6 +515,42 @@ mod tests {
515 }
516 }
517
518 + #[test]
519 + fn smbcloud_mcp_read_tools_are_read_only() {
520 + let _guard = env_guard();
521 + for tool in [
522 + "mcp__smbcloud__me",
523 + "mcp__smbcloud__deployments",
524 + "mcp__smbcloud__project_list",
525 + "mcp__smbcloud__project_show",
526 + ] {
527 + assert_eq!(classify(tool), ToolRisk::ReadOnly, "{tool}");
528 + assert_eq!(
529 + decision_for("t-smbcloud", tool, "{}"),
530 + Decision::Allow,
531 + "{tool}"
532 + );
533 + }
534 + }
535 +
536 + #[test]
537 + fn smbcloud_mcp_mutating_and_unknown_tools_stay_gated() {
538 + for tool in [
539 + // smbcloud server, but not on the read allow-list
540 + "mcp__smbcloud__project_create",
541 + "mcp__smbcloud__project_update",
542 + "mcp__smbcloud__project_delete",
543 + "mcp__smbcloud__",
544 + // allow-listed names on other servers get no exemption
545 + "mcp__other__project_list",
546 + "mcp__other__me",
547 + // `smbcloud` must match the whole server name
548 + "mcp__smbcloudx__project_list",
549 + ] {
550 + assert_eq!(classify(tool), ToolRisk::Mutating, "{tool}");
551 + }
552 + }
553 +
554 #[test]
555 fn read_only_tool_still_honors_an_explicit_deny() {
556 let _guard = env_guard();