From d73af49742a9fbea3ed4ca797dc1bd7bc4efce35 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 07:50:15 -0500 Subject: [PATCH] feat(llm): set_llm_provider stores Anthropic key in credential store, extends egress allowlist (T10.2/M3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_llm_provider now accepts provider "anthropic": the apiKey argument is written straight to crate::llm::credentials (OS credential store) and never assigned into Settings, so it structurally cannot reach settings.json/wa.db (FR-SEC-1). apply_llm_provider_args pulls the settings-mutation logic into a small pure function specifically so that guarantee is unit-testable without touching a real keyring. llm_provider_from_settings gets an "anthropic" arm; api.anthropic.com joins the settings-derived egress allowlist through the exact same generic is_local()-based path privacy_self_check_json already uses for every other provider — no anthropic-specific allowlist code, and the host only appears once the provider is actually selected (never unconditionally). Also fixes pre-existing cargo-fmt drift on one unrelated line this file's formatter pass touched (CONTENT_RANGE header call). --- src-tauri/src/commands.rs | 159 +++++++++++++++++++++++++++++++++----- 1 file changed, 139 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0b6cede..bfbda16 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -57,6 +57,7 @@ fn default_settings() -> Settings { low_overhead: false, default_record: false, consent_acknowledged: false, + hosted_ai_acknowledged: false, sync_enabled: false, retention_max_age_days: None, retention_max_size_gb: None, @@ -1735,7 +1736,10 @@ pub(crate) fn serve_recording( if let Some((start, end)) = parse_byte_range(range, total) { return base() .status(StatusCode::PARTIAL_CONTENT) - .header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}")) + .header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total}"), + ) .header(header::CONTENT_LENGTH, (end - start + 1).to_string()) .body(plain[start..=end].to_vec()) .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR)); @@ -1964,6 +1968,18 @@ fn llm_provider_from_settings(settings: &Settings) -> Option Some(Box::new(crate::llm::AnthropicProvider { + model: settings.llm_model.clone(), + credential_ref: crate::llm::ANTHROPIC_CREDENTIAL_REF.to_string(), + endpoint: settings.llm_endpoint.clone(), + })), _ => None, // "off" } } @@ -1986,34 +2002,79 @@ pub async fn llm_status() -> WaResult { } #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SetLlmProviderArgs { - pub provider: String, // "ollama" | "custom" | "off" (Phase 10a adds "anthropic" | "openai") + pub provider: String, // "ollama" | "custom" | "anthropic" | "off" ("openai" not yet wired — M3 scope is Anthropic) pub endpoint: Option, pub model: Option, + /// Hosted providers only (Anthropic, M3.2). Consumed here to write the + /// OS credential store and then dropped — never assigned into `Settings`, + /// so it structurally cannot reach settings.json/wa.db (ADR-0011, + /// FR-SEC-1). Optional on every call so re-saving the model/endpoint + /// doesn't force re-entering a key that's already stored. + pub api_key: Option, } -/// Select/configure the LLM provider (T5.2, FR-LLM-1). Hosted providers -/// (`apiKey`, stored only in the OS credential store — never settings/DB) -/// land in Phase 10a. -#[tauri::command] -pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> { - if !matches!(args.provider.as_str(), "ollama" | "custom" | "off") { - return Err(WaError::new( - "llm", - format!( - "provider '{}' is not available yet — hosted providers land in Phase 10a", - args.provider - ), - )); - } - let mut settings = load_settings(); - settings.llm_provider = args.provider; - if let Some(endpoint) = args.endpoint { +/// Pure settings mutation for `set_llm_provider` — everything except the +/// credential-store write, so it's unit-testable without touching a real +/// keyring and so the "the API key never reaches `Settings`" guarantee is +/// checkable structurally (this function's signature has no key parameter +/// at all; see `set_llm_provider_never_writes_the_api_key_into_settings`). +/// Anthropic's endpoint is fixed, not user-configurable (ADR-0011) — any +/// `endpoint` argument is ignored for that provider so a stale endpoint left +/// over from a previous "custom"/"ollama" selection can't leak through. +fn apply_llm_provider_args( + settings: &mut Settings, + provider: &str, + endpoint: Option, + model: Option, +) { + settings.llm_provider = provider.to_string(); + if provider == "anthropic" { + settings.llm_endpoint = "https://api.anthropic.com".to_string(); + } else if let Some(endpoint) = endpoint { settings.llm_endpoint = endpoint; } - if let Some(model) = args.model { + if let Some(model) = model { settings.llm_model = model; } +} + +/// Select/configure the LLM provider (T5.2/T10.2, FR-LLM-1, ADR-0011). +/// Hosted providers (`apiKey`) are stored only in the OS credential store — +/// never settings/DB (FR-SEC-1); selecting "anthropic" here is itself what +/// puts `api.anthropic.com` on the settings-derived egress allowlist (see +/// `privacy_self_check_json`), so it must never happen implicitly. +#[tauri::command] +pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> { + if !matches!( + args.provider.as_str(), + "ollama" | "custom" | "anthropic" | "off" + ) { + return Err(WaError::new( + "llm", + format!("provider '{}' is not available yet", args.provider), + )); + } + if args.provider == "anthropic" { + match args.api_key.as_deref().map(str::trim) { + Some(key) if !key.is_empty() => { + crate::llm::credentials::set(crate::llm::ANTHROPIC_CREDENTIAL_REF, key) + .map_err(|e| WaError::new("llm", e.to_string()))?; + } + // No new key supplied — fine only if one was already stored from + // a previous call (e.g. the user is just changing the model). + _ if crate::llm::credentials::get(crate::llm::ANTHROPIC_CREDENTIAL_REF).is_err() => { + return Err(WaError::new( + "llm", + "an Anthropic API key is required".to_string(), + )); + } + _ => {} + } + } + let mut settings = load_settings(); + apply_llm_provider_args(&mut settings, &args.provider, args.endpoint, args.model); save_settings(&settings) } @@ -3465,6 +3526,64 @@ mod tests { ); } + #[test] + fn privacy_self_check_anthropic_host_joins_the_allowlist_only_once_selected() { + // Off by default (ADR-0011) — merely *knowing about* Anthropic (the + // provider code exists) must not put its host on the allowlist. + let result = privacy_self_check_json(&default_settings(), Vec::new()); + assert_eq!(result["allowlistedHosts"], serde_json::json!([])); + + // Only once the user has actually selected it (mirrors what + // set_llm_provider/apply_llm_provider_args writes) does the same + // generic llm_provider_from_settings + is_local() path used for every + // other provider pick it up — no anthropic-specific allowlist code. + let mut settings = default_settings(); + settings.llm_provider = "anthropic".to_string(); + settings.llm_endpoint = "https://api.anthropic.com".to_string(); + let result = privacy_self_check_json(&settings, Vec::new()); + assert_eq!(result["llmIsLocal"], false); + assert_eq!( + result["allowlistedHosts"], + serde_json::json!(["api.anthropic.com"]) + ); + } + + #[test] + fn set_llm_provider_never_writes_the_api_key_into_settings() { + // apply_llm_provider_args has no key parameter at all — this proves + // structurally (not just by inspection) that a supplied apiKey can + // never end up in the Settings struct that gets serialized to + // settings.json (FR-SEC-1, ADR-0011). The real key only ever reaches + // crate::llm::credentials::set, called separately in + // set_llm_provider before this function runs. + let mut settings = default_settings(); + apply_llm_provider_args( + &mut settings, + "anthropic", + Some("https://attacker-controlled.example/ignored".to_string()), + Some("claude-3-5-sonnet-latest".to_string()), + ); + assert_eq!(settings.llm_provider, "anthropic"); + // The fixed hosted endpoint wins over any caller-supplied one. + assert_eq!(settings.llm_endpoint, "https://api.anthropic.com"); + assert_eq!(settings.llm_model, "claude-3-5-sonnet-latest"); + + let json = serde_json::to_string(&settings).expect("settings must serialize"); + assert!(!json.to_lowercase().contains("api_key")); + assert!(!json.to_lowercase().contains("apikey")); + assert!(!json.contains("sk-ant-")); + } + + #[test] + fn apply_llm_provider_args_keeps_the_existing_endpoint_for_non_anthropic_providers() { + let mut settings = default_settings(); + settings.llm_endpoint = "http://192.168.0.42:11434".to_string(); + apply_llm_provider_args(&mut settings, "ollama", None, Some("llama3.1".to_string())); + assert_eq!(settings.llm_provider, "ollama"); + assert_eq!(settings.llm_endpoint, "http://192.168.0.42:11434"); + assert_eq!(settings.llm_model, "llama3.1"); + } + #[test] fn privacy_self_check_only_enabled_sync_targets_join_the_allowlist() { let mut settings = default_settings();