feat(llm): set_llm_provider stores Anthropic key in credential store, extends egress allowlist (T10.2/M3.2)

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).
This commit is contained in:
iamdoubz
2026-07-07 07:50:15 -05:00
parent e7925fc3ad
commit d73af49742
+139 -20
View File
@@ -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<Box<dyn crate::llm:
model: settings.llm_model.clone(),
credential_ref: None, // local, unauthenticated custom endpoint (ADR-0007); Phase 10a sets this for hosted gateways
})),
// Hosted (ADR-0011, M3.1/M3.2): the key is never read from settings —
// only `set_llm_provider` writes it, straight to the OS credential
// store via `ANTHROPIC_CREDENTIAL_REF`. Selecting "anthropic" here is
// itself the "configured" signal that puts api.anthropic.com on the
// egress allowlist (see `privacy_self_check_json` below, which reuses
// this same `is_local()` check unconditionally for every provider —
// no anthropic-specific branch needed there).
"anthropic" => 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<serde_json::Value> {
}
#[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<String>,
pub model: Option<String>,
/// 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<String>,
}
/// 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<String>,
model: Option<String>,
) {
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();