fix(audit): panic/DoS/correctness bugs on core (swarm, trivial tier)

The main-compatible subset of the trivial-tier audit fixes (the backfill.rs +
trace_recorder.rs fixes are deferred — those files arrive with the backfill
feature PR). All verified against real code:

- compression: saturating_sub on bytes_saved (short inputs compress larger)
- dreams stage1_replay: select most-recent-N then order, not first-N-then-sort
- prediction_error: count total_evaluations in direct evaluate_with_intent
  branches (rates could exceed 1.0)
- reconsolidation: document the (already-correct) idempotency via remove()
- speculative file_memory_map: dedup + cap (was unbounded growth)
- relationships: reject duplicate ids (silent overwrite corrupted the index)
- github: validate owner/repo charset (raw URL-path interpolation)
- redmine list_live_ids: u64 offset + wrap/page-cap guards
- consolidation/phases: char-boundary-safe truncation (UTF-8 slice panic)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-28 14:47:51 -05:00
parent b3bcf5d216
commit 5f6ba0e7d1
9 changed files with 73 additions and 12 deletions

View file

@ -274,7 +274,10 @@ impl MemoryCompressor {
// Update stats
self.stats.memories_compressed += memories.len();
self.stats.compressions_created += 1;
self.stats.bytes_saved += original_size - compressed.compressed_size;
// saturating_sub: short/repetitive memories can compress LARGER than the
// original (header + bullet facts > tiny inputs), which would underflow a
// usize subtraction (panic in debug, huge wrap in release).
self.stats.bytes_saved += original_size.saturating_sub(compressed.compressed_size);
self.stats.operations += 1;
self.update_average_stats(&compressed);

View file

@ -357,8 +357,13 @@ impl ConsolidationScheduler {
/// Stage 1: Replay recent memories in sequence
fn stage1_replay(&self, memories: &[DreamMemory]) -> MemoryReplay {
// Sort by creation time for sequential replay
let mut sorted: Vec<_> = memories.iter().take(MAX_REPLAY_MEMORIES).collect();
// Select the MOST RECENT memories, then order them chronologically for
// sequential replay. The old code took the first N in arbitrary input
// order BEFORE sorting, so "recent" memories were dropped whenever the
// caller's slice was not already recency-ordered.
let mut sorted: Vec<_> = memories.iter().collect();
sorted.sort_by_key(|m| std::cmp::Reverse(m.created_at));
sorted.truncate(MAX_REPLAY_MEMORIES);
sorted.sort_by_key(|m| m.created_at);
let sequence: Vec<String> = sorted.iter().map(|m| m.id.clone()).collect();

View file

@ -493,6 +493,10 @@ impl PredictionErrorGate {
) -> GateDecision {
match intent {
EvaluationIntent::ForceCreate => {
// Count this evaluation: the fallback branches reach evaluate()
// (which counts), but these direct branches must count themselves
// or create/update/supersede rates can exceed 1.0.
self.stats.total_evaluations += 1;
self.stats.creates += 1;
GateDecision::Create {
reason: CreateReason::ExplicitCreate,
@ -504,6 +508,7 @@ impl PredictionErrorGate {
// Find the target candidate
if let Some(c) = candidates.iter().find(|c| c.id == target_id) {
let similarity = cosine_similarity(new_embedding, &c.embedding);
self.stats.total_evaluations += 1;
self.stats.updates += 1;
GateDecision::Update {
target_id: target_id.clone(),
@ -522,6 +527,7 @@ impl PredictionErrorGate {
} => {
if let Some(c) = candidates.iter().find(|c| c.id == old_memory_id) {
let similarity = cosine_similarity(new_embedding, &c.embedding);
self.stats.total_evaluations += 1;
self.stats.supersedes += 1;
GateDecision::Supersede {
old_memory_id,

View file

@ -551,6 +551,10 @@ impl ReconsolidationManager {
///
/// Returns the reconsolidation result with all applied modifications.
pub fn reconsolidate(&mut self, memory_id: &str) -> Option<ReconsolidatedMemory> {
// remove() already guarantees idempotency: a second call finds no entry
// and returns None via `?`. The `reconsolidated` flag was never set to
// true anywhere, so the guard below was dead — but keep it as a correct,
// explicit belt-and-suspenders in case the entry is ever retained.
let state = self.labile_memories.remove(memory_id)?;
if state.reconsolidated {

View file

@ -268,13 +268,23 @@ impl SpeculativeRetriever {
}
}
// Update file-memory associations
// Update file-memory associations. Dedupe and cap per file so the map
// cannot grow without bound in a long-running server (mirrors the
// MAX_PATTERN_HISTORY trim on access_sequence above).
if let Some(file) = file_context
&& let Ok(mut map) = self.file_memory_map.write()
{
map.entry(file.to_string())
.or_insert_with(Vec::new)
.push(memory_id.to_string());
let ids = map.entry(file.to_string()).or_insert_with(Vec::new);
let id = memory_id.to_string();
if !ids.contains(&id) {
ids.push(id);
// keep only the most recent N associations per file
const MAX_FILE_MEMORIES: usize = 256;
if ids.len() > MAX_FILE_MEMORIES {
let excess = ids.len() - MAX_FILE_MEMORIES;
ids.drain(0..excess);
}
}
}
}

View file

@ -176,6 +176,15 @@ impl RelationshipTracker {
let id = relationship.id.clone();
// Reject duplicate ids: a second insert with the same id but different
// files would overwrite the relationship while leaving the stale id in
// each previous file's index, corrupting get_related_files.
if self.relationships.contains_key(&id) {
return Err(RelationshipError::Invalid(format!(
"duplicate relationship id: {id}"
)));
}
// Index by each file
for file in &relationship.files {
self.file_relationships

View file

@ -103,6 +103,18 @@ impl GithubConnector {
"owner and repo are required".to_string(),
));
}
// owner/repo are interpolated raw into request URLs; restrict them to
// GitHub's actual charset so `/`, `%`, `?`, `#`, traversal sequences, etc.
// cannot break out of the path or redirect the request.
let valid = |s: &str| {
s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
};
if !valid(&config.owner) || !valid(&config.repo) {
return Err(ConnectorError::Config(
"owner/repo may only contain [A-Za-z0-9._-]".to_string(),
));
}
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.build()

View file

@ -491,7 +491,11 @@ impl Connector for RedmineConnector {
// Enumerate all issue ids (open AND closed) for the reconcile pass.
// status_id=* is mandatory here too, or closed issues read as deleted.
let mut ids = Vec::new();
let mut offset: u32 = 0;
// u64 offset (a u32 could wrap on a huge/compromised total_count, turning
// the loop infinite + allocating unboundedly). Also hard-cap pages.
let mut offset: u64 = 0;
const MAX_PAGES: u32 = 10_000;
let mut pages = 0u32;
loop {
let url = format!("{}/issues.json", self.config.root());
let resp = self
@ -518,8 +522,14 @@ impl Connector for RedmineConnector {
for issue in &page.issues {
ids.push(issue.id.to_string());
}
offset += page.issues.len() as u32;
if (offset as u64) >= page.total_count {
let new_offset = offset + page.issues.len() as u64;
// Defensive: a non-advancing page would loop forever.
if new_offset <= offset {
break;
}
offset = new_offset;
pages += 1;
if offset >= page.total_count || pages >= MAX_PAGES {
break;
}
}

View file

@ -626,13 +626,15 @@ impl DreamEngine {
tag_b: &str,
conn_type: CreativeConnectionType,
) -> String {
// char-boundary-safe: &content[..60] panics if a multi-byte UTF-8 char
// straddles byte 60. get(..60) returns None at a non-boundary => fall back.
let a_summary = if a.content.len() > 60 {
&a.content[..60]
a.content.get(..60).unwrap_or(&a.content)
} else {
&a.content
};
let b_summary = if b.content.len() > 60 {
&b.content[..60]
b.content.get(..60).unwrap_or(&b.content)
} else {
&b.content
};