1use 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 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 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#[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 #[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#[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 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 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#[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}