diff --git a/core/src/chat.rs b/core/src/chat.rs index 4ad8b57b..85c95ab5 100644 --- a/core/src/chat.rs +++ b/core/src/chat.rs @@ -302,18 +302,20 @@ pub fn get_recent_chat_history_with_compaction( .replace('\n', " "), ); } else if msg.role == "assistant" { + let assistant_a: String = msg + .content + .chars() + .take(80) + .collect::() + .trim() + .replace('\n', " "); if let Some(user_q) = current_user.take() { - let assistant_a: String = msg - .content - .chars() - .take(80) - .collect::() - .trim() - .replace('\n', " "); turn_summaries.push(format!( "- User: \"{}\" ➔ Assistant: \"{}\"", user_q, assistant_a )); + } else { + turn_summaries.push(format!("- Assistant: \"{}\"", assistant_a)); } } } diff --git a/core/src/embed/job.rs b/core/src/embed/job.rs index ced8dcaf..2c841103 100644 --- a/core/src/embed/job.rs +++ b/core/src/embed/job.rs @@ -95,7 +95,7 @@ pub fn embed_node_with_config( EmbedError::InferenceFailed(format!("database failed loading node {node_id}: {err}")) })?; - let Some((title, mut summary, mut detail, vault_id, sub_vault_id, privacy_tier)) = node else { + let Some((title, summary, detail, vault_id, sub_vault_id, privacy_tier)) = node else { return Ok(false); }; @@ -103,8 +103,6 @@ pub fn embed_node_with_config( EmbedError::InferenceFailed(format!("embedding settings read failed: {err}")) })?; - let mut title = title; - let effective_tier = crate::resolve_node_effective_privacy( conn, &vault_id, @@ -132,13 +130,6 @@ pub fn embed_node_with_config( } } - if crate::privacy::embedding_uses_stub(&effective_tier) { - let stub = crate::privacy::generate_pointer_stub(&title, node_id); - title = stub; - summary = String::new(); - detail = None; - } - let chunks = chunk_node_text(&title, &summary, detail.as_deref(), chunk_config); let computed_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); @@ -845,7 +836,7 @@ mod tests { Some("Changed Detail") )); - // Scenario 6: Privacy tier changes (open -> locked), text remains same + // Scenario 6: Privacy tier changes (open -> redacted), text remains same assert!(stored_text_columns_changed( "Title", "Summary", @@ -853,21 +844,21 @@ mod tests { false, false, "open", - "locked", + "redacted", "Title", "Summary", Some("Detail") )); - // Scenario 7: Privacy tier stays locked, text remains same + // Scenario 7: Privacy tier stays redacted, text remains same assert!(!stored_text_columns_changed( "Title", "Summary", Some("Detail"), false, false, - "locked", - "locked", + "redacted", + "redacted", "Title", "Summary", Some("Detail") @@ -947,33 +938,6 @@ mod tests { )? .is_none()); - // 2. Set up a remote destination and a locked node, with is_unlocked = false. - conn.execute( - "INSERT INTO vaults (id, name, privacy_tier) VALUES ('v_locked', 'Locked Vault', 'locked');", - [], - )?; - conn.execute( - "INSERT INTO nodes (id, vault_id, node_type, title, summary, detail, source, source_type, priority, meta) - VALUES ('n_locked', 'v_locked', 'concept', 'Secret Title', 'Secret Summary', 'Secret Detail', 'test', 'manual', '{}', '{}');", - [], - )?; - - // Try embedding n_locked when is_unlocked = false. - // It should embed the stub. - let is_embedded = embed_node(&mut conn, "n_locked", &engine, &cancel, false)?; - assert!(is_embedded); - assert_eq!(engine.calls(), 1); - - let inputs = match engine.inputs.lock() { - Ok(guard) => guard, - Err(_) => panic!("Failed to lock inputs"), - }; - assert_eq!(inputs.len(), 1); - let stub = crate::privacy::generate_pointer_stub("Secret Title", "n_locked"); - assert!(inputs[0].contains(&stub)); - assert!(!inputs[0].contains("Secret Summary")); - assert!(!inputs[0].contains("Secret Detail")); - // 3. Set up a redacted node and verify it is skipped and deletes stale vectors. conn.execute( "INSERT INTO vaults (id, name, privacy_tier) VALUES ('v_redacted', 'Redacted Vault', 'redacted');", diff --git a/core/src/embed/search.rs b/core/src/embed/search.rs index d545b2ce..b32172b5 100644 --- a/core/src/embed/search.rs +++ b/core/src/embed/search.rs @@ -99,7 +99,7 @@ pub fn expand_vault_scope( }) .map_err(|err| format!("Failed expanding vault scope: {err}"))?; - let mut expanded = HashSet::new(); + let mut expanded = vaults.clone(); for r in rows { let v_id = r.map_err(|err| format!("Failed reading expanded vault ID: {err}"))?; expanded.insert(v_id); @@ -140,9 +140,12 @@ pub fn find_top_n_similar( let (query_str, params_vec) = if let Some(vaults) = target_vaults { let placeholders = vec!["?"; vaults.len()].join(", "); let query = format!( - "SELECT n.id, n.vault_id, n.title, n.summary, n.node_type, ne.embedding + "SELECT n.id, n.vault_id, n.title, n.summary, n.node_type, ne.embedding, + n.sub_vault_id, + COALESCE(o.privacy_tier, n.privacy_tier) AS node_privacy_tier FROM node_embeddings ne JOIN nodes n ON ne.node_id = n.id + LEFT JOIN privacy_overrides o ON n.id = o.node_id LEFT JOIN vaults v ON n.vault_id = v.id LEFT JOIN vaults sv ON n.sub_vault_id = sv.id WHERE ne.chunk_type = 'primary' @@ -159,9 +162,12 @@ pub fn find_top_n_similar( p.extend(vaults.iter().cloned()); (query, p) } else { - let query = "SELECT n.id, n.vault_id, n.title, n.summary, n.node_type, ne.embedding + let query = "SELECT n.id, n.vault_id, n.title, n.summary, n.node_type, ne.embedding, + n.sub_vault_id, + COALESCE(o.privacy_tier, n.privacy_tier) AS node_privacy_tier FROM node_embeddings ne JOIN nodes n ON ne.node_id = n.id + LEFT JOIN privacy_overrides o ON n.id = o.node_id LEFT JOIN vaults v ON n.vault_id = v.id LEFT JOIN vaults sv ON n.sub_vault_id = sv.id WHERE ne.chunk_type = 'primary' @@ -194,13 +200,27 @@ pub fn find_top_n_similar( node_type: row.get(4)?, }; let embedding_bytes: Vec = row.get(5)?; - Ok((node, embedding_bytes)) + let sub_vault_id: Option = row.get(6)?; + let node_privacy_tier: Option = row.get(7)?; + Ok((node, embedding_bytes, sub_vault_id, node_privacy_tier)) }) .map_err(|err| format!("Failed to execute search query: {}", err))?; let mut candidates = Vec::new(); for row_res in rows { - let (node, bytes) = row_res.map_err(|err| format!("Failed to read row: {}", err))?; + let (node, bytes, sub_vault_id, node_privacy_tier) = + row_res.map_err(|err| format!("Failed to read row: {}", err))?; + + let effective = crate::resolve_node_effective_privacy( + conn, + &node.vault_id, + sub_vault_id.as_deref(), + node_privacy_tier.as_deref(), + )?; + + if crate::privacy::embedding_should_skip(&effective) { + continue; + } match deserialize_f32_vec(&bytes) { Ok(vec) => { @@ -614,4 +634,82 @@ mod tests { Ok(()) } + + #[test] + fn test_find_top_n_similar_nested_vault_waterfall_privacy_filtering( + ) -> Result<(), Box> { + let conn = setup_test_db()?; + let model = "test-model"; + let query = vec![1.0, 0.0, 0.0]; + + // 1. Create a 5-level nested vault hierarchy + // v_lvl0 (open) -> v_lvl1 (open) -> v_lvl2 (redacted) -> v_lvl3 (open) -> v_lvl4 (open) + conn.execute( + "INSERT INTO vaults (id, name, privacy_tier) VALUES ('v_lvl0', 'Level 0', 'open');", + [], + )?; + conn.execute("INSERT INTO vaults (id, parent_vault_id, name, privacy_tier) VALUES ('v_lvl1', 'v_lvl0', 'Level 1', 'open');", [])?; + conn.execute("INSERT INTO vaults (id, parent_vault_id, name, privacy_tier) VALUES ('v_lvl2', 'v_lvl1', 'Level 2', 'redacted');", [])?; + conn.execute("INSERT INTO vaults (id, parent_vault_id, name, privacy_tier) VALUES ('v_lvl3', 'v_lvl2', 'Level 3', 'open');", [])?; + conn.execute("INSERT INTO vaults (id, parent_vault_id, name, privacy_tier) VALUES ('v_lvl4', 'v_lvl3', 'Level 4', 'open');", [])?; + + // 2. Insert nodes at various depths + conn.execute("INSERT INTO nodes (id, vault_id, node_type, title, summary) VALUES ('n_lvl0', 'v_lvl0', 'concept', 'L0 Note', 'Sum');", [])?; + conn.execute("INSERT INTO nodes (id, vault_id, node_type, title, summary) VALUES ('n_lvl1', 'v_lvl1', 'concept', 'L1 Note', 'Sum');", [])?; + conn.execute("INSERT INTO nodes (id, vault_id, node_type, title, summary) VALUES ('n_lvl3', 'v_lvl3', 'concept', 'L3 Note under Redacted Parent', 'Sum');", [])?; + conn.execute("INSERT INTO nodes (id, vault_id, node_type, title, summary) VALUES ('n_lvl4', 'v_lvl4', 'concept', 'L4 Note under Redacted Ancestor', 'Sum');", [])?; + + // Insert a node in v_lvl1 with a direct privacy_overrides record setting it to redacted + conn.execute("INSERT INTO nodes (id, vault_id, node_type, title, summary) VALUES ('n_lvl1_override', 'v_lvl1', 'concept', 'L1 Overridden Note', 'Sum');", [])?; + conn.execute("INSERT INTO privacy_overrides (node_id, privacy_tier) VALUES ('n_lvl1_override', 'redacted');", [])?; + + // Upsert embeddings for all nodes + for n_id in ["n_lvl0", "n_lvl1", "n_lvl3", "n_lvl4", "n_lvl1_override"] { + upsert_embedding( + &conn, + &EmbeddingRow { + node_id: n_id.to_string(), + chunk_index: 0, + chunk_type: "primary".to_string(), + model: model.to_string(), + embedding: vec![1.0, 0.0, 0.0], + computed_at: "time".to_string(), + }, + )?; + } + + // Test: Vector search scoped to root vault v_lvl0 + let root_scope = HashSet::from(["v_lvl0".to_string()]); + let results = find_top_n_similar(&conn, &query, model, 10, Some(&root_scope))?; + + let result_ids: HashSet = results.into_iter().map(|(n, _)| n.id).collect(); + + // Open nodes in open vaults MUST be present + assert!( + result_ids.contains("n_lvl0"), + "L0 open node must be returned" + ); + assert!( + result_ids.contains("n_lvl1"), + "L1 open node must be returned" + ); + + // Nodes in/under v_lvl2 (redacted) MUST be excluded by waterfall privacy + assert!( + !result_ids.contains("n_lvl3"), + "L3 node under redacted parent must be excluded" + ); + assert!( + !result_ids.contains("n_lvl4"), + "L4 node under redacted ancestor must be excluded" + ); + + // Node with privacy override = redacted MUST be excluded + assert!( + !result_ids.contains("n_lvl1_override"), + "Node with redacted override must be excluded" + ); + + Ok(()) + } } diff --git a/core/src/ephemeral.rs b/core/src/ephemeral.rs index dd0979fa..8ab94e41 100644 --- a/core/src/ephemeral.rs +++ b/core/src/ephemeral.rs @@ -599,6 +599,26 @@ pub fn compute_fallback_text_vector(text: &str) -> Vec { vec } +/// Compute a 384-dimensional embedding vector for an ephemeral query string. +pub fn compute_ephemeral_query_vector(query: &str) -> Vec { + use crate::embed::engine::EmbedEngine; + + if query.trim().is_empty() { + return vec![0.0f32; 384]; + } + + if let Ok(engine) = + crate::embed::BundledEmbedEngine::new(crate::embed::bundled::DEFAULT_BUNDLED_MODEL_ID, 384) + { + if let Ok(vectors) = engine.embed(&[query.to_string()]) { + if let Some(vec) = vectors.into_iter().next() { + return vec; + } + } + } + compute_fallback_text_vector(query) +} + /// Compute 384-dimensional query embedding vector using bundled GIST ONNX model (or normalized fallback). pub fn embed_query(query: &str) -> Vec { use crate::embed::engine::EmbedEngine; diff --git a/core/src/lib.rs b/core/src/lib.rs index 8f0d3be4..b7eefeeb 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -217,7 +217,7 @@ fn fetch_private_referenced_nodes( Some(container_tier.as_str()), ); - if effective == "redacted" || effective == "locked" { + if effective != "open" { private_nodes.insert(id); } } @@ -380,7 +380,7 @@ fn onboarding_default_vault_spec(vault_id: &str) -> Option Result<(), String> { "Credentials", "key", "Local-only secrets and API keys.", - "locked", + "redacted", "pinned", 1_i64, "{}" @@ -1418,7 +1418,7 @@ pub fn is_node_private(conn: &Connection, node_id: &str) -> Result privacy_tier.as_deref(), )?; - Ok(effective == "redacted" || effective == "locked") + Ok(effective != "open") } pub fn log_memory_agent_error(conn: &Connection, raw_response: &str) -> Result<(), String> { @@ -2000,7 +2000,17 @@ pub fn resolve_vault_effective_privacy( WHERE id = ?1 AND deleted_at IS NULL LIMIT 1;", [id.as_str()], - |row| Ok((row.get::<_, Option>(0)?, row.get::<_, String>(1)?)), + |row| { + let parent = row.get::<_, Option>(0)?; + // If privacy_tier is NULL, the vault inherits from its parent ancestry chain. + // If there is no parent (top-level root vault) and tier is NULL, Amber defaults the top-level + // baseline to "open" (the standard creation default). For sub-vaults, NULL never loosens an ancestor's + // stricter tier because `get_privacy_rank` comparison monotonically preserves the strictest tier seen. + let tier = row + .get::<_, Option>(1)? + .unwrap_or_else(|| "open".to_string()); + Ok((parent, tier)) + }, ) { Ok(record) => record, Err(rusqlite::Error::QueryReturnedNoRows) => { @@ -4094,7 +4104,7 @@ fn node_create(input: NodeCreateInput, state: tauri::State<'_, DbState>) -> IpcR params![ id, target_vault_id, - None::, + input.sub_vault_id, node_type, stored_title, stored_summary, @@ -4281,7 +4291,7 @@ fn node_update(input: NodeUpdateInput, state: tauri::State<'_, DbState>) -> IpcR params![ input.id, effective_target_vault_id, - None::, + next_sub_vault_id, next_node_type, stored_title, stored_summary, @@ -4718,6 +4728,24 @@ async fn llm_chat( attached_document } }; + let parsed_provider = match provider.trim().to_lowercase().as_str() { + "ollama" => llm::client::LlmProvider::Ollama, + "lmstudio" => llm::client::LlmProvider::LmStudio, + "anthropic" => llm::client::LlmProvider::Anthropic, + "openai" => llm::client::LlmProvider::OpenAi, + "google" => llm::client::LlmProvider::Google, + "xai" => llm::client::LlmProvider::XAi, + _ => return Err("Unsupported provider. Use 'ollama', 'lmstudio', 'anthropic', 'openai', 'google', or 'xai'.".to_string()), + }; + + // SECURITY INVARIANT (Milestone 2.4.6 Commit 5): + // If destination is a Cloud LLM, context assembly scope MUST be forced to "cloud" + // regardless of what assembler scope UI state was passed. + let effective_scope = if parsed_provider.is_cloud() { + "cloud".to_string() + } else { + scope + }; let vault_memory_context = if session_id == "temporary-session" { "[Off the Record Mode: Context assembly has been bypassed. No personal memories or notes are accessible in this session.]".to_string() @@ -4727,7 +4755,7 @@ async fn llm_chat( &conn, node_ids, llm::assembler::AssemblerConfig { - scope, + scope: effective_scope, max_tokens: max_assembler_tokens.unwrap_or(DEFAULT_ASSEMBLER_MAX_TOKENS), is_unlocked: is_redacted_unlocked, }, @@ -4773,7 +4801,7 @@ async fn llm_chat( \"layout\": {\n\ \"title\": \"Fruit Counts\"\n\ }\n\ - }\n\ + }\n\ ```\n\ Always output fully valid JSON (double quotes for keys and string values). Do not embed comments inside the JSON."; @@ -4789,16 +4817,6 @@ async fn llm_chat( retrieved_doc_content, }; - let parsed_provider = match provider.trim().to_lowercase().as_str() { - "ollama" => llm::client::LlmProvider::Ollama, - "lmstudio" => llm::client::LlmProvider::LmStudio, - "anthropic" => llm::client::LlmProvider::Anthropic, - "openai" => llm::client::LlmProvider::OpenAi, - "google" => llm::client::LlmProvider::Google, - "xai" => llm::client::LlmProvider::XAi, - _ => return Err("Unsupported provider. Use 'ollama', 'lmstudio', 'anthropic', 'openai', 'google', or 'xai'.".to_string()), - }; - let client = llm::client::UniversalClient::new(parsed_provider, endpoint, model); // Load recent conversation history (token-budgeted) so the model sees previous turns without overflowing @@ -5538,7 +5556,7 @@ mod tests { unique_node_ids.insert(node_id.clone()); tx.execute( "INSERT INTO nodes (id, vault_id, sub_vault_id, privacy_tier, deleted_at) - VALUES (?1, 'vault_root', NULL, 'locked', NULL);", + VALUES (?1, 'vault_root', NULL, 'redacted', NULL);", [node_id], ) .unwrap_or_else(|err| panic!("expected node insert to succeed: {err}")); diff --git a/core/src/llm/assembler.rs b/core/src/llm/assembler.rs index bed3838f..32d8b124 100644 --- a/core/src/llm/assembler.rs +++ b/core/src/llm/assembler.rs @@ -5,8 +5,8 @@ use rusqlite::Connection; use tiktoken_rs::CoreBPE; use crate::privacy::{ - cloud_llm_context_policy, generate_pointer_stub, get_effective_privacy, - local_llm_context_policy, unrestricted_llm_context_policy, LlmContextPolicy, + cloud_llm_context_policy, get_effective_privacy, local_llm_context_policy, + unrestricted_llm_context_policy, LlmContextPolicy, }; const ATTENTION_SINK_TOKENS: usize = 50; @@ -220,6 +220,13 @@ fn fetch_requested_nodes( if !seen_ids.insert(id.clone()) { continue; } + let vault_id: Option = row.get(5).ok(); + let vault_effective_tier = if let Some(ref vid) = vault_id { + crate::resolve_vault_effective_privacy(db, vid).ok() + } else { + row.get(8).ok() + }; + nodes.push(AssemblerNode { id, title: row @@ -234,12 +241,8 @@ fn fetch_requested_nodes( node_privacy_tier: row.get(4).map_err(|err| { format!("Failed decoding node privacy field in assembler: {err}") })?, - sub_vault_privacy_tier: row.get(7).map_err(|err| { - format!("Failed decoding sub-vault privacy field in assembler: {err}") - })?, - vault_privacy_tier: row.get(8).map_err(|err| { - format!("Failed decoding vault privacy field in assembler: {err}") - })?, + sub_vault_privacy_tier: row.get(7).ok(), + vault_privacy_tier: vault_effective_tier, }); } } @@ -283,7 +286,6 @@ pub fn build_context( let block = match policy { LlmContextPolicy::Omit => continue, - LlmContextPolicy::Stub => generate_pointer_stub(&node.title, &node.id), LlmContextPolicy::Full => format!( "\n{}\n\n{}\n", escape_xml_attr(&node.title), @@ -407,16 +409,16 @@ mod tests { id, vault_id, sub_vault_id, title, summary, detail, privacy_tier, priority, is_archived, deleted_at ) VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, ?7, 0, NULL);", [ - "node_local_only", + "node_open", "vault_a", - "Local Only Node", - "local summary", - "local detail", - "local_only", + "Open Node", + "open summary", + "open detail", + "open", "{\"access_count_30active\":6}", ], ) { - panic!("failed inserting node for assembler test: {err}"); + panic!("failed inserting open node for assembler test: {err}"); } if let Err(err) = conn.execute( @@ -424,12 +426,12 @@ mod tests { id, vault_id, sub_vault_id, title, summary, detail, privacy_tier, priority, is_archived, deleted_at ) VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, ?7, 0, NULL);", [ - "node_locked", + "node_local_only", "vault_a", - "Locked Node", - "locked summary", - "locked detail", - "locked", + "Local Only Node", + "local summary", + "local detail", + "local_only", "{\"access_count_30active\":6}", ], ) { @@ -479,12 +481,11 @@ mod tests { let conn = setup_in_memory_db(); let node_ids = vec![ "node_local_only".to_string(), - "node_locked".to_string(), "node_redacted".to_string(), "node_nested_redacted".to_string(), ]; - // 1. Local scope (locked): local_only included; locked, redacted are stubbed. + // 1. Local scope (locked session): local_only included; redacted omitted when locked. let local_result = match build_context( &conn, node_ids.clone(), @@ -501,13 +502,11 @@ mod tests { // local_only is included assert!(local_result.contains("")); assert!(local_result.contains("local detail")); - // locked is stubbed since we aren't unlocked - assert!(local_result.contains("[LOCKED NODE STUB] Title: Locked Node")); - // redacted is fully omitted, even for local models + // redacted is fully omitted when session is locked assert!(!local_result.contains("Redacted Node")); assert!(!local_result.contains("Nested Redacted Node")); - // 1.5 Local scope (unlocked): locked is fully included. + // 1.5 Local scope (unlocked session): redacted node is included when unlocked. let local_unlocked_result = match build_context( &conn, node_ids.clone(), @@ -520,11 +519,10 @@ mod tests { Ok(value) => value, Err(err) => panic!("local scope assembler failed: {err}"), }; - assert!(local_unlocked_result.contains("")); - assert!(local_unlocked_result.contains("locked detail")); - assert!(!local_unlocked_result.contains("Redacted Node")); + assert!(local_unlocked_result.contains("")); + assert!(local_unlocked_result.contains("redacted detail")); - // 2. Cloud scope: locked is stubbed; local_only and redacted are completely omitted. + // 2. Cloud scope: local_only and redacted are completely omitted. let cloud_result = match build_context( &conn, node_ids, @@ -538,8 +536,6 @@ mod tests { Err(err) => panic!("cloud scope assembler failed: {err}"), }; - // locked is stubbed - assert!(cloud_result.contains("[LOCKED NODE STUB] Title: Locked Node")); // local_only is completely omitted assert!(!cloud_result.contains("Local Only Node")); // redacted is completely omitted @@ -548,6 +544,109 @@ mod tests { assert!(!cloud_result.contains("Nested Redacted Node")); } + #[test] + fn test_security_cloud_call_egress_filtering_3tier_matrix() { + // SECURITY TEST: Verify cloud provider request assembled with a mix of all 3 tiers. + // Assert: Open is included, Local Only is excluded entirely, Redacted is excluded entirely. + let conn = setup_in_memory_db(); + let node_ids = vec![ + "node_open".to_string(), + "node_local_only".to_string(), + "node_redacted".to_string(), + "node_nested_redacted".to_string(), + ]; + + let vault_context = build_context( + &conn, + node_ids, + AssemblerConfig { + scope: "cloud".to_string(), + max_tokens: 4000, + is_unlocked: true, // Even if unlocked, cloud scope MUST exclude Local Only & Redacted + }, + ) + .expect("cloud prompt assembly failed"); + + let components = super::PromptComponents { + system_directives: "System directive".to_string(), + vault_memory_context: vault_context, + retrieved_doc_content: None, + }; + + let assembled = components.assemble_system_prompt(); + + // 1. Open content MUST be included + assert!(assembled.contains("")); + assert!(assembled.contains("open detail")); + + // 2. Local Only content MUST be excluded entirely (zero cleartext, zero stubs) + assert!(!assembled.contains("Local Only Node")); + assert!(!assembled.contains("local detail")); + + // 3. Redacted content MUST be excluded entirely from cloud calls + assert!(!assembled.contains("Redacted Node")); + assert!(!assembled.contains("redacted detail")); + assert!(!assembled.contains("Nested Redacted Node")); + } + + #[test] + fn test_security_local_call_egress_filtering_3tier_matrix() { + // SECURITY TEST: Verify local provider request assembled with a mix of all 3 tiers. + // Assert: Open is included, Local Only IS included, Redacted is excluded when locked & included when unlocked. + let conn = setup_in_memory_db(); + let node_ids = vec![ + "node_open".to_string(), + "node_local_only".to_string(), + "node_redacted".to_string(), + ]; + + // Case A: Session is LOCKED (is_unlocked = false) + let locked_context = build_context( + &conn, + node_ids.clone(), + AssemblerConfig { + scope: "local".to_string(), + max_tokens: 4000, + is_unlocked: false, + }, + ) + .expect("local prompt assembly failed"); + + let locked_components = super::PromptComponents { + system_directives: "System directive".to_string(), + vault_memory_context: locked_context, + retrieved_doc_content: None, + }; + + let locked_assembled = locked_components.assemble_system_prompt(); + assert!(locked_assembled.contains("")); + assert!(locked_assembled.contains("")); + assert!(!locked_assembled.contains("Redacted Node")); + + // Case B: Session is UNLOCKED (is_unlocked = true) + let unlocked_context = build_context( + &conn, + node_ids, + AssemblerConfig { + scope: "local".to_string(), + max_tokens: 4000, + is_unlocked: true, + }, + ) + .expect("local prompt assembly failed"); + + let unlocked_components = super::PromptComponents { + system_directives: "System directive".to_string(), + vault_memory_context: unlocked_context, + retrieved_doc_content: None, + }; + + let unlocked_assembled = unlocked_components.assemble_system_prompt(); + assert!(unlocked_assembled.contains("")); + assert!(unlocked_assembled.contains("")); + assert!(unlocked_assembled.contains("")); + } + #[test] fn trimming_preserves_prompt_head_attention_sink() { let conn = setup_in_memory_db(); @@ -652,11 +751,11 @@ mod tests { let conn = setup_in_memory_db(); // Insert nodes with different priority values to make sure priority sorting // is overridden by relevance ordering - let node_ids = vec!["node_locked".to_string(), "node_local_only".to_string()]; + let node_ids = vec!["node_open".to_string(), "node_local_only".to_string()]; let nodes = fetch_requested_nodes(&conn, &node_ids).unwrap(); assert_eq!(nodes.len(), 2); - assert_eq!(nodes[0].id, "node_locked"); + assert_eq!(nodes[0].id, "node_open"); assert_eq!(nodes[1].id, "node_local_only"); let context = build_context( @@ -670,12 +769,12 @@ mod tests { ) .unwrap(); - // The assembled context should contain Locked Node document before Local Only Node document - let pos_locked = context.find("title=\"Locked Node\"").unwrap(); + // The assembled context should contain Open Node document before Local Only Node document + let pos_open = context.find("title=\"Open Node\"").unwrap(); let pos_local = context.find("title=\"Local Only Node\"").unwrap(); assert!( - pos_locked < pos_local, - "Locked Node should come before Local Only Node to preserve relevance ordering!" + pos_open < pos_local, + "Open Node should come before Local Only Node to preserve relevance ordering!" ); } diff --git a/core/src/llm/client.rs b/core/src/llm/client.rs index 9118bb30..a0020367 100644 --- a/core/src/llm/client.rs +++ b/core/src/llm/client.rs @@ -32,6 +32,19 @@ pub enum LlmProvider { XAi, } +impl LlmProvider { + pub fn is_cloud(&self) -> bool { + matches!( + self, + LlmProvider::Anthropic | LlmProvider::OpenAi | LlmProvider::Google | LlmProvider::XAi + ) + } + + pub fn is_local(&self) -> bool { + !self.is_cloud() + } +} + pub struct UniversalClient { pub provider: LlmProvider, pub endpoint: String, diff --git a/core/src/memory_agent/changeset.rs b/core/src/memory_agent/changeset.rs index e0749bdb..538c2bf5 100644 --- a/core/src/memory_agent/changeset.rs +++ b/core/src/memory_agent/changeset.rs @@ -747,11 +747,14 @@ mod tests { let create_sql = " CREATE TABLE vaults ( id TEXT PRIMARY KEY, + parent_vault_id TEXT, + privacy_tier TEXT, deleted_at TEXT ); CREATE TABLE sub_vaults ( id TEXT PRIMARY KEY, vault_id TEXT, + privacy_tier TEXT, deleted_at TEXT ); CREATE TABLE sessions ( @@ -766,6 +769,7 @@ mod tests { title TEXT NOT NULL, summary TEXT NOT NULL, detail TEXT, + privacy_tier TEXT, version INTEGER NOT NULL DEFAULT 1, is_archived INTEGER NOT NULL DEFAULT 0, deleted_at TEXT @@ -779,6 +783,10 @@ mod tests { computed_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (node_id, chunk_index, chunk_type) ); + CREATE TABLE privacy_overrides ( + node_id TEXT PRIMARY KEY, + privacy_tier TEXT + ); "; if let Err(e) = conn.execute_batch(create_sql) { panic!("Failed to create test database: {e}"); diff --git a/core/src/memory_agent/persistence.rs b/core/src/memory_agent/persistence.rs index 9601ad0c..ccc826a5 100644 --- a/core/src/memory_agent/persistence.rs +++ b/core/src/memory_agent/persistence.rs @@ -171,10 +171,9 @@ pub fn list_pending_changesets( fn privacy_tier_value(tier: &str) -> i32 { match tier.trim().to_lowercase().as_str() { "open" => 0, - "local_only" => 1, - "local" => 1, - "locked" => 2, - "redacted" => 3, + "local_only" | "local" => 1, + // "locked" is preserved as a defensive fallback for legacy unmigrated data, mapping to rank 2 (redacted). + "locked" | "redacted" => 2, _ => 0, } } @@ -542,7 +541,7 @@ mod tests { // Insert restricted vault and sub-vaults conn.execute( - "INSERT INTO vaults (id, name, privacy_tier) VALUES ('vault-restricted', 'Vault Restricted', 'locked');", + "INSERT INTO vaults (id, name, privacy_tier) VALUES ('vault-restricted', 'Vault Restricted', 'redacted');", [], )?; conn.execute( @@ -586,14 +585,14 @@ mod tests { let items = list_changeset_items(&conn, &cs_id)?; assert_eq!(items.len(), 2); - // Item 0 targets sub-null-tier which inherits 'locked' (value 2). Source is 'open' (value 0). + // Item 0 targets sub-null-tier which inherits 'redacted' (value 2). Source is 'open' (value 0). // Since 2 > 0, it must trigger a Security Warning. assert!(items[0].anomaly_warning.is_some()); let warning_text = items[0] .anomaly_warning .as_ref() .ok_or("Expected anomaly warning to be present")?; - assert!(warning_text.contains("Security Warning: Slated for Sub Null Tier (LOCKED)")); + assert!(warning_text.contains("Security Warning: Slated for Sub Null Tier (REDACTED)")); // Item 1 targets sub-open-tier which overrides parent and has 'open' (value 0). Source is 'open' (value 0). // Since 0 is not > 0, it must NOT trigger a Security Warning. @@ -601,4 +600,14 @@ mod tests { Ok(()) } + + #[test] + fn test_privacy_tier_value_legacy_fallback() { + assert_eq!(privacy_tier_value("open"), 0); + assert_eq!(privacy_tier_value("local_only"), 1); + assert_eq!(privacy_tier_value("local"), 1); + assert_eq!(privacy_tier_value("redacted"), 2); + assert_eq!(privacy_tier_value("locked"), 2); // Legacy fallback + assert_eq!(privacy_tier_value("unknown"), 0); + } } diff --git a/core/src/privacy.rs b/core/src/privacy.rs index ba46f4ab..1127db82 100644 --- a/core/src/privacy.rs +++ b/core/src/privacy.rs @@ -1,4 +1,4 @@ -//! Amber privacy tiers — four tiers, two independent axes. +//! Amber privacy tiers — three simplified tiers, two independent axes. //! //! ## Axis 1: Egress (where may AI see this?) //! @@ -6,8 +6,7 @@ //! |-------------|-----------|-------------------------| //! | `open` | full | full | //! | `local_only`| omit | full | -//! | `locked` | stub | full when session unlocked; stub otherwise | -//! | `redacted` | omit | omit (assembler never includes encrypted nodes) | +//! | `redacted` | omit | full when session unlocked; omit otherwise | //! //! ## Axis 2: Disclosure (what is visible before unlock?) //! @@ -15,33 +14,27 @@ //! |-------------|----------------------------|--------------|--------------------| //! | `open` | visible | visible | no | //! | `local_only`| visible | visible | no | -//! | `locked` | visible | gated | no | //! | `redacted` | hidden (`[REDACTED]`) | gated | yes | //! //! ## Durable indexes (embeddings, exports) //! //! Persisted vectors must not store cleartext that the tier withholds from cloud context. -//! Session unlock gates UI reading and *ephemeral* local LLM context — not promotion of -//! locked content into durable indexes. //! //! | Tier | Embedding policy | //! |-------------|-------------------------------------------| //! | `open` | full cleartext | //! | `local_only`| full cleartext (local ONNX / local Ollama)| -//! | `locked` | pointer stub always | //! | `redacted` | skip (delete existing vectors) | //! //! Effective tier resolves as the strictest of node, sub-vault, and vault tiers. -//! All subsystems (`llm::assembler`, `embed::job`, UI helpers) should follow this matrix. +//! All subsystems (`llm::assembler`, `embed::job`, UI helpers) follow this matrix. pub const TIER_OPEN: &str = "open"; pub const TIER_LOCAL_ONLY: &str = "local_only"; -pub const TIER_LOCKED: &str = "locked"; pub const TIER_REDACTED: &str = "redacted"; const OPEN: &str = TIER_OPEN; const LOCAL_ONLY: &str = TIER_LOCAL_ONLY; -const LOCKED: &str = TIER_LOCKED; const REDACTED: &str = TIER_REDACTED; /// Whether full node content may be sent to a cloud LLM. @@ -49,43 +42,33 @@ pub fn allows_cloud_content(tier: &str) -> bool { normalize_tier(Some(tier)) == OPEN } -/// Whether a pointer stub (title + id) may be sent to a cloud LLM. -pub fn allows_cloud_stub(tier: &str) -> bool { - normalize_tier(Some(tier)) == LOCKED +/// Whether a tier normalized to redacted. +pub fn is_redacted(tier: &str) -> bool { + normalize_tier(Some(tier)) == REDACTED } /// Whether node/vault payload is encrypted at rest (`encrypted_payload`). /// Disclosure axis — use when persisting redacted tier content. #[allow(dead_code)] pub fn encrypts_at_rest(tier: &str) -> bool { - normalize_tier(Some(tier)) == REDACTED + is_redacted(tier) } /// Whether UI should hide metadata until the master-password session is active. /// Disclosure axis — mirror in `ui/utils/privacy.ts` display helpers. #[allow(dead_code)] pub fn hides_metadata_until_unlock(tier: &str) -> bool { - normalize_tier(Some(tier)) == REDACTED + is_redacted(tier) } /// Whether embeddings should be skipped and any existing vectors deleted. pub fn embedding_should_skip(tier: &str) -> bool { - normalize_tier(Some(tier)) == REDACTED -} - -/// Whether embeddings must use a pointer stub instead of cleartext chunks. -pub fn embedding_uses_stub(tier: &str) -> bool { - normalize_tier(Some(tier)) == LOCKED + is_redacted(tier) } -/// Local LLM context for locked tier: stub unless the redacted session is unlocked. -pub fn local_llm_locked_uses_full_content_when_unlocked(is_unlocked: bool) -> bool { - is_unlocked -} - -/// Whether a node must be omitted from local LLM context assembly. -pub fn omits_from_local_llm(tier: &str) -> bool { - normalize_tier(Some(tier)) == REDACTED +/// Whether a node must be omitted from local LLM context assembly when locked. +pub fn omits_from_local_llm(tier: &str, is_unlocked: bool) -> bool { + is_redacted(tier) && !is_unlocked } /// Whether local-only nodes must not embed via a non-loopback Ollama endpoint. @@ -97,27 +80,20 @@ pub fn embedding_blocks_on_remote_ollama(tier: &str) -> bool { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LlmContextPolicy { Full, - Stub, Omit, } pub fn cloud_llm_context_policy(tier: &str) -> LlmContextPolicy { if allows_cloud_content(tier) { LlmContextPolicy::Full - } else if allows_cloud_stub(tier) { - LlmContextPolicy::Stub } else { LlmContextPolicy::Omit } } pub fn local_llm_context_policy(tier: &str, is_unlocked: bool) -> LlmContextPolicy { - if omits_from_local_llm(tier) { + if omits_from_local_llm(tier, is_unlocked) { LlmContextPolicy::Omit - } else if allows_cloud_stub(tier) - && !local_llm_locked_uses_full_content_when_unlocked(is_unlocked) - { - LlmContextPolicy::Stub } else { LlmContextPolicy::Full } @@ -125,7 +101,7 @@ pub fn local_llm_context_policy(tier: &str, is_unlocked: bool) -> LlmContextPoli /// Unrestricted/debug scopes: full content for non-redacted tiers. pub fn unrestricted_llm_context_policy(tier: &str) -> LlmContextPolicy { - if omits_from_local_llm(tier) { + if is_redacted(tier) { LlmContextPolicy::Omit } else { LlmContextPolicy::Full @@ -133,10 +109,10 @@ pub fn unrestricted_llm_context_policy(tier: &str) -> LlmContextPolicy { } fn normalize_tier(tier: Option<&str>) -> &'static str { - match tier { - Some(LOCAL_ONLY) => LOCAL_ONLY, - Some(LOCKED) => LOCKED, - Some(REDACTED) => REDACTED, + match tier.map(|t| t.trim()) { + Some(t) if t.eq_ignore_ascii_case(LOCAL_ONLY) => LOCAL_ONLY, + // Defensive normalization: map legacy "locked" string inputs to REDACTED (matching frontend normalizeTier) + Some(t) if t.eq_ignore_ascii_case(REDACTED) || t.eq_ignore_ascii_case("locked") => REDACTED, _ => OPEN, } } @@ -145,8 +121,7 @@ pub fn get_privacy_rank(tier: Option<&str>) -> u8 { match normalize_tier(tier) { OPEN => 0, LOCAL_ONLY => 1, - LOCKED => 2, - REDACTED => 3, + REDACTED => 2, _ => 0, } } @@ -172,28 +147,20 @@ pub fn get_effective_privacy( resolve_chain_effective_privacy([node_tier, sub_vault_tier, vault_tier]) } -pub fn generate_pointer_stub(node_title: &str, node_id: &str) -> String { - format!( - "[LOCKED NODE STUB] Title: {} (ID: {}) - Content withheld due to privacy constraints.", - node_title, node_id - ) -} - #[cfg(test)] mod tests { use super::{ - allows_cloud_content, allows_cloud_stub, cloud_llm_context_policy, - embedding_blocks_on_remote_ollama, embedding_should_skip, embedding_uses_stub, - encrypts_at_rest, get_effective_privacy, get_privacy_rank, hides_metadata_until_unlock, - local_llm_context_policy, local_llm_locked_uses_full_content_when_unlocked, - omits_from_local_llm, unrestricted_llm_context_policy, LlmContextPolicy, TIER_LOCAL_ONLY, - TIER_LOCKED, TIER_OPEN, TIER_REDACTED, + allows_cloud_content, cloud_llm_context_policy, embedding_blocks_on_remote_ollama, + embedding_should_skip, encrypts_at_rest, get_effective_privacy, get_privacy_rank, + hides_metadata_until_unlock, local_llm_context_policy, omits_from_local_llm, + unrestricted_llm_context_policy, LlmContextPolicy, TIER_LOCAL_ONLY, TIER_OPEN, + TIER_REDACTED, }; #[test] - fn privacy_waterfall_parent_locked_beats_node_open() { - let effective = get_effective_privacy(Some("open"), Some("locked"), None); - assert_eq!(effective, "locked"); + fn privacy_waterfall_parent_local_only_beats_node_open() { + let effective = get_effective_privacy(Some("open"), Some("local_only"), None); + assert_eq!(effective, "local_only"); } #[test] @@ -209,8 +176,8 @@ mod tests { } #[test] - fn privacy_waterfall_redacted_beats_locked_across_hierarchy() { - let effective = get_effective_privacy(Some("open"), Some("locked"), Some("redacted")); + fn privacy_waterfall_redacted_beats_local_only_across_hierarchy() { + let effective = get_effective_privacy(Some("open"), Some("local_only"), Some("redacted")); assert_eq!(effective, "redacted"); } @@ -224,9 +191,8 @@ mod tests { #[test] fn egress_policy_matrix() { assert!(allows_cloud_content(TIER_OPEN)); - assert!(!allows_cloud_content(TIER_LOCKED)); - assert!(allows_cloud_stub(TIER_LOCKED)); - assert!(!allows_cloud_stub(TIER_OPEN)); + assert!(!allows_cloud_content(TIER_LOCAL_ONLY)); + assert!(!allows_cloud_content(TIER_REDACTED)); assert_eq!( cloud_llm_context_policy(TIER_LOCAL_ONLY), LlmContextPolicy::Omit @@ -241,49 +207,38 @@ mod tests { #[test] fn disclosure_and_embedding_policy_matrix() { assert!(encrypts_at_rest(TIER_REDACTED)); - assert!(!encrypts_at_rest(TIER_LOCKED)); + assert!(!encrypts_at_rest(TIER_LOCAL_ONLY)); assert!(hides_metadata_until_unlock(TIER_REDACTED)); - assert!(!hides_metadata_until_unlock(TIER_LOCKED)); + assert!(!hides_metadata_until_unlock(TIER_LOCAL_ONLY)); assert!(embedding_should_skip(TIER_REDACTED)); - assert!(!embedding_should_skip(TIER_LOCKED)); - assert!(embedding_uses_stub(TIER_LOCKED)); - assert!(!embedding_uses_stub(TIER_OPEN)); - } - - #[test] - fn locked_local_llm_respects_session_unlock() { - assert!(!local_llm_locked_uses_full_content_when_unlocked(false)); - assert!(local_llm_locked_uses_full_content_when_unlocked(true)); + assert!(!embedding_should_skip(TIER_LOCAL_ONLY)); } #[test] fn llm_context_policy_matrix() { assert_eq!(cloud_llm_context_policy(TIER_OPEN), LlmContextPolicy::Full); - assert_eq!( - cloud_llm_context_policy(TIER_LOCKED), - LlmContextPolicy::Stub - ); assert_eq!( cloud_llm_context_policy(TIER_LOCAL_ONLY), LlmContextPolicy::Omit ); assert_eq!( - local_llm_context_policy(TIER_LOCKED, false), - LlmContextPolicy::Stub + local_llm_context_policy(TIER_REDACTED, false), + LlmContextPolicy::Omit ); assert_eq!( - local_llm_context_policy(TIER_LOCKED, true), + local_llm_context_policy(TIER_REDACTED, true), LlmContextPolicy::Full ); assert_eq!( - local_llm_context_policy(TIER_REDACTED, true), - LlmContextPolicy::Omit + local_llm_context_policy(TIER_LOCAL_ONLY, false), + LlmContextPolicy::Full ); assert_eq!( - unrestricted_llm_context_policy(TIER_LOCKED), - LlmContextPolicy::Full + unrestricted_llm_context_policy(TIER_REDACTED), + LlmContextPolicy::Omit ); - assert!(omits_from_local_llm(TIER_REDACTED)); + assert!(omits_from_local_llm(TIER_REDACTED, false)); + assert!(!omits_from_local_llm(TIER_REDACTED, true)); assert!(embedding_blocks_on_remote_ollama(TIER_LOCAL_ONLY)); } @@ -291,11 +246,10 @@ mod tests { fn commit_2_privacy_decision_table_matrix() { use super::resolve_chain_effective_privacy; - // Matrix tests: (Ancestor Tier, Descendant Tier) -> Expected Effective Tier + // Matrix tests: 3 tiers (Ancestor Tier, Descendant Tier) -> Expected Effective Tier let cases = [ (Some(TIER_OPEN), Some(TIER_OPEN), TIER_OPEN), (Some(TIER_OPEN), Some(TIER_LOCAL_ONLY), TIER_LOCAL_ONLY), - (Some(TIER_OPEN), Some(TIER_LOCKED), TIER_LOCKED), (Some(TIER_OPEN), Some(TIER_REDACTED), TIER_REDACTED), (Some(TIER_LOCAL_ONLY), Some(TIER_OPEN), TIER_LOCAL_ONLY), ( @@ -303,15 +257,9 @@ mod tests { Some(TIER_LOCAL_ONLY), TIER_LOCAL_ONLY, ), - (Some(TIER_LOCAL_ONLY), Some(TIER_LOCKED), TIER_LOCKED), (Some(TIER_LOCAL_ONLY), Some(TIER_REDACTED), TIER_REDACTED), - (Some(TIER_LOCKED), Some(TIER_OPEN), TIER_LOCKED), - (Some(TIER_LOCKED), Some(TIER_LOCAL_ONLY), TIER_LOCKED), - (Some(TIER_LOCKED), Some(TIER_LOCKED), TIER_LOCKED), - (Some(TIER_LOCKED), Some(TIER_REDACTED), TIER_REDACTED), (Some(TIER_REDACTED), Some(TIER_OPEN), TIER_REDACTED), (Some(TIER_REDACTED), Some(TIER_LOCAL_ONLY), TIER_REDACTED), - (Some(TIER_REDACTED), Some(TIER_LOCKED), TIER_REDACTED), (Some(TIER_REDACTED), Some(TIER_REDACTED), TIER_REDACTED), ]; @@ -328,10 +276,10 @@ mod tests { fn multi_level_n_depth_privacy_inheritance_propagation() { use super::resolve_chain_effective_privacy; - // Level 1: Locked -> Level 2: None/Open -> Level 3: Open => Locked + // Level 1: Local Only -> Level 2: None/Open -> Level 3: Open => Local Only assert_eq!( - resolve_chain_effective_privacy([Some(TIER_LOCKED), None, Some(TIER_OPEN)]), - TIER_LOCKED + resolve_chain_effective_privacy([Some(TIER_LOCAL_ONLY), None, Some(TIER_OPEN)]), + TIER_LOCAL_ONLY ); // Level 1: Open -> Level 2: None -> Level 3: Redacted => Redacted diff --git a/core/tests/integration_tests/embedding.rs b/core/tests/integration_tests/embedding.rs index dcd10ef9..5337fd61 100644 --- a/core/tests/integration_tests/embedding.rs +++ b/core/tests/integration_tests/embedding.rs @@ -670,7 +670,7 @@ fn test_privacy_tier_change_clears_stale_embeddings() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { + // CRITICAL SECURITY ASSERTION: + // Explicitly verify that zero rows previously tagged 'locked' are ever remapped to 'open'. + // A 'locked' -> 'open' remap would constitute a severe privacy failure, exposing protected + // content to cloud LLM calls. + let conn = Connection::open_in_memory()?; + conn.pragma_update(None, "foreign_keys", "ON")?; + + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + "#, + )?; + + // Apply migrations 1 through 12 + apply_migrations_through_version(&conn, 12); + + // Seed test data with 'locked' privacy_tier across all entity types + conn.execute( + "INSERT INTO vaults (id, name, privacy_tier) VALUES ('v_sec_locked', 'Sec Locked Vault', 'locked');", + [], + )?; + conn.execute( + "INSERT INTO sub_vaults (id, vault_id, name, privacy_tier) VALUES ('sv_sec_locked', 'v_sec_locked', 'Sec Subvault', 'locked');", + [], + )?; + conn.execute( + "INSERT INTO nodes (id, vault_id, title, summary, privacy_tier) VALUES ('n_sec_locked', 'v_sec_locked', 'Locked Secret', 'Summary', 'locked');", + [], + )?; + conn.execute( + "INSERT INTO privacy_overrides (node_id, privacy_tier) VALUES ('n_sec_locked', 'locked');", + [], + )?; + + // Record tracked IDs before running migration 0013 + let locked_vault_ids = vec!["v_sec_locked"]; + let locked_node_ids = vec!["n_sec_locked"]; + + // Run migration 0013 + apply_migrations_through_version(&conn, 13); + + // SECURITY CHECK 1: Zero previously-locked vaults end up tagged 'open' + for id in &locked_vault_ids { + let tier: String = conn.query_row( + "SELECT privacy_tier FROM vaults WHERE id = ?1;", + params![id], + |row| row.get(0), + )?; + assert_ne!( + tier, "open", + "SECURITY FAILURE: Vault {id} was tagged 'locked' pre-migration but ended up 'open'!" + ); + } + + // SECURITY CHECK 2: Zero previously-locked nodes end up tagged 'open' + for id in &locked_node_ids { + let tier: String = conn.query_row( + "SELECT privacy_tier FROM nodes WHERE id = ?1;", + params![id], + |row| row.get(0), + )?; + assert_ne!( + tier, "open", + "SECURITY FAILURE: Node {id} was tagged 'locked' pre-migration but ended up 'open'!" + ); + } + + // SECURITY CHECK 3: Zero previously-locked sub-vaults end up tagged 'open' (checking sub_vaults table directly) + let sv_tier: String = conn.query_row( + "SELECT privacy_tier FROM sub_vaults WHERE id = 'sv_sec_locked';", + [], + |row| row.get(0), + )?; + assert_ne!( + sv_tier, "open", + "SECURITY FAILURE: Sub-vault sv_sec_locked was tagged 'locked' pre-migration but ended up 'open'!" + ); + + // SECURITY CHECK 4: Zero previously-locked privacy_overrides end up tagged 'open' + let override_tier: String = conn.query_row( + "SELECT privacy_tier FROM privacy_overrides WHERE node_id = 'n_sec_locked';", + [], + |row| row.get(0), + )?; + assert_ne!( + override_tier, "open", + "SECURITY FAILURE: Privacy override for n_sec_locked was tagged 'locked' pre-migration but ended up 'open'!" + ); + + Ok(()) +} + +#[test] +fn test_migration_0013_remaps_all_locked_data_to_redacted() -> Result<(), Box> +{ + let conn = Connection::open_in_memory()?; + conn.pragma_update(None, "foreign_keys", "ON")?; + + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + "#, + )?; + + // Apply migrations 1 through 12 + apply_migrations_through_version(&conn, 12); + + // Seed mixed privacy tier dataset + conn.execute( + "INSERT INTO vaults (id, name, privacy_tier) VALUES ('v_open', 'Open Vault', 'open');", + [], + )?; + conn.execute( + "INSERT INTO vaults (id, name, privacy_tier) VALUES ('v_local', 'Local Vault', 'local_only');", + [], + )?; + conn.execute( + "INSERT INTO vaults (id, name, privacy_tier) VALUES ('v_locked', 'Locked Vault', 'locked');", + [], + )?; + conn.execute( + "INSERT INTO vaults (id, name, privacy_tier) VALUES ('v_redacted', 'Redacted Vault', 'redacted');", + [], + )?; + conn.execute( + "INSERT INTO nodes (id, vault_id, title, summary, privacy_tier) VALUES ('n_locked', 'v_locked', 'Locked Node', 'Sum', 'locked');", + [], + )?; + + // Apply migration 0013 + apply_migrations_through_version(&conn, 13); + + // Assert previously 'locked' items are now 'redacted' + let v_locked_tier: String = conn.query_row( + "SELECT privacy_tier FROM vaults WHERE id = 'v_locked';", + [], + |row| row.get(0), + )?; + assert_eq!( + v_locked_tier, "redacted", + "v_locked should remap to redacted" + ); + + let n_locked_tier: String = conn.query_row( + "SELECT privacy_tier FROM nodes WHERE id = 'n_locked';", + [], + |row| row.get(0), + )?; + assert_eq!( + n_locked_tier, "redacted", + "n_locked should remap to redacted" + ); + + // Assert untouched items remain intact + let v_open_tier: String = conn.query_row( + "SELECT privacy_tier FROM vaults WHERE id = 'v_open';", + [], + |row| row.get(0), + )?; + assert_eq!(v_open_tier, "open"); + + let v_local_tier: String = conn.query_row( + "SELECT privacy_tier FROM vaults WHERE id = 'v_local';", + [], + |row| row.get(0), + )?; + assert_eq!(v_local_tier, "local_only"); + + // Assert ZERO remaining 'locked' rows exist anywhere in the database + let remaining_locked_vaults: i64 = conn.query_row( + "SELECT COUNT(*) FROM vaults WHERE privacy_tier = 'locked';", + [], + |row| row.get(0), + )?; + assert_eq!(remaining_locked_vaults, 0, "No locked vaults should remain"); + + let remaining_locked_nodes: i64 = conn.query_row( + "SELECT COUNT(*) FROM nodes WHERE privacy_tier = 'locked';", + [], + |row| row.get(0), + )?; + assert_eq!(remaining_locked_nodes, 0, "No locked nodes should remain"); + + Ok(()) +} diff --git a/core/tests/integration_tests/recursive_vaults.rs b/core/tests/integration_tests/recursive_vaults.rs index 617b6bcc..ed6438e9 100644 --- a/core/tests/integration_tests/recursive_vaults.rs +++ b/core/tests/integration_tests/recursive_vaults.rs @@ -53,7 +53,7 @@ fn test_deep_nesting_15_levels_resolution_and_performance() -> Result<(), Box Result<(), Box Result<(), Box< r#" PRAGMA foreign_keys = OFF; INSERT INTO vaults (id, parent_vault_id, name, privacy_tier) VALUES ('v_x', 'v_y', 'X', 'open'); - INSERT INTO vaults (id, parent_vault_id, name, privacy_tier) VALUES ('v_y', 'v_x', 'Y', 'locked'); + INSERT INTO vaults (id, parent_vault_id, name, privacy_tier) VALUES ('v_y', 'v_x', 'Y', 'redacted'); PRAGMA foreign_keys = ON; "#, )?; @@ -163,7 +163,7 @@ fn test_query_time_cycle_defense_in_depth_terminates_safely() -> Result<(), Box< let privacy = resolve_vault_effective_privacy(&conn, "v_x")?; let elapsed = start.elapsed(); - assert_eq!(privacy, "locked"); + assert_eq!(privacy, "redacted"); assert!( elapsed.as_millis() < 50, "Cyclic query must terminate immediately via cycle guard" diff --git a/db/migrations/0013_privacy_3tier_consolidation.sql b/db/migrations/0013_privacy_3tier_consolidation.sql new file mode 100644 index 00000000..b44ff4cb --- /dev/null +++ b/db/migrations/0013_privacy_3tier_consolidation.sql @@ -0,0 +1,25 @@ +-- Migration: 0013_privacy_3tier_consolidation.sql +-- Streamline privacy model from 4 tiers ('open', 'local_only', 'locked', 'redacted') +-- down to 3 tiers ('open', 'local_only', 'redacted'). +-- Remap all existing 'locked' privacy_tier records in vaults, sub_vaults, nodes, +-- and privacy_overrides to 'redacted' to guarantee zero data exposure / privacy regression. + +-- 1. Remap vaults table +UPDATE vaults +SET privacy_tier = 'redacted' +WHERE privacy_tier = 'locked'; + +-- 2. Remap sub_vaults table (legacy table synchronized via triggers) +UPDATE sub_vaults +SET privacy_tier = 'redacted' +WHERE privacy_tier = 'locked'; + +-- 3. Remap nodes table +UPDATE nodes +SET privacy_tier = 'redacted' +WHERE privacy_tier = 'locked'; + +-- 4. Remap privacy_overrides table (if present) +UPDATE privacy_overrides +SET privacy_tier = 'redacted' +WHERE privacy_tier = 'locked'; diff --git a/ui/components/ImportHub.tsx b/ui/components/ImportHub.tsx index e5e08b34..c1b0f3e1 100644 --- a/ui/components/ImportHub.tsx +++ b/ui/components/ImportHub.tsx @@ -298,36 +298,41 @@ export default function ImportHub({ let unlisten: (() => void) | undefined; let cancelled = false; - void getCurrentWindow() - .onDragDropEvent((event) => { - if (event.payload.type !== "drop") return; - const state = dragDropStateRef.current; - if ( - !state.selectedFramework || - !state.selectedVault || - state.importBusy || - state.starting - ) { - if (state.importBusy) setStartError(BUSY_IMPORT_MESSAGE); - return; - } - const pdfPath = event.payload.paths.find(isPdfPath); - if (!pdfPath) { - setStartError("Only PDF files are supported for import."); - return; - } - void state.beginImport(pdfPath); - }) - .then((fn) => { - if (cancelled) { - fn(); - return; - } - unlisten = fn; - }) - .catch(() => { - // Non-Tauri / browser preview — drag-drop stays unavailable. - }); + if ( + typeof window !== "undefined" && + ("__TAURI_INTERNALS__" in window || "__TAURI_IPC__" in window || "__TAURI__" in window) + ) { + void getCurrentWindow() + .onDragDropEvent((event) => { + if (event.payload.type !== "drop") return; + const state = dragDropStateRef.current; + if ( + !state.selectedFramework || + !state.selectedVault || + state.importBusy || + state.starting + ) { + if (state.importBusy) setStartError(BUSY_IMPORT_MESSAGE); + return; + } + const pdfPath = event.payload.paths.find(isPdfPath); + if (!pdfPath) { + setStartError("Only PDF files are supported for import."); + return; + } + void state.beginImport(pdfPath); + }) + .then((fn) => { + if (cancelled) { + fn(); + return; + } + unlisten = fn; + }) + .catch(() => { + // Non-Tauri / browser preview — drag-drop stays unavailable. + }); + } return () => { cancelled = true; diff --git a/ui/components/NodeEditor.test.tsx b/ui/components/NodeEditor.test.tsx index c9a9a4b5..79b7eb2c 100644 --- a/ui/components/NodeEditor.test.tsx +++ b/ui/components/NodeEditor.test.tsx @@ -74,12 +74,12 @@ const mockIsAuthSetup = vi.mocked(isAuthSetup); const mockUpdateNodeFn = vi.mocked(updateNode); const mockRefreshAllPriorityScores = vi.mocked(refreshAllPriorityScores); -function renderEditor(selectedNodeId: string) { +function renderEditor(selectedNodeId: string, isRedactedUnlocked = false) { return render( ); @@ -125,28 +125,22 @@ describe("NodeEditor privacy gating", () => { expect(document.querySelector(".editor-privacy select")).not.toBeInTheDocument(); }); - it("shows content lock for locked nodes and disables editing", async () => { + it("renders privacy select for unlocked redacted node", async () => { setupNodeMocks(nodeLocked); - renderEditor(nodeLocked.id); + renderEditor(nodeLocked.id, true); await waitFor(() => { - expect(screen.getByText("Content Protected")).toBeInTheDocument(); + const selects = screen.getAllByRole("combobox"); + expect(selects.length).toBeGreaterThan(0); }); - - const titleInput = screen.getByPlaceholderText("Title") as HTMLInputElement; - const summaryInput = screen.getByPlaceholderText("Summary") as HTMLTextAreaElement; - - expect(titleInput.disabled).toBe(true); - expect(summaryInput.disabled).toBe(true); - expect(screen.queryByTestId("node-editor-detail")).not.toBeInTheDocument(); }); - it("disables less restrictive options when parent vault is locked", async () => { + it("disables less restrictive options when parent vault is redacted", async () => { const nodeInLockedVault = { ...nodeOpen, vaultId: vaultLocked.id }; setupNodeMocks(nodeInLockedVault, [vaultLocked]); - renderEditor(nodeInLockedVault.id); + renderEditor(nodeInLockedVault.id, true); await waitFor(() => { const openOption = screen.getByRole("option", { name: "Open" }) as HTMLOptionElement; @@ -156,10 +150,10 @@ describe("NodeEditor privacy gating", () => { const localOnlyOption = screen.getByRole("option", { name: "Local-Only", }) as HTMLOptionElement; - const lockedOption = screen.getByRole("option", { name: "Locked" }) as HTMLOptionElement; + const redactedOption = screen.getByRole("option", { name: "Redacted" }) as HTMLOptionElement; expect(localOnlyOption.disabled).toBe(true); - expect(lockedOption.disabled).toBe(false); + expect(redactedOption.disabled).toBe(false); }); it("auto-saves privacy tier changes via updateNode", async () => { diff --git a/ui/components/NodeEditor.tsx b/ui/components/NodeEditor.tsx index ce134390..4897bae7 100644 --- a/ui/components/NodeEditor.tsx +++ b/ui/components/NodeEditor.tsx @@ -881,12 +881,6 @@ function NodeEditor({ > Local-Only - - - diff --git a/ui/components/PrivacyBadge.test.tsx b/ui/components/PrivacyBadge.test.tsx index d04af7cc..99bf307b 100644 --- a/ui/components/PrivacyBadge.test.tsx +++ b/ui/components/PrivacyBadge.test.tsx @@ -3,20 +3,23 @@ import { describe, it, expect } from "vitest"; import { PrivacyBadge } from "./PrivacyBadge"; describe("PrivacyBadge Component", () => { - it("renders correct classes for standard tiers", () => { + it("renders correct classes for the 3 standard tiers", () => { const { container, rerender } = render(); expect(container.querySelector(".privacy-badge")).toHaveClass("open"); rerender(); expect(container.querySelector(".privacy-badge")).toHaveClass("local_only"); - rerender(); - expect(container.querySelector(".privacy-badge")).toHaveClass("locked"); - rerender(); expect(container.querySelector(".privacy-badge")).toHaveClass("redacted"); }); + it("normalizes legacy 'locked' tier to 'redacted'", () => { + const { container } = render(); + expect(container.querySelector(".privacy-badge")).toHaveClass("redacted"); + expect(container.querySelector(".privacy-badge")).not.toHaveClass("locked"); + }); + it("falls back to open tier for unknown values", () => { const { container } = render(); expect(container.querySelector(".privacy-badge")).toHaveClass("open"); diff --git a/ui/components/PrivacyBadge.tsx b/ui/components/PrivacyBadge.tsx index 630bff2f..a3e5e41d 100644 --- a/ui/components/PrivacyBadge.tsx +++ b/ui/components/PrivacyBadge.tsx @@ -1,14 +1,18 @@ -import { GlobeIcon, HomeIcon, LockIcon, SquareIcon } from "./icons"; +import { GlobeIcon, HomeIcon, SquareIcon } from "./icons"; const TIER_ICONS: Record = { open: , local_only: , - locked: , redacted: , }; +function normalizeBadgeTier(tier: string): string { + if (tier === "locked") return "redacted"; + return tier in TIER_ICONS ? tier : "open"; +} + export function PrivacyBadge({ tier, className }: { tier: string; className?: string }) { - const normalizedTier = tier in TIER_ICONS ? tier : "open"; + const normalizedTier = normalizeBadgeTier(tier); const icon = TIER_ICONS[normalizedTier]; return {icon}; diff --git a/ui/components/SpatialWorkspace.tsx b/ui/components/SpatialWorkspace.tsx index 31a322f5..64dfd238 100644 --- a/ui/components/SpatialWorkspace.tsx +++ b/ui/components/SpatialWorkspace.tsx @@ -2298,7 +2298,6 @@ export default function SpatialWorkspace({ > - diff --git a/ui/components/VaultSidebar.test.tsx b/ui/components/VaultSidebar.test.tsx index 4c912d57..b501019a 100644 --- a/ui/components/VaultSidebar.test.tsx +++ b/ui/components/VaultSidebar.test.tsx @@ -115,7 +115,7 @@ describe("VaultSidebar privacy UI", () => { const user = userEvent.setup(); mockListVaults.mockResolvedValue([vaultLocked]); - renderSidebar(false); + renderSidebar(true); await waitFor(() => { expect(screen.getByText("Locked Vault")).toBeInTheDocument(); diff --git a/ui/components/VaultSidebar.tsx b/ui/components/VaultSidebar.tsx index 46b4e5d8..db5d06f4 100644 --- a/ui/components/VaultSidebar.tsx +++ b/ui/components/VaultSidebar.tsx @@ -1289,7 +1289,6 @@ function VaultSidebar({ > - @@ -1389,7 +1388,6 @@ function VaultSidebar({ > - @@ -1485,7 +1483,6 @@ function VaultSidebar({ > - diff --git a/ui/ipc.ts b/ui/ipc.ts index c069970d..ef4aa5c6 100644 --- a/ui/ipc.ts +++ b/ui/ipc.ts @@ -60,6 +60,13 @@ export type { EmbeddingStatus, }; +export function isTauriAvailable(): boolean { + return ( + typeof window !== "undefined" && + ("__TAURI_INTERNALS__" in window || "__TAURI_IPC__" in window || "__TAURI__" in window) + ); +} + export async function invokeTyped( command: string, payload?: Record @@ -72,6 +79,16 @@ export async function invokeTyped( return { err: String(error) }; } } + if (!isTauriAvailable()) { + if (command === "is_auth_setup") return { ok: true } as unknown as IpcResult; + if (command === "list_vaults") return { ok: [] } as unknown as IpcResult; + if (command === "get_nodes" || command === "get_all_nodes") + return { ok: [] } as unknown as IpcResult; + if (command === "settings_get") return { ok: null } as unknown as IpcResult; + if (command === "settings_set") return { ok: true } as unknown as IpcResult; + if (command === "list_tags") return { ok: [] } as unknown as IpcResult; + return { err: `Tauri API not available in browser environment (${command})` }; + } try { return await invoke>(command, payload); } catch (error) { diff --git a/ui/services/chat.ts b/ui/services/chat.ts index f774a32f..4eabc2a2 100644 --- a/ui/services/chat.ts +++ b/ui/services/chat.ts @@ -60,7 +60,11 @@ export async function chatCreateSession(id: string, summary?: string): Promise { - void chatClearEphemeralSession(id); + try { + await chatClearEphemeralSession(id); + } catch (err) { + console.warn(`[chat] Failed clearing ephemeral session ${id}:`, err); + } return unwrapIpcResult(ipcChatDeleteSession(id)); } diff --git a/ui/utils/privacy.test.ts b/ui/utils/privacy.test.ts index 82f2e055..b02084db 100644 --- a/ui/utils/privacy.test.ts +++ b/ui/utils/privacy.test.ts @@ -12,7 +12,6 @@ describe("shouldOmitSpatialConnector", () => { it("shows connectors for non-redacted tiers regardless of unlock state", () => { expect(shouldOmitSpatialConnector("open", false)).toBe(false); - expect(shouldOmitSpatialConnector("locked", false)).toBe(false); expect(shouldOmitSpatialConnector("local_only", false)).toBe(false); }); }); diff --git a/ui/utils/privacy.ts b/ui/utils/privacy.ts index ea132360..602887b3 100644 --- a/ui/utils/privacy.ts +++ b/ui/utils/privacy.ts @@ -1,16 +1,17 @@ /** - * Amber privacy tiers — four tiers, two axes. Rust source of truth: core/src/privacy.rs + * Amber privacy tiers — three tiers (consolidated from 4-tier). Rust source of truth: core/src/privacy.rs * - * Axis 1 (egress): open → cloud+local | local_only → local only | locked → cloud stub | - * redacted → omitted from cloud - * Axis 2 (disclosure): open/local → full UI | locked → title visible, body gated | - * redacted → metadata hidden + encrypted at rest + * Tiers: + * 0: open -> No restrictions (cloud+local, full UI, searchable) + * 1: local_only -> Local LLM context only, barred from cloud egress + * 2: redacted -> Local encrypted payload, barred from LLMs and search until session unlock */ +export type PrivacyTier = "open" | "local_only" | "redacted"; + const PRIVACY_RANKS: Record = { open: 0, local_only: 1, - locked: 2, - redacted: 3, + redacted: 2, }; type VaultHierarchyLike = { @@ -23,7 +24,11 @@ function normalizeTier(tier?: string | null): string { if (!tier) { return "open"; } - return tier in PRIVACY_RANKS ? tier : "open"; + const lower = tier.toLowerCase(); + if (lower === "locked") { + return "redacted"; + } + return lower in PRIVACY_RANKS ? lower : "open"; } export const getPrivacyRank = (tier?: string | null): number => PRIVACY_RANKS[normalizeTier(tier)]; @@ -156,30 +161,33 @@ export function runPrivacyTests() { const mockVaults: Record = { vault_open: { name: "Open Vault", privacyTier: "open" }, vault_local: { name: "Local Vault", privacyTier: "local_only" }, - vault_locked: { name: "Locked Vault", privacyTier: "locked", parentVaultId: "vault_local" }, - vault_redacted: { name: "Redacted Vault", privacyTier: "redacted" }, + vault_redacted: { + name: "Redacted Vault", + privacyTier: "redacted", + parentVaultId: "vault_local", + }, }; // Test 1: Simple hierarchy - const tier = getVaultEffectivePrivacy("vault_locked", mockVaults); - if (tier !== "locked") { - throw new Error(`Privacy Test 1 Failed: Expected locked, got ${tier}`); + const tier = getVaultEffectivePrivacy("vault_redacted", mockVaults); + if (tier !== "redacted") { + throw new Error(`Privacy Test 1 Failed: Expected redacted, got ${tier}`); } // Test 2: Cycle detection (Self-referencing cycle A -> B -> A) const cyclicVaults: Record = { vault_a: { name: "Vault A", privacyTier: "open", parentVaultId: "vault_b" }, - vault_b: { name: "Vault B", privacyTier: "locked", parentVaultId: "vault_a" }, + vault_b: { name: "Vault B", privacyTier: "redacted", parentVaultId: "vault_a" }, }; - // This should not infinite loop and should resolve vault_a to locked (from vault_b) + // This should not infinite loop and should resolve vault_a to redacted (from vault_b) const tierA = getVaultEffectivePrivacy("vault_a", cyclicVaults); const tierB = getVaultEffectivePrivacy("vault_b", cyclicVaults); - if (tierA !== "locked") { - throw new Error(`Privacy Test 2 Failed: Expected locked for A, got ${tierA}`); + if (tierA !== "redacted") { + throw new Error(`Privacy Test 2 Failed: Expected redacted for A, got ${tierA}`); } - if (tierB !== "locked") { - throw new Error(`Privacy Test 2 Failed: Expected locked for B, got ${tierB}`); + if (tierB !== "redacted") { + throw new Error(`Privacy Test 2 Failed: Expected redacted for B, got ${tierB}`); } // Test 3: Self-referencing (A -> A) @@ -192,10 +200,10 @@ export function runPrivacyTests() { } // Test 4: getVaultDisplayPath - Standard hierarchy - const displayPath1 = getVaultDisplayPath("vault_locked", mockVaults); - if (displayPath1 !== "Local Vault / Locked Vault") { + const displayPath1 = getVaultDisplayPath("vault_redacted", mockVaults, true); + if (displayPath1 !== "Local Vault / Redacted Vault") { throw new Error( - `Privacy Test 4 Failed: Expected 'Local Vault / Locked Vault', got '${displayPath1}'` + `Privacy Test 4 Failed: Expected 'Local Vault / Redacted Vault', got '${displayPath1}'` ); } @@ -218,7 +226,7 @@ export function runPrivacyTests() { } // Test 6: getVaultDisplayPath - Cycle breaking - const displayPathCycle = getVaultDisplayPath("vault_b", cyclicVaults); + const displayPathCycle = getVaultDisplayPath("vault_b", cyclicVaults, true); if (displayPathCycle !== "Vault A / Vault B") { throw new Error( `Privacy Test 6 Failed: Expected 'Vault A / Vault B', got '${displayPathCycle}'` @@ -260,14 +268,10 @@ export function runPrivacyTests() { if (s6 !== "Custom Redacted") { throw new Error(`Privacy Test 9b Failed: Expected 'Custom Redacted', got '${s6}'`); } - const s7 = getPrivacyDisplaySummary("Secret info", "locked", false); + const s7 = getPrivacyDisplaySummary("Secret info", "local_only", false); if (s7 !== "Secret info") { throw new Error(`Privacy Test 9c Failed: Expected 'Secret info', got '${s7}'`); } - const s8 = getPrivacyDisplaySummary("Secret info", "local_only", false); - if (s8 !== "Secret info") { - throw new Error(`Privacy Test 9d Failed: Expected 'Secret info', got '${s8}'`); - } // Test 10: getPrivacyDisplaySummary - Privacy tiers with isUnlocked = true const s9 = getPrivacyDisplaySummary("Secret info", "redacted", true);