Skip to main content

tmux_mcp/
manifest.rs

1//! One typed capability definition bound to each native MCP tool route.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::Arc;
5
6use rmcp::handler::server::router::tool::{ToolRoute, ToolRouter};
7use rmcp::model::{MetaObject, ToolAnnotations};
8use serde::{Deserialize, Serialize};
9
10use crate::TmuxTools;
11use crate::policy::{Selection, SurfaceError, Toolset};
12
13pub(crate) const CAPABILITY_KEY: &str = "com.git-pull.libtmux-mcp/capability";
14const DEFINITION_KEY: &str = "com.git-pull.libtmux-mcp/internal-definition";
15
16#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
17#[serde(rename_all = "kebab-case")]
18pub(crate) enum ProcessReach {
19    None,
20    ConfiguredProcess,
21    PaneInput,
22    PaneCommand,
23}
24
25#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
26#[serde(rename_all = "kebab-case")]
27pub(crate) enum TmuxEffect {
28    Observe,
29    Change,
30    Delete,
31}
32
33#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
34#[serde(rename_all = "kebab-case")]
35pub(crate) enum OutputClass {
36    TmuxMetadata,
37    TerminalContent,
38    ProcessEnvironment,
39    ConfiguredCommand,
40}
41
42#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
43#[serde(rename_all = "kebab-case")]
44pub(crate) enum InputSink {
45    None,
46    TmuxLookup,
47    TmuxState,
48    TmuxFormat,
49    PaneInput,
50    ShellCommand,
51    ProcessArgv,
52    Regex,
53    NestedTool,
54}
55
56#[cfg(test)]
57mod input_sink_tests {
58    use std::collections::BTreeSet;
59
60    use super::InputSink;
61
62    #[test]
63    fn wire_vocabulary_is_exact() {
64        let error = serde_json::from_str::<InputSink>(r#""not-a-sink""#)
65            .expect_err("the sentinel must not be a valid input sink")
66            .to_string();
67        let (_, expected) = error
68            .split_once("expected ")
69            .expect("serde lists the accepted wire variants");
70        let (expected, _) = expected
71            .split_once(" at line ")
72            .expect("serde reports the JSON location");
73        let actual: BTreeSet<_> = expected
74            .trim_start_matches("one of ")
75            .split(", ")
76            .map(|name| name.trim_matches('`'))
77            .collect();
78        let shared = BTreeSet::from([
79            "nested-tool",
80            "none",
81            "pane-input",
82            "process-argv",
83            "regex",
84            "shell-command",
85            "tmux-format",
86            "tmux-lookup",
87            "tmux-state",
88        ]);
89
90        assert_eq!(actual, shared);
91    }
92}
93
94#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
95#[serde(rename_all = "kebab-case")]
96pub(crate) enum InputLiteralization {
97    DoubleHashOnce,
98    ValidatedVariableName,
99}
100
101#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
102#[serde(rename_all = "camelCase")]
103#[allow(
104    clippy::struct_excessive_bools,
105    reason = "MCP defines four independent annotation hints"
106)]
107pub(crate) struct Annotations {
108    pub(crate) read_only_hint: bool,
109    pub(crate) destructive_hint: bool,
110    pub(crate) idempotent_hint: bool,
111    pub(crate) open_world_hint: bool,
112}
113
114impl Annotations {
115    fn render(self, title: Option<String>) -> ToolAnnotations {
116        ToolAnnotations::from_raw(
117            title,
118            Some(self.read_only_hint),
119            Some(self.destructive_hint),
120            Some(self.idempotent_hint),
121            Some(self.open_world_hint),
122        )
123    }
124}
125
126#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
127#[serde(rename_all = "camelCase")]
128#[allow(
129    clippy::struct_excessive_bools,
130    reason = "the capability contract carries independent boolean facts"
131)]
132pub(crate) struct Capability {
133    pub(crate) toolset: Toolset,
134    pub(crate) process_reach: ProcessReach,
135    pub(crate) tmux_effects: BTreeSet<TmuxEffect>,
136    pub(crate) output_classes: BTreeSet<OutputClass>,
137    pub(crate) may_expose_secrets: bool,
138    pub(crate) may_return_untrusted_content: bool,
139    /// Whether repeating a call with the same arguments changes nothing more.
140    ///
141    /// Declared only for a tool that changes tmux: a read-only tool is
142    /// idempotent by derivation.
143    pub(crate) idempotent: bool,
144    pub(crate) input_sinks: BTreeMap<String, BTreeSet<InputSink>>,
145    pub(crate) input_literalization: BTreeMap<String, InputLiteralization>,
146    pub(crate) nested_authority: BTreeSet<String>,
147    pub(crate) amplifies_future_input: bool,
148}
149
150#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
151#[serde(rename_all = "camelCase")]
152#[allow(
153    clippy::struct_excessive_bools,
154    reason = "the public capability contract carries independent boolean facts"
155)]
156pub(crate) struct PublishedCapability {
157    pub(crate) toolset: Toolset,
158    pub(crate) process_reach: ProcessReach,
159    pub(crate) tmux_effects: BTreeSet<TmuxEffect>,
160    pub(crate) output_classes: BTreeSet<OutputClass>,
161    pub(crate) may_expose_secrets: bool,
162    pub(crate) may_return_untrusted_content: bool,
163    pub(crate) input_literalization: BTreeMap<String, InputLiteralization>,
164    pub(crate) nested_authority: BTreeSet<String>,
165    pub(crate) amplifies_future_input: bool,
166}
167
168impl From<&Capability> for PublishedCapability {
169    fn from(definition: &Capability) -> Self {
170        Self {
171            toolset: definition.toolset,
172            process_reach: definition.process_reach,
173            tmux_effects: definition.tmux_effects.clone(),
174            output_classes: definition.output_classes.clone(),
175            may_expose_secrets: definition.may_expose_secrets,
176            may_return_untrusted_content: definition.may_return_untrusted_content,
177            input_literalization: definition.input_literalization.clone(),
178            nested_authority: definition.nested_authority.clone(),
179            amplifies_future_input: definition.amplifies_future_input,
180        }
181    }
182}
183
184impl Capability {
185    pub(crate) fn controlled_opener(&self) -> &'static str {
186        controlled_opener(self.toolset, self.process_reach, &self.output_classes)
187    }
188
189    /// The four MCP hints, derived from this row so they cannot drift from it.
190    ///
191    /// Pane input is destructive because the receiving shell runs whatever
192    /// arrives. The open-world hint follows process reach and terminal
193    /// content, not `may_return_untrusted_content`, which every row sets.
194    pub(crate) fn annotations(&self) -> Annotations {
195        let read_only = self.process_reach == ProcessReach::None
196            && self
197                .tmux_effects
198                .iter()
199                .all(|effect| *effect == TmuxEffect::Observe);
200        let drives_a_shell = matches!(
201            self.process_reach,
202            ProcessReach::PaneInput | ProcessReach::PaneCommand
203        );
204        Annotations {
205            read_only_hint: read_only,
206            destructive_hint: self.tmux_effects.contains(&TmuxEffect::Delete) || drives_a_shell,
207            idempotent_hint: read_only || self.idempotent,
208            open_world_hint: self.process_reach != ProcessReach::None
209                || self.output_classes.contains(&OutputClass::TerminalContent),
210        }
211    }
212}
213
214fn controlled_opener(
215    toolset: Toolset,
216    process_reach: ProcessReach,
217    output_classes: &BTreeSet<OutputClass>,
218) -> &'static str {
219    match process_reach {
220        ProcessReach::ConfiguredProcess => {
221            "Start a pane's configured process; accepts no command payload."
222        }
223        ProcessReach::PaneInput => {
224            "Send input to a pane's program; a shell that receives it runs it with your user's permissions."
225        }
226        ProcessReach::PaneCommand => "Run a shell command in a pane with your user's permissions.",
227        ProcessReach::None => match toolset {
228            Toolset::Manage | Toolset::Execute => {
229                "Change tmux state; no client-supplied executable input."
230            }
231            Toolset::Teardown => "Delete tmux state; accepts no command payload.",
232            Toolset::Inspect if output_classes.contains(&OutputClass::TerminalContent) => {
233                "Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted."
234            }
235            Toolset::Inspect if output_classes.contains(&OutputClass::ProcessEnvironment) => {
236                "Read the tmux environment; accepts no client-supplied executable input. Values are withheld unless the operator allowed the name."
237            }
238            Toolset::Inspect if output_classes.contains(&OutputClass::ConfiguredCommand) => {
239                "Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration."
240            }
241            Toolset::Inspect => {
242                "Inspect tmux metadata; accepts no client-supplied executable input."
243            }
244        },
245    }
246}
247
248/// Build the namespaced custom metadata attached to one native tool route.
249#[allow(
250    clippy::expect_used,
251    reason = "the closed capability value has no fallible serializer"
252)]
253pub(crate) fn metadata(capability: Capability, always_load: bool) -> MetaObject {
254    let mut meta = MetaObject::new();
255    meta.0.insert(
256        DEFINITION_KEY.to_owned(),
257        serde_json::to_value(capability).expect("capability metadata serializes"),
258    );
259    if always_load {
260        meta.0.insert(
261            "anthropic/alwaysLoad".to_owned(),
262            serde_json::Value::Bool(true),
263        );
264    }
265    meta
266}
267
268fn capability(meta: Option<&MetaObject>, name: &str) -> Result<Capability, SurfaceError> {
269    let value = meta
270        .and_then(|meta| meta.0.get(DEFINITION_KEY))
271        .ok_or_else(|| SurfaceError::new(format!("tool {name:?} has no capability manifest")))?;
272    serde_json::from_value(value.clone()).map_err(|error| {
273        SurfaceError::new(format!(
274            "tool {name:?} has invalid capability metadata: {error}"
275        ))
276    })
277}
278
279#[derive(Clone, Debug, Serialize)]
280#[serde(rename_all = "camelCase")]
281pub(crate) struct ReportTool {
282    pub(crate) name: String,
283    pub(crate) title: String,
284    pub(crate) annotations: Annotations,
285    /// The only part a tool's `_meta` carries: the rest is on the tool.
286    #[serde(flatten)]
287    pub(crate) capability: PublishedCapability,
288}
289
290#[cfg(test)]
291impl ReportTool {
292    pub(crate) fn controlled_opener(&self) -> &'static str {
293        controlled_opener(
294            self.capability.toolset,
295            self.capability.process_reach,
296            &self.capability.output_classes,
297        )
298    }
299}
300
301/// The frozen, effective MCP surface reported at `tmux://capabilities`.
302#[derive(Clone, Debug, Serialize)]
303#[serde(rename_all = "camelCase")]
304pub struct CapabilityReport {
305    pub(crate) schema_version: u8,
306    pub(crate) frozen: bool,
307    pub(crate) boundary: BoundaryReport,
308    pub(crate) connection: ConnectionReport,
309    pub(crate) socket: SocketReport,
310    pub(crate) toolsets: Vec<&'static str>,
311    pub(crate) included_tools: Vec<String>,
312    pub(crate) excluded_tools: Vec<String>,
313    pub(crate) tool_count: usize,
314    pub(crate) effective_tools: Vec<String>,
315    pub(crate) tools: Vec<ReportTool>,
316    pub(crate) host_command_tools: u8,
317    pub(crate) tool_filtering_boundary: &'static str,
318    pub(crate) execution_authority: &'static str,
319    pub(crate) operating_system_boundary: &'static str,
320}
321
322#[derive(Clone, Debug, Serialize)]
323#[serde(rename_all = "camelCase")]
324#[allow(
325    clippy::struct_excessive_bools,
326    reason = "the report carries four independent MCP boundary facts"
327)]
328pub(crate) struct BoundaryReport {
329    pub(crate) one_socket_per_process: bool,
330    pub(crate) per_call_socket_selection: bool,
331    pub(crate) host_command_execution: bool,
332    pub(crate) dynamic_resources: bool,
333}
334
335#[derive(Clone, Debug, Serialize)]
336#[serde(rename_all = "camelCase")]
337pub(crate) struct ConnectionReport {
338    pub(crate) socket_selector: String,
339    pub(crate) socket_provenance: &'static str,
340    pub(crate) resolved_socket_path: String,
341    pub(crate) server_state: &'static str,
342    pub(crate) configuration_provenance: &'static str,
343    pub(crate) attach_command: String,
344}
345
346#[derive(Clone, Debug, Serialize)]
347#[serde(rename_all = "camelCase")]
348pub(crate) struct SocketReport {
349    pub(crate) selector: String,
350    pub(crate) selection_provenance: &'static str,
351    pub(crate) server_state: &'static str,
352    pub(crate) configuration_provenance: &'static str,
353    pub(crate) namespace_boundary: &'static str,
354}
355
356pub(crate) struct Resolved {
357    pub(crate) router: ToolRouter<TmuxTools>,
358    pub(crate) nested_router: ToolRouter<TmuxTools>,
359    pub(crate) report: CapabilityReport,
360}
361
362pub(crate) fn resolve(
363    mut router: ToolRouter<TmuxTools>,
364    selection: &Selection,
365) -> Result<Resolved, SurfaceError> {
366    let known: BTreeSet<String> = router.map.keys().map(ToString::to_string).collect();
367    for name in selection
368        .included_names()
369        .iter()
370        .chain(selection.excluded_names())
371    {
372        if !known.contains(name) {
373            return Err(SurfaceError::new(format!("unknown tool {name:?}")));
374        }
375    }
376
377    let mut capabilities = BTreeMap::new();
378    for (name, route) in &mut router.map {
379        let row = capability(route.attr.meta.as_ref(), name)?;
380        let input_schema = Arc::make_mut(&mut route.attr.input_schema);
381        input_schema.insert("additionalProperties".to_owned(), false.into());
382        validate(name, input_schema, &row, &known)?;
383        capabilities.insert(name.to_string(), row);
384    }
385    let source_capabilities = capabilities.clone();
386    let mut nested_router = router.clone();
387
388    let selected_toolsets: BTreeSet<_> = selection.toolsets().iter().copied().collect();
389    let withheld: Vec<_> = capabilities
390        .iter()
391        .filter(|(name, row)| {
392            (!selected_toolsets.contains(&row.toolset) && !selection.includes(name))
393                || selection.excludes(name)
394        })
395        .map(|(name, _)| name.clone())
396        .collect();
397    for name in withheld {
398        router.remove_route(&name);
399        capabilities.remove(&name);
400    }
401
402    for row in capabilities.values_mut() {
403        if !row.nested_authority.is_empty() {
404            row.nested_authority
405                .retain(|name| !selection.excludes(name));
406            recompute_aggregate(row, &source_capabilities)?;
407        }
408    }
409    let nested_authority: BTreeSet<_> = capabilities
410        .values()
411        .flat_map(|row| row.nested_authority.iter().cloned())
412        .collect();
413    nested_router
414        .map
415        .retain(|name, _| nested_authority.contains(name.as_ref()));
416
417    let mut tools = Vec::with_capacity(capabilities.len());
418    for (name, row) in capabilities {
419        let route = router
420            .map
421            .get_mut(name.as_str())
422            .ok_or_else(|| SurfaceError::new(format!("tool {name:?} has no route")))?;
423        let report = finish_route(name.clone(), &row, route, &nested_router)?;
424        refresh_metadata(route.attr.meta.as_mut(), name.as_str(), &report)?;
425        tools.push(report);
426    }
427
428    Ok(Resolved {
429        router,
430        nested_router,
431        report: CapabilityReport {
432            schema_version: 2,
433            frozen: true,
434            boundary: BoundaryReport {
435                one_socket_per_process: true,
436                per_call_socket_selection: false,
437                host_command_execution: false,
438                dynamic_resources: false,
439            },
440            connection: ConnectionReport {
441                socket_selector: String::new(),
442                socket_provenance: "unknown",
443                resolved_socket_path: String::new(),
444                server_state: "unknown",
445                configuration_provenance: "unknown",
446                attach_command: String::new(),
447            },
448            socket: SocketReport {
449                selector: String::new(),
450                selection_provenance: "unknown",
451                server_state: "unknown",
452                configuration_provenance: "unknown",
453                namespace_boundary: "tmux-objects-only",
454            },
455            toolsets: selection
456                .toolsets()
457                .iter()
458                .map(|toolset| toolset.name())
459                .collect(),
460            included_tools: selection.included_names().iter().cloned().collect(),
461            excluded_tools: selection.excluded_names().iter().cloned().collect(),
462            tool_count: tools.len(),
463            effective_tools: tools.iter().map(|tool| tool.name.clone()).collect(),
464            tools,
465            host_command_tools: 0,
466            tool_filtering_boundary: "interface-shaping-not-authorization",
467            execution_authority: "tmux-user",
468            operating_system_boundary: "none",
469        },
470    })
471}
472
473fn finish_route(
474    name: String,
475    row: &Capability,
476    route: &mut ToolRoute<TmuxTools>,
477    nested_router: &ToolRouter<TmuxTools>,
478) -> Result<ReportTool, SurfaceError> {
479    let opener = row.controlled_opener();
480    let remainder = route.attr.description.as_deref().unwrap_or("").trim();
481    // The tool's own text goes first, so a caller that truncates to the
482    // first sentence can still tell tools apart: the safety sentence,
483    // shared by every tool in the same (toolset, process_reach,
484    // output_classes) bucket, trails it instead.
485    route.attr.description = Some(
486        if remainder.ends_with(opener) {
487            remainder.to_owned()
488        } else if remainder.is_empty() {
489            opener.to_owned()
490        } else {
491            format!("{remainder} {opener}")
492        }
493        .into(),
494    );
495    route.attr.annotations = Some(row.annotations().render(route.attr.title.clone()));
496    if row
497        .input_sinks
498        .values()
499        .any(|sinks| sinks.contains(&InputSink::NestedTool))
500    {
501        set_nested_operation_schemas(
502            &name,
503            Arc::make_mut(&mut route.attr.input_schema),
504            nested_router,
505        )?;
506    }
507    let title = route
508        .attr
509        .title
510        .as_deref()
511        .ok_or_else(|| SurfaceError::new(format!("tool {name:?} has no title")))?
512        .to_owned();
513    // Required on every tool, and read from it: the report does not repeat
514    // what `tools/list` already gave the client.
515    if route.attr.description.is_none() {
516        return Err(SurfaceError::new(format!(
517            "tool {name:?} has no description"
518        )));
519    }
520    if route.attr.output_schema.is_none() {
521        return Err(SurfaceError::new(format!(
522            "tool {name:?} has no output schema"
523        )));
524    }
525    Ok(ReportTool {
526        name,
527        title,
528        annotations: row.annotations(),
529        capability: PublishedCapability::from(row),
530    })
531}
532
533fn recompute_aggregate(
534    aggregate: &mut Capability,
535    source: &BTreeMap<String, Capability>,
536) -> Result<(), SurfaceError> {
537    aggregate.tmux_effects.clear();
538    aggregate.output_classes.clear();
539    aggregate.may_expose_secrets = false;
540    aggregate.may_return_untrusted_content = false;
541    for name in &aggregate.nested_authority {
542        let nested = source
543            .get(name)
544            .ok_or_else(|| SurfaceError::new(format!("unknown nested tool {name:?}")))?;
545        aggregate
546            .tmux_effects
547            .extend(nested.tmux_effects.iter().copied());
548        aggregate
549            .output_classes
550            .extend(nested.output_classes.iter().copied());
551        aggregate.may_expose_secrets |= nested.may_expose_secrets;
552        aggregate.may_return_untrusted_content |= nested.may_return_untrusted_content;
553    }
554    if aggregate.nested_authority.is_empty() {
555        aggregate.tmux_effects.insert(TmuxEffect::Observe);
556    }
557    Ok(())
558}
559
560fn refresh_metadata(
561    meta: Option<&mut MetaObject>,
562    name: &str,
563    row: &ReportTool,
564) -> Result<(), SurfaceError> {
565    let meta = meta.ok_or_else(|| SurfaceError::new(format!("tool {name:?} has no metadata")))?;
566    let value = serde_json::to_value(&row.capability).map_err(|error| {
567        SurfaceError::new(format!(
568            "tool {name:?} capability cannot serialize: {error}"
569        ))
570    })?;
571    meta.0.remove(DEFINITION_KEY);
572    meta.0.insert(CAPABILITY_KEY.to_owned(), value);
573    Ok(())
574}
575
576fn set_nested_operation_schemas(
577    name: &str,
578    schema: &mut serde_json::Map<String, serde_json::Value>,
579    nested: &ToolRouter<TmuxTools>,
580) -> Result<(), SurfaceError> {
581    let operations = schema
582        .get_mut("properties")
583        .and_then(serde_json::Value::as_object_mut)
584        .and_then(|properties| properties.get_mut("operations"))
585        .and_then(serde_json::Value::as_object_mut)
586        .ok_or_else(|| SurfaceError::new(format!("tool {name:?} has no operations schema")))?;
587    let items = if nested.map.is_empty() {
588        serde_json::json!({"not": {}})
589    } else {
590        let mut names = nested
591            .map
592            .keys()
593            .map(ToString::to_string)
594            .collect::<Vec<_>>();
595        names.sort();
596        let alternatives = names
597            .into_iter()
598            .map(|nested_name| {
599                let route = nested.map.get(nested_name.as_str()).ok_or_else(|| {
600                    SurfaceError::new(format!("unknown nested tool {nested_name:?}"))
601                })?;
602                let mut input = inline_local_references(serde_json::Value::Object(
603                    (*route.attr.input_schema).clone(),
604                ))?;
605                if let Some(input) = input.as_object_mut() {
606                    input.insert("description".to_owned(), "That tool's arguments.".into());
607                }
608                Ok(serde_json::json!({
609                    "type": "object",
610                    "properties": {
611                        "tool": {"type": "string", "const": nested_name},
612                        "arguments": input,
613                    },
614                    "required": ["tool"],
615                    "additionalProperties": false,
616                }))
617            })
618            .collect::<Result<Vec<_>, SurfaceError>>()?;
619        serde_json::json!({"oneOf": alternatives})
620    };
621    operations.insert("items".to_owned(), items);
622    if let Some(definitions) = schema
623        .get_mut("$defs")
624        .and_then(serde_json::Value::as_object_mut)
625    {
626        definitions.remove("ReadOperation");
627        if definitions.is_empty() {
628            schema.remove("$defs");
629        }
630    }
631    Ok(())
632}
633
634fn inline_local_references(
635    mut schema: serde_json::Value,
636) -> Result<serde_json::Value, SurfaceError> {
637    let definitions = schema
638        .get("$defs")
639        .and_then(serde_json::Value::as_object)
640        .cloned()
641        .unwrap_or_default();
642    if let Some(object) = schema.as_object_mut() {
643        object.remove("$defs");
644    }
645    inline_references_in(&mut schema, &definitions, 0)?;
646    Ok(schema)
647}
648
649fn inline_references_in(
650    value: &mut serde_json::Value,
651    definitions: &serde_json::Map<String, serde_json::Value>,
652    depth: usize,
653) -> Result<(), SurfaceError> {
654    if depth > 32 {
655        return Err(SurfaceError::new(
656            "nested input schema reference depth exceeded",
657        ));
658    }
659    if let Some(reference) = value
660        .as_object()
661        .and_then(|object| object.get("$ref"))
662        .and_then(serde_json::Value::as_str)
663        .and_then(|reference| reference.strip_prefix("#/$defs/"))
664    {
665        *value = definitions
666            .get(reference)
667            .cloned()
668            .ok_or_else(|| SurfaceError::new(format!("unknown schema reference {reference:?}")))?;
669        return inline_references_in(value, definitions, depth + 1);
670    }
671    match value {
672        serde_json::Value::Object(object) => {
673            for nested in object.values_mut() {
674                inline_references_in(nested, definitions, depth + 1)?;
675            }
676        }
677        serde_json::Value::Array(values) => {
678            for nested in values {
679                inline_references_in(nested, definitions, depth + 1)?;
680            }
681        }
682        _ => {}
683    }
684    Ok(())
685}
686
687fn validate(
688    name: &str,
689    schema: &serde_json::Map<String, serde_json::Value>,
690    row: &Capability,
691    known: &BTreeSet<String>,
692) -> Result<(), SurfaceError> {
693    if row.tmux_effects.is_empty() {
694        return Err(SurfaceError::new(format!(
695            "tool {name:?} has no direct tmux effect"
696        )));
697    }
698    let schema_keys: BTreeSet<String> = schema
699        .get("properties")
700        .and_then(serde_json::Value::as_object)
701        .map(|properties| properties.keys().cloned().collect())
702        .unwrap_or_default();
703    let sink_keys: BTreeSet<String> = row.input_sinks.keys().cloned().collect();
704    if schema_keys != sink_keys {
705        return Err(SurfaceError::new(format!(
706            "tool {name:?} input sinks do not equal its schema keys: schema={schema_keys:?}, sinks={sink_keys:?}"
707        )));
708    }
709    for (input, sinks) in &row.input_sinks {
710        if sinks.is_empty() {
711            return Err(SurfaceError::new(format!(
712                "tool {name:?} input {input:?} has no sink"
713            )));
714        }
715        if sinks.len() > 1 && sinks.contains(&InputSink::None) {
716            return Err(SurfaceError::new(format!(
717                "tool {name:?} input {input:?} combines none with another sink"
718            )));
719        }
720    }
721    let format_inputs: BTreeSet<_> = row
722        .input_sinks
723        .iter()
724        .filter(|(_, sinks)| sinks.contains(&InputSink::TmuxFormat))
725        .map(|(input, _)| input.clone())
726        .collect();
727    let controlled_inputs: BTreeSet<_> = row.input_literalization.keys().cloned().collect();
728    if format_inputs != controlled_inputs {
729        return Err(SurfaceError::new(format!(
730            "tool {name:?} tmux-format sinks do not equal input literalization keys"
731        )));
732    }
733    let has_pane_input = row
734        .input_sinks
735        .values()
736        .any(|sinks| sinks.contains(&InputSink::PaneInput));
737    let has_pane_command = row
738        .input_sinks
739        .values()
740        .any(|sinks| sinks.contains(&InputSink::ShellCommand));
741    let has_nested_tool = row
742        .input_sinks
743        .values()
744        .any(|sinks| sinks.contains(&InputSink::NestedTool));
745    match row.process_reach {
746        ProcessReach::PaneInput if !has_pane_input => {
747            return Err(SurfaceError::new(format!(
748                "tool {name:?} declares pane-input reach without a pane-input sink"
749            )));
750        }
751        ProcessReach::PaneCommand if !has_pane_command => {
752            return Err(SurfaceError::new(format!(
753                "tool {name:?} declares pane-command reach without a pane-command sink"
754            )));
755        }
756        ProcessReach::None | ProcessReach::ConfiguredProcess
757            if has_pane_input || has_pane_command =>
758        {
759            return Err(SurfaceError::new(format!(
760                "tool {name:?} input sinks exceed its process reach"
761            )));
762        }
763        _ => {}
764    }
765    validate_authority(name, row, known, has_nested_tool)?;
766    Ok(())
767}
768
769fn validate_authority(
770    name: &str,
771    row: &Capability,
772    known: &BTreeSet<String>,
773    has_nested_tool: bool,
774) -> Result<(), SurfaceError> {
775    if !row.nested_authority.is_subset(known) {
776        return Err(SurfaceError::new(format!(
777            "tool {name:?} names unknown nested authority"
778        )));
779    }
780    if row.nested_authority.contains(name) {
781        return Err(SurfaceError::new(format!(
782            "tool {name:?} includes itself in nested authority"
783        )));
784    }
785    if has_nested_tool == row.nested_authority.is_empty() {
786        return Err(SurfaceError::new(format!(
787            "tool {name:?} must declare nested-tool sinks and nested authority together"
788        )));
789    }
790    if row.amplifies_future_input != (name == "set_synchronize_panes") {
791        return Err(SurfaceError::new(format!(
792            "tool {name:?} has an invalid future-input amplification declaration"
793        )));
794    }
795    Ok(())
796}
797
798/// Attach one typed capability row to an SDK-native tool route.
799#[macro_export]
800macro_rules! capability_meta {
801    (@idempotent) => { false };
802    (@idempotent $idempotent:expr) => { $idempotent };
803    (
804        $toolset:ident, $reach:ident, [$($effect:ident),+ $(,)?],
805        [$($output:ident),* $(,)?], $secrets:expr, $untrusted:expr,
806        {$($input:literal => [$($sink:ident),+ $(,)?]),* $(,)?};
807        idempotent
808    ) => {
809        $crate::capability_meta!(
810            $toolset, $reach,
811            effects = [$($effect),+],
812            outputs = [$($output),*],
813            secrets = $secrets,
814            untrusted = $untrusted,
815            sinks = {$($input => [$($sink),+]),*},
816            literalized = [],
817            format_validated = [],
818            nested = [],
819            idempotent = true,
820            self_bounded = false,
821            always_load = false,
822        )
823    };
824    (
825        $toolset:ident, $reach:ident, [$($effect:ident),+ $(,)?],
826        [$($output:ident),* $(,)?], $secrets:expr, $untrusted:expr,
827        {$($input:literal => [$($sink:ident),+ $(,)?]),* $(,)?}
828    ) => {
829        $crate::capability_meta!(
830            $toolset, $reach,
831            effects = [$($effect),+],
832            outputs = [$($output),*],
833            secrets = $secrets,
834            untrusted = $untrusted,
835            sinks = {$($input => [$($sink),+]),*},
836            literalized = [],
837            format_validated = [],
838            nested = [],
839            self_bounded = false,
840            always_load = false,
841        )
842    };
843    (
844        $toolset:ident, $reach:ident, [$($effect:ident),+ $(,)?],
845        [$($output:ident),* $(,)?], $secrets:expr, $untrusted:expr,
846        {$($input:literal => [$($sink:ident),+ $(,)?]),* $(,)?};
847        always_load
848    ) => {
849        $crate::capability_meta!(
850            $toolset, $reach,
851            effects = [$($effect),+],
852            outputs = [$($output),*],
853            secrets = $secrets,
854            untrusted = $untrusted,
855            sinks = {$($input => [$($sink),+]),*},
856            literalized = [],
857            format_validated = [],
858            nested = [],
859            self_bounded = false,
860            always_load = true,
861        )
862    };
863    (
864        $toolset:ident, $reach:ident,
865        effects = [$($effect:ident),+ $(,)?],
866        outputs = [$($output:ident),* $(,)?],
867        secrets = $secrets:expr,
868        untrusted = $untrusted:expr,
869        sinks = {$($input:literal => [$($sink:ident),+ $(,)?]),* $(,)?},
870        literalized = [$($literalized:literal),* $(,)?],
871        $(format_validated = [$($validated:literal),* $(,)?],)?
872        nested = [$($nested:literal),* $(,)?],
873        $(idempotent = $idempotent:expr,)?
874        self_bounded = $self_bounded:expr,
875        always_load = $always_load:expr $(,)?
876    ) => {
877        $crate::capability_meta!(
878            $toolset, $reach,
879            effects = [$($effect),+],
880            outputs = [$($output),*],
881            secrets = $secrets,
882            untrusted = $untrusted,
883            sinks = {$($input => [$($sink),+]),*},
884            literalized = [$($literalized),*],
885            format_validated = [$($($validated),*)?],
886            nested = [$($nested),*],
887            amplifies_future_input = false,
888            $(idempotent = $idempotent,)?
889            self_bounded = $self_bounded,
890            always_load = $always_load,
891        )
892    };
893    (
894        $toolset:ident, $reach:ident,
895        effects = [$($effect:ident),+ $(,)?],
896        outputs = [$($output:ident),* $(,)?],
897        secrets = $secrets:expr,
898        untrusted = $untrusted:expr,
899        sinks = {$($input:literal => [$($sink:ident),+ $(,)?]),* $(,)?},
900        literalized = [$($literalized:literal),* $(,)?],
901        $(format_validated = [$($validated:literal),* $(,)?],)?
902        nested = [$($nested:literal),* $(,)?],
903        amplifies_future_input = $amplifies:expr,
904        $(idempotent = $idempotent:expr,)?
905        self_bounded = $self_bounded:expr,
906        always_load = $always_load:expr $(,)?
907    ) => {{
908        $crate::manifest::metadata(
909            $crate::manifest::Capability {
910                toolset: $crate::policy::Toolset::$toolset,
911                process_reach: $crate::manifest::ProcessReach::$reach,
912                tmux_effects: [$($crate::manifest::TmuxEffect::$effect),+]
913                    .into_iter()
914                    .collect(),
915                output_classes: [$($crate::manifest::OutputClass::$output),*]
916                    .into_iter()
917                    .collect(),
918                may_expose_secrets: $secrets,
919                may_return_untrusted_content: $untrusted,
920                idempotent: $crate::capability_meta!(@idempotent $($idempotent)?),
921                input_sinks: [$(
922                    (
923                        $input.to_owned(),
924                        [$($crate::manifest::InputSink::$sink),+]
925                            .into_iter()
926                            .collect(),
927                    )
928                ),*]
929                    .into_iter()
930                    .collect(),
931                input_literalization: [
932                    $(
933                        (
934                            $literalized.to_owned(),
935                            $crate::manifest::InputLiteralization::DoubleHashOnce,
936                        ),
937                    )*
938                    $($(
939                            (
940                                $validated.to_owned(),
941                                $crate::manifest::InputLiteralization::ValidatedVariableName,
942                            ),
943                    )*)?
944                ]
945                    .into_iter()
946                    .collect(),
947                nested_authority: [$($nested.to_owned()),*]
948                    .into_iter()
949                    .collect(),
950                amplifies_future_input: $amplifies,
951            },
952            $always_load,
953        )
954    }};
955}