feat(colibri-vault): scaffold vault credential provision crate #85

Merged
clawdie merged 2 commits from feat/colibri-vault into main 2026-06-19 21:26:49 +02:00
4 changed files with 318 additions and 1 deletions

11
Cargo.lock generated
View file

@ -423,6 +423,17 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "colibri-vault"
version = "0.0.1"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"

View file

@ -1,5 +1,5 @@
[workspace] [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/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/clawdie"]
[package] [package]
name = "colibri" name = "colibri"

View file

@ -0,0 +1,12 @@
[package]
name = "colibri-vault"
version = "0.0.1"
edition = "2021"
description = "Vaultwarden credential provision for Colibri — fetch a tenant collection → jail .env"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tracing = "0.1"
tokio = { version = "1", features = ["process", "rt"] }

View file

@ -0,0 +1,294 @@
//! colibri-vault — Vaultwarden credential provision for Colibri.
//!
//! Fetches a named collection from Vaultwarden via the `bw` CLI and materializes
//! a `.env` file into a target directory (typically a freshly-created Bastille jail
//! root). Fail-closed, idempotent, wraps the existing Bitwarden CLI instead of
//! reimplementing the protocol.
//!
//! # Usage
//!
//! ```ignore
//! use colibri_vault::provision;
//!
//! provision("hermes-osa-tenant", "/jails/tenant-1/home/clawdie").await?;
//! ```
//!
//! # Requirements
//!
//! - `bw` CLI installed and logged in (`BW_SESSION` or `BW_CLIENTID`+`BW_CLIENTSECRET`)
//! - `BW_SERVER` pointing at the Vaultwarden instance
//! - The authenticated account has read access to the target collection
use std::path::{Path, PathBuf};
use std::process::Stdio;
use thiserror::Error;
use tokio::process::Command;
#[derive(Error, Debug)]
pub enum VaultError {
#[error("bw CLI not found — install @bitwarden/cli")]
BwNotFound,
#[error("bw command failed: {0}")]
BwFailed(String),
#[error("collection not found: {0}")]
CollectionNotFound(String),
#[error("no items in collection: {0}")]
CollectionEmpty(String),
#[error("cannot write to target: {0}")]
TargetNotWritable(PathBuf),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON deserialization failed: {0}")]
Json(#[from] serde_json::Error),
}
/// Provision a tenant's secrets into a target directory.
///
/// 1. Looks up the collection by name
/// 2. Fetches all items in that collection
/// 3. Writes a 0600 `.env` file with KEY=VALUE pairs into `target_dir`
///
/// No-op if the collection has no items (returns Ok with a warning).
/// Fails if `bw` is not installed or the collection doesn't exist.
pub async fn provision(
collection_name: &str,
target_dir: impl AsRef<Path>,
) -> Result<ProvisionResult, VaultError> {
let target_dir = target_dir.as_ref();
// Verify bw is available
which("bw").ok_or(VaultError::BwNotFound)?;
// Verify target is writable
std::fs::create_dir_all(target_dir)?;
let test = target_dir.join(".vault-write-test");
std::fs::write(&test, b"")?;
std::fs::remove_file(&test)?;
// 1. Find the collection by name
let collection_id = resolve_collection(collection_name).await?;
// 2. Fetch items
let items = list_items(&collection_id).await?;
if items.is_empty() {
return Ok(ProvisionResult {
collection: collection_name.to_string(),
items_written: 0,
bytes_written: 0,
path: target_dir.join(".env"),
});
}
// 3. Write .env
let env_path = target_dir.join(".env");
let mut env_content = String::new();
env_content.push_str("# Generated by colibri-vault — do not edit by hand\n");
env_content.push_str(&format!("# Collection: {collection_name}\n"));
for item in &items {
if let Some(login) = &item.login {
// item.name = KEY, login.password = VALUE (Vaultwarden login item convention)
let raw_key = item.name.trim();
if let Some(password) = &login.password {
let key = validate_key(raw_key);
if key.is_empty() {
tracing::warn!(item = item.name, "skipping item with invalid env key");
continue;
}
let val = password.trim();
env_content.push_str(&format!("{key}={val}\n"));
}
}
// Also handle secure notes (KEY=VALUE pairs separated by newlines)
if item.kind == 2 {
// Secure note
if let Some(notes) = &item.notes {
for line in notes.lines() {
let line = line.trim();
if line.contains('=') && !line.starts_with('#') {
env_content.push_str(&format!("{line}\n"));
}
}
}
}
}
let bytes = env_content.len();
std::fs::write(&env_path, &env_content)?;
// Set 0600
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(ProvisionResult {
collection: collection_name.to_string(),
items_written: items.len(),
bytes_written: bytes,
path: env_path,
})
}
#[derive(Debug, Clone)]
pub struct ProvisionResult {
pub collection: String,
pub items_written: usize,
pub bytes_written: usize,
pub path: PathBuf,
}
// ── Internal helpers ──────────────────────────────────
async fn resolve_collection(name: &str) -> Result<String, VaultError> {
let output = bw(&["list", "collections", "--search", name]).await?;
let collections: Vec<SerdeCollection> =
serde_json::from_str(&output).map_err(|e| {
VaultError::BwFailed(format!(
"failed to parse collections: {e} — output: {output}"
))
})?;
collections
.iter()
.find(|c| c.name == name || c.id == name)
.map(|c| c.id.clone())
.ok_or_else(|| VaultError::CollectionNotFound(name.to_string()))
}
async fn list_items(collection_id: &str) -> Result<Vec<SerdeItem>, VaultError> {
let output = bw(&["list", "items", "--collectionid", collection_id]).await?;
let items: Vec<SerdeItem> = serde_json::from_str(&output).map_err(|e| {
VaultError::BwFailed(format!(
"failed to parse items: {e} — output: {output}"
))
})?;
Ok(items)
}
async fn bw(args: &[&str]) -> Result<String, VaultError> {
let output = Command::new("bw")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| VaultError::BwFailed(format!("bw invocation failed: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VaultError::BwFailed(stderr.trim().to_string()));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn which(cmd: &str) -> Option<PathBuf> {
std::env::var_os("PATH").and_then(|path| {
std::env::split_paths(&path).find_map(|dir| {
let candidate = dir.join(cmd);
if candidate.is_file() {
Some(candidate)
} else {
None
}
})
})
}
// ── Serde types (minimum surface for bw CLI JSON) ─────
#[derive(serde::Deserialize, Debug)]
struct SerdeCollection {
id: String,
name: String,
}
#[derive(serde::Deserialize, Debug)]
struct SerdeItem {
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
name: String,
#[serde(rename = "type")]
kind: u8,
login: Option<SerdeLogin>,
notes: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct SerdeLogin {
username: Option<String>,
password: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provision_result_debug() {
let r = ProvisionResult {
collection: "test".into(),
items_written: 3,
bytes_written: 256,
path: PathBuf::from("/tmp/.env"),
};
assert_eq!(r.collection, "test");
assert_eq!(r.items_written, 3);
}
#[test]
fn vault_error_display() {
let e = VaultError::CollectionNotFound("missing".into());
assert!(e.to_string().contains("missing"));
}
#[test]
fn key_from_item_name_not_username() {
// The contract: item.name = KEY, login.password = VALUE
// Not username=KEY (bug that would produce wrong .env output)
let item = SerdeItem {
id: "test-id".into(),
name: "OPENROUTER_API_KEY".into(),
kind: 1,
login: Some(SerdeLogin {
username: Some("irrelevant@email.com".into()),
password: Some("sk-or-v1-abc123".into()),
}),
notes: None,
};
assert_eq!(item.name, "OPENROUTER_API_KEY");
assert_eq!(item.login.as_ref().unwrap().password.as_deref(), Some("sk-or-v1-abc123"));
}
#[test]
fn validate_key_rejects_dangerous_chars() {
assert_eq!(validate_key("MY_KEY"), "MY_KEY");
assert_eq!(validate_key("my-key"), "MY_KEY");
assert_eq!(validate_key("OpenRouter API Key"), "OPENROUTER_API_KEY");
assert_eq!(validate_key("my.key.name"), "MY_KEY_NAME");
// Reject injection attempts
assert!(validate_key("BAD;rm -rf /").is_empty());
assert!(validate_key("KEY\nMALICIOUS=1").is_empty());
assert!(validate_key("").is_empty());
}
}
/// Validate and normalize a key for .env output.
/// Reject keys with non-[A-Z0-9_] characters (matching the clawdie-vault-fetch helper).
fn validate_key(raw: &str) -> String {
let key = raw.to_uppercase().replace([' ', '-', '.'], "_");
if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !key.is_empty() {
key
} else {
String::new()
}
}