feat: add colibri-zfs, colibri-pf crates + MCP tools + wiki tools

New crates:
- colibri-zfs: Snapshot::list(), destroy(), destroy_all(), older_than()
  Structured ZFS snapshot lifecycle without shell parsing
- colibri-pf: list_rules(), list_states()
  Structured PF firewall state inspection

New MCP tools (6):
- colibri_zfs_list_snapshots: list snapshots for a dataset
- colibri_zfs_destroy_snapshot: destroy a snapshot by name
- colibri_pf_list_rules: active PF firewall rules
- colibri_pf_list_states: PF state table entries
- colibri_wiki_search: search wiki pages with line excerpts
- colibri_wiki_page: read wiki page content in full

Tool count: 12 → 18
This commit is contained in:
Sam & Claude 2026-06-27 14:49:46 +02:00
parent c05ca3a7b7
commit dd45200692
10 changed files with 544 additions and 4 deletions

20
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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",
])

View file

@ -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"

View file

@ -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<Value> {
}
})),
),
// ── 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<serde_json::Value> {
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<String> = 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<String> = 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<String> {
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()
}

View file

@ -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);
}

View file

@ -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"

View file

@ -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<Vec<String>, 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<Vec<PfState>, 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();
}
}

View file

@ -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"

View file

@ -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 <dataset>`.
pub fn list(dataset: &str) -> Result<Vec<Self>, 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<Snapshot> {
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<SystemTime> {
// With -p flag, ZFS outputs Unix seconds (e.g. "1719270900")
if let Ok(secs) = s.parse::<u64>() {
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<SystemTime, ()> {
// 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());
}
}