diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index a6b3dd21..b59a824b 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -66,18 +66,111 @@ impl PluginExactVersion { #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct PluginGrantConfig { - pub tools: Vec, - pub secrets: Vec, - pub filesystem: Vec, - pub network: bool, + /// Source-qualified package id this grant is pinned to, for example `project:example`. + pub id: Option, + /// Exact package version this grant is pinned to. + pub version: Option, + /// Deterministic package digest this grant is pinned to. + pub digest: Option, + /// Explicit capabilities granted for the pinned package identity/version/digest. + pub permissions: Vec, } impl PluginGrantConfig { pub fn is_empty(&self) -> bool { - self.tools.is_empty() - && self.secrets.is_empty() - && self.filesystem.is_empty() - && !self.network + self.permissions.is_empty() + } + + pub fn binding_error( + &self, + identity: &SourceQualifiedPluginId, + digest: &str, + version: &str, + ) -> Option<&'static str> { + if self.permissions.is_empty() { + return None; + } + let Some(grant_id) = &self.id else { + return Some("plugin grant is missing a source-qualified package id binding"); + }; + match SourceQualifiedPluginId::parse(grant_id) { + Ok(grant_identity) if &grant_identity == identity => {} + Ok(_) => return Some("plugin grant package id binding does not match enabled package"), + Err(_) => { + return Some( + "plugin grant package id binding is not a valid source-qualified plugin id", + ); + } + } + let Some(grant_digest) = &self.digest else { + return Some("plugin grant is missing a deterministic digest binding"); + }; + if !digest_matches(grant_digest, digest) { + return Some("plugin grant digest binding does not match enabled package digest"); + } + let Some(grant_version) = &self.version else { + return Some("plugin grant is missing an exact package version binding"); + }; + if !grant_version.matches(version) { + return Some("plugin grant version binding does not match enabled package version"); + } + None + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PluginPermission { + Surface { surface: PluginSurface }, + Tool { name: String }, + ToolNamespace { namespace: String }, + ExternalWrite, + HostApi { api: PluginHostApi }, +} + +impl PluginPermission { + pub fn label(&self) -> String { + match self { + Self::Surface { surface } => format!("surfaces.{surface}"), + Self::Tool { name } => format!("tool.{name}"), + Self::ToolNamespace { namespace } => format!("tool_namespace.{namespace}"), + Self::ExternalWrite => "external_write".to_string(), + Self::HostApi { api } => format!("host_api.{api}"), + } + } + + pub fn surface(surface: PluginSurface) -> Self { + Self::Surface { surface } + } + + pub fn tool(name: impl Into) -> Self { + Self::Tool { name: name.into() } + } + + pub fn tool_namespace(namespace: impl Into) -> Self { + Self::ToolNamespace { + namespace: namespace.into(), + } + } + + pub fn host_api(api: PluginHostApi) -> Self { + Self::HostApi { api } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginHostApi { + Https, + Fs, +} + +impl fmt::Display for PluginHostApi { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Https => f.write_str("https"), + Self::Fs => f.write_str("fs"), + } } } @@ -190,6 +283,10 @@ pub struct PluginPackageManifest { pub hooks: Vec, #[serde(default)] pub tools: Vec, + /// Permission requests declared by the package. These are requests only; + /// enablement grants must match them before runtime surfaces are exposed. + #[serde(default)] + pub permissions: Vec, } impl PluginPackageManifest { @@ -229,6 +326,11 @@ pub struct PluginToolManifest { pub name: String, pub description: String, pub input_schema: serde_json::Value, + /// Whether this Tool declares side effects outside the model-visible result. + /// The flag does not grant authority; it requires a matching external_write + /// request and grant before registration or execution. + #[serde(default)] + pub external_write: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -561,12 +663,16 @@ pub fn resolve_enabled_plugins( } } - if !enablement.grants.is_empty() { + if let Some(message) = + enablement + .grants + .binding_error(&identity, &package.digest, &package.manifest.version) + { resolution.diagnostics.push( PluginDiagnostic::new( PluginDiagnosticKind::Grant, PluginDiagnosticPhase::Resolution, - "plugin authority grants are not implemented and fail closed", + message, ) .with_source(identity.source) .with_identity(&identity) @@ -1937,6 +2043,93 @@ input_schema = { type = "object", properties = { query = { type = "string" } }, ); } + #[test] + fn typed_permission_grant_binding_resolves_only_exact_package_identity() { + let (report, _) = fixture_with_enabled_plugin(false); + let digest = report.packages[0].digest.clone(); + let exact_grants = PluginGrantConfig { + id: Some("project:example".to_string()), + version: Some(PluginExactVersion("0.1.0".to_string())), + digest: Some(digest.clone()), + permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + }; + let resolution = resolve_enabled_plugins( + &PluginConfig { + enabled: vec![PluginEnablementConfig { + id: "project:example".to_string(), + grants: exact_grants, + ..PluginEnablementConfig::default() + }], + ..PluginConfig::default() + }, + &report, + ); + assert!( + resolution.diagnostics.is_empty(), + "{:#?}", + resolution.diagnostics + ); + assert_eq!(resolution.resolved.len(), 1); + + for grants in [ + PluginGrantConfig { + id: Some("project:other".to_string()), + version: Some(PluginExactVersion("0.1.0".to_string())), + digest: Some(digest.clone()), + permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + }, + PluginGrantConfig { + id: Some("project:example".to_string()), + version: Some(PluginExactVersion("0.1.1".to_string())), + digest: Some(digest.clone()), + permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + }, + PluginGrantConfig { + id: Some("project:example".to_string()), + version: Some(PluginExactVersion("0.1.0".to_string())), + digest: Some("sha256:unrelated".to_string()), + permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + }, + ] { + let resolution = resolve_enabled_plugins( + &PluginConfig { + enabled: vec![PluginEnablementConfig { + id: "project:example".to_string(), + grants, + ..PluginEnablementConfig::default() + }], + ..PluginConfig::default() + }, + &report, + ); + assert!(resolution.resolved.is_empty()); + assert!( + resolution + .diagnostics + .iter() + .any(|diag| diag.kind == PluginDiagnosticKind::Grant), + "{:#?}", + resolution.diagnostics + ); + } + } + + #[test] + fn unknown_permission_kind_fails_closed_at_manifest_parse_boundary() { + let error = toml::from_str::( + r#"schema_version = 1 +id = "example" +name = "Example" +version = "0.1.0" + +[[permissions]] +kind = "ambient_shell" +"#, + ) + .unwrap_err(); + assert!(error.to_string().contains("ambient_shell"), "{error}"); + } + #[test] fn surface_and_grant_failures_do_not_resolve() { let (report, _) = fixture_with_enabled_plugin(false); @@ -1951,7 +2144,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } }, PluginEnablementConfig { id: "project:example".to_string(), grants: PluginGrantConfig { - filesystem: vec![".".to_string()], + permissions: vec![PluginPermission::surface(PluginSurface::Tool)], ..PluginGrantConfig::default() }, ..PluginEnablementConfig::default() diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index a5eae230..97949a31 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -15,8 +15,8 @@ use llm_worker::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput, }; use manifest::plugin::{ - PluginConfig, PluginDiscoveryLimits, PluginSurface, ResolvedPluginRecord, - read_resolved_plugin_runtime_module, + PluginConfig, PluginDiscoveryLimits, PluginHostApi, PluginPermission, PluginSurface, + PluginToolManifest, ResolvedPluginRecord, read_resolved_plugin_runtime_module, }; use serde_json::Value; @@ -106,6 +106,8 @@ impl FeatureModule for PluginToolFeature { fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { validate_declared_tool_names(&self.record)?; let origin = self.origin(); + let mut registered = 0usize; + let mut denied = Vec::new(); for tool in &self.record.manifest.tools { validate_tool_name(&tool.name).map_err(|reason| { FeatureInstallError::Install(format!( @@ -119,6 +121,17 @@ impl FeatureModule for PluginToolFeature { self.record.identity, tool.name )) })?; + if let Err(error) = authorize_plugin_tool(&self.record, tool) { + let message = format!( + "plugin `{}` tool `{}` registration denied: {}", + self.record.identity, + tool.name, + error.bounded_message() + ); + context.diagnostics().warning(message.clone()); + denied.push(message); + continue; + } context.tools().register(ToolContribution::new( tool.name.clone(), plugin_wasm_tool_definition( @@ -129,11 +142,128 @@ impl FeatureModule for PluginToolFeature { origin.clone(), ), ))?; + registered += 1; + } + if registered == 0 && !denied.is_empty() { + let summary = if denied.len() == 1 { + denied.remove(0) + } else { + format!( + "{} plugin tool registrations denied; first denial: {}", + denied.len(), + denied[0] + ) + }; + return Err(FeatureInstallError::Install(bounded_message(summary))); } Ok(()) } } +#[derive(Debug)] +struct PluginPermissionError(String); + +impl PluginPermissionError { + fn bounded_message(&self) -> String { + bounded_message(&self.0) + } +} + +fn authorize_plugin_tool( + record: &ResolvedPluginRecord, + tool: &PluginToolManifest, +) -> Result<(), PluginPermissionError> { + validate_grant_binding(record)?; + require_permission( + &record.manifest.permissions, + &PluginPermission::surface(PluginSurface::Tool), + "requested surfaces.tool permission is missing", + )?; + require_permission( + &record.grants.permissions, + &PluginPermission::surface(PluginSurface::Tool), + "granted surfaces.tool permission is missing", + )?; + if !permission_allows_tool(&record.manifest.permissions, &tool.name) { + return Err(PluginPermissionError(format!( + "requested tool permission for `{}` is missing", + tool.name + ))); + } + if !permission_allows_tool(&record.grants.permissions, &tool.name) { + return Err(PluginPermissionError(format!( + "granted tool permission for `{}` is missing", + tool.name + ))); + } + if tool.external_write { + require_permission( + &record.manifest.permissions, + &PluginPermission::ExternalWrite, + "requested external_write permission is missing", + )?; + require_permission( + &record.grants.permissions, + &PluginPermission::ExternalWrite, + "granted external_write permission is missing", + )?; + } + Ok(()) +} + +fn authorize_plugin_host_api( + record: &ResolvedPluginRecord, + api: PluginHostApi, +) -> Result<(), PluginPermissionError> { + validate_grant_binding(record)?; + let permission = PluginPermission::host_api(api); + require_permission( + &record.manifest.permissions, + &permission, + &format!("requested host_api.{api} permission is missing"), + )?; + require_permission( + &record.grants.permissions, + &permission, + &format!("granted host_api.{api} permission is missing"), + )?; + Err(PluginPermissionError(format!( + "host_api.{api} is not implemented" + ))) +} + +fn validate_grant_binding(record: &ResolvedPluginRecord) -> Result<(), PluginPermissionError> { + if let Some(message) = + record + .grants + .binding_error(&record.identity, &record.digest, &record.manifest.version) + { + return Err(PluginPermissionError(message.to_string())); + } + Ok(()) +} + +fn require_permission( + permissions: &[PluginPermission], + expected: &PluginPermission, + missing_message: &str, +) -> Result<(), PluginPermissionError> { + if permissions.iter().any(|permission| permission == expected) { + return Ok(()); + } + Err(PluginPermissionError(missing_message.to_string())) +} + +fn permission_allows_tool(permissions: &[PluginPermission], tool_name: &str) -> bool { + permissions.iter().any(|permission| match permission { + PluginPermission::Tool { name } => name == tool_name, + PluginPermission::ToolNamespace { namespace } => { + !namespace.is_empty() && tool_name.starts_with(namespace) + } + _ => false, + }) +} + const PLUGIN_WASM_HOST_MODULE: &str = "yoi:tool"; const PLUGIN_WASM_ENTRYPOINT: &str = "yoi_tool_call"; const PLUGIN_WASM_MAX_INPUT_BYTES: usize = 64 * 1024; @@ -259,6 +389,20 @@ fn run_plugin_wasm_tool( tool_name: String, input: Vec, ) -> Result { + let tool = record + .manifest + .tools + .iter() + .find(|tool| tool.name == tool_name) + .ok_or_else(|| { + PluginWasmError::Module("requested tool is not declared by plugin manifest".to_string()) + })?; + authorize_plugin_tool(&record, tool).map_err(|error| { + PluginWasmError::Module(format!( + "plugin permission denied: {}", + error.bounded_message() + )) + })?; let limits = PluginDiscoveryLimits::default(); let module_bytes = read_resolved_plugin_runtime_module(&record, &limits) .map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?; @@ -276,7 +420,7 @@ fn run_plugin_wasm_tool( let engine = wasmi::Engine::new(&config); let module = wasmi::Module::new(&engine, &module_bytes[..]) .map_err(|error| PluginWasmError::Module(error.to_string()))?; - validate_wasm_imports(&module)?; + validate_wasm_imports(&record, &module)?; let store_limits = wasmi::StoreLimitsBuilder::new() .memory_size(PLUGIN_WASM_MEMORY_BYTES) @@ -319,8 +463,27 @@ fn run_plugin_wasm_tool( decode_plugin_wasm_output(&store.data().output) } -fn validate_wasm_imports(module: &wasmi::Module) -> Result<(), PluginWasmError> { +fn validate_wasm_imports( + record: &ResolvedPluginRecord, + module: &wasmi::Module, +) -> Result<(), PluginWasmError> { for import in module.imports() { + if import.module() == "yoi:https" { + authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| { + PluginWasmError::Module(format!( + "plugin host API dispatch denied: {}", + error.bounded_message() + )) + })?; + } + if import.module() == "yoi:fs" { + authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| { + PluginWasmError::Module(format!( + "plugin host API dispatch denied: {}", + error.bounded_message() + )) + })?; + } if import.module() != PLUGIN_WASM_HOST_MODULE { return Err(PluginWasmError::Module(format!( "unsupported import module `{}`; only `{}` is available", @@ -752,8 +915,9 @@ fn is_supported_schema_keyword(key: &str) -> bool { mod tests { use super::*; use manifest::plugin::{ - PluginDiscoveryOptions, PluginEnablementConfig, PluginPackageManifest, - PluginRuntimeManifest, SourceQualifiedPluginId, resolve_plugin_config_for_startup, + PluginDiscoveryOptions, PluginEnablementConfig, PluginExactVersion, PluginGrantConfig, + PluginPackageManifest, PluginRuntimeManifest, SourceQualifiedPluginId, + resolve_plugin_config_for_startup, }; use serde_json::json; use std::fs; @@ -765,6 +929,7 @@ mod tests { name: name.into(), description: format!("{name} tool"), input_schema: json!({"type":"object","properties":{},"additionalProperties":false}), + external_write: false, } } @@ -777,6 +942,7 @@ mod tests { tools: Vec, ) -> ResolvedPluginRecord { let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap(); + let permissions = tool_permissions(&tools); ResolvedPluginRecord { identity: parsed_identity.clone(), source: parsed_identity.source, @@ -794,13 +960,29 @@ mod tests { runtime: None, hooks: Vec::new(), tools, + permissions: permissions.clone(), }, enabled_surfaces: vec![PluginSurface::Tool], - grants: manifest::plugin::PluginGrantConfig::default(), + grants: PluginGrantConfig { + id: Some(parsed_identity.to_string()), + version: Some(PluginExactVersion("0.1.0".to_string())), + digest: Some("sha256:abc".to_string()), + permissions, + }, config: None, } } + fn tool_permissions(tools: &[manifest::plugin::PluginToolManifest]) -> Vec { + let mut permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; + permissions.extend( + tools + .iter() + .map(|tool| PluginPermission::tool(tool.name.clone())), + ); + permissions + } + fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize { report .reports @@ -818,6 +1000,20 @@ mod tests { }) } + fn install_plugin_record( + record: ResolvedPluginRecord, + ) -> ( + super::super::FeatureRegistryInstallReport, + Vec, + ) { + let mut pending = Vec::new(); + let mut hooks = crate::hook::HookRegistryBuilder::new(); + let report = super::super::FeatureRegistryBuilder::default() + .with_module(PluginToolFeature::new(record)) + .install_into_pending(&mut pending, &mut hooks); + (report, pending) + } + #[test] fn rejects_invalid_root_schema() { let schema = json!({"type":"string"}); @@ -942,6 +1138,146 @@ mod tests { assert_eq!(origin.surface, "tool"); } + #[test] + fn no_grant_denies_plugin_tool_registration_and_runtime_execution() { + let mut record = record(vec![tool("PluginSearch")]); + record.grants = PluginGrantConfig::default(); + + let (report, pending) = install_plugin_record(record.clone()); + assert!(pending.is_empty()); + assert!(has_diagnostic(&report, "registration denied")); + assert!(has_diagnostic( + &report, + "granted surfaces.tool permission is missing" + )); + + let error = run_plugin_wasm_tool(record, "PluginSearch".into(), br#"{}"#.to_vec()) + .unwrap_err() + .bounded_message(); + assert!(error.contains("plugin permission denied"), "{error}"); + assert!( + error.contains("granted surfaces.tool permission is missing"), + "{error}" + ); + assert!(error.len() < 700, "{error}"); + } + + #[test] + fn specific_tool_grant_registers_only_intended_plugin_tool() { + let mut record = record(vec![tool("PluginAllowed"), tool("PluginDenied")]); + record.grants.permissions = vec![ + PluginPermission::surface(PluginSurface::Tool), + PluginPermission::tool("PluginAllowed"), + ]; + + let (report, pending) = install_plugin_record(record); + assert_eq!(pending.len(), 1); + let (meta, _) = pending[0](); + assert_eq!(meta.name, "PluginAllowed"); + assert_eq!(report.installed_tool_names(), vec!["PluginAllowed"]); + assert!(has_diagnostic( + &report, + "granted tool permission for `PluginDenied` is missing" + )); + } + + #[test] + fn grant_binding_mismatches_do_not_authorize_plugin_tool() { + let mut unrelated = record(vec![tool("PluginSearch")]); + unrelated.grants.id = Some("project:other".to_string()); + let error = authorize_plugin_tool(&unrelated, &unrelated.manifest.tools[0]) + .unwrap_err() + .bounded_message(); + assert!( + error.contains("package id binding does not match"), + "{error}" + ); + + let mut bad_digest = record(vec![tool("PluginSearch")]); + bad_digest.grants.digest = Some("sha256:not-the-package".to_string()); + let error = authorize_plugin_tool(&bad_digest, &bad_digest.manifest.tools[0]) + .unwrap_err() + .bounded_message(); + assert!(error.contains("digest binding does not match"), "{error}"); + + let mut bad_version = record(vec![tool("PluginSearch")]); + bad_version.grants.version = Some(PluginExactVersion("9.9.9".to_string())); + let error = authorize_plugin_tool(&bad_version, &bad_version.manifest.tools[0]) + .unwrap_err() + .bounded_message(); + assert!(error.contains("version binding does not match"), "{error}"); + } + + #[test] + fn requested_surface_tool_and_external_write_permissions_are_required() { + let mut missing_surface = record(vec![tool("PluginSearch")]); + missing_surface.manifest.permissions = vec![PluginPermission::tool("PluginSearch")]; + let (report, pending) = install_plugin_record(missing_surface); + assert!(pending.is_empty()); + assert!(has_diagnostic( + &report, + "requested surfaces.tool permission is missing" + )); + + let mut missing_tool = record(vec![tool("PluginSearch")]); + missing_tool.manifest.permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; + let (report, pending) = install_plugin_record(missing_tool); + assert!(pending.is_empty()); + assert!(has_diagnostic( + &report, + "requested tool permission for `PluginSearch` is missing" + )); + + let mut external_tool = tool("PluginWrite"); + external_tool.external_write = true; + let mut missing_external_request = record(vec![external_tool]); + let (report, pending) = install_plugin_record(missing_external_request.clone()); + assert!(pending.is_empty()); + assert!(has_diagnostic( + &report, + "requested external_write permission is missing" + )); + + missing_external_request + .manifest + .permissions + .push(PluginPermission::ExternalWrite); + let (report, pending) = install_plugin_record(missing_external_request); + assert!(pending.is_empty()); + assert!(has_diagnostic( + &report, + "granted external_write permission is missing" + )); + } + + #[test] + fn future_host_api_imports_are_permission_checked_before_unimplemented_boundary() { + let (_dir, mut record) = resolved_record_with_wasm(https_import_module()); + let error = run_plugin_wasm_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec()) + .unwrap_err() + .bounded_message(); + assert!( + error.contains("requested host_api.https permission is missing"), + "{error}" + ); + + record + .manifest + .permissions + .push(PluginPermission::host_api(PluginHostApi::Https)); + record + .grants + .permissions + .push(PluginPermission::host_api(PluginHostApi::Https)); + let error = run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()) + .unwrap_err() + .bounded_message(); + assert!( + error.contains("host_api.https is not implemented"), + "{error}" + ); + } + #[test] fn package_without_enabled_tool_surface_registers_no_schema() { let mut config = PluginConfig::default(); @@ -1176,7 +1512,14 @@ mod tests { resolved.diagnostics ); assert_eq!(resolved.resolved.len(), 1); - (dir, resolved.resolved[0].clone()) + let mut record = resolved.resolved[0].clone(); + record.grants = PluginGrantConfig { + id: Some(record.identity.to_string()), + version: Some(PluginExactVersion(record.version.clone())), + digest: Some(record.digest.clone()), + permissions: tool_permissions(&record.manifest.tools), + }; + (dir, record) } fn write_plugin_package(path: &Path, wasm: &[u8]) { @@ -1192,6 +1535,14 @@ kind = "wasm" entry = "plugin.wasm" abi = "yoi-plugin-wasm-1" +[[permissions]] +kind = "surface" +surface = "tool" + +[[permissions]] +kind = "tool" +name = "PluginEcho" + [[tools]] name = "PluginEcho" description = "Echo plugin tool" @@ -1296,6 +1647,17 @@ input_schema = { type = "object", additionalProperties = true } .unwrap() } + fn https_import_module() -> Vec { + wat::parse_str( + r#"(module + (import "yoi:https" "request" (func $request)) + (memory (export "memory") 1) + (func (export "yoi_tool_call")) + )"#, + ) + .unwrap() + } + fn wat_bytes(bytes: &[u8]) -> String { bytes .iter() diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index b34845b7..9d1c232f 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -5354,6 +5354,7 @@ permission = "read" runtime: None, hooks: vec![], tools: vec![], + permissions: vec![], }, enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook], grants: manifest::plugin::PluginGrantConfig::default(),