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:
+139
-20
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user