use std::io::{IsTerminal, Write}; use std::path::PathBuf; use anyhow::{Context, Result}; use chrono::Utc; use clap::{Parser, Subcommand}; use colored::Colorize; use wasmbox_compliance::{PolicyDecision, RunLog, RunLogEntry}; use wasmbox_shared::{Capability, WasmBoxConfig, WasmBoxPaths}; // Exit codes per CLAUDE.md const EXIT_ERROR: i32 = 1; const EXIT_PERMISSION_DENIED: i32 = 2; const EXIT_VERIFICATION_FAILED: i32 = 3; #[derive(Parser)] #[command(name = "wasmbox", about = "Sandboxed Wasm tool launcher", version)] struct Cli { #[command(subcommand)] command: Commands, /// Output in JSON format #[arg(long, global = true)] json: bool, /// Override WasmBox home directory #[arg(long, global = true)] home: Option, } #[derive(Subcommand)] enum Commands { /// Run a tool (installed or local file) Run { /// Tool name (optionally with @version) name: Option, /// Run a local .wasm file directly #[arg(long)] file: Option, /// Manifest for local .wasm (declares capabilities) #[arg(long)] manifest: Option, /// Force zero capabilities (full sandbox) #[arg(long)] sandbox: bool, /// Allow specific capabilities (comma-separated: stdin,stdout,clipboard) #[arg(long, value_delimiter = ',')] allow: Vec, /// Allow all capabilities requested in manifest #[arg(long)] allow_all: bool, /// Arguments to pass to the tool (after --) #[arg(last = true)] tool_args: Vec, }, /// Install a tool from a registry Install { /// Tool name name: String, /// Registry URL to install from #[arg(long)] registry: Option, /// Pre-approve specific capabilities #[arg(long, value_delimiter = ',')] allow: Vec, /// Pre-approve all manifest capabilities #[arg(long)] allow_all: bool, }, /// List installed tools List, /// Search registries for tools Search { /// Search query query: String, }, /// Show tool info and manifest Info { /// Tool name name: String, }, /// Verify tool hash against manifest Verify { /// Tool name name: String, }, /// Manage permissions for a tool Permissions { /// Tool name name: String, #[command(subcommand)] action: Option, }, /// Manage registries Registry { #[command(subcommand)] action: RegistryAction, }, /// Audit all granted permissions across all tools Audit { /// Export compliance report #[arg(long)] export: bool, /// Output format for export (json, md, csv) #[arg(long, default_value = "json")] format: String, /// Sign the exported report with Ed25519 #[arg(long)] sign: bool, /// Generate Ed25519 signing keypair #[arg(long)] init_key: bool, /// Verify a signed audit report #[arg(long)] verify: Option, /// Start of audit period (ISO 8601 date) #[arg(long)] from: Option, /// End of audit period (ISO 8601 date) #[arg(long)] to: Option, /// Scope the export to a single session ID #[arg(long)] session: Option, }, /// View execution run log Log { /// Show last N entries #[arg(long)] last: Option, /// Filter by tool name #[arg(long)] tool: Option, /// Show only blocked executions #[arg(long)] blocked: bool, /// Filter entries after this date (YYYY-MM-DD) #[arg(long)] after: Option, /// Filter entries before this date (YYYY-MM-DD) #[arg(long)] before: Option, /// Export log to file #[arg(long)] export: Option, /// Rotate log (archive current, start fresh) #[arg(long)] rotate: bool, /// Show log statistics #[arg(long)] stats: bool, /// Filter by session ID #[arg(long)] session: Option, /// Filter by execution mode (wasmbox, via, blocked, native) #[arg(long)] mode: Option, /// Filter by binary hash #[arg(long)] binary_hash: Option, }, /// Manage compliance policy Policy { #[command(subcommand)] action: PolicyAction, }, /// Remove an installed tool Remove { /// Tool name name: String, }, /// Update a tool to the latest version Update { /// Tool name name: String, }, /// Revoke a specific capability for a tool Revoke { /// Tool name name: String, /// Capability to revoke (stdout, stdin, network, filesystem, clipboard, env) capability: String, }, /// Compute SHA-256 hash of a .wasm file Hash { /// Path to .wasm file file: String, }, /// Proxy a single command: log it, then exec the real binary Via { /// Pipe and count stdin/stdout/stderr bytes (loses interactive TTY behaviour) #[arg(long)] capture_streams: bool, /// Command name to proxy cmd: String, /// Arguments to pass to the command #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, /// Manage PATH shims that route commands through `wasmbox via` Shell { #[command(subcommand)] action: ShellAction, }, /// Launch an agent inside the recorded environment Wrap { /// Agent command to launch agent: String, /// Start a fresh session even if WASMBOX_SESSION_ID is set #[arg(long)] new_session: bool, /// Arguments to pass to the agent #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, /// Launch Claude Code in the recorded environment (alias for `wrap claude`) Claude { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, /// Launch OpenCode in the recorded environment (alias for `wrap opencode`) Opencode { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, /// Launch Aider in the recorded environment (alias for `wrap aider`) Aider { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, } #[derive(Subcommand)] enum PermissionAction { /// Show granted permissions Show, /// Revoke all permissions Revoke, } #[derive(Subcommand)] enum RegistryAction { /// Add a registry Add { /// Registry URL url: String, }, /// List configured registries List, /// Remove a registry Remove { /// Registry URL url: String, }, } #[derive(Subcommand)] enum ShellAction { /// Generate PATH shims for commands Init { /// Shim every executable in standard bin directories #[arg(long)] all: bool, /// Extra commands to shim (comma-separated) #[arg(long, value_delimiter = ',')] include: Vec, /// Commands to skip (comma-separated) #[arg(long, value_delimiter = ',')] exclude: Vec, /// Regenerate all shims #[arg(long)] rebuild: bool, /// Append the PATH export line to the user's shell rc file #[arg(long)] auto_add_to_shell: bool, }, /// Show shim directory status Status, /// Regenerate all shims (alias for `init --rebuild`) Rebuild, } #[derive(Subcommand)] enum PolicyAction { /// Initialize a new policy from currently installed tools Init { /// Policy name #[arg(long)] name: Option, /// Approver email #[arg(long)] approved_by: Option, /// Enforcement mode (enforce, warn, disabled) #[arg(long, default_value = "warn")] enforcement: String, }, /// Show current policy Show, /// Check all installed tools against policy Check, /// Add a tool to the policy Add { /// Tool name name: String, }, /// Remove a tool from the policy Remove { /// Tool name name: String, }, /// Set enforcement to block unapproved tools Enforce, /// Set enforcement to warn but allow Warn, /// Disable policy enforcement Disable, /// Show changes since last policy approval Diff, /// Export policy to stdout Export, } #[tokio::main] async fn main() { if std::env::var_os("NO_COLOR").is_some() { colored::control::set_override(false); } let cli = Cli::parse(); let paths = WasmBoxPaths::resolve(cli.home.as_deref()); let result = match cli.command { Commands::Run { name, file, manifest, sandbox, allow, allow_all, tool_args, } => cmd_run( &paths, name, file, manifest, sandbox, &allow, allow_all, tool_args, ), Commands::Install { ref name, ref registry, ref allow, allow_all, } => cmd_install(&paths, name, registry.as_deref(), allow, allow_all).await, Commands::List => cmd_list(&paths, cli.json), Commands::Search { ref query } => cmd_search(&paths, query, cli.json).await, Commands::Info { ref name } => cmd_info(&paths, name, cli.json), Commands::Verify { ref name } => cmd_verify(&paths, name), Commands::Permissions { ref name, ref action, } => cmd_permissions(&paths, name, action, cli.json), Commands::Registry { ref action } => cmd_registry(&paths, action), Commands::Audit { export, ref format, sign, init_key, ref verify, ref from, ref to, ref session, } => cmd_audit( &paths, cli.json, export, format, sign, init_key, verify.as_deref(), from.as_deref(), to.as_deref(), session.as_deref(), ), Commands::Log { last, ref tool, blocked, ref after, ref before, ref export, rotate, stats, ref session, ref mode, ref binary_hash, } => cmd_log( &paths, cli.json, last, tool.as_deref(), blocked, after.as_deref(), before.as_deref(), export.as_deref(), rotate, stats, session.as_deref(), mode.as_deref(), binary_hash.as_deref(), ), Commands::Policy { ref action } => cmd_policy(&paths, action, cli.json), Commands::Remove { ref name } => cmd_remove(&paths, name), Commands::Update { ref name } => cmd_update(&paths, name).await, Commands::Revoke { ref name, ref capability, } => cmd_revoke(&paths, name, capability), Commands::Hash { ref file } => cmd_hash(file), Commands::Via { capture_streams, ref cmd, ref args, } => cmd_via(&paths, cmd, args, capture_streams), Commands::Shell { ref action } => cmd_shell(&paths, action, cli.json), Commands::Wrap { ref agent, new_session, ref args, } => cmd_wrap(&paths, agent, new_session, args), Commands::Claude { ref args } => cmd_wrap(&paths, "claude", false, args), Commands::Opencode { ref args } => cmd_wrap(&paths, "opencode", false, args), Commands::Aider { ref args } => cmd_wrap(&paths, "aider", false, args), }; if let Err(e) = result { let msg = format!("{e:#}"); if msg.contains("tool exited with non-zero status") { std::process::exit(1); } else if msg.contains("permission denied") || msg.contains("Permission denied") { eprintln!("{}: {e:#}", "error".red().bold()); std::process::exit(EXIT_PERMISSION_DENIED); } else if msg.contains("verification failed") || msg.contains("hash mismatch") { eprintln!("{}: {e:#}", "error".red().bold()); std::process::exit(EXIT_VERIFICATION_FAILED); } else { eprintln!("{}: {e:#}", "error".red().bold()); std::process::exit(EXIT_ERROR); } } } // --- Run --- #[allow(clippy::too_many_arguments)] fn cmd_run( paths: &WasmBoxPaths, name: Option, file: Option, manifest_path: Option, sandbox: bool, allow: &[String], allow_all: bool, tool_args: Vec, ) -> Result<()> { let (wasm_path, manifest, tool_name) = resolve_run_target(paths, name, file, manifest_path)?; let capabilities = resolve_capabilities(paths, &tool_name, &manifest, sandbox, allow, allow_all)?; let version = manifest .as_ref() .map(|m| m.tool.version.to_string()) .unwrap_or_else(|| "unknown".to_string()); let cap_strings: Vec = capabilities.iter().map(format_capability).collect(); // Verify hash if we have a manifest let (hash, hash_verified) = if let Some(ref m) = manifest { let wasm_bytes = std::fs::read(&wasm_path) .with_context(|| format!("reading {}", wasm_path.display()))?; match wasmbox_verify::verify_hash(&wasm_bytes, &m.binary.hash) { Ok(()) => { eprintln!( "{} {} [{}]", "verified".green().bold(), tool_name, &m.binary.hash[..20], ); (m.binary.hash.clone(), true) } Err(e) => { // Log hash failure and bail let run_log = RunLog::new(paths.home()); let entry = RunLogEntry { ts: Utc::now(), tool: tool_name.clone(), version: version.clone(), hash: m.binary.hash.clone(), hash_verified: false, capabilities: cap_strings, exit_code: None, duration_ms: None, policy: "approved".to_string(), policy_reason: None, session_id: std::env::var("WASMBOX_SESSION_ID").ok(), mode: "wasmbox".to_string(), path: None, binary_hash: None, args: None, killed: None, stdin_bytes: None, stdout_bytes: None, stderr_bytes: None, }; let _ = run_log.append(&entry); anyhow::bail!("verification failed for {}: {e}", tool_name); } } } else { ("unknown".to_string(), true) }; // Check policy before execution let policy_store = wasmbox_compliance::PolicyStore::load(paths.home()) .map_err(|e| anyhow::anyhow!("policy error: {e}"))?; let policy_decision = policy_store.check_tool(&tool_name, &hash, &cap_strings); match &policy_decision { PolicyDecision::Blocked(reason) => { let run_log = RunLog::new(paths.home()); let entry = RunLogEntry { ts: Utc::now(), tool: tool_name.clone(), version: version.clone(), hash: hash.clone(), hash_verified, capabilities: cap_strings, exit_code: None, duration_ms: None, policy: "blocked".to_string(), policy_reason: Some(reason.clone()), session_id: std::env::var("WASMBOX_SESSION_ID").ok(), mode: "wasmbox".to_string(), path: None, binary_hash: None, args: None, killed: None, stdin_bytes: None, stdout_bytes: None, stderr_bytes: None, }; let _ = run_log.append(&entry); if let Some(p) = policy_store.policy() { eprintln!( "{} by policy: \"{}\" {}", "BLOCKED".red().bold(), tool_name, reason, ); eprintln!("Policy: {} v{}", p.policy.name, p.policy.version); eprintln!("Approved by: {}", p.policy.approved_by); eprintln!("Enforcement: {:?}", p.policy.enforcement); eprintln!("\nTo add this tool to the policy, run:"); eprintln!(" wasmbox policy add {tool_name}"); } anyhow::bail!("permission denied: blocked by policy"); } PolicyDecision::Warned(reason) => { if let Some(p) = policy_store.policy() { eprintln!( "{}: \"{}\" {}", "WARNING".yellow().bold(), tool_name, reason, ); eprintln!( "Policy: {} v{} (enforcement: {:?})", p.policy.name, p.policy.version, p.policy.enforcement, ); } } PolicyDecision::Approved | PolicyDecision::NoPolicy => {} } eprintln!( "{} {} with {} capabilities", "running".cyan().bold(), tool_name, capabilities.len(), ); // Log before execution let run_log = RunLog::new(paths.home()); let start = std::time::Instant::now(); let pre_entry = RunLogEntry { ts: Utc::now(), tool: tool_name.clone(), version: version.clone(), hash: hash.clone(), hash_verified, capabilities: cap_strings.clone(), exit_code: None, duration_ms: None, policy: policy_decision.status_str().to_string(), policy_reason: policy_decision.reason().map(String::from), session_id: std::env::var("WASMBOX_SESSION_ID").ok(), mode: "wasmbox".to_string(), path: None, binary_hash: None, args: None, killed: None, stdin_bytes: None, stdout_bytes: None, stderr_bytes: None, }; let _ = run_log.append(&pre_entry); let config = wasmbox_runtime::RunConfig { wasm_path, capabilities, fuel_limit: None, args: tool_args, }; // Run wasm on a dedicated thread to avoid tokio runtime conflict // (wasmtime-wasi sync creates its own runtime internally) let result: Result<()> = std::thread::scope(|s| { s.spawn(|| wasmbox_runtime::run_tool(&config).map_err(|e| anyhow::anyhow!("{e}"))) .join() .expect("wasm thread panicked") }); // Log after execution with actual exit code and duration let duration = start.elapsed(); let exit_code = match &result { Ok(()) => Some(0), Err(_) => Some(1), }; let post_entry = RunLogEntry { ts: Utc::now(), tool: tool_name, version, hash, hash_verified, capabilities: cap_strings, exit_code, duration_ms: Some(duration.as_millis() as u64), policy: policy_decision.status_str().to_string(), policy_reason: policy_decision.reason().map(String::from), session_id: std::env::var("WASMBOX_SESSION_ID").ok(), mode: "wasmbox".to_string(), path: None, binary_hash: None, args: None, killed: None, stdin_bytes: None, stdout_bytes: None, stderr_bytes: None, }; let _ = run_log.append(&post_entry); result } fn resolve_run_target( paths: &WasmBoxPaths, name: Option, file: Option, manifest_path: Option, ) -> Result<(PathBuf, Option, String)> { if let Some(file) = file { // Local file execution let wasm_path = PathBuf::from(&file); if !wasm_path.exists() { anyhow::bail!("file not found: {file}"); } let manifest = manifest_path .map(|p| { wasmbox_manifest::Manifest::from_file(std::path::Path::new(&p)) .map_err(|e| anyhow::anyhow!("manifest error: {e}")) }) .transpose()?; let tool_name = manifest .as_ref() .map(|m| m.tool.name.clone()) .unwrap_or_else(|| { wasm_path .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "unknown".to_string()) }); Ok((wasm_path, manifest, tool_name)) } else if let Some(name) = name { // Installed tool let (tool_name, version) = parse_tool_spec(&name); let version = find_installed_version(paths, &tool_name, version.as_deref())?; let manifest_file = paths.tool_manifest_path(&tool_name, &version); let manifest = wasmbox_manifest::Manifest::from_file(&manifest_file) .map_err(|e| anyhow::anyhow!("manifest error for {tool_name}@{version}: {e}"))?; let wasm_path = paths.tool_wasm_path(&tool_name, &version, &manifest.binary.wasm); Ok((wasm_path, Some(manifest), tool_name)) } else { anyhow::bail!("provide a tool name or --file "); } } fn resolve_capabilities( paths: &WasmBoxPaths, tool_name: &str, manifest: &Option, sandbox: bool, allow: &[String], allow_all: bool, ) -> Result> { if sandbox { return Ok(Vec::new()); } // Explicit --allow flags take priority if !allow.is_empty() { let caps = parse_allow_flags(allow)?; persist_if_installed(paths, tool_name, manifest, &caps)?; return Ok(caps); } // --allow-all grants everything in manifest, or stdin+stdout if no manifest if allow_all { let caps = manifest .as_ref() .map(|m| m.requested_capabilities()) .unwrap_or_else(|| vec![Capability::Stdin, Capability::Stdout]); persist_if_installed(paths, tool_name, manifest, &caps)?; return Ok(caps); } // Check stored permissions let perm_path = paths.permissions_file(); if perm_path.exists() { let store = wasmbox_permissions::PermissionStore::load(&perm_path) .map_err(|e| anyhow::anyhow!("{e}"))?; if store.get(tool_name).is_some() { return Ok(store.to_capabilities(tool_name)); } } // No stored permissions — prompt or fail let requested = manifest .as_ref() .map(|m| m.requested_capabilities()) .unwrap_or_default(); if requested.is_empty() { return Ok(Vec::new()); } // If not a TTY, fail with permission denied if !std::io::stdin().is_terminal() { anyhow::bail!( "permission denied: {} requests capabilities but no \ TTY for prompting. Use --allow or --allow-all", tool_name, ); } prompt_capabilities(paths, tool_name, manifest, &requested) } /// Persist granted capabilities for installed tools so future runs don't re-prompt. fn persist_if_installed( paths: &WasmBoxPaths, tool_name: &str, manifest: &Option, caps: &[Capability], ) -> Result<()> { // Only persist for installed tools (those with a manifest on disk) let Some(m) = manifest else { return Ok(()) }; let manifest_path = paths.tool_manifest_path(tool_name, &m.tool.version.to_string()); if !manifest_path.exists() { return Ok(()); } let perm_path = paths.permissions_file(); let mut store = wasmbox_permissions::PermissionStore::load(&perm_path) .map_err(|e| anyhow::anyhow!("{e}"))?; store .grant(tool_name, &m.tool.version.to_string(), caps) .map_err(|e| anyhow::anyhow!("{e}"))?; Ok(()) } fn prompt_capabilities( paths: &WasmBoxPaths, tool_name: &str, manifest: &Option, requested: &[Capability], ) -> Result> { let version = manifest .as_ref() .map(|m| m.tool.version.to_string()) .unwrap_or_else(|| "unknown".to_string()); eprintln!(); if let Some(m) = manifest { eprintln!( " {} v{} - {}", m.tool.name.bold(), m.tool.version, m.tool.description, ); eprintln!(" Author: {} | License: {}", m.tool.author, m.tool.license,); eprintln!(" Hash: {} [{}]", &m.binary.hash[..20], "VERIFIED".green()); } eprintln!(); eprintln!(" {}:", "Requested capabilities".bold()); for cap in requested { match cap { Capability::Stdin => eprintln!(" stdin: {}", "yes".yellow()), Capability::Stdout => eprintln!(" stdout: {}", "yes".yellow()), Capability::Clipboard => eprintln!(" clipboard: {}", "yes".yellow()), Capability::Network(hosts) => { eprintln!(" network: {}", hosts.join(", ").yellow()); } Capability::Filesystem(g) => { let mode = match (g.read, g.write) { (true, true) => "read+write", (true, false) => "read", (false, true) => "write", (false, false) => "none", }; eprintln!(" filesystem: {} ({})", g.path.yellow(), mode); } Capability::Env(vars) => { eprintln!(" env: {}", vars.join(", ").yellow()); } } } eprintln!(); eprint!(" Allow? [Y/n] "); std::io::stderr().flush()?; let mut input = String::new(); std::io::stdin().read_line(&mut input)?; let input = input.trim().to_lowercase(); if input.is_empty() || input == "y" || input == "yes" { // Store the approval paths.ensure_dirs()?; let perm_path = paths.permissions_file(); let mut store = wasmbox_permissions::PermissionStore::load(&perm_path) .map_err(|e| anyhow::anyhow!("{e}"))?; store .grant(tool_name, &version, requested) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!(" {}", "Approved.".green()); Ok(requested.to_vec()) } else { anyhow::bail!("permission denied: user declined capabilities for {tool_name}"); } } fn parse_allow_flags(allow: &[String]) -> Result> { let mut caps = Vec::new(); for item in allow { match item.as_str() { "stdin" => caps.push(Capability::Stdin), "stdout" => caps.push(Capability::Stdout), "clipboard" => caps.push(Capability::Clipboard), other => anyhow::bail!("unknown capability: {other}. Valid: stdin, stdout, clipboard"), } } Ok(caps) } fn parse_tool_spec(spec: &str) -> (String, Option) { if let Some((name, version)) = spec.rsplit_once('@') { (name.to_string(), Some(version.to_string())) } else { (spec.to_string(), None) } } fn find_installed_version( paths: &WasmBoxPaths, tool_name: &str, requested_version: Option<&str>, ) -> Result { let tool_cache = paths.cache_dir().join(tool_name); if !tool_cache.exists() { anyhow::bail!("tool not found: {tool_name}. Run: wasmbox install {tool_name}"); } if let Some(version) = requested_version { let version_dir = tool_cache.join(version); if version_dir.exists() { return Ok(version.to_string()); } anyhow::bail!("version {version} not found for {tool_name}"); } // Find the latest version (highest semver) let mut versions: Vec = Vec::new(); for entry in std::fs::read_dir(&tool_cache)? { let entry = entry?; if entry.file_type()?.is_dir() && let Ok(v) = semver::Version::parse(&entry.file_name().to_string_lossy()) { versions.push(v); } } versions.sort(); versions .last() .map(|v| v.to_string()) .ok_or_else(|| anyhow::anyhow!("no installed versions for {tool_name}")) } // --- Install --- async fn cmd_install( paths: &WasmBoxPaths, name: &str, registry_url: Option<&str>, allow: &[String], allow_all: bool, ) -> Result<()> { paths.ensure_dirs()?; let registries = get_registries(paths, registry_url)?; let client = wasmbox_registry::RegistryClient::new(registries.clone()); // Find the tool in registries eprintln!("{} {} in registries...", "searching".cyan().bold(), name); let mut found_registry = None; let mut found_entry = None; for reg in ®istries { match client.fetch_index(reg).await { Ok(index) => { if let Some(entry) = index.tools.iter().find(|t| t.name == name) { found_registry = Some(reg.clone()); found_entry = Some(entry.clone()); break; } } Err(e) => { eprintln!( "{}: failed to fetch {}: {e}", "warning".yellow().bold(), reg.url, ); } } } let registry = found_registry.ok_or_else(|| anyhow::anyhow!("tool not found in any registry: {name}"))?; let entry = found_entry.unwrap(); eprintln!( "{} {} v{} ({})", "found".green().bold(), entry.name, entry.version, human_size(entry.size), ); // Download the binary eprintln!("{} binary...", "downloading".cyan().bold()); let wasm_bytes = client .fetch_binary(®istry, name) .await .map_err(|e| anyhow::anyhow!("{e}"))?; // Verify hash wasmbox_verify::verify_hash(&wasm_bytes, &entry.hash) .map_err(|e| anyhow::anyhow!("verification failed: {e}"))?; eprintln!("{} hash verified", "ok".green().bold()); // Fetch manifest let manifest_json = client .fetch_manifest(®istry, name) .await .map_err(|e| anyhow::anyhow!("{e}"))?; // Parse manifest before writing anything let manifest = wasmbox_manifest::Manifest::parse(&manifest_json) .map_err(|e| anyhow::anyhow!("manifest error: {e}"))?; // Atomic install: write to a temp dir, then rename into place. // This prevents partial installs from corrupting the cache. let cache_dir = paths.cache_dir(); std::fs::create_dir_all(&cache_dir)?; let staging_dir = tempfile::tempdir_in(&cache_dir)?; std::fs::write(staging_dir.path().join("wasmbox.toml"), &manifest_json)?; std::fs::write(staging_dir.path().join(&manifest.binary.wasm), &wasm_bytes)?; // Move into final location let tool_dir = paths.tool_dir(name, &entry.version); if tool_dir.exists() { std::fs::remove_dir_all(&tool_dir)?; } std::fs::create_dir_all(tool_dir.parent().unwrap())?; std::fs::rename(staging_dir.path(), &tool_dir)?; // Prevent tempdir cleanup from erroring on the now-moved dir let _ = staging_dir.keep(); eprintln!( "{} {} v{} to {}", "installed".green().bold(), name, entry.version, tool_dir.display(), ); // Pre-approve capabilities if requested if allow_all || !allow.is_empty() { let capabilities = if allow_all { manifest.requested_capabilities() } else { parse_allow_flags(allow)? }; if !capabilities.is_empty() { let perm_path = paths.permissions_file(); let mut store = wasmbox_permissions::PermissionStore::load(&perm_path) .map_err(|e| anyhow::anyhow!("{e}"))?; store .grant(name, &entry.version, &capabilities) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!( "{} {} capabilities pre-approved", "ok".green().bold(), capabilities.len(), ); } } Ok(()) } // --- Search --- async fn cmd_search(paths: &WasmBoxPaths, query: &str, json: bool) -> Result<()> { let registries = get_registries(paths, None)?; let client = wasmbox_registry::RegistryClient::new(registries); let results = client .search(query) .await .map_err(|e| anyhow::anyhow!("{e}"))?; if json { println!("{}", serde_json::to_string_pretty(&results)?); return Ok(()); } if results.is_empty() { eprintln!("no results for '{query}'"); return Ok(()); } for tool in &results { eprintln!( " {} v{} - {}", tool.name.bold(), tool.version, tool.description, ); if !tool.categories.is_empty() { eprintln!(" categories: {}", tool.categories.join(", ")); } } eprintln!("\n{} result(s)", results.len()); Ok(()) } // --- Info --- fn cmd_info(paths: &WasmBoxPaths, name: &str, json: bool) -> Result<()> { let (tool_name, version) = parse_tool_spec(name); let version = find_installed_version(paths, &tool_name, version.as_deref())?; let manifest_path = paths.tool_manifest_path(&tool_name, &version); let manifest = wasmbox_manifest::Manifest::from_file(&manifest_path) .map_err(|e| anyhow::anyhow!("{e}"))?; if json { let mut value = serde_json::to_value(manifest.to_metadata())?; if let (Some(obj), Some(agent)) = (value.as_object_mut(), manifest.agent.as_ref()) { obj.insert("agent".to_string(), serde_json::to_value(agent)?); } println!("{}", serde_json::to_string_pretty(&value)?); return Ok(()); } eprintln!(" {} v{}", manifest.tool.name.bold(), manifest.tool.version); eprintln!(" {}", manifest.tool.description); eprintln!( " Author: {} | License: {}", manifest.tool.author, manifest.tool.license, ); if let Some(ref hp) = manifest.tool.homepage { eprintln!(" Homepage: {hp}"); } eprintln!(" Hash: {}", manifest.binary.hash); eprintln!(" Type: {}", manifest.ui.tool_type); let caps = manifest.requested_capabilities(); if caps.is_empty() { eprintln!(" Capabilities: none (pure sandbox)"); } else { eprintln!(" Capabilities:"); for cap in &caps { eprintln!(" - {}", format_capability(cap)); } } Ok(()) } // --- Verify --- fn cmd_verify(paths: &WasmBoxPaths, name: &str) -> Result<()> { let (tool_name, version) = parse_tool_spec(name); let version = find_installed_version(paths, &tool_name, version.as_deref())?; let manifest_path = paths.tool_manifest_path(&tool_name, &version); let manifest = wasmbox_manifest::Manifest::from_file(&manifest_path) .map_err(|e| anyhow::anyhow!("{e}"))?; let wasm_path = paths.tool_wasm_path(&tool_name, &version, &manifest.binary.wasm); let wasm_bytes = std::fs::read(&wasm_path).with_context(|| format!("reading {}", wasm_path.display()))?; match wasmbox_verify::verify_hash(&wasm_bytes, &manifest.binary.hash) { Ok(()) => { eprintln!( "{} {} v{} - hash matches manifest", "verified".green().bold(), tool_name, version, ); Ok(()) } Err(e) => { anyhow::bail!("verification failed for {tool_name} v{version}: {e}"); } } } // --- List --- fn cmd_list(paths: &WasmBoxPaths, json: bool) -> Result<()> { let cache_dir = paths.cache_dir(); if !cache_dir.exists() { if json { println!("[]"); } else { eprintln!("no tools installed"); } return Ok(()); } let mut tools: Vec = Vec::new(); for entry in std::fs::read_dir(&cache_dir)? { let entry = entry?; if !entry.file_type()?.is_dir() { continue; } let tool_name = entry.file_name().to_string_lossy().to_string(); for ver_entry in std::fs::read_dir(entry.path())? { let ver_entry = ver_entry?; if !ver_entry.file_type()?.is_dir() { continue; } let version = ver_entry.file_name().to_string_lossy().to_string(); let manifest_path = ver_entry.path().join("wasmbox.toml"); if json { if let Ok(m) = wasmbox_manifest::Manifest::from_file(&manifest_path) { tools.push(serde_json::to_value(m.to_metadata())?); } } else if let Ok(m) = wasmbox_manifest::Manifest::from_file(&manifest_path) { eprintln!( " {} v{} - {}", m.tool.name.bold(), m.tool.version, m.tool.description, ); } else { eprintln!(" {} v{}", tool_name.bold(), version); } } } if json { println!("{}", serde_json::to_string_pretty(&tools)?); } Ok(()) } // --- Remove --- fn cmd_remove(paths: &WasmBoxPaths, name: &str) -> Result<()> { let (tool_name, version) = parse_tool_spec(name); let tool_cache = paths.cache_dir().join(&tool_name); if !tool_cache.exists() { anyhow::bail!("tool not installed: {tool_name}"); } if let Some(version) = version { // Remove specific version let version_dir = tool_cache.join(&version); if !version_dir.exists() { anyhow::bail!("version {version} not found for {tool_name}"); } std::fs::remove_dir_all(&version_dir)?; eprintln!("{} {} v{}", "removed".green().bold(), tool_name, version); // If no versions left, remove the tool directory if std::fs::read_dir(&tool_cache)?.next().is_none() { std::fs::remove_dir(&tool_cache)?; } } else { // Remove all versions std::fs::remove_dir_all(&tool_cache)?; eprintln!("{} {} (all versions)", "removed".green().bold(), tool_name); } // Remove permissions let perm_path = paths.permissions_file(); if perm_path.exists() { let mut store = wasmbox_permissions::PermissionStore::load(&perm_path) .map_err(|e| anyhow::anyhow!("{e}"))?; store .revoke(&tool_name) .map_err(|e| anyhow::anyhow!("{e}"))?; } Ok(()) } // --- Update --- async fn cmd_update(paths: &WasmBoxPaths, name: &str) -> Result<()> { let (tool_name, _) = parse_tool_spec(name); let current_version = find_installed_version(paths, &tool_name, None)?; let manifest_path = paths.tool_manifest_path(&tool_name, ¤t_version); let current_manifest = wasmbox_manifest::Manifest::from_file(&manifest_path) .map_err(|e| anyhow::anyhow!("{e}"))?; let registries = get_registries(paths, None)?; let client = wasmbox_registry::RegistryClient::new(registries.clone()); // Find latest version in registries let mut latest_entry = None; for reg in ®istries { if let Ok(index) = client.fetch_index(reg).await && let Some(entry) = index.tools.iter().find(|t| t.name == tool_name) { latest_entry = Some((reg.clone(), entry.clone())); break; } } let (registry, entry) = latest_entry.ok_or_else(|| anyhow::anyhow!("tool not found in registries: {tool_name}"))?; if entry.version == current_version && entry.hash == current_manifest.binary.hash { eprintln!( "{} {} is already up to date (v{})", "ok".green().bold(), tool_name, current_version ); return Ok(()); } eprintln!( "{} {} v{} -> v{}", "updating".cyan().bold(), tool_name, current_version, entry.version, ); eprintln!(" old hash: {}", current_manifest.binary.hash); eprintln!(" new hash: {}", entry.hash); // Download new version let wasm_bytes = client .fetch_binary(®istry, &tool_name) .await .map_err(|e| anyhow::anyhow!("{e}"))?; wasmbox_verify::verify_hash(&wasm_bytes, &entry.hash) .map_err(|e| anyhow::anyhow!("verification failed: {e}"))?; let manifest_json = client .fetch_manifest(®istry, &tool_name) .await .map_err(|e| anyhow::anyhow!("{e}"))?; // Store new version (keep old for rollback) — atomic write let new_manifest = wasmbox_manifest::Manifest::parse(&manifest_json).map_err(|e| anyhow::anyhow!("{e}"))?; let cache_dir = paths.cache_dir(); std::fs::create_dir_all(&cache_dir)?; let staging_dir = tempfile::tempdir_in(&cache_dir)?; std::fs::write(staging_dir.path().join("wasmbox.toml"), &manifest_json)?; std::fs::write( staging_dir.path().join(&new_manifest.binary.wasm), &wasm_bytes, )?; let tool_dir = paths.tool_dir(&tool_name, &entry.version); if tool_dir.exists() { std::fs::remove_dir_all(&tool_dir)?; } std::fs::create_dir_all(tool_dir.parent().unwrap())?; std::fs::rename(staging_dir.path(), &tool_dir)?; let _ = staging_dir.keep(); eprintln!( "{} {} to v{} (v{} kept for rollback)", "updated".green().bold(), tool_name, entry.version, current_version, ); Ok(()) } // --- Permissions --- fn cmd_permissions( paths: &WasmBoxPaths, name: &str, action: &Option, json: bool, ) -> Result<()> { let perm_path = paths.permissions_file(); let mut store = wasmbox_permissions::PermissionStore::load(&perm_path) .map_err(|e| anyhow::anyhow!("{e}"))?; match action { Some(PermissionAction::Revoke) => { store.revoke(name).map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!("{} all permissions for {name}", "revoked".green().bold()); } Some(PermissionAction::Show) | None => { if let Some(perm) = store.get(name) { if json { println!("{}", serde_json::to_string_pretty(perm)?); } else { print_permissions(name, perm); } } else { eprintln!("no permissions granted for {name}"); } } } Ok(()) } // --- Revoke --- fn cmd_revoke(paths: &WasmBoxPaths, name: &str, capability: &str) -> Result<()> { let perm_path = paths.permissions_file(); let mut store = wasmbox_permissions::PermissionStore::load(&perm_path) .map_err(|e| anyhow::anyhow!("{e}"))?; let changed = store .revoke_capability(name, capability) .map_err(|e| anyhow::anyhow!("{e}"))?; if changed { eprintln!("{} {capability} for {name}", "revoked".green().bold(),); } else { eprintln!("no {capability} permission found for {name}"); } Ok(()) } // --- Audit --- #[allow(clippy::too_many_arguments)] fn cmd_audit( paths: &WasmBoxPaths, json: bool, export: bool, format: &str, sign: bool, init_key: bool, verify: Option<&str>, from: Option<&str>, to: Option<&str>, session: Option<&str>, ) -> Result<()> { // Key generation if init_key { paths.ensure_dirs()?; let pub_key = wasmbox_compliance::generate_signing_key(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!( "{} Ed25519 keypair at {}", "generated".green().bold(), paths.home().join("audit_key.pem").display(), ); eprintln!("Public key: {pub_key}"); eprintln!("Register this public key with your compliance team."); return Ok(()); } // Verify a signed report if let Some(report_path) = verify { let content = std::fs::read_to_string(report_path) .with_context(|| format!("reading {report_path}"))?; // Detect report shape: session reports carry `report_type: // "session_audit"` at the top level; compliance reports do not. let probe: serde_json::Value = serde_json::from_str(&content)?; let is_session = probe.get("report_type").and_then(|v| v.as_str()) == Some("session_audit"); let result = if is_session { let report: wasmbox_compliance::SessionReport = serde_json::from_str(&content)?; wasmbox_compliance::verify_session_report(&report) .map_err(|e| anyhow::anyhow!("{e}"))? } else { let report: wasmbox_compliance::AuditReport = serde_json::from_str(&content)?; wasmbox_compliance::verify_report(&report).map_err(|e| anyhow::anyhow!("{e}"))? }; if result.valid { eprintln!("Signature: {}", "VALID".green().bold()); } else { eprintln!("Signature: {}", "INVALID".red().bold()); } eprintln!("Signed by: {}", result.public_key); eprintln!("Report generated: {}", result.generated_at); if !result.valid { anyhow::bail!("verification failed: signature is invalid"); } return Ok(()); } // Export compliance report if export { // Session-scoped export. if let Some(session_id) = session { let mut report = wasmbox_compliance::generate_session_report( paths.home(), env!("CARGO_PKG_VERSION"), session_id, ) .map_err(|e| anyhow::anyhow!("{e}"))?; if sign { wasmbox_compliance::sign_session_report(&mut report, paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; } match format { "json" => println!("{}", serde_json::to_string_pretty(&report)?), "md" => println!("{}", wasmbox_compliance::format_session_markdown(&report)), other => anyhow::bail!("unsupported format for session export: {other}"), } return Ok(()); } let period_from = from .map(parse_date_start) .transpose()? .unwrap_or_else(|| Utc::now() - chrono::Duration::days(90)); let period_to = to.map(parse_date_end).transpose()?.unwrap_or_else(Utc::now); let installed_tools = collect_tool_audit_entries(paths)?; let mut report = wasmbox_compliance::generate_report( paths.home(), env!("CARGO_PKG_VERSION"), period_from, period_to, installed_tools, ) .map_err(|e| anyhow::anyhow!("{e}"))?; if sign { wasmbox_compliance::sign_report(&mut report, paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; } match format { "json" => println!("{}", serde_json::to_string_pretty(&report)?), "md" => println!("{}", wasmbox_compliance::format_markdown(&report)), "csv" => println!("{}", wasmbox_compliance::format_csv(&report)), _ => anyhow::bail!("unsupported format: {format}. Use json, md, or csv"), } return Ok(()); } // Default: show permissions (backwards compatible) let perm_path = paths.permissions_file(); if !perm_path.exists() { if json { println!("{{}}"); } else { eprintln!("no permissions granted"); } return Ok(()); } let store = wasmbox_permissions::PermissionStore::load(&perm_path) .map_err(|e| anyhow::anyhow!("{e}"))?; let tools = store.list_all(); if json { // Include policy status per tool let policy_store = wasmbox_compliance::PolicyStore::load(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; let mut output = serde_json::Map::new(); for (name, perm) in tools { let mut perm_val = serde_json::to_value(perm)?; if let Some(obj) = perm_val.as_object_mut() { let status = if policy_store.has_policy() { let caps: Vec = store .to_capabilities(name) .iter() .map(format_capability) .collect(); let hash = get_installed_hash(paths, name).unwrap_or_default(); policy_store .check_tool(name, &hash, &caps) .status_str() .to_string() } else { "no_policy".to_string() }; obj.insert( "policy_status".to_string(), serde_json::Value::String(status), ); } output.insert(name.clone(), perm_val); } println!("{}", serde_json::to_string_pretty(&output)?); } else if tools.is_empty() { eprintln!("no permissions granted"); } else { for (name, perm) in tools { print_permissions(name, perm); eprintln!(); } } Ok(()) } // --- Log --- #[allow(clippy::too_many_arguments)] fn cmd_log( paths: &WasmBoxPaths, json: bool, last: Option, tool: Option<&str>, blocked: bool, after: Option<&str>, before: Option<&str>, export: Option<&str>, rotate: bool, stats: bool, session: Option<&str>, mode: Option<&str>, binary_hash: Option<&str>, ) -> Result<()> { let run_log = RunLog::new(paths.home()); if rotate { run_log.rotate().map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!("{} log rotated", "ok".green().bold()); return Ok(()); } if let Some(dest) = export { let count = run_log .export(std::path::Path::new(dest)) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!("{} {count} entries to {dest}", "exported".green().bold()); return Ok(()); } if stats { let s = run_log.stats().map_err(|e| anyhow::anyhow!("{e}"))?; if json { println!("{}", serde_json::to_string_pretty(&s)?); } else { eprintln!("Total runs: {}", s.total_runs); eprintln!("Unique tools: {}", s.unique_tools); eprintln!("Blocked: {}", s.blocked); eprintln!("Hash failures: {}", s.hash_failures); if let Some(first) = s.first_entry { eprintln!("First entry: {first}"); } if let Some(last) = s.last_entry { eprintln!("Last entry: {last}"); } } return Ok(()); } let after_dt = after.map(parse_date_start).transpose()?; let before_dt = before.map(parse_date_end).transpose()?; let entries = if session.is_some() || mode.is_some() || binary_hash.is_some() { run_log .read_advanced(session, mode, binary_hash) .map_err(|e| anyhow::anyhow!("{e}"))? } else { run_log .read_filtered(tool, blocked, after_dt, before_dt, last) .map_err(|e| anyhow::anyhow!("{e}"))? }; if json { println!("{}", serde_json::to_string_pretty(&entries)?); } else if entries.is_empty() { eprintln!("no log entries"); } else { for entry in &entries { let status = match entry.policy.as_str() { "blocked" => entry.policy.red().to_string(), "warned" => entry.policy.yellow().to_string(), _ => entry.policy.green().to_string(), }; let exit = entry .exit_code .map(|c| c.to_string()) .unwrap_or_else(|| "-".to_string()); let dur = entry .duration_ms .map(|d| format!("{d}ms")) .unwrap_or_else(|| "-".to_string()); // Sandboxed-tool entries carry a version + hash check; proxied // (`via`) and other entries show their execution mode instead. let tag = if entry.mode == "wasmbox" { format!( "v{} [{}]", entry.version, if entry.hash_verified { "ok" } else { "FAIL" }, ) } else { format!("[{}]", entry.mode) }; eprintln!( " {} {} {} exit={} dur={} policy={}", entry.ts.format("%Y-%m-%dT%H:%M:%S"), entry.tool.bold(), tag, exit, dur, status, ); } eprintln!("\n{} entries", entries.len()); } Ok(()) } // --- Policy --- fn cmd_policy(paths: &WasmBoxPaths, action: &PolicyAction, json: bool) -> Result<()> { match action { PolicyAction::Init { name, approved_by, enforcement, } => { let mode = match enforcement.as_str() { "enforce" => wasmbox_compliance::EnforcementMode::Enforce, "warn" => wasmbox_compliance::EnforcementMode::Warn, "disabled" => wasmbox_compliance::EnforcementMode::Disabled, other => anyhow::bail!( "invalid enforcement mode: {other}. Use enforce, warn, or disabled" ), }; let policy_name = name.as_deref().unwrap_or("WasmBox Policy"); let approver = approved_by.as_deref().unwrap_or("unset"); // Collect currently installed tools let mut tools = std::collections::HashMap::new(); let cache_dir = paths.cache_dir(); if cache_dir.exists() { for entry in std::fs::read_dir(&cache_dir)? { let entry = entry?; if !entry.file_type()?.is_dir() { continue; } let tool_name = entry.file_name().to_string_lossy().to_string(); if let Ok(version) = find_installed_version(paths, &tool_name, None) { let manifest_path = paths.tool_manifest_path(&tool_name, &version); if let Ok(m) = wasmbox_manifest::Manifest::from_file(&manifest_path) { let caps: Vec = m .requested_capabilities() .iter() .map(format_capability) .collect(); tools.insert( tool_name, wasmbox_compliance::PolicyTool { approved_hash: m.binary.hash.clone(), max_version: format!( "{}.{}.x", m.tool.version.major, m.tool.version.minor, ), capabilities: caps, approved_date: Utc::now().date_naive(), risk_tier: None, notes: None, }, ); } } } } paths.ensure_dirs()?; wasmbox_compliance::PolicyStore::init(paths.home(), policy_name, approver, mode, tools) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!( "{} policy at {}", "created".green().bold(), paths.home().join("policy.toml").display(), ); } PolicyAction::Show => { let store = wasmbox_compliance::PolicyStore::load(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; let Some(policy) = store.policy() else { eprintln!("no policy file. Run: wasmbox policy init"); return Ok(()); }; if json { println!("{}", serde_json::to_string_pretty(policy)?); } else { eprintln!( "Policy: {} v{}", policy.policy.name.bold(), policy.policy.version ); eprintln!("Approved by: {}", policy.policy.approved_by); eprintln!("Approved date: {}", policy.policy.approved_date); eprintln!("Enforcement: {:?}", policy.policy.enforcement); eprintln!("Default action: {:?}", policy.policy.default.action); eprintln!("\nApproved tools ({}):", policy.tools.len()); for (name, tool) in &policy.tools { let risk = tool.risk_tier.as_deref().unwrap_or("-"); eprintln!( " {} [{}] caps={:?} risk={}", name.bold(), &tool.approved_hash[..20.min(tool.approved_hash.len())], tool.capabilities, risk, ); } } } PolicyAction::Check => { let store = wasmbox_compliance::PolicyStore::load(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; if !store.has_policy() { eprintln!("no policy file. Run: wasmbox policy init"); return Ok(()); } let cache_dir = paths.cache_dir(); if !cache_dir.exists() { eprintln!("no tools installed"); return Ok(()); } let mut results = Vec::new(); for entry in std::fs::read_dir(&cache_dir)? { let entry = entry?; if !entry.file_type()?.is_dir() { continue; } let tool_name = entry.file_name().to_string_lossy().to_string(); if let Ok(version) = find_installed_version(paths, &tool_name, None) { let manifest_path = paths.tool_manifest_path(&tool_name, &version); if let Ok(m) = wasmbox_manifest::Manifest::from_file(&manifest_path) { let caps: Vec = m .requested_capabilities() .iter() .map(format_capability) .collect(); let decision = store.check_tool(&tool_name, &m.binary.hash, &caps); results.push((tool_name, version, m.binary.hash.clone(), decision)); } } } if json { let json_results: Vec = results .iter() .map(|(name, version, hash, decision)| { serde_json::json!({ "name": name, "version": version, "hash": hash, "status": decision.status_str(), "reason": decision.reason(), }) }) .collect(); println!("{}", serde_json::to_string_pretty(&json_results)?); } else { for (name, version, hash, decision) in &results { let status = match decision { PolicyDecision::Approved => "APPROVED".green().to_string(), PolicyDecision::Warned(r) => format!("{} ({})", "WARNED".yellow(), r), PolicyDecision::Blocked(r) => format!("{} ({})", "BLOCKED".red(), r), PolicyDecision::NoPolicy => "NO POLICY".yellow().to_string(), }; eprintln!( " {} v{} {} {}", name.bold(), version, &hash[..20.min(hash.len())], status, ); } } } PolicyAction::Add { name } => { let mut store = wasmbox_compliance::PolicyStore::load(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; if !store.has_policy() { anyhow::bail!("no policy file. Run: wasmbox policy init"); } let version = find_installed_version(paths, name, None)?; let manifest_path = paths.tool_manifest_path(name, &version); let m = wasmbox_manifest::Manifest::from_file(&manifest_path) .map_err(|e| anyhow::anyhow!("{e}"))?; let caps: Vec = m .requested_capabilities() .iter() .map(format_capability) .collect(); store .add_tool( name, wasmbox_compliance::PolicyTool { approved_hash: m.binary.hash.clone(), max_version: format!( "{}.{}.x", m.tool.version.major, m.tool.version.minor, ), capabilities: caps, approved_date: Utc::now().date_naive(), risk_tier: None, notes: None, }, ) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!( "{} {} v{} ({})", "added".green().bold(), name, version, &m.binary.hash[..20], ); } PolicyAction::Remove { name } => { let mut store = wasmbox_compliance::PolicyStore::load(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; if !store.has_policy() { anyhow::bail!("no policy file. Run: wasmbox policy init"); } let removed = store .remove_tool(name) .map_err(|e| anyhow::anyhow!("{e}"))?; if removed { eprintln!("{} {} from policy", "removed".green().bold(), name); } else { eprintln!("{name} not in policy"); } } PolicyAction::Enforce => { let mut store = wasmbox_compliance::PolicyStore::load(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; if !store.has_policy() { anyhow::bail!("no policy file. Run: wasmbox policy init"); } store .set_enforcement(wasmbox_compliance::EnforcementMode::Enforce) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!( "{} enforcement mode: {}", "set".green().bold(), "enforce".bold() ); } PolicyAction::Warn => { let mut store = wasmbox_compliance::PolicyStore::load(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; if !store.has_policy() { anyhow::bail!("no policy file. Run: wasmbox policy init"); } store .set_enforcement(wasmbox_compliance::EnforcementMode::Warn) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!( "{} enforcement mode: {}", "set".green().bold(), "warn".bold() ); } PolicyAction::Disable => { let mut store = wasmbox_compliance::PolicyStore::load(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; if !store.has_policy() { anyhow::bail!("no policy file. Run: wasmbox policy init"); } store .set_enforcement(wasmbox_compliance::EnforcementMode::Disabled) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!( "{} enforcement mode: {}", "set".green().bold(), "disabled".bold() ); } PolicyAction::Diff => { let store = wasmbox_compliance::PolicyStore::load(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; let Some(policy) = store.policy() else { eprintln!("no policy file. Run: wasmbox policy init"); return Ok(()); }; let cache_dir = paths.cache_dir(); let mut changes = Vec::new(); // Check installed tools against policy if cache_dir.exists() { for entry in std::fs::read_dir(&cache_dir)? { let entry = entry?; if !entry.file_type()?.is_dir() { continue; } let tool_name = entry.file_name().to_string_lossy().to_string(); if let Ok(version) = find_installed_version(paths, &tool_name, None) { let manifest_path = paths.tool_manifest_path(&tool_name, &version); if let Ok(m) = wasmbox_manifest::Manifest::from_file(&manifest_path) { if let Some(pt) = policy.tools.get(&tool_name) { if pt.approved_hash != m.binary.hash { changes.push(format!( "CHANGED: {} hash was {} now {}", tool_name, &pt.approved_hash[..20.min(pt.approved_hash.len())], &m.binary.hash[..20], )); } } else { changes.push(format!( "NEW: {} v{} installed but not in policy", tool_name, version, )); } } } } } // Check policy tools no longer installed for name in policy.tools.keys() { if !cache_dir.join(name).exists() { changes.push(format!( "REMOVED: {} in policy but no longer installed", name, )); } } if json { println!("{}", serde_json::to_string_pretty(&changes)?); } else if changes.is_empty() { eprintln!("no changes since last policy approval"); } else { for change in &changes { eprintln!(" {change}"); } } } PolicyAction::Export => { let store = wasmbox_compliance::PolicyStore::load(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; let Some(policy) = store.policy() else { anyhow::bail!("no policy file. Run: wasmbox policy init"); }; println!("{}", toml::to_string_pretty(policy)?); } } Ok(()) } /// Helper: parse a YYYY-MM-DD date as start-of-day UTC. fn parse_date_start(s: &str) -> Result> { let date = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") .with_context(|| format!("invalid date: {s}. Use YYYY-MM-DD"))?; Ok(date.and_hms_opt(0, 0, 0).unwrap().and_utc()) } /// Helper: parse a YYYY-MM-DD date as end-of-day UTC. fn parse_date_end(s: &str) -> Result> { let date = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") .with_context(|| format!("invalid date: {s}. Use YYYY-MM-DD"))?; Ok(date.and_hms_opt(23, 59, 59).unwrap().and_utc()) } /// Helper: get the hash of an installed tool's current version. fn get_installed_hash(paths: &WasmBoxPaths, tool_name: &str) -> Option { let version = find_installed_version(paths, tool_name, None).ok()?; let manifest_path = paths.tool_manifest_path(tool_name, &version); let m = wasmbox_manifest::Manifest::from_file(&manifest_path).ok()?; Some(m.binary.hash.clone()) } /// Helper: collect all installed tools as ToolAuditEntry for audit export. fn collect_tool_audit_entries( paths: &WasmBoxPaths, ) -> Result> { let policy_store = wasmbox_compliance::PolicyStore::load(paths.home()).map_err(|e| anyhow::anyhow!("{e}"))?; let cache_dir = paths.cache_dir(); let mut entries = Vec::new(); if !cache_dir.exists() { return Ok(entries); } for dir_entry in std::fs::read_dir(&cache_dir)? { let dir_entry = dir_entry?; if !dir_entry.file_type()?.is_dir() { continue; } let tool_name = dir_entry.file_name().to_string_lossy().to_string(); if let Ok(version) = find_installed_version(paths, &tool_name, None) { let manifest_path = paths.tool_manifest_path(&tool_name, &version); if let Ok(m) = wasmbox_manifest::Manifest::from_file(&manifest_path) { let wasm_path = paths.tool_wasm_path(&tool_name, &version, &m.binary.wasm); let hash_verified = wasm_path .exists() .then(|| { let data = std::fs::read(&wasm_path).ok()?; wasmbox_verify::verify_hash(&data, &m.binary.hash).ok() }) .flatten() .is_some(); let caps: Vec = m .requested_capabilities() .iter() .map(format_capability) .collect(); let decision = policy_store.check_tool(&tool_name, &m.binary.hash, &caps); let policy_tool = policy_store.policy().and_then(|p| p.tools.get(&tool_name)); entries.push(wasmbox_compliance::ToolAuditEntry { name: tool_name, version, hash: m.binary.hash.clone(), hash_verified, capabilities: caps, policy_status: decision.status_str().to_string(), risk_tier: policy_tool.and_then(|t| t.risk_tier.clone()), approved_date: policy_tool.map(|t| t.approved_date.to_string()), notes: policy_tool.and_then(|t| t.notes.clone()), }); } } } Ok(entries) } // --- Registry management --- fn cmd_registry(paths: &WasmBoxPaths, action: &RegistryAction) -> Result<()> { paths.ensure_dirs()?; let config_path = paths.config_file(); let mut config = WasmBoxConfig::load(&config_path).map_err(|e| anyhow::anyhow!("config error: {e}"))?; match action { RegistryAction::Add { url } => { if config.registries.iter().any(|r| r.url == *url) { eprintln!("registry already configured: {url}"); return Ok(()); } config.registries.push(wasmbox_shared::RegistryConfig { url: url.clone(), name: None, }); config .save(&config_path) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!("{} registry {url}", "added".green().bold()); } RegistryAction::List => { if config.registries.is_empty() { eprintln!("no registries configured"); eprintln!("add one with: wasmbox registry add "); } else { for reg in &config.registries { if let Some(ref name) = reg.name { eprintln!(" {} ({})", reg.url, name); } else { eprintln!(" {}", reg.url); } } } } RegistryAction::Remove { url } => { let before = config.registries.len(); config.registries.retain(|r| r.url != *url); if config.registries.len() < before { config .save(&config_path) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!("{} registry {url}", "removed".green().bold()); } else { eprintln!("registry not found: {url}"); } } } Ok(()) } // --- Hash --- fn cmd_hash(file: &str) -> Result<()> { let path = std::path::Path::new(file); if !path.exists() { anyhow::bail!("file not found: {file}"); } let hash = wasmbox_verify::hash_file(path).map_err(|e| anyhow::anyhow!("hash error: {e}"))?; println!("{hash}"); Ok(()) } // --- Via --- fn cmd_via(paths: &WasmBoxPaths, cmd: &str, args: &[String], capture_streams: bool) -> Result<()> { let shim_dir = paths.home().join("proxy"); let policy_store = wasmbox_compliance::PolicyStore::load(paths.home()).map_err(|e| anyhow::anyhow!("{e}"))?; let proxy_policy = resolve_proxy_policy(&policy_store); let req = wasmbox_compliance::ViaRequest { cmd: cmd.to_string(), args: args.to_vec(), home: paths.home().to_path_buf(), shim_dir, session_id: std::env::var("WASMBOX_SESSION_ID").ok(), }; let outcome = if capture_streams { wasmbox_compliance::run_via_capture(&req) } else { wasmbox_compliance::run_via_with_policy(req, proxy_policy.as_ref()) } .map_err(|e| anyhow::anyhow!("{e}"))?; // Mirror the child's outcome exactly. std::process::exit(outcome.exit_code); } /// Resolve the effective proxy policy: the policy file's `[proxy]` section, /// with the mode overridden by `WASMBOX_MODE` if that env var is set. fn resolve_proxy_policy( store: &wasmbox_compliance::PolicyStore, ) -> Option { let mut policy = store.proxy_policy().cloned(); if let Ok(mode_str) = std::env::var("WASMBOX_MODE") { let mode = match mode_str.as_str() { "record" => wasmbox_compliance::ProxyMode::Record, "suggest" => wasmbox_compliance::ProxyMode::Suggest, "enforce" => wasmbox_compliance::ProxyMode::Enforce, _ => return policy, }; let mut p = policy.unwrap_or_default(); p.mode = mode; policy = Some(p); } policy } // --- Shell --- fn cmd_shell(paths: &WasmBoxPaths, action: &ShellAction, json: bool) -> Result<()> { match action { ShellAction::Status => { let status = wasmbox_compliance::shim_status(paths.home()) .map_err(|e| anyhow::anyhow!("{e}"))?; if json { println!("{}", serde_json::to_string_pretty(&status)?); } else { eprintln!("Shim directory: {}", status.proxy_dir); eprintln!("Shims: {}", status.shim_count); eprintln!("On PATH: {}", if status.active { "yes" } else { "no" }); } Ok(()) } ShellAction::Rebuild => cmd_shell_init(paths, json, false, &[], &[], false), ShellAction::Init { all, include, exclude, rebuild: _, auto_add_to_shell, } => cmd_shell_init(paths, json, *all, include, exclude, *auto_add_to_shell), } } fn cmd_shell_init( paths: &WasmBoxPaths, json: bool, all: bool, include: &[String], exclude: &[String], auto_add_to_shell: bool, ) -> Result<()> { let mut commands: Vec = if all { scan_all_executables() } else { wasmbox_compliance::curated_commands() .into_iter() .map(String::from) .collect() }; for c in include { if !commands.contains(c) { commands.push(c.clone()); } } commands.retain(|c| !exclude.contains(c)); let report = wasmbox_compliance::generate_shims(paths.home(), &commands) .map_err(|e| anyhow::anyhow!("{e}"))?; let export_line = format!("export PATH=\"{}:$PATH\"", report.proxy_dir); if auto_add_to_shell { append_to_shell_rc(&export_line)?; } if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { eprintln!( "{} {} shims in {}", "generated".green().bold(), report.created, report.proxy_dir, ); if !auto_add_to_shell { eprintln!("\nTo activate, add to ~/.zshrc or ~/.bashrc:"); eprintln!(" {export_line}"); } } Ok(()) } /// Scan standard bin directories for executables (for `--all`). fn scan_all_executables() -> Vec { use std::os::unix::fs::PermissionsExt; let mut found = std::collections::BTreeSet::new(); let dirs = ["/usr/bin", "/usr/local/bin"]; let mut all_dirs: Vec = dirs.iter().map(std::path::PathBuf::from).collect(); if let Some(home) = std::env::var_os("HOME") { all_dirs.push(std::path::PathBuf::from(home).join(".local/bin")); } for dir in all_dirs { if let Ok(rd) = std::fs::read_dir(&dir) { for entry in rd.filter_map(|e| e.ok()) { let path = entry.path(); if let Ok(meta) = std::fs::metadata(&path) && meta.is_file() && meta.permissions().mode() & 0o111 != 0 && let Some(name) = path.file_name().and_then(|n| n.to_str()) { found.insert(name.to_string()); } } } } found.into_iter().collect() } /// Append the PATH export line to the user's shell rc file (~/.zshrc or ~/.bashrc). fn append_to_shell_rc(export_line: &str) -> Result<()> { let home = std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME is not set"))?; let shell = std::env::var("SHELL").unwrap_or_default(); let rc = if shell.ends_with("zsh") { ".zshrc" } else { ".bashrc" }; let rc_path = std::path::PathBuf::from(home).join(rc); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&rc_path) .with_context(|| format!("opening {}", rc_path.display()))?; use std::io::Write; writeln!(file, "\n# Added by wasmbox shell init\n{export_line}")?; eprintln!( "{} PATH export to {}", "added".green().bold(), rc_path.display() ); Ok(()) } // --- Wrap --- fn cmd_wrap(paths: &WasmBoxPaths, agent: &str, new_session: bool, args: &[String]) -> Result<()> { // Resolve the agent binary up front for a friendly not-installed error. let shim_dir = paths.home().join("proxy"); if wasmbox_compliance::resolve_binary_path(agent, &shim_dir).is_none() { anyhow::bail!("agent '{agent}' not found on PATH. Install it, or check the name."); } let session_id = wasmbox_compliance::resolve_session_id(new_session); let mut entries = vec![shim_dir.clone()]; entries.extend(std::env::split_paths( &std::env::var_os("PATH").unwrap_or_default(), )); let path_var = std::env::join_paths(entries).map_err(|e| anyhow::anyhow!("building PATH: {e}"))?; eprintln!( "{} session {} starting", "WasmBox".cyan().bold(), session_id, ); let status = std::process::Command::new(agent) .args(args) .env("WASMBOX_SESSION_ID", &session_id) .env("PATH", &path_var) .status() .with_context(|| format!("launching {agent}"))?; // Print the session summary unless quieted. if std::env::var("WASMBOX_QUIET").as_deref() != Ok("1") { let summary = wasmbox_compliance::build_summary(paths.home(), &session_id) .map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!("\n{}", summary.render()); } std::process::exit(status.code().unwrap_or(1)); } // --- Helpers --- fn get_registries( paths: &WasmBoxPaths, override_url: Option<&str>, ) -> Result> { if let Some(url) = override_url { return Ok(vec![wasmbox_registry::Registry { url: url.to_string(), }]); } let config_path = paths.config_file(); let config = WasmBoxConfig::load(&config_path).map_err(|e| anyhow::anyhow!("config error: {e}"))?; if config.registries.is_empty() { anyhow::bail!("no registries configured. Add one with: wasmbox registry add "); } Ok(config .registries .iter() .map(|r| wasmbox_registry::Registry { url: r.url.clone() }) .collect()) } fn human_size(bytes: u64) -> String { if bytes < 1024 { format!("{bytes} B") } else if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else { format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } } fn format_capability(cap: &Capability) -> String { match cap { Capability::Stdin => "stdin".to_string(), Capability::Stdout => "stdout".to_string(), Capability::Clipboard => "clipboard".to_string(), Capability::Network(hosts) => format!("network: {}", hosts.join(", ")), Capability::Filesystem(g) => { let mode = match (g.read, g.write) { (true, true) => "rw", (true, false) => "r", (false, true) => "w", (false, false) => "none", }; format!("filesystem: {} ({})", g.path, mode) } Capability::Env(vars) => format!("env: {}", vars.join(", ")), } } fn print_permissions(name: &str, perm: &wasmbox_permissions::ToolPermissions) { eprintln!("{} v{}:", name.bold(), perm.version); if perm.stdout { eprintln!(" stdout: {}", "granted".green()); } if perm.stdin { eprintln!(" stdin: {}", "granted".green()); } if perm.clipboard { eprintln!(" clipboard: {}", "granted".green()); } if !perm.network.is_empty() { eprintln!(" network: {}", perm.network.join(", ")); } if !perm.filesystem.is_empty() { for fs in &perm.filesystem { eprintln!( " filesystem: {} (read={}, write={})", fs.path, fs.read, fs.write, ); } } if !perm.env.is_empty() { eprintln!(" env: {}", perm.env.join(", ")); } }