From df4d4fb6ab4f77a112b1a2cb69cdf65f76e45935 Mon Sep 17 00:00:00 2001 From: Hux <156232898+huximaxi@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:27:07 +0200 Subject: [PATCH 1/4] egress: record every egress site through one recorder Close the two dormant raw-egress paths (call_ollama, embed_text) that egressed content with no WAL frame, and unify recording: - note_egress() is the single recording path: honors LOCI_WAL_DISABLED (stamps egress.disabled) and stamps egress.degraded on write failure. The decorator + call_ollama + embed_text all record through it. - egress_class_for_host() shared by the backend and the raw paths. - EgressLogged::new is now pub(crate) (defense-in-depth; the ollama()/claude() factories are the only public constructors). --- desktop/src-tauri/src/inference/mod.rs | 79 +++++++++++++++----------- desktop/src-tauri/src/main.rs | 18 ++++++ 2 files changed, 63 insertions(+), 34 deletions(-) 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..3087295 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -259,6 +259,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 +313,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, From 57594126bb27d9082aeb3e5b45d50e44b4af43a0 Mon Sep 17 00:00:00 2001 From: Hux <156232898+huximaxi@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:27:07 +0200 Subject: [PATCH 2/4] build: fix stale version + dead workflow reference - VERSION 0.2.0 -> 0.6.0-beta (matches tauri.conf.json) - drop the dead .github/workflows/release.yml reference - state plainly the build is UNSIGNED (notarization wiring is a follow-up) --- desktop/build.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/build.sh b/desktop/build.sh index 74fb124..2cc457f 100755 --- a/desktop/build.sh +++ b/desktop/build.sh @@ -17,7 +17,7 @@ # ───────────────────────────────────────────────────────────────────────────── set -e -VERSION="0.2.0" +VERSION="0.6.0-beta" APP="loci wizard" echo "" @@ -40,7 +40,7 @@ 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 "" @@ -63,8 +63,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 " ─────────────────────────────────────────" ;; From 7cdfddc4a12c364462662c161994d9e48d4fbe19 Mon Sep 17 00:00:00 2001 From: Hux <156232898+huximaxi@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:21:36 +0200 Subject: [PATCH 3/4] fix(desktop): bound Tailscale CGNAT check to 100.64.0.0/10 validate_ollama_url accepted any 100.x host whose second octet was >= 64, which also permits 100.128.0.0 through 100.255.255.255 (public IP space) despite the comment and error message both claiming the CGNAT range is 100.64 to 100.127. A base_url pointing at a routable public host in 100.128+ passed the SSRF gate and was classified as LocalNetwork egress. Bound the second octet to 64..=127. Add url_validation_tests covering the CGNAT edges, public space above and below the range, and non-permitted hosts and schemes. --- desktop/src-tauri/src/main.rs | 56 +++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3087295..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 } @@ -3245,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() From b50116d600a132883ff0eeeea6858c4e24a35451 Mon Sep 17 00:00:00 2001 From: Daniel Nemet <156232898+huximaxi@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:36:33 +0200 Subject: [PATCH 4/4] fix(build): state UNSIGNED on the Windows build path too The macOS build path already prints an unsigned-binary notice before notarization; the --windows NSIS path had none, so a user who only ever cross-builds for Windows got no signing-risk warning at all. Also drops the stale "v0.2.0" references in the header/output-path comments now that VERSION is 0.6.0-beta. --- desktop/build.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/desktop/build.sh b/desktop/build.sh index 2cc457f..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,11 +8,11 @@ # # 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 @@ -46,6 +45,12 @@ case "${1:-}" in 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 " ─────────────────────────────────────────" ;; *)