Skip to content

Commit c3555c9

Browse files
authored
fix: comprehensive audit fixes — security, performance, tech debt, tests (#51)
Security: - Credentials file permissions set to 0o600 on Unix - Input validation for MCP tool_store (topic max 255, content max 100k) - FTS query hardening (length limit, token cap, quote stripping) - Embedding dims validated to 64-4096 range - Search result limits capped to 100 Performance: - CLI recall uses batch_update_access instead of N individual calls - Reduced cloning in search_hybrid (owned keys iteration) - Reduced cloning in tool_store dedup (struct literal vs clone) Tech debt + Logging: - Replaced eprintln! with tracing::warn! (6 locations) - Fixed unwrap() panic in extract_patterns - Replaced .expect() with proper error handling in embed command - Added tracing on rollback paths and datetime parse failures - Added DEFAULT_EMBEDDING_DIMS constant (replaces hardcoded 384) - Added DEDUP_SIMILARITY_THRESHOLD constant (replaces magic 0.85) Redundancy + Dead code: - Removed unused delete_cloud_memory and sync_memory_background - Extracted topic_matches/keyword_matches to icm-core (shared CLI+MCP) - Added MSG_NO_MEMORIES constant for consistent messaging Tests (+29 new): - Store: empty queries, nonexistent CRUD, batch access, auto-consolidate, decay, prune, topics, stats, topic prefix - MCP: empty recall, input validation (topic/content length) - Security: FTS sanitization, embedding dims bounds, search limits, credentials permissions
1 parent b2a98da commit c3555c9

8 files changed

Lines changed: 499 additions & 116 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/icm-cli/src/cloud.rs

Lines changed: 30 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ pub fn save_credentials(creds: &Credentials) -> Result<()> {
7777
}
7878
let json = serde_json::to_string_pretty(creds)?;
7979
std::fs::write(&path, json)?;
80+
81+
// Restrict file permissions to owner-only on Unix (0o600)
82+
#[cfg(unix)]
83+
{
84+
use std::os::unix::fs::PermissionsExt;
85+
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
86+
}
87+
8088
Ok(())
8189
}
8290

@@ -459,51 +467,6 @@ pub fn pull_memories(
459467
Ok(memories)
460468
}
461469

462-
/// Delete a memory from RTK Cloud.
463-
/// DELETE {endpoint}/api/icm/memories/{id}
464-
pub fn delete_cloud_memory(creds: &Credentials, memory_id: &str) -> Result<()> {
465-
let url = format!(
466-
"{}/api/icm/memories/{}",
467-
creds.endpoint.trim_end_matches('/'),
468-
memory_id
469-
);
470-
471-
let resp = ureq::delete(&url)
472-
.set("Authorization", &format!("Bearer {}", creds.token))
473-
.set("X-Org-Id", &creds.org_id)
474-
.timeout(std::time::Duration::from_secs(5))
475-
.call()
476-
.context("Failed to delete cloud memory")?;
477-
478-
let status = resp.status();
479-
if status != 200 && status != 204 {
480-
let body = resp.into_string().unwrap_or_default();
481-
anyhow::bail!("Cloud delete failed ({}): {}", status, body);
482-
}
483-
484-
Ok(())
485-
}
486-
487-
/// Fire-and-forget sync: push memory in a background thread.
488-
/// Used after store/update operations to sync without blocking.
489-
pub fn sync_memory_background(memory: Memory) {
490-
let creds = match load_credentials() {
491-
Some(c) => c,
492-
None => return,
493-
};
494-
495-
// Only sync project/org scoped memories
496-
if memory.scope == Scope::User {
497-
return;
498-
}
499-
500-
std::thread::spawn(move || {
501-
if let Err(e) = sync_memory(&creds, &memory) {
502-
tracing::warn!("Cloud sync failed: {}", e);
503-
}
504-
});
505-
}
506-
507470
/// Check if cloud sync is available (credentials exist and scope requires it).
508471
pub fn requires_cloud(scope: Scope) -> bool {
509472
scope != Scope::User
@@ -541,6 +504,28 @@ mod tests {
541504
assert_eq!(url_decode("no_encoding"), "no_encoding");
542505
}
543506

507+
#[cfg(unix)]
508+
#[test]
509+
fn test_credentials_file_permissions_0600() {
510+
use std::os::unix::fs::PermissionsExt;
511+
512+
// Create a temp file, apply the same permission logic as save_credentials
513+
let dir = std::env::temp_dir().join(format!("icm-test-{}", std::process::id()));
514+
std::fs::create_dir_all(&dir).unwrap();
515+
let path = dir.join("test-credentials.json");
516+
517+
std::fs::write(&path, "{}").unwrap();
518+
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
519+
520+
let metadata = std::fs::metadata(&path).unwrap();
521+
let mode = metadata.permissions().mode() & 0o777;
522+
assert_eq!(mode, 0o600, "credentials file should be owner-only (0o600)");
523+
524+
// Cleanup
525+
let _ = std::fs::remove_file(&path);
526+
let _ = std::fs::remove_dir(&dir);
527+
}
528+
544529
#[test]
545530
fn test_requires_cloud() {
546531
assert!(!requires_cloud(Scope::User));

crates/icm-cli/src/main.rs

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ use clap::{Parser, Subcommand, ValueEnum};
1414
use serde_json::Value;
1515

1616
use icm_core::{
17-
Concept, ConceptLink, Feedback, FeedbackStore, Importance, Label, Memoir, MemoirStore, Memory,
18-
MemoryStore, Relation,
17+
keyword_matches, topic_matches, Concept, ConceptLink, Feedback, FeedbackStore, Importance,
18+
Label, Memoir, MemoirStore, Memory, MemoryStore, Relation, MSG_NO_MEMORIES,
1919
};
2020
use icm_store::SqliteStore;
2121

@@ -678,7 +678,7 @@ fn main() -> Result<()> {
678678
use icm_core::Embedder;
679679
e.dimensions()
680680
})
681-
.unwrap_or(384);
681+
.unwrap_or(icm_core::DEFAULT_EMBEDDING_DIMS);
682682
let db_path = cli.db.clone().unwrap_or_else(default_db_path);
683683
let store = open_store(cli.db, embedding_dims)?;
684684

@@ -774,7 +774,10 @@ fn main() -> Result<()> {
774774
} => {
775775
#[cfg(feature = "embeddings")]
776776
{
777-
let emb = embedder.as_ref().expect("embeddings feature enabled");
777+
let emb = match embedder.as_ref() {
778+
Some(e) => e,
779+
None => bail!("embeddings not available — check your configuration"),
780+
};
778781
cmd_embed(&store, emb, topic.as_deref(), force, batch_size)
779782
}
780783
#[cfg(not(feature = "embeddings"))]
@@ -919,27 +922,30 @@ fn cmd_recall(
919922
keyword: Option<&str>,
920923
) -> Result<()> {
921924
// Auto-decay if >24h since last decay
922-
let _ = store.maybe_auto_decay();
925+
if let Err(e) = store.maybe_auto_decay() {
926+
tracing::warn!(error = %e, "auto-decay failed during recall");
927+
}
923928

924929
// Try hybrid search if embedder is available
925930
if let Some(emb) = embedder {
926931
if let Ok(query_emb) = emb.embed(query) {
927932
if let Ok(results) = store.search_hybrid(query, &query_emb, limit) {
928933
let mut scored = results;
929934
if let Some(t) = topic {
930-
scored.retain(|(m, _)| m.topic == t);
935+
scored.retain(|(m, _)| topic_matches(&m.topic, t));
931936
}
932937
if let Some(kw) = keyword {
933-
scored.retain(|(m, _)| m.keywords.iter().any(|k| k.contains(kw)));
938+
scored.retain(|(m, _)| keyword_matches(&m.keywords, kw));
934939
}
935940

936941
if scored.is_empty() {
937-
println!("No memories found.");
942+
println!("{MSG_NO_MEMORIES}");
938943
return Ok(());
939944
}
940945

946+
let ids: Vec<&str> = scored.iter().map(|(m, _)| m.id.as_str()).collect();
947+
let _ = store.batch_update_access(&ids);
941948
for (mem, score) in &scored {
942-
let _ = store.update_access(&mem.id);
943949
print_memory_detail(mem, Some(*score));
944950
}
945951
return Ok(());
@@ -956,19 +962,20 @@ fn cmd_recall(
956962
}
957963

958964
if let Some(t) = topic {
959-
results.retain(|m| m.topic == t);
965+
results.retain(|m| topic_matches(&m.topic, t));
960966
}
961967
if let Some(kw) = keyword {
962-
results.retain(|m| m.keywords.iter().any(|k| k.contains(kw)));
968+
results.retain(|m| keyword_matches(&m.keywords, kw));
963969
}
964970

965971
if results.is_empty() {
966-
println!("No memories found.");
972+
println!("{MSG_NO_MEMORIES}");
967973
return Ok(());
968974
}
969975

976+
let ids: Vec<&str> = results.iter().map(|m| m.id.as_str()).collect();
977+
let _ = store.batch_update_access(&ids);
970978
for mem in &results {
971-
let _ = store.update_access(&mem.id);
972979
print_memory_detail(mem, None);
973980
}
974981

@@ -992,7 +999,7 @@ fn cmd_list(store: &SqliteStore, topic: Option<&str>, all: bool, sort: SortField
992999
}
9931000

9941001
if memories.is_empty() {
995-
println!("No memories found.");
1002+
println!("{MSG_NO_MEMORIES}");
9961003
return Ok(());
9971004
}
9981005

crates/icm-core/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ pub mod memoir_store;
99
pub mod memory;
1010
pub mod store;
1111

12+
/// Default embedding vector dimensions (used when no embedder is configured).
13+
pub const DEFAULT_EMBEDDING_DIMS: usize = 384;
14+
1215
pub use embedder::Embedder;
1316
pub use error::{IcmError, IcmResult};
1417
#[cfg(feature = "embeddings")]
@@ -21,3 +24,16 @@ pub use memory::{
2124
Importance, Memory, MemorySource, PatternCluster, Scope, StoreStats, TopicHealth,
2225
};
2326
pub use store::MemoryStore;
27+
28+
/// Common message for empty search results.
29+
pub const MSG_NO_MEMORIES: &str = "No memories found.";
30+
31+
/// Check if a memory's topic matches a filter (supports prefix with ':').
32+
pub fn topic_matches(memory_topic: &str, filter: &str) -> bool {
33+
memory_topic == filter || memory_topic.starts_with(&format!("{filter}:"))
34+
}
35+
36+
/// Check if any keyword contains the filter string.
37+
pub fn keyword_matches(keywords: &[String], filter: &str) -> bool {
38+
keywords.iter().any(|k| k.contains(filter))
39+
}

0 commit comments

Comments
 (0)