diff --git a/Cargo.lock b/Cargo.lock index 3b86e34..b2c1dec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2761,6 +2761,15 @@ dependencies = [ "untrusted 0.9.0", ] +[[package]] +name = "seccompiler" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "345a3e4dddf721a478089d4697b83c6c0a8f5bf16086f6c13397e4534eb6e2e5" +dependencies = [ + "libc", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3037,10 +3046,12 @@ dependencies = [ "hidapi", "hmac", "indicatif", + "libc", "libloading", "mockito", "rand 0.8.6", "rustyline", + "seccompiler", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index 475e90e..6986c91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,10 @@ async-openai = "0.14" tokio = { version = "1.35", features = ["full"] } futures = "0.3" +[target.'cfg(target_os = "linux")'.dependencies] +seccompiler = "0.4.0" +libc = "0.2" + [features] hardware-wallet = ["dep:hidapi", "dep:trezor-client"] diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index fa5faf6..17a4221 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -1,9 +1,9 @@ use crate::plugins::interface::CORE_VERSION; use crate::plugins::manifest; use crate::plugins::registry::{self, RegisteredCommand, TrustLevel, UninstallOptions}; -use crate::plugins::{PluginLoadError, PluginManager}; +use crate::plugins::{Capability, PluginLoadError, PluginManager}; use crate::utils::print as p; -use anyhow::{Context, Result}; +use anyhow::Result; use clap::Subcommand; use std::path::Path; use std::path::PathBuf; @@ -151,6 +151,26 @@ fn install(name: String, path: Option, source: Option, force: b } }; + let meta = match get_plugin_metadata(&lib_path) { + Ok(m) => m, + Err(error) => { + p::warn(&format!( + "Plugin registered without capability discovery: {}", + error + )); + crate::plugins::loader::PluginMetadataDump { + name: name.clone(), + version: plugin_manifest.version.clone(), + description: "".to_string(), + capabilities: Vec::new(), + commands: discovered_commands.clone(), + } + } + }; + + let approved_caps = audit_and_approve_capabilities(&name, &meta.capabilities)?; + let content_hash = crate::plugins::loader::calculate_sha256(&lib_path).ok(); + registry::install_plugin( &name, &lib_path, @@ -158,6 +178,8 @@ fn install(name: String, path: Option, source: Option, force: b &plugin_manifest.starforge_version, &plugin_manifest.version, discovered_commands.clone(), + approved_caps, + content_hash, )?; p::header("Plugin Install"); @@ -435,13 +457,24 @@ fn update(name: Option, yes: bool) -> Result<()> { match status { Ok(s) if s.success() => { + let path = std::path::Path::new(&pl.path); + let meta = get_plugin_metadata(path).ok(); + let caps = if let Some(ref m) = meta { + audit_and_approve_capabilities(&pl.name, &m.capabilities)? + } else { + pl.capabilities.clone() + }; + let content_hash = crate::plugins::loader::calculate_sha256(path).ok(); + registry::install_plugin( &pl.name, - std::path::Path::new(&pl.path), + path, &pl.source, &pl.starforge_version, &pl.plugin_version, pl.commands.clone(), + caps, + content_hash, )?; p::success(&format!(" '{}' updated via cargo install", pl.name)); updated += 1; @@ -481,15 +514,26 @@ fn update(name: Option, yes: bool) -> Result<()> { if modified > installed_epoch { // Library on disk is newer — refresh the registry entry. + let path = std::path::Path::new(&pl.path); + let meta = get_plugin_metadata(path).ok(); + let caps = if let Some(ref m) = meta { + audit_and_approve_capabilities(&pl.name, &m.capabilities)? + } else { + pl.capabilities.clone() + }; + let content_hash = crate::plugins::loader::calculate_sha256(path).ok(); let cmds = discover_commands_from_library(&pl.path) .unwrap_or_else(|_| pl.commands.clone()); + registry::install_plugin( &pl.name, - std::path::Path::new(&pl.path), + path, &pl.source, &pl.starforge_version, &pl.plugin_version, cmds, + caps, + content_hash, )?; p::success(&format!( " '{}' library on disk is newer — registry refreshed.", @@ -883,19 +927,59 @@ fn print_audit_report(report: &AuditReport) { } fn discover_commands_from_library(path: &str) -> Result> { - let mut pm = PluginManager::new(); - unsafe { - pm.load_plugin(path) - .with_context(|| format!("Failed to load plugin from {}", path))?; - } - Ok(pm - .list_commands() - .into_iter() - .map(|c| RegisteredCommand { - name: c.name, - description: c.description, - }) - .collect()) + let meta = get_plugin_metadata(Path::new(path))?; + Ok(meta.commands) +} + +fn get_plugin_metadata(library_path: &Path) -> Result { + let output = std::process::Command::new(std::env::current_exe()?) + .arg("__dump_plugin_metadata") + .arg(library_path) + .output()?; + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Failed to dump plugin metadata: {}", err); + } + let meta: crate::plugins::loader::PluginMetadataDump = serde_json::from_slice(&output.stdout)?; + Ok(meta) +} + +fn audit_and_approve_capabilities( + name: &str, + capabilities: &[Capability], +) -> Result> { + if capabilities.is_empty() { + return Ok(Vec::new()); + } + + p::header("Plugin Capability Audit"); + p::warn(&format!( + "Plugin '{}' requests the following permissions/capabilities:", + name + )); + for cap in capabilities { + p::warn(&format!(" • {}: {}", cap.name(), cap.description())); + } + println!(); + + use std::io::IsTerminal; + let approved = if std::io::stdin().is_terminal() { + let theme = dialoguer::theme::ColorfulTheme::default(); + dialoguer::Confirm::with_theme(&theme) + .with_prompt("Do you want to grant these capabilities and load/install the plugin?") + .default(false) + .interact() + .unwrap_or(false) + } else { + p::warn("Non-interactive terminal detected: auto-approving capabilities."); + true + }; + + if !approved { + anyhow::bail!("Capability approval declined by user"); + } + + Ok(capabilities.to_vec()) } fn commands(name: Option) -> Result<()> { diff --git a/src/commands/telemetry.rs b/src/commands/telemetry.rs index 3e4a163..dc314da 100644 --- a/src/commands/telemetry.rs +++ b/src/commands/telemetry.rs @@ -53,19 +53,16 @@ fn handle_status() -> Result<()> { p::header("Telemetry Status"); p::separator(); + p::kv("Collection", if enabled { "enabled" } else { "disabled" }); p::kv( - "Collection", - if enabled { "enabled" } else { "disabled" }, + "Schema Version", + &telemetry::TELEMETRY_SCHEMA_VERSION.to_string(), ); - p::kv("Schema Version", &telemetry::TELEMETRY_SCHEMA_VERSION.to_string()); p::kv("Events Stored", &count.to_string()); p::kv("Log Size", &format!("{:.1} KB", size as f64 / 1024.0)); p::kv( "Limits", - &format!( - "{} entries / 5 MB", - telemetry::MAX_ENTRIES - ), + &format!("{} entries / 5 MB", telemetry::MAX_ENTRIES), ); if let Some(val) = env_override { p::kv("Env Override (STARFORGE_TELEMETRY)", &val); @@ -97,10 +94,7 @@ fn handle_show(limit: usize) -> Result<()> { println!(" {}", "─".repeat(72).dimmed()); for ev in &events { - let ts = ev - .timestamp - .format("%Y-%m-%d %H:%M:%S") - .to_string(); + let ts = ev.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(); let status = if ev.success { "✓ ok".green().to_string() } else { @@ -155,6 +149,9 @@ fn handle_clear(yes: bool) -> Result<()> { } telemetry::clear_log()?; - p::success(&format!("Telemetry log cleared ({} events removed).", count)); + p::success(&format!( + "Telemetry log cleared ({} events removed).", + count + )); Ok(()) } diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs index 5929f3f..9db3985 100644 --- a/src/commands/upgrade.rs +++ b/src/commands/upgrade.rs @@ -378,7 +378,8 @@ fn handle_propose(args: ProposeArgs) -> Result<()> { // ── Upgrade simulation + auth display ───────────────────────────────── p::step(4, 5, "Simulating upgrade transaction…"); - match soroban::simulate_upgrade_transaction(&args.contract_id, &new_hash, wallet, &args.network) { + match soroban::simulate_upgrade_transaction(&args.contract_id, &new_hash, wallet, &args.network) + { Ok(sim) => { p::kv("Estimated fee", &format!("{} stroops", sim.fee)); if !sim.auth_entries.is_empty() { @@ -667,7 +668,8 @@ fn handle_execute(args: ExecuteArgs) -> Result<()> { p::step(2, 3, "Validating multisig signer weights…"); match horizon::fetch_account_signers(&wallet.public_key, &args.network) { Ok(signer_info) => { - let local_keys: Vec<&str> = cfg.wallets.iter().map(|w| w.public_key.as_str()).collect(); + let local_keys: Vec<&str> = + cfg.wallets.iter().map(|w| w.public_key.as_str()).collect(); let available_weight: u32 = signer_info .signers .iter() @@ -677,7 +679,10 @@ fn handle_execute(args: ExecuteArgs) -> Result<()> { let required = signer_info.thresholds.high; p::kv("On-chain high threshold", &required.to_string()); - p::kv("Available local signer weight", &available_weight.to_string()); + p::kv( + "Available local signer weight", + &available_weight.to_string(), + ); if required > 0 && available_weight < required { let missing: Vec = signer_info diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index d7f35aa..0c8a0ec 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -1941,14 +1941,15 @@ fn multisig_create( p::kv("Signers", &account.signers.len().to_string()); if let Some(path) = xdr_output { p::step(1, 2, "Fetching source account sequence..."); - let source_account = horizon::fetch_account(&account.account_id, &network).map_err(|e| { - anyhow::anyhow!( - "Multi-sig account not found on {}: {}\nFund it with: starforge wallet fund {}", - network, - e, - account.name - ) - })?; + let source_account = + horizon::fetch_account(&account.account_id, &network).map_err(|e| { + anyhow::anyhow!( + "Multi-sig account not found on {}: {}\nFund it with: starforge wallet fund {}", + network, + e, + account.name + ) + })?; p::step(2, 2, "Building unsigned SetOptions transaction XDR..."); let setup_xdr = multisig::build_account_setup_transaction_xdr(&account, &source_account.sequence)?; @@ -1967,8 +1968,7 @@ fn multisig_create( " {}", format!( "starforge wallet multisig sign {} --transaction tx.xdr --network {}", - account.name, - network + account.name, network ) .cyan() ); @@ -1977,8 +1977,7 @@ fn multisig_create( " {}", format!( "starforge wallet multisig submit {} --transaction tx.xdr --network {}", - account.name, - network + account.name, network ) .cyan() ); @@ -2150,7 +2149,11 @@ fn multisig_submit(name: String, transaction: PathBuf, network: Option) ); } - p::step(1, 1, &format!("Submitting signed envelope to Horizon ({})...", network)); + p::step( + 1, + 1, + &format!("Submitting signed envelope to Horizon ({})...", network), + ); let result = horizon::submit_multisig_transaction(&signed_xdr, &network)?; println!(); diff --git a/src/main.rs b/src/main.rs index 71b00e5..884a8bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -122,6 +122,26 @@ enum Commands { } fn main() { + let args: Vec = std::env::args().collect(); + if args.len() > 1 && args[1] == "__run_plugin_library" { + if let Err(e) = plugins::loader::run_plugin_library_internal(&args[2..]) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + std::process::exit(0); + } + if args.len() > 1 && args[1] == "__dump_plugin_metadata" { + if args.len() < 3 { + eprintln!("Error: Missing plugin library path"); + std::process::exit(1); + } + if let Err(e) = plugins::loader::dump_plugin_metadata_internal(&args[2]) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + std::process::exit(0); + } + let cli = Cli::parse(); // Initialise structured logging before anything else runs. @@ -226,7 +246,6 @@ fn main() { fn handle_external_plugin(args: Vec) -> anyhow::Result<()> { use anyhow::Context; - use plugins::registry::TrustLevel; if args.is_empty() { anyhow::bail!("No plugin command provided"); @@ -237,15 +256,13 @@ fn handle_external_plugin(args: Vec) -> anyhow::Result<()> { let reg = plugins::registry::load_registry().unwrap_or_default(); if reg.plugins.is_empty() { - anyhow::bail!( - "Unknown command '{}'. No plugins installed.\n\nTry: starforge plugin install --path ", - plugin_name - ); + anyhow::bail!("No plugins registered. Use: starforge plugin install --path "); } // Check if the command matches any registered plugin command before loading .so files. let all_commands = plugins::registry::load_all_registered_commands(); let known = all_commands.iter().any(|c| c.name == *plugin_name); + if !known { let available: Vec = all_commands .iter() @@ -260,26 +277,66 @@ fn handle_external_plugin(args: Vec) -> anyhow::Result<()> { anyhow::bail!("Unknown command '{}'.\n\n{}", plugin_name, hint); } - // Warn about unknown-trust plugins before loading. - for pl in reg.plugins.iter().filter(|p| { - plugins::registry::classify_source(&p.source) == TrustLevel::Unknown && !p.source.is_empty() - }) { - eprintln!( - " ⚠ Warning: plugin '{}' is from an untrusted source: {}", - pl.name, pl.source + let target_plugin = reg + .plugins + .iter() + .find(|p| p.commands.iter().any(|c| c.name == *plugin_name)) + .ok_or_else(|| anyhow::anyhow!("Plugin command '{}' not found in registry", plugin_name))?; + + // Elevate Unknown plugins to blocked status + if target_plugin.trust == plugins::registry::TrustLevel::Unknown { + anyhow::bail!( + "Execution blocked: plugin '{}' is from an untrusted source ({})", + target_plugin.name, + target_plugin.source ); } - let mut pm = plugins::PluginManager::new(); - for pl in ®.plugins { - unsafe { - pm.load_plugin(&pl.path) - .with_context(|| format!("Failed to load plugin '{}' from {}", pl.name, pl.path))?; + let config = utils::config::load().unwrap_or_default(); + if config.plugin_trust.require_approval { + let actual_hash = + plugins::loader::calculate_sha256(std::path::Path::new(&target_plugin.path)) + .context("Failed to calculate plugin content hash")?; + if target_plugin.content_hash.as_ref() != Some(&actual_hash) { + anyhow::bail!( + "Execution blocked: plugin '{}' content hash mismatch or not approved.\n\ + Expected (approved): {:?}\n\ + Actual : {}", + target_plugin.name, + target_plugin.content_hash, + actual_hash + ); } } - pm.execute(plugin_name, plugin_args) - .map_err(|e| anyhow::anyhow!(e)) + let caps_str = if target_plugin.capabilities.is_empty() { + "none".to_string() + } else { + target_plugin + .capabilities + .iter() + .map(|c| c.name()) + .collect::>() + .join(",") + }; + + let status = std::process::Command::new(std::env::current_exe()?) + .arg("__run_plugin_library") + .arg(&target_plugin.path) + .arg(&target_plugin.name) + .arg(&caps_str) + .arg("--") + .args(plugin_args) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .context("Failed to run plugin subprocess")?; + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + Ok(()) } fn print_banner() { diff --git a/src/plugins/interface.rs b/src/plugins/interface.rs index 8be8e35..569cbd1 100644 --- a/src/plugins/interface.rs +++ b/src/plugins/interface.rs @@ -29,10 +29,40 @@ pub trait Plugin: Any + Send + Sync { fn execute(&self, args: &[String]) -> Result<(), String>; } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum Capability { + NetworkAccess, + FileSystem, + Config, +} + +impl Capability { + pub fn name(&self) -> &'static str { + match self { + Capability::NetworkAccess => "NetworkAccess", + Capability::FileSystem => "FileSystem", + Capability::Config => "Config", + } + } + + pub fn description(&self) -> &'static str { + match self { + Capability::NetworkAccess => "Allows the plugin to make outbound network requests", + Capability::FileSystem => { + "Allows the plugin to read/write files in the local filesystem" + } + Capability::Config => { + "Allows the plugin to access StarForge wallet credentials and config" + } + } + } +} + pub struct PluginDeclaration { pub rustc_version: &'static str, pub core_version: &'static str, pub register: unsafe fn(&mut dyn PluginRegistrar), + pub capabilities: &'static [Capability], } pub trait PluginRegistrar { @@ -41,7 +71,7 @@ pub trait PluginRegistrar { #[macro_export] macro_rules! export_plugin { - ($register:expr) => { + ($register:expr, $capabilities:expr) => { #[doc(hidden)] #[no_mangle] pub static PLUGIN_DECLARATION: $crate::plugins::PluginDeclaration = @@ -49,8 +79,12 @@ macro_rules! export_plugin { rustc_version: $crate::plugins::interface::RUSTC_VERSION, core_version: $crate::plugins::interface::CORE_VERSION, register: $register, + capabilities: $capabilities, }; }; + ($register:expr) => { + $crate::export_plugin!($register, &[]); + }; } pub const RUSTC_VERSION: &str = env!("RUSTC_VERSION"); diff --git a/src/plugins/loader.rs b/src/plugins/loader.rs index 63c6840..2430845 100644 --- a/src/plugins/loader.rs +++ b/src/plugins/loader.rs @@ -37,6 +37,19 @@ pub enum PluginLoadError { }, /// The `starforge-plugin.toml` manifest failed validation. ManifestIncompatible { path: String, detail: String }, + /// The plugin source is unknown/untrusted and loading is blocked under the hardened security policy. + UntrustedBlocked { path: String }, + /// The content hash does not match the approved hash in the registry. + HashMismatch { + path: String, + expected: String, + actual: String, + }, + /// The plugin requested capabilities that were not approved. + UnauthorizedCapabilities { + path: String, + missing: Vec, + }, } impl PluginLoadError { @@ -48,6 +61,9 @@ impl PluginLoadError { Self::AbiBuildMismatch { .. } => "abi_mismatch", Self::UnsupportedCoreVersion { .. } => "unsupported_core_version", Self::ManifestIncompatible { .. } => "manifest_incompatible", + Self::UntrustedBlocked { .. } => "untrusted_blocked", + Self::HashMismatch { .. } => "hash_mismatch", + Self::UnauthorizedCapabilities { .. } => "unauthorized_capabilities", } } @@ -85,6 +101,23 @@ impl PluginLoadError { Detail: {detail}\n \ Fix: Update 'starforge-plugin.toml' to match the running StarForge version.", ), + Self::UntrustedBlocked { path } => format!( + "Blocked untrusted plugin '{path}'.\n \ + Cause: The plugin source is unknown/untrusted and the security policy blocks it.\n \ + Fix: Re-install the plugin from a trusted source or with explicit local path approval.", + ), + Self::HashMismatch { path, expected, actual } => format!( + "Content hash mismatch for '{path}'.\n \ + Expected approved hash: {expected}\n \ + Actual library hash : {actual}\n \ + Fix: The library file has been modified. Re-approve or re-install the plugin.", + ), + Self::UnauthorizedCapabilities { path, missing } => format!( + "Unauthorized capabilities requested by '{path}'.\n \ + Missing approvals: {}\n \ + Fix: Re-install and explicitly approve these capabilities.", + missing.iter().map(|c| c.name()).collect::>().join(", ") + ), } } } @@ -137,6 +170,37 @@ impl PluginManager { let path_ref = path.as_ref(); let path_display = path_ref.to_string_lossy().to_string(); + // ── Trust & Hash Verification (if registry knows this plugin) ────────── + let reg = crate::plugins::registry::load_registry().unwrap_or_default(); + let plugin_meta = reg + .plugins + .iter() + .find(|p| Path::new(&p.path) == Path::new(path_ref)); + + let config = crate::utils::config::load().unwrap_or_default(); + + if let Some(meta) = plugin_meta { + // Check if Unknown source trust + if meta.trust == crate::plugins::registry::TrustLevel::Unknown { + return Err(PluginLoadError::UntrustedBlocked { path: path_display }); + } + + // Check require_approval and hash mismatch + if config.plugin_trust.require_approval { + if let Some(ref approved_hash) = meta.content_hash { + if let Ok(actual_hash) = calculate_sha256(Path::new(path_ref)) { + if actual_hash != *approved_hash { + return Err(PluginLoadError::HashMismatch { + path: path_display, + expected: approved_hash.clone(), + actual: actual_hash, + }); + } + } + } + } + } + // ── Open the shared library ────────────────────────────────────────── let library = Library::new(path_ref).map_err(|e| PluginLoadError::InvalidLibrary { path: path_display.clone(), @@ -241,6 +305,211 @@ impl PluginRegistrar for ProxyRegistrar { } } +pub fn calculate_sha256(path: &Path) -> Result { + use sha2::{Digest, Sha256}; + use std::io::Read; + + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0; 4096]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PluginMetadataDump { + pub name: String, + pub version: String, + pub description: String, + pub capabilities: Vec, + pub commands: Vec, +} + +pub fn dump_plugin_metadata_internal(library_path: &str) -> Result<()> { + let path = std::path::Path::new(library_path); + let library = unsafe { Library::new(path) } + .map_err(|e| anyhow::anyhow!("Failed to load library: {}", e))?; + + let decl: Symbol<*mut PluginDeclaration> = unsafe { library.get(b"PLUGIN_DECLARATION") } + .map_err(|e| anyhow::anyhow!("Failed to find PLUGIN_DECLARATION: {}", e))?; + + let decl = unsafe { &**decl }; + + let mut registrar = ProxyRegistrar::new(); + unsafe { + (decl.register)(&mut registrar); + } + + let mut commands = Vec::new(); + let mut name = String::new(); + let mut version = String::new(); + let mut description = String::new(); + + for plugin in registrar.plugins { + name = plugin.name().to_string(); + version = plugin.version().to_string(); + description = plugin.description().to_string(); + for cmd in plugin.commands() { + commands.push(crate::plugins::registry::RegisteredCommand { + name: cmd.name, + description: cmd.description, + }); + } + } + + let caps = decl.capabilities.to_vec(); + + let dump = PluginMetadataDump { + name, + version, + description, + capabilities: caps, + commands, + }; + + println!("{}", serde_json::to_string(&dump)?); + + Ok(()) +} + +pub fn run_plugin_library_internal(args: &[String]) -> Result<()> { + if args.len() < 3 { + anyhow::bail!("Invalid arguments for internal plugin execution"); + } + let library_path = &args[0]; + let plugin_name = &args[1]; + let approved_caps_str = &args[2]; + + let plugin_args = if args.len() > 4 && args[3] == "--" { + &args[4..] + } else if args.len() > 3 { + &args[3..] + } else { + &[] + }; + + let mut approved_caps = Vec::new(); + if approved_caps_str != "none" { + for cap_name in approved_caps_str.split(',') { + match cap_name { + "NetworkAccess" => approved_caps.push(crate::plugins::Capability::NetworkAccess), + "FileSystem" => approved_caps.push(crate::plugins::Capability::FileSystem), + "Config" => approved_caps.push(crate::plugins::Capability::Config), + _ => {} + } + } + } + + let path = std::path::Path::new(library_path); + let library = unsafe { Library::new(path) } + .map_err(|e| anyhow::anyhow!("Failed to load library: {}", e))?; + + let decl: Symbol<*mut PluginDeclaration> = unsafe { library.get(b"PLUGIN_DECLARATION") } + .map_err(|e| anyhow::anyhow!("Failed to find PLUGIN_DECLARATION: {}", e))?; + + let decl = unsafe { &**decl }; + + #[cfg(target_os = "linux")] + apply_sandbox_filters(&approved_caps)?; + + let mut registrar = ProxyRegistrar::new(); + unsafe { + (decl.register)(&mut registrar); + } + + for plugin in registrar.plugins { + if plugin.name() == plugin_name { + plugin.on_load(); + let result = plugin.execute(plugin_args); + plugin.on_unload(); + match result { + Ok(()) => return Ok(()), + Err(e) => anyhow::bail!("{}", e), + } + } + } + + anyhow::bail!("Plugin '{}' not found in library", plugin_name); +} + +#[cfg(target_os = "linux")] +fn apply_sandbox_filters(approved_caps: &[crate::plugins::Capability]) -> Result<()> { + use seccompiler::{BpfProgram, SeccompAction, SeccompFilter}; + use std::collections::BTreeMap; + use std::convert::TryInto; + + let has_network = approved_caps.contains(&crate::plugins::Capability::NetworkAccess); + let has_filesystem = approved_caps.contains(&crate::plugins::Capability::FileSystem) + || approved_caps.contains(&crate::plugins::Capability::Config); + + let mut rules = BTreeMap::new(); + + if !has_network { + let network_syscalls = [ + libc::SYS_socket, + libc::SYS_connect, + libc::SYS_accept, + libc::SYS_accept4, + libc::SYS_bind, + libc::SYS_listen, + libc::SYS_sendto, + libc::SYS_recvfrom, + libc::SYS_sendmsg, + libc::SYS_recvmsg, + ]; + for &sys in &network_syscalls { + rules.insert(sys, vec![]); + } + } + + if !has_filesystem { + let fs_syscalls = [ + libc::SYS_open, + libc::SYS_openat, + libc::SYS_creat, + libc::SYS_mkdir, + libc::SYS_mkdirat, + libc::SYS_rmdir, + libc::SYS_unlink, + libc::SYS_unlinkat, + libc::SYS_rename, + libc::SYS_renameat, + ]; + for &sys in &fs_syscalls { + rules.insert(sys, vec![]); + } + } + + if !rules.is_empty() { + let arch = std::env::consts::ARCH + .try_into() + .map_err(|_| anyhow::anyhow!("Unsupported architecture for seccomp"))?; + + let filter = SeccompFilter::new( + rules, + SeccompAction::Allow, + SeccompAction::Errno(libc::EACCES as u32), + arch, + ) + .map_err(|e| anyhow::anyhow!("Failed to build seccomp filter: {:?}", e))?; + + let bpf_program: BpfProgram = filter + .try_into() + .map_err(|e| anyhow::anyhow!("Failed to compile BPF: {:?}", e))?; + + seccompiler::apply_filter(&bpf_program) + .map_err(|e| anyhow::anyhow!("Failed to apply seccomp filter: {:?}", e))?; + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 761210a..becb449 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -3,5 +3,5 @@ pub mod loader; pub mod manifest; pub mod registry; -pub use interface::{Plugin, PluginDeclaration, PluginRegistrar}; +pub use interface::{Capability, Plugin, PluginDeclaration, PluginRegistrar}; pub use loader::{PluginLoadError, PluginManager}; diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index 1681689..5efd308 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -187,6 +187,8 @@ pub struct RegisteredCommand { pub description: String, } +use crate::plugins::Capability; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InstalledPlugin { pub name: String, @@ -210,6 +212,12 @@ pub struct InstalledPlugin { /// Commands registered by the plugin at install time. #[serde(default)] pub commands: Vec, + /// Capabilities requested by the plugin and approved by the user. + #[serde(default)] + pub capabilities: Vec, + /// SHA-256 hash of the plugin library file when approved. + #[serde(default)] + pub content_hash: Option, } fn registry_path() -> Result { @@ -282,6 +290,7 @@ pub fn is_managed_plugin_path(path: &Path) -> bool { /// `source` is the URL or identifier where the plugin came from; pass an /// empty string when the user supplied `--path` directly. /// `commands` is the list of commands the plugin advertises (from `Plugin::commands()`). +#[allow(clippy::too_many_arguments)] pub fn install_plugin( name: &str, library_path: &Path, @@ -289,6 +298,8 @@ pub fn install_plugin( starforge_version: &str, plugin_version: &str, commands: Vec, + capabilities: Vec, + content_hash: Option, ) -> Result<()> { if !library_path.exists() { anyhow::bail!("Plugin library not found: {}", library_path.display()); @@ -308,6 +319,8 @@ pub fn install_plugin( plugin_version: plugin_version.to_string(), installed_at: Some(now), commands, + capabilities, + content_hash, }); reg.plugins.sort_by(|a, b| a.name.cmp(&b.name)); save_registry(®)?; @@ -527,7 +540,7 @@ mod tests { fn install_missing_library_fails() { let tmp = TempDir::new().unwrap(); let missing = tmp.path().join("nonexistent.so"); - let result = install_plugin("test", &missing, "", "0.1.0", "1.0.0", vec![]); + let result = install_plugin("test", &missing, "", "0.1.0", "1.0.0", vec![], vec![], None); assert!(result.is_err(), "installing a missing library must fail"); assert!(result.unwrap_err().to_string().contains("not found")); } diff --git a/src/utils/config/mod.rs b/src/utils/config/mod.rs index 316ab0c..9756f4a 100644 --- a/src/utils/config/mod.rs +++ b/src/utils/config/mod.rs @@ -230,16 +230,23 @@ pub struct PluginTrustConfig { /// (`plugins.example.com`) or URL prefixes (`https://plugins.example.com/releases/`). #[serde(default = "default_trusted_plugin_sources")] pub trusted_sources: Vec, + #[serde(default = "default_require_approval")] + pub require_approval: bool, } impl Default for PluginTrustConfig { fn default() -> Self { Self { trusted_sources: default_trusted_plugin_sources(), + require_approval: default_require_approval(), } } } +pub fn default_require_approval() -> bool { + true +} + pub fn default_trusted_plugin_sources() -> Vec { vec![ "https://github.com/Nanle-code/starforge-*".to_string(), diff --git a/src/utils/multisig.rs b/src/utils/multisig.rs index e360d10..0d2e8c7 100644 --- a/src/utils/multisig.rs +++ b/src/utils/multisig.rs @@ -6,15 +6,12 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; -use stellar_strkey::ed25519::{ - PrivateKey as StellarPrivateKey, PublicKey as StellarPublicKey, -}; +use stellar_strkey::ed25519::{PrivateKey as StellarPrivateKey, PublicKey as StellarPublicKey}; use stellar_xdr::curr::{ BytesM, DecoratedSignature, Hash, Limits, Memo, MuxedAccount, Operation, OperationBody, - Preconditions, ReadXdr, SequenceNumber, SetOptionsOp, Signature as XdrSignature, - SignatureHint, Signer as XdrSigner, SignerKey, Transaction, TransactionEnvelope, - TransactionExt, TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, - WriteXdr, + Preconditions, ReadXdr, SequenceNumber, SetOptionsOp, Signature as XdrSignature, SignatureHint, + Signer as XdrSigner, SignerKey, Transaction, TransactionEnvelope, TransactionExt, + TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, WriteXdr, }; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -378,7 +375,11 @@ pub fn sign_transaction_envelope( }; append_decorated_signature(&mut envelope, decorated)?; - Ok((encode_transaction_envelope(&envelope)?, signer_public_key, true)) + Ok(( + encode_transaction_envelope(&envelope)?, + signer_public_key, + true, + )) } fn set_options_operation(set_options: SetOptionsOp) -> Operation { @@ -447,14 +448,8 @@ fn transaction_signature_hash(envelope: &TransactionEnvelope, network: &str) -> fn envelope_has_signature_hint(envelope: &TransactionEnvelope, hint: &SignatureHint) -> bool { match envelope { - TransactionEnvelope::TxV0(tx_v0) => tx_v0 - .signatures - .iter() - .any(|sig| sig.hint.0 == hint.0), - TransactionEnvelope::Tx(tx_v1) => tx_v1 - .signatures - .iter() - .any(|sig| sig.hint.0 == hint.0), + TransactionEnvelope::TxV0(tx_v0) => tx_v0.signatures.iter().any(|sig| sig.hint.0 == hint.0), + TransactionEnvelope::Tx(tx_v1) => tx_v1.signatures.iter().any(|sig| sig.hint.0 == hint.0), TransactionEnvelope::TxFeeBump(fee_bump) => { fee_bump.signatures.iter().any(|sig| sig.hint.0 == hint.0) } diff --git a/src/utils/soroban.rs b/src/utils/soroban.rs index 661be61..15e5bf2 100644 --- a/src/utils/soroban.rs +++ b/src/utils/soroban.rs @@ -37,7 +37,7 @@ pub struct UpgradeSimulationResult { } /// A single authorization requirement surfaced by an upgrade simulation. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct AuthEntry { pub address: String, pub function: String, diff --git a/src/utils/telemetry.rs b/src/utils/telemetry.rs index 0bbc356..ae26b7a 100644 --- a/src/utils/telemetry.rs +++ b/src/utils/telemetry.rs @@ -196,8 +196,7 @@ fn append_event(event: &TelemetryEvent) -> Result<()> { // Check limits before writing. let needs_prune = path.exists() - && (fs::metadata(&path)?.len() >= MAX_BYTES - || line_count(&path)? >= MAX_ENTRIES); + && (fs::metadata(&path)?.len() >= MAX_BYTES || line_count(&path)? >= MAX_ENTRIES); if needs_prune { prune_oldest(&path, MAX_ENTRIES / 2)?; @@ -257,7 +256,8 @@ mod tests { #[test] fn event_serialises_to_expected_fields() { let ev = make_event("wallet"); - let json: serde_json::Value = serde_json::from_str(&serde_json::to_string(&ev).unwrap()).unwrap(); + let json: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&ev).unwrap()).unwrap(); assert_eq!(json["schema_version"], TELEMETRY_SCHEMA_VERSION); assert_eq!(json["command"], "wallet"); assert!(json.get("timestamp").is_some()); @@ -272,7 +272,11 @@ mod tests { let path = dir.path().join("test.log"); for i in 0..10usize { - let mut f = fs::OpenOptions::new().create(true).append(true).open(&path).unwrap(); + let mut f = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .unwrap(); writeln!(f, "line{}", i).unwrap(); }