Skip to main content

tailscale_mcp/
gating.rs

1//! Which tools this server offers, and at which risk tier.
2//!
3//! The decision this module implements is that a tool the server may not run is
4//! *hidden*, not listed-and-refused. A model that can see a tool will try it,
5//! and a listing full of tools that always fail is worse than a shorter listing
6//! that works. [`Gate::permits`] is therefore consulted when building the tool
7//! list, not only when handling a call.
8//!
9//! Calls are still checked. A client may call a name it did not get from the
10//! listing, and that is what [`crate::error::ToolError::not_permitted`] answers.
11
12use std::collections::BTreeSet;
13use std::fmt;
14
15use thiserror::Error;
16
17use crate::meta::{Surface, Tier, ToolMeta, Toolset};
18
19/// A named starting selection of toolsets.
20///
21/// Presets exist because the useful selections are few and the combinatorics
22/// are many. An operator who wants something else says so with modifiers.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Preset {
25    /// Just enough to answer "what is the state of my network": status, the
26    /// device inventory, the policy file, DNS.
27    Minimal,
28    /// The default. Everything an agent needs for day-to-day work, and nothing
29    /// that is either a diagnostic rabbit hole or an escape hatch.
30    #[default]
31    Core,
32    /// Every typed toolset. Excludes the debug knobs and the passthrough,
33    /// which are opt-in by name because they are not part of the supported
34    /// surface in the same way.
35    Full,
36}
37
38impl Preset {
39    pub const ALL: &'static [Preset] = &[Self::Minimal, Self::Core, Self::Full];
40
41    pub const fn as_str(self) -> &'static str {
42        match self {
43            Self::Minimal => "minimal",
44            Self::Core => "core",
45            Self::Full => "full",
46        }
47    }
48
49    pub fn parse(s: &str) -> Result<Self, ConfigError> {
50        Self::ALL
51            .iter()
52            .copied()
53            .find(|p| p.as_str() == s)
54            .ok_or_else(|| ConfigError::UnknownPreset(s.to_owned()))
55    }
56
57    /// The toolsets this preset selects.
58    pub fn toolsets(self) -> BTreeSet<Toolset> {
59        use Toolset::*;
60        let minimal = [LocalStatus, TailnetDevices, TailnetPolicy, TailnetDns];
61        match self {
62            Self::Minimal => minimal.into_iter().collect(),
63            Self::Core => minimal
64                .into_iter()
65                .chain([
66                    LocalPrefs,
67                    LocalServe,
68                    LocalFiles,
69                    TailnetKeys,
70                    TailnetUsers,
71                    TailnetInvites,
72                    TailnetWebhooks,
73                    TailnetSettings,
74                    TailnetServices,
75                ])
76                .collect(),
77            Self::Full => Toolset::ALL
78                .iter()
79                .copied()
80                .filter(|t| !matches!(t, LocalDebug | LocalPassthrough))
81                .collect(),
82        }
83    }
84}
85
86impl fmt::Display for Preset {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.write_str(self.as_str())
89    }
90}
91
92/// A configuration mistake that must stop the server before it serves anything.
93///
94/// These are all startup errors on purpose. A typo in a toolset name that
95/// silently selected nothing would present as "the tools vanished", which is a
96/// much worse afternoon than a refusal to start.
97#[derive(Debug, Clone, PartialEq, Eq, Error)]
98pub enum ConfigError {
99    #[error("unknown preset `{0}`; expected one of minimal, core, full")]
100    UnknownPreset(String),
101
102    #[error("unknown toolset `{0}`")]
103    UnknownToolset(String),
104
105    #[error(
106        "toolset list mixes replacement and adjustment: write either a bare list \
107         of toolsets, or a list where every entry begins with `+` or `-`"
108    )]
109    MixedToolsetSyntax,
110
111    #[error(
112        "the selected toolsets and tier leave no tools enabled; \
113             widen the preset, the toolsets, or the tier"
114    )]
115    NoToolsEnabled,
116
117    #[error("`{setting}` was set to `{value}`, which is not {expected}")]
118    InvalidValue {
119        setting: String,
120        value: String,
121        expected: &'static str,
122    },
123}
124
125/// Parse a toolset selection string.
126///
127/// Two shapes, and never a mixture:
128///
129/// - `local-status,tailnet-devices` replaces the preset's selection outright.
130/// - `+local-debug,-tailnet-org` adjusts it.
131///
132/// The mixture is rejected rather than resolved because there is no reading of
133/// `local-status,-tailnet-org` that a reader would agree on.
134pub fn apply_toolset_modifiers(
135    base: BTreeSet<Toolset>,
136    spec: &str,
137) -> Result<BTreeSet<Toolset>, ConfigError> {
138    let entries: Vec<&str> = spec
139        .split(',')
140        .map(str::trim)
141        .filter(|s| !s.is_empty())
142        .collect();
143    if entries.is_empty() {
144        return Ok(base);
145    }
146
147    let adjusting = entries[0].starts_with(['+', '-']);
148    if entries
149        .iter()
150        .any(|e| e.starts_with(['+', '-']) != adjusting)
151    {
152        return Err(ConfigError::MixedToolsetSyntax);
153    }
154
155    let mut selected = if adjusting { base } else { BTreeSet::new() };
156    for entry in entries {
157        let (remove, name) = match entry.strip_prefix('-') {
158            Some(name) => (true, name),
159            None => (false, entry.strip_prefix('+').unwrap_or(entry)),
160        };
161        let toolset =
162            Toolset::parse(name).ok_or_else(|| ConfigError::UnknownToolset(name.to_owned()))?;
163        if remove {
164            selected.remove(&toolset);
165        } else {
166            selected.insert(toolset);
167        }
168    }
169    Ok(selected)
170}
171
172/// The decision procedure for "may this server offer this tool".
173#[derive(Debug, Clone)]
174pub struct Gate {
175    toolsets: BTreeSet<Toolset>,
176    max_tier: Tier,
177    /// Surfaces whose backend is absent. Their tools are hidden however the
178    /// toolsets were selected, because offering a tool that can only answer
179    /// `backend_unavailable` helps nobody.
180    unavailable: BTreeSet<Surface>,
181}
182
183impl Gate {
184    /// Build a gate, refusing a configuration that would offer nothing.
185    ///
186    /// `all_tools` is the full metadata table; the emptiness check is made
187    /// against it rather than against the toolset set, because a selection can
188    /// be non-empty and still yield no tools once the tier is applied.
189    pub fn new(
190        toolsets: BTreeSet<Toolset>,
191        max_tier: Tier,
192        unavailable: BTreeSet<Surface>,
193        all_tools: &[ToolMeta],
194    ) -> Result<Self, ConfigError> {
195        let gate = Self {
196            toolsets,
197            max_tier,
198            unavailable,
199        };
200        if !all_tools.iter().any(|t| gate.permits(t)) {
201            return Err(ConfigError::NoToolsEnabled);
202        }
203        Ok(gate)
204    }
205
206    /// A gate for tests and for the `tools` subcommand, which must be able to
207    /// describe an empty selection rather than refuse to.
208    pub fn unchecked(
209        toolsets: BTreeSet<Toolset>,
210        max_tier: Tier,
211        unavailable: BTreeSet<Surface>,
212    ) -> Self {
213        Self {
214            toolsets,
215            max_tier,
216            unavailable,
217        }
218    }
219
220    pub fn permits(&self, tool: &ToolMeta) -> bool {
221        self.toolsets.contains(&tool.toolset)
222            && tool.tier <= self.max_tier
223            && !self.unavailable.contains(&tool.surface())
224    }
225
226    /// Why a tool was not offered, phrased as the flag that would offer it.
227    /// Used to build the hint on `not_permitted`.
228    pub fn needs(&self, tool: &ToolMeta) -> String {
229        if self.unavailable.contains(&tool.surface()) {
230            return match tool.surface() {
231                Surface::Local => "a working `tailscale` binary".to_owned(),
232                Surface::Tailnet => "a control-plane credential".to_owned(),
233            };
234        }
235        let mut needs = Vec::new();
236        if !self.toolsets.contains(&tool.toolset) {
237            needs.push(format!("`--toolsets +{}`", tool.toolset));
238        }
239        if tool.tier > self.max_tier
240            && let Some(flag) = tool.tier.flag()
241        {
242            needs.push(format!("`{flag}`"));
243        }
244        if needs.is_empty() {
245            // Not reachable through `permits`, but a caller may ask about a
246            // tool it is permitted to run.
247            return "no additional permission".to_owned();
248        }
249        needs.join(" and ")
250    }
251
252    pub fn toolsets(&self) -> &BTreeSet<Toolset> {
253        &self.toolsets
254    }
255
256    /// Whether this session offers any tool on `surface`.
257    ///
258    /// Both halves matter and neither alone is the answer: a surface whose
259    /// toolsets nobody selected offers nothing, and so does one that was
260    /// selected but is not there — no binary, no credential, or an operator
261    /// switch. [`permits`](Self::permits) asks the same two questions of one
262    /// tool; this asks them of a whole surface, for the instructions, which
263    /// have to describe what a session can actually do rather than what it was
264    /// asked for.
265    pub fn offers(&self, surface: Surface) -> bool {
266        !self.unavailable.contains(&surface) && self.toolsets.iter().any(|t| t.surface() == surface)
267    }
268
269    /// The selected toolsets that this session can actually serve from.
270    ///
271    /// [`toolsets`](Self::toolsets) is what was asked for; this is what came of
272    /// it. Anything shown to a caller wants this one — naming a toolset whose
273    /// surface is missing tells a model that `tailnet-devices` is there, in a
274    /// session where every call to it is refused. That was written twice
275    /// before it was written once: the startup note and the instructions each
276    /// listed the selection, and each contradicted a sentence beside it.
277    pub fn offered_toolsets(&self) -> impl Iterator<Item = Toolset> + '_ {
278        self.toolsets
279            .iter()
280            .copied()
281            .filter(|toolset| self.offers(toolset.surface()))
282    }
283
284    pub fn max_tier(&self) -> Tier {
285        self.max_tier
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::meta::Toolset::*;
293
294    fn tool(name: &'static str, toolset: Toolset, tier: Tier) -> ToolMeta {
295        ToolMeta {
296            name,
297            toolset,
298            tier,
299            summary: "",
300            self_severing: false,
301            severs_local_node: false,
302            requires_confirmation: false,
303            idempotent: true,
304            varying_tier: false,
305            min_version: None,
306            platforms: None,
307        }
308    }
309
310    #[test]
311    fn a_mixed_list_is_refused_rather_than_guessed_at() {
312        assert_eq!(
313            apply_toolset_modifiers(Preset::Core.toolsets(), "local-status,-tailnet-dns"),
314            Err(ConfigError::MixedToolsetSyntax)
315        );
316    }
317
318    #[test]
319    fn an_empty_spec_leaves_the_preset_alone() {
320        let base = Preset::Core.toolsets();
321        assert_eq!(
322            apply_toolset_modifiers(base.clone(), "  ,, ").expect("empty is fine"),
323            base
324        );
325    }
326
327    #[test]
328    fn the_gate_hides_by_toolset_tier_and_surface() {
329        let gate = Gate::unchecked(
330            BTreeSet::from([LocalStatus, TailnetDevices]),
331            Tier::Write,
332            BTreeSet::from([Surface::Tailnet]),
333        );
334        assert!(gate.permits(&tool("a", LocalStatus, Tier::Read)));
335        assert!(gate.permits(&tool("b", LocalStatus, Tier::Write)));
336        // Above the tier.
337        assert!(!gate.permits(&tool("c", LocalStatus, Tier::Destructive)));
338        // Toolset not selected.
339        assert!(!gate.permits(&tool("d", LocalServe, Tier::Read)));
340        // Surface unavailable, even though the toolset is selected.
341        assert!(!gate.permits(&tool("e", TailnetDevices, Tier::Read)));
342    }
343
344    #[test]
345    fn the_gate_explains_what_would_enable_a_tool() {
346        let gate = Gate::unchecked(BTreeSet::from([LocalStatus]), Tier::Read, BTreeSet::new());
347        assert_eq!(
348            gate.needs(&tool("a", LocalServe, Tier::Destructive)),
349            "`--toolsets +local-serve` and `--allow-destructive`"
350        );
351        assert_eq!(
352            gate.needs(&tool("b", LocalStatus, Tier::Write)),
353            "`--allow-write`"
354        );
355
356        let no_backend = Gate::unchecked(
357            BTreeSet::from([TailnetDevices]),
358            Tier::Read,
359            BTreeSet::from([Surface::Tailnet]),
360        );
361        assert_eq!(
362            no_backend.needs(&tool("c", TailnetDevices, Tier::Read)),
363            "a control-plane credential"
364        );
365    }
366}