Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions desktop/build.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
# loci wizard — build script
# v0.2.0
# ─────────────────────────────────────────────────────────────────────────────
# Usage:
# ./build.sh — macOS universal .dmg (arm64 + x86_64)
Expand All @@ -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 ""
Expand All @@ -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 " ─────────────────────────────────────────"
;;

*)
Expand All @@ -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 " ─────────────────────────────────────────"
;;
Expand Down
79 changes: 45 additions & 34 deletions desktop/src-tauri/src/inference/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -310,40 +343,18 @@ pub struct EgressLogged<B: InferenceBackend> {
}

impl<B: InferenceBackend> EgressLogged<B> {
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));
}
);
}
}

Expand Down
74 changes: 72 additions & 2 deletions desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,11 @@ fn validate_ollama_url(raw: &str) -> Result<url::Url, String> {

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::<u8>().map(|n| n >= 64).unwrap_or(false)
parts[1].parse::<u8>().map(|n| (64..=127).contains(&n)).unwrap_or(false)
} else {
false
}
Expand Down Expand Up @@ -256,6 +257,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 {
Expand Down Expand Up @@ -301,6 +311,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,
Expand Down Expand Up @@ -3107,6 +3126,57 @@ mod cockpit_tests {
}
}

#[cfg(test)]
mod url_validation_tests {
use super::*;

fn host_of(raw: &str) -> Option<String> {
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()
Expand Down
Loading