mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
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:
parent
b3bcf5d216
commit
5f6ba0e7d1
9 changed files with 73 additions and 12 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue