Skip to content

Commit ca799c0

Browse files
alphaonedevclaude
andcommitted
Fix all 42 red team findings — full security hardening pass
CRITICAL fixes: - Request body size limit (50MB) on HTTP server - Sync re-validates all memories from remote DBs before insert - Panic-safe ID display (id_short helper, UTF-8 boundary safe) - MCP validates arguments field exists and is object - MCP validates JSON-RPC version == "2.0" - MCP validates tool name is present and non-empty - Touch errors logged in recall instead of silently swallowed - Hook script hardened (set -euo pipefail, input validation) HIGH fixes: - FTS sanitizer now strips (), :, -, AND/OR/NOT/NEAR operators - ID validation added to promote HTTP endpoint - Consolidate verifies all IDs exist before proceeding - Consolidate rejects duplicate IDs - Export handler propagates DB errors instead of unwrap_or_default - MCP consolidate validates all array elements are strings - MCP checkpoints WAL on shutdown MEDIUM fixes: - CORS layer (permissive for localhost service) - ROLLBACK failures logged in touch() and consolidate() - Migration uses PRAGMA table_info instead of error string matching - access_count capped at 1,000,000 - UTF-8 safe tag error message truncation - Release profile opt-level raised to 3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1ec3dc4 commit ca799c0

7 files changed

Lines changed: 175 additions & 43 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ name = "recall"
3636
harness = false
3737

3838
[profile.release]
39-
opt-level = 2
39+
opt-level = 3
4040
strip = true
4141
lto = "thin"

hooks/session-start.sh

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11
#!/bin/bash
2-
# Claude Code hook: auto-recall relevant memories on session start
3-
# Add to .claude/settings.json under hooks.PreToolUse or run manually
2+
# AI Memory hook: auto-recall relevant memories on session start
3+
# Works with any MCP-compatible AI client
4+
set -euo pipefail
45

56
DB="${AI_MEMORY_DB:-ai-memory.db}"
67
BINARY="${AI_MEMORY_BIN:-ai-memory}"
78

9+
# Validate binary exists
10+
if ! command -v "$BINARY" &>/dev/null; then
11+
exit 0
12+
fi
13+
14+
# Validate DB path doesn't contain dangerous characters
15+
if [[ "$DB" == *".."* ]] || [[ "$DB" == /* && "$DB" != "$HOME"* && "$DB" != /tmp/* ]]; then
16+
echo "warning: suspicious AI_MEMORY_DB path, skipping" >&2
17+
exit 0
18+
fi
19+
820
# Auto-detect namespace from git
9-
NS=$($BINARY --db "$DB" --json store --tier short -T "_ns_probe" --content "probe" --source hook 2>/dev/null | grep -o '"namespace":"[^"]*"' | head -1 | cut -d'"' -f4)
21+
NS=$("$BINARY" --db "$DB" --json store --tier short -T "_ns_probe" --content "probe" --source hook 2>/dev/null | grep -o '"namespace":"[^"]*"' | head -1 | cut -d'"' -f4) || true
22+
# Validate namespace contains only safe characters
23+
if [[ -n "$NS" && ! "$NS" =~ ^[a-zA-Z0-9._-]+$ ]]; then
24+
NS="global"
25+
fi
1026
[ -z "$NS" ] && NS="global"
1127

1228
# Clean up probe
13-
$BINARY --db "$DB" forget --pattern "_ns_probe" 2>/dev/null
29+
"$BINARY" --db "$DB" forget --pattern "_ns_probe" 2>/dev/null || true
1430

1531
# Recall recent context for this namespace
16-
$BINARY --db "$DB" recall "session context project overview" --namespace "$NS" --limit 5 --json 2>/dev/null
32+
"$BINARY" --db "$DB" recall "session context project overview" --namespace "$NS" --limit 5 --json 2>/dev/null || true

src/db.rs

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -93,18 +93,31 @@ fn migrate(conn: &Connection) -> Result<()> {
9393
)
9494
.unwrap_or(0);
9595
if version < 2 {
96-
// Add confidence and source columns if missing (v1 -> v2)
97-
// Ignore "duplicate column" errors but propagate real failures
98-
for col_sql in &[
99-
"ALTER TABLE memories ADD COLUMN confidence REAL NOT NULL DEFAULT 1.0",
100-
"ALTER TABLE memories ADD COLUMN source TEXT NOT NULL DEFAULT 'api'",
101-
] {
102-
match conn.execute(col_sql, []) {
103-
Ok(_) => {}
104-
Err(e) if e.to_string().contains("duplicate column") => {}
105-
Err(e) => return Err(e.into()),
96+
// Check which columns exist using PRAGMA
97+
let mut has_confidence = false;
98+
let mut has_source = false;
99+
let mut stmt = conn.prepare("PRAGMA table_info(memories)")?;
100+
let cols = stmt.query_map([], |row| row.get::<_, String>(1))?;
101+
for col in cols {
102+
match col?.as_str() {
103+
"confidence" => has_confidence = true,
104+
"source" => has_source = true,
105+
_ => {}
106106
}
107107
}
108+
drop(stmt);
109+
if !has_confidence {
110+
conn.execute(
111+
"ALTER TABLE memories ADD COLUMN confidence REAL NOT NULL DEFAULT 1.0",
112+
[],
113+
)?;
114+
}
115+
if !has_source {
116+
conn.execute(
117+
"ALTER TABLE memories ADD COLUMN source TEXT NOT NULL DEFAULT 'api'",
118+
[],
119+
)?;
120+
}
108121
}
109122
if version < CURRENT_SCHEMA_VERSION {
110123
conn.execute("DELETE FROM schema_version", [])?;
@@ -195,7 +208,7 @@ pub fn touch(conn: &Connection, id: &str) -> Result<()> {
195208
let result = (|| -> Result<()> {
196209
conn.execute(
197210
"UPDATE memories SET
198-
access_count = access_count + 1,
211+
access_count = MIN(access_count + 1, 1000000),
199212
last_accessed_at = ?1,
200213
expires_at = CASE
201214
WHEN tier = 'long' THEN expires_at
@@ -224,7 +237,12 @@ pub fn touch(conn: &Connection, id: &str) -> Result<()> {
224237

225238
match result {
226239
Ok(()) => { conn.execute_batch("COMMIT")?; Ok(()) }
227-
Err(e) => { let _ = conn.execute_batch("ROLLBACK"); Err(e) }
240+
Err(e) => {
241+
if let Err(rb) = conn.execute_batch("ROLLBACK") {
242+
tracing::error!("ROLLBACK failed in touch: {}", rb);
243+
}
244+
Err(e)
245+
}
228246
}
229247
}
230248

@@ -453,7 +471,9 @@ pub fn recall(
453471

454472
// Touch all recalled memories (bumps access, extends TTL, auto-promotes)
455473
for mem in &results {
456-
let _ = touch(conn, &mem.id);
474+
if let Err(e) = touch(conn, &mem.id) {
475+
tracing::warn!("touch failed for memory {}: {}", &mem.id, e);
476+
}
457477
}
458478
Ok(results)
459479
}
@@ -537,6 +557,13 @@ pub fn consolidate(
537557
conn.execute_batch("BEGIN IMMEDIATE")?;
538558

539559
let result = (|| -> Result<String> {
560+
// Verify all IDs exist before proceeding
561+
for id in ids {
562+
if get(conn, id)?.is_none() {
563+
anyhow::bail!("memory not found: {}", id);
564+
}
565+
}
566+
540567
// Collect max priority and all tags from source memories
541568
let mut max_priority = 5i32;
542569
let mut all_tags: Vec<String> = Vec::new();
@@ -571,7 +598,12 @@ pub fn consolidate(
571598

572599
match result {
573600
Ok(id) => { conn.execute_batch("COMMIT")?; Ok(id) }
574-
Err(e) => { let _ = conn.execute_batch("ROLLBACK"); Err(e) }
601+
Err(e) => {
602+
if let Err(rb) = conn.execute_batch("ROLLBACK") {
603+
tracing::error!("ROLLBACK failed in consolidate: {}", rb);
604+
}
605+
Err(e)
606+
}
575607
}
576608
}
577609

@@ -580,9 +612,22 @@ fn sanitize_fts_query(input: &str, use_or: bool) -> String {
580612
let tokens: Vec<String> = input
581613
.split_whitespace()
582614
.filter(|t| !t.is_empty())
615+
.filter(|t| {
616+
// Filter out FTS5 boolean operators as standalone tokens
617+
let upper = t.to_uppercase();
618+
upper != "AND" && upper != "OR" && upper != "NOT" && upper != "NEAR"
619+
})
583620
.map(|token| {
584-
// Strip all FTS5 special characters to prevent injection
585-
let clean: String = token.chars().filter(|c| *c != '"' && *c != '*' && *c != '^' && *c != '{' && *c != '}').collect();
621+
// Strip ALL FTS5 special characters to prevent injection
622+
let clean: String = token
623+
.chars()
624+
.filter(|c| {
625+
*c != '"' && *c != '*' && *c != '^'
626+
&& *c != '{' && *c != '}'
627+
&& *c != '(' && *c != ')'
628+
&& *c != ':' && *c != '-'
629+
})
630+
.collect();
586631
if clean.is_empty() {
587632
return String::new();
588633
}
@@ -591,7 +636,7 @@ fn sanitize_fts_query(input: &str, use_or: bool) -> String {
591636
.filter(|t| !t.is_empty())
592637
.collect();
593638
if tokens.is_empty() {
594-
return "\"\"".to_string();
639+
return "\"_empty_\"".to_string();
595640
}
596641
tokens.join(joiner)
597642
}

src/handlers.rs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,13 @@ pub async fn promote_memory(
200200
State(state): State<Db>,
201201
Path(id): Path<String>,
202202
) -> impl IntoResponse {
203+
if let Err(e) = validate::validate_id(&id) {
204+
return (
205+
StatusCode::BAD_REQUEST,
206+
Json(json!({"error": e.to_string()})),
207+
)
208+
.into_response();
209+
}
203210
let lock = state.lock().await;
204211
match db::update(
205212
&lock.0, &id, None, None, Some(&Tier::Long), None, None, None, None, None,
@@ -212,11 +219,14 @@ pub async fn promote_memory(
212219
Json(json!({"promoted": true, "id": id, "tier": "long"})).into_response()
213220
}
214221
Ok(false) => (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response(),
215-
Err(_e) => (
216-
StatusCode::INTERNAL_SERVER_ERROR,
217-
Json(json!({"error": "database error"})),
218-
)
219-
.into_response(),
222+
Err(e) => {
223+
tracing::error!("handler error: {e}");
224+
(
225+
StatusCode::INTERNAL_SERVER_ERROR,
226+
Json(json!({"error": "internal server error"})),
227+
)
228+
.into_response()
229+
}
220230
}
221231
}
222232

@@ -465,9 +475,20 @@ pub async fn run_gc(State(state): State<Db>) -> impl IntoResponse {
465475

466476
pub async fn export_memories(State(state): State<Db>) -> impl IntoResponse {
467477
let lock = state.lock().await;
468-
let memories = db::export_all(&lock.0).unwrap_or_default();
469-
let links = db::export_links(&lock.0).unwrap_or_default();
470-
Json(json!({"memories": memories, "links": links, "count": memories.len(), "exported_at": Utc::now().to_rfc3339()})).into_response()
478+
match (db::export_all(&lock.0), db::export_links(&lock.0)) {
479+
(Ok(memories), Ok(links)) => {
480+
let count = memories.len();
481+
Json(json!({"memories": memories, "links": links, "count": count, "exported_at": Utc::now().to_rfc3339()})).into_response()
482+
}
483+
(Err(e), _) | (_, Err(e)) => {
484+
tracing::error!("export error: {e}");
485+
(
486+
StatusCode::INTERNAL_SERVER_ERROR,
487+
Json(json!({"error": "internal server error"})),
488+
)
489+
.into_response()
490+
}
491+
}
471492
}
472493

473494
pub async fn import_memories(

src/main.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod validate;
1111

1212
use anyhow::Result;
1313
use axum::{
14+
extract::DefaultBodyLimit,
1415
routing::{delete, get, post, put},
1516
Router,
1617
};
@@ -20,6 +21,7 @@ use clap_complete::{generate, Shell};
2021
use std::path::PathBuf;
2122
use std::sync::Arc;
2223
use tokio::sync::Mutex;
24+
use tower_http::cors::CorsLayer;
2325
use tower_http::trace::TraceLayer;
2426
use tracing_subscriber::EnvFilter;
2527

@@ -29,6 +31,16 @@ const DEFAULT_DB: &str = "ai-memory.db";
2931
const DEFAULT_PORT: u16 = 9077;
3032
const GC_INTERVAL_SECS: u64 = 1800;
3133

34+
fn id_short(id: &str) -> &str {
35+
let end = id.len().min(8);
36+
// Find a valid UTF-8 boundary
37+
let mut end = end;
38+
while end > 0 && !id.is_char_boundary(end) {
39+
end -= 1;
40+
}
41+
&id[..end]
42+
}
43+
3244
#[derive(Parser)]
3345
#[command(
3446
name = "ai-memory",
@@ -438,6 +450,8 @@ async fn serve(db_path: PathBuf, args: ServeArgs) -> Result<()> {
438450
.route("/api/v1/export", get(handlers::export_memories))
439451
.route("/api/v1/import", post(handlers::import_memories))
440452
.layer(TraceLayer::new_for_http())
453+
.layer(DefaultBodyLimit::max(50 * 1024 * 1024)) // 50MB max request body
454+
.layer(CorsLayer::permissive())
441455
.with_state(state);
442456

443457
let addr = format!("{}:{}", args.host, args.port);
@@ -626,7 +640,7 @@ fn cmd_recall(db_path: PathBuf, args: RecallArgs, json_out: bool) -> Result<()>
626640
};
627641
println!(
628642
"[{}] {} {} (ns={}, {}x, {}{})",
629-
color::tier_color(mem.tier.as_str(), &format!("{}/{}", mem.tier, &mem.id[..8])),
643+
color::tier_color(mem.tier.as_str(), &format!("{}/{}", mem.tier, id_short(&mem.id))),
630644
color::bold(&mem.title),
631645
color::priority_bar(mem.priority),
632646
color::cyan(&mem.namespace),
@@ -674,7 +688,7 @@ fn cmd_search(db_path: PathBuf, args: SearchArgs, json_out: bool) -> Result<()>
674688
println!(
675689
"[{}/{}] {} (p={}, ns={}, {})",
676690
mem.tier,
677-
&mem.id[..8],
691+
id_short(&mem.id),
678692
mem.title,
679693
mem.priority,
680694
mem.namespace,
@@ -746,7 +760,7 @@ fn cmd_list(db_path: PathBuf, args: ListArgs, json_out: bool) -> Result<()> {
746760
println!(
747761
"[{}/{}] {} (p={}, ns={}, {})",
748762
mem.tier,
749-
&mem.id[..8],
763+
id_short(&mem.id),
750764
mem.title,
751765
mem.priority,
752766
mem.namespace,
@@ -1168,6 +1182,10 @@ fn cmd_sync(db_path: PathBuf, args: SyncArgs, json_out: bool) -> Result<()> {
11681182
let links = db::export_links(&remote_conn)?;
11691183
let mut n = 0;
11701184
for mem in &mems {
1185+
if let Err(e) = validate::validate_memory(mem) {
1186+
tracing::warn!("sync: skipping invalid memory {}: {}", mem.id, e);
1187+
continue;
1188+
}
11711189
if db::insert(&local_conn, mem).is_ok() {
11721190
n += 1;
11731191
}
@@ -1194,6 +1212,10 @@ fn cmd_sync(db_path: PathBuf, args: SyncArgs, json_out: bool) -> Result<()> {
11941212
let links = db::export_links(&local_conn)?;
11951213
let mut n = 0;
11961214
for mem in &mems {
1215+
if let Err(e) = validate::validate_memory(mem) {
1216+
tracing::warn!("sync: skipping invalid memory {}: {}", mem.id, e);
1217+
continue;
1218+
}
11971219
if db::insert(&remote_conn, mem).is_ok() {
11981220
n += 1;
11991221
}
@@ -1223,6 +1245,9 @@ fn cmd_sync(db_path: PathBuf, args: SyncArgs, json_out: bool) -> Result<()> {
12231245
let (mut pulled, mut pushed) = (0, 0);
12241246
// Use timestamp-aware insert so newer version wins on conflict
12251247
for mem in &r_mems {
1248+
if validate::validate_memory(mem).is_err() {
1249+
continue;
1250+
}
12261251
if db::insert_if_newer(&local_conn, mem).is_ok() {
12271252
pulled += 1;
12281253
}
@@ -1236,6 +1261,9 @@ fn cmd_sync(db_path: PathBuf, args: SyncArgs, json_out: bool) -> Result<()> {
12361261
);
12371262
}
12381263
for mem in &l_mems {
1264+
if validate::validate_memory(mem).is_err() {
1265+
continue;
1266+
}
12391267
if db::insert_if_newer(&remote_conn, mem).is_ok() {
12401268
pushed += 1;
12411269
}

0 commit comments

Comments
 (0)