diff --git a/desktop/build.sh b/desktop/build.sh index 74fb124..eb75b52 100755 --- a/desktop/build.sh +++ b/desktop/build.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash # loci wizard — build script -# v0.2.0 # ───────────────────────────────────────────────────────────────────────────── # Usage: # ./build.sh — macOS universal .dmg (arm64 + x86_64) @@ -9,15 +8,15 @@ # # Output (macOS): # src-tauri/target/universal-apple-darwin/release/bundle/dmg/ -# loci wizard_0.2.0_universal.dmg +# loci wizard_${VERSION}_universal.dmg # # Output (Windows, run on Windows or CI): # src-tauri/target/release/bundle/nsis/ -# loci wizard_0.2.0_x64-setup.exe +# loci wizard_${VERSION}_x64-setup.exe # ───────────────────────────────────────────────────────────────────────────── set -e -VERSION="0.2.0" +VERSION="0.6.0-beta" APP="loci wizard" echo "" @@ -40,12 +39,18 @@ case "${1:-}" in echo " ╔══════════════════════════════════════════╗" echo " ║ Windows target must run on Windows or ║" echo " ║ a GitHub Actions windows-latest runner. ║" - echo " ║ See: .github/workflows/release.yml ║" + echo " ║ Run this script on a Windows runner. ║" echo " ╚══════════════════════════════════════════╝" npm run tauri:build -- --target x86_64-pc-windows-msvc echo "" echo " ✦ Windows installer:" find src-tauri/target -name "*.exe" -path "*/nsis/*" 2>/dev/null || echo " (run on Windows to generate)" + echo "" + echo " ─────────────────────────────────────────" + echo " This build is UNSIGNED. Authenticode signing is" + echo " required before public distribution. See:" + echo " https://tauri.app/distribute/sign/windows/" + echo " ─────────────────────────────────────────" ;; *) @@ -63,8 +68,8 @@ case "${1:-}" in find src-tauri/target -name "*.dmg" 2>/dev/null | head -3 echo "" echo " ─────────────────────────────────────────" - echo " Gatekeeper note: notarization required" - echo " for public distribution. See:" + echo " This build is UNSIGNED. Notarization is required" + echo " before public distribution. See:" echo " https://tauri.app/distribute/sign/apple/" echo " ─────────────────────────────────────────" ;; diff --git a/desktop/src-tauri/src/inference/mod.rs b/desktop/src-tauri/src/inference/mod.rs index 394abf3..a1ae946 100644 --- a/desktop/src-tauri/src/inference/mod.rs +++ b/desktop/src-tauri/src/inference/mod.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use std::ffi::OsString; use std::fs::{create_dir_all, OpenOptions}; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// A local-first inference provider. One-shot completion plus a cheap probe. pub trait InferenceBackend { @@ -183,14 +183,7 @@ impl InferenceBackend for OllamaBackend { } fn egress_class(&self) -> EgressClass { - // Local means loopback ONLY. A permitted non-loopback host (a Tailscale - // peer) physically leaves this machine, so it is LocalNetwork, not Local. - match self.base.host_str() { - Some("localhost") | Some("127.0.0.1") | Some("::1") | Some("[::1]") => { - EgressClass::Local - } - _ => EgressClass::LocalNetwork, - } + egress_class_for_host(self.base.host_str()) } fn dest_host(&self) -> String { @@ -301,6 +294,46 @@ pub fn egress_wal_path() -> PathBuf { .join("egress.jsonl") } +/// Classify a host for egress: loopback = Local; any permitted non-loopback host +/// (e.g. a Tailscale peer) leaves the machine, so it is LocalNetwork. +pub fn egress_class_for_host(host: Option<&str>) -> EgressClass { + match host { + Some("localhost") | Some("127.0.0.1") | Some("::1") | Some("[::1]") => EgressClass::Local, + _ => EgressClass::LocalNetwork, + } +} + +/// The single recording path for EVERY egress site (the decorator + any raw +/// command). Honors the LOCI_WAL_DISABLED kill switch (stamps egress.disabled) +/// and, on write failure, stamps egress.degraded — so a gap is never invisible. +pub fn note_egress(wal_path: &Path, event_type: &str, egress_class: EgressClass, dest_host: &str, payload: &[u8]) { + if let Some(parent) = wal_path.parent() { + let _ = create_dir_all(parent); + } + if std::env::var_os("LOCI_WAL_DISABLED").is_some() { + let _ = std::fs::write( + wal_path.with_file_name("egress.disabled"), + chrono::Utc::now().to_rfc3339(), + ); + return; + } + if let Err(e) = loci_wal::record_egress( + wal_path, + chrono::Utc::now().to_rfc3339(), + event_type, + egress_class, + dest_host, + payload, + None, + ) { + let _ = OpenOptions::new() + .create(true) + .append(true) + .open(wal_path.with_file_name("egress.degraded")) + .and_then(|mut f| writeln!(f, "{} {}", chrono::Utc::now().to_rfc3339(), e)); + } +} + /// Decorator that records an egress frame on every `chat`, then delegates. /// Not built directly by call sites — obtain a backend via `ollama()` / `claude()`, /// the ONLY constructors, so no site can egress content without this chokepoint. @@ -310,40 +343,18 @@ pub struct EgressLogged { } impl EgressLogged { - pub fn new(inner: B, wal_path: PathBuf) -> Self { + pub(crate) fn new(inner: B, wal_path: PathBuf) -> Self { Self { inner, wal_path } } fn record(&self, prompt: &str) { - if let Some(parent) = self.wal_path.parent() { - let _ = create_dir_all(parent); - } - // Kill switch: LOCI_WAL_DISABLED turns the writer off without a rebuild — - // but it must NOT let the receipt read clean while logging is off. Stamp a - // marker `loci audit` warns on, so a disabled window is never invisible. - if std::env::var_os("LOCI_WAL_DISABLED").is_some() { - let marker = self.wal_path.with_file_name("egress.disabled"); - let _ = std::fs::write(&marker, chrono::Utc::now().to_rfc3339()); - return; - } - if let Err(e) = loci_wal::record_egress( + note_egress( &self.wal_path, - chrono::Utc::now().to_rfc3339(), "chat", self.inner.egress_class(), &self.inner.dest_host(), prompt.as_bytes(), - None, - ) { - // A dropped write leaves NO gap in the chain, so it is invisible to - // `loci audit`. Surface it: a degraded marker the audit reads + warns on. - let marker = self.wal_path.with_file_name("egress.degraded"); - let _ = OpenOptions::new() - .create(true) - .append(true) - .open(&marker) - .and_then(|mut f| writeln!(f, "{} {}", chrono::Utc::now().to_rfc3339(), e)); - } + ); } } diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index f4bb320..77b611d 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -178,10 +178,11 @@ fn validate_ollama_url(raw: &str) -> Result { let is_localhost = host == "localhost" || host == "127.0.0.1" || host == "[::1]" || host == "::1"; let is_tailscale = host.starts_with("100.") && { - // Validate it's a real Tailscale CGNAT address (100.64.0.0/10) + // Validate it's a real Tailscale CGNAT address (100.64.0.0/10). + // The second octet is bounded 64..=127; 128+ is public IP space, not CGNAT. let parts: Vec<&str> = host.splitn(4, '.').collect(); if parts.len() >= 2 { - parts[1].parse::().map(|n| n >= 64).unwrap_or(false) + parts[1].parse::().map(|n| (64..=127).contains(&n)).unwrap_or(false) } else { false } @@ -259,6 +260,15 @@ async fn call_ollama( let base = validate_ollama_url(raw)?; let url = base.join("/v1/chat/completions").map_err(|e| e.to_string())?; + // Record the egress even on this raw path so it is never invisible to `loci audit`. + inference::note_egress( + &inference::egress_wal_path(), + "chat", + inference::egress_class_for_host(base.host_str()), + base.host_str().unwrap_or("localhost"), + prompt.as_bytes(), + ); + let body = OllamaChatRequest { model, messages: vec![OllamaChatMessage { @@ -304,6 +314,15 @@ async fn embed_text( let base = validate_ollama_url(raw)?; let url = base.join("/api/embeddings").map_err(|e| e.to_string())?; + // Embeddings leave the machine too — record the egress on this raw path. + inference::note_egress( + &inference::egress_wal_path(), + "embed", + inference::egress_class_for_host(base.host_str()), + base.host_str().unwrap_or("localhost"), + text.as_bytes(), + ); + let body = OllamaEmbedRequest { model: model.unwrap_or_else(|| "nomic-embed-text".to_string()), prompt: text, @@ -3227,6 +3246,57 @@ mod csp_gate_tests { } } +#[cfg(test)] +mod url_validation_tests { + use super::*; + + fn host_of(raw: &str) -> Option { + validate_ollama_url(raw) + .ok() + .map(|u| u.host_str().unwrap_or("").to_string()) + } + + #[test] + fn accepts_loopback_and_cgnat_bounds() { + // Loopback forms. + assert!(validate_ollama_url("http://localhost:11434").is_ok()); + assert!(validate_ollama_url("http://127.0.0.1:11434").is_ok()); + assert!(validate_ollama_url("http://[::1]:11434").is_ok()); + // Tailscale CGNAT (100.64.0.0/10) — both ends of the real range. + assert!(validate_ollama_url("http://100.64.0.1:11434").is_ok()); + assert!(validate_ollama_url("http://100.127.255.255:11434").is_ok()); + // HTTPS is accepted for permitted hosts (e.g. Tailscale HTTPS). + assert!(validate_ollama_url("https://100.64.0.1:11434").is_ok()); + } + + #[test] + fn rejects_public_space_above_cgnat() { + // 100.128.0.0–100.255.255.255 is public IP space, NOT CGNAT — must reject. + // This is the SSRF regression the bounded second-octet check closes. + assert!(validate_ollama_url("http://100.128.0.1:11434").is_err()); + assert!(validate_ollama_url("http://100.200.5.5:11434").is_err()); + assert!(validate_ollama_url("http://100.255.255.255:11434").is_err()); + // Below the range too (100.0–100.63 is also public). + assert!(validate_ollama_url("http://100.0.0.1:11434").is_err()); + assert!(validate_ollama_url("http://100.63.255.255:11434").is_err()); + } + + #[test] + fn rejects_arbitrary_and_bad_schemes() { + assert!(validate_ollama_url("http://example.com:11434").is_err()); + assert!(validate_ollama_url("http://10.0.0.1:11434").is_err()); + assert!(validate_ollama_url("http://192.168.1.10:11434").is_err()); + assert!(validate_ollama_url("ftp://100.64.0.1:11434").is_err()); + assert!(validate_ollama_url("file:///etc/hosts").is_err()); + } + + #[test] + fn permitted_cgnat_host_survives_round_trip() { + assert_eq!(host_of("http://100.64.0.1:11434").as_deref(), Some("100.64.0.1")); + assert_eq!(host_of("http://100.128.0.1:11434"), None); + } +} + fn main() { let ollama_client = OllamaState { client: reqwest::Client::builder()