Skip to main content

tailscale_mcp/tools/
passthrough.rs

1//! The one tool that runs a command this server has no tool for.
2//!
3//! Every other tool on the local surface is a fixed argument list with typed
4//! parameters. This one takes the argument list from the caller, which is a
5//! different kind of promise and so is off in every preset, `full` included: a
6//! session reaches it by naming `local-passthrough`, and naming it is the
7//! statement that the caller accepts commands this server has not vetted.
8//!
9//! ## How a command is judged
10//!
11//! The tool's row carries [`Tier::Read`], which is a floor and not the truth —
12//! see [`ToolMeta::varying_tier`](crate::meta::ToolMeta::varying_tier). What
13//! decides the tier is the command, and there are three answers:
14//!
15//! - [`COVERED`] names the commands a typed tool already runs, each at that
16//!   tool's own tier and confirmation. `tailscale down` is destructive here
17//!   because `tailscale_down` is destructive there, and needs the same
18//!   `confirm: true`. A test drives every typed tool and pins this table to
19//!   what they actually run, so the two cannot drift apart quietly.
20//! - The two exclusion lists — this module's own and
21//!   [`local_debug::EXCLUDED`], which [`excluded`] chains together — name the
22//!   commands this server never runs, each with the reason the caller is
23//!   shown. Closing a toolset is not a way in and neither is this.
24//! - Anything else is unknown, and unknown counts as destructive. A command
25//!   nobody has judged is judged at the top.
26//!
27//! ## Reading the command out of the arguments
28//!
29//! This server does not parse the client's flags and should not have to, so it
30//! reads the command twice and keeps whichever reading is stricter. One reading
31//! is the leading run of words that are not flags; the other is every word that
32//! is not a flag. Each is matched deepest-first, up to `MAX_DEPTH` words, so
33//! that `lock remove` is not read as `lock`.
34//!
35//! Neither reading is sound alone, and each fails in the opposite direction.
36//! `tailscale serve --bg reset` runs `serve reset`, which the leading reading
37//! calls `serve`: a write-tier session would wipe the serve configuration with
38//! no confirmation. `tailscale funnel --set-path status 8080` runs `funnel`,
39//! which the every-word reading calls `funnel status`: a destructive command
40//! read as a reader. Working out which applies is the flag parsing this
41//! refuses to do, and taking the stricter of the two is wrong only ever in the
42//! direction of a tier or a confirmation the caller did not need. Case and
43//! surrounding spaces are dropped before matching, because the client's own
44//! subcommand lookup ignores case at every depth: `["DEBUG", "PREFS"]` runs
45//! `debug prefs`.
46//!
47//! What no reading rescues is a path that stops partway into something
48//! excluded. `["debug", "--file=get"]` has no word after `debug`, and letting
49//! it through as unknown would run a `debug`-anything at the destructive tier,
50//! so it is refused outright rather than guessed at. That is also why
51//! `tailscale_debug_file_list` exists as a typed tool: its subcommand is a flag
52//! on the parent, which is useful and is unreadable here.
53//!
54//! Two commands are deliberately absent from every table. `debug reload-config`
55//! is in neither list and so is unknown-destructive, which is what DECISIONS
56//! Q44 asks for: runnable, but never on this server's recommendation.
57//! `completion` has no typed tool to inherit from, and inventing a tier for it
58//! would be the first row nothing checks.
59//!
60//! ## What the caller gets
61//!
62//! An argument list, never a string: nothing here is parsed by a shell, so a
63//! semicolon or a quote in an argument is a semicolon or a quote in an
64//! argument. The command line reaches the log and any error redacted, because
65//! unlike every typed tool's arguments these were not built here.
66
67use rmcp::schemars::JsonSchema;
68use serde::{Deserialize, Serialize};
69use serde_json::Value;
70use tailscale_cli::Invocation;
71
72use crate::cli;
73use crate::context::ToolContext;
74use crate::error::{ErrorCode, ToolError, ToolResult};
75use crate::meta::Tier;
76use crate::tools::common::{Excluded, bounded_wait, printed, report};
77use crate::tools::local_debug;
78
79crate::tools! {
80    /// Run a `tailscale` subcommand that no other tool covers, given as a list
81    /// of arguments. Prefer a typed tool wherever one exists: they validate
82    /// what they are given and answer with structured data rather than the
83    /// text a person would read. What this is allowed to run depends on the
84    /// command — one a typed tool covers takes that tool's tier and
85    /// confirmation, an unrecognised one counts as destructive, and a command
86    /// this server never runs is refused with the reason why.
87    tailscale_run => RunParams, run,
88        toolset: LocalPassthrough, tier: Read, varying: true;
89}
90
91/// The deepest command path either table names, and so the most words worth
92/// matching. Five paths are that deep, all of them under `configure`;
93/// `every_path_fits_the_depth_the_matcher_searches` holds the rest shallower.
94const MAX_DEPTH: usize = 3;
95
96/// How long a passthrough command may run before this server gives up, and the
97/// bound it uses when the caller names none.
98const DEFAULT_RUN_TIMEOUT: u64 = 30;
99const MAX_RUN_TIMEOUT: u64 = 300;
100
101/// A command a typed tool already runs, and the terms it runs on there.
102///
103/// Neither field is this module's opinion. Both are read off the typed tool's
104/// own row, and `the_covered_table_follows_the_tools_it_claims_to_follow`
105/// re-derives them from the arguments those tools actually build.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct Covered {
108    /// The command as it is written after `tailscale`, words separated by
109    /// spaces.
110    pub path: &'static str,
111    /// The tier of the typed tool that runs it. Where two tools share a path,
112    /// the higher of them: `switch` is both `tailscale_switch_list` and
113    /// `tailscale_switch_profile`, and a caller writing `switch` unqualified
114    /// may mean either.
115    pub tier: Tier,
116    /// Whether that tool requires a confirmation, for the same reason.
117    pub confirm: bool,
118}
119
120/// Every command a typed tool covers, with the terms that tool runs it on.
121///
122/// Grouped as the modules are, so that a tool added there has an obvious home
123/// here. Debug's twenty-nine come last, and its thirtieth tool is not among
124/// them: `tailscale_debug_file_list` runs `debug --file=get`, a flag on the
125/// parent command rather than a subcommand, which has no path to name.
126pub const COVERED: &[Covered] = &[
127    // -- local-prefs --------------------------------------------------------
128    Covered {
129        path: "up",
130        tier: Tier::Destructive,
131        confirm: true,
132    },
133    Covered {
134        path: "down",
135        tier: Tier::Destructive,
136        confirm: true,
137    },
138    Covered {
139        path: "set",
140        tier: Tier::Write,
141        confirm: false,
142    },
143    Covered {
144        path: "get",
145        tier: Tier::Read,
146        confirm: false,
147    },
148    Covered {
149        path: "login",
150        tier: Tier::Write,
151        confirm: true,
152    },
153    Covered {
154        path: "logout",
155        tier: Tier::Destructive,
156        confirm: true,
157    },
158    Covered {
159        path: "switch",
160        tier: Tier::Write,
161        confirm: true,
162    },
163    Covered {
164        path: "switch remove",
165        tier: Tier::Destructive,
166        confirm: true,
167    },
168    // -- local-status -------------------------------------------------------
169    Covered {
170        path: "status",
171        tier: Tier::Read,
172        confirm: false,
173    },
174    Covered {
175        path: "ip",
176        tier: Tier::Read,
177        confirm: false,
178    },
179    Covered {
180        path: "netcheck",
181        tier: Tier::Read,
182        confirm: false,
183    },
184    Covered {
185        path: "ping",
186        tier: Tier::Read,
187        confirm: false,
188    },
189    Covered {
190        path: "whois",
191        tier: Tier::Read,
192        confirm: false,
193    },
194    Covered {
195        path: "whoami",
196        tier: Tier::Read,
197        confirm: false,
198    },
199    Covered {
200        path: "version",
201        tier: Tier::Read,
202        confirm: false,
203    },
204    Covered {
205        path: "licenses",
206        tier: Tier::Read,
207        confirm: false,
208    },
209    Covered {
210        path: "bugreport",
211        tier: Tier::Read,
212        confirm: false,
213    },
214    Covered {
215        path: "appc-routes",
216        tier: Tier::Read,
217        confirm: false,
218    },
219    Covered {
220        path: "routecheck",
221        tier: Tier::Read,
222        confirm: false,
223    },
224    Covered {
225        path: "wait",
226        tier: Tier::Read,
227        confirm: false,
228    },
229    Covered {
230        path: "dns status",
231        tier: Tier::Read,
232        confirm: false,
233    },
234    Covered {
235        path: "dns query",
236        tier: Tier::Read,
237        confirm: false,
238    },
239    Covered {
240        path: "exit-node list",
241        tier: Tier::Read,
242        confirm: false,
243    },
244    Covered {
245        path: "exit-node suggest",
246        tier: Tier::Read,
247        confirm: false,
248    },
249    Covered {
250        path: "metrics print",
251        tier: Tier::Read,
252        confirm: false,
253    },
254    Covered {
255        path: "service list",
256        tier: Tier::Read,
257        confirm: false,
258    },
259    Covered {
260        path: "syspolicy list",
261        tier: Tier::Read,
262        confirm: false,
263    },
264    Covered {
265        path: "configure sysext status",
266        tier: Tier::Read,
267        confirm: false,
268    },
269    // -- local-serve --------------------------------------------------------
270    Covered {
271        path: "serve",
272        tier: Tier::Write,
273        confirm: false,
274    },
275    Covered {
276        path: "serve status",
277        tier: Tier::Read,
278        confirm: false,
279    },
280    Covered {
281        path: "serve reset",
282        tier: Tier::Destructive,
283        confirm: true,
284    },
285    Covered {
286        path: "serve drain",
287        tier: Tier::Write,
288        confirm: false,
289    },
290    Covered {
291        path: "serve clear",
292        tier: Tier::Destructive,
293        confirm: true,
294    },
295    Covered {
296        path: "serve advertise",
297        tier: Tier::Write,
298        confirm: false,
299    },
300    Covered {
301        path: "serve get-config",
302        tier: Tier::Read,
303        confirm: false,
304    },
305    Covered {
306        path: "serve set-config",
307        tier: Tier::Write,
308        confirm: false,
309    },
310    Covered {
311        path: "funnel",
312        tier: Tier::Destructive,
313        confirm: true,
314    },
315    Covered {
316        path: "funnel status",
317        tier: Tier::Read,
318        confirm: false,
319    },
320    // -- local-files --------------------------------------------------------
321    Covered {
322        path: "file cp",
323        tier: Tier::Write,
324        confirm: false,
325    },
326    Covered {
327        path: "file get",
328        tier: Tier::Write,
329        confirm: false,
330    },
331    Covered {
332        path: "cert",
333        tier: Tier::Write,
334        confirm: false,
335    },
336    Covered {
337        path: "metrics write",
338        tier: Tier::Write,
339        confirm: false,
340    },
341    Covered {
342        path: "configure kubeconfig",
343        tier: Tier::Write,
344        confirm: false,
345    },
346    Covered {
347        path: "syspolicy reload",
348        tier: Tier::Write,
349        confirm: false,
350    },
351    Covered {
352        path: "drive list",
353        tier: Tier::Read,
354        confirm: false,
355    },
356    Covered {
357        path: "drive share",
358        tier: Tier::Write,
359        confirm: false,
360    },
361    Covered {
362        path: "drive rename",
363        tier: Tier::Write,
364        confirm: false,
365    },
366    Covered {
367        path: "drive unshare",
368        tier: Tier::Destructive,
369        confirm: false,
370    },
371    // -- local-lock ---------------------------------------------------------
372    Covered {
373        path: "lock status",
374        tier: Tier::Read,
375        confirm: false,
376    },
377    Covered {
378        path: "lock log",
379        tier: Tier::Read,
380        confirm: false,
381    },
382    Covered {
383        path: "lock init",
384        tier: Tier::Destructive,
385        confirm: true,
386    },
387    Covered {
388        path: "lock add",
389        tier: Tier::Write,
390        confirm: false,
391    },
392    Covered {
393        path: "lock remove",
394        tier: Tier::Destructive,
395        confirm: false,
396    },
397    Covered {
398        path: "lock sign",
399        tier: Tier::Write,
400        confirm: false,
401    },
402    Covered {
403        path: "lock disable",
404        tier: Tier::Destructive,
405        confirm: true,
406    },
407    Covered {
408        path: "lock disablement-kdf",
409        tier: Tier::Read,
410        confirm: false,
411    },
412    Covered {
413        path: "lock local-disable",
414        tier: Tier::Destructive,
415        confirm: false,
416    },
417    Covered {
418        path: "lock revoke-keys",
419        tier: Tier::Destructive,
420        confirm: true,
421    },
422    // -- local-debug --------------------------------------------------------
423    Covered {
424        path: "debug derp-map",
425        tier: Tier::Read,
426        confirm: false,
427    },
428    Covered {
429        path: "debug netmap",
430        tier: Tier::Read,
431        confirm: false,
432    },
433    Covered {
434        path: "debug hostinfo",
435        tier: Tier::Read,
436        confirm: false,
437    },
438    Covered {
439        path: "debug control-knobs",
440        tier: Tier::Read,
441        confirm: false,
442    },
443    Covered {
444        path: "debug daemon-goroutines",
445        tier: Tier::Read,
446        confirm: false,
447    },
448    Covered {
449        path: "debug daemon-bus-graph",
450        tier: Tier::Read,
451        confirm: false,
452    },
453    Covered {
454        path: "debug daemon-bus-queues",
455        tier: Tier::Read,
456        confirm: false,
457    },
458    Covered {
459        path: "debug metrics",
460        tier: Tier::Read,
461        confirm: false,
462    },
463    Covered {
464        path: "debug statedir",
465        tier: Tier::Read,
466        confirm: false,
467    },
468    Covered {
469        path: "debug go-buildinfo",
470        tier: Tier::Read,
471        confirm: false,
472    },
473    Covered {
474        path: "debug peer-relay-servers",
475        tier: Tier::Read,
476        confirm: false,
477    },
478    Covered {
479        path: "debug peer-relay-sessions",
480        tier: Tier::Read,
481        confirm: false,
482    },
483    Covered {
484        path: "debug stat",
485        tier: Tier::Read,
486        confirm: false,
487    },
488    Covered {
489        path: "debug via",
490        tier: Tier::Read,
491        confirm: false,
492    },
493    Covered {
494        path: "debug watch-ipn",
495        tier: Tier::Read,
496        confirm: false,
497    },
498    Covered {
499        path: "debug peer-endpoint-changes",
500        tier: Tier::Read,
501        confirm: false,
502    },
503    Covered {
504        path: "debug resolve",
505        tier: Tier::Read,
506        confirm: false,
507    },
508    Covered {
509        path: "debug dial-types",
510        tier: Tier::Read,
511        confirm: false,
512    },
513    Covered {
514        path: "debug derp",
515        tier: Tier::Read,
516        confirm: false,
517    },
518    Covered {
519        path: "debug ts2021",
520        tier: Tier::Read,
521        confirm: false,
522    },
523    Covered {
524        path: "debug portmap",
525        tier: Tier::Read,
526        confirm: false,
527    },
528    Covered {
529        path: "debug component-logs",
530        tier: Tier::Write,
531        confirm: false,
532    },
533    Covered {
534        path: "debug restun",
535        tier: Tier::Write,
536        confirm: false,
537    },
538    Covered {
539        path: "debug rebind",
540        tier: Tier::Write,
541        confirm: false,
542    },
543    Covered {
544        path: "debug rotate-disco-key",
545        tier: Tier::Write,
546        confirm: false,
547    },
548    Covered {
549        path: "debug derp-unset-on-demand",
550        tier: Tier::Write,
551        confirm: false,
552    },
553    Covered {
554        path: "debug pick-new-derp",
555        tier: Tier::Write,
556        confirm: false,
557    },
558    Covered {
559        path: "debug force-prefer-derp",
560        tier: Tier::Write,
561        confirm: false,
562    },
563    Covered {
564        path: "debug force-netmap-update",
565        tier: Tier::Write,
566        confirm: false,
567    },
568];
569
570/// Every documented command this server will not run at all.
571///
572/// Three of the four grounds `CONTEXT.md` names are here: it runs in the
573/// foreground with no end, it wires a stream to a terminal this server does not
574/// have, or it changes the host outside Tailscale. The fourth — printing a
575/// secret — belongs to `local_debug::EXCLUDED`, which holds the hidden half of
576/// the same rule. [`excluded`] is the two of them together, and is how anything
577/// outside this module reads either.
578const EXCLUDED: &[Excluded] = &[
579    Excluded {
580        path: "ssh",
581        reason: "it opens an interactive session on a peer, which needs a terminal \
582                 this server does not have",
583    },
584    Excluded {
585        path: "nc",
586        reason: "it wires a socket to standard input and output, which a tool call \
587                 has nothing to connect",
588    },
589    Excluded {
590        path: "web",
591        reason: "it runs a web server in the foreground until interrupted",
592    },
593    Excluded {
594        path: "systray",
595        reason: "it runs a desktop application in the foreground until quit",
596    },
597    Excluded {
598        path: "update",
599        reason: "it replaces the `tailscale` binary this server is talking to; \
600                 update the host the way the host is normally updated",
601    },
602    Excluded {
603        path: "configure sysext activate",
604        reason: "it installs a system extension and reboots into it, which is a \
605                 change to the host rather than to the tailnet",
606    },
607    Excluded {
608        path: "configure sysext deactivate",
609        reason: "it removes a system extension the node may be running on, which is \
610                 a change to the host rather than to the tailnet",
611    },
612    Excluded {
613        path: "configure mac-vpn install",
614        reason: "it installs a system VPN profile, which is a change to the host \
615                 rather than to the tailnet",
616    },
617    Excluded {
618        path: "configure mac-vpn uninstall",
619        reason: "it removes a system VPN profile, which is a change to the host \
620                 rather than to the tailnet",
621    },
622];
623
624/// Every command this server refuses, documented and hidden alike.
625///
626/// The two halves are separate constants because they are each other's
627/// context: one is read beside the debug tools, the other beside this one. A
628/// caller meets them as a single list, which is what this is for.
629pub fn excluded() -> impl Iterator<Item = &'static Excluded> {
630    EXCLUDED.iter().chain(local_debug::EXCLUDED)
631}
632
633/// What this server knows about a command it was asked to run.
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub enum Known {
636    /// A typed tool runs this, and these are its terms.
637    Covered(&'static Covered),
638    /// This server never runs it, and here is what to tell the caller.
639    Excluded(&'static Excluded),
640    /// Nobody has judged it, so it is judged at the top.
641    Unknown,
642}
643
644/// The command a caller named, and what is known about it.
645///
646/// # Errors
647///
648/// When the arguments do not name a command this server can read: no bare word
649/// at all, or bare words that stop partway into something excluded.
650pub fn classify(args: &[String]) -> ToolResult<(String, Known)> {
651    // Two readings, because this server does not parse the client's flags and
652    // should not have to. The client does parse them: `tailscale serve --bg
653    // reset` runs `serve reset`, so reading only the words before the first
654    // flag would call it `serve` and let a write-tier session wipe the config
655    // with no confirmation. Reading every bare word instead has the opposite
656    // fault — `tailscale funnel --set-path status 8080` would read as `funnel
657    // status`, a reader, when the flag's value only looks like a subcommand.
658    //
659    // Neither reading is right on its own, and working out which one applies is
660    // the flag parsing this refuses to do. So both are taken and the stricter
661    // wins. Being wrong then costs a caller a tier or a confirmation they did
662    // not need, which is the direction to be wrong in.
663    let leading = words(args.iter().take_while(|arg| !is_flag(arg)));
664    let every = words(args.iter().filter(|arg| !is_flag(arg)));
665
666    if every.first().is_none_or(String::is_empty) {
667        return Err(ToolError::invalid_args(
668            "the first argument has to be a subcommand rather than a flag or an \
669             empty string, so that what runs can be read from the arguments",
670        ));
671    }
672
673    let (path, known) = if leading.is_empty() {
674        read(&every)
675    } else {
676        std::cmp::max_by_key(read(&leading), read(&every), |(_, known)| strictness(known))
677    };
678
679    // Unknown is ordinarily destructive-and-run. It is not that when a reading
680    // stops partway into a command this server refuses: `["debug",
681    // "--file=get"]` has no bare word after `debug`, and letting it through as
682    // unknown would run a `debug`-anything on a session with the destructive
683    // tier. Refusing the partial path is the only answer that does not depend
684    // on parsing flags the way the client's own package would.
685    if known == Known::Unknown {
686        for reading in [&leading, &every] {
687            if excluded().any(|e| is_proper_prefix(reading, e.path)) {
688                return Err(ToolError::invalid_args(format!(
689                    "`tailscale {}` names only part of a command, and this server \
690                     decides what to allow from the words that are not flags. Write \
691                     the whole subcommand, with its flags after it.",
692                    reading.join(" ")
693                )));
694            }
695        }
696    }
697    Ok((path, known))
698}
699
700/// Whether an argument is a flag rather than a word of the command.
701fn is_flag(arg: &str) -> bool {
702    arg.starts_with('-')
703}
704
705/// One reading's words, in the form the tables are written in.
706///
707/// Lowercased, because the client matches its own subcommands without regard to
708/// case at every depth — `tailscale DEBUG PREFS` runs `debug prefs` — and a
709/// table compared with `==` would otherwise be evaded by shouting. Trimmed for
710/// the same reason in reverse: the client does not trim, so a padded word names
711/// no command there, and reading it as one here only ever refuses more.
712fn words<'a>(args: impl Iterator<Item = &'a String>) -> Vec<String> {
713    args.map(|arg| arg.trim().to_ascii_lowercase()).collect()
714}
715
716/// The deepest command either table names at the start of `words`.
717fn read(words: &[String]) -> (String, Known) {
718    for depth in (1..=words.len().min(MAX_DEPTH)).rev() {
719        let path = words[..depth].join(" ");
720        if let Some(found) = excluded().find(|e| e.path == path) {
721            return (path, Known::Excluded(found));
722        }
723        if let Some(found) = COVERED.iter().find(|c| c.path == path) {
724            return (path, Known::Covered(found));
725        }
726    }
727    (words.join(" "), Known::Unknown)
728}
729
730/// How strict a reading is, for choosing between two readings of one command.
731///
732/// Nothing is stricter than a command this server does not run at all, so that
733/// ranks above every tier; below it, a higher tier is stricter than a lower one
734/// and needing a confirmation is stricter than not.
735fn strictness(known: &Known) -> (bool, Tier, bool) {
736    match known {
737        Known::Excluded(_) => (true, Tier::Destructive, true),
738        Known::Covered(covered) => (false, covered.tier, covered.confirm),
739        Known::Unknown => (false, Tier::Destructive, false),
740    }
741}
742
743/// Whether `words` is the beginning of `path` without being all of it.
744fn is_proper_prefix(words: &[String], path: &str) -> bool {
745    let mut theirs = path.split(' ');
746    !words.is_empty()
747        && words
748            .iter()
749            .all(|word| theirs.next() == Some(word.as_str()))
750        && theirs.next().is_some()
751}
752
753/// Run any `tailscale` subcommand.
754#[derive(Debug, Deserialize, JsonSchema)]
755pub struct RunParams {
756    /// The arguments to `tailscale`, one per element, subcommand first: `["dns",
757    /// "status", "--all"]` and never `"dns status --all"`. Nothing here is
758    /// parsed by a shell, so a value containing a space, a quote or a semicolon
759    /// is one argument and not several.
760    pub args: Vec<String>,
761    /// Set to true to confirm the command, which some of them need for the same
762    /// reason the tool covering them does. A refusal names the command and says
763    /// whose terms it is borrowing; what those terms are for is on that tool.
764    #[serde(default)]
765    pub confirm: bool,
766    /// How long to let the command run, in seconds. Clamped to 1..=300, and 30
767    /// when it is not given, which is what every typed tool without a reason to
768    /// wait longer uses.
769    #[serde(default)]
770    pub timeout_seconds: Option<u64>,
771}
772
773/// What a passthrough command did.
774#[derive(Debug, Serialize, JsonSchema)]
775pub struct RunReport {
776    /// The command as it ran, with anything secret-shaped removed.
777    pub command: String,
778    /// The tier it was judged at.
779    pub tier: &'static str,
780    /// Whether a typed tool covers it. When this is false the tier above is
781    /// this server declining to guess rather than something it knows, and a
782    /// typed tool is worth looking for.
783    pub covered: bool,
784    /// Everything the client printed.
785    #[serde(skip_serializing_if = "Option::is_none")]
786    pub printed: Option<String>,
787}
788
789async fn run(ctx: &ToolContext, params: RunParams) -> ToolResult<Value> {
790    let meta = &metas::tailscale_run;
791    let (path, known) = classify(&params.args)?;
792
793    let (tier, confirm) = match known {
794        Known::Excluded(excluded) => {
795            // No hint: there is no switch that turns this on, which is the
796            // whole difference between this refusal and the gate's.
797            return Err(ToolError::new(
798                ErrorCode::NotPermitted,
799                format!(
800                    "`tailscale {}` is never run by this server: {}",
801                    excluded.path, excluded.reason
802                ),
803            ));
804        }
805        Known::Covered(covered) => (covered.tier, covered.confirm),
806        Known::Unknown => (Tier::Destructive, false),
807    };
808
809    // `Tier::Read` outranks nothing and has no flag, so the two halves are one
810    // condition: a tier above the session's is a tier with a switch to name.
811    if tier > ctx.max_tier
812        && let Some(flag) = tier.flag()
813    {
814        return Err(ToolError::not_permitted(&format!("tailscale {path}"), flag));
815    }
816    // Only a covered row asks for a confirmation; an unknown command is
817    // destructive without one, because there is no tool whose terms to borrow.
818    if confirm && !params.confirm {
819        return Err(ToolError::confirmation_required(
820            &format!("tailscale {path}"),
821            "runs at the same terms as the tool that covers it",
822        ));
823    }
824
825    let (_, bound) = bounded_wait(params.timeout_seconds, DEFAULT_RUN_TIMEOUT, MAX_RUN_TIMEOUT);
826    // Anything that is not a read takes the exclusive lane. A typed tool knows
827    // whether its own write can share; this one does not, and queueing behind
828    // another mutation costs a wait where guessing wrong costs a race.
829    let invocation = if tier == Tier::Read {
830        Invocation::read(params.args.clone())
831    } else {
832        Invocation::mutate(params.args.clone())
833    }
834    .with_timeout(bound);
835
836    let command = cli::displayed(ctx, &invocation);
837    tracing::info!(command = %command, tier = tier.as_str(), "passthrough");
838    let output = cli::run(ctx, meta, invocation).await?;
839    report(RunReport {
840        command,
841        tier: tier.as_str(),
842        covered: matches!(known, Known::Covered(_)),
843        printed: printed(ctx, &output),
844    })
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    use crate::testing::{Reply, StubBackend, context};
851    use std::sync::Arc;
852
853    fn words(args: &[&str]) -> Vec<String> {
854        args.iter().map(|a| (*a).to_owned()).collect()
855    }
856
857    fn class(args: &[&str]) -> Known {
858        classify(&words(args))
859            .expect("the arguments name a command")
860            .1
861    }
862
863    #[test]
864    fn every_path_fits_the_depth_the_matcher_searches() {
865        for path in COVERED
866            .iter()
867            .map(|c| c.path)
868            .chain(excluded().map(|e| e.path))
869        {
870            assert!(
871                path.split(' ').count() <= MAX_DEPTH,
872                "`{path}` is deeper than the matcher looks"
873            );
874        }
875    }
876
877    #[test]
878    fn no_path_is_named_twice() {
879        let mut seen: Vec<&str> = COVERED
880            .iter()
881            .map(|c| c.path)
882            .chain(excluded().map(|e| e.path))
883            .collect();
884        let before = seen.len();
885        seen.sort_unstable();
886        seen.dedup();
887        assert_eq!(before, seen.len(), "a command is judged in two places");
888    }
889
890    #[test]
891    fn every_path_is_written_the_way_the_matcher_compares_it() {
892        // The matcher lowercases what it is given and compares with `==`, so a
893        // row written in mixed case would sit in the table matching nothing.
894        for path in COVERED
895            .iter()
896            .map(|c| c.path)
897            .chain(excluded().map(|e| e.path))
898        {
899            assert_eq!(path, path.to_ascii_lowercase(), "`{path}` cannot match");
900            assert_eq!(path.trim(), path, "`{path}` cannot match");
901        }
902    }
903
904    #[test]
905    fn a_partial_path_into_an_excluded_command_is_refused_rather_than_guessed() {
906        // Neither reading gets past `debug`, so there is nothing to judge and
907        // letting it through as unknown would run it at the destructive tier.
908        let error = classify(&words(&["debug", "--file=get"]))
909            .expect_err("`debug` on its own cannot be judged");
910        assert_eq!(error.code, ErrorCode::InvalidArgs);
911        assert!(
912            error.message.contains("only part of a command"),
913            "{error:?}"
914        );
915    }
916
917    #[test]
918    fn a_flag_value_that_only_looks_like_a_subcommand_keeps_the_stricter_terms() {
919        // The other direction: `status` here is the value of `--set-path`, and
920        // reading it as `funnel status` would drop a destructive command to a
921        // reader. Neither reading can be trusted alone, so the stricter wins.
922        let (path, known) = classify(&words(&["funnel", "--set-path", "status", "8080"]))
923            .expect("`funnel` is readable");
924        assert_eq!(path, "funnel");
925        let Known::Covered(covered) = known else {
926            panic!("`funnel` has a row");
927        };
928        assert_eq!((covered.tier, covered.confirm), (Tier::Destructive, true));
929    }
930
931    #[test]
932    fn a_blank_first_argument_is_refused_rather_than_run_as_a_nameless_command() {
933        // `["", "down"]` would otherwise read as the unknown command `" down"`
934        // and run at the destructive tier as `tailscale "" down`.
935        for args in [vec![""], vec!["", "down"], vec!["   "]] {
936            let args: Vec<String> = args.into_iter().map(str::to_owned).collect();
937            let error = classify(&args).expect_err("a blank subcommand cannot be read");
938            assert_eq!(error.code, ErrorCode::InvalidArgs);
939        }
940        // A blank word after the subcommand is one of its arguments, and the
941        // subcommand is still read.
942        let args: Vec<String> = ["ping", ""].into_iter().map(str::to_owned).collect();
943        let (path, _) = classify(&args).expect("`ping` is still readable");
944        assert_eq!(path, "ping");
945    }
946
947    #[test]
948    fn a_leading_flag_does_not_hide_the_subcommand_behind_it() {
949        // The leading-words reading is empty here, so only the other one has
950        // anything to say, and it says `status`.
951        let (path, known) =
952            classify(&words(&["--socket=/tmp/x", "status"])).expect("`status` is still named");
953        assert_eq!(path, "status");
954        assert!(matches!(known, Known::Covered(_)));
955    }
956
957    #[test]
958    fn reload_config_stays_runnable() {
959        // DECISIONS Q44: in neither table, so unknown and destructive rather
960        // than refused.
961        assert!(matches!(class(&["debug", "reload-config"]), Known::Unknown));
962    }
963
964    /// Run the handler against a scripted client and report what it ran.
965    async fn against(reply: Reply, params: RunParams) -> (ToolResult<Value>, Vec<Vec<String>>) {
966        let backend = Arc::new(StubBackend::always(reply));
967        let ctx = context(backend.clone());
968        let answer = run(&ctx, params).await;
969        (answer, backend.argv())
970    }
971
972    fn params(args: &[&str]) -> RunParams {
973        RunParams {
974            args: words(args),
975            confirm: false,
976            timeout_seconds: None,
977        }
978    }
979
980    #[tokio::test]
981    async fn the_report_says_how_the_command_was_judged() {
982        let (answer, _) = against(Reply::ok("100.64.0.1\n"), params(&["ip"])).await;
983        let value = answer.expect("a read runs");
984        assert_eq!(value["tier"], "read");
985        assert_eq!(value["covered"], true);
986        assert_eq!(value["command"], "tailscale ip");
987        assert_eq!(value["printed"], "100.64.0.1");
988    }
989
990    #[tokio::test]
991    async fn a_secret_on_the_argument_list_is_kept_out_of_the_report() {
992        // The one place a caller's own arguments become text this server
993        // shows: every typed tool builds its own, and none of them can carry
994        // a key the way this can.
995        let key = "tskey-auth-kFakeExample-0123456789";
996        let (answer, argv) = against(
997            Reply::ok("done\n"),
998            RunParams {
999                args: words(&["up", &format!("--auth-key={key}")]),
1000                confirm: true,
1001                timeout_seconds: None,
1002            },
1003        )
1004        .await;
1005        let value = answer.expect("`up` runs once confirmed");
1006        let command = value["command"].as_str().expect("a command line");
1007        assert!(
1008            !command.contains(key),
1009            "the key reached the report: {command}"
1010        );
1011
1012        // Redaction is what is shown and never what is run: the client still
1013        // has to receive the real key.
1014        assert_eq!(argv, vec![words(&["up", &format!("--auth-key={key}")])]);
1015    }
1016}