1use std::collections::{BTreeMap, BTreeSet};
9use std::io::{self, Write};
10use std::sync::{
11 Arc,
12 atomic::{AtomicU64, Ordering},
13};
14
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value};
17use sha2::{Digest, Sha256};
18use thiserror::Error;
19use unicode_width::UnicodeWidthChar;
20use uuid::Uuid;
21
22use crate::event::{EventError, EventKind, EventStore};
23use crate::workspace::WorkspaceState;
24
25pub mod native {
28 pub use crate::genui_native::*;
29}
30
31pub const PROTOCOL_ID: &str = "falsegreen.agent.genui.surface";
32pub const SCHEMA_ID: &str = "surface-v1";
33pub const PROTOCOL_MAJOR: u16 = 1;
34pub const PROTOCOL_MINOR: u16 = 0;
35pub const DEFAULT_MAX_DEPTH: usize = 32;
36pub const DEFAULT_MAX_COMPONENTS: usize = 512;
37pub const DEFAULT_MAX_ACTIONS: usize = 256;
38pub const DEFAULT_MAX_TEXT_BYTES: usize = 64 * 1024;
39pub const DEFAULT_MAX_ROWS: usize = 1_024;
40pub const DEFAULT_MAX_COLUMNS: usize = 128;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct ProtocolVersion {
45 pub major: u16,
46 pub minor: u16,
47}
48
49impl ProtocolVersion {
50 #[must_use]
51 pub const fn current() -> Self {
52 Self {
53 major: PROTOCOL_MAJOR,
54 minor: PROTOCOL_MINOR,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum UnsupportedComponentPolicy {
62 Reject,
63 Placeholder,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct HostCapabilities {
69 pub protocol: ProtocolVersion,
70 pub forward_compatible_minor: bool,
71 pub supported_components: BTreeSet<String>,
72 pub supported_actions: BTreeSet<ActionKind>,
73 pub unsupported_component_policy: UnsupportedComponentPolicy,
74 pub max_depth: usize,
75 pub max_components: usize,
76 pub max_actions: usize,
77 pub terminal_width: usize,
78}
79
80impl Default for HostCapabilities {
81 fn default() -> Self {
82 Self {
83 protocol: ProtocolVersion::current(),
84 forward_compatible_minor: false,
85 supported_components: ComponentKind::all_names()
86 .into_iter()
87 .map(str::to_owned)
88 .collect(),
89 supported_actions: [
90 ActionKind::LocalPresentation,
91 ActionKind::FormValueUpdate,
92 ActionKind::Navigation,
93 ActionKind::McpTool,
94 ActionKind::Consequential,
95 ]
96 .into_iter()
97 .collect(),
98 unsupported_component_policy: UnsupportedComponentPolicy::Placeholder,
99 max_depth: DEFAULT_MAX_DEPTH,
100 max_components: DEFAULT_MAX_COMPONENTS,
101 max_actions: DEFAULT_MAX_ACTIONS,
102 terminal_width: 100,
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct NegotiatedCapabilities {
110 pub protocol: ProtocolVersion,
111 pub unsupported_component_policy: UnsupportedComponentPolicy,
112 pub supported_components: BTreeSet<String>,
113 pub supported_actions: BTreeSet<ActionKind>,
114 pub terminal_width: usize,
115}
116
117pub fn negotiate(
118 offered: &[ProtocolVersion],
119 host: &HostCapabilities,
120) -> Result<NegotiatedCapabilities, GenUiError> {
121 let version = offered
122 .iter()
123 .copied()
124 .filter(|version| version.major == host.protocol.major)
125 .filter(|version| {
126 version.minor <= host.protocol.minor
127 || host.forward_compatible_minor && version.minor >= host.protocol.minor
128 })
129 .max()
130 .ok_or(GenUiError::UnsupportedProtocol {
131 offered: offered.to_vec(),
132 supported: host.protocol,
133 })?;
134 Ok(NegotiatedCapabilities {
135 protocol: version,
136 unsupported_component_policy: host.unsupported_component_policy,
137 supported_components: host.supported_components.clone(),
138 supported_actions: host.supported_actions.clone(),
139 terminal_width: host.terminal_width.max(1),
140 })
141}
142
143#[derive(Debug, Error, Clone, PartialEq, Eq)]
144pub enum GenUiError {
145 #[error("unsupported surface protocol; offered {offered:?}, host supports {supported:?}")]
146 UnsupportedProtocol {
147 offered: Vec<ProtocolVersion>,
148 supported: ProtocolVersion,
149 },
150 #[error("surface protocol identity is invalid: {0}")]
151 InvalidProtocol(String),
152 #[error("surface validation failed: {0}")]
153 InvalidSurface(String),
154 #[error("unsupported component {component:?}")]
155 UnsupportedComponent { component: String },
156 #[error("unsupported action kind {0:?}")]
157 UnsupportedAction(ActionKind),
158 #[error("action binding is stale: {0}")]
159 StaleAction(String),
160 #[error("action binding does not match the rendered label or payload")]
161 BindingMismatch,
162 #[error("consequential action {0:?} was already activated")]
163 DuplicateAction(String),
164 #[error("MCP schema is unsupported: {0}")]
165 UnsupportedMcpSchema(String),
166 #[error("MCP submission is invalid: {0}")]
167 InvalidMcpSubmission(String),
168 #[error("action is not authorized by the host catalog: {0}")]
169 UnauthorizedAction(String),
170 #[error("host confirmation is required for this action")]
171 ConfirmationRequired,
172 #[error("GenUI session event could not be persisted: {0}")]
173 EventStore(String),
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct Surface {
179 pub id: String,
180 pub protocol: ProtocolVersion,
181 pub schema: String,
182 pub root: Component,
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub actions: Vec<Action>,
185 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
186 pub metadata: BTreeMap<String, String>,
187}
188
189impl Surface {
190 #[must_use]
191 pub fn new(id: impl Into<String>, root: Component) -> Self {
192 Self {
193 id: id.into(),
194 protocol: ProtocolVersion::current(),
195 schema: SCHEMA_ID.to_owned(),
196 root,
197 actions: Vec::new(),
198 metadata: BTreeMap::new(),
199 }
200 }
201
202 pub fn validate(&self) -> Result<(), GenUiError> {
203 self.validate_with_catalog(&HostCapabilities::default(), &ActionCatalog::default())
204 }
205
206 pub fn validate_with(&self, host: &HostCapabilities) -> Result<(), GenUiError> {
207 self.validate_with_catalog(host, &ActionCatalog::default())
208 }
209
210 pub fn validate_structure(&self, host: &HostCapabilities) -> Result<(), GenUiError> {
215 self.validate_shape(host)
216 }
217
218 pub fn validate_with_catalog(
222 &self,
223 host: &HostCapabilities,
224 catalog: &ActionCatalog,
225 ) -> Result<(), GenUiError> {
226 self.validate_shape(host)?;
227 let surface_digest = self.binding_surface_digest()?;
228 for action in &self.actions {
229 catalog.validate_action(self, action, &surface_digest)?;
230 }
231 Ok(())
232 }
233
234 pub fn validate_for_host(
236 &self,
237 host: &HostCapabilities,
238 catalog: &ActionCatalog,
239 ) -> Result<(), GenUiError> {
240 self.validate_with_catalog(host, catalog)
241 }
242
243 fn validate_shape(&self, host: &HostCapabilities) -> Result<(), GenUiError> {
244 validate_id("surface", &self.id)?;
245 if self.schema != SCHEMA_ID {
246 return Err(GenUiError::InvalidProtocol(format!(
247 "unknown schema identity {:?}",
248 self.schema
249 )));
250 }
251 if self.protocol.major != host.protocol.major
252 || (self.protocol.minor > host.protocol.minor && !host.forward_compatible_minor)
253 {
254 return Err(GenUiError::UnsupportedProtocol {
255 offered: vec![self.protocol],
256 supported: host.protocol,
257 });
258 }
259 let mut component_ids = BTreeSet::new();
263 let mut count = 0usize;
264 validate_component(
265 &self.root,
266 1,
267 &mut count,
268 host.max_depth,
269 host.max_components,
270 &mut component_ids,
271 host,
272 )?;
273 if component_ids.contains(&self.id) {
274 return Err(GenUiError::InvalidSurface(format!(
275 "surface/component ID collision {:?}",
276 self.id
277 )));
278 }
279 if self.actions.len() > host.max_actions {
280 return Err(GenUiError::InvalidSurface(format!(
281 "action count {} exceeds bound {}",
282 self.actions.len(),
283 host.max_actions
284 )));
285 }
286 let mut action_ids = BTreeSet::new();
287 for action in &self.actions {
288 if !action_ids.insert(action.id.clone()) {
289 return Err(GenUiError::InvalidSurface(format!(
290 "duplicate action ID {:?}",
291 action.id
292 )));
293 }
294 if component_ids.contains(&action.id) {
295 return Err(GenUiError::InvalidSurface(format!(
296 "component/action ID collision {:?}",
297 action.id
298 )));
299 }
300 if self.id == action.id {
301 return Err(GenUiError::InvalidSurface(format!(
302 "surface/action ID collision {:?}",
303 action.id
304 )));
305 }
306 action.validate(&self.id, host)?;
307 }
308 Ok(())
309 }
310
311 pub fn canonical_json(&self) -> Result<Vec<u8>, GenUiError> {
312 self.validate_shape(&HostCapabilities::default())?;
316 canonical_json_bytes(self).map_err(|error| GenUiError::InvalidSurface(error.to_string()))
317 }
318
319 pub fn digest(&self) -> Result<String, GenUiError> {
320 self.binding_surface_digest()
325 }
326
327 fn binding_surface_digest(&self) -> Result<String, GenUiError> {
331 let mut structural = self.clone();
332 structural.actions.clear();
333 structural.canonical_json().map(|bytes| sha256_hex(&bytes))
334 }
335}
336
337#[derive(Debug, Clone, PartialEq, Serialize)]
338pub struct Component {
339 pub id: String,
340 #[serde(flatten)]
341 pub kind: ComponentKind,
342}
343
344impl<'de> Deserialize<'de> for Component {
345 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
346 where
347 D: serde::Deserializer<'de>,
348 {
349 let mut object = Map::<String, Value>::deserialize(deserializer)?;
350 let id = object
351 .remove("id")
352 .ok_or_else(|| serde::de::Error::missing_field("id"))?;
353 let id = String::deserialize(id).map_err(serde::de::Error::custom)?;
354 let kind =
355 ComponentKind::deserialize(Value::Object(object)).map_err(serde::de::Error::custom)?;
356 Ok(Self { id, kind })
357 }
358}
359
360impl Component {
361 #[must_use]
362 pub fn text(id: impl Into<String>, text: impl Into<String>) -> Self {
363 Self {
364 id: id.into(),
365 kind: ComponentKind::Text { text: text.into() },
366 }
367 }
368
369 #[must_use]
370 pub fn status(
371 id: impl Into<String>,
372 label: impl Into<String>,
373 value: impl Into<String>,
374 ) -> Self {
375 Self {
376 id: id.into(),
377 kind: ComponentKind::Status {
378 label: label.into(),
379 value: value.into(),
380 level: StatusLevel::Info,
381 },
382 }
383 }
384}
385
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
388pub enum ComponentKind {
389 Text {
390 text: String,
391 },
392 Markdown {
393 markdown: String,
394 },
395 Status {
396 label: String,
397 value: String,
398 level: StatusLevel,
399 },
400 Progress {
401 label: String,
402 current: u64,
403 total: Option<u64>,
404 },
405 Table {
406 columns: Vec<String>,
407 rows: Vec<Vec<String>>,
408 },
409 KeyValue {
410 entries: BTreeMap<String, String>,
411 },
412 Diff {
413 before: String,
414 after: String,
415 },
416 TestResults {
417 passed: u64,
418 failed: u64,
419 skipped: u64,
420 details: Vec<String>,
421 },
422 Form {
423 fields: Vec<FormField>,
424 },
425 Choice {
426 label: String,
427 options: Vec<ChoiceOption>,
428 selected: Option<String>,
429 },
430 Evidence {
431 title: String,
432 items: Vec<EvidenceItem>,
433 },
434 Timeline {
435 entries: Vec<TimelineEntry>,
436 },
437 Stack {
438 children: Vec<Component>,
439 },
440 Columns {
441 columns: Vec<Vec<Component>>,
442 },
443}
444
445impl ComponentKind {
446 #[must_use]
447 pub fn name(&self) -> &'static str {
448 match self {
449 Self::Text { .. } => "text",
450 Self::Markdown { .. } => "markdown",
451 Self::Status { .. } => "status",
452 Self::Progress { .. } => "progress",
453 Self::Table { .. } => "table",
454 Self::KeyValue { .. } => "key_value",
455 Self::Diff { .. } => "diff",
456 Self::TestResults { .. } => "test_results",
457 Self::Form { .. } => "form",
458 Self::Choice { .. } => "choice",
459 Self::Evidence { .. } => "evidence",
460 Self::Timeline { .. } => "timeline",
461 Self::Stack { .. } => "stack",
462 Self::Columns { .. } => "columns",
463 }
464 }
465
466 #[must_use]
467 pub fn all_names() -> Vec<&'static str> {
468 vec![
469 "text",
470 "markdown",
471 "status",
472 "progress",
473 "table",
474 "key_value",
475 "diff",
476 "test_results",
477 "form",
478 "choice",
479 "evidence",
480 "timeline",
481 "stack",
482 "columns",
483 ]
484 }
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
488#[serde(rename_all = "snake_case")]
489pub enum StatusLevel {
490 Info,
491 Success,
492 Warning,
493 Error,
494}
495
496#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
497#[serde(deny_unknown_fields)]
498pub struct FormField {
499 pub id: String,
500 pub label: String,
501 pub field_type: FieldType,
502 pub required: bool,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub description: Option<String>,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub value: Option<Value>,
507 #[serde(default, skip_serializing_if = "Vec::is_empty")]
508 pub choices: Vec<String>,
509 #[serde(default, skip_serializing_if = "Vec::is_empty")]
513 pub choice_values: Vec<Value>,
514 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
515 pub constraints: BTreeMap<String, Value>,
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
519#[serde(rename_all = "snake_case")]
520pub enum FieldType {
521 String,
522 Number,
523 Integer,
524 Boolean,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528#[serde(deny_unknown_fields)]
529pub struct ChoiceOption {
530 pub value: String,
531 pub label: String,
532}
533
534#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
535#[serde(deny_unknown_fields)]
536pub struct EvidenceItem {
537 pub label: String,
538 pub value: String,
539}
540
541#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
542#[serde(deny_unknown_fields)]
543pub struct TimelineEntry {
544 pub label: String,
545 pub detail: String,
546 pub state: TimelineState,
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
550#[serde(rename_all = "snake_case")]
551pub enum TimelineState {
552 Pending,
553 Active,
554 Complete,
555 Failed,
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
559#[serde(rename_all = "snake_case")]
560pub enum ActionKind {
561 LocalPresentation,
562 FormValueUpdate,
563 Navigation,
564 McpTool,
565 Consequential,
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
572#[serde(rename_all = "snake_case")]
573pub enum ActionSourceType {
574 Auto,
575 Mcp,
576 HostLocal,
577}
578
579#[must_use]
584pub const fn action_kind_source_compatible(
585 action_kind: ActionKind,
586 source_type: ActionSourceType,
587) -> bool {
588 matches!(
589 (action_kind, source_type),
590 (ActionKind::McpTool, ActionSourceType::Mcp)
591 | (ActionKind::Consequential, ActionSourceType::Mcp)
592 | (ActionKind::Consequential, ActionSourceType::HostLocal)
593 | (ActionKind::Navigation, ActionSourceType::HostLocal)
594 | (ActionKind::FormValueUpdate, ActionSourceType::HostLocal)
595 | (ActionKind::LocalPresentation, ActionSourceType::HostLocal)
596 )
597}
598
599#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
602#[serde(deny_unknown_fields)]
603pub struct Action {
604 pub id: String,
605 pub label: String,
606 pub kind: ActionKind,
607 pub state_digest: String,
608}
609
610#[derive(Debug, Clone, PartialEq, Eq)]
612pub struct ResolvedActionBinding {
613 action_id: String,
614 action_kind: ActionKind,
615 source_type: ActionSourceType,
616 label_digest: String,
617 surface_id: String,
618 surface_digest: String,
619 state_digest: String,
620 state_generation: u64,
621 principal: String,
622 authorization_context: String,
623 session_id: String,
624 provider_id: String,
625 server_id: String,
626 tool_name: String,
627 remote_tool_name: String,
628 schema_digest: String,
629 policy_version: u64,
630 policy_digest: String,
631 requires_confirmation: bool,
632}
633
634impl ResolvedActionBinding {
635 #[must_use]
636 pub fn action_id(&self) -> &str {
637 &self.action_id
638 }
639 #[must_use]
640 pub fn action_kind(&self) -> ActionKind {
641 self.action_kind
642 }
643 #[must_use]
644 pub fn source_type(&self) -> ActionSourceType {
645 self.source_type
646 }
647 #[must_use]
648 pub fn label_digest(&self) -> &str {
649 &self.label_digest
650 }
651 #[must_use]
652 pub fn surface_id(&self) -> &str {
653 &self.surface_id
654 }
655 #[must_use]
656 pub fn surface_digest(&self) -> &str {
657 &self.surface_digest
658 }
659 #[must_use]
660 pub fn state_digest(&self) -> &str {
661 &self.state_digest
662 }
663 #[must_use]
664 pub fn state_generation(&self) -> u64 {
665 self.state_generation
666 }
667 #[must_use]
668 pub fn principal(&self) -> &str {
669 &self.principal
670 }
671 #[must_use]
672 pub fn authorization_context(&self) -> &str {
673 &self.authorization_context
674 }
675 #[must_use]
676 pub fn session_id(&self) -> &str {
677 &self.session_id
678 }
679 #[must_use]
680 pub fn provider_id(&self) -> &str {
681 &self.provider_id
682 }
683 #[must_use]
684 pub fn server_id(&self) -> &str {
685 &self.server_id
686 }
687 #[must_use]
688 pub fn tool_name(&self) -> &str {
689 &self.tool_name
690 }
691 #[must_use]
692 pub fn remote_tool_name(&self) -> &str {
693 &self.remote_tool_name
694 }
695 #[must_use]
696 pub fn schema_digest(&self) -> &str {
697 &self.schema_digest
698 }
699 #[must_use]
700 pub fn policy_version(&self) -> u64 {
701 self.policy_version
702 }
703 #[must_use]
704 pub fn policy_digest(&self) -> &str {
705 &self.policy_digest
706 }
707 #[must_use]
708 pub fn requires_confirmation(&self) -> bool {
709 self.requires_confirmation
710 }
711}
712
713#[derive(Debug, Clone)]
714pub struct HostConfirmation {
715 identity: Value,
716 payload: Value,
717}
718
719impl HostConfirmation {
720 pub(crate) fn identity(&self) -> &Value {
721 &self.identity
722 }
723
724 pub(crate) fn payload(&self) -> &Value {
725 &self.payload
726 }
727}
728
729#[derive(Debug, Clone)]
732pub struct ActionCatalog {
733 bindings: BTreeMap<String, ResolvedActionBinding>,
734 current_states: BTreeMap<(String, String), HostStateIdentity>,
735 durable_state_keys: BTreeSet<(String, String)>,
740 action_owners: BTreeMap<String, String>,
741 generation: Arc<AtomicU64>,
744 base_generation: u64,
747 mutation_depth: u8,
748 mutation_changed: bool,
749}
750
751impl Default for ActionCatalog {
752 fn default() -> Self {
753 Self {
754 bindings: BTreeMap::new(),
755 current_states: BTreeMap::new(),
756 durable_state_keys: BTreeSet::new(),
757 action_owners: BTreeMap::new(),
758 generation: Arc::new(AtomicU64::new(1)),
759 base_generation: 1,
760 mutation_depth: 0,
761 mutation_changed: false,
762 }
763 }
764}
765
766#[derive(Debug, Clone, PartialEq, Eq)]
767struct HostStateIdentity {
768 generation: u64,
769 digest: String,
770 authorization_context: String,
771}
772
773impl ActionCatalog {
774 #[must_use]
775 pub fn generation(&self) -> u64 {
776 self.generation.load(Ordering::Acquire)
777 }
778
779 #[must_use]
780 pub(crate) fn base_generation(&self) -> u64 {
781 self.base_generation
782 }
783
784 #[must_use]
788 pub(crate) fn clone_for_staging(&self) -> Self {
789 let mut staged = self.clone();
790 let generation = self.generation();
791 staged.generation = Arc::new(AtomicU64::new(generation));
792 staged.base_generation = generation;
793 staged.mutation_depth = 0;
794 staged.mutation_changed = false;
795 staged
796 }
797
798 pub(crate) fn publish_staged(&mut self, staged: Self) {
799 let next_generation = self.generation().saturating_add(1);
800 self.bindings = staged.bindings;
805 self.current_states = staged.current_states;
806 self.durable_state_keys = staged.durable_state_keys;
807 self.action_owners = staged.action_owners;
808 self.base_generation = next_generation;
809 self.mutation_depth = 0;
810 self.mutation_changed = false;
811 self.generation.store(next_generation, Ordering::Release);
812 }
813
814 fn bump_generation(&mut self) {
815 if self.mutation_depth > 0 {
816 self.mutation_changed = true;
817 return;
818 }
819 let _ = self
820 .generation
821 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
822 Some(value.saturating_add(1))
823 });
824 }
825
826 fn with_mutation<R>(
827 &mut self,
828 operation: impl FnOnce(&mut Self) -> Result<R, GenUiError>,
829 ) -> Result<R, GenUiError> {
830 let original_generation = self.generation();
834 let live_catalog = original_generation == self.base_generation;
835 let original_bindings = self.bindings.clone();
836 let original_states = self.current_states.clone();
837 let original_durable_keys = self.durable_state_keys.clone();
838 let original_owners = self.action_owners.clone();
839 let mut staged = self.clone_for_staging();
840 staged.mutation_depth = 0;
841 staged.mutation_changed = false;
842 let result = operation(&mut staged);
843 let Ok(value) = result else {
844 return result;
846 };
847 let changed = staged.bindings != original_bindings
848 || staged.current_states != original_states
849 || staged.durable_state_keys != original_durable_keys
850 || staged.action_owners != original_owners;
851 if changed {
852 self.bindings = staged.bindings;
853 self.current_states = staged.current_states;
854 self.durable_state_keys = staged.durable_state_keys;
855 self.action_owners = staged.action_owners;
856 if live_catalog {
857 self.base_generation = original_generation.saturating_add(1);
858 }
859 let _ = self
860 .generation
861 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
862 Some(value.max(original_generation).saturating_add(1))
867 });
868 }
869 self.mutation_depth = 0;
870 self.mutation_changed = false;
871 Ok(value)
872 }
873
874 pub fn validate_source_type(
875 action_kind: ActionKind,
876 source_type: ActionSourceType,
877 ) -> Result<(), GenUiError> {
878 if action_kind_source_compatible(action_kind, source_type) {
879 Ok(())
880 } else {
881 Err(GenUiError::UnauthorizedAction(format!(
882 "invalid action kind/source type pair: {action_kind:?}/{source_type:?}"
883 )))
884 }
885 }
886
887 #[must_use]
888 pub fn new_action_id() -> String {
889 format!("gui_action_{}", Uuid::new_v4().simple())
890 }
891
892 #[allow(clippy::too_many_arguments)]
897 pub fn bind_mcp_action_with_remote(
898 &mut self,
899 store: &EventStore,
900 action: &Action,
901 surface_id: &str,
902 surface_digest: &str,
903 session_id: &str,
904 principal: &str,
905 provider_id: &str,
906 server_id: &str,
907 exposed_tool_name: &str,
908 remote_tool_name: &str,
909 schema_digest: &str,
910 requires_confirmation: bool,
911 ) -> Result<(), GenUiError> {
912 Self::validate_source_type(action.kind, ActionSourceType::Mcp)?;
916 let Some((state_generation, state_digest, authorization_context)) = store
917 .genui_current_state(session_id, principal)
918 .map_err(event_error)?
919 else {
920 return Err(GenUiError::StaleAction(
921 "a durable trusted current state must exist before binding".to_owned(),
922 ));
923 };
924 if state_digest != action.state_digest {
925 return Err(GenUiError::StaleAction(
926 "action state digest does not match durable trusted state".to_owned(),
927 ));
928 }
929 validate_id("remote tool", remote_tool_name)?;
930 let policy_digest = sha256_hex(
931 format!(
932 "falsegreen.tool-policy.v1|{}|1",
933 if requires_confirmation {
934 "consequential"
935 } else {
936 "read_only"
937 }
938 )
939 .as_bytes(),
940 );
941 self.with_mutation(|catalog| {
942 catalog.bind_action_with_source(
943 action,
944 surface_id,
945 surface_digest,
946 session_id,
947 principal,
948 &authorization_context,
949 state_generation,
950 &state_digest,
951 provider_id,
952 server_id,
953 exposed_tool_name,
954 schema_digest,
955 1,
956 &policy_digest,
957 requires_confirmation,
958 ActionSourceType::Mcp,
959 )?;
960 catalog.set_current_state(
961 session_id,
962 principal,
963 state_generation,
964 &state_digest,
965 &authorization_context,
966 )?;
967 catalog.set_remote_tool_name(action.id.as_str(), remote_tool_name)
968 })
969 }
970
971 #[allow(clippy::too_many_arguments)]
972 pub(crate) fn bind_mcp_action_with_context(
973 &mut self,
974 action: &Action,
975 surface_id: &str,
976 surface_digest: &str,
977 session_id: &str,
978 principal: &str,
979 authorization_context: &str,
980 state_generation: u64,
981 state_digest: &str,
982 provider_id: &str,
983 server_id: &str,
984 tool_name: &str,
985 schema_digest: &str,
986 policy_version: u64,
987 policy_digest: &str,
988 requires_confirmation: bool,
989 source_type: ActionSourceType,
990 ) -> Result<(), GenUiError> {
991 self.bind_action_with_source(
995 action,
996 surface_id,
997 surface_digest,
998 session_id,
999 principal,
1000 authorization_context,
1001 state_generation,
1002 state_digest,
1003 provider_id,
1004 server_id,
1005 tool_name,
1006 schema_digest,
1007 policy_version,
1008 policy_digest,
1009 requires_confirmation,
1010 source_type,
1011 )
1012 }
1013
1014 #[allow(clippy::too_many_arguments)]
1018 pub fn bind_host_local_action_with_context(
1019 &mut self,
1020 action: &Action,
1021 surface_id: &str,
1022 surface_digest: &str,
1023 session_id: &str,
1024 principal: &str,
1025 authorization_context: &str,
1026 state_generation: u64,
1027 state_digest: &str,
1028 provider_id: &str,
1029 server_id: &str,
1030 tool_name: &str,
1031 schema_digest: &str,
1032 policy_version: u64,
1033 policy_digest: &str,
1034 requires_confirmation: bool,
1035 ) -> Result<(), GenUiError> {
1036 self.bind_action_with_source(
1037 action,
1038 surface_id,
1039 surface_digest,
1040 session_id,
1041 principal,
1042 authorization_context,
1043 state_generation,
1044 state_digest,
1045 provider_id,
1046 server_id,
1047 tool_name,
1048 schema_digest,
1049 policy_version,
1050 policy_digest,
1051 requires_confirmation,
1052 ActionSourceType::HostLocal,
1053 )
1054 }
1055
1056 #[allow(clippy::too_many_arguments)]
1060 pub(crate) fn bind_action_with_source(
1061 &mut self,
1062 action: &Action,
1063 surface_id: &str,
1064 surface_digest: &str,
1065 session_id: &str,
1066 principal: &str,
1067 authorization_context: &str,
1068 state_generation: u64,
1069 state_digest: &str,
1070 provider_id: &str,
1071 server_id: &str,
1072 tool_name: &str,
1073 schema_digest: &str,
1074 policy_version: u64,
1075 policy_digest: &str,
1076 requires_confirmation: bool,
1077 source_type: ActionSourceType,
1078 ) -> Result<(), GenUiError> {
1079 Self::validate_source_type(action.kind, source_type)?;
1080 action.validate(surface_id, &HostCapabilities::default())?;
1081 let label_digest = sha256_hex(normalize_action_label(&action.label)?.as_bytes());
1082 validate_digest("surface", surface_digest)?;
1083 validate_digest("schema", schema_digest)?;
1084 validate_digest("policy", policy_digest)?;
1085 if state_digest != action.state_digest {
1086 return Err(GenUiError::StaleAction(
1087 "host state digest does not match the presented action".to_owned(),
1088 ));
1089 }
1090 for (name, value) in [
1091 ("session", session_id),
1092 ("principal", principal),
1093 ("authorization context", authorization_context),
1094 ("provider", provider_id),
1095 ("server", server_id),
1096 ("tool", tool_name),
1097 ] {
1098 validate_id(name, value)?;
1099 }
1100 if requires_confirmation != (action.kind == ActionKind::Consequential) {
1101 return Err(GenUiError::UnauthorizedAction(
1102 "host policy and action kind disagree".to_owned(),
1103 ));
1104 }
1105 let state_key = (session_id.to_owned(), principal.to_owned());
1106 if let Some(current) = self.current_states.get(&state_key) {
1107 if state_generation > current.generation {
1108 return Err(GenUiError::StaleAction(
1109 "binding cannot advance trusted state generation".to_owned(),
1110 ));
1111 }
1112 if state_generation == current.generation
1113 && (state_digest != current.digest
1114 || authorization_context != current.authorization_context)
1115 {
1116 return Err(GenUiError::StaleAction(
1117 "binding state identity does not match the current trusted state".to_owned(),
1118 ));
1119 }
1120 }
1121 self.bindings.insert(
1122 action.id.clone(),
1123 ResolvedActionBinding {
1124 action_id: action.id.clone(),
1125 action_kind: action.kind,
1126 source_type,
1127 label_digest,
1128 surface_id: surface_id.to_owned(),
1129 surface_digest: surface_digest.to_owned(),
1130 state_digest: state_digest.to_owned(),
1131 state_generation,
1132 principal: principal.to_owned(),
1133 authorization_context: authorization_context.to_owned(),
1134 session_id: session_id.to_owned(),
1135 provider_id: provider_id.to_owned(),
1136 server_id: server_id.to_owned(),
1137 tool_name: tool_name.to_owned(),
1138 remote_tool_name: tool_name.to_owned(),
1139 schema_digest: schema_digest.to_owned(),
1140 policy_version,
1141 policy_digest: policy_digest.to_owned(),
1142 requires_confirmation,
1143 },
1144 );
1145 self.action_owners
1146 .entry(action.id.clone())
1147 .or_insert_with(|| "__surface_root__".to_owned());
1148 self.bump_generation();
1149 Ok(())
1150 }
1151
1152 pub(crate) fn set_current_state(
1157 &mut self,
1158 session_id: &str,
1159 principal: &str,
1160 generation: u64,
1161 digest: &str,
1162 authorization_context: &str,
1163 ) -> Result<(), GenUiError> {
1164 validate_id("session", session_id)?;
1165 validate_id("principal", principal)?;
1166 validate_id("authorization context", authorization_context)?;
1167 if digest.is_empty() {
1168 return Err(GenUiError::StaleAction(
1169 "trusted state digest must not be empty".to_owned(),
1170 ));
1171 }
1172 let key = (session_id.to_owned(), principal.to_owned());
1173 if let Some(previous) = self.current_states.get(&key) {
1174 if generation < previous.generation {
1175 return Err(GenUiError::StaleAction(
1176 "trusted state generation cannot rewind".to_owned(),
1177 ));
1178 }
1179 if generation == previous.generation
1180 && (digest != previous.digest
1181 || authorization_context != previous.authorization_context)
1182 {
1183 return Err(GenUiError::StaleAction(
1184 "trusted state identity cannot change without advancing generation".to_owned(),
1185 ));
1186 }
1187 }
1188 let next = HostStateIdentity {
1189 generation,
1190 digest: digest.to_owned(),
1191 authorization_context: authorization_context.to_owned(),
1192 };
1193 let changed =
1194 self.current_states.get(&key) != Some(&next) || !self.durable_state_keys.contains(&key);
1195 self.current_states.insert(key.clone(), next);
1196 self.durable_state_keys.insert(key);
1200 if changed {
1201 self.bump_generation();
1202 }
1203 Ok(())
1204 }
1205
1206 pub fn restore_current_state(
1210 &mut self,
1211 store: &EventStore,
1212 session_id: &str,
1213 principal: &str,
1214 ) -> Result<bool, GenUiError> {
1215 let Some((generation, digest, authorization_context)) = store
1216 .genui_current_state(session_id, principal)
1217 .map_err(event_error)?
1218 else {
1219 return Ok(false);
1220 };
1221 self.set_current_state(
1222 session_id,
1223 principal,
1224 generation,
1225 &digest,
1226 &authorization_context,
1227 )?;
1228 self.durable_state_keys
1229 .insert((session_id.to_owned(), principal.to_owned()));
1230 Ok(true)
1231 }
1232
1233 pub fn bind_action_owner(
1237 &mut self,
1238 action_id: &str,
1239 component_id: &str,
1240 ) -> Result<(), GenUiError> {
1241 if !self.bindings.contains_key(action_id) {
1242 return Err(GenUiError::UnauthorizedAction(
1243 "action ID is not present in host catalog".to_owned(),
1244 ));
1245 }
1246 validate_id("component", component_id)?;
1247 if let Some(existing) = self.action_owners.get(action_id)
1248 && existing != component_id
1249 && existing != "__surface_root__"
1250 {
1251 return Err(GenUiError::UnauthorizedAction(
1252 "action owner is already bound to a different component".to_owned(),
1253 ));
1254 }
1255 let changed = self.action_owners.get(action_id).map(String::as_str) != Some(component_id);
1256 self.action_owners
1257 .insert(action_id.to_owned(), component_id.to_owned());
1258 if changed {
1259 self.bump_generation();
1260 }
1261 Ok(())
1262 }
1263
1264 pub(crate) fn set_remote_tool_name(
1265 &mut self,
1266 action_id: &str,
1267 remote_tool_name: &str,
1268 ) -> Result<(), GenUiError> {
1269 validate_id("remote tool", remote_tool_name)?;
1270 let binding = self
1271 .bindings
1272 .get_mut(action_id)
1273 .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1274 if binding.remote_tool_name != remote_tool_name {
1275 binding.remote_tool_name = remote_tool_name.to_owned();
1276 self.bump_generation();
1277 }
1278 Ok(())
1279 }
1280
1281 pub fn validate_surface(
1282 &self,
1283 surface: &Surface,
1284 host: &HostCapabilities,
1285 ) -> Result<(), GenUiError> {
1286 surface.validate_shape(host)?;
1287 let digest = surface.binding_surface_digest()?;
1288 for action in &surface.actions {
1289 self.validate_action(surface, action, &digest)?;
1290 }
1291 Ok(())
1292 }
1293
1294 pub(crate) fn admit_rendered_surface(
1298 &mut self,
1299 surface: &Surface,
1300 capabilities: &NegotiatedCapabilities,
1301 ) -> Result<(), GenUiError> {
1302 self.with_mutation(|catalog| catalog.admit_rendered_surface_inner(surface, capabilities))
1303 }
1304
1305 fn admit_rendered_surface_inner(
1306 &mut self,
1307 surface: &Surface,
1308 capabilities: &NegotiatedCapabilities,
1309 ) -> Result<(), GenUiError> {
1310 let host = HostCapabilities {
1311 protocol: capabilities.protocol,
1312 supported_components: capabilities.supported_components.clone(),
1313 supported_actions: capabilities.supported_actions.clone(),
1314 unsupported_component_policy: capabilities.unsupported_component_policy,
1315 terminal_width: capabilities.terminal_width.max(1),
1316 ..HostCapabilities::default()
1317 };
1318 surface.validate_shape(&host)?;
1319 let surface_digest = surface.binding_surface_digest()?;
1320 let unsupported = unsupported_component_ids(&surface.root, &host.supported_components);
1321 for action in &surface.actions {
1322 let owned_by_unsupported =
1323 self.action_owners
1324 .get(&action.id)
1325 .map_or(!unsupported.is_empty(), |owner| {
1326 if owner == "__surface_root__" {
1327 unsupported.contains(&surface.root.id)
1328 } else {
1329 unsupported.iter().any(|root| {
1330 root == owner || component_contains_id(&surface.root, root, owner)
1331 })
1332 }
1333 });
1334 let has_valid_durable_state = self.bindings.get(&action.id).is_some_and(|binding| {
1335 self.durable_state_keys
1336 .contains(&(binding.session_id.clone(), binding.principal.clone()))
1337 && self.current_state_matches(binding)
1338 });
1339 if !capabilities.supported_actions.contains(&action.kind)
1340 || owned_by_unsupported
1341 || !has_valid_durable_state
1342 || self
1343 .validate_action(surface, action, &surface_digest)
1344 .is_err()
1345 {
1346 self.remove(&action.id);
1347 }
1348 }
1349 Ok(())
1350 }
1351
1352 pub fn admit_rendered_surface_with_store(
1356 &mut self,
1357 surface: &Surface,
1358 capabilities: &NegotiatedCapabilities,
1359 store: &EventStore,
1360 ) -> Result<(), GenUiError> {
1361 self.with_mutation(|catalog| {
1362 catalog.admit_rendered_surface_with_store_inner(surface, capabilities, store)
1363 })
1364 }
1365
1366 fn admit_rendered_surface_with_store_inner(
1367 &mut self,
1368 surface: &Surface,
1369 capabilities: &NegotiatedCapabilities,
1370 store: &EventStore,
1371 ) -> Result<(), GenUiError> {
1372 for action in &surface.actions {
1377 let Some(binding) = self.bindings.get(&action.id).cloned() else {
1378 continue;
1379 };
1380 if let Some((generation, digest, authorization_context)) = store
1381 .genui_current_state(&binding.session_id, &binding.principal)
1382 .map_err(event_error)?
1383 {
1384 let _ = self.set_current_state(
1385 &binding.session_id,
1386 &binding.principal,
1387 generation,
1388 &digest,
1389 &authorization_context,
1390 );
1391 }
1392 }
1393 self.admit_rendered_surface_inner(surface, capabilities)?;
1394 let surface_digest = surface.binding_surface_digest()?;
1395 let action_ids = surface
1396 .actions
1397 .iter()
1398 .map(|action| action.id.clone())
1399 .collect::<Vec<_>>();
1400 for action_id in action_ids {
1401 let valid = self.bindings.get(&action_id).is_some_and(|_| {
1402 surface
1403 .actions
1404 .iter()
1405 .find(|action| action.id == action_id)
1406 .is_some_and(|action| {
1407 self.validate_action(surface, action, &surface_digest)
1408 .is_ok()
1409 && self
1410 .validate_durable_current_action(store, &action_id)
1411 .is_ok()
1412 })
1413 });
1414 if !valid {
1415 self.remove(&action_id);
1416 } else if let Some(binding) = self.bindings.get(&action_id) {
1417 self.durable_state_keys
1418 .insert((binding.session_id.clone(), binding.principal.clone()));
1419 }
1420 }
1421 Ok(())
1422 }
1423
1424 pub fn validate_action(
1425 &self,
1426 surface: &Surface,
1427 action: &Action,
1428 surface_digest: &str,
1429 ) -> Result<(), GenUiError> {
1430 action.validate(&surface.id, &HostCapabilities::default())?;
1431 let binding = self.bindings.get(&action.id).ok_or_else(|| {
1432 GenUiError::UnauthorizedAction("action ID is not present in host catalog".to_owned())
1433 })?;
1434 Self::validate_source_type(binding.action_kind, binding.source_type)?;
1435 let label = normalize_action_label(&action.label)?;
1436 if binding.action_id != action.id
1437 || binding.action_kind != action.kind
1438 || binding.label_digest != sha256_hex(label.as_bytes())
1439 || binding.surface_id != surface.id
1440 || binding.surface_digest != surface_digest
1441 || binding.state_digest != action.state_digest
1442 || label.is_empty()
1443 || binding.requires_confirmation != (action.kind == ActionKind::Consequential)
1444 || !self
1445 .current_states
1446 .get(&(binding.session_id.clone(), binding.principal.clone()))
1447 .is_some_and(|state| {
1448 state.generation == binding.state_generation
1449 && state.digest == binding.state_digest
1450 && state.authorization_context == binding.authorization_context
1451 })
1452 {
1453 return Err(GenUiError::StaleAction(
1454 "host binding no longer matches rendered action".to_owned(),
1455 ));
1456 }
1457 Ok(())
1458 }
1459
1460 #[must_use]
1461 pub fn resolve(&self, action_id: &str) -> Option<&ResolvedActionBinding> {
1462 self.bindings.get(action_id)
1463 }
1464
1465 pub fn remove(&mut self, action_id: &str) -> bool {
1466 let owner_removed = self.action_owners.remove(action_id).is_some();
1467 let binding_removed = self.bindings.remove(action_id).is_some();
1468 if owner_removed || binding_removed {
1469 self.bump_generation();
1470 }
1471 binding_removed
1472 }
1473
1474 pub fn clear(&mut self) {
1476 if self.bindings.is_empty()
1477 && self.current_states.is_empty()
1478 && self.durable_state_keys.is_empty()
1479 && self.action_owners.is_empty()
1480 {
1481 return;
1482 }
1483 self.bindings.clear();
1484 self.current_states.clear();
1485 self.durable_state_keys.clear();
1486 self.action_owners.clear();
1487 self.bump_generation();
1488 }
1489
1490 #[must_use]
1491 pub fn digest(&self) -> String {
1492 let entries: Vec<Value> = self
1493 .bindings
1494 .values()
1495 .map(|b| {
1496 serde_json::json!({
1497 "action_id": b.action_id,
1498 "action_kind": b.action_kind,
1499 "source_type": b.source_type,
1500 "label_digest": b.label_digest,
1501 "surface_id": b.surface_id,
1502 "surface_digest": b.surface_digest,
1503 "state_digest": b.state_digest,
1504 "state_generation": b.state_generation,
1505 "principal": b.principal,
1506 "authorization_context": b.authorization_context,
1507 "session_id": b.session_id,
1508 "provider_id": b.provider_id,
1509 "server_id": b.server_id,
1510 "tool_name": b.tool_name,
1511 "remote_tool_name": b.remote_tool_name,
1512 "schema_digest": b.schema_digest,
1513 "policy_version": b.policy_version,
1514 "policy_digest": b.policy_digest,
1515 "requires_confirmation": b.requires_confirmation,
1516 "owner_component_id": self.action_owners.get(&b.action_id),
1517 })
1518 })
1519 .collect();
1520 digest_json(&entries)
1521 }
1522
1523 pub fn present_action(
1524 &self,
1525 store: &mut EventStore,
1526 session_id: &str,
1527 action_id: &str,
1528 ) -> Result<(), GenUiError> {
1529 let binding = self
1530 .bindings
1531 .get(action_id)
1532 .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1533 if binding.session_id != session_id {
1534 return Err(GenUiError::UnauthorizedAction(
1535 "session is not bound to this action".to_owned(),
1536 ));
1537 }
1538 if !self.current_state_matches(binding) {
1539 return Err(GenUiError::StaleAction("trusted state changed".to_owned()));
1540 }
1541 self.validate_durable_current_action(store, action_id)?;
1542 store
1543 .append_genui_lifecycle(
1544 session_id,
1545 EventKind::GenUiActionPresented,
1546 &identity_payload(binding, None),
1547 )
1548 .map(|_| ())
1549 .map_err(event_error)
1550 }
1551
1552 pub fn request_confirmation(
1553 &self,
1554 store: &mut EventStore,
1555 session_id: &str,
1556 action_id: &str,
1557 payload: &Value,
1558 ) -> Result<HostConfirmation, GenUiError> {
1559 let binding = self
1560 .bindings
1561 .get(action_id)
1562 .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1563 if !binding.requires_confirmation {
1564 return Err(GenUiError::UnauthorizedAction(
1565 "read-only action does not require confirmation".to_owned(),
1566 ));
1567 }
1568 if binding.session_id != session_id {
1569 return Err(GenUiError::UnauthorizedAction(
1570 "session is not bound to this action".to_owned(),
1571 ));
1572 }
1573 if !self.current_state_matches(binding) {
1574 return Err(GenUiError::StaleAction("trusted state changed".to_owned()));
1575 }
1576 self.validate_durable_current_action(store, action_id)?;
1577 let identity = identity_payload(binding, Some(payload));
1578 store
1579 .append_genui_lifecycle(
1580 session_id,
1581 EventKind::GenUiActionConfirmationRequested,
1582 &identity,
1583 )
1584 .map_err(event_error)?;
1585 Ok(HostConfirmation {
1586 identity,
1587 payload: payload.clone(),
1588 })
1589 }
1590
1591 pub(crate) fn append_confirmed_after_live_validation(
1592 &self,
1593 store: &mut EventStore,
1594 confirmation: &HostConfirmation,
1595 ) -> Result<(), GenUiError> {
1596 self.validate_confirmation_identity(confirmation)?;
1597 let action_id = confirmation
1598 .identity
1599 .get("action_id")
1600 .and_then(Value::as_str)
1601 .ok_or(GenUiError::ConfirmationRequired)?;
1602 if confirmation
1603 .identity
1604 .get("payload_digest")
1605 .and_then(Value::as_str)
1606 != Some(digest_json(&confirmation.payload).as_str())
1607 {
1608 return Err(GenUiError::ConfirmationRequired);
1609 }
1610 self.validate_durable_current_action(store, action_id)?;
1611 let session_id = confirmation
1612 .identity
1613 .get("session_id")
1614 .and_then(Value::as_str)
1615 .ok_or(GenUiError::ConfirmationRequired)?;
1616 store
1617 .append_genui_lifecycle(
1618 session_id,
1619 EventKind::GenUiActionConfirmed,
1620 &confirmation.identity,
1621 )
1622 .map(|_| ())
1623 .map_err(event_error)
1624 }
1625
1626 pub(crate) fn validate_confirmation_identity(
1627 &self,
1628 confirmation: &HostConfirmation,
1629 ) -> Result<&ResolvedActionBinding, GenUiError> {
1630 let session_id = confirmation
1631 .identity
1632 .get("session_id")
1633 .and_then(Value::as_str)
1634 .ok_or(GenUiError::ConfirmationRequired)?;
1635 let action_id = confirmation
1636 .identity
1637 .get("action_id")
1638 .and_then(Value::as_str)
1639 .ok_or(GenUiError::ConfirmationRequired)?;
1640 let binding = self
1641 .bindings
1642 .get(action_id)
1643 .ok_or(GenUiError::ConfirmationRequired)?;
1644 let expected = identity_payload(binding, None);
1645 let identity_matches = expected.as_object().is_some_and(|expected| {
1646 confirmation.identity.as_object().is_some_and(|actual| {
1647 expected
1648 .iter()
1649 .all(|(key, value)| actual.get(key) == Some(value))
1650 })
1651 });
1652 if binding.session_id != session_id
1653 || !identity_matches
1654 || confirmation
1655 .identity
1656 .get("payload_digest")
1657 .and_then(Value::as_str)
1658 .is_none()
1659 {
1660 return Err(GenUiError::ConfirmationRequired);
1661 }
1662 if !self.current_state_matches(binding) {
1663 return Err(GenUiError::ConfirmationRequired);
1664 }
1665 Ok(binding)
1666 }
1667
1668 pub fn identity_for(&self, action_id: &str, payload: &Value) -> Result<Value, GenUiError> {
1669 let binding = self
1670 .bindings
1671 .get(action_id)
1672 .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1673 Ok(identity_payload(binding, Some(payload)))
1674 }
1675
1676 fn current_state_matches(&self, binding: &ResolvedActionBinding) -> bool {
1677 self.current_states
1678 .get(&(binding.session_id.clone(), binding.principal.clone()))
1679 .is_some_and(|state| {
1680 state.generation == binding.state_generation
1681 && state.digest == binding.state_digest
1682 && state.authorization_context == binding.authorization_context
1683 })
1684 }
1685
1686 pub fn validate_current_action(&self, action_id: &str) -> Result<(), GenUiError> {
1687 let binding = self
1688 .bindings
1689 .get(action_id)
1690 .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1691 if self.current_state_matches(binding) {
1692 Ok(())
1693 } else {
1694 Err(GenUiError::StaleAction(
1695 "trusted state changed immediately before execution".to_owned(),
1696 ))
1697 }
1698 }
1699
1700 pub(crate) fn validate_durable_current_action(
1701 &self,
1702 store: &EventStore,
1703 action_id: &str,
1704 ) -> Result<(), GenUiError> {
1705 let binding = self
1706 .bindings
1707 .get(action_id)
1708 .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1709 let Some((generation, digest, authorization_context)) = store
1710 .genui_current_state(&binding.session_id, &binding.principal)
1711 .map_err(event_error)?
1712 else {
1713 return Err(GenUiError::StaleAction(
1714 "no durable trusted current state exists for this action".to_owned(),
1715 ));
1716 };
1717 if generation == binding.state_generation
1718 && digest == binding.state_digest
1719 && authorization_context == binding.authorization_context
1720 {
1721 Ok(())
1722 } else {
1723 Err(GenUiError::StaleAction(
1724 "durable trusted state changed immediately before execution".to_owned(),
1725 ))
1726 }
1727 }
1728
1729 pub(crate) fn validate_fresh_workspace_identity(
1734 &self,
1735 store: &EventStore,
1736 action_id: &str,
1737 fresh_digest: &str,
1738 fresh_authorization_context: &str,
1739 ) -> Result<(), GenUiError> {
1740 let binding = self
1741 .bindings
1742 .get(action_id)
1743 .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1744 let Some((generation, digest, authorization_context)) = store
1745 .genui_current_state(&binding.session_id, &binding.principal)
1746 .map_err(event_error)?
1747 else {
1748 return Err(GenUiError::StaleAction(
1749 "no durable trusted current state exists for this action".to_owned(),
1750 ));
1751 };
1752 if generation != binding.state_generation
1753 || digest != binding.state_digest
1754 || authorization_context != binding.authorization_context
1755 || digest != fresh_digest
1756 || authorization_context != fresh_authorization_context
1757 {
1758 return Err(GenUiError::StaleAction(
1759 "current workspace state changed before MCP transport".to_owned(),
1760 ));
1761 }
1762 Ok(())
1763 }
1764}
1765
1766impl Action {
1767 pub fn validate(&self, surface_id: &str, host: &HostCapabilities) -> Result<(), GenUiError> {
1768 validate_id("action", &self.id)?;
1769 if !self.id.starts_with("gui_action_") {
1770 return Err(GenUiError::InvalidSurface(
1771 "action IDs must be opaque host-issued IDs".to_owned(),
1772 ));
1773 }
1774 let label = normalize_action_label(&self.label)?;
1775 if label.trim().is_empty() || label.chars().count() > 512 {
1776 return Err(GenUiError::InvalidSurface(format!(
1777 "action {:?} has an empty or oversized label",
1778 self.id
1779 )));
1780 }
1781 if !host.supported_actions.contains(&self.kind) {
1782 return Err(GenUiError::UnsupportedAction(self.kind));
1783 }
1784 if surface_id.is_empty() {
1785 return Err(GenUiError::InvalidSurface(
1786 "action has no owning surface".to_owned(),
1787 ));
1788 }
1789 Ok(())
1790 }
1791}
1792
1793fn identity_payload(binding: &ResolvedActionBinding, payload: Option<&Value>) -> Value {
1794 let mut object = Map::new();
1795 for (key, value) in [
1796 ("action_id", Value::String(binding.action_id.clone())),
1797 (
1798 "action_kind",
1799 serde_json::to_value(binding.action_kind).expect("ActionKind is serializable"),
1800 ),
1801 (
1802 "source_type",
1803 serde_json::to_value(binding.source_type).expect("ActionSourceType is serializable"),
1804 ),
1805 ("label_digest", Value::String(binding.label_digest.clone())),
1806 ("surface_id", Value::String(binding.surface_id.clone())),
1807 (
1808 "surface_digest",
1809 Value::String(binding.surface_digest.clone()),
1810 ),
1811 ("state_digest", Value::String(binding.state_digest.clone())),
1812 (
1813 "state_generation",
1814 Value::Number(binding.state_generation.into()),
1815 ),
1816 ("session_id", Value::String(binding.session_id.clone())),
1817 ("principal", Value::String(binding.principal.clone())),
1818 (
1819 "authorization_context",
1820 Value::String(binding.authorization_context.clone()),
1821 ),
1822 ("provider_id", Value::String(binding.provider_id.clone())),
1823 ("server_id", Value::String(binding.server_id.clone())),
1824 ("tool_name", Value::String(binding.tool_name.clone())),
1825 (
1826 "remote_tool_name",
1827 Value::String(binding.remote_tool_name.clone()),
1828 ),
1829 (
1830 "schema_digest",
1831 Value::String(binding.schema_digest.clone()),
1832 ),
1833 (
1834 "policy_version",
1835 Value::Number(binding.policy_version.into()),
1836 ),
1837 (
1838 "policy_digest",
1839 Value::String(binding.policy_digest.clone()),
1840 ),
1841 (
1842 "schema_version",
1843 Value::Number(crate::event::GENUI_EVENT_SCHEMA_VERSION.into()),
1844 ),
1845 (
1846 "requires_confirmation",
1847 Value::Bool(binding.requires_confirmation),
1848 ),
1849 ] {
1850 object.insert(key.to_owned(), value);
1851 }
1852 if let Some(payload) = payload {
1853 object.insert(
1854 "payload_digest".to_owned(),
1855 Value::String(digest_json(payload)),
1856 );
1857 }
1858 Value::Object(object)
1859}
1860
1861fn event_error(error: EventError) -> GenUiError {
1862 GenUiError::EventStore(error.to_string())
1863}
1864
1865fn validate_component(
1866 component: &Component,
1867 depth: usize,
1868 count: &mut usize,
1869 max_depth: usize,
1870 max_components: usize,
1871 ids: &mut BTreeSet<String>,
1872 host: &HostCapabilities,
1873) -> Result<(), GenUiError> {
1874 if depth > max_depth {
1875 return Err(GenUiError::InvalidSurface(format!(
1876 "component nesting exceeds bound {max_depth}"
1877 )));
1878 }
1879 *count = count.saturating_add(1);
1880 if *count > max_components {
1881 return Err(GenUiError::InvalidSurface(format!(
1882 "component count exceeds bound {max_components}"
1883 )));
1884 }
1885 validate_id("component", &component.id)?;
1886 if !ids.insert(component.id.clone()) {
1887 return Err(GenUiError::InvalidSurface(format!(
1888 "duplicate component ID {:?}",
1889 component.id
1890 )));
1891 }
1892 let name = component.kind.name();
1893 if !host.supported_components.contains(name)
1894 && host.unsupported_component_policy == UnsupportedComponentPolicy::Reject
1895 {
1896 return Err(GenUiError::UnsupportedComponent {
1897 component: name.to_owned(),
1898 });
1899 }
1900 validate_kind(&component.kind, host.max_columns())?;
1901 match &component.kind {
1902 ComponentKind::Stack { children } => {
1903 for child in children {
1904 validate_component(
1905 child,
1906 depth + 1,
1907 count,
1908 max_depth,
1909 max_components,
1910 ids,
1911 host,
1912 )?;
1913 }
1914 }
1915 ComponentKind::Columns { columns } => {
1916 for column in columns {
1917 for child in column {
1918 validate_component(
1919 child,
1920 depth + 1,
1921 count,
1922 max_depth,
1923 max_components,
1924 ids,
1925 host,
1926 )?;
1927 }
1928 }
1929 }
1930 _ => {}
1931 }
1932 Ok(())
1933}
1934
1935impl HostCapabilities {
1936 fn max_columns(&self) -> usize {
1937 DEFAULT_MAX_COLUMNS
1938 }
1939}
1940
1941fn validate_kind(kind: &ComponentKind, max_columns: usize) -> Result<(), GenUiError> {
1942 let mut text_bytes = 0usize;
1943 let mut add_text = |text: &str| {
1944 text_bytes = text_bytes.saturating_add(text.len());
1945 text_bytes <= DEFAULT_MAX_TEXT_BYTES
1946 };
1947 let valid = match kind {
1948 ComponentKind::Text { text } => add_text(text),
1949 ComponentKind::Markdown { markdown } => add_text(markdown),
1950 ComponentKind::Status { label, value, .. } => add_text(label) && add_text(value),
1951 ComponentKind::Progress {
1952 label,
1953 total,
1954 current,
1955 } => add_text(label) && total.is_none_or(|total| *current <= total),
1956 ComponentKind::Table { columns, rows } => {
1957 columns.len() <= max_columns
1958 && rows.len() <= DEFAULT_MAX_ROWS
1959 && rows
1960 .iter()
1961 .all(|row| row.len() == columns.len() && row.iter().all(|cell| add_text(cell)))
1962 && columns.iter().all(|column| add_text(column))
1963 }
1964 ComponentKind::KeyValue { entries } => {
1965 entries.len() <= DEFAULT_MAX_ROWS
1966 && entries
1967 .iter()
1968 .all(|(key, value)| add_text(key) && add_text(value))
1969 }
1970 ComponentKind::Diff { before, after } => add_text(before) && add_text(after),
1971 ComponentKind::TestResults { details, .. } => {
1972 details.len() <= DEFAULT_MAX_ROWS && details.iter().all(|detail| add_text(detail))
1973 }
1974 ComponentKind::Form { fields } => {
1975 fields.len() <= DEFAULT_MAX_ROWS
1976 && fields.iter().all(|field| {
1977 validate_id("field", &field.id).is_ok()
1978 && add_text(&field.label)
1979 && field
1980 .description
1981 .as_ref()
1982 .is_none_or(|description| add_text(description))
1983 && field.choices.len() <= max_columns
1984 && field.choices.iter().all(|choice| add_text(choice))
1985 && (field.choice_values.is_empty()
1986 || (field.choice_values.len() == field.choices.len()
1987 && field
1988 .choice_values
1989 .iter()
1990 .all(|value| validate_value_size(value).is_ok())))
1991 && field
1992 .value
1993 .as_ref()
1994 .is_none_or(|value| validate_value_size(value).is_ok())
1995 && field.constraints.keys().all(|key| {
1996 matches!(
1997 key.as_str(),
1998 "minLength" | "maxLength" | "minimum" | "maximum" | "pattern"
1999 )
2000 })
2001 })
2002 }
2003 ComponentKind::Choice {
2004 label,
2005 options,
2006 selected,
2007 } => {
2008 add_text(label)
2009 && options.len() <= DEFAULT_MAX_ROWS
2010 && options
2011 .iter()
2012 .all(|option| add_text(&option.value) && add_text(&option.label))
2013 && selected
2014 .as_ref()
2015 .is_none_or(|value| options.iter().any(|option| &option.value == value))
2016 }
2017 ComponentKind::Evidence { title, items } => {
2018 add_text(title)
2019 && items.len() <= DEFAULT_MAX_ROWS
2020 && items
2021 .iter()
2022 .all(|item| add_text(&item.label) && add_text(&item.value))
2023 }
2024 ComponentKind::Timeline { entries } => {
2025 entries.len() <= DEFAULT_MAX_ROWS
2026 && entries
2027 .iter()
2028 .all(|entry| add_text(&entry.label) && add_text(&entry.detail))
2029 }
2030 ComponentKind::Stack { children } => children.len() <= DEFAULT_MAX_ROWS,
2031 ComponentKind::Columns { columns } => {
2032 columns.len() <= max_columns
2033 && columns
2034 .iter()
2035 .all(|column| column.len() <= DEFAULT_MAX_ROWS)
2036 }
2037 };
2038 if valid && text_bytes <= DEFAULT_MAX_TEXT_BYTES {
2039 Ok(())
2040 } else {
2041 Err(GenUiError::InvalidSurface(format!(
2042 "component payload exceeds bounded size or has invalid shape for {}",
2043 kind.name()
2044 )))
2045 }
2046}
2047
2048fn validate_id(label: &str, value: &str) -> Result<(), GenUiError> {
2049 if value.is_empty()
2050 || value.len() > 128
2051 || value.bytes().any(|byte| {
2052 !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))
2053 })
2054 {
2055 Err(GenUiError::InvalidSurface(format!(
2056 "{label} ID {:?} is not a stable bounded identifier",
2057 value
2058 )))
2059 } else {
2060 Ok(())
2061 }
2062}
2063
2064fn validate_digest(label: &str, value: &str) -> Result<(), GenUiError> {
2065 if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
2066 Err(GenUiError::InvalidSurface(format!(
2067 "{label} digest is invalid"
2068 )))
2069 } else {
2070 Ok(())
2071 }
2072}
2073
2074fn is_bidi_format(character: char) -> bool {
2075 matches!(
2076 character,
2077 '\u{061c}'
2078 | '\u{200e}'
2079 | '\u{200f}'
2080 | '\u{200b}'
2081 | '\u{200c}'
2082 | '\u{200d}'
2083 | '\u{202a}'..='\u{202e}'
2084 | '\u{2060}'
2085 | '\u{2066}'..='\u{2069}'
2086 | '\u{feff}'
2087 )
2088}
2089
2090fn is_zero_width_format(character: char) -> bool {
2091 matches!(
2092 character,
2093 '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2028}' | '\u{2029}' | '\u{2060}' | '\u{feff}'
2094 )
2095}
2096
2097fn normalize_action_label(label: &str) -> Result<String, GenUiError> {
2102 if label.chars().any(|character| {
2103 character.is_control()
2104 || is_bidi_format(character)
2105 || matches!(
2106 character,
2107 '\n' | '\r'
2108 | '\t'
2109 | '\u{200b}'
2110 | '\u{200c}'
2111 | '\u{200d}'
2112 | '\u{2060}'
2113 | '\u{feff}'
2114 | '\u{2028}'
2115 | '\u{2029}'
2116 )
2117 }) {
2118 return Err(GenUiError::InvalidSurface(
2119 "action label contains a control, newline, tab, or bidi formatting character"
2120 .to_owned(),
2121 ));
2122 }
2123 let display = sanitize_text(label);
2124 if display != label {
2125 return Err(GenUiError::InvalidSurface(
2126 "action label cannot be normalized safely".to_owned(),
2127 ));
2128 }
2129 Ok(display)
2130}
2131
2132fn validate_value_size(value: &Value) -> Result<(), GenUiError> {
2133 let bytes =
2134 serde_json::to_vec(value).map_err(|error| GenUiError::InvalidSurface(error.to_string()))?;
2135 if bytes.len() > DEFAULT_MAX_TEXT_BYTES {
2136 Err(GenUiError::InvalidSurface(
2137 "value exceeds bounded size".to_owned(),
2138 ))
2139 } else {
2140 Ok(())
2141 }
2142}
2143
2144fn digest_json<T: Serialize>(value: &T) -> String {
2145 let bytes = canonical_json_bytes(value).expect("typed GenUI values are serializable");
2146 sha256_hex(&bytes)
2147}
2148
2149pub(crate) fn digest_value(value: &Value) -> String {
2150 digest_json(value)
2151}
2152
2153pub(crate) fn derive_host_state_identity(
2158 session_id: &str,
2159 principal: &str,
2160 host_snapshot: &Value,
2161) -> (String, String) {
2162 let digest = digest_json(&serde_json::json!({
2163 "schema": "falsegreen.agent.genui.host-state.v1",
2164 "session_id": session_id,
2165 "principal": principal,
2166 "snapshot": host_snapshot,
2167 }));
2168 let authorization_context = format!("agent-session:{session_id}:principal:{principal}");
2169 (digest, authorization_context)
2170}
2171
2172pub fn derive_host_workspace_state_identity(
2177 session_id: &str,
2178 principal: &str,
2179 workspace_state: &WorkspaceState,
2180) -> (String, String) {
2181 derive_host_state_identity(
2182 session_id,
2183 principal,
2184 &serde_json::json!({"workspace": workspace_state}),
2185 )
2186}
2187
2188#[must_use]
2192pub fn mcp_schema_digest(schema: &Value) -> String {
2193 match canonical_json_bytes_value_iterative(schema) {
2197 Ok(bytes) => sha256_hex(&bytes),
2198 Err(_) => sha256_hex(b"falsegreen.agent.genui.mcp-schema.invalid-v2"),
2199 }
2200}
2201
2202fn canonical_json_bytes_value_iterative(value: &Value) -> Result<Vec<u8>, serde_json::Error> {
2203 enum Task<'a> {
2204 Value(&'a Value),
2205 Key(&'a str),
2206 Raw(&'static [u8]),
2207 }
2208
2209 let mut output = Vec::new();
2210 let mut tasks = vec![Task::Value(value)];
2211 while let Some(task) = tasks.pop() {
2212 match task {
2213 Task::Raw(bytes) => output.extend_from_slice(bytes),
2214 Task::Key(key) => serde_json::to_writer(&mut output, key)?,
2215 Task::Value(value) => match value {
2216 Value::Null => output.extend_from_slice(b"null"),
2217 Value::Bool(value) => {
2218 output.extend_from_slice(if *value { b"true" } else { b"false" })
2219 }
2220 Value::Number(value) => serde_json::to_writer(&mut output, value)?,
2221 Value::String(value) => serde_json::to_writer(&mut output, value)?,
2222 Value::Array(items) => {
2223 output.push(b'[');
2224 tasks.push(Task::Raw(b"]"));
2225 for (index, item) in items.iter().enumerate().rev() {
2226 if index + 1 < items.len() {
2227 tasks.push(Task::Raw(b","));
2228 }
2229 tasks.push(Task::Value(item));
2230 }
2231 }
2232 Value::Object(object) => {
2233 let mut entries = object
2234 .iter()
2235 .map(|(key, value)| (key.as_str(), value))
2236 .collect::<Vec<_>>();
2237 entries.sort_unstable_by(|left, right| left.0.cmp(right.0));
2238 output.push(b'{');
2239 tasks.push(Task::Raw(b"}"));
2240 for (index, (key, value)) in entries.iter().enumerate().rev() {
2241 if index + 1 < entries.len() {
2242 tasks.push(Task::Raw(b","));
2243 }
2244 tasks.push(Task::Value(value));
2245 tasks.push(Task::Raw(b":"));
2246 tasks.push(Task::Key(key));
2247 }
2248 }
2249 },
2250 }
2251 }
2252 Ok(output)
2253}
2254
2255fn canonical_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
2256 let value = serde_json::to_value(value)?;
2257 serde_json::to_vec(&canonicalize_json(value))
2258}
2259
2260fn canonicalize_json(value: Value) -> Value {
2261 match value {
2262 Value::Object(object) => {
2263 let mut canonical = Map::new();
2264 for (key, value) in object {
2265 canonical.insert(key, canonicalize_json(value));
2266 }
2267 let mut sorted = BTreeMap::new();
2270 for (key, value) in canonical {
2271 sorted.insert(key, value);
2272 }
2273 Value::Object(sorted.into_iter().collect())
2274 }
2275 Value::Array(items) => Value::Array(items.into_iter().map(canonicalize_json).collect()),
2276 other => other,
2277 }
2278}
2279
2280fn sha256_hex(bytes: &[u8]) -> String {
2281 format!("{:x}", Sha256::digest(bytes))
2282}
2283
2284#[must_use]
2289pub fn sanitize_text(input: &str) -> String {
2290 let mut output = String::with_capacity(input.len());
2291 let mut chars = input.chars().peekable();
2292 while let Some(ch) = chars.next() {
2293 if ch == '\u{1b}' {
2294 if chars.peek() == Some(&'[') {
2295 chars.next();
2296 for next in chars.by_ref() {
2297 if ('@'..='~').contains(&next) {
2298 break;
2299 }
2300 }
2301 } else if chars.peek() == Some(&']') {
2302 chars.next();
2303 let mut previous_escape = false;
2304 for next in chars.by_ref() {
2305 if next == '\u{7}' {
2306 break;
2307 }
2308 if previous_escape && next == '\\' {
2309 break;
2310 }
2311 previous_escape = next == '\u{1b}';
2312 }
2313 } else if matches!(chars.peek(), Some('P' | '^' | '_')) {
2314 chars.next();
2318 let mut previous_escape = false;
2319 for next in chars.by_ref() {
2320 if next == '\u{7}' {
2321 break;
2322 }
2323 if previous_escape && next == '\\' {
2324 break;
2325 }
2326 previous_escape = next == '\u{1b}';
2327 }
2328 } else {
2329 let _ = chars.next();
2330 }
2331 } else if ch == '\n' {
2332 output.push(ch);
2333 } else if ch.is_control() || is_bidi_format(ch) || is_zero_width_format(ch) {
2334 output.push(' ');
2335 } else {
2336 output.push(ch);
2337 }
2338 }
2339 output
2340}
2341
2342#[must_use]
2347pub fn sanitize_single_line_metadata(input: &str) -> String {
2348 let sanitized = sanitize_text(input);
2349 let mut output = String::with_capacity(sanitized.len());
2350 let mut pending_space = false;
2351 for character in sanitized.chars() {
2352 if character.is_whitespace()
2353 || character.is_control()
2354 || is_bidi_format(character)
2355 || is_zero_width_format(character)
2356 {
2357 pending_space = true;
2358 continue;
2359 }
2360 if pending_space && !output.is_empty() {
2361 output.push(' ');
2362 }
2363 output.push(character);
2364 pending_space = false;
2365 }
2366 output
2367}
2368
2369#[allow(dead_code)]
2373pub(crate) fn render_surface_with_capabilities(
2374 surface: &Surface,
2375 capabilities: &NegotiatedCapabilities,
2376) -> Result<String, GenUiError> {
2377 let host = HostCapabilities {
2378 protocol: capabilities.protocol,
2379 supported_components: capabilities.supported_components.clone(),
2380 supported_actions: capabilities.supported_actions.clone(),
2381 unsupported_component_policy: capabilities.unsupported_component_policy,
2382 terminal_width: capabilities.terminal_width.max(1),
2383 ..HostCapabilities::default()
2384 };
2385 surface.validate_shape(&host)?;
2386 let mut lines = Vec::new();
2387 let has_unsupported_component =
2388 !unsupported_component_ids(&surface.root, &host.supported_components).is_empty();
2389 render_component(&surface.root, &host.supported_components, &mut lines);
2390 if !has_unsupported_component {
2394 for action in &surface.actions {
2395 if !capabilities.supported_actions.contains(&action.kind) {
2396 continue;
2397 }
2398 let label = normalize_action_label(&action.label)
2399 .expect("validated action labels have a canonical display representation");
2400 lines.push(format!("[{}] {label}", action.id));
2401 }
2402 }
2403 Ok(lines
2404 .into_iter()
2405 .flat_map(|line| wrap_line(&line, host.terminal_width))
2406 .collect::<Vec<_>>()
2407 .join("\n"))
2408}
2409
2410#[allow(dead_code)]
2413pub(crate) fn render_surface_with_capabilities_and_catalog(
2414 surface: &Surface,
2415 capabilities: &NegotiatedCapabilities,
2416 catalog: &mut ActionCatalog,
2417) -> Result<String, GenUiError> {
2418 catalog.admit_rendered_surface(surface, capabilities)?;
2419 let host = HostCapabilities {
2420 protocol: capabilities.protocol,
2421 supported_components: capabilities.supported_components.clone(),
2422 supported_actions: capabilities.supported_actions.clone(),
2423 unsupported_component_policy: capabilities.unsupported_component_policy,
2424 terminal_width: capabilities.terminal_width.max(1),
2425 ..HostCapabilities::default()
2426 };
2427 surface.validate_shape(&host)?;
2428 let mut lines = Vec::new();
2429 render_component(&surface.root, &host.supported_components, &mut lines);
2430 for action in &surface.actions {
2431 if !capabilities.supported_actions.contains(&action.kind)
2432 || catalog.resolve(&action.id).is_none()
2433 {
2434 continue;
2435 }
2436 let label = normalize_action_label(&action.label)
2437 .expect("validated action labels have a canonical display representation");
2438 lines.push(format!("[{}] {label}", action.id));
2439 }
2440 Ok(lines
2441 .into_iter()
2442 .flat_map(|line| wrap_line(&line, host.terminal_width))
2443 .collect::<Vec<_>>()
2444 .join("\n"))
2445}
2446
2447pub fn render_surface_with_capabilities_and_catalog_with_store(
2451 surface: &Surface,
2452 capabilities: &NegotiatedCapabilities,
2453 catalog: &mut ActionCatalog,
2454 store: &EventStore,
2455) -> Result<String, GenUiError> {
2456 catalog.admit_rendered_surface_with_store(surface, capabilities, store)?;
2457 let host = HostCapabilities {
2458 protocol: capabilities.protocol,
2459 supported_components: capabilities.supported_components.clone(),
2460 supported_actions: capabilities.supported_actions.clone(),
2461 unsupported_component_policy: capabilities.unsupported_component_policy,
2462 terminal_width: capabilities.terminal_width.max(1),
2463 ..HostCapabilities::default()
2464 };
2465 surface.validate_shape(&host)?;
2466 let mut lines = Vec::new();
2467 render_component(&surface.root, &host.supported_components, &mut lines);
2468 for action in &surface.actions {
2469 if !capabilities.supported_actions.contains(&action.kind)
2470 || catalog.resolve(&action.id).is_none()
2471 {
2472 continue;
2473 }
2474 let label = normalize_action_label(&action.label)
2475 .expect("validated action labels have a canonical display representation");
2476 lines.push(format!("[{}] {label}", action.id));
2477 }
2478 Ok(lines
2479 .into_iter()
2480 .flat_map(|line| wrap_line(&line, host.terminal_width))
2481 .collect::<Vec<_>>()
2482 .join("\n"))
2483}
2484
2485fn unsupported_component_ids(
2486 component: &Component,
2487 supported: &BTreeSet<String>,
2488) -> BTreeSet<String> {
2489 let mut output = BTreeSet::new();
2490 collect_unsupported_component_ids(component, supported, &mut output);
2491 output
2492}
2493
2494fn collect_unsupported_component_ids(
2495 component: &Component,
2496 supported: &BTreeSet<String>,
2497 output: &mut BTreeSet<String>,
2498) {
2499 if !supported.contains(component.kind.name()) {
2500 output.insert(component.id.clone());
2501 }
2502 match &component.kind {
2503 ComponentKind::Stack { children } => {
2504 for child in children {
2505 collect_unsupported_component_ids(child, supported, output);
2506 }
2507 }
2508 ComponentKind::Columns { columns } => {
2509 for child in columns.iter().flatten() {
2510 collect_unsupported_component_ids(child, supported, output);
2511 }
2512 }
2513 _ => {}
2514 }
2515}
2516
2517fn component_contains_id(component: &Component, ancestor: &str, target: &str) -> bool {
2518 if component.id == ancestor {
2519 return component.id == target || component_contains_descendant(component, target);
2520 }
2521 match &component.kind {
2522 ComponentKind::Stack { children } => children
2523 .iter()
2524 .any(|child| component_contains_id(child, ancestor, target)),
2525 ComponentKind::Columns { columns } => columns
2526 .iter()
2527 .flatten()
2528 .any(|child| component_contains_id(child, ancestor, target)),
2529 _ => false,
2530 }
2531}
2532
2533fn component_contains_descendant(component: &Component, target: &str) -> bool {
2534 match &component.kind {
2535 ComponentKind::Stack { children } => children
2536 .iter()
2537 .any(|child| child.id == target || component_contains_descendant(child, target)),
2538 ComponentKind::Columns { columns } => columns
2539 .iter()
2540 .flatten()
2541 .any(|child| child.id == target || component_contains_descendant(child, target)),
2542 _ => false,
2543 }
2544}
2545
2546#[must_use]
2547pub fn default_negotiated_capabilities(width: usize) -> NegotiatedCapabilities {
2548 let host = HostCapabilities {
2549 terminal_width: width.max(1),
2550 ..HostCapabilities::default()
2551 };
2552 negotiate(&[ProtocolVersion::current()], &host)
2553 .expect("current protocol must negotiate with the default host")
2554}
2555
2556fn render_component(component: &Component, supported: &BTreeSet<String>, lines: &mut Vec<String>) {
2557 if !supported.contains(component.kind.name()) {
2558 lines.push(format!(
2559 "[unsupported component: {}]",
2560 component.kind.name()
2561 ));
2562 return;
2563 }
2564 match &component.kind {
2565 ComponentKind::Text { text } | ComponentKind::Markdown { markdown: text } => {
2566 lines.extend(sanitize_text(text).lines().map(str::to_owned));
2567 }
2568 ComponentKind::Status { label, value, .. } => {
2569 lines.push(format!(
2570 "{}: {}",
2571 sanitize_text(label),
2572 sanitize_text(value)
2573 ));
2574 }
2575 ComponentKind::Progress {
2576 label,
2577 current,
2578 total,
2579 } => {
2580 let progress = total.map_or_else(
2581 || "indeterminate".to_owned(),
2582 |total| format!("{current}/{total}"),
2583 );
2584 lines.push(format!("{}: {progress}", sanitize_text(label)));
2585 }
2586 ComponentKind::Table { columns, rows } => {
2587 lines.push(
2588 columns
2589 .iter()
2590 .map(|column| sanitize_text(column))
2591 .collect::<Vec<_>>()
2592 .join(" | "),
2593 );
2594 for row in rows {
2595 lines.push(
2596 row.iter()
2597 .map(|cell| sanitize_text(cell))
2598 .collect::<Vec<_>>()
2599 .join(" | "),
2600 );
2601 }
2602 }
2603 ComponentKind::KeyValue { entries } => {
2604 for (key, value) in entries {
2605 lines.push(format!("{}: {}", sanitize_text(key), sanitize_text(value)));
2606 }
2607 }
2608 ComponentKind::Diff { before, after } => {
2609 lines.push(format!("- {}", sanitize_text(before)));
2610 lines.push(format!("+ {}", sanitize_text(after)));
2611 }
2612 ComponentKind::TestResults {
2613 passed,
2614 failed,
2615 skipped,
2616 details,
2617 } => {
2618 lines.push(format!(
2619 "tests: {passed} passed, {failed} failed, {skipped} skipped"
2620 ));
2621 lines.extend(
2622 details
2623 .iter()
2624 .map(|detail| format!(" {}", sanitize_text(detail))),
2625 );
2626 }
2627 ComponentKind::Form { fields } => {
2628 for field in fields {
2629 let value = field.value.as_ref().map_or_else(
2630 || "".to_owned(),
2631 |value| sanitize_single_line_metadata(&value.to_string()),
2632 );
2633 lines.push(format!(
2634 "{}{}: {}",
2635 if field.required { "*" } else { "" },
2636 sanitize_text(&field.label),
2637 value
2638 ));
2639 }
2640 }
2641 ComponentKind::Choice {
2642 label,
2643 options,
2644 selected,
2645 } => {
2646 lines.push(format!("{}:", sanitize_text(label)));
2647 for option in options {
2648 let marker = if selected.as_ref() == Some(&option.value) {
2649 "*"
2650 } else {
2651 " "
2652 };
2653 lines.push(format!(" [{marker}] {}", sanitize_text(&option.label)));
2654 }
2655 }
2656 ComponentKind::Evidence { title, items } => {
2657 lines.push(sanitize_text(title));
2658 for item in items {
2659 lines.push(format!(
2660 " {}: {}",
2661 sanitize_text(&item.label),
2662 sanitize_text(&item.value)
2663 ));
2664 }
2665 }
2666 ComponentKind::Timeline { entries } => {
2667 for entry in entries {
2668 lines.push(format!(
2669 "{:?} {} — {}",
2670 entry.state,
2671 sanitize_text(&entry.label),
2672 sanitize_text(&entry.detail)
2673 ));
2674 }
2675 }
2676 ComponentKind::Stack { children } => {
2677 for child in children {
2678 render_component(child, supported, lines);
2679 }
2680 }
2681 ComponentKind::Columns { columns } => {
2682 let mut rendered = Vec::new();
2683 for column in columns {
2684 let mut column_lines = Vec::new();
2685 for child in column {
2686 render_component(child, supported, &mut column_lines);
2687 }
2688 rendered.push(column_lines);
2689 }
2690 let rows = rendered.iter().map(Vec::len).max().unwrap_or(0);
2691 for row in 0..rows {
2692 lines.push(
2693 rendered
2694 .iter()
2695 .map(|column| column.get(row).cloned().unwrap_or_default())
2696 .collect::<Vec<_>>()
2697 .join(" | "),
2698 );
2699 }
2700 }
2701 }
2702}
2703
2704fn wrap_line(line: &str, width: usize) -> Vec<String> {
2705 if line.is_empty() {
2706 return vec![String::new()];
2707 }
2708 let width = width.max(1);
2709 let mut output = Vec::new();
2710 let mut current = String::new();
2711 let mut current_width = 0usize;
2712 for character in line.chars() {
2713 let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
2714 if character_width > 0 && current_width + character_width > width {
2715 output.push(std::mem::take(&mut current));
2716 current_width = 0;
2717 }
2718 current.push(character);
2719 current_width = current_width.saturating_add(character_width);
2720 }
2721 if !current.is_empty() || output.is_empty() {
2722 output.push(current);
2723 }
2724 output
2725}
2726
2727pub const MCP_SCHEMA_ADAPTER_ID: &str = "falsegreen.agent.genui.mcp-schema.v1";
2731pub const MCP_SCHEMA_MAX_BYTES: usize = 4 * 1024 * 1024;
2732pub const MCP_SCHEMA_MAX_FIELDS: usize = 256;
2733pub const MCP_SCHEMA_MAX_ENUM_VALUES: usize = DEFAULT_MAX_COLUMNS;
2734pub const MCP_SCHEMA_MAX_STRING_LENGTH: u64 = 1_000_000;
2735pub const MCP_SCHEMA_MAX_JSON_DEPTH: usize = 64;
2739pub const MCP_SCHEMA_MAX_JSON_NODES: usize = 16_384;
2743
2744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2745pub enum McpSchemaDialect {
2746 Draft7,
2747 Draft202012,
2748}
2749
2750impl McpSchemaDialect {
2751 #[must_use]
2752 pub const fn uri(self) -> &'static str {
2753 match self {
2754 Self::Draft7 => "http://json-schema.org/draft-07/schema#",
2755 Self::Draft202012 => "https://json-schema.org/draft/2020-12/schema",
2756 }
2757 }
2758
2759 fn parse(value: &Value) -> Result<Self, GenUiError> {
2760 let uri = value
2761 .as_str()
2762 .ok_or_else(|| unsupported_schema("unsupported_dialect", "$schema must be a string"))?;
2763 match uri {
2764 "http://json-schema.org/draft-07/schema#" => Ok(Self::Draft7),
2765 "https://json-schema.org/draft/2020-12/schema" => Ok(Self::Draft202012),
2766 _ => Err(unsupported_schema(
2767 "unsupported_dialect",
2768 format!("unsupported JSON-Schema dialect {uri:?}"),
2769 )),
2770 }
2771 }
2772}
2773
2774#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2775pub enum McpSchemaType {
2776 String,
2777 Integer,
2778 Boolean,
2779}
2780
2781impl McpSchemaType {
2782 fn parse(name: &str, property: &str) -> Result<Self, GenUiError> {
2783 match name {
2784 "string" => Ok(Self::String),
2785 "integer" => Ok(Self::Integer),
2786 "boolean" => Ok(Self::Boolean),
2787 "number" => Err(unsupported_schema(
2788 "unsupported_type",
2789 format!(
2790 "property {property:?} uses number; exact decimal semantics are not enabled"
2791 ),
2792 )),
2793 "array" => Err(unsupported_schema(
2794 "unsupported_nested_array",
2795 format!("property {property:?} uses an array"),
2796 )),
2797 "object" => Err(unsupported_schema(
2798 "unsupported_nested_object",
2799 format!("property {property:?} uses a nested object"),
2800 )),
2801 other => Err(unsupported_schema(
2802 "unsupported_type",
2803 format!("property {property:?} uses unsupported type {other:?}"),
2804 )),
2805 }
2806 }
2807
2808 fn field_type(self) -> FieldType {
2809 match self {
2810 Self::String => FieldType::String,
2811 Self::Integer => FieldType::Integer,
2812 Self::Boolean => FieldType::Boolean,
2813 }
2814 }
2815}
2816
2817#[derive(Debug, Clone, PartialEq, Eq)]
2818pub enum McpScalarValue {
2819 String(String),
2820 Integer(i64),
2821 UnsignedInteger(u64),
2822 Boolean(bool),
2823}
2824
2825impl McpScalarValue {
2826 fn parse(value: &Value, expected: McpSchemaType, property: &str) -> Result<Self, GenUiError> {
2827 let parsed = match expected {
2828 McpSchemaType::String => value.as_str().map(|value| Self::String(value.to_owned())),
2829 McpSchemaType::Integer => value
2830 .as_i64()
2831 .map(Self::Integer)
2832 .or_else(|| value.as_u64().map(Self::UnsignedInteger)),
2833 McpSchemaType::Boolean => value.as_bool().map(Self::Boolean),
2834 };
2835 parsed.ok_or_else(|| {
2836 unsupported_schema(
2837 "enum_type_mismatch",
2838 format!("property {property:?} has a value of the wrong type"),
2839 )
2840 })
2841 }
2842
2843 fn from_submission(value: &Value, expected: McpSchemaType) -> Option<Self> {
2844 match expected {
2845 McpSchemaType::String => value.as_str().map(|value| Self::String(value.to_owned())),
2846 McpSchemaType::Integer => value
2847 .as_i64()
2848 .map(Self::Integer)
2849 .or_else(|| value.as_u64().map(Self::UnsignedInteger)),
2850 McpSchemaType::Boolean => value.as_bool().map(Self::Boolean),
2851 }
2852 }
2853
2854 fn to_json(&self) -> Value {
2855 match self {
2856 Self::String(value) => Value::String(value.clone()),
2857 Self::Integer(value) => Value::Number((*value).into()),
2858 Self::UnsignedInteger(value) => Value::Number((*value).into()),
2859 Self::Boolean(value) => Value::Bool(*value),
2860 }
2861 }
2862
2863 fn display(&self) -> String {
2864 sanitize_single_line_metadata(&match self {
2865 Self::String(value) => value.clone(),
2866 Self::Integer(value) => value.to_string(),
2867 Self::UnsignedInteger(value) => value.to_string(),
2868 Self::Boolean(value) => value.to_string(),
2869 })
2870 }
2871
2872 fn integer(&self) -> Option<i128> {
2873 match self {
2874 Self::Integer(value) => Some(i128::from(*value)),
2875 Self::UnsignedInteger(value) => Some(i128::from(*value)),
2876 _ => None,
2877 }
2878 }
2879}
2880
2881#[derive(Debug, Clone, PartialEq, Eq)]
2882pub struct McpSchemaField {
2883 pub name: String,
2884 pub schema_type: McpSchemaType,
2885 pub required: bool,
2886 pub enum_values: Vec<McpScalarValue>,
2887 pub title: Option<String>,
2888 pub description: Option<String>,
2889 pub default: Option<McpScalarValue>,
2890 pub min_length: Option<u64>,
2891 pub max_length: Option<u64>,
2892 pub minimum: Option<i128>,
2893 pub maximum: Option<i128>,
2894}
2895
2896impl McpSchemaField {
2897 fn validate_value(&self, value: &Value) -> Result<(), GenUiError> {
2898 let parsed = McpScalarValue::from_submission(value, self.schema_type).ok_or_else(|| {
2899 GenUiError::InvalidMcpSubmission(format!("field {:?} has the wrong type", self.name))
2900 })?;
2901 if !self.enum_values.is_empty() && !self.enum_values.contains(&parsed) {
2902 return Err(GenUiError::InvalidMcpSubmission(format!(
2903 "field {:?} is outside its enum",
2904 self.name
2905 )));
2906 }
2907 if let Some(number) = parsed.integer() {
2908 if self.minimum.is_some_and(|minimum| number < minimum) {
2909 return Err(GenUiError::InvalidMcpSubmission(format!(
2910 "field {:?} is below its minimum",
2911 self.name
2912 )));
2913 }
2914 if self.maximum.is_some_and(|maximum| number > maximum) {
2915 return Err(GenUiError::InvalidMcpSubmission(format!(
2916 "field {:?} exceeds its maximum",
2917 self.name
2918 )));
2919 }
2920 }
2921 if let Some(text) = value.as_str() {
2922 if self
2923 .min_length
2924 .is_some_and(|minimum| text.chars().count() < minimum as usize)
2925 {
2926 return Err(GenUiError::InvalidMcpSubmission(format!(
2927 "field {:?} is shorter than minLength",
2928 self.name
2929 )));
2930 }
2931 if self
2932 .max_length
2933 .is_some_and(|maximum| text.chars().count() > maximum as usize)
2934 {
2935 return Err(GenUiError::InvalidMcpSubmission(format!(
2936 "field {:?} is longer than maxLength",
2937 self.name
2938 )));
2939 }
2940 }
2941 Ok(())
2942 }
2943
2944 fn validate_default(&self, value: &Value) -> Result<(), GenUiError> {
2945 McpScalarValue::parse(value, self.schema_type, &self.name).map_err(|_| {
2946 unsupported_schema(
2947 "malformed_default",
2948 format!("property {:?} default has the wrong type", self.name),
2949 )
2950 })?;
2951 self.validate_value(value).map_err(|error| match error {
2952 GenUiError::InvalidMcpSubmission(message) => {
2953 unsupported_schema("malformed_default", message)
2954 }
2955 other => other,
2956 })
2957 }
2958
2959 fn as_form_field(&self) -> FormField {
2960 let choices = self
2961 .enum_values
2962 .iter()
2963 .map(McpScalarValue::display)
2964 .collect::<Vec<_>>();
2965 let choice_values = self
2966 .enum_values
2967 .iter()
2968 .map(McpScalarValue::to_json)
2969 .collect::<Vec<_>>();
2970 let mut constraints = BTreeMap::new();
2971 if let Some(value) = self.min_length {
2972 constraints.insert("minLength".to_owned(), Value::Number(value.into()));
2973 }
2974 if let Some(value) = self.max_length {
2975 constraints.insert("maxLength".to_owned(), Value::Number(value.into()));
2976 }
2977 if let Some(value) = self.minimum.and_then(i128_to_json_number) {
2978 constraints.insert("minimum".to_owned(), Value::Number(value));
2979 }
2980 if let Some(value) = self.maximum.and_then(i128_to_json_number) {
2981 constraints.insert("maximum".to_owned(), Value::Number(value));
2982 }
2983 FormField {
2984 id: self.name.clone(),
2985 label: self
2986 .title
2987 .as_deref()
2988 .filter(|title| !title.is_empty())
2989 .unwrap_or(&self.name)
2990 .to_owned(),
2991 field_type: self.schema_type.field_type(),
2992 required: self.required,
2993 description: self.description.clone(),
2994 value: self.default.as_ref().map(McpScalarValue::to_json),
2995 choices,
2996 choice_values,
2997 constraints,
2998 }
2999 }
3000}
3001
3002#[derive(Debug, Clone, PartialEq, Eq)]
3003pub struct McpSchemaIr {
3004 pub adapter: &'static str,
3005 pub dialect: Option<McpSchemaDialect>,
3006 pub source_digest: String,
3007 pub additional_properties: bool,
3008 pub title: Option<String>,
3009 pub description: Option<String>,
3010 pub fields: Vec<McpSchemaField>,
3011}
3012
3013impl McpSchemaIr {
3014 pub fn parse(schema: &Value) -> Result<Self, GenUiError> {
3018 parse_mcp_schema(schema)
3019 }
3020
3021 #[must_use]
3022 pub fn source_digest(&self) -> &str {
3023 &self.source_digest
3024 }
3025
3026 #[must_use]
3027 pub fn dialect_uri(&self) -> Option<&'static str> {
3028 self.dialect.map(McpSchemaDialect::uri)
3029 }
3030
3031 #[must_use]
3032 pub fn form_fields(&self) -> Vec<FormField> {
3033 self.fields
3034 .iter()
3035 .map(McpSchemaField::as_form_field)
3036 .collect()
3037 }
3038
3039 pub fn validate_submission(&self, arguments: &Value) -> Result<(), GenUiError> {
3043 let args = arguments.as_object().ok_or_else(|| {
3044 GenUiError::InvalidMcpSubmission("arguments must be an object".to_owned())
3045 })?;
3046 for field in &self.fields {
3047 if field.required && !args.contains_key(&field.name) {
3048 return Err(GenUiError::InvalidMcpSubmission(format!(
3049 "required field {:?} is missing",
3050 field.name
3051 )));
3052 }
3053 }
3054 for (name, value) in args {
3055 let Some(field) = self.fields.iter().find(|field| field.name == *name) else {
3056 return Err(GenUiError::InvalidMcpSubmission(
3057 "unknown field is not permitted by the original schema".to_owned(),
3058 ));
3059 };
3060 field.validate_value(value)?;
3061 }
3062 Ok(())
3063 }
3064
3065 fn validate_form_projection(&self) -> Result<(), GenUiError> {
3066 let form = ComponentKind::Form {
3067 fields: self.form_fields(),
3068 };
3069 validate_kind(&form, DEFAULT_MAX_COLUMNS).map_err(|error| {
3070 unsupported_schema(
3071 "surface_limit",
3072 format!("adapted form cannot be constructed: {error}"),
3073 )
3074 })
3075 }
3076
3077 pub fn reconstruct_payload(
3081 &self,
3082 values: &BTreeMap<String, Value>,
3083 ) -> Result<Value, GenUiError> {
3084 let arguments = Value::Object(
3085 values
3086 .iter()
3087 .map(|(name, value)| (name.clone(), value.clone()))
3088 .collect(),
3089 );
3090 self.validate_submission(&arguments)?;
3091 Ok(arguments)
3092 }
3093}
3094
3095pub fn adapt_mcp_schema(schema: &Value) -> Result<McpSchemaIr, GenUiError> {
3097 McpSchemaIr::parse(schema)
3098}
3099
3100pub fn reconstruct_mcp_payload(
3102 schema: &Value,
3103 values: &BTreeMap<String, Value>,
3104) -> Result<Value, GenUiError> {
3105 McpSchemaIr::parse(schema)?.reconstruct_payload(values)
3106}
3107
3108pub fn mcp_tool_surface(
3112 surface_id: impl Into<String>,
3113 tool_name: impl Into<String>,
3114 schema: &Value,
3115 state_digest: impl Into<String>,
3116) -> Result<Surface, GenUiError> {
3117 let surface_id = surface_id.into();
3118 let tool_name = tool_name.into();
3119 validate_id("tool", &tool_name)?;
3120 let ir = McpSchemaIr::parse(schema)?;
3121 let root = Component {
3122 id: "mcp-form".to_owned(),
3123 kind: ComponentKind::Form {
3124 fields: ir.form_fields(),
3125 },
3126 };
3127 let mut surface = Surface::new(surface_id, root);
3128 surface.actions.push(Action {
3129 id: ActionCatalog::new_action_id(),
3130 label: "Submit".to_owned(),
3131 kind: ActionKind::McpTool,
3132 state_digest: state_digest.into(),
3133 });
3134 surface.validate_shape(&HostCapabilities::default())?;
3135 Ok(surface)
3136}
3137
3138pub fn validate_mcp_submission(schema: &Value, arguments: &Value) -> Result<(), GenUiError> {
3139 McpSchemaIr::parse(schema)?.validate_submission(arguments)
3140}
3141
3142fn validate_schema_json_bounds(schema: &Value) -> Result<(), GenUiError> {
3143 let mut pending = vec![(schema, 0usize)];
3144 let mut nodes = 0usize;
3145 while let Some((value, depth)) = pending.pop() {
3146 if depth > MCP_SCHEMA_MAX_JSON_DEPTH {
3147 return Err(unsupported_schema(
3148 "schema_depth_exceeded",
3149 format!(
3150 "JSON nesting depth {depth} exceeds the global bound {MCP_SCHEMA_MAX_JSON_DEPTH}"
3151 ),
3152 ));
3153 }
3154 nodes = nodes.saturating_add(1);
3155 if nodes > MCP_SCHEMA_MAX_JSON_NODES {
3156 return Err(unsupported_schema(
3157 "schema_complexity_exceeded",
3158 format!("JSON node count exceeds the global bound {MCP_SCHEMA_MAX_JSON_NODES}"),
3159 ));
3160 }
3161 match value {
3162 Value::Array(items) => pending.extend(items.iter().map(|item| (item, depth + 1))),
3163 Value::Object(object) => pending.extend(object.values().map(|item| (item, depth + 1))),
3164 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
3165 }
3166 }
3167 Ok(())
3168}
3169
3170fn parse_mcp_schema(schema: &Value) -> Result<McpSchemaIr, GenUiError> {
3171 validate_schema_json_bounds(schema)?;
3172 validate_schema_serialized_size(schema)?;
3173 let object = schema
3174 .as_object()
3175 .ok_or_else(|| unsupported_schema("unsupported_type", "input schema must be an object"))?;
3176 let allowed_root = [
3177 "type",
3178 "properties",
3179 "required",
3180 "additionalProperties",
3181 "title",
3182 "description",
3183 "default",
3184 "examples",
3185 "$schema",
3186 "$id",
3187 ];
3188 reject_unknown_schema_keywords(object, &allowed_root, "root")?;
3189 if object.get("type").and_then(Value::as_str) != Some("object") {
3190 return Err(unsupported_schema(
3191 "unsupported_type",
3192 "root schema must have type object",
3193 ));
3194 }
3195 match object.get("additionalProperties") {
3196 Some(Value::Bool(false)) => {}
3197 Some(Value::Bool(true)) => {
3198 return Err(unsupported_schema(
3199 "additional_properties",
3200 "additionalProperties:true is outside the FalseGreen schema subset",
3201 ));
3202 }
3203 Some(_) => {
3204 return Err(unsupported_schema(
3205 "additional_properties",
3206 "additionalProperties must be exactly false",
3207 ));
3208 }
3209 None => {
3210 return Err(unsupported_schema(
3211 "additional_properties",
3212 "additionalProperties must be explicitly false",
3213 ));
3214 }
3215 }
3216 let dialect = object
3217 .get("$schema")
3218 .map(McpSchemaDialect::parse)
3219 .transpose()?;
3220 let title = parse_metadata(object, "title", "root")?;
3221 let description = parse_metadata(object, "description", "root")?;
3222 if object.contains_key("$id") {
3223 return Err(unsupported_schema(
3224 "unsupported_keyword",
3225 "$id is not supported by the G3 adapter",
3226 ));
3227 }
3228 if object.contains_key("examples") {
3229 return Err(unsupported_schema(
3230 "unsupported_annotation",
3231 "root examples are not supported by the G3 adapter",
3232 ));
3233 }
3234 let required = parse_required_fields(object)?;
3235 let properties = match object.get("properties") {
3236 Some(Value::Object(properties)) => properties,
3237 Some(_) => {
3238 return Err(unsupported_schema(
3239 "malformed_schema",
3240 "properties must be an object",
3241 ));
3242 }
3243 None => &Map::new(),
3244 };
3245 if properties.len() > MCP_SCHEMA_MAX_FIELDS {
3246 return Err(unsupported_schema(
3247 "schema_too_large",
3248 format!("property count exceeds {MCP_SCHEMA_MAX_FIELDS}"),
3249 ));
3250 }
3251 for name in &required {
3252 if !properties.contains_key(name) {
3253 return Err(unsupported_schema(
3254 "required_unknown_field",
3255 format!("required field {name:?} is not declared in properties"),
3256 ));
3257 }
3258 }
3259 let mut fields = Vec::with_capacity(properties.len());
3260 for (name, property) in properties {
3261 if validate_id("field", name).is_err() {
3262 return Err(unsupported_schema(
3263 "unsupported_property_name",
3264 format!("property name {name:?} cannot be represented as a trusted field ID"),
3265 ));
3266 }
3267 let property = property.as_object().ok_or_else(|| {
3268 unsupported_schema(
3269 "malformed_schema",
3270 format!("property {name:?} is not an object"),
3271 )
3272 })?;
3273 fields.push(parse_schema_field(name, property, required.contains(name))?);
3274 }
3275 let ir = McpSchemaIr {
3276 adapter: MCP_SCHEMA_ADAPTER_ID,
3277 dialect,
3278 source_digest: mcp_schema_digest(schema),
3279 additional_properties: false,
3280 title,
3281 description,
3282 fields,
3283 };
3284 if let Some(default) = object.get("default") {
3285 let default = default.as_object().ok_or_else(|| {
3286 unsupported_schema("malformed_default", "root default must be an object")
3287 })?;
3288 let default = Value::Object(default.clone());
3289 ir.validate_submission(&default).map_err(|error| {
3290 unsupported_schema(
3291 "malformed_default",
3292 format!("root default does not satisfy the exact payload validator: {error}"),
3293 )
3294 })?;
3295 }
3296 ir.validate_form_projection()?;
3297 Ok(ir)
3298}
3299
3300fn validate_schema_serialized_size(schema: &Value) -> Result<(), GenUiError> {
3301 let mut writer = SchemaSizeWriter {
3302 bytes: 0,
3303 exceeded: false,
3304 };
3305 if let Err(error) = serde_json::to_writer(&mut writer, schema)
3306 && !writer.exceeded
3307 {
3308 return Err(unsupported_schema("malformed_schema", error.to_string()));
3309 }
3310 if writer.exceeded {
3311 return Err(unsupported_schema(
3312 "schema_too_large",
3313 format!("schema exceeds the {MCP_SCHEMA_MAX_BYTES} byte adapter bound"),
3314 ));
3315 }
3316 Ok(())
3317}
3318
3319struct SchemaSizeWriter {
3320 bytes: usize,
3321 exceeded: bool,
3322}
3323
3324impl Write for SchemaSizeWriter {
3325 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
3326 let next = self.bytes.saturating_add(buffer.len());
3327 if next > MCP_SCHEMA_MAX_BYTES {
3328 self.exceeded = true;
3329 return Err(io::Error::new(
3330 io::ErrorKind::WriteZero,
3331 "MCP schema exceeds bounded size",
3332 ));
3333 }
3334 self.bytes = next;
3335 Ok(buffer.len())
3336 }
3337
3338 fn flush(&mut self) -> io::Result<()> {
3339 Ok(())
3340 }
3341}
3342
3343fn parse_schema_field(
3344 name: &str,
3345 property: &Map<String, Value>,
3346 required: bool,
3347) -> Result<McpSchemaField, GenUiError> {
3348 let allowed_property = [
3349 "type",
3350 "enum",
3351 "minimum",
3352 "maximum",
3353 "minLength",
3354 "maxLength",
3355 "pattern",
3356 "title",
3357 "description",
3358 "default",
3359 "examples",
3360 ];
3361 let type_name = property
3362 .get("type")
3363 .and_then(Value::as_str)
3364 .ok_or_else(|| {
3365 unsupported_schema("malformed_schema", format!("property {name:?} has no type"))
3366 })?;
3367 let schema_type = McpSchemaType::parse(type_name, name)?;
3368 reject_unknown_schema_keywords(property, &allowed_property, name)?;
3369 if property.contains_key("pattern") {
3370 return Err(unsupported_schema(
3371 "unsupported_keyword",
3372 format!("property {name:?} uses unsupported pattern"),
3373 ));
3374 }
3375 let allows_lengths = schema_type == McpSchemaType::String;
3376 if property
3377 .keys()
3378 .any(|keyword| matches!(keyword.as_str(), "minLength" | "maxLength") && !allows_lengths)
3379 {
3380 return Err(unsupported_schema(
3381 "wrong_type_keyword",
3382 format!("string length keyword on non-string property {name:?}"),
3383 ));
3384 }
3385 let allows_bounds = schema_type == McpSchemaType::Integer;
3386 if property
3387 .keys()
3388 .any(|keyword| matches!(keyword.as_str(), "minimum" | "maximum") && !allows_bounds)
3389 {
3390 return Err(unsupported_schema(
3391 "wrong_type_keyword",
3392 format!("numeric bound on non-integer property {name:?}"),
3393 ));
3394 }
3395 let min_length = parse_length(property, "minLength", name, allows_lengths)?;
3396 let max_length = parse_length(property, "maxLength", name, allows_lengths)?;
3397 if min_length
3398 .zip(max_length)
3399 .is_some_and(|(minimum, maximum)| minimum > maximum)
3400 {
3401 return Err(unsupported_schema(
3402 "malformed_bound",
3403 format!("property {name:?} minLength exceeds maxLength"),
3404 ));
3405 }
3406 let minimum = parse_integer_bound(property, "minimum", name, allows_bounds)?;
3407 let maximum = parse_integer_bound(property, "maximum", name, allows_bounds)?;
3408 if minimum
3409 .zip(maximum)
3410 .is_some_and(|(minimum, maximum)| minimum > maximum)
3411 {
3412 return Err(unsupported_schema(
3413 "malformed_bound",
3414 format!("property {name:?} minimum exceeds maximum"),
3415 ));
3416 }
3417 let enum_values = parse_enum(property, schema_type, name)?;
3418 if property.contains_key("examples") {
3419 return Err(unsupported_schema(
3420 "unsupported_annotation",
3421 format!("property {name:?} examples are not supported by the G3 adapter"),
3422 ));
3423 }
3424 let field = McpSchemaField {
3425 name: name.to_owned(),
3426 schema_type,
3427 required,
3428 enum_values,
3429 title: parse_metadata(property, "title", name)?,
3430 description: parse_metadata(property, "description", name)?,
3431 default: property
3432 .get("default")
3433 .map(|value| {
3434 McpScalarValue::parse(value, schema_type, name).map_err(|_| {
3435 unsupported_schema(
3436 "malformed_default",
3437 format!("property {name:?} default has the wrong type"),
3438 )
3439 })
3440 })
3441 .transpose()?,
3442 min_length,
3443 max_length,
3444 minimum,
3445 maximum,
3446 };
3447 if let Some(default) = property.get("default") {
3448 field.validate_default(default)?;
3449 }
3450 Ok(field)
3451}
3452
3453fn parse_enum(
3454 property: &Map<String, Value>,
3455 schema_type: McpSchemaType,
3456 name: &str,
3457) -> Result<Vec<McpScalarValue>, GenUiError> {
3458 let Some(values) = property.get("enum") else {
3459 return Ok(Vec::new());
3460 };
3461 let values = values.as_array().ok_or_else(|| {
3462 unsupported_schema(
3463 "malformed_enum",
3464 format!("property {name:?} enum must be an array"),
3465 )
3466 })?;
3467 if values.is_empty() {
3468 return Err(unsupported_schema(
3469 "malformed_enum",
3470 format!("property {name:?} enum must not be empty"),
3471 ));
3472 }
3473 if values.len() > MCP_SCHEMA_MAX_ENUM_VALUES {
3474 return Err(unsupported_schema(
3475 "schema_too_large",
3476 format!("property {name:?} enum exceeds {MCP_SCHEMA_MAX_ENUM_VALUES} values"),
3477 ));
3478 }
3479 let mut parsed = Vec::with_capacity(values.len());
3480 let mut display_values = BTreeSet::new();
3481 for value in values {
3482 let scalar = McpScalarValue::parse(value, schema_type, name)?;
3483 if parsed.contains(&scalar) {
3484 return Err(unsupported_schema(
3485 "duplicate_enum",
3486 format!("property {name:?} enum contains duplicate values"),
3487 ));
3488 }
3489 if !display_values.insert(scalar.display()) {
3490 return Err(unsupported_schema(
3491 "ambiguous_enum",
3492 format!("property {name:?} enum values have the same safe display label"),
3493 ));
3494 }
3495 parsed.push(scalar);
3496 }
3497 Ok(parsed)
3498}
3499
3500fn parse_length(
3501 property: &Map<String, Value>,
3502 keyword: &str,
3503 name: &str,
3504 applicable: bool,
3505) -> Result<Option<u64>, GenUiError> {
3506 let Some(value) = property.get(keyword) else {
3507 return Ok(None);
3508 };
3509 let length = value.as_u64().ok_or_else(|| {
3510 unsupported_schema(
3511 "malformed_bound",
3512 format!("property {name:?} {keyword} must be a non-negative integer"),
3513 )
3514 })?;
3515 if !applicable || length > MCP_SCHEMA_MAX_STRING_LENGTH {
3516 return Err(unsupported_schema(
3517 "wrong_type_keyword",
3518 format!("property {name:?} {keyword} is outside the exact adapter limits"),
3519 ));
3520 }
3521 Ok(Some(length))
3522}
3523
3524fn parse_integer_bound(
3525 property: &Map<String, Value>,
3526 keyword: &str,
3527 name: &str,
3528 applicable: bool,
3529) -> Result<Option<i128>, GenUiError> {
3530 let Some(value) = property.get(keyword) else {
3531 return Ok(None);
3532 };
3533 if !applicable {
3534 return Err(unsupported_schema(
3535 "wrong_type_keyword",
3536 format!("numeric bound on non-integer property {name:?}"),
3537 ));
3538 }
3539 let number = value
3540 .as_i64()
3541 .map(i128::from)
3542 .or_else(|| value.as_u64().map(i128::from));
3543 number
3544 .ok_or_else(|| {
3545 unsupported_schema(
3546 "malformed_bound",
3547 format!("property {name:?} {keyword} must be an exact integer"),
3548 )
3549 })
3550 .map(Some)
3551}
3552
3553fn parse_metadata(
3554 object: &Map<String, Value>,
3555 key: &str,
3556 location: &str,
3557) -> Result<Option<String>, GenUiError> {
3558 let Some(value) = object.get(key) else {
3559 return Ok(None);
3560 };
3561 let value = value.as_str().ok_or_else(|| {
3562 unsupported_schema(
3563 "malformed_metadata",
3564 format!("{key} at {location} must be a string"),
3565 )
3566 })?;
3567 if value.len() > DEFAULT_MAX_TEXT_BYTES {
3568 return Err(unsupported_schema(
3569 "schema_too_large",
3570 format!("{key} at {location} exceeds the presentation text limit"),
3571 ));
3572 }
3573 Ok(Some(sanitize_single_line_metadata(value)))
3574}
3575
3576fn parse_required_fields(object: &Map<String, Value>) -> Result<BTreeSet<String>, GenUiError> {
3577 let Some(required) = object.get("required") else {
3578 return Ok(BTreeSet::new());
3579 };
3580 let values = required.as_array().ok_or_else(|| {
3581 unsupported_schema("malformed_required", "required must be an array of strings")
3582 })?;
3583 if values.len() > MCP_SCHEMA_MAX_FIELDS {
3584 return Err(unsupported_schema(
3585 "schema_too_large",
3586 format!("required count exceeds {MCP_SCHEMA_MAX_FIELDS}"),
3587 ));
3588 }
3589 let mut output = BTreeSet::new();
3590 for value in values {
3591 let name = value.as_str().ok_or_else(|| {
3592 unsupported_schema("malformed_required", "required contains a non-string entry")
3593 })?;
3594 if name.is_empty() {
3595 return Err(unsupported_schema(
3596 "malformed_required",
3597 "required contains an empty property name",
3598 ));
3599 }
3600 if !output.insert(name.to_owned()) {
3601 return Err(unsupported_schema(
3602 "duplicate_required",
3603 "required contains a duplicate entry",
3604 ));
3605 }
3606 }
3607 Ok(output)
3608}
3609
3610fn reject_unknown_schema_keywords(
3611 object: &Map<String, Value>,
3612 allowed: &[&str],
3613 location: &str,
3614) -> Result<(), GenUiError> {
3615 if let Some(keyword) = object
3616 .keys()
3617 .find(|keyword| !allowed.contains(&keyword.as_str()))
3618 {
3619 return Err(unsupported_schema(
3620 "unsupported_keyword",
3621 format!("unsupported JSON-Schema keyword {keyword:?} at {location}"),
3622 ));
3623 }
3624 Ok(())
3625}
3626
3627fn unsupported_schema(reason: &str, message: impl Into<String>) -> GenUiError {
3628 GenUiError::UnsupportedMcpSchema(format!("{reason}: {}", message.into()))
3629}
3630
3631fn i128_to_json_number(value: i128) -> Option<serde_json::Number> {
3632 i64::try_from(value)
3633 .map(serde_json::Number::from)
3634 .ok()
3635 .or_else(|| u64::try_from(value).map(serde_json::Number::from).ok())
3636}
3637
3638pub fn state_surface(surface_id: impl Into<String>, state: &BTreeMap<String, String>) -> Surface {
3641 let entries = state.clone();
3642 Surface::new(
3643 surface_id,
3644 Component {
3645 id: "state-root".to_owned(),
3646 kind: ComponentKind::KeyValue { entries },
3647 },
3648 )
3649}
3650
3651#[must_use]
3652pub fn session_surface(session: &crate::session::Session) -> Surface {
3653 let mut entries = BTreeMap::new();
3654 entries.insert("session".to_owned(), session.id.clone());
3655 entries.insert("state".to_owned(), format!("{:?}", session.state));
3656 entries.insert("goal".to_owned(), session.goal.clone());
3657 entries.insert("model_turns".to_owned(), session.model_turns.to_string());
3658 entries.insert("tool_calls".to_owned(), session.tool_calls.to_string());
3659 entries.insert(
3660 "repair_cycles".to_owned(),
3661 session.repair_cycles.to_string(),
3662 );
3663 state_surface("agent-session", &entries)
3664}
3665
3666#[must_use]
3667pub fn falsegreen_result_surface(result: &crate::falsegreen::FalseGreenResult) -> Surface {
3668 let mut entries = BTreeMap::new();
3669 entries.insert(
3670 "verification".to_owned(),
3671 format!("{:?}", result.verification),
3672 );
3673 entries.insert(
3674 "verification_status".to_owned(),
3675 result.verification_status.clone(),
3676 );
3677 entries.insert(
3678 "candidate_sha256".to_owned(),
3679 result.candidate_sha256.clone(),
3680 );
3681 entries.insert(
3682 "completion_authority".to_owned(),
3683 format!("{:?}", result.completion_authority),
3684 );
3685 let evidence = vec![EvidenceItem {
3686 label: "canonical evidence".to_owned(),
3687 value: sanitize_text(&result.evidence.to_string()),
3688 }];
3689 Surface::new(
3690 "falsegreen-verification",
3691 Component {
3692 id: "verification".to_owned(),
3693 kind: ComponentKind::Stack {
3694 children: vec![
3695 Component {
3696 id: "facts".to_owned(),
3697 kind: ComponentKind::KeyValue { entries },
3698 },
3699 Component {
3700 id: "evidence".to_owned(),
3701 kind: ComponentKind::Evidence {
3702 title: "Evidence (diagnostic projection)".to_owned(),
3703 items: evidence,
3704 },
3705 },
3706 ],
3707 },
3708 },
3709 )
3710}
3711
3712#[must_use]
3713pub fn run_outcome_surface(outcome: &crate::agent::RunOutcome) -> Surface {
3714 let mut entries = BTreeMap::new();
3715 entries.insert("session".to_owned(), outcome.session_id.clone());
3716 entries.insert("state".to_owned(), format!("{:?}", outcome.state));
3717 entries.insert("model_turns".to_owned(), outcome.model_turns.to_string());
3718 entries.insert("tool_calls".to_owned(), outcome.tool_calls.to_string());
3719 entries.insert(
3720 "repair_cycles".to_owned(),
3721 outcome.repair_cycles.to_string(),
3722 );
3723 if let Some(result) = &outcome.falsegreen_result {
3724 entries.insert(
3725 "verification".to_owned(),
3726 format!("{:?}", result.verification),
3727 );
3728 entries.insert(
3729 "candidate_sha256".to_owned(),
3730 result.candidate_sha256.clone(),
3731 );
3732 entries.insert(
3733 "completion_authority".to_owned(),
3734 format!("{:?}", result.completion_authority),
3735 );
3736 }
3737 state_surface("agent-run", &entries)
3738}
3739#[cfg(test)]
3740mod v3_tests {
3741 use super::*;
3742 use serde_json::json;
3743
3744 fn establish_state(
3745 catalog: &mut ActionCatalog,
3746 store: &mut EventStore,
3747 session_id: &str,
3748 principal: &str,
3749 digest: &str,
3750 authorization_context: &str,
3751 ) {
3752 let generation = store
3753 .advance_genui_current_state(session_id, principal, digest, authorization_context)
3754 .expect("trusted state");
3755 catalog
3756 .set_current_state(
3757 session_id,
3758 principal,
3759 generation,
3760 digest,
3761 authorization_context,
3762 )
3763 .expect("catalog state");
3764 }
3765
3766 fn bound_surface(kind: ActionKind) -> (Surface, ActionCatalog) {
3767 let mut surface = Surface::new("surface", Component::text("root", "ok"));
3768 surface.actions.push(Action {
3769 id: ActionCatalog::new_action_id(),
3770 label: "Run tool".to_owned(),
3771 kind,
3772 state_digest: "state-v1".to_owned(),
3773 });
3774 let digest = surface.digest().expect("surface digest");
3775 let action = &surface.actions[0];
3776 let mut catalog = ActionCatalog::default();
3777 catalog
3778 .bind_mcp_action_with_context(
3779 action,
3780 &surface.id,
3781 &digest,
3782 "session-1",
3783 "principal-1",
3784 "default",
3785 1,
3786 &action.state_digest,
3787 "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
3788 "server",
3789 "mcp__server__tool__aaaaaaaaaaaaaaaa",
3790 &"b".repeat(64),
3791 1,
3792 &sha256_hex("falsegreen.tool-policy.v1|consequential|1".as_bytes()),
3793 kind == ActionKind::Consequential,
3794 ActionSourceType::Mcp,
3795 )
3796 .expect("host binding");
3797 catalog
3798 .set_remote_tool_name(action.id.as_str(), "remote-tool")
3799 .expect("remote identity");
3800 (surface, catalog)
3801 }
3802
3803 #[test]
3804 fn model_action_has_no_executable_authority_and_catalog_is_exact() {
3805 let (surface, mut catalog) = bound_surface(ActionKind::Consequential);
3806 catalog
3807 .set_current_state("session-1", "principal-1", 1, "state-v1", "default")
3808 .expect("trusted state");
3809 let encoded = serde_json::to_value(&surface.actions[0]).expect("serialize");
3810 assert!(encoded.get("binding").is_none());
3811 assert!(
3812 serde_json::from_value::<Action>(json!({
3813 "id": surface.actions[0].id,
3814 "label": "Run tool",
3815 "kind": "consequential",
3816 "state_digest": "state-v1",
3817 "callback": "shell"
3818 }))
3819 .is_err()
3820 );
3821 catalog
3822 .validate_surface(&surface, &HostCapabilities::default())
3823 .expect("admission");
3824 let mut relabeled = surface.clone();
3825 relabeled.actions[0].label = "Different".to_owned();
3826 assert!(
3827 catalog
3828 .validate_surface(&relabeled, &HostCapabilities::default())
3829 .is_err()
3830 );
3831 let mut rebound = surface.clone();
3832 rebound.actions[0].kind = ActionKind::McpTool;
3833 assert!(
3834 catalog
3835 .validate_surface(&rebound, &HostCapabilities::default())
3836 .is_err()
3837 );
3838 }
3839
3840 #[test]
3841 fn confirmation_is_host_event_and_replay_is_durable_lifecycle() {
3842 let (surface, mut catalog) = bound_surface(ActionKind::Consequential);
3843 let action_id = surface.actions[0].id.clone();
3844 let payload = json!({"mode": "safe"});
3845 let mut store = EventStore::open_memory().expect("store");
3846 establish_state(
3847 &mut catalog,
3848 &mut store,
3849 "session-1",
3850 "principal-1",
3851 "state-v1",
3852 "default",
3853 );
3854 catalog
3855 .present_action(&mut store, "session-1", &action_id)
3856 .expect("present");
3857 let confirmation = catalog
3858 .request_confirmation(&mut store, "session-1", &action_id, &payload)
3859 .expect("request");
3860 catalog
3861 .append_confirmed_after_live_validation(&mut store, &confirmation)
3862 .expect("confirm");
3863 let identity = catalog
3864 .identity_for(&action_id, &payload)
3865 .expect("identity");
3866 store
3867 .start_genui_action("session-1", &action_id, &identity, true)
3868 .expect("start");
3869 assert!(matches!(
3870 store.start_genui_action("session-1", &action_id, &identity, true),
3871 Err(EventError::GenUiActionAlreadyStarted(_))
3872 ));
3873 let events = store.events("session-1").expect("events");
3874 assert!(
3875 events
3876 .iter()
3877 .any(|event| event.kind == EventKind::GenUiActionConfirmed)
3878 );
3879 assert!(
3880 events
3881 .iter()
3882 .any(|event| event.kind == EventKind::GenUiActionExecutionStarted)
3883 );
3884 }
3885
3886 #[test]
3887 fn restart_reconciles_unknown_external_outcome_without_retry() {
3888 let (surface, mut catalog) = bound_surface(ActionKind::Consequential);
3889 let action_id = surface.actions[0].id.clone();
3890 let payload = json!({"mode": "safe"});
3891 let mut store = EventStore::open_memory().expect("store");
3892 establish_state(
3893 &mut catalog,
3894 &mut store,
3895 "session-1",
3896 "principal-1",
3897 "state-v1",
3898 "default",
3899 );
3900 catalog
3901 .present_action(&mut store, "session-1", &action_id)
3902 .expect("present");
3903 let confirmation = catalog
3904 .request_confirmation(&mut store, "session-1", &action_id, &payload)
3905 .expect("request");
3906 catalog
3907 .append_confirmed_after_live_validation(&mut store, &confirmation)
3908 .expect("confirm");
3909 let identity = catalog
3910 .identity_for(&action_id, &payload)
3911 .expect("identity");
3912 store
3913 .start_genui_action("session-1", &action_id, &identity, true)
3914 .expect("start");
3915 assert_eq!(
3916 store
3917 .reconcile_genui_actions("session-1")
3918 .expect("reconcile"),
3919 1
3920 );
3921 assert!(matches!(
3922 store.start_genui_action("session-1", &action_id, &identity, true),
3923 Err(EventError::GenUiActionOutcomeUnknown(_))
3924 ));
3925 }
3926
3927 #[test]
3928 fn persisted_lifecycle_handles_crash_windows_without_replay() {
3929 let (surface, mut catalog) = bound_surface(ActionKind::Consequential);
3930 let action_id = surface.actions[0].id.clone();
3931 let payload = json!({"mode": "safe"});
3932 let directory = tempfile::tempdir().expect("temporary event store");
3933 let path = directory.path().join("events.db");
3934
3935 {
3938 let mut store = EventStore::open(&path).expect("open");
3939 establish_state(
3940 &mut catalog,
3941 &mut store,
3942 "session-1",
3943 "principal-1",
3944 "state-v1",
3945 "default",
3946 );
3947 catalog
3948 .present_action(&mut store, "session-1", &action_id)
3949 .expect("present");
3950 let confirmation = catalog
3951 .request_confirmation(&mut store, "session-1", &action_id, &payload)
3952 .expect("request");
3953 catalog
3954 .append_confirmed_after_live_validation(&mut store, &confirmation)
3955 .expect("confirm");
3956 }
3957 let mut store = EventStore::open(&path).expect("reopen");
3958 let identity = catalog
3959 .identity_for(&action_id, &payload)
3960 .expect("identity");
3961 store
3962 .start_genui_action("session-1", &action_id, &identity, true)
3963 .expect("retry after pre-start crash");
3964 drop(store);
3965
3966 let mut store = EventStore::open(&path).expect("reopen after start");
3969 assert_eq!(
3970 store
3971 .reconcile_genui_actions("session-1")
3972 .expect("reconcile"),
3973 1
3974 );
3975 assert!(matches!(
3976 store.start_genui_action("session-1", &action_id, &identity, true),
3977 Err(EventError::GenUiActionOutcomeUnknown(_))
3978 ));
3979
3980 let completed_path = directory.path().join("completed.db");
3983 {
3984 let mut completed = EventStore::open(&completed_path).expect("completed store");
3985 establish_state(
3986 &mut catalog,
3987 &mut completed,
3988 "session-1",
3989 "principal-1",
3990 "state-v1",
3991 "default",
3992 );
3993 catalog
3994 .present_action(&mut completed, "session-1", &action_id)
3995 .expect("present");
3996 let confirmation = catalog
3997 .request_confirmation(&mut completed, "session-1", &action_id, &payload)
3998 .expect("request");
3999 catalog
4000 .append_confirmed_after_live_validation(&mut completed, &confirmation)
4001 .expect("confirm");
4002 completed
4003 .start_genui_action("session-1", &action_id, &identity, true)
4004 .expect("start");
4005 completed
4006 .complete_genui_action("session-1", &action_id, &identity, true, &"c".repeat(64))
4007 .expect("complete");
4008 }
4009 let mut completed = EventStore::open(&completed_path).expect("reopen completed store");
4010 assert!(matches!(
4011 completed.start_genui_action("session-1", &action_id, &identity, true),
4012 Err(EventError::GenUiActionAlreadyStarted(_))
4013 ));
4014 }
4015
4016 #[test]
4017 fn schema_subset_fails_closed_for_unsupported_constructs() {
4018 for schema in [
4019 json!({"type":"object","properties":{},"additionalProperties":true}),
4020 json!({"type":"object","properties":{}}),
4021 ] {
4022 assert!(matches!(
4023 validate_mcp_submission(&schema, &json!({})),
4024 Err(GenUiError::UnsupportedMcpSchema(_))
4025 ));
4026 }
4027 let exact = json!({
4028 "type":"object",
4029 "properties":{"x":{"type":"string"}},
4030 "additionalProperties":false
4031 });
4032 assert!(validate_mcp_submission(&exact, &json!({"x":"ok"})).is_ok());
4033 assert!(matches!(
4034 validate_mcp_submission(&exact, &json!({"x":"ok","extra":true})),
4035 Err(GenUiError::InvalidMcpSubmission(_))
4036 ));
4037 let patterned =
4038 json!({"type":"object","properties":{"x":{"type":"string","pattern":"^[A-Z]+$"}}});
4039 assert!(matches!(
4040 validate_mcp_submission(&patterned, &json!({"x":"OK"})),
4041 Err(GenUiError::UnsupportedMcpSchema(_))
4042 ));
4043 let schema_valued = json!({"type":"object","properties":{"x":{"type":"string"}},"additionalProperties":{"type":"string"}});
4044 assert!(matches!(
4045 validate_mcp_submission(&schema_valued, &json!({"x":"ok","y":"ok"})),
4046 Err(GenUiError::UnsupportedMcpSchema(_))
4047 ));
4048 let integer = json!({"type":"object","properties":{"x":{"type":"integer"}},"additionalProperties":false});
4049 assert!(validate_mcp_submission(&integer, &json!({"x":1})).is_ok());
4050 assert!(validate_mcp_submission(&integer, &json!({"x":1.0})).is_err());
4051 let duplicate_enum =
4052 json!({"type":"object","properties":{"x":{"type":"string","enum":["a","a"]}}});
4053 assert!(matches!(
4054 mcp_tool_surface(
4055 "surface",
4056 "mcp__server__tool__aaaaaaaaaaaaaaaa",
4057 &duplicate_enum,
4058 "state"
4059 ),
4060 Err(GenUiError::UnsupportedMcpSchema(_))
4061 ));
4062 }
4063
4064 #[test]
4065 fn labels_reject_terminal_spoofing_and_width_is_display_cell_based() {
4066 let mut surface = Surface::new("surface", Component::text("root", "界e\u{301}"));
4067 surface.actions.push(Action {
4068 id: ActionCatalog::new_action_id(),
4069 label: "bad\u{202e}label".to_owned(),
4070 kind: ActionKind::McpTool,
4071 state_digest: "state".to_owned(),
4072 });
4073 assert!(surface.validate().is_err());
4074 let negotiated = default_negotiated_capabilities(4);
4075 let safe = Surface::new("safe", Component::text("root", "界e\u{301}"));
4076 let rendered = render_surface_with_capabilities(&safe, &negotiated).expect("render");
4077 use unicode_width::UnicodeWidthStr;
4078 assert!(rendered.lines().all(|line| line.width() <= 4));
4079 }
4080
4081 #[test]
4082 fn negotiated_component_policy_and_shape_bounds_remain_intact() {
4083 let surface = Surface::new("surface", Component::text("root", "ok"));
4084 let mut host = HostCapabilities::default();
4085 host.supported_components.remove("text");
4086 host.unsupported_component_policy = UnsupportedComponentPolicy::Reject;
4087 assert!(matches!(
4088 surface.validate_with(&host),
4089 Err(GenUiError::UnsupportedComponent { .. })
4090 ));
4091 host.unsupported_component_policy = UnsupportedComponentPolicy::Placeholder;
4092 surface.validate_with(&host).expect("placeholder policy");
4093 let oversized = Surface::new(
4094 "large",
4095 Component::text("root", "x".repeat(DEFAULT_MAX_TEXT_BYTES + 1)),
4096 );
4097 assert!(oversized.validate().is_err());
4098 }
4099
4100 #[test]
4101 fn terminal_control_sequences_are_inert_in_general_text() {
4102 let value = sanitize_text(
4103 "\u{1b}[31mred\u{1b}[0m \u{1b}]8;;https://evil.example\u{7}link\u{1b}]8;;\u{7} \u{1b}Psecret\u{1b}\\tail",
4104 );
4105 assert_eq!(value, "red link tail");
4106 assert!(!value.contains('\u{1b}'));
4107 }
4108
4109 #[test]
4110 fn protocol_negotiation_rejects_unknown_major_versions() {
4111 let host = HostCapabilities::default();
4112 assert!(matches!(
4113 negotiate(&[ProtocolVersion { major: 2, minor: 0 }], &host),
4114 Err(GenUiError::UnsupportedProtocol { .. })
4115 ));
4116 }
4117}
4118
4119#[cfg(test)]
4120mod v4_tests {
4121 use super::*;
4122 use serde_json::json;
4123
4124 fn bound() -> (Surface, ActionCatalog) {
4125 let mut surface = Surface::new("v4-surface", Component::text("root", "ok"));
4126 surface.actions.push(Action {
4127 id: ActionCatalog::new_action_id(),
4128 label: "Run".to_owned(),
4129 kind: ActionKind::Consequential,
4130 state_digest: "state-v4".to_owned(),
4131 });
4132 let digest = surface.digest().expect("surface digest");
4133 let action = &surface.actions[0];
4134 let mut catalog = ActionCatalog::default();
4135 catalog
4136 .bind_mcp_action_with_context(
4137 action,
4138 &surface.id,
4139 &digest,
4140 "session-v4",
4141 "principal-v4",
4142 "default",
4143 1,
4144 &action.state_digest,
4145 "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4146 "server-v4",
4147 "mcp__server__tool__aaaaaaaaaaaaaaaa",
4148 &"a".repeat(64),
4149 1,
4150 &sha256_hex("falsegreen.tool-policy.v1|consequential|1".as_bytes()),
4151 true,
4152 ActionSourceType::Mcp,
4153 )
4154 .expect("bind");
4155 catalog
4156 .set_remote_tool_name(action.id.as_str(), "remote-tool")
4157 .expect("remote identity");
4158 (surface, catalog)
4159 }
4160
4161 #[test]
4162 fn lifecycle_rejects_out_of_order_and_terminal_resurrection() {
4163 let (surface, mut catalog) = bound();
4164 let id = &surface.actions[0].id;
4165 let payload = json!({"x": "y"});
4166 let identity = catalog.identity_for(id, &payload).expect("identity");
4167 let mut store = EventStore::open_memory().expect("store");
4168 let generation = store
4169 .advance_genui_current_state("session-v4", "principal-v4", "state-v4", "default")
4170 .expect("trusted state");
4171 catalog
4172 .set_current_state(
4173 "session-v4",
4174 "principal-v4",
4175 generation,
4176 "state-v4",
4177 "default",
4178 )
4179 .expect("catalog state");
4180 assert!(
4181 store
4182 .append("session-v4", EventKind::GenUiActionConfirmed, &identity)
4183 .is_err()
4184 );
4185 catalog
4186 .present_action(&mut store, "session-v4", id)
4187 .expect("present");
4188 let confirmation = catalog
4189 .request_confirmation(&mut store, "session-v4", id, &payload)
4190 .expect("request");
4191 catalog
4192 .append_confirmed_after_live_validation(&mut store, &confirmation)
4193 .expect("confirm");
4194 store
4195 .start_genui_action("session-v4", id, &identity, true)
4196 .expect("start");
4197 store
4198 .complete_genui_action("session-v4", id, &identity, true, &"d".repeat(64))
4199 .expect("terminal");
4200 assert!(
4201 catalog
4202 .request_confirmation(&mut store, "session-v4", id, &payload)
4203 .is_err()
4204 );
4205 assert!(
4206 store
4207 .start_genui_action("session-v4", id, &identity, true)
4208 .is_err()
4209 );
4210 }
4211
4212 #[test]
4213 fn generic_event_append_cannot_write_genui_namespace() {
4214 let (_, catalog) = bound();
4215 let identity = catalog
4216 .identity_for(catalog.bindings.keys().next().expect("action"), &json!({}))
4217 .expect("identity");
4218 let mut store = EventStore::open_memory().expect("store");
4219 assert!(matches!(
4220 store.append("session-v4", EventKind::GenUiActionPresented, &identity),
4221 Err(EventError::GenUiEventMustUseLifecycle)
4222 ));
4223 }
4224
4225 #[test]
4226 fn host_state_and_renderer_admission_stale_actions() {
4227 let (surface, mut catalog) = bound();
4228 catalog
4229 .set_current_state("session-v4", "principal-v4", 2, "state-v5", "default")
4230 .expect("state update");
4231 assert!(
4232 catalog
4233 .validate_surface(&surface, &HostCapabilities::default())
4234 .is_err()
4235 );
4236
4237 let mut safe_surface = surface.clone();
4238 safe_surface.actions[0].state_digest = "state-v5".to_owned();
4239 let digest = safe_surface.digest().expect("digest");
4240 catalog.remove(&safe_surface.actions[0].id);
4241 catalog
4242 .bind_mcp_action_with_context(
4243 &safe_surface.actions[0],
4244 &safe_surface.id,
4245 &digest,
4246 "session-v4",
4247 "principal-v4",
4248 "default",
4249 2,
4250 "state-v5",
4251 "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4252 "server-v4",
4253 "mcp__server__tool__aaaaaaaaaaaaaaaa",
4254 &"a".repeat(64),
4255 1,
4256 &sha256_hex("falsegreen.tool-policy.v1|consequential|1".as_bytes()),
4257 true,
4258 ActionSourceType::Mcp,
4259 )
4260 .expect("rebind");
4261 let mut caps = default_negotiated_capabilities(80);
4262 caps.supported_components.remove("text");
4263 let rendered =
4264 render_surface_with_capabilities_and_catalog(&safe_surface, &caps, &mut catalog)
4265 .expect("placeholder render");
4266 assert!(rendered.contains("unsupported component"));
4267 assert!(!rendered.contains("gui_action_"));
4268 assert!(catalog.resolve(&safe_surface.actions[0].id).is_none());
4269 }
4270
4271 #[test]
4272 fn catalog_mutations_are_transactional_and_bump_once() {
4273 let mut store = EventStore::open_memory().expect("store");
4274 let generation = store
4275 .advance_genui_current_state("tx-session", "tx-principal", "state", "auth")
4276 .expect("durable state");
4277 let action = Action {
4278 id: ActionCatalog::new_action_id(),
4279 label: "Read".to_owned(),
4280 kind: ActionKind::McpTool,
4281 state_digest: "state".to_owned(),
4282 };
4283 let surface = Surface::new("tx-surface", Component::text("root", "ok"));
4284 let surface_digest = surface.digest().expect("surface digest");
4285 let schema_digest = "a".repeat(64);
4286 let mut catalog = ActionCatalog::default();
4287 let initial_generation = catalog.generation();
4288 catalog
4289 .bind_mcp_action_with_remote(
4290 &store,
4291 &action,
4292 &surface.id,
4293 &surface_digest,
4294 "tx-session",
4295 "tx-principal",
4296 "provider",
4297 "server",
4298 "exposed",
4299 "remote",
4300 &schema_digest,
4301 false,
4302 )
4303 .expect("successful transactional bind");
4304 assert_eq!(catalog.generation(), initial_generation + 1);
4305 let committed_digest = catalog.digest();
4306 assert!(catalog.resolve(&action.id).is_some());
4307 catalog
4308 .bind_action_owner(&action.id, "owner-one")
4309 .expect("first owner");
4310 let owner_generation = catalog.generation();
4311 let owner_digest = catalog.digest();
4312 assert!(catalog.bind_action_owner(&action.id, "owner-two").is_err());
4313 assert_eq!(catalog.generation(), owner_generation);
4314 assert_eq!(catalog.digest(), owner_digest);
4315
4316 let before_failed_bind = catalog.digest();
4317 let before_failed_generation = catalog.generation();
4318 assert!(
4319 catalog
4320 .bind_mcp_action_with_remote(
4321 &store,
4322 &action,
4323 &surface.id,
4324 &surface_digest,
4325 "tx-session",
4326 "tx-principal",
4327 "provider",
4328 "server",
4329 "exposed",
4330 "",
4331 &schema_digest,
4332 false,
4333 )
4334 .is_err()
4335 );
4336 assert_eq!(catalog.generation(), before_failed_generation);
4337 assert_eq!(catalog.digest(), before_failed_bind);
4338
4339 let stale = Action {
4340 id: ActionCatalog::new_action_id(),
4341 label: "Stale".to_owned(),
4342 kind: ActionKind::McpTool,
4343 state_digest: "stale-state".to_owned(),
4344 };
4345 let stale_digest = catalog.digest();
4346 let stale_generation = catalog.generation();
4347 assert!(
4348 catalog
4349 .bind_mcp_action_with_remote(
4350 &store,
4351 &stale,
4352 &surface.id,
4353 &surface_digest,
4354 "tx-session",
4355 "tx-principal",
4356 "provider",
4357 "server",
4358 "exposed",
4359 "remote",
4360 &schema_digest,
4361 false,
4362 )
4363 .is_err()
4364 );
4365 assert_eq!(catalog.generation(), stale_generation);
4366 assert_eq!(catalog.digest(), stale_digest);
4367 assert!(catalog.resolve(&stale.id).is_none());
4368
4369 let invalid = Action {
4370 id: ActionCatalog::new_action_id(),
4371 label: "Local".to_owned(),
4372 kind: ActionKind::Navigation,
4373 state_digest: "state".to_owned(),
4374 };
4375 let invalid_digest = catalog.digest();
4376 let invalid_generation = catalog.generation();
4377 assert!(
4378 catalog
4379 .bind_mcp_action_with_context(
4380 &invalid,
4381 &surface.id,
4382 &surface_digest,
4383 "tx-session",
4384 "tx-principal",
4385 "auth",
4386 generation,
4387 "state",
4388 "provider",
4389 "server",
4390 "exposed",
4391 &schema_digest,
4392 1,
4393 &"b".repeat(64),
4394 false,
4395 ActionSourceType::Mcp,
4396 )
4397 .is_err()
4398 );
4399 assert_eq!(catalog.generation(), invalid_generation);
4400 assert_eq!(catalog.digest(), invalid_digest);
4401 assert_ne!(committed_digest, owner_digest);
4402 }
4403
4404 #[test]
4405 fn public_binding_requires_existing_durable_state_and_cannot_seed_authority() {
4406 let mut surface = Surface::new("safe-bind", Component::text("root", "ok"));
4407 surface.actions.push(Action {
4408 id: ActionCatalog::new_action_id(),
4409 label: "Run".to_owned(),
4410 kind: ActionKind::Consequential,
4411 state_digest: "attacker".to_owned(),
4412 });
4413 let digest = surface.digest().expect("surface digest");
4414 let mut catalog = ActionCatalog::default();
4415 let store = EventStore::open_memory().expect("store");
4416 let result = catalog.bind_mcp_action_with_remote(
4417 &store,
4418 &surface.actions[0],
4419 &surface.id,
4420 &digest,
4421 "safe-session",
4422 "safe-principal",
4423 "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4424 "server",
4425 "mcp__server__tool__aaaaaaaaaaaaaaaa",
4426 "remote-tool",
4427 &"b".repeat(64),
4428 true,
4429 );
4430 assert!(result.is_err());
4431 assert!(catalog.resolve(&surface.actions[0].id).is_none());
4432 assert!(
4433 store
4434 .genui_current_state("safe-session", "safe-principal")
4435 .expect("state query")
4436 .is_none()
4437 );
4438 }
4439
4440 #[test]
4441 fn store_renderer_fails_closed_for_deleted_or_advanced_state() {
4442 let (surface, mut catalog) = bound();
4443 let directory = tempfile::tempdir().expect("store directory");
4444 let path = directory.path().join("events.db");
4445 let mut store = EventStore::open(&path).expect("store");
4446 let generation = store
4447 .advance_genui_current_state("session-v4", "principal-v4", "state-v4", "default")
4448 .expect("state");
4449 catalog
4450 .set_current_state(
4451 "session-v4",
4452 "principal-v4",
4453 generation,
4454 "state-v4",
4455 "default",
4456 )
4457 .expect("catalog state");
4458 let capabilities = default_negotiated_capabilities(80);
4459 let rendered = render_surface_with_capabilities_and_catalog_with_store(
4460 &surface,
4461 &capabilities,
4462 &mut catalog,
4463 &store,
4464 )
4465 .expect("valid render");
4466 assert!(rendered.contains(&surface.actions[0].id));
4467
4468 store
4469 .advance_genui_current_state("session-v4", "principal-v4", "state-v5", "default")
4470 .expect("state advance");
4471 let mut advanced_catalog = catalog.clone();
4472 let rendered = render_surface_with_capabilities_and_catalog_with_store(
4473 &surface,
4474 &capabilities,
4475 &mut advanced_catalog,
4476 &store,
4477 )
4478 .expect("stale state is a valid presentation");
4479 assert!(!rendered.contains(&surface.actions[0].id));
4480 assert!(advanced_catalog.resolve(&surface.actions[0].id).is_none());
4481
4482 let path = store.path().expect("path").to_owned();
4483 drop(store);
4484 rusqlite::Connection::open(&path)
4485 .expect("raw connection")
4486 .execute(
4487 "DELETE FROM genui_current_states WHERE session_id = ?1 AND principal = ?2",
4488 rusqlite::params!["session-v4", "principal-v4"],
4489 )
4490 .expect("delete state");
4491 let store = EventStore::open(&path).expect("reopen");
4492 let rendered = render_surface_with_capabilities_and_catalog_with_store(
4493 &surface,
4494 &capabilities,
4495 &mut catalog,
4496 &store,
4497 )
4498 .expect("missing state is a valid presentation");
4499 assert!(!rendered.contains(&surface.actions[0].id));
4500 assert!(catalog.resolve(&surface.actions[0].id).is_none());
4501 }
4502
4503 #[test]
4504 fn store_renderer_preserves_valid_sibling_when_other_state_is_missing() {
4505 let mut surface = Surface::new(
4506 "mixed-store",
4507 Component {
4508 id: "root".to_owned(),
4509 kind: ComponentKind::Stack {
4510 children: vec![
4511 Component::text("valid", "valid"),
4512 Component::text("stale", "stale"),
4513 ],
4514 },
4515 },
4516 );
4517 surface.actions = vec![
4518 Action {
4519 id: "gui_action_store_valid".to_owned(),
4520 label: "Valid".to_owned(),
4521 kind: ActionKind::McpTool,
4522 state_digest: "state-v4".to_owned(),
4523 },
4524 Action {
4525 id: "gui_action_store_stale".to_owned(),
4526 label: "Stale".to_owned(),
4527 kind: ActionKind::McpTool,
4528 state_digest: "state-v4".to_owned(),
4529 },
4530 ];
4531 let digest = surface.digest().expect("surface digest");
4532 let policy = sha256_hex("falsegreen.tool-policy.v1|read_only|1".as_bytes());
4533 let mut catalog = ActionCatalog::default();
4534 for (action, session_id, principal) in [
4535 (
4536 &surface.actions[0],
4537 "mixed-valid-session",
4538 "mixed-valid-principal",
4539 ),
4540 (
4541 &surface.actions[1],
4542 "mixed-stale-session",
4543 "mixed-stale-principal",
4544 ),
4545 ] {
4546 catalog
4547 .bind_mcp_action_with_context(
4548 action,
4549 &surface.id,
4550 &digest,
4551 session_id,
4552 principal,
4553 "default",
4554 1,
4555 "state-v4",
4556 "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4557 "server",
4558 "mcp__server__tool__aaaaaaaaaaaaaaaa",
4559 &"a".repeat(64),
4560 1,
4561 &policy,
4562 false,
4563 ActionSourceType::Mcp,
4564 )
4565 .expect("binding");
4566 }
4567 let directory = tempfile::tempdir().expect("store directory");
4568 let path = directory.path().join("events.db");
4569 let mut store = EventStore::open(&path).expect("store");
4570 let generation = store
4571 .advance_genui_current_state(
4572 "mixed-valid-session",
4573 "mixed-valid-principal",
4574 "state-v4",
4575 "default",
4576 )
4577 .expect("valid durable state");
4578 catalog
4579 .set_current_state(
4580 "mixed-valid-session",
4581 "mixed-valid-principal",
4582 generation,
4583 "state-v4",
4584 "default",
4585 )
4586 .expect("valid catalog state");
4587 let rendered = render_surface_with_capabilities_and_catalog_with_store(
4588 &surface,
4589 &default_negotiated_capabilities(80),
4590 &mut catalog,
4591 &store,
4592 )
4593 .expect("mixed render");
4594 assert!(rendered.contains("gui_action_store_valid"));
4595 assert!(!rendered.contains("gui_action_store_stale"));
4596 assert!(catalog.resolve("gui_action_store_valid").is_some());
4597 assert!(catalog.resolve("gui_action_store_stale").is_none());
4598 }
4599
4600 #[test]
4601 fn store_renderer_validates_exact_incoming_action_and_preserves_valid_siblings() {
4602 let mut surface = Surface::new(
4603 "exact-render",
4604 Component {
4605 id: "root".to_owned(),
4606 kind: ComponentKind::Stack {
4607 children: vec![Component::text("one", "one"), Component::text("two", "two")],
4608 },
4609 },
4610 );
4611 surface.actions = vec![
4612 Action {
4613 id: "gui_action_exact_one".to_owned(),
4614 label: "One".to_owned(),
4615 kind: ActionKind::McpTool,
4616 state_digest: "state-exact".to_owned(),
4617 },
4618 Action {
4619 id: "gui_action_exact_two".to_owned(),
4620 label: "Two".to_owned(),
4621 kind: ActionKind::McpTool,
4622 state_digest: "state-exact".to_owned(),
4623 },
4624 ];
4625 let digest = surface.digest().expect("structural digest");
4626 let policy = sha256_hex("falsegreen.tool-policy.v1|read_only|1".as_bytes());
4627 let mut catalog = ActionCatalog::default();
4628 for action in &surface.actions {
4629 catalog
4630 .bind_mcp_action_with_context(
4631 action,
4632 &surface.id,
4633 &digest,
4634 "exact-session",
4635 "exact-principal",
4636 "default",
4637 1,
4638 "state-exact",
4639 "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4640 "server",
4641 "mcp__server__tool__aaaaaaaaaaaaaaaa",
4642 &"a".repeat(64),
4643 1,
4644 &policy,
4645 false,
4646 ActionSourceType::Mcp,
4647 )
4648 .expect("binding");
4649 catalog
4650 .set_remote_tool_name(&action.id, "remote-tool")
4651 .expect("remote identity");
4652 }
4653 let mut store = EventStore::open_memory().expect("store");
4654 let generation = store
4655 .advance_genui_current_state(
4656 "exact-session",
4657 "exact-principal",
4658 "state-exact",
4659 "default",
4660 )
4661 .expect("state");
4662 catalog
4663 .set_current_state(
4664 "exact-session",
4665 "exact-principal",
4666 generation,
4667 "state-exact",
4668 "default",
4669 )
4670 .expect("catalog state");
4671 let caps = default_negotiated_capabilities(80);
4672 let valid = render_surface_with_capabilities_and_catalog_with_store(
4673 &surface,
4674 &caps,
4675 &mut catalog.clone(),
4676 &store,
4677 )
4678 .expect("valid render");
4679 assert!(valid.contains("gui_action_exact_one"));
4680 assert!(valid.contains("gui_action_exact_two"));
4681
4682 let mut mutated = surface.clone();
4683 mutated.actions[0].label = "Totally Safe".to_owned();
4684 let mut mutated_catalog = catalog.clone();
4685 let rendered = render_surface_with_capabilities_and_catalog_with_store(
4686 &mutated,
4687 &caps,
4688 &mut mutated_catalog,
4689 &store,
4690 )
4691 .expect("mutated render");
4692 assert!(!rendered.contains("gui_action_exact_one"));
4693 assert!(rendered.contains("gui_action_exact_two"));
4694
4695 let mut revoked_catalog = catalog;
4696 revoked_catalog.remove("gui_action_exact_one");
4697 let rendered = render_surface_with_capabilities_and_catalog_with_store(
4698 &surface,
4699 &caps,
4700 &mut revoked_catalog,
4701 &store,
4702 )
4703 .expect("revoked render");
4704 assert!(!rendered.contains("gui_action_exact_one"));
4705 }
4706
4707 #[test]
4708 fn dialect_and_numeric_subset_are_exact() {
4709 for dialect in [
4710 "urn:attacker-draft-07-marker",
4711 "draft-07-but-not-really",
4712 "https://json-schema.org/draft/2019-09/schema",
4713 ] {
4714 assert!(
4715 validate_mcp_submission(&json!({"$schema": dialect, "type": "object"}), &json!({}))
4716 .is_err()
4717 );
4718 }
4719 assert!(validate_mcp_submission(
4720 &json!({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "additionalProperties": false}),
4721 &json!({})
4722 )
4723 .is_ok());
4724 assert!(
4725 validate_mcp_submission(
4726 &json!({"type": "object", "properties": {"x": {"type": "number"}}}),
4727 &json!({"x": 1.2})
4728 )
4729 .is_err()
4730 );
4731 }
4732
4733 #[test]
4734 fn schema_keyword_applicability_fails_closed() {
4735 for schema in [
4736 json!({"type":"object", "properties":{"x":{"type":"string","minimum":1}}}),
4737 json!({"type":"object", "properties":{"x":{"type":"boolean","minimum":1}}}),
4738 json!({"type":"object", "properties":{"x":{"type":"integer","minLength":1}}}),
4739 json!({"type":"object", "properties":{"x":{"type":"boolean","minLength":1}}}),
4740 json!({"type":"object", "properties":{"x":{"type":"integer","enum":[true]}}}),
4741 ] {
4742 assert!(validate_mcp_submission(&schema, &json!({})).is_err());
4743 }
4744 let valid = json!({
4745 "$schema":"https://json-schema.org/draft/2020-12/schema",
4746 "type":"object",
4747 "properties":{
4748 "name":{"type":"string","minLength":1},
4749 "count":{"type":"integer","minimum":0},
4750 "enabled":{"type":"boolean"}
4751 },
4752 "additionalProperties":false
4753 });
4754 assert!(
4755 validate_mcp_submission(&valid, &json!({"name":"ok","count":1,"enabled":true})).is_ok()
4756 );
4757 }
4758
4759 #[test]
4760 fn schema_bounds_are_iterative_and_reject_deep_annotations_before_digest() {
4761 let mut just_below = json!("leaf");
4762 for _ in 0..(MCP_SCHEMA_MAX_JSON_DEPTH - 1) {
4763 just_below = Value::Array(vec![just_below]);
4764 }
4765 assert!(validate_schema_json_bounds(&just_below).is_ok());
4766
4767 let mut at_limit = json!("leaf");
4768 for _ in 0..MCP_SCHEMA_MAX_JSON_DEPTH {
4769 at_limit = Value::Array(vec![at_limit]);
4770 }
4771 assert!(validate_schema_json_bounds(&at_limit).is_ok());
4772
4773 let mut over_limit = json!("leaf");
4774 for _ in 0..=MCP_SCHEMA_MAX_JSON_DEPTH {
4775 over_limit = Value::Array(vec![over_limit]);
4776 }
4777 let error = validate_schema_json_bounds(&over_limit).expect_err("depth limit");
4778 assert!(error.to_string().contains("schema_depth_exceeded"));
4779
4780 for nesting in [100usize, 1_000, 5_000] {
4781 let mut annotation = json!("attacker");
4782 for _ in 0..nesting {
4783 annotation = Value::Array(vec![annotation]);
4784 }
4785 let mut schema_object = Map::new();
4786 schema_object.insert("type".to_owned(), Value::String("object".to_owned()));
4787 schema_object.insert("additionalProperties".to_owned(), Value::Bool(false));
4788 schema_object.insert("examples".to_owned(), annotation);
4789 let schema = Value::Object(schema_object);
4790 let error = adapt_mcp_schema(&schema).expect_err("deep annotation must fail closed");
4791 assert!(
4792 error.to_string().contains("schema_depth_exceeded"),
4793 "nesting {nesting}: {error}"
4794 );
4795 assert_eq!(mcp_schema_digest(&schema).len(), 64);
4798 std::mem::forget(schema);
4803 }
4804 }
4805
4806 #[test]
4807 fn examples_and_id_are_explicitly_unsupported() {
4808 let examples = json!({
4809 "type": "object",
4810 "additionalProperties": false,
4811 "examples": [{"x": "nested"}]
4812 });
4813 let error = adapt_mcp_schema(&examples).expect_err("examples policy");
4814 assert!(error.to_string().contains("unsupported_annotation"));
4815
4816 let property_examples = json!({
4817 "type": "object",
4818 "additionalProperties": false,
4819 "properties": {"x": {"type": "string", "examples": ["ok"]}}
4820 });
4821 let error = adapt_mcp_schema(&property_examples).expect_err("property examples policy");
4822 assert!(error.to_string().contains("unsupported_annotation"));
4823
4824 for identifier in ["not a URI", "\u{1b}[31murn:attacker", "urn:ok"] {
4825 let schema = json!({
4826 "type": "object",
4827 "additionalProperties": false,
4828 "$id": identifier
4829 });
4830 let error = adapt_mcp_schema(&schema).expect_err("$id policy");
4831 assert!(error.to_string().contains("unsupported_keyword"));
4832 }
4833 }
4834
4835 #[test]
4836 fn root_default_uses_exact_payload_validation() {
4837 let base = json!({
4838 "type": "object",
4839 "properties": {
4840 "x": {"type": "integer", "minimum": 1, "maximum": 3},
4841 "mode": {"type": "string", "enum": ["safe", "fast"]}
4842 },
4843 "required": ["x"],
4844 "additionalProperties": false
4845 });
4846 for default in [
4847 json!({}),
4848 json!({"x": 1, "extra": true}),
4849 json!({"x": "1"}),
4850 json!({"x": 9}),
4851 json!({"x": 1, "mode": "unsafe"}),
4852 ] {
4853 let mut schema = base.clone();
4854 schema["default"] = default;
4855 let error = adapt_mcp_schema(&schema).expect_err("invalid root default");
4856 assert!(error.to_string().contains("malformed_default"));
4857 }
4858
4859 let mut valid = base;
4860 valid["default"] = json!({"x": 2});
4861 assert!(adapt_mcp_schema(&valid).is_ok());
4862 }
4863
4864 #[test]
4865 fn metadata_is_single_line_and_support_matches_surface_limits() {
4866 let fake = "line1\n[gui_action_fake] line2\r\n\t\u{202e}\u{200b}";
4867 let safe = sanitize_single_line_metadata(fake);
4868 assert_eq!(safe, "line1 [gui_action_fake] line2");
4869 assert!(!safe.contains('\n'));
4870 assert!(!safe.contains('\r'));
4871 assert!(!safe.contains('\t'));
4872
4873 let mut supported_properties = Map::new();
4874 let mut enum_values = Vec::new();
4875 for index in 0..MCP_SCHEMA_MAX_ENUM_VALUES {
4876 enum_values.push(json!(format!("choice-{index}")));
4877 }
4878 supported_properties.insert(
4879 "choice".to_owned(),
4880 json!({"type": "string", "enum": enum_values}),
4881 );
4882 let supported = json!({
4883 "type": "object",
4884 "properties": supported_properties,
4885 "additionalProperties": false
4886 });
4887 let ir = adapt_mcp_schema(&supported).expect("just-under enum limit supported");
4888 assert_eq!(ir.fields[0].enum_values.len(), MCP_SCHEMA_MAX_ENUM_VALUES);
4889 assert!(
4890 mcp_tool_surface(
4891 "supported",
4892 "mcp__fixture__tool__aaaaaaaaaaaaaaaa",
4893 &supported,
4894 "state"
4895 )
4896 .is_ok()
4897 );
4898
4899 let mut over_values = Vec::new();
4900 for index in 0..=MCP_SCHEMA_MAX_ENUM_VALUES {
4901 over_values.push(json!(format!("choice-{index}")));
4902 }
4903 let refused = json!({
4904 "type": "object",
4905 "properties": {"choice": {"type": "string", "enum": over_values}},
4906 "additionalProperties": false
4907 });
4908 let error = adapt_mcp_schema(&refused).expect_err("over enum limit refused");
4909 assert!(error.to_string().contains("schema_too_large"));
4910 assert!(
4911 mcp_tool_surface(
4912 "refused",
4913 "mcp__fixture__tool__aaaaaaaaaaaaaaaa",
4914 &refused,
4915 "state"
4916 )
4917 .is_err()
4918 );
4919
4920 let collision = json!({
4921 "type": "object",
4922 "properties": {"choice": {"type": "string", "enum": ["a\nb", "a b"]}},
4923 "additionalProperties": false
4924 });
4925 let error = adapt_mcp_schema(&collision).expect_err("sanitized enum collision");
4926 assert!(error.to_string().contains("ambiguous_enum"));
4927 }
4928
4929 #[test]
4930 fn renderer_revokes_only_unsupported_subtree_actions() {
4931 let mut surface = Surface::new(
4932 "mixed",
4933 Component {
4934 id: "root".to_owned(),
4935 kind: ComponentKind::Stack {
4936 children: vec![
4937 Component::text("supported", "supported"),
4938 Component {
4939 id: "unsupported".to_owned(),
4940 kind: ComponentKind::Markdown {
4941 markdown: "degraded".to_owned(),
4942 },
4943 },
4944 ],
4945 },
4946 },
4947 );
4948 surface.actions = vec![
4949 Action {
4950 id: "gui_action_supported".to_owned(),
4951 label: "Supported".to_owned(),
4952 kind: ActionKind::Consequential,
4953 state_digest: "state".to_owned(),
4954 },
4955 Action {
4956 id: "gui_action_unsupported".to_owned(),
4957 label: "Unsupported".to_owned(),
4958 kind: ActionKind::Consequential,
4959 state_digest: "state".to_owned(),
4960 },
4961 ];
4962 let digest = surface.digest().expect("surface digest");
4963 let mut catalog = ActionCatalog::default();
4964 for action in &surface.actions {
4965 catalog
4966 .bind_mcp_action_with_context(
4967 action,
4968 &surface.id,
4969 &digest,
4970 "session-mixed",
4971 "principal-mixed",
4972 "default",
4973 1,
4974 &action.state_digest,
4975 "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4976 "server-mixed",
4977 "mcp__server__tool__aaaaaaaaaaaaaaaa",
4978 &"a".repeat(64),
4979 1,
4980 &sha256_hex("falsegreen.tool-policy.v1|consequential|1".as_bytes()),
4981 true,
4982 ActionSourceType::Mcp,
4983 )
4984 .expect("bind");
4985 catalog
4986 .set_remote_tool_name(action.id.as_str(), "remote-tool")
4987 .expect("remote identity");
4988 }
4989 catalog
4990 .set_current_state("session-mixed", "principal-mixed", 1, "state", "default")
4991 .expect("trusted state");
4992 catalog
4993 .bind_action_owner("gui_action_supported", "supported")
4994 .expect("supported owner");
4995 catalog
4996 .bind_action_owner("gui_action_unsupported", "unsupported")
4997 .expect("unsupported owner");
4998 let mut capabilities = default_negotiated_capabilities(80);
4999 capabilities.supported_components.remove("markdown");
5000 let rendered =
5001 render_surface_with_capabilities_and_catalog(&surface, &capabilities, &mut catalog)
5002 .expect("placeholder render");
5003 assert!(rendered.contains("gui_action_supported"));
5004 assert!(!rendered.contains("gui_action_unsupported"));
5005 assert!(catalog.resolve("gui_action_supported").is_some());
5006 assert!(catalog.resolve("gui_action_unsupported").is_none());
5007 }
5008
5009 #[test]
5010 fn schema_ir_preserves_typed_values_and_reconstructs_without_coercion() {
5011 let schema = json!({
5012 "$schema": "https://json-schema.org/draft/2020-12/schema",
5013 "type": "object",
5014 "title": "Input\u{1b}[31m",
5015 "description": "description\u{202e}marker",
5016 "properties": {
5017 "mode": {
5018 "type": "string",
5019 "enum": ["safe", "fast"],
5020 "default": "safe",
5021 "title": "Mode\u{200b}",
5022 "description": "Choose a mode"
5023 },
5024 "count": {
5025 "type": "integer",
5026 "minimum": 1,
5027 "maximum": 9,
5028 "enum": [1, 3, 9],
5029 "default": 3
5030 },
5031 "enabled": {
5032 "type": "boolean",
5033 "enum": [true, false]
5034 },
5035 "note": {
5036 "type": "string",
5037 "minLength": 1,
5038 "maxLength": 20
5039 }
5040 },
5041 "required": ["mode", "count", "enabled"],
5042 "additionalProperties": false
5043 });
5044 let ir = adapt_mcp_schema(&schema).expect("exact schema IR");
5045 assert_eq!(ir.adapter, MCP_SCHEMA_ADAPTER_ID);
5046 assert_eq!(
5047 ir.dialect_uri(),
5048 Some("https://json-schema.org/draft/2020-12/schema")
5049 );
5050 assert_eq!(ir.source_digest(), mcp_schema_digest(&schema));
5051 assert_eq!(ir.title.as_deref(), Some("Input"));
5052 assert_eq!(ir.description.as_deref(), Some("description marker"));
5053 assert_eq!(ir.fields.len(), 4);
5054 let form = ir.form_fields();
5055 let count = ir
5056 .fields
5057 .iter()
5058 .find(|field| field.name == "count")
5059 .unwrap();
5060 assert_eq!(
5061 count.enum_values,
5062 vec![
5063 McpScalarValue::Integer(1),
5064 McpScalarValue::Integer(3),
5065 McpScalarValue::Integer(9)
5066 ]
5067 );
5068 let count_form = form.iter().find(|field| field.id == "count").unwrap();
5069 assert_eq!(count_form.choice_values, vec![json!(1), json!(3), json!(9)]);
5070 let enabled_form = form.iter().find(|field| field.id == "enabled").unwrap();
5071 assert_eq!(enabled_form.choice_values, vec![json!(true), json!(false)]);
5072 let mode_form = form.iter().find(|field| field.id == "mode").unwrap();
5073 assert_eq!(mode_form.label, "Mode");
5074 assert_eq!(mode_form.description.as_deref(), Some("Choose a mode"));
5075
5076 let values = BTreeMap::from([
5077 ("mode".to_owned(), json!("fast")),
5078 ("count".to_owned(), json!(9)),
5079 ("enabled".to_owned(), json!(true)),
5080 ]);
5081 let payload = reconstruct_mcp_payload(&schema, &values).expect("payload");
5082 assert_eq!(
5083 payload,
5084 json!({"mode": "fast", "count": 9, "enabled": true})
5085 );
5086 assert!(
5087 reconstruct_mcp_payload(
5088 &schema,
5089 &BTreeMap::from([
5090 ("mode".to_owned(), json!("fast")),
5091 ("count".to_owned(), json!("9")),
5092 ("enabled".to_owned(), json!(true)),
5093 ])
5094 )
5095 .is_err()
5096 );
5097 assert!(
5098 reconstruct_mcp_payload(
5099 &schema,
5100 &BTreeMap::from([
5101 ("mode".to_owned(), json!("safe")),
5102 ("count".to_owned(), json!(3)),
5103 ("enabled".to_owned(), json!(true)),
5104 ("note".to_owned(), Value::Null),
5105 ])
5106 )
5107 .is_err()
5108 );
5109 }
5110
5111 #[test]
5112 fn schema_ir_rejects_unsupported_constructs_with_reason_codes() {
5113 let cases = [
5114 (
5115 json!({"type":"object","additionalProperties":false,"unknown":true}),
5116 "unsupported_keyword",
5117 ),
5118 (
5119 json!({"$schema":"https://json-schema.org/draft/2019-09/schema","type":"object","additionalProperties":false}),
5120 "unsupported_dialect",
5121 ),
5122 (
5123 json!({"type":"object","properties":{"n":{"type":"number"}},"additionalProperties":false}),
5124 "unsupported_type",
5125 ),
5126 (
5127 json!({"type":"object","properties":{"x":{"type":"string","enum":["a","a"]}},"additionalProperties":false}),
5128 "duplicate_enum",
5129 ),
5130 (
5131 json!({"type":"object","properties":{"x":{"type":"string","enum":[1]}},"additionalProperties":false}),
5132 "enum_type_mismatch",
5133 ),
5134 (
5135 json!({"type":"object","properties":{"x":{"type":"integer","minimum":1.5}},"additionalProperties":false}),
5136 "malformed_bound",
5137 ),
5138 (
5139 serde_json::from_str::<Value>(r#"{"type":"object","properties":{"x":{"type":"integer","minimum":18446744073709551616}},"additionalProperties":false}"#).expect("huge bound JSON"),
5140 "malformed_bound",
5141 ),
5142 (
5143 json!({"type":"object","properties":{"x":{"type":"string","default":false}},"additionalProperties":false}),
5144 "malformed_default",
5145 ),
5146 (
5147 json!({"type":"object","properties":{"x":{"type":"object","properties":{}}},"additionalProperties":false}),
5148 "unsupported_nested_object",
5149 ),
5150 (
5151 json!({"type":"object","properties":{"x":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}),
5152 "unsupported_nested_array",
5153 ),
5154 ];
5155 for (schema, reason) in cases {
5156 let error = adapt_mcp_schema(&schema).expect_err("schema must fail closed");
5157 assert!(
5158 error.to_string().contains(reason),
5159 "{reason} missing from {error}"
5160 );
5161 }
5162
5163 let mut too_many_fields = Map::new();
5164 for index in 0..=MCP_SCHEMA_MAX_FIELDS {
5165 too_many_fields.insert(format!("field_{index}"), json!({"type": "string"}));
5166 }
5167 let too_many_fields = json!({
5168 "type": "object",
5169 "properties": too_many_fields,
5170 "additionalProperties": false
5171 });
5172 let error = adapt_mcp_schema(&too_many_fields).expect_err("field limit");
5173 assert!(error.to_string().contains("schema_too_large"));
5174
5175 let too_large = json!({
5176 "type": "object",
5177 "description": "x".repeat(MCP_SCHEMA_MAX_BYTES),
5178 "additionalProperties": false
5179 });
5180 let error = adapt_mcp_schema(&too_large).expect_err("byte limit");
5181 assert!(error.to_string().contains("schema_too_large"));
5182 }
5183
5184 #[test]
5185 fn schema_digest_changes_for_presentation_and_semantic_drift() {
5186 let first = json!({
5187 "type":"object",
5188 "properties":{"mode":{"type":"string","enum":["safe"]}},
5189 "additionalProperties":false
5190 });
5191 let mut second = first.clone();
5192 second["properties"]["mode"]["enum"] = json!(["unsafe"]);
5193 assert_ne!(mcp_schema_digest(&first), mcp_schema_digest(&second));
5194 let mut presentation = first.clone();
5195 presentation["properties"]["mode"]["title"] = json!("Mode");
5196 assert_ne!(mcp_schema_digest(&first), mcp_schema_digest(&presentation));
5197 let first_ir = adapt_mcp_schema(&first).expect("first IR");
5198 let second_ir = adapt_mcp_schema(&second).expect("second IR");
5199 assert_ne!(first_ir.source_digest(), second_ir.source_digest());
5200 let mut identity_only = first.clone();
5201 identity_only["$id"] = json!("urn:falsegreen:g3:alternate");
5202 let identity_error = adapt_mcp_schema(&identity_only).expect_err("$id is unsupported");
5203 assert!(identity_error.to_string().contains("unsupported_keyword"));
5204 assert!(
5205 first_ir
5206 .form_fields()
5207 .iter()
5208 .all(|field| !field.label.contains('\n'))
5209 );
5210 }
5211
5212 #[test]
5213 fn schema_adapter_performance_shapes_are_bounded() {
5214 use std::time::{Duration, Instant};
5215
5216 for (label, count) in [
5217 ("small", 5usize),
5218 ("medium", 25),
5219 ("large", 100),
5220 ("maximum", MCP_SCHEMA_MAX_FIELDS),
5221 ] {
5222 let mut properties = Map::new();
5223 for index in 0..count {
5224 let name = format!("field_{index}");
5225 let property = match index % 3 {
5226 0 => json!({"type": "string", "minLength": 0, "maxLength": 64}),
5227 1 => json!({"type": "integer", "minimum": 0, "maximum": 100}),
5228 _ => json!({"type": "boolean"}),
5229 };
5230 properties.insert(name, property);
5231 }
5232 let schema = json!({
5233 "type": "object",
5234 "properties": properties,
5235 "additionalProperties": false
5236 });
5237 let started = Instant::now();
5238 let ir = adapt_mcp_schema(&schema).expect("performance schema IR");
5239 let adaptation = started.elapsed();
5240 let surface = mcp_tool_surface(
5241 "performance",
5242 "mcp__fixture__tool__aaaaaaaaaaaaaaaa",
5243 &schema,
5244 "state",
5245 )
5246 .expect("performance surface");
5247 let started = Instant::now();
5248 let rendered =
5249 render_surface_with_capabilities(&surface, &default_negotiated_capabilities(120))
5250 .expect("performance render");
5251 let rendering = started.elapsed();
5252 assert_eq!(ir.fields.len(), count);
5253 assert!(surface.validate_shape(&HostCapabilities::default()).is_ok());
5254 assert!(!rendered.is_empty());
5255 eprintln!(
5256 "G3 schema performance {label}: adapt={adaptation:?}, render_surface={rendering:?}"
5257 );
5258 }
5259
5260 let mut annotation = json!("attacker");
5261 for _ in 0..5_000 {
5262 annotation = Value::Array(vec![annotation]);
5263 }
5264 let mut deep_object = Map::new();
5265 deep_object.insert("type".to_owned(), Value::String("object".to_owned()));
5266 deep_object.insert("additionalProperties".to_owned(), Value::Bool(false));
5267 deep_object.insert("examples".to_owned(), annotation);
5268 let deep_schema = Value::Object(deep_object);
5269 let started = Instant::now();
5270 let error = adapt_mcp_schema(&deep_schema).expect_err("deep attacker schema");
5271 let rejection = started.elapsed();
5272 assert!(error.to_string().contains("schema_depth_exceeded"));
5273 assert!(
5274 rejection < Duration::from_secs(1),
5275 "deep-schema rejection took {rejection:?}"
5276 );
5277 std::mem::forget(deep_schema);
5278 }
5279}