diff --git a/Cargo.lock b/Cargo.lock index 8b282e4..9fe74c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,6 +370,42 @@ dependencies = [ "num-traits", ] +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -564,6 +600,7 @@ dependencies = [ "cliclack", "console 0.15.11", "dirs", + "futures-util", "hex", "indicatif 0.17.11", "predicates", @@ -852,6 +889,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index a76ac23..927221b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ cliclack = "0.3" async-trait = "0.1" sha2 = "0.10" hex = "0.4" +futures-util = "0.3" [dev-dependencies] tempfile = "3.19" diff --git a/README.md b/README.md index de25a03..8f246d2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![GitHub Release](https://img.shields.io/github/v/release/dean0x/mino)](https://github.com/dean0x/mino/releases) [![CI](https://github.com/dean0x/mino/actions/workflows/ci.yml/badge.svg)](https://github.com/dean0x/mino/actions/workflows/ci.yml) -Secure sandbox wrapper for AI coding agents using OrbStack + Podman rootless containers. +Secure sandbox wrapper for AI coding agents using rootless Podman containers. Wraps **any command** in isolated containers with temporary cloud credentials and SSH agent forwarding. Works with Claude Code, Aider, Cursor, or any CLI tool. @@ -25,7 +25,7 @@ AI coding agents are powerful but require significant system access. Mino provid ## Features -- **Rootless Containers**: Podman containers inside OrbStack VMs - no root required +- **Rootless Containers**: Podman containers with no root required (OrbStack VM on macOS, native on Linux) - **Temporary Credentials**: Generates short-lived AWS/GCP/Azure tokens (1-12 hours) - **SSH Agent Forwarding**: Git authentication without exposing private keys - **Persistent Caching**: Content-addressed dependency caches survive session crashes @@ -35,12 +35,11 @@ AI coding agents are powerful but require significant system access. Mino provid ## Requirements -- **macOS** with [OrbStack](https://orbstack.dev) installed -- Cloud CLIs (optional, for credential generation): - - `aws` - AWS credentials via STS - - `gcloud` - GCP access tokens - - `az` - Azure access tokens - - `gh` - GitHub token +- **macOS**: [OrbStack](https://orbstack.dev) installed (manages a lightweight Linux VM with Podman) +- **Linux**: [Podman](https://podman.io) installed in rootless mode (no VM needed) +- Cloud CLIs (optional): `aws`, `gcloud`, `az`, `gh` + +Run `mino setup` to check and install prerequisites for your platform. ## Installation @@ -466,6 +465,13 @@ When using `--network-allow`, Mino: - ACCEPT DNS (port 53, UDP + TCP) - ACCEPT each allowlisted host:port +### Presets + +| Preset | Destinations | Use case | +|--------|-------------|----------| +| `dev` | github.com (443, 22), api.github.com, registry.npmjs.org, crates.io, static.crates.io, index.crates.io, pypi.org, files.pythonhosted.org, api.anthropic.com, api.openai.com | Dev with AI agents | +| `registries` | registry.npmjs.org, crates.io, static.crates.io, index.crates.io, pypi.org, files.pythonhosted.org | Package install only | + ### Configuration Set default network allowlist in config: @@ -591,6 +597,8 @@ To customize a built-in layer, create a layer with the same name in your project ## Architecture +### macOS (via OrbStack) + ``` macOS Host | @@ -608,6 +616,23 @@ macOS Host - NO access to: ~/.ssh, ~/, system dirs ``` +### Linux (native Podman) + +``` +Linux Host + | + +- mino CLI (Rust binary) + | - Validates environment (rootless Podman) + | - Generates temp credentials (STS, gcloud, az) + | - Manages session lifecycle + | + +-> Podman rootless container (no VM layer) + - Mounted: /workspace (project dir only) + - SSH agent socket forwarded + - Temp credentials as env vars + - NO access to: ~/.ssh, ~/, system dirs +``` + ## Credential Strategy | Service | Method | Lifetime | @@ -651,6 +676,27 @@ For maximum security: 3. Use `--network none` or `--network-allow` for network-restricted sessions 4. Use `--network-preset registries` to limit egress to package registries only +## Audit Log + +Mino writes security events to `/mino/audit.log` in JSON Lines format. Enabled by default; disable with `general.audit_log = false` in config. + +Each line is a JSON object: +```json +{"timestamp":"2026-03-09T12:00:00Z","event":"session.created","data":{...}} +``` + +### Events + +| Event | When | Data fields | +|-------|------|-------------| +| `session.created` | Session state initialized | `name`, `project_dir`, `image`, `command` | +| `credentials.injected` | Cloud credentials passed to container | `session_name`, `providers` | +| `session.started` | Container running | `name`, `container_id` | +| `session.stopped` | Container exited | `name`, `exit_code` | +| `session.failed` | Container failed to start | `name`, `error` | + +Audit logging uses silent failure mode — IO errors are logged via `tracing::warn` but never block or crash the primary workflow. + ## Development ```bash diff --git a/src/cli/commands/cache.rs b/src/cli/commands/cache.rs index 66f8798..da7899c 100644 --- a/src/cli/commands/cache.rs +++ b/src/cli/commands/cache.rs @@ -206,7 +206,12 @@ async fn show_project_info( ui::key_value(&ctx, "Project", &project_dir.display().to_string()); // Detect lockfiles - let lockfiles = detect_lockfiles(&project_dir)?; + let lockfiles = { + let dir = project_dir.clone(); + tokio::task::spawn_blocking(move || detect_lockfiles(&dir)) + .await + .map_err(|e| MinoError::Internal(format!("lockfile detection task failed: {e}")))? + }?; if lockfiles.is_empty() { ui::step_info(&ctx, "No lockfiles detected in this project."); @@ -276,9 +281,8 @@ async fn show_project_info( } } - // Show total cache usage - let all_sizes = runtime.volume_disk_usage("mino-cache-").await?; - let total_size: u64 = all_sizes.values().sum(); + // Show total cache usage (reuse sizes from earlier query) + let total_size: u64 = sizes.values().sum(); let limit_bytes = gb_to_bytes(config.cache.max_total_gb); let percent = CacheSizeStatus::percentage(total_size, limit_bytes); diff --git a/src/cli/commands/run/cache.rs b/src/cli/commands/run/cache.rs index 33b5beb..dac3573 100644 --- a/src/cli/commands/run/cache.rs +++ b/src/cli/commands/run/cache.rs @@ -6,7 +6,7 @@ use crate::cache::{ }; use crate::cli::args::RunArgs; use crate::config::Config; -use crate::error::MinoResult; +use crate::error::{MinoError, MinoResult}; use crate::orchestration::ContainerRuntime; use console::style; use std::collections::HashMap; @@ -31,7 +31,12 @@ pub(super) async fn setup_caches( return Ok((cache_mounts, cache_env, cache_session)); } - let lockfiles = detect_lockfiles(project_dir)?; + let lockfiles = { + let dir = project_dir.to_path_buf(); + tokio::task::spawn_blocking(move || detect_lockfiles(&dir)) + .await + .map_err(|e| MinoError::Internal(format!("lockfile detection task failed: {e}")))? + }?; if lockfiles.is_empty() { debug!("No lockfiles detected, skipping cache setup"); return Ok((cache_mounts, cache_env, cache_session)); diff --git a/src/orchestration/mod.rs b/src/orchestration/mod.rs index 8c2db50..aeb789b 100644 --- a/src/orchestration/mod.rs +++ b/src/orchestration/mod.rs @@ -16,8 +16,11 @@ pub use orbstack::OrbStack; pub use podman::ContainerConfig; pub use runtime::{ContainerRuntime, VolumeInfo}; +use std::collections::HashMap; use tokio::io::{AsyncBufReadExt, BufReader}; +use crate::error::MinoResult; + /// Max number of output lines to include in build error messages. const BUILD_ERROR_TAIL_LINES: usize = 50; @@ -80,3 +83,109 @@ pub(crate) async fn stream_child_output( all_output } + +/// Parse `du -sb` output to extract the byte size. +/// +/// `du -sb` prints `\t` -- this extracts and parses the leading +/// number, returning `None` if the output cannot be parsed. +pub(crate) fn parse_du_bytes(output: &[u8]) -> Option { + let text = String::from_utf8_lossy(output); + text.split_whitespace() + .next() + .and_then(|s| s.parse::().ok()) +} + +/// Collect volume disk usage results from a batch of parallel futures. +/// +/// Each future should resolve to `Ok(Some((name, size)))` on success or +/// `Ok(None)` when the size could not be determined. Errors propagate. +pub(crate) fn collect_disk_usage( + results: Vec>>, +) -> MinoResult> { + let mut sizes = HashMap::new(); + for result in results { + if let Some((name, size)) = result? { + sizes.insert(name, size); + } + } + Ok(sizes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::MinoError; + + // -- parse_du_bytes -- + + #[test] + fn parse_du_bytes_valid() { + let output = b"12345\t/var/lib/containers/storage/volumes/vol/_data\n"; + assert_eq!(parse_du_bytes(output), Some(12345)); + } + + #[test] + fn parse_du_bytes_large_value() { + let output = b"1073741824\t/some/path\n"; + assert_eq!(parse_du_bytes(output), Some(1_073_741_824)); + } + + #[test] + fn parse_du_bytes_empty() { + assert_eq!(parse_du_bytes(b""), None); + } + + #[test] + fn parse_du_bytes_non_numeric() { + assert_eq!(parse_du_bytes(b"abc\t/path\n"), None); + } + + #[test] + fn parse_du_bytes_whitespace_only() { + assert_eq!(parse_du_bytes(b" \t \n"), None); + } + + // -- collect_disk_usage -- + + #[test] + fn collect_disk_usage_happy_path() { + let results = vec![ + Ok(Some(("vol-a".to_string(), 100))), + Ok(Some(("vol-b".to_string(), 200))), + ]; + let sizes = collect_disk_usage(results).unwrap(); + assert_eq!(sizes.len(), 2); + assert_eq!(sizes["vol-a"], 100); + assert_eq!(sizes["vol-b"], 200); + } + + #[test] + fn collect_disk_usage_skips_none() { + let results = vec![ + Ok(Some(("vol-a".to_string(), 100))), + Ok(None), + Ok(Some(("vol-c".to_string(), 300))), + ]; + let sizes = collect_disk_usage(results).unwrap(); + assert_eq!(sizes.len(), 2); + assert_eq!(sizes["vol-a"], 100); + assert_eq!(sizes["vol-c"], 300); + } + + #[test] + fn collect_disk_usage_empty() { + let results: Vec>> = vec![]; + let sizes = collect_disk_usage(results).unwrap(); + assert!(sizes.is_empty()); + } + + #[test] + fn collect_disk_usage_propagates_error() { + let results = vec![ + Ok(Some(("vol-a".to_string(), 100))), + Err(MinoError::Internal("test error".to_string())), + ]; + let err = collect_disk_usage(results).unwrap_err(); + assert!(err.to_string().contains("test error")); + } +} diff --git a/src/orchestration/native_podman.rs b/src/orchestration/native_podman.rs index f032494..7b8b67b 100644 --- a/src/orchestration/native_podman.rs +++ b/src/orchestration/native_podman.rs @@ -519,9 +519,8 @@ impl ContainerRuntime for NativePodmanRuntime { // Get volume sizes by inspecting each volume individually. // Note: `podman system df -v --format json` is not supported (flags conflict). let volumes = self.volume_list(prefix).await?; - let mut sizes = HashMap::new(); - for vol in &volumes { + let futures = volumes.into_iter().map(|vol| async move { let output = self .exec(&[ "volume", @@ -532,29 +531,34 @@ impl ContainerRuntime for NativePodmanRuntime { ]) .await?; - if output.status.success() { - let mountpoint = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !mountpoint.is_empty() { - // Get directory size via du - let du_output = tokio::process::Command::new("du") - .args(["-sb", &mountpoint]) - .output() - .await - .map_err(|e| MinoError::io("du", e))?; - - if du_output.status.success() { - let du_str = String::from_utf8_lossy(&du_output.stdout); - if let Some(size_str) = du_str.split_whitespace().next() { - if let Ok(size) = size_str.parse::() { - sizes.insert(vol.name.clone(), size); - } - } - } + if !output.status.success() { + return Ok(None); + } + + let mountpoint = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if mountpoint.is_empty() { + return Ok(None); + } + + let du_output = tokio::process::Command::new("du") + .args(["-sb", &mountpoint]) + .output() + .await + .map_err(|e| MinoError::io("du", e))?; + + if du_output.status.success() { + if let Some(size) = super::parse_du_bytes(&du_output.stdout) { + return Ok(Some((vol.name.clone(), size))); } } - } - Ok(sizes) + Ok(None) + }); + + let results: Vec>> = + futures_util::future::join_all(futures).await; + + super::collect_disk_usage(results) } async fn get_container_exit_code(&self, container_id: &str) -> MinoResult> { diff --git a/src/orchestration/orbstack_runtime.rs b/src/orchestration/orbstack_runtime.rs index 8db7cac..7aaa65e 100644 --- a/src/orchestration/orbstack_runtime.rs +++ b/src/orchestration/orbstack_runtime.rs @@ -585,9 +585,8 @@ impl ContainerRuntime for OrbStackRuntime { // Get volume sizes by inspecting each volume individually. // Note: `podman system df -v --format json` is not supported (flags conflict). let volumes = self.volume_list(prefix).await?; - let mut sizes = HashMap::new(); - for vol in &volumes { + let futures = volumes.into_iter().map(|vol| async move { let output = self .orbstack .exec(&[ @@ -600,24 +599,29 @@ impl ContainerRuntime for OrbStackRuntime { ]) .await?; - if output.status.success() { - let mountpoint = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !mountpoint.is_empty() { - // Get directory size via du - let du_output = self.orbstack.exec(&["du", "-sb", &mountpoint]).await?; - if du_output.status.success() { - let du_str = String::from_utf8_lossy(&du_output.stdout); - if let Some(size_str) = du_str.split_whitespace().next() { - if let Ok(size) = size_str.parse::() { - sizes.insert(vol.name.clone(), size); - } - } - } + if !output.status.success() { + return Ok(None); + } + + let mountpoint = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if mountpoint.is_empty() { + return Ok(None); + } + + let du_output = self.orbstack.exec(&["du", "-sb", &mountpoint]).await?; + if du_output.status.success() { + if let Some(size) = super::parse_du_bytes(&du_output.stdout) { + return Ok(Some((vol.name.clone(), size))); } } - } - Ok(sizes) + Ok(None) + }); + + let results: Vec>> = + futures_util::future::join_all(futures).await; + + super::collect_disk_usage(results) } async fn get_container_exit_code(&self, container_id: &str) -> MinoResult> {