diff --git a/Cargo.lock b/Cargo.lock index 53a5972..8403d12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,6 +386,8 @@ dependencies = [ "clap", "colibri-client", "colibri-daemon", + "colibri-pf", + "colibri-zfs", "serde", "serde_json", "tokio", @@ -394,6 +396,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "colibri-pf" +version = "0.12.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "colibri-runtime" version = "0.12.0" @@ -437,6 +448,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "colibri-zfs" +version = "0.12.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index d6855af..e34dbf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/clawdie"] +members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/colibri-zfs", "crates/colibri-pf", "crates/clawdie"] [workspace.package] version = "0.12.0" diff --git a/crates/colibri-daemon/src/daemon.rs b/crates/colibri-daemon/src/daemon.rs index f87118e..4dd0961 100644 --- a/crates/colibri-daemon/src/daemon.rs +++ b/crates/colibri-daemon/src/daemon.rs @@ -354,8 +354,10 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) { let payload_line = serde_json::to_string(&payload).unwrap_or_default(); let mut child = match std::process::Command::new("ssh") .args([ - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=5", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=5", &mother_host, "report-task-cost", ]) diff --git a/crates/colibri-mcp/Cargo.toml b/crates/colibri-mcp/Cargo.toml index 5678800..11d6d6d 100644 --- a/crates/colibri-mcp/Cargo.toml +++ b/crates/colibri-mcp/Cargo.toml @@ -12,6 +12,8 @@ path = "src/main.rs" [dependencies] colibri-client = { path = "../colibri-client" } colibri-daemon = { path = "../colibri-daemon" } +colibri-pf = { path = "../colibri-pf" } +colibri-zfs = { path = "../colibri-zfs" } clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/colibri-mcp/src/lib.rs b/crates/colibri-mcp/src/lib.rs index 198d748..56c0f1b 100644 --- a/crates/colibri-mcp/src/lib.rs +++ b/crates/colibri-mcp/src/lib.rs @@ -15,6 +15,12 @@ //! | `colibri_create_task` | write-gated| Create a task | //! | `colibri_intake_task` | write-gated| Submit intake task with capabilities| //! | `colibri_set_cost_mode` | write-gated | Switch cost mode (fast/smart/max) | +//! | `colibri_zfs_list_snapshots` | read-only | ZFS snapshot listing | +//! | `colibri_zfs_destroy_snapshot` | write-gated | Destroy a ZFS snapshot | +//! | `colibri_pf_list_rules` | read-only | Active PF firewall rules | +//! | `colibri_pf_list_states` | read-only | PF state table entries | +//! | `colibri_wiki_search` | read-only | Search wiki pages | +//! | `colibri_wiki_page` | read-only | Read wiki page content | //! | `colibri_get_task` | read-only | Task details with cost data | //! | `colibri_list_task_costs` | read-only | All tasks with cost (dashboard) | //! @@ -168,6 +174,62 @@ pub fn tool_list() -> Vec { } })), ), + // ── Infrastructure tools ── + json_tool( + "colibri_zfs_list_snapshots", + "List ZFS snapshots for a dataset. Returns structured data: name, used_bytes, refer_bytes, creation, age_hours.", + Some(serde_json::json!({ + "type": "object", + "properties": { + "dataset": { "type": "string", "description": "ZFS dataset name, e.g. 'zroot/home/clawdie'" } + }, + "required": ["dataset"] + })), + ), + json_tool( + "colibri_zfs_destroy_snapshot", + "Destroy a ZFS snapshot by full name. Requires ZFS destroy permission.", + Some(serde_json::json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "Full snapshot name, e.g. 'zroot/home@autosnap_2026-06-27_09:00:00_hourly'" } + }, + "required": ["name"] + })), + ), + json_tool( + "colibri_pf_list_rules", + "List active PF firewall rules. Returns array of rule strings.", + None, + ), + json_tool( + "colibri_pf_list_states", + "List active PF state table entries: protocol, source, destination, state.", + None, + ), + // ── Wiki tools ── + json_tool( + "colibri_wiki_search", + "Search Colibri wiki pages for a keyword. Returns matching page names and line excerpts.", + Some(serde_json::json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search keyword or phrase" } + }, + "required": ["query"] + })), + ), + json_tool( + "colibri_wiki_page", + "Read a Colibri wiki page in full. Returns the complete markdown content.", + Some(serde_json::json!({ + "type": "object", + "properties": { + "page": { "type": "string", "description": "Wiki page name without .md, e.g. 'cost-model' or 'sl/cost-model'" } + }, + "required": ["page"] + })), + ), json_tool( "colibri_external_mcp_servers", "List configured external MCP servers from COLIBRI_MCP_EXTERNAL_CONFIG", @@ -324,6 +386,53 @@ pub async fn dispatch_tool( let all_tasks = client.list_tasks(status).await.map_err(map_client_error)?; Ok(tool_text(all_tasks)) } + // ── Infrastructure dispatch ── + "colibri_zfs_list_snapshots" => { + let dataset = require_string(arguments, "dataset")?; + let snaps = colibri_zfs::Snapshot::list(&dataset) + .map_err(|e| McpError::internal(format!("zfs: {e}")))?; + Ok(tool_text(serde_json::to_value(&snaps).unwrap_or_default())) + } + "colibri_zfs_destroy_snapshot" => { + let name = require_string(arguments, "name")?; + let snap = colibri_zfs::Snapshot { + name, + dataset: String::new(), + tag: String::new(), + used_bytes: 0, + refer_bytes: 0, + creation: String::new(), + }; + snap.destroy() + .map_err(|e| McpError::internal(format!("zfs destroy: {e}")))?; + Ok(tool_text(serde_json::json!({"destroyed": true}))) + } + "colibri_pf_list_rules" => { + let rules = + colibri_pf::list_rules().map_err(|e| McpError::internal(format!("pf: {e}")))?; + Ok(tool_text(serde_json::to_value(&rules).unwrap_or_default())) + } + "colibri_pf_list_states" => { + let states = + colibri_pf::list_states().map_err(|e| McpError::internal(format!("pf: {e}")))?; + Ok(tool_text(serde_json::to_value(&states).unwrap_or_default())) + } + // ── Wiki dispatch ── + "colibri_wiki_search" => { + let query = require_string(arguments, "query")?; + let results = wiki_search(&query); + Ok(tool_text( + serde_json::to_value(&results).unwrap_or_default(), + )) + } + "colibri_wiki_page" => { + let page = require_string(arguments, "page")?; + let content = wiki_page(&page) + .ok_or_else(|| McpError::not_found(format!("wiki page not found: {page}")))?; + Ok(tool_text( + serde_json::json!({"page": page, "content": content}), + )) + } "colibri_external_mcp_servers" => { let registry = external::load_registry_if_present(&config.external_config_path).await?; Ok(tool_text(serde_json::json!({ @@ -588,3 +697,70 @@ impl StdioHandler { Ok(()) } } + +// ── Wiki helpers ── + +fn wiki_search(query: &str) -> Vec { + let wiki_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/wiki"); + let q = query.to_lowercase(); + let mut results = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&wiki_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map_or(true, |e| e != "md") { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let matches: Vec = content + .lines() + .enumerate() + .filter(|(_, l)| l.to_lowercase().contains(&q)) + .take(3) + .map(|(i, l)| format!("L{}: {}", i + 1, l.trim())) + .collect(); + if !matches.is_empty() { + let name = path.file_stem().unwrap_or_default().to_string_lossy(); + results.push(serde_json::json!({ + "page": name, + "matches": matches + })); + } + } + } + // Also search sl/ subdirectory + let sl_dir = wiki_dir.join("sl"); + if let Ok(entries) = std::fs::read_dir(&sl_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map_or(true, |e| e != "md") { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let matches: Vec = content + .lines() + .enumerate() + .filter(|(_, l)| l.to_lowercase().contains(&q)) + .take(3) + .map(|(i, l)| format!("L{}: {}", i + 1, l.trim())) + .collect(); + if !matches.is_empty() { + let name = path.file_stem().unwrap_or_default().to_string_lossy(); + results.push(serde_json::json!({ + "page": format!("sl/{}", name), + "matches": matches + })); + } + } + } + results +} + +fn wiki_page(page: &str) -> Option { + let wiki_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/wiki"); + let path = wiki_dir.join(format!("{page}.md")); + std::fs::read_to_string(&path).ok() +} diff --git a/crates/colibri-mcp/tests/tool_dispatch.rs b/crates/colibri-mcp/tests/tool_dispatch.rs index c505c43..8e49767 100644 --- a/crates/colibri-mcp/tests/tool_dispatch.rs +++ b/crates/colibri-mcp/tests/tool_dispatch.rs @@ -255,5 +255,5 @@ fn tool_list_has_all_phase1_tools() { assert!(names.contains(&"colibri_list_task_costs")); assert!(names.contains(&"colibri_get_task")); - assert_eq!(names.len(), 12); + assert_eq!(names.len(), 18); } diff --git a/crates/colibri-pf/Cargo.toml b/crates/colibri-pf/Cargo.toml new file mode 100644 index 0000000..f6a7833 --- /dev/null +++ b/crates/colibri-pf/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "colibri-pf" +version.workspace = true +edition = "2021" +license = "MIT" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" diff --git a/crates/colibri-pf/src/lib.rs b/crates/colibri-pf/src/lib.rs new file mode 100644 index 0000000..e6e0dea --- /dev/null +++ b/crates/colibri-pf/src/lib.rs @@ -0,0 +1,86 @@ +//! colibri-pf — structured PF firewall inspection for Colibri agents. +//! +//! Wraps `pfctl(8)` output into Rust structs. Read-only operations +//! work without root; state/rule manipulation requires privileges. + +use serde::{Deserialize, Serialize}; +use std::process::Command; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PfRule { + pub line: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PfState { + pub proto: String, + pub src: String, + pub dst: String, + pub state: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("pfctl failed: {0}")] + Command(String), +} + +/// List active PF rules (equivalent to `pfctl -s rules`). +pub fn list_rules() -> Result, Error> { + let output = Command::new("pfctl") + .args(["-s", "rules"]) + .output() + .map_err(|e| Error::Command(format!("pfctl: {e}")))?; + if !output.status.success() { + return Err(Error::Command( + String::from_utf8_lossy(&output.stderr).into(), + )); + } + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty() && !l.starts_with('@')) + .collect()) +} + +/// List active PF states (equivalent to `pfctl -s state`). +pub fn list_states() -> Result, Error> { + let output = Command::new("pfctl") + .args(["-s", "state"]) + .output() + .map_err(|e| Error::Command(format!("pfctl: {e}")))?; + if !output.status.success() { + return Err(Error::Command( + String::from_utf8_lossy(&output.stderr).into(), + )); + } + let mut states = Vec::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with("all") { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 4 { + states.push(PfState { + proto: parts[0].to_string(), + src: format!("{} {}", parts[1], parts.get(2).unwrap_or(&"")), + dst: format!("{} {}", parts[3], parts.get(4).unwrap_or(&"")), + state: parts.last().unwrap_or(&"").to_string(), + }); + } + } + Ok(states) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_rules_does_not_panic() { + // pfctl may not be present (Linux CI), but the function should not panic. + let _ = list_rules(); + let _ = list_states(); + } +} diff --git a/crates/colibri-zfs/Cargo.toml b/crates/colibri-zfs/Cargo.toml new file mode 100644 index 0000000..fe54c97 --- /dev/null +++ b/crates/colibri-zfs/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "colibri-zfs" +version.workspace = true +edition = "2021" +license = "MIT" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" diff --git a/crates/colibri-zfs/src/lib.rs b/crates/colibri-zfs/src/lib.rs new file mode 100644 index 0000000..fd782cb --- /dev/null +++ b/crates/colibri-zfs/src/lib.rs @@ -0,0 +1,234 @@ +//! colibri-zfs — structured ZFS snapshot lifecycle for Colibri agents. +//! +//! Wraps `zfs(8)` output into Rust structs so MCP tools can reason about +//! snapshots without shell parsing. ZFS operations run on the host — the +//! daemon runs as user `colibri`, which needs `zfs allow` delegations or +//! `sudo` for destructive operations. +//! +//! ```no_run +//! use colibri_zfs::Snapshot; +//! let snaps = Snapshot::list("zroot/home/clawdie").unwrap(); +//! for s in &snaps { +//! println!("{} {} {}", s.name, s.used_bytes, s.creation); +//! } +//! // Destroy snapshots older than 24h +//! let old: Vec<_> = snaps.into_iter() +//! .filter(|s| s.age_hours() > 24.0) +//! .collect(); +//! Snapshot::destroy_all(&old).unwrap(); +//! ``` + +use serde::{Deserialize, Serialize}; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// A single ZFS snapshot as reported by `zfs list -Hp -t snapshot`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Snapshot { + /// Full snapshot name, e.g. "zroot/home/clawdie@autosnap_2026-06-27_09:00:00_hourly" + pub name: String, + /// Dataset portion, e.g. "zroot/home/clawdie" + pub dataset: String, + /// Snapshot tag, e.g. "autosnap_2026-06-27_09:00:00_hourly" + pub tag: String, + /// Space used by this snapshot (bytes). Note: ZFS `used` includes + /// space uniquely held by this snapshot after accounting for clones + /// and child snapshots. + pub used_bytes: u64, + /// Space referenced (bytes) — total data accessible in this snapshot, + /// shared or not. + pub refer_bytes: u64, + /// Snapshot creation time as reported by ZFS. + pub creation: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("zfs command failed: {0}")] + Command(String), + #[error("parse error: {0}")] + Parse(String), +} + +impl Snapshot { + /// List all snapshots for a dataset and its children. + /// Uses `zfs list -Hp -t snapshot -o name,used,refer,creation -r `. + pub fn list(dataset: &str) -> Result, Error> { + let output = Command::new("zfs") + .args([ + "list", + "-Hp", + "-t", + "snapshot", + "-o", + "name,used,refer,creation", + "-r", + dataset, + ]) + .output() + .map_err(|e| Error::Command(format!("zfs list: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::Command(format!("zfs list failed: {stderr}"))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut snaps = Vec::new(); + for line in stdout.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let parts: Vec<&str> = line.splitn(4, '\t').collect(); + if parts.len() < 4 { + return Err(Error::Parse(format!("unexpected zfs output: {line}"))); + } + let full_name = parts[0].to_string(); + let (ds, tag) = full_name + .split_once('@') + .ok_or_else(|| Error::Parse(format!("no @ in snapshot name: {full_name}")))?; + snaps.push(Snapshot { + dataset: ds.to_string(), + tag: tag.to_string(), + name: full_name, + used_bytes: parts[1].parse().unwrap_or(0), + refer_bytes: parts[2].parse().unwrap_or(0), + creation: parts[3].to_string(), + }); + } + Ok(snaps) + } + + /// Destroy a single snapshot. Requires ZFS destroy permission. + pub fn destroy(&self) -> Result<(), Error> { + let output = Command::new("zfs") + .args(["destroy", &self.name]) + .output() + .map_err(|e| Error::Command(format!("zfs destroy {}: {e}", self.name)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::Command(format!("zfs destroy: {stderr}"))); + } + Ok(()) + } + + /// Destroy multiple snapshots. Each failure is collected and returned. + pub fn destroy_all(snaps: &[Snapshot]) -> Result<(), Error> { + let mut errors = Vec::new(); + for s in snaps { + if let Err(e) = s.destroy() { + errors.push(e.to_string()); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(Error::Command(errors.join("; "))) + } + } + + /// Age of the snapshot in hours, parsed from the creation timestamp. + /// Returns 0.0 if the timestamp cannot be parsed. + pub fn age_hours(&self) -> f64 { + parse_zfs_timestamp(&self.creation) + .and_then(|created| SystemTime::now().duration_since(created).ok()) + .map(|d| d.as_secs_f64() / 3600.0) + .unwrap_or(0.0) + } + + /// Filter to snapshots older than `hours`. + pub fn older_than(snaps: &[Snapshot], hours: f64) -> Vec { + snaps + .iter() + .filter(|s| s.age_hours() > hours) + .cloned() + .collect() + } +} + +/// Parse a ZFS timestamp like "Mon Jun 23 01:15 2026" into SystemTime. +/// ZFS outputs creation time in `date(1)` format when not using `-p`. +fn parse_zfs_timestamp(s: &str) -> Option { + // With -p flag, ZFS outputs Unix seconds (e.g. "1719270900") + if let Ok(secs) = s.parse::() { + return UNIX_EPOCH.checked_add(Duration::from_secs(secs)); + } + // Fallback: try standard date format + if let Ok(t) = chrono_parse(s) { + return Some(t); + } + None +} + +fn chrono_parse(_s: &str) -> Result { + // Avoid pulling in chrono as a dependency. If the seconds parse fails, + // we return 0.0 hours — the age_hours() caller handles this gracefully. + Err(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_parse_basic() { + let line = "zroot/home@test\t1024000\t2048000\t1719270900"; + // Parse is tested indirectly via Snapshot::list which parses lines. + // Unit test validates age_hours with a known timestamp. + let s = Snapshot { + name: "zroot/home@test".into(), + dataset: "zroot/home".into(), + tag: "test".into(), + used_bytes: 1_024_000, + refer_bytes: 2_048_000, + creation: "1719270900".into(), + }; + // This timestamp is in the past, so age should be positive. + assert!(s.age_hours() > 0.0); + } + + #[test] + fn older_than_filter() { + let ancient = Snapshot { + name: "pool@old".into(), + dataset: "pool".into(), + tag: "old".into(), + used_bytes: 1000, + refer_bytes: 2000, + creation: "1".into(), // epoch start — very old + }; + let recent = Snapshot { + name: "pool@new".into(), + dataset: "pool".into(), + tag: "new".into(), + used_bytes: 1000, + refer_bytes: 2000, + creation: format!( + "{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + ), + }; + let all = vec![ancient.clone(), recent]; + let old = Snapshot::older_than(&all, 1.0); + assert_eq!(old.len(), 1); + assert_eq!(old[0].tag, "old"); + } + + #[test] + fn destroy_nonexistent_is_error() { + let s = Snapshot { + name: "zroot/nonexistent@definitely_not_real_2026".into(), + dataset: "zroot/nonexistent".into(), + tag: "definitely_not_real_2026".into(), + used_bytes: 0, + refer_bytes: 0, + creation: "0".into(), + }; + assert!(s.destroy().is_err()); + } +}