Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
62 changes: 54 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
|
Expand All @@ -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 |
Expand Down Expand Up @@ -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 `<state_dir>/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
Expand Down
12 changes: 8 additions & 4 deletions src/cli/commands/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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);

Expand Down
9 changes: 7 additions & 2 deletions src/cli/commands/run/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
Expand Down
109 changes: 109 additions & 0 deletions src/orchestration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 `<bytes>\t<path>` -- this extracts and parses the leading
/// number, returning `None` if the output cannot be parsed.
pub(crate) fn parse_du_bytes(output: &[u8]) -> Option<u64> {
let text = String::from_utf8_lossy(output);
text.split_whitespace()
.next()
.and_then(|s| s.parse::<u64>().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<Option<(String, u64)>>>,
) -> MinoResult<HashMap<String, u64>> {
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<MinoResult<Option<(String, u64)>>> = 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"));
}
}
Loading
Loading