Skip to main content

tmux_mcp/
policy.rs

1use std::collections::BTreeSet;
2use std::ffi::OsStr;
3use std::ffi::OsString;
4use std::fmt;
5use std::future::Future;
6use std::sync::{Arc, OnceLock};
7use std::time::Duration;
8
9use libtmux::Server;
10use rmcp::model::ErrorData;
11use serde::{Deserialize, Serialize};
12
13use crate::tail::Tails;
14use crate::{CallerIdentity, TmuxTools, schema, tools};
15
16/// The older Rust-specific safety setting, rejected rather than ignored.
17pub const RETIRED_RUST_SAFETY_ENV: &str = "TMUX_MCP_SAFETY";
18
19/// The unordered toolsets enabled for this process.
20pub const TOOLSETS_ENV: &str = "LIBTMUX_TOOLSETS";
21
22/// Individual tools added after toolset expansion.
23pub const TOOLS_ENV: &str = "LIBTMUX_TOOLS";
24
25/// Individual tools removed after every inclusion path.
26pub const EXCLUDE_TOOLS_ENV: &str = "LIBTMUX_EXCLUDE_TOOLS";
27
28/// The retired ordered-safety setting, rejected rather than ignored.
29pub const RETIRED_SAFETY_ENV: &str = "LIBTMUX_SAFETY";
30
31/// The tmux environment variables whose values tools may return.
32pub const ENVIRONMENT_VALUES_ENV: &str = "LIBTMUX_ENVIRONMENT_VALUES";
33
34/// The most names [`ENVIRONMENT_VALUES_ENV`] may allow.
35const MAX_ENVIRONMENT_VALUES: usize = 32;
36
37/// Parse the operator's allowed environment names.
38///
39/// Absent or empty allows none. A name is compared exactly, so `PATH` does
40/// not allow `path`.
41///
42/// # Errors
43///
44/// Returns an error for an empty element, a name containing `=` or NUL, or
45/// more than 32 names.
46pub fn parse_environment_values(value: Option<&str>) -> Result<BTreeSet<String>, SurfaceError> {
47    let names = parse_optional_names(value, ENVIRONMENT_VALUES_ENV)?;
48    if let Some(name) = names.iter().find(|name| name.contains(['=', '\0'])) {
49        return Err(SurfaceError::new(format!(
50            "{ENVIRONMENT_VALUES_ENV} names {name:?}, which is not a variable name"
51        )));
52    }
53    if names.len() > MAX_ENVIRONMENT_VALUES {
54        return Err(SurfaceError::new(format!(
55            "{ENVIRONMENT_VALUES_ENV} allows at most {MAX_ENVIRONMENT_VALUES} names"
56        )));
57    }
58    Ok(names)
59}
60
61/// Read [`ENVIRONMENT_VALUES_ENV`] before serving MCP.
62///
63/// # Errors
64///
65/// Returns an error for invalid UTF-8 or any error
66/// [`parse_environment_values`] reports.
67pub fn environment_values_from_env() -> Result<BTreeSet<String>, SurfaceError> {
68    parse_environment_values(unicode_env(ENVIRONMENT_VALUES_ENV)?.as_deref())
69}
70
71/// One mechanical group in the advertised MCP tool inventory.
72#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
73#[serde(rename_all = "kebab-case")]
74pub enum Toolset {
75    /// Read tmux metadata, pane output, environment, or configuration.
76    Inspect,
77    /// Change tmux state without supplying executable input.
78    Manage,
79    /// Start configured processes or supply pane input and commands.
80    Execute,
81    /// Delete tmux state.
82    Teardown,
83}
84
85impl Toolset {
86    const ALL: [Self; 4] = [Self::Inspect, Self::Manage, Self::Execute, Self::Teardown];
87
88    fn parse(name: &str) -> Option<Self> {
89        match name {
90            "inspect" => Some(Self::Inspect),
91            "manage" => Some(Self::Manage),
92            "execute" => Some(Self::Execute),
93            "teardown" => Some(Self::Teardown),
94            _ => None,
95        }
96    }
97
98    /// The name used in environment selections and capability reports.
99    #[must_use]
100    pub const fn name(self) -> &'static str {
101        match self {
102            Self::Inspect => "inspect",
103            Self::Manage => "manage",
104            Self::Execute => "execute",
105            Self::Teardown => "teardown",
106        }
107    }
108}
109
110/// The startup-frozen request for one MCP tool surface.
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct Selection {
113    toolsets: Vec<Toolset>,
114    include: BTreeSet<String>,
115    exclude: BTreeSet<String>,
116}
117
118impl Selection {
119    /// Parse the three list settings before any tmux connection is opened.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error for empty tokens or unknown tool and toolset names.
124    pub fn parse(
125        toolsets: Option<&str>,
126        include: Option<&str>,
127        exclude: Option<&str>,
128    ) -> Result<Self, SurfaceError> {
129        Self::parse_for_socket(toolsets, include, exclude, false)
130    }
131
132    /// Parse a selection while applying the selected socket's provenance.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error for empty tokens or unknown tool and toolset names.
137    pub fn parse_for_socket(
138        toolsets: Option<&str>,
139        include: Option<&str>,
140        exclude: Option<&str>,
141        default_teardown: bool,
142    ) -> Result<Self, SurfaceError> {
143        let toolsets = match toolsets {
144            None if default_teardown => Toolset::ALL.to_vec(),
145            None => vec![Toolset::Inspect, Toolset::Manage, Toolset::Execute],
146            Some("") => Vec::new(),
147            Some(value) => parse_names(value, "LIBTMUX_TOOLSETS")?
148                .into_iter()
149                .map(|name| {
150                    Toolset::parse(&name).ok_or_else(|| {
151                        SurfaceError::new(format!(
152                            "unknown toolset {name:?}; expected inspect, manage, execute, or teardown"
153                        ))
154                    })
155                })
156                .collect::<Result<BTreeSet<_>, _>>()?
157                .into_iter()
158                .collect(),
159        };
160        Ok(Self {
161            toolsets,
162            include: parse_optional_names(include, "LIBTMUX_TOOLS")?,
163            exclude: parse_optional_names(exclude, "LIBTMUX_EXCLUDE_TOOLS")?,
164        })
165    }
166
167    /// Read and validate the process-wide selection before serving MCP.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error for invalid UTF-8, malformed selections, or retired settings.
172    pub fn from_env(default_teardown: bool) -> Result<Self, SurfaceError> {
173        for retired in [RETIRED_SAFETY_ENV, RETIRED_RUST_SAFETY_ENV] {
174            if std::env::var_os(retired).is_some() {
175                return Err(SurfaceError::new(format!(
176                    "{retired} has been removed; use {TOOLSETS_ENV}"
177                )));
178            }
179        }
180        let toolsets = unicode_env(TOOLSETS_ENV)?;
181        let include = unicode_env(TOOLS_ENV)?;
182        let exclude = unicode_env(EXCLUDE_TOOLS_ENV)?;
183        Self::parse_for_socket(
184            toolsets.as_deref(),
185            include.as_deref(),
186            exclude.as_deref(),
187            default_teardown,
188        )
189    }
190
191    /// The startup-frozen toolsets in deterministic order.
192    #[must_use]
193    pub fn toolsets(&self) -> &[Toolset] {
194        &self.toolsets
195    }
196
197    pub(super) fn includes(&self, name: &str) -> bool {
198        self.include.contains(name)
199    }
200
201    pub(super) fn excludes(&self, name: &str) -> bool {
202        self.exclude.contains(name)
203    }
204
205    pub(super) fn included_names(&self) -> &BTreeSet<String> {
206        &self.include
207    }
208
209    pub(super) fn excluded_names(&self) -> &BTreeSet<String> {
210        &self.exclude
211    }
212}
213
214fn unicode_env(name: &'static str) -> Result<Option<String>, SurfaceError> {
215    match std::env::var(name) {
216        Ok(value) => Ok(Some(value)),
217        Err(std::env::VarError::NotPresent) => Ok(None),
218        Err(std::env::VarError::NotUnicode(value)) => Err(non_unicode(name, value)),
219    }
220}
221
222fn non_unicode(name: &'static str, _value: OsString) -> SurfaceError {
223    SurfaceError::new(format!("{name} must be valid UTF-8"))
224}
225
226fn parse_optional_names(
227    value: Option<&str>,
228    variable: &'static str,
229) -> Result<BTreeSet<String>, SurfaceError> {
230    match value {
231        None | Some("") => Ok(BTreeSet::new()),
232        Some(value) => Ok(parse_names(value, variable)?.into_iter().collect()),
233    }
234}
235
236fn parse_names(value: &str, variable: &'static str) -> Result<Vec<String>, SurfaceError> {
237    value
238        .split(',')
239        .map(|raw| {
240            let name = raw.trim();
241            if name.is_empty() {
242                Err(SurfaceError::new(format!(
243                    "{variable} contains an empty name"
244                )))
245            } else {
246                Ok(name.to_owned())
247            }
248        })
249        .collect()
250}
251
252/// A startup configuration or manifest error.
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct SurfaceError(String);
255
256impl SurfaceError {
257    pub(crate) fn new(message: impl Into<String>) -> Self {
258        Self(message.into())
259    }
260}
261
262impl fmt::Display for SurfaceError {
263    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264        formatter.write_str(&self.0)
265    }
266}
267
268impl std::error::Error for SurfaceError {}
269
270/// What startup can honestly claim about the selected tmux daemon.
271#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
272#[serde(rename_all = "kebab-case")]
273pub enum SocketProvenance {
274    /// A product socket this process can create with minimal configuration.
275    DedicatedMinimal,
276    /// The product socket already existed, so its configuration is unknown.
277    DedicatedExisting,
278    /// The operator selected a socket, whose daemon configuration is unknown.
279    OperatorSelected,
280    /// The operator selected a socket where no daemon answered at startup.
281    OperatorSelectedAbsent,
282    /// The operator selected an explicit tmux configuration for an absent daemon.
283    UserConfigured,
284    /// A daemon already answered, so an explicit config was not its provenance.
285    UserConfiguredExisting,
286    /// A programmatic builder did not provide provenance.
287    #[default]
288    Unknown,
289}
290
291impl SocketProvenance {
292    /// Whether absent toolset configuration may include teardown.
293    #[must_use]
294    pub const fn defaults_to_teardown(self) -> bool {
295        matches!(self, Self::DedicatedMinimal)
296    }
297
298    fn report(self, server: &Server) -> crate::manifest::SocketReport {
299        let selector = server.socket_name().map_or_else(
300            || format!("path:{}", server.socket_path().display()),
301            |name| format!("name:{}", name.to_string_lossy()),
302        );
303        let (selection_provenance, server_state, configuration_provenance) = match self {
304            Self::DedicatedMinimal => ("default-dedicated", "created", "minimal"),
305            Self::DedicatedExisting => ("default-dedicated", "existing", "unknown"),
306            Self::OperatorSelected | Self::UserConfiguredExisting => {
307                ("operator-current", "existing", "unknown")
308            }
309            Self::OperatorSelectedAbsent => ("operator-current", "absent", "unknown"),
310            Self::UserConfigured => ("operator-current", "absent", "user-configured"),
311            Self::Unknown => ("unknown", "unknown", "unknown"),
312        };
313        crate::manifest::SocketReport {
314            selector,
315            selection_provenance,
316            server_state,
317            configuration_provenance,
318            namespace_boundary: "tmux-objects-only",
319        }
320    }
321}
322
323fn shell_quote(value: &OsStr) -> String {
324    let value = value.to_string_lossy();
325    format!("'{}'", value.replace('\'', "'\"'\"'"))
326}
327
328fn connection_report(
329    socket: &crate::manifest::SocketReport,
330    server: &Server,
331) -> crate::manifest::ConnectionReport {
332    let path = server.socket_path().as_os_str();
333    crate::manifest::ConnectionReport {
334        socket_selector: socket.selector.clone(),
335        socket_provenance: socket.selection_provenance,
336        resolved_socket_path: server.socket_path().to_string_lossy().into_owned(),
337        server_state: socket.server_state,
338        configuration_provenance: socket.configuration_provenance,
339        attach_command: format!(
340            "{} -N -S {} attach",
341            shell_quote(server.tmux_executable()),
342            shell_quote(path),
343        ),
344    }
345}
346
347/// Assembles a [`TmuxTools`] with the parts the environment usually supplies.
348#[derive(Debug)]
349pub struct Builder {
350    server: Server,
351    caller: Option<CallerIdentity>,
352    selection: Selection,
353    socket_provenance: SocketProvenance,
354    environment_values: BTreeSet<String>,
355}
356
357impl Builder {
358    /// Allow tools to return the values of these tmux environment variables.
359    ///
360    /// None are allowed by default: `show_environment` reports names and
361    /// state, and `get_tmux_variables` refuses a name the environment holds.
362    /// A tmux server inherits the environment of the shell that started it,
363    /// so its values are the user's tokens and keys.
364    #[must_use]
365    pub fn environment_values(mut self, names: BTreeSet<String>) -> Self {
366        self.environment_values = names;
367        self
368    }
369
370    /// Say where this process is running, rather than reading the environment.
371    #[must_use]
372    pub fn caller(mut self, caller: Option<CallerIdentity>) -> Self {
373        self.caller = caller;
374        self
375    }
376
377    /// Choose the startup-frozen unordered tool surface.
378    #[must_use]
379    pub fn selection(mut self, selection: Selection) -> Self {
380        self.selection = selection;
381        self
382    }
383
384    /// Record only the socket/configuration provenance startup established.
385    #[must_use]
386    pub const fn socket_provenance(mut self, provenance: SocketProvenance) -> Self {
387        self.socket_provenance = provenance;
388        self
389    }
390
391    /// Build the server with its startup-frozen tool selection.
392    ///
393    /// # Panics
394    ///
395    /// Panics if native tool metadata violates the capability contract.
396    #[must_use]
397    #[allow(
398        clippy::expect_used,
399        reason = "build preserves the existing infallible constructor contract"
400    )]
401    pub fn build(self) -> TmuxTools {
402        self.try_build()
403            .expect("native tool routes and the requested surface are valid")
404    }
405
406    /// Build the server after validating every named tool against the manifest.
407    ///
408    /// # Errors
409    ///
410    /// Returns an error if the native routes or requested selection are invalid.
411    pub fn try_build(self) -> Result<TmuxTools, SurfaceError> {
412        let identity = Arc::new(crate::identity::InstanceIdentity::new());
413        let mut router = tools::router();
414        for route in router.map.values_mut() {
415            schema::strip_unknown_formats(Arc::make_mut(&mut route.attr.input_schema));
416            if let Some(schema) = route.attr.output_schema.as_mut() {
417                schema::strip_unknown_formats(Arc::make_mut(schema));
418            }
419        }
420        let mut resolved = crate::manifest::resolve(router, &self.selection)?;
421        let socket = self.socket_provenance.report(&self.server);
422        resolved.report.connection = connection_report(&socket, &self.server);
423        resolved.report.socket = socket;
424        let router = resolved.router;
425        Ok(TmuxTools {
426            server: Arc::new(self.server),
427            caller: self.caller.map(Arc::new),
428            capability_report: Arc::new(resolved.report),
429            socket: Arc::new(OnceLock::new()),
430            tails: Arc::new(Tails::new(identity)),
431            echoes: Arc::new(crate::echo::PaneEchoes::new()),
432            tool_router: router,
433            nested_tool_router: resolved.nested_router,
434            environment_values: Arc::new(self.environment_values),
435        })
436    }
437}
438
439/// Reports how a long call is getting on, when the client asked to be told.
440///
441/// MCP sends progress only to a request that carried a `progressToken`, so a
442/// client that did not ask pays nothing: there is no token, and the notifier
443/// does not exist. Without this a sixty-second wait is indistinguishable from
444/// a server that has stopped answering.
445#[derive(Clone, Debug)]
446struct Progress {
447    peer: rmcp::service::Peer<rmcp::RoleServer>,
448    token: rmcp::model::ProgressToken,
449}
450
451/// Whoever asked to be told how a long call is getting on.
452///
453/// Extracted from the request rather than passed, so a tool declares that it
454/// reports progress by taking one. It is empty unless the client sent a
455/// progress token, and an empty one can be built directly -- which is what
456/// lets these tools be driven without a live client.
457#[derive(Clone, Debug, Default)]
458pub struct Reporter(Option<Progress>);
459
460impl Reporter {
461    /// A reporter with nobody to report to.
462    #[must_use]
463    pub const fn none() -> Self {
464        Self(None)
465    }
466}
467
468impl<C> rmcp::handler::server::common::FromContextPart<C> for Reporter
469where
470    C: rmcp::handler::server::common::AsRequestContext,
471{
472    fn from_context_part(context: &mut C) -> Result<Self, ErrorData> {
473        let context = context.as_request_context();
474        Ok(Self(context.meta.get_progress_token().map(|token| {
475            Progress {
476                peer: context.peer.clone(),
477                token,
478            }
479        })))
480    }
481}
482
483impl Progress {
484    /// Say what is happening now.
485    ///
486    /// `so_far` is seconds elapsed, because the protocol asks for a number
487    /// that rises every time and a wait has no other measure of its own
488    /// progress: it does not know how long it will take.
489    ///
490    /// Best-effort: a client that has gone away is the caller's problem to
491    /// notice through its own request, not this notification's to report.
492    async fn say(&self, so_far: f64, message: impl Into<String>) {
493        let mut param = rmcp::model::ProgressNotificationParam::new(self.token.clone(), so_far);
494        param.message = Some(message.into());
495        let _ = self.peer.notify_progress(param).await;
496    }
497}
498
499/// Report progress every so often while a future runs.
500///
501/// Wraps rather than threads a reporter through each primitive: the useful
502/// thing to say about a wait is that it is still waiting, and how long for,
503/// which needs nothing from inside it.
504pub(super) async fn reporting<T>(
505    reporter: Reporter,
506    what: &str,
507    work: impl Future<Output = T>,
508) -> T {
509    let Some(progress) = reporter.0 else {
510        return work.await;
511    };
512
513    let began = tokio::time::Instant::now();
514    let ticker = async {
515        let mut every = tokio::time::interval(PROGRESS_EVERY);
516        // The first tick is immediate, and "0 seconds in" says nothing.
517        every.tick().await;
518        loop {
519            every.tick().await;
520            let elapsed = began.elapsed().as_secs();
521            progress
522                .say(
523                    f64::from(u32::try_from(elapsed).unwrap_or(u32::MAX)),
524                    format!("{what}, {elapsed}s so far"),
525                )
526                .await;
527        }
528    };
529
530    tokio::select! {
531        outcome = work => outcome,
532        () = ticker => unreachable!("the ticker loops forever"),
533    }
534}
535
536/// How often a long call says it is still going.
537const PROGRESS_EVERY: Duration = Duration::from_secs(5);
538
539impl TmuxTools {
540    /// Expose one tmux server, locating this process within it.
541    #[must_use]
542    pub fn new(server: Server) -> Self {
543        Self::builder(server).build()
544    }
545
546    /// Expose one tmux server, saying explicitly where this process is and how
547    /// much of the surface it may use.
548    ///
549    /// The environment is process-wide, so a test that needs a caller or a
550    /// selection cannot set one without disturbing every other test. This is how it
551    /// says so instead.
552    #[must_use]
553    pub fn builder(server: Server) -> Builder {
554        Builder {
555            server,
556            caller: CallerIdentity::from_env(),
557            selection: Selection {
558                toolsets: vec![Toolset::Inspect, Toolset::Manage, Toolset::Execute],
559                include: BTreeSet::new(),
560                exclude: BTreeSet::new(),
561            },
562            socket_provenance: SocketProvenance::Unknown,
563            environment_values: BTreeSet::new(),
564        }
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::{Selection, Toolset};
571    use crate::manifest::{OutputClass, ProcessReach, TmuxEffect};
572    use std::collections::BTreeSet;
573
574    #[test]
575    fn toolset_selection_distinguishes_empty_from_empty_tokens() {
576        let empty = Selection::parse(Some(""), None, None).expect("empty surface");
577        assert!(empty.toolsets().is_empty());
578
579        let inspect = Selection::parse(Some("inspect"), None, None).expect("one toolset");
580        assert_eq!(inspect.toolsets(), &[Toolset::Inspect]);
581
582        for malformed in [",inspect", "inspect,", "inspect,,manage"] {
583            let error = Selection::parse(Some(malformed), None, None).expect_err("empty token");
584            assert!(error.to_string().contains("empty"), "{malformed}: {error}");
585        }
586    }
587
588    #[test]
589    fn registered_routes_are_the_capability_manifest() {
590        let selection =
591            Selection::parse_for_socket(None, None, None, true).expect("dedicated minimal surface");
592        let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
593            .expect("complete manifest");
594        let listed = resolved.router.list_all();
595        let reported: Vec<_> = resolved
596            .report
597            .tools
598            .iter()
599            .map(|tool| tool.name.as_str())
600            .collect();
601        let names: Vec<_> = listed.iter().map(|tool| tool.name.as_ref()).collect();
602
603        assert_eq!(names, reported);
604        assert!(listed.iter().all(|tool| {
605            let description = tool.description.as_deref().expect("description");
606            resolved
607                .report
608                .tools
609                .iter()
610                .any(|row| row.name == tool.name && description.ends_with(row.controlled_opener()))
611        }));
612    }
613
614    /// A caller that reads only up to a tool's first sentence -- a common
615    /// truncation or summary strategy -- must still be able to tell tools
616    /// apart.
617    ///
618    /// `finish_route` used to prepend the coarse, capability-keyed safety
619    /// sentence before a tool's own description; several tools sharing a
620    /// `(toolset, process_reach, output_classes)` bucket then shared the
621    /// byte-identical opener.
622    #[test]
623    fn every_tools_first_sentence_is_distinct() {
624        let selection =
625            Selection::parse_for_socket(None, None, None, true).expect("dedicated minimal surface");
626        let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
627            .expect("complete manifest");
628
629        let listed = resolved.router.list_all();
630        let mut by_first_sentence: std::collections::HashMap<&str, Vec<&str>> =
631            std::collections::HashMap::new();
632        for tool in &listed {
633            let description = tool.description.as_deref().expect("description");
634            let first_sentence = description.split(". ").next().unwrap_or(description);
635            by_first_sentence
636                .entry(first_sentence)
637                .or_default()
638                .push(tool.name.as_ref());
639        }
640        let collisions: Vec<_> = by_first_sentence
641            .into_iter()
642            .filter(|(_, names)| names.len() > 1)
643            .collect();
644        assert!(
645            collisions.is_empty(),
646            "tools sharing a first sentence, indistinguishable by a caller that reads only that \
647             far: {collisions:?}",
648        );
649    }
650
651    /// The generated safety sentence follows a tool's own text, so a summary
652    /// with no closing period ran into it: "List every tmux session on the
653    /// server Inspect tmux metadata; ...".
654    #[test]
655    fn every_tools_own_description_ends_its_sentence() {
656        let unfinished: Vec<_> = crate::tools::router()
657            .list_all()
658            .into_iter()
659            .filter(|tool| {
660                !tool
661                    .description
662                    .as_deref()
663                    .unwrap_or_default()
664                    .trim_end()
665                    .ends_with(['.', '!', '?'])
666            })
667            .map(|tool| tool.name.into_owned())
668            .collect();
669
670        assert!(unfinished.is_empty(), "no closing period: {unfinished:?}");
671    }
672
673    /// Clients auto-approve or prompt from these hints, so a tool that
674    /// changes tmux must never claim to be read-only.
675    #[test]
676    fn annotations_follow_each_tools_capability_row() {
677        let selection = Selection::parse(Some("inspect,manage,execute,teardown"), None, None)
678            .expect("selection");
679        let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
680            .expect("complete manifest");
681        let mut distinct = BTreeSet::new();
682        let mut destructive_tools = BTreeSet::new();
683
684        for tool in resolved.router.list_all() {
685            let row = &resolved
686                .report
687                .tools
688                .iter()
689                .find(|row| row.name == tool.name)
690                .expect("report row")
691                .capability;
692            let hints = tool.annotations.as_ref().expect("annotations");
693            let read_only = hints.read_only_hint.expect("readOnlyHint");
694            let destructive = hints.destructive_hint.expect("destructiveHint");
695            let changes_tmux = row.toolset != Toolset::Inspect
696                || row.process_reach != ProcessReach::None
697                || row
698                    .tmux_effects
699                    .iter()
700                    .any(|effect| *effect != TmuxEffect::Observe);
701            let can_destroy = row.tmux_effects.contains(&TmuxEffect::Delete)
702                || matches!(
703                    row.process_reach,
704                    ProcessReach::PaneInput | ProcessReach::PaneCommand
705                );
706
707            assert_eq!(read_only, !changes_tmux, "{} readOnlyHint", tool.name);
708            assert_eq!(destructive, can_destroy, "{} destructiveHint", tool.name);
709            if destructive {
710                destructive_tools.insert(tool.name.to_string());
711            }
712            distinct.insert((
713                read_only,
714                destructive,
715                hints.idempotent_hint.expect("idempotentHint"),
716                hints.open_world_hint.expect("openWorldHint"),
717            ));
718        }
719        assert!(distinct.len() > 3, "hints barely vary: {distinct:?}");
720        // tmux 3.7 applies a lowered history limit to existing panes,
721        // discarding their scrollback.
722        assert!(destructive_tools.contains("set_history_limit"));
723    }
724
725    #[test]
726    fn configured_value_reads_disclose_configured_command_output() {
727        let selection = Selection::parse(Some("inspect"), None, None).expect("selection");
728        let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
729            .expect("complete manifest");
730
731        for name in ["get_tmux_variables", "show_option"] {
732            let tool = resolved
733                .report
734                .tools
735                .iter()
736                .find(|tool| tool.name == name)
737                .expect("tool row");
738            assert_eq!(
739                tool.capability.output_classes,
740                [OutputClass::TmuxMetadata, OutputClass::ConfiguredCommand]
741                    .into_iter()
742                    .collect(),
743                "{name}",
744            );
745            assert!(
746                tool.controlled_opener()
747                    .starts_with("Read configured tmux commands;")
748            );
749        }
750    }
751
752    #[test]
753    fn synchronize_panes_is_the_only_declared_input_amplifier() {
754        let selection = Selection::parse(Some("inspect,manage,execute,teardown"), None, None)
755            .expect("selection");
756        let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
757            .expect("complete manifest");
758        let report = serde_json::to_value(resolved.report).expect("report serializes");
759        let tools = report["tools"].as_array().expect("tool rows");
760
761        assert!(
762            tools
763                .iter()
764                .all(|tool| tool["amplifiesFutureInput"].is_boolean()),
765            "every manifest row carries the amplification fact"
766        );
767        let amplified: Vec<_> = tools
768            .iter()
769            .filter(|tool| tool["amplifiesFutureInput"] == true)
770            .map(|tool| tool["name"].as_str().expect("tool name"))
771            .collect();
772        assert_eq!(amplified, ["set_synchronize_panes"]);
773        let synchronize = resolved
774            .router
775            .list_all()
776            .into_iter()
777            .find(|tool| tool.name == "set_synchronize_panes")
778            .expect("synchronize route");
779        let description = synchronize.description.as_deref().expect("description");
780        for claim in ["window default", "Individual pane overrides", "can amplify"] {
781            assert!(description.contains(claim), "missing {claim:?}");
782        }
783    }
784
785    #[test]
786    fn unknown_provenance_defaults_without_teardown() {
787        let selection = Selection::parse(None, None, None).expect("conservative default");
788        assert_eq!(
789            selection.toolsets(),
790            &[Toolset::Inspect, Toolset::Manage, Toolset::Execute]
791        );
792    }
793
794    #[test]
795    fn spawn_routes_accept_no_command_or_environment_payload() {
796        let router = crate::tools::router();
797        for name in [
798            "create_session",
799            "create_window",
800            "split_window",
801            "respawn_pane",
802        ] {
803            let schema = &router.get(name).expect("spawn route").input_schema;
804            let keys: BTreeSet<_> = schema
805                .get("properties")
806                .and_then(serde_json::Value::as_object)
807                .expect("object schema")
808                .keys()
809                .map(String::as_str)
810                .collect();
811            assert!(!keys.contains("command"), "{name}");
812            assert!(!keys.contains("environment"), "{name}");
813            assert!(!keys.contains("env"), "{name}");
814        }
815    }
816
817    #[test]
818    fn exclusion_removes_aggregate_nested_authority() {
819        let selection =
820            Selection::parse(Some("inspect"), None, Some("capture_pane")).expect("selection");
821        let resolved =
822            crate::manifest::resolve(crate::tools::router(), &selection).expect("resolved surface");
823        let batch = resolved
824            .report
825            .tools
826            .iter()
827            .find(|tool| tool.name == "call_read_tools_batch")
828            .expect("batch route");
829
830        assert!(!batch.capability.nested_authority.contains("capture_pane"));
831    }
832
833    #[test]
834    fn read_batch_covers_every_non_self_bounded_inspect_route() {
835        let selection = Selection::parse(Some("inspect"), None, None).expect("selection");
836        let resolved =
837            crate::manifest::resolve(crate::tools::router(), &selection).expect("resolved surface");
838        let expected: BTreeSet<_> = resolved
839            .report
840            .tools
841            .iter()
842            .filter(|tool| {
843                tool.capability.toolset == Toolset::Inspect
844                    && tool.name != "call_read_tools_batch"
845                    && tool.name != "wait_for_text"
846            })
847            .map(|tool| tool.name.clone())
848            .collect();
849        let batch = resolved
850            .report
851            .tools
852            .iter()
853            .find(|tool| tool.name == "call_read_tools_batch")
854            .expect("batch route");
855
856        assert_eq!(batch.capability.nested_authority, expected);
857        let description = resolved
858            .router
859            .list_all()
860            .into_iter()
861            .find(|tool| tool.name == "call_read_tools_batch")
862            .and_then(|tool| tool.description.map(std::borrow::Cow::into_owned))
863            .expect("batch description");
864        assert!(
865            description.contains("inner tools do not receive separate client approval"),
866            "{description}"
867        );
868    }
869}