From aa3926d474ab8bf47070058579e4891709fd86f6 Mon Sep 17 00:00:00 2001 From: Alexey Puzik Date: Wed, 4 Feb 2026 21:12:20 +0200 Subject: [PATCH 01/10] WASM guards WIP --- crates/agentgateway/src/mcp/handler.rs | 55 ++++++ crates/agentgateway/src/mcp/security/mod.rs | 58 +++++++ .../src/mcp/security/native/mod.rs | 13 ++ crates/agentgateway/src/mcp/security/wasm.rs | 163 +++++++++++++++++- .../simple-pattern-guard/wit/guard.wit | 8 +- 5 files changed, 291 insertions(+), 6 deletions(-) diff --git a/crates/agentgateway/src/mcp/handler.rs b/crates/agentgateway/src/mcp/handler.rs index b69a75d10..f39bd4c29 100644 --- a/crates/agentgateway/src/mcp/handler.rs +++ b/crates/agentgateway/src/mcp/handler.rs @@ -65,6 +65,61 @@ impl Relay { } else { Some(backend.targets[0].name.to_string()) }; + // Evaluate connection guards BEFORE establishing connections + // This allows blocking connections to malicious/unknown servers + for target in &backend.targets { + // Get server URL from backend (SimpleBackend uses hostport() method) + let server_url_string = target.backend.as_ref().map(|b| b.hostport()); + let server_url = server_url_string.as_deref(); + let context = crate::mcp::security::GuardContext { + server_name: target.name.to_string(), + identity: None, + metadata: serde_json::Value::Null, + }; + + match security_guards.evaluate_connection(&target.name, server_url, &context) { + Ok(crate::mcp::security::GuardDecision::Allow) => { + tracing::debug!( + target = %target.name, + "Connection guard allowed" + ); + }, + Ok(crate::mcp::security::GuardDecision::Deny(reason)) => { + tracing::warn!( + target = %target.name, + code = %reason.code, + message = %reason.message, + "Connection guard denied - blocking connection" + ); + return Err(anyhow::anyhow!( + "Connection to {} blocked by security guard: {} - {}", + target.name, + reason.code, + reason.message + )); + }, + Ok(crate::mcp::security::GuardDecision::Modify(_)) => { + // Modify not applicable for connection phase + tracing::debug!( + target = %target.name, + "Connection guard returned Modify (treating as Allow)" + ); + }, + Err(e) => { + tracing::error!( + target = %target.name, + error = %e, + "Connection guard error" + ); + return Err(anyhow::anyhow!( + "Connection guard failed for {}: {}", + target.name, + e + )); + }, + } + } + Ok(Self { upstreams: Arc::new(upstream::UpstreamGroup::new(client, backend)?), policies, diff --git a/crates/agentgateway/src/mcp/security/mod.rs b/crates/agentgateway/src/mcp/security/mod.rs index daea33bda..0b367c704 100644 --- a/crates/agentgateway/src/mcp/security/mod.rs +++ b/crates/agentgateway/src/mcp/security/mod.rs @@ -95,6 +95,10 @@ pub enum McpGuardKind { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum GuardPhase { + /// Before establishing connection to MCP server + /// Used for server whitelisting, typosquat detection, TLS validation + Connection, + /// Before forwarding client request to MCP server Request, @@ -398,6 +402,60 @@ impl GuardExecutor { Ok(()) } + /// Execute guards before establishing connection to an MCP server + /// Used for server whitelisting, typosquat detection, TLS validation + pub fn evaluate_connection( + &self, + server_name: &str, + server_url: Option<&str>, + context: &GuardContext, + ) -> GuardResult { + let guards = self.guards.read().expect("guards lock poisoned"); + tracing::info!( + guard_count = guards.len(), + server = %server_name, + server_url = ?server_url, + "GuardExecutor::evaluate_connection called" + ); + for guard_entry in guards.iter() { + // Only run guards configured for Connection phase + if !guard_entry.config.runs_on.contains(&GuardPhase::Connection) { + continue; + } + + // Execute guard with timeout + let result = self.execute_with_timeout( + || guard_entry.guard.evaluate_connection(server_name, server_url, context), + Duration::from_millis(guard_entry.config.timeout_ms), + &guard_entry.config, + ); + + // Handle result based on failure mode + match result { + Ok(GuardDecision::Allow) => continue, + Ok(decision) => return Ok(decision), + Err(e) => { + match guard_entry.config.failure_mode { + FailureMode::FailClosed => { + return Err(GuardError::ExecutionError(format!( + "Guard {} failed: {}", + guard_entry.config.id, + e + ))); + }, + FailureMode::FailOpen => { + tracing::warn!("Guard {} failed but continuing due to fail_open: {}", + guard_entry.config.id, e); + continue; + }, + } + }, + } + } + + Ok(GuardDecision::Allow) + } + /// Execute guards on a tools/list response pub fn evaluate_tools_list( &self, diff --git a/crates/agentgateway/src/mcp/security/native/mod.rs b/crates/agentgateway/src/mcp/security/native/mod.rs index 8a9d83ad1..1cb767594 100644 --- a/crates/agentgateway/src/mcp/security/native/mod.rs +++ b/crates/agentgateway/src/mcp/security/native/mod.rs @@ -21,6 +21,19 @@ use super::{GuardContext, GuardDecision, GuardResult}; /// Common trait for all native guards pub trait NativeGuard: Send + Sync { + /// Evaluate before establishing connection to an MCP server + /// Used for server whitelisting, typosquat detection, TLS validation + fn evaluate_connection( + &self, + server_name: &str, + server_url: Option<&str>, + context: &GuardContext, + ) -> GuardResult { + // Default: allow + let _ = (server_name, server_url, context); + Ok(GuardDecision::Allow) + } + /// Evaluate a tools/list response fn evaluate_tools_list( &self, diff --git a/crates/agentgateway/src/mcp/security/wasm.rs b/crates/agentgateway/src/mcp/security/wasm.rs index 3b8686a3c..0647f5f9e 100644 --- a/crates/agentgateway/src/mcp/security/wasm.rs +++ b/crates/agentgateway/src/mcp/security/wasm.rs @@ -31,6 +31,13 @@ pub struct WasmGuardConfig { #[serde(default = "default_max_memory")] pub max_memory: usize, + /// Maximum WebAssembly stack size (bytes). + /// Python WASM components require significantly more stack space (2-4 MB) + /// due to the embedded Python interpreter. + /// Default: 2 MB (sufficient for most Python guards) + #[serde(default = "default_max_wasm_stack")] + pub max_wasm_stack: usize, + /// Timeout for guard execution (milliseconds) #[serde(default = "default_timeout_ms")] pub timeout_ms: u64, @@ -44,10 +51,36 @@ fn default_max_memory() -> usize { 10 * 1024 * 1024 // 10 MB } +fn default_max_wasm_stack() -> usize { + 2 * 1024 * 1024 // 2 MB - sufficient for Python WASM guards +} + fn default_timeout_ms() -> u64 { 100 } +/// Run a closure on a thread with a large stack. +/// Python WASM components require significant native stack space that exceeds +/// the default thread stack size, especially on Windows where the main thread +/// stack cannot be grown dynamically. +/// Uses scoped threads to avoid 'static lifetime requirements. +#[cfg(feature = "wasm-guards")] +fn run_with_large_stack(stack_size: usize, f: F) -> T +where + F: FnOnce() -> T + Send, + T: Send, +{ + std::thread::scope(|scope| { + scope + .spawn(|| { + // Grow the stack on this thread before executing + stacker::grow(stack_size, f) + }) + .join() + .expect("WASM thread panicked") + }) +} + /// State stored in the wasmtime Store for host functions #[cfg(feature = "wasm-guards")] struct WasmState { @@ -120,15 +153,24 @@ impl WasmGuard { // Configure wasmtime engine let mut engine_config = Config::new(); engine_config.wasm_component_model(true); + // Set maximum WASM stack size - Python WASM components require larger stacks + // due to the embedded interpreter + engine_config.max_wasm_stack(config.max_wasm_stack); let engine = Engine::new(&engine_config).map_err(|e| { GuardError::WasmError(format!("Failed to create wasmtime engine: {}", e)) })?; // Load and compile the WASM component - let component = Component::from_file(&engine, expanded_path.as_ref()).map_err(|e| { - GuardError::WasmError(format!("Failed to load WASM component: {}", e)) - })?; + // Python WASM components require significant native stack space during compilation + // due to the embedded interpreter. On Windows, the main thread stack cannot be grown, + // so we spawn a dedicated thread with a large stack (8MB) for compilation. + let path_for_thread = expanded_path.to_string(); + let engine_clone = engine.clone(); + let component = run_with_large_stack(8 * 1024 * 1024, move || { + Component::from_file(&engine_clone, &path_for_thread) + }) + .map_err(|e| GuardError::WasmError(format!("Failed to load WASM component: {}", e)))?; tracing::info!( guard_id = %guard_id, @@ -263,6 +305,20 @@ impl WasmGuard { ))) } } + "warn" => { + // Warn means allow but log the warnings + if let Some(Val::List(warnings)) = payload.as_deref() { + for warning in warnings { + if let Val::String(msg) = warning { + tracing::warn!( + warning = %msg, + "WASM guard returned warning" + ); + } + } + } + Ok(GuardDecision::Allow) + } _ => Err(GuardError::WasmError(format!( "Unknown decision variant: {}", name @@ -319,7 +375,7 @@ impl WasmGuard { } } - /// Execute the guard with timeout protection + /// Execute the guard with timeout protection and sufficient stack space fn execute_with_timeout(&self, f: F) -> GuardResult where F: FnOnce() -> GuardResult, @@ -327,7 +383,10 @@ impl WasmGuard { // For synchronous execution, we use a simple approach // In production, this could be enhanced with proper async timeout let start = std::time::Instant::now(); - let result = f(); + // Python WASM components require significant native stack space due to the + // embedded interpreter. Use stacker to grow the native stack when needed. + // Use stacker::grow to force allocation of a large stack segment (8MB). + let result = stacker::grow(8 * 1024 * 1024, f); let elapsed = start.elapsed(); if elapsed.as_millis() as u64 > self.config.timeout_ms { @@ -430,6 +489,7 @@ impl NativeGuard for WasmGuard { // Build context as WIT record let context_record = Val::Record(vec![ ("server-name".into(), Val::String(context.server_name.clone().into())), + ("server-url".into(), Val::Option(None)), // Not applicable for tools_list evaluation ( "identity".into(), match &context.identity { @@ -493,6 +553,95 @@ impl NativeGuard for WasmGuard { Ok(GuardDecision::Allow) } + fn evaluate_connection( + &self, + server_name: &str, + server_url: Option<&str>, + context: &GuardContext, + ) -> GuardResult { + self.execute_with_timeout(|| { + tracing::debug!( + guard_id = %self.guard_id, + server = %server_name, + server_url = ?server_url, + "Evaluating connection with WASM guard" + ); + + let linker = self.create_linker()?; + let state = WasmState::new(self.config.config.clone()); + let mut store = Store::new(&self.engine, state); + + // Instantiate the component + let instance = linker + .instantiate(&mut store, &self.component) + .map_err(|e| GuardError::WasmError(format!("Failed to instantiate component: {}", e)))?; + + // Get the exported function from the guard interface + let guard_export_idx = instance + .get_export(&mut store, None, "mcp:security-guard/guard@0.1.0") + .ok_or_else(|| { + GuardError::WasmError( + "Guard interface not found in component exports".to_string(), + ) + })?; + + // Get the evaluate-server-connection function + let func_export_idx = instance + .get_export(&mut store, Some(&guard_export_idx), "evaluate-server-connection") + .ok_or_else(|| { + GuardError::WasmError( + "Function evaluate-server-connection not found in guard interface".to_string(), + ) + })?; + + let func = instance + .get_func(&mut store, &func_export_idx) + .ok_or_else(|| { + GuardError::WasmError( + "Could not get function from export index".to_string(), + ) + })?; + + // Build context as WIT record with server_url + let context_record = Val::Record(vec![ + ("server-name".into(), Val::String(context.server_name.clone().into())), + ( + "server-url".into(), + match server_url { + Some(url) => Val::Option(Some(Box::new(Val::String(url.to_string().into())))), + None => Val::Option(None), + }, + ), + ( + "identity".into(), + match &context.identity { + Some(id) => Val::Option(Some(Box::new(Val::String(id.clone().into())))), + None => Val::Option(None), + }, + ), + ( + "metadata".into(), + Val::String( + serde_json::to_string(&context.metadata) + .unwrap_or_else(|_| "{}".to_string()) + .into(), + ), + ), + ]); + + // Call the function + let mut results = vec![Val::Bool(false)]; // Placeholder for result + func.call(&mut store, &[context_record], &mut results) + .map_err(|e| GuardError::WasmError(format!("WASM function call failed: {}", e)))?; + + // Post-call cleanup + func.post_return(&mut store) + .map_err(|e| GuardError::WasmError(format!("WASM post-return failed: {}", e)))?; + + Self::parse_decision(&results) + }) + } + fn reset_server(&self, server_name: &str) { // WASM guards are stateless by design - no per-server state to reset tracing::debug!( @@ -525,6 +674,7 @@ mod tests { let invalid_config = WasmGuardConfig { module_path: String::new(), max_memory: 1024 * 1024, + max_wasm_stack: default_max_wasm_stack(), timeout_ms: 100, config: HashMap::new(), }; @@ -538,6 +688,7 @@ mod tests { let valid_config = WasmGuardConfig { module_path: "/path/to/probe.wasm".to_string(), max_memory: 10 * 1024 * 1024, + max_wasm_stack: default_max_wasm_stack(), timeout_ms: 100, config: HashMap::new(), }; @@ -559,6 +710,7 @@ mod tests { #[test] fn test_default_config_values() { assert_eq!(default_max_memory(), 10 * 1024 * 1024); + assert_eq!(default_max_wasm_stack(), 2 * 1024 * 1024); assert_eq!(default_timeout_ms(), 100); } @@ -647,6 +799,7 @@ module_path: ./guards/test.wasm let config = WasmGuardConfig { module_path: wasm_path.to_str().unwrap().to_string(), max_memory: 10 * 1024 * 1024, + max_wasm_stack: default_max_wasm_stack(), timeout_ms: 1000, config: HashMap::new(), // Use default patterns }; diff --git a/examples/wasm-guards/simple-pattern-guard/wit/guard.wit b/examples/wasm-guards/simple-pattern-guard/wit/guard.wit index 2fc28bc13..07c9085d2 100644 --- a/examples/wasm-guards/simple-pattern-guard/wit/guard.wit +++ b/examples/wasm-guards/simple-pattern-guard/wit/guard.wit @@ -20,6 +20,7 @@ interface guard { /// Context provided by the host record guard-context { server-name: string, + server-url: option, /// URL of the MCP server (for connection validation) identity: option, metadata: string, /// JSON-serialized metadata } @@ -28,7 +29,8 @@ interface guard { variant decision { allow, deny(deny-reason), - modify(string), /// JSON-serialized modification + modify(string), /// JSON-serialized modification (for PII masking, etc.) + warn(list), /// Allow with warnings } /// Reason for denying a request @@ -41,6 +43,10 @@ interface guard { /// Main guard evaluation function /// Called by the host for every tools/list response evaluate-tools-list: func(tools: list, context: guard-context) -> result; + + /// Evaluate server connection before establishing + /// Called by the host before connecting to an MCP server + evaluate-server-connection: func(context: guard-context) -> result; } /// Host interface - functions provided by AgentGateway to the WASM module From 652bf4bdb0dd0c3748f9deb010bd6e98d547e61c Mon Sep 17 00:00:00 2001 From: Alexey Puzik Date: Thu, 5 Feb 2026 14:05:49 +0200 Subject: [PATCH 02/10] Expose UI for wasm guards WIP --- .../simple-pattern-guard/src/lib.rs | 63 +++ .../simple-pattern-guard/wit/guard.wit | 9 + ui/src/components/policy/form-renderers.tsx | 33 +- ui/src/components/schema-form/SchemaField.tsx | 90 ++++ ui/src/components/schema-form/SchemaForm.tsx | 185 ++++++++ .../schema-form/fields/CheckboxField.tsx | 45 ++ .../schema-form/fields/InputField.tsx | 68 +++ .../schema-form/fields/KeyValueField.tsx | 102 +++++ .../schema-form/fields/MultiselectField.tsx | 68 +++ .../schema-form/fields/ObjectArrayField.tsx | 192 ++++++++ .../schema-form/fields/SelectField.tsx | 74 +++ .../schema-form/fields/SliderField.tsx | 93 ++++ .../schema-form/fields/TagsField.tsx | 100 +++++ .../schema-form/fields/TextareaField.tsx | 47 ++ ui/src/components/schema-form/index.ts | 21 + ui/src/hooks/useGuardSchemas.ts | 379 ++++++++++++++++ ui/src/lib/guard-schema-types.ts | 421 ++++++++++++++++++ 17 files changed, 1982 insertions(+), 8 deletions(-) create mode 100644 ui/src/components/schema-form/SchemaField.tsx create mode 100644 ui/src/components/schema-form/SchemaForm.tsx create mode 100644 ui/src/components/schema-form/fields/CheckboxField.tsx create mode 100644 ui/src/components/schema-form/fields/InputField.tsx create mode 100644 ui/src/components/schema-form/fields/KeyValueField.tsx create mode 100644 ui/src/components/schema-form/fields/MultiselectField.tsx create mode 100644 ui/src/components/schema-form/fields/ObjectArrayField.tsx create mode 100644 ui/src/components/schema-form/fields/SelectField.tsx create mode 100644 ui/src/components/schema-form/fields/SliderField.tsx create mode 100644 ui/src/components/schema-form/fields/TagsField.tsx create mode 100644 ui/src/components/schema-form/fields/TextareaField.tsx create mode 100644 ui/src/components/schema-form/index.ts create mode 100644 ui/src/hooks/useGuardSchemas.ts create mode 100644 ui/src/lib/guard-schema-types.ts diff --git a/examples/wasm-guards/simple-pattern-guard/src/lib.rs b/examples/wasm-guards/simple-pattern-guard/src/lib.rs index 67f13464b..aa875726a 100644 --- a/examples/wasm-guards/simple-pattern-guard/src/lib.rs +++ b/examples/wasm-guards/simple-pattern-guard/src/lib.rs @@ -88,6 +88,69 @@ impl Guest for SimplePatternGuard { log_info("All tools passed pattern check"); Ok(Decision::Allow) } + + fn evaluate_server_connection(context: GuardContext) -> Result { + // This guard focuses on tool patterns, so we allow all connections + log_info(&format!( + "Allowing connection to server '{}'", + context.server_name + )); + Ok(Decision::Allow) + } + + fn get_settings_schema() -> String { + serde_json::json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agentgateway://guards/simple-pattern/v1", + "title": "Simple Pattern Guard", + "description": "Blocks tools matching configurable patterns in name or description", + "type": "object", + "properties": { + "blocked_patterns": { + "type": "array", + "title": "Blocked Patterns", + "description": "List of substrings to block (case-insensitive)", + "items": { "type": "string" }, + "default": ["delete", "rm -rf", "drop table", "eval", "exec"], + "x-ui": { + "component": "tags", + "placeholder": "Enter pattern and press Enter", + "order": 1 + } + }, + "scan_descriptions": { + "type": "boolean", + "title": "Scan Descriptions", + "description": "Also check tool descriptions for blocked patterns", + "default": true, + "x-ui": { "order": 2 } + }, + "max_tool_count": { + "type": "integer", + "title": "Max Tool Count", + "description": "Maximum allowed tools per server (0 = unlimited)", + "default": 0, + "minimum": 0, + "x-ui": { "order": 3, "advanced": true } + } + }, + "x-guard-meta": { + "guardType": "simple_pattern", + "version": "1.0.0", + "category": "detection", + "defaultRunsOn": ["tools_list"], + "icon": "filter" + } + }).to_string() + } + + fn get_default_config() -> String { + serde_json::json!({ + "blocked_patterns": ["delete", "rm -rf", "drop table", "eval", "exec"], + "scan_descriptions": true, + "max_tool_count": 0 + }).to_string() + } } // Helper: Get blocked patterns from config or use defaults diff --git a/examples/wasm-guards/simple-pattern-guard/wit/guard.wit b/examples/wasm-guards/simple-pattern-guard/wit/guard.wit index 07c9085d2..0df40fe37 100644 --- a/examples/wasm-guards/simple-pattern-guard/wit/guard.wit +++ b/examples/wasm-guards/simple-pattern-guard/wit/guard.wit @@ -47,6 +47,15 @@ interface guard { /// Evaluate server connection before establishing /// Called by the host before connecting to an MCP server evaluate-server-connection: func(context: guard-context) -> result; + + /// Get JSON Schema describing guard's configurable parameters + /// Returns JSON-serialized JSON Schema (Draft 2020-12) + /// Schema describes guard-specific settings only (not common settings like priority, timeout) + get-settings-schema: func() -> string; + + /// Get default configuration as JSON + /// Returns JSON-serialized default config values + get-default-config: func() -> string; } /// Host interface - functions provided by AgentGateway to the WASM module diff --git a/ui/src/components/policy/form-renderers.tsx b/ui/src/components/policy/form-renderers.tsx index 54054f312..3e58327ba 100644 --- a/ui/src/components/policy/form-renderers.tsx +++ b/ui/src/components/policy/form-renderers.tsx @@ -11,9 +11,10 @@ import { SelectValue, } from "@/components/ui/select"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Trash2, Plus } from "lucide-react"; +import { Trash2, Plus, Wand2 } from "lucide-react"; import { formatArrayForInput, handleNumberArrayInput } from "@/lib/policy-utils"; import { McpAuthorizationRule } from "@/lib/types"; +import { MCP_CORS_DEFAULTS } from "@/lib/policy-defaults"; import { ArrayInput, TargetInput, @@ -86,15 +87,31 @@ export function renderJwtAuthForm({ data, onChange }: FormRendererProps) { } export function renderCorsForm({ data, onChange }: FormRendererProps) { + const applyMcpDefaults = () => { + onChange({ ...MCP_CORS_DEFAULTS }); + }; + return (
-
- onChange({ ...data, allowCredentials: checked })} - /> - +
+
+ onChange({ ...data, allowCredentials: checked })} + /> + +
+
void; + + /** Error message */ + error?: string; + + /** Disable the field */ + disabled?: boolean; +} + +/** + * Routes a schema property to the appropriate field component + */ +export function SchemaField({ + name, + schema, + value, + onChange, + error, + disabled = false, +}: SchemaFieldProps) { + const component = inferUIComponent(schema); + + const commonProps = { + name, + schema, + value, + onChange, + error, + disabled, + }; + + switch (component) { + case "checkbox": + return ; + + case "select": + return ; + + case "slider": + return ; + + case "tags": + return ; + + case "multiselect": + return ; + + case "object-array": + return []} />; + + case "key-value": + return } />; + + case "textarea": + return ; + + case "input": + default: + return ; + } +} diff --git a/ui/src/components/schema-form/SchemaForm.tsx b/ui/src/components/schema-form/SchemaForm.tsx new file mode 100644 index 000000000..ef972558e --- /dev/null +++ b/ui/src/components/schema-form/SchemaForm.tsx @@ -0,0 +1,185 @@ +/** + * SchemaForm - Dynamic form generator from JSON Schema + * + * This component renders a form based on a JSON Schema definition, + * automatically selecting appropriate field components based on type + * and UI hints. + */ + +import { useMemo, useState } from "react"; +import { + type GuardSettingsSchema, + type SchemaProperty, + getSortedProperties, + getGroupedProperties, +} from "@/lib/guard-schema-types"; +import { SchemaField } from "./SchemaField"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Button } from "@/components/ui/button"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +interface SchemaFormProps { + /** JSON Schema defining the form fields */ + schema: GuardSettingsSchema; + + /** Current form values */ + value: Record; + + /** Callback when any value changes */ + onChange: (value: Record) => void; + + /** Validation errors by field path */ + errors?: Record; + + /** Show advanced fields by default */ + showAdvanced?: boolean; + + /** Disable all fields */ + disabled?: boolean; +} + +/** + * Dynamic form component that renders fields from a JSON Schema + */ +export function SchemaForm({ + schema, + value, + onChange, + errors = {}, + showAdvanced = false, + disabled = false, +}: SchemaFormProps) { + const [advancedExpanded, setAdvancedExpanded] = useState(showAdvanced); + + // Group properties by their x-ui.group value + const groupedProperties = useMemo(() => getGroupedProperties(schema), [schema]); + + // Get group definitions from schema + const groupDefs = schema["x-ui-groups"] || {}; + + // Sort groups by their order + const sortedGroups = useMemo(() => { + const groups = Array.from(groupedProperties.keys()); + return groups.sort((a, b) => { + const orderA = a ? groupDefs[a]?.order ?? 999 : 0; + const orderB = b ? groupDefs[b]?.order ?? 999 : 0; + return orderA - orderB; + }); + }, [groupedProperties, groupDefs]); + + // Separate advanced fields + const { regularFields, advancedFields } = useMemo(() => { + const regular: Array<[string, SchemaProperty]> = []; + const advanced: Array<[string, SchemaProperty]> = []; + + for (const [key, prop] of getSortedProperties(schema)) { + if (prop["x-ui"]?.advanced) { + advanced.push([key, prop]); + } else { + regular.push([key, prop]); + } + } + + return { regularFields: regular, advancedFields: advanced }; + }, [schema]); + + const handleFieldChange = (key: string, newValue: unknown) => { + onChange({ ...value, [key]: newValue }); + }; + + const renderField = (key: string, prop: SchemaProperty) => ( + handleFieldChange(key, newValue)} + error={errors[key]} + disabled={disabled} + /> + ); + + // Check if we're using groups + const hasGroups = sortedGroups.some((g) => g !== undefined); + + if (hasGroups) { + return ( +
+ {sortedGroups.map((groupName) => { + const groupProps = groupedProperties.get(groupName) || []; + const groupDef = groupName ? groupDefs[groupName] : null; + + // Separate regular and advanced within group + const regular = groupProps.filter(([, p]) => !p["x-ui"]?.advanced); + const advanced = groupProps.filter(([, p]) => p["x-ui"]?.advanced); + + if (regular.length === 0 && advanced.length === 0) return null; + + return ( +
+ {groupDef && ( +
+

{groupDef.title}

+ {groupDef.description && ( +

+ {groupDef.description} +

+ )} +
+ )} +
+ {regular.map(([key, prop]) => renderField(key, prop))} +
+ {advanced.length > 0 && ( + + + + + + {advanced.map(([key, prop]) => renderField(key, prop))} + + + )} +
+ ); + })} +
+ ); + } + + // No groups - render flat list + return ( +
+ {regularFields.map(([key, prop]) => renderField(key, prop))} + + {advancedFields.length > 0 && ( + + + + + + {advancedFields.map(([key, prop]) => renderField(key, prop))} + + + )} +
+ ); +} diff --git a/ui/src/components/schema-form/fields/CheckboxField.tsx b/ui/src/components/schema-form/fields/CheckboxField.tsx new file mode 100644 index 000000000..fe461133b --- /dev/null +++ b/ui/src/components/schema-form/fields/CheckboxField.tsx @@ -0,0 +1,45 @@ +/** + * CheckboxField - Boolean toggle for boolean schema types + */ + +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { type SchemaProperty } from "@/lib/guard-schema-types"; + +interface CheckboxFieldProps { + name: string; + schema: SchemaProperty; + value: boolean; + onChange: (value: boolean) => void; + error?: string; + disabled?: boolean; +} + +export function CheckboxField({ + name, + schema, + value, + onChange, + error, + disabled, +}: CheckboxFieldProps) { + return ( +
+
+ onChange(checked === true)} + disabled={disabled} + /> + +
+ {schema.description && ( +

{schema.description}

+ )} + {error &&

{error}

} +
+ ); +} diff --git a/ui/src/components/schema-form/fields/InputField.tsx b/ui/src/components/schema-form/fields/InputField.tsx new file mode 100644 index 000000000..1840a56fc --- /dev/null +++ b/ui/src/components/schema-form/fields/InputField.tsx @@ -0,0 +1,68 @@ +/** + * InputField - Text/number input for string and number schema types + */ + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { type SchemaProperty } from "@/lib/guard-schema-types"; + +interface InputFieldProps { + name: string; + schema: SchemaProperty; + value: unknown; + onChange: (value: unknown) => void; + error?: string; + disabled?: boolean; +} + +export function InputField({ + name, + schema, + value, + onChange, + error, + disabled, +}: InputFieldProps) { + const isNumber = schema.type === "number" || schema.type === "integer"; + const placeholder = schema["x-ui"]?.placeholder; + + const handleChange = (e: React.ChangeEvent) => { + const rawValue = e.target.value; + if (isNumber) { + if (rawValue === "") { + onChange(undefined); + } else { + const num = schema.type === "integer" ? parseInt(rawValue, 10) : parseFloat(rawValue); + if (!isNaN(num)) { + onChange(num); + } + } + } else { + onChange(rawValue); + } + }; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} + + {error &&

{error}

} +
+ ); +} diff --git a/ui/src/components/schema-form/fields/KeyValueField.tsx b/ui/src/components/schema-form/fields/KeyValueField.tsx new file mode 100644 index 000000000..75a0a0ee2 --- /dev/null +++ b/ui/src/components/schema-form/fields/KeyValueField.tsx @@ -0,0 +1,102 @@ +/** + * KeyValueField - Key-value pair editor for object maps + */ + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Plus, Trash2 } from "lucide-react"; +import { type SchemaProperty } from "@/lib/guard-schema-types"; + +interface KeyValueFieldProps { + name: string; + schema: SchemaProperty; + value: Record; + onChange: (value: Record) => void; + error?: string; + disabled?: boolean; +} + +export function KeyValueField({ + name, + schema, + value, + onChange, + error, + disabled, +}: KeyValueFieldProps) { + const entries = Object.entries(value || {}); + + const addEntry = () => { + const newKey = `key${entries.length + 1}`; + onChange({ ...value, [newKey]: "" }); + }; + + const removeEntry = (key: string) => { + const newValue = { ...value }; + delete newValue[key]; + onChange(newValue); + }; + + const updateKey = (oldKey: string, newKey: string) => { + if (oldKey === newKey) return; + const newValue: Record = {}; + for (const [k, v] of Object.entries(value || {})) { + if (k === oldKey) { + newValue[newKey] = v; + } else { + newValue[k] = v; + } + } + onChange(newValue); + }; + + const updateValue = (key: string, newVal: string) => { + onChange({ ...value, [key]: newVal }); + }; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} +
+ {entries.map(([key, val], index) => ( +
+ updateKey(key, e.target.value)} + placeholder="Key" + disabled={disabled} + className="flex-1" + /> + updateValue(key, e.target.value)} + placeholder="Value" + disabled={disabled} + className="flex-1" + /> + {!disabled && ( + + )} +
+ ))} + {!disabled && ( + + )} +
+ {error &&

{error}

} +
+ ); +} diff --git a/ui/src/components/schema-form/fields/MultiselectField.tsx b/ui/src/components/schema-form/fields/MultiselectField.tsx new file mode 100644 index 000000000..036d4e6d1 --- /dev/null +++ b/ui/src/components/schema-form/fields/MultiselectField.tsx @@ -0,0 +1,68 @@ +/** + * MultiselectField - Checkbox group for enum array schema types + */ + +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { type SchemaProperty, type ArraySchemaProperty, type StringSchemaProperty } from "@/lib/guard-schema-types"; + +interface MultiselectFieldProps { + name: string; + schema: SchemaProperty; + value: string[]; + onChange: (value: string[]) => void; + error?: string; + disabled?: boolean; +} + +export function MultiselectField({ + name, + schema, + value, + onChange, + error, + disabled, +}: MultiselectFieldProps) { + const items = Array.isArray(value) ? value : []; + + // Get enum values from items schema + const arraySchema = schema as ArraySchemaProperty; + const itemsSchema = arraySchema.items as StringSchemaProperty | undefined; + const enumValues = itemsSchema?.enum || []; + + // Get display labels from UI hints + const labels = schema["x-ui"]?.labels || {}; + + const handleToggle = (enumValue: string, checked: boolean) => { + if (checked) { + onChange([...items, enumValue]); + } else { + onChange(items.filter((item) => item !== enumValue)); + } + }; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} +
+ {enumValues.map((enumValue) => ( +
+ handleToggle(enumValue, checked === true)} + disabled={disabled} + /> + +
+ ))} +
+ {error &&

{error}

} +
+ ); +} diff --git a/ui/src/components/schema-form/fields/ObjectArrayField.tsx b/ui/src/components/schema-form/fields/ObjectArrayField.tsx new file mode 100644 index 000000000..dda35a21b --- /dev/null +++ b/ui/src/components/schema-form/fields/ObjectArrayField.tsx @@ -0,0 +1,192 @@ +/** + * ObjectArrayField - Expandable list for array of objects schema types + */ + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react"; +import { + type SchemaProperty, + type ArraySchemaProperty, + type ObjectSchemaProperty, +} from "@/lib/guard-schema-types"; + +interface ObjectArrayFieldProps { + name: string; + schema: SchemaProperty; + value: Record[]; + onChange: (value: Record[]) => void; + error?: string; + disabled?: boolean; +} + +export function ObjectArrayField({ + name, + schema, + value, + onChange, + error, + disabled, +}: ObjectArrayFieldProps) { + const [expandedItems, setExpandedItems] = useState>(new Set([0])); + const items = Array.isArray(value) ? value : []; + + // Get item schema + const arraySchema = schema as ArraySchemaProperty; + const itemSchema = arraySchema.items as ObjectSchemaProperty | undefined; + const itemProperties = itemSchema?.properties || {}; + const requiredFields = itemSchema?.required || []; + + const toggleExpanded = (index: number) => { + const newExpanded = new Set(expandedItems); + if (newExpanded.has(index)) { + newExpanded.delete(index); + } else { + newExpanded.add(index); + } + setExpandedItems(newExpanded); + }; + + const addItem = () => { + // Create new item with defaults + const newItem: Record = {}; + for (const [key, prop] of Object.entries(itemProperties)) { + if (prop.default !== undefined) { + newItem[key] = prop.default; + } + } + const newItems = [...items, newItem]; + onChange(newItems); + setExpandedItems(new Set([...expandedItems, newItems.length - 1])); + }; + + const removeItem = (index: number) => { + const newItems = items.filter((_, i) => i !== index); + onChange(newItems); + const newExpanded = new Set(expandedItems); + newExpanded.delete(index); + setExpandedItems(newExpanded); + }; + + const updateItem = (index: number, key: string, newValue: unknown) => { + const newItems = [...items]; + newItems[index] = { ...newItems[index], [key]: newValue }; + onChange(newItems); + }; + + const getItemLabel = (item: Record, index: number): string => { + // Try to find a name/title field for display + const labelField = Object.keys(itemProperties).find( + (k) => k === "name" || k === "title" || k === "id" + ); + if (labelField && item[labelField]) { + return String(item[labelField]); + } + return `Item ${index + 1}`; + }; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} +
+ {items.map((item, index) => ( + + toggleExpanded(index)} + > + +
+
+ {expandedItems.has(index) ? ( + + ) : ( + + )} + {getItemLabel(item, index)} +
+ {!disabled && ( + + )} +
+
+ + + {Object.entries(itemProperties).map(([key, prop]) => ( +
+ + {prop.type === "boolean" ? ( +
+ updateItem(index, key, e.target.checked)} + disabled={disabled} + className="h-4 w-4" + /> + {prop.description && ( + + {prop.description} + + )} +
+ ) : ( + { + const rawValue = e.target.value; + if (prop.type === "number" || prop.type === "integer") { + const num = parseFloat(rawValue); + updateItem(index, key, isNaN(num) ? undefined : num); + } else { + updateItem(index, key, rawValue); + } + }} + placeholder={prop["x-ui"]?.placeholder || prop.description} + disabled={disabled} + /> + )} +
+ ))} +
+
+
+
+ ))} + {!disabled && ( + + )} +
+ {error &&

{error}

} +
+ ); +} diff --git a/ui/src/components/schema-form/fields/SelectField.tsx b/ui/src/components/schema-form/fields/SelectField.tsx new file mode 100644 index 000000000..6fd1f93c3 --- /dev/null +++ b/ui/src/components/schema-form/fields/SelectField.tsx @@ -0,0 +1,74 @@ +/** + * SelectField - Dropdown select for enum schema types + */ + +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { type SchemaProperty, type StringSchemaProperty, type NumberSchemaProperty } from "@/lib/guard-schema-types"; + +interface SelectFieldProps { + name: string; + schema: SchemaProperty; + value: string | number; + onChange: (value: string | number) => void; + error?: string; + disabled?: boolean; +} + +export function SelectField({ + name, + schema, + value, + onChange, + error, + disabled, +}: SelectFieldProps) { + // Get enum values from schema + const enumValues: (string | number)[] = + schema.type === "string" + ? (schema as StringSchemaProperty).enum || [] + : (schema as NumberSchemaProperty).enum || []; + + // Get display labels from UI hints + const labels = schema["x-ui"]?.labels || {}; + + const handleChange = (newValue: string) => { + if (schema.type === "number" || schema.type === "integer") { + onChange(parseFloat(newValue)); + } else { + onChange(newValue); + } + }; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} + + {error &&

{error}

} +
+ ); +} diff --git a/ui/src/components/schema-form/fields/SliderField.tsx b/ui/src/components/schema-form/fields/SliderField.tsx new file mode 100644 index 000000000..51f5f109b --- /dev/null +++ b/ui/src/components/schema-form/fields/SliderField.tsx @@ -0,0 +1,93 @@ +/** + * SliderField - Range slider for bounded number schema types + */ + +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { type SchemaProperty, type NumberSchemaProperty } from "@/lib/guard-schema-types"; + +interface SliderFieldProps { + name: string; + schema: SchemaProperty; + value: number; + onChange: (value: number) => void; + error?: string; + disabled?: boolean; +} + +export function SliderField({ + name, + schema, + value, + onChange, + error, + disabled, +}: SliderFieldProps) { + const numSchema = schema as NumberSchemaProperty; + const min = numSchema.minimum ?? 0; + const max = numSchema.maximum ?? 100; + const step = numSchema.multipleOf ?? (schema.type === "integer" ? 1 : 0.01); + + // Local state for the input field to allow typing + const [inputValue, setInputValue] = useState(String(value ?? min)); + + const handleSliderChange = (e: React.ChangeEvent) => { + const num = parseFloat(e.target.value); + onChange(num); + setInputValue(String(num)); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + }; + + const handleInputBlur = () => { + let num = parseFloat(inputValue); + if (isNaN(num)) { + num = value ?? min; + } + // Clamp to range + num = Math.max(min, Math.min(max, num)); + onChange(num); + setInputValue(String(num)); + }; + + return ( +
+
+ + +
+ {schema.description && ( +

{schema.description}

+ )} +
+ {min} + + {max} +
+ {error &&

{error}

} +
+ ); +} diff --git a/ui/src/components/schema-form/fields/TagsField.tsx b/ui/src/components/schema-form/fields/TagsField.tsx new file mode 100644 index 000000000..e826e82e7 --- /dev/null +++ b/ui/src/components/schema-form/fields/TagsField.tsx @@ -0,0 +1,100 @@ +/** + * TagsField - Tag input for string array schema types + */ + +import { useState, KeyboardEvent } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { X } from "lucide-react"; +import { type SchemaProperty } from "@/lib/guard-schema-types"; + +interface TagsFieldProps { + name: string; + schema: SchemaProperty; + value: string[]; + onChange: (value: string[]) => void; + error?: string; + disabled?: boolean; +} + +export function TagsField({ + name, + schema, + value, + onChange, + error, + disabled, +}: TagsFieldProps) { + const [inputValue, setInputValue] = useState(""); + const items = Array.isArray(value) ? value : []; + const placeholder = schema["x-ui"]?.placeholder || "Type and press Enter"; + + const addTag = (tag: string) => { + const trimmed = tag.trim(); + if (trimmed && !items.includes(trimmed)) { + onChange([...items, trimmed]); + } + setInputValue(""); + }; + + const removeTag = (index: number) => { + const newItems = items.filter((_, i) => i !== index); + onChange(newItems); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + addTag(inputValue); + } else if (e.key === "Backspace" && inputValue === "" && items.length > 0) { + removeTag(items.length - 1); + } + }; + + const handleBlur = () => { + if (inputValue.trim()) { + addTag(inputValue); + } + }; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} +
+ {items.length > 0 && ( +
+ {items.map((item, index) => ( + + {item} + {!disabled && ( + + )} + + ))} +
+ )} + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + placeholder={placeholder} + disabled={disabled} + className={error ? "border-destructive" : ""} + /> +
+ {error &&

{error}

} +
+ ); +} diff --git a/ui/src/components/schema-form/fields/TextareaField.tsx b/ui/src/components/schema-form/fields/TextareaField.tsx new file mode 100644 index 000000000..9f21de867 --- /dev/null +++ b/ui/src/components/schema-form/fields/TextareaField.tsx @@ -0,0 +1,47 @@ +/** + * TextareaField - Multi-line text input for long string schema types + */ + +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { type SchemaProperty } from "@/lib/guard-schema-types"; + +interface TextareaFieldProps { + name: string; + schema: SchemaProperty; + value: string; + onChange: (value: string) => void; + error?: string; + disabled?: boolean; +} + +export function TextareaField({ + name, + schema, + value, + onChange, + error, + disabled, +}: TextareaFieldProps) { + const placeholder = schema["x-ui"]?.placeholder; + const rows = schema["x-ui"]?.rows ?? 4; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} +