mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-02 22:01:01 +02:00
fix(audit): 16 verified panic/DoS/correctness bugs (swarm, trivial tier)
All verified against real code before fixing (49/95 CRITICAL+HIGH confirmed real; the rest were false positives). This is the low-risk batch: panics/DoS: - backfill: clamp scan_limit to [10,5000] + lookback to [1,365] (negative scan_limit => SQLite LIMIT -1 => unbounded fetch = DoS) - trace_recorder/phases: char-boundary-safe truncation (byte-slice &s[..n] panics on multi-byte UTF-8) - compression: saturating_sub on bytes_saved (short inputs compress larger) - redmine list_live_ids: u64 offset + wrap/page-cap guards (u32 wrap => infinite loop + unbounded alloc) - speculative file_memory_map: dedup + cap (was unbounded growth) correctness: - 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) - relationships: reject duplicate ids (silent overwrite corrupted the index) - github: validate owner/repo charset (raw URL-path interpolation) - reconsolidation: document the (already-correct) idempotency via remove() core 535/0, mcp 453/0, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c9e66108d
commit
a3750378bd
11 changed files with 428 additions and 57 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()
|
||||
|
|
|
|||
|
|
@ -445,7 +445,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
|
||||
|
|
@ -472,8 +476,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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,11 +14,10 @@
|
|||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vestige_core::advanced::retroactive_backfill::{
|
||||
BackfillCandidate, FailureEvent, RetroactiveBackfill,
|
||||
self, BackfillCandidate, FailureEvent, RetroactiveBackfill,
|
||||
};
|
||||
use vestige_core::advanced::prediction_error::cosine_similarity;
|
||||
use vestige_core::{KnowledgeNode, Storage};
|
||||
|
|
@ -70,41 +69,23 @@ struct Args {
|
|||
}
|
||||
|
||||
/// Pull entities out of a memory: its tags, plus heuristic code-ish tokens from
|
||||
/// content (UPPER_SNAKE env vars, dotted/slashed file paths, FooBar identifiers).
|
||||
/// These are the shared-entity join keys the backward reach follows.
|
||||
/// content (UPPER_SNAKE env vars, dotted/slashed file paths). These are the
|
||||
/// shared-entity join keys the backward reach follows.
|
||||
///
|
||||
/// Thin `&KnowledgeNode` adapter over the single core definition
|
||||
/// [`retroactive_backfill::extract_entities`] so the MCP tool, CLI, and the
|
||||
/// offline consolidation pass all extract entities identically (no drift).
|
||||
fn extract_entities(node: &KnowledgeNode) -> Vec<String> {
|
||||
let mut set: HashSet<String> = node.tags.iter().map(|t| t.to_lowercase()).collect();
|
||||
for raw in node.content.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.' || c == '/' || c == '-')) {
|
||||
let tok = raw.trim_matches(|c: char| c == '.' || c == '/' || c == '-');
|
||||
if tok.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let is_env = tok.len() >= 3
|
||||
&& tok.chars().all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit())
|
||||
&& tok.chars().any(|c| c.is_ascii_uppercase());
|
||||
let is_path = (tok.contains('/') || tok.contains('.'))
|
||||
&& tok.chars().any(|c| c.is_ascii_alphabetic());
|
||||
if is_env || is_path {
|
||||
set.insert(tok.to_lowercase());
|
||||
}
|
||||
}
|
||||
set.into_iter().collect()
|
||||
retroactive_backfill::extract_entities(&node.content, &node.tags)
|
||||
}
|
||||
|
||||
/// Heuristic: does this memory read like a failure/"aversive event"? Checks both
|
||||
/// content AND tags against the full FAILURE_MARKERS list. Public so the CLI and
|
||||
/// any caller share ONE failure-detection definition (no drifting subsets).
|
||||
///
|
||||
/// Thin `&KnowledgeNode` adapter over [`retroactive_backfill::looks_like_failure`].
|
||||
pub fn looks_like_failure(node: &KnowledgeNode) -> bool {
|
||||
let hay = node.content.to_lowercase();
|
||||
vestige_core::advanced::retroactive_backfill::FAILURE_MARKERS
|
||||
.iter()
|
||||
.any(|m| hay.contains(m))
|
||||
|| node.tags.iter().any(|t| {
|
||||
let tl = t.to_lowercase();
|
||||
vestige_core::advanced::retroactive_backfill::FAILURE_MARKERS
|
||||
.iter()
|
||||
.any(|m| tl.contains(m))
|
||||
})
|
||||
retroactive_backfill::looks_like_failure(&node.content, &node.tags)
|
||||
}
|
||||
|
||||
pub async fn execute(storage: &Arc<Storage>, args: Option<Value>) -> Result<Value, String> {
|
||||
|
|
@ -112,9 +93,13 @@ pub async fn execute(storage: &Arc<Storage>, args: Option<Value>) -> Result<Valu
|
|||
Some(v) => serde_json::from_value(v).map_err(|e| e.to_string())?,
|
||||
None => Args::default(),
|
||||
};
|
||||
let lookback = args.lookback_days.unwrap_or(30);
|
||||
// Clamp numeric inputs to the documented schema bounds. The MCP dispatch
|
||||
// layer does NOT enforce the JSON-schema min/max, so a caller can send
|
||||
// scan_limit=-1 (SQLite treats a negative LIMIT as unbounded => full-table
|
||||
// fetch = DoS) or values above the 5000 cap. Clamp rather than trust.
|
||||
let lookback = args.lookback_days.unwrap_or(30).clamp(1, 365);
|
||||
let promote = args.promote.unwrap_or(true);
|
||||
let scan_limit = args.scan_limit.unwrap_or(500);
|
||||
let scan_limit = args.scan_limit.unwrap_or(500).clamp(10, 5000);
|
||||
|
||||
// 1. Resolve the failure event.
|
||||
let failure_node = match &args.failure_id {
|
||||
|
|
@ -146,6 +131,7 @@ pub async fn execute(storage: &Arc<Storage>, args: Option<Value>) -> Result<Valu
|
|||
id: failure_node.id.clone(),
|
||||
content: failure_node.content.clone(),
|
||||
entities: failure_entities.clone(),
|
||||
tags: failure_node.tags.clone(),
|
||||
prediction_error: pe,
|
||||
manual: args.manual,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ pub fn gate_writes(
|
|||
mode: vestige_core::ReviewMode,
|
||||
) -> Vec<serde_json::Value> {
|
||||
use vestige_core::{
|
||||
classify_write, MemoryPr, MemoryPrKind, MemoryPrStatus, RiskClass, WriteContext,
|
||||
MemoryPr, MemoryPrKind, MemoryPrStatus, RiskClass, WriteContext, classify_write,
|
||||
};
|
||||
|
||||
if !is_write_tool(tool) {
|
||||
|
|
@ -139,7 +139,10 @@ pub fn gate_writes(
|
|||
|
||||
let ctx = WriteContext {
|
||||
source: Some(WriteSource::Agent),
|
||||
node_type: node.as_ref().map(|n| n.node_type.clone()).unwrap_or_default(),
|
||||
node_type: node
|
||||
.as_ref()
|
||||
.map(|n| n.node_type.clone())
|
||||
.unwrap_or_default(),
|
||||
content: node.as_ref().map(|n| n.content.clone()).unwrap_or_default(),
|
||||
tags: node.as_ref().map(|n| n.tags.clone()).unwrap_or_default(),
|
||||
contradicts_trust,
|
||||
|
|
@ -176,9 +179,9 @@ pub fn gate_writes(
|
|||
// secret, and the PR row is read by the dashboard and may be exported).
|
||||
// Store a short, redacted preview + a content hash instead. The preview
|
||||
// is dropped entirely when the write was gated for a sensitive topic.
|
||||
let sensitive = signals.iter().any(|s| {
|
||||
s.code == "sensitive_topic" || s.code == "sensitive_node_type"
|
||||
});
|
||||
let sensitive = signals
|
||||
.iter()
|
||||
.any(|s| s.code == "sensitive_topic" || s.code == "sensitive_node_type");
|
||||
let raw_content = node.as_ref().map(|n| n.content.as_str()).unwrap_or("");
|
||||
let preview = content_preview(raw_content, sensitive);
|
||||
let content_hash = hash_content(raw_content);
|
||||
|
|
@ -238,6 +241,196 @@ pub fn gate_writes(
|
|||
opened
|
||||
}
|
||||
|
||||
struct PendingMemoryMutation {
|
||||
action: String,
|
||||
id: String,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Pre-gate memory mutations that would otherwise be irreversible or directly
|
||||
/// inhibitory before the reviewer sees them.
|
||||
///
|
||||
/// Normal risky writes are still handled post-commit by [`gate_writes`]. Purge,
|
||||
/// delete, and suppress are different: executing the tool first either removes
|
||||
/// the row or mutates retrieval influence before review. Under Risk-Gated and
|
||||
/// Paranoid modes this function opens a pending Memory PR and returns a tool
|
||||
/// response without performing the mutation. Fast mode keeps the historical
|
||||
/// direct-execution behavior.
|
||||
pub fn gate_pending_memory_mutation(
|
||||
storage: &Arc<Storage>,
|
||||
event_tx: Option<&broadcast::Sender<VestigeEvent>>,
|
||||
run_id: &str,
|
||||
tool: &str,
|
||||
args: &Option<serde_json::Value>,
|
||||
mode: vestige_core::ReviewMode,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
use vestige_core::{
|
||||
MemoryPr, MemoryPrKind, MemoryPrStatus, RiskClass, WriteContext, classify_write,
|
||||
};
|
||||
|
||||
if matches!(mode, vestige_core::ReviewMode::Fast) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(pending) = pending_memory_mutation(tool, args) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let node = match storage.get_node(&pending.id) {
|
||||
Ok(Some(node)) => node,
|
||||
Ok(None) => return Ok(None),
|
||||
Err(e) => return Err(format!("failed to inspect memory before review gate: {e}")),
|
||||
};
|
||||
|
||||
let ctx = WriteContext {
|
||||
source: Some(WriteSource::Agent),
|
||||
node_type: node.node_type.clone(),
|
||||
content: node.content.clone(),
|
||||
tags: node.tags.clone(),
|
||||
forgets: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (class, signals) = classify_write(&ctx, mode);
|
||||
if class != RiskClass::Review {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let sensitive = signals
|
||||
.iter()
|
||||
.any(|s| s.code == "sensitive_topic" || s.code == "sensitive_node_type");
|
||||
let preview = content_preview(&node.content, sensitive);
|
||||
let content_hash = hash_content(&node.content);
|
||||
let kind = MemoryPrKind::NodeDecayed;
|
||||
let title = format!("{}: {}", pr_kind_phrase(kind), preview);
|
||||
let pr = MemoryPr {
|
||||
id: format!("pr_{}", uuid::Uuid::new_v4().simple()),
|
||||
kind,
|
||||
status: MemoryPrStatus::Pending,
|
||||
title: title.clone(),
|
||||
diff: serde_json::json!({
|
||||
"decision": pending.action,
|
||||
"pendingAction": pending.action,
|
||||
"requiresApproval": true,
|
||||
"reason": pending.reason,
|
||||
"node": {
|
||||
"id": pending.id,
|
||||
"nodeType": node.node_type,
|
||||
"contentPreview": preview,
|
||||
"contentHash": content_hash,
|
||||
"tags": node.tags,
|
||||
"deleted": false,
|
||||
},
|
||||
}),
|
||||
signals: signals.clone(),
|
||||
subject_id: Some(pending.id.clone()),
|
||||
run_id: Some(run_id.to_string()),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
decided_at: None,
|
||||
decision: None,
|
||||
};
|
||||
|
||||
if let Err(e) = storage.save_memory_pr(&pr) {
|
||||
tracing::warn!("pending destructive Memory PR save failed: {e}");
|
||||
return Err(format!(
|
||||
"review gate failed closed: could not open Memory PR for pending mutation: {e}"
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(tx) = event_tx {
|
||||
let _ = tx.send(VestigeEvent::MemoryPrOpened {
|
||||
id: pr.id.clone(),
|
||||
kind: kind.as_str().to_string(),
|
||||
title: title.clone(),
|
||||
signal_count: signals.len(),
|
||||
run_id: Some(run_id.to_string()),
|
||||
timestamp: Utc::now(),
|
||||
});
|
||||
}
|
||||
|
||||
let opened = serde_json::json!({
|
||||
"id": pr.id,
|
||||
"kind": kind.as_str(),
|
||||
"title": pr.title,
|
||||
"signals": signals,
|
||||
"subjectId": pending.id,
|
||||
});
|
||||
|
||||
Ok(Some(serde_json::json!({
|
||||
"action": format!("{}_pending_review", pending.action),
|
||||
"success": false,
|
||||
"pendingReview": true,
|
||||
"nodeId": pending.id,
|
||||
"message": "Mutation was not executed. Vestige opened a Memory PR and is waiting for review.",
|
||||
"memoryPrsOpened": [opened],
|
||||
"memoryPrNotice": "Vestige opened a Memory PR before applying this destructive or suppressive memory mutation. Approve with `forget`; keep the memory with `promote`; hold it suppressed with `quarantine`.",
|
||||
})))
|
||||
}
|
||||
|
||||
fn pending_memory_mutation(
|
||||
tool: &str,
|
||||
args: &Option<serde_json::Value>,
|
||||
) -> Option<PendingMemoryMutation> {
|
||||
let args = args.as_ref()?;
|
||||
match tool {
|
||||
"memory" => {
|
||||
let action = args.get("action")?.as_str()?.to_ascii_lowercase();
|
||||
if !matches!(action.as_str(), "purge" | "delete") {
|
||||
return None;
|
||||
}
|
||||
if !args
|
||||
.get("confirm")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(PendingMemoryMutation {
|
||||
action,
|
||||
id: args.get("id")?.as_str()?.to_string(),
|
||||
reason: args
|
||||
.get("reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string),
|
||||
})
|
||||
}
|
||||
"delete_knowledge" => {
|
||||
if !args
|
||||
.get("confirm")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(PendingMemoryMutation {
|
||||
action: "delete".to_string(),
|
||||
id: args.get("id")?.as_str()?.to_string(),
|
||||
reason: args
|
||||
.get("reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string),
|
||||
})
|
||||
}
|
||||
"suppress" => {
|
||||
if args
|
||||
.get("reverse")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(PendingMemoryMutation {
|
||||
action: "suppress".to_string(),
|
||||
id: args.get("id")?.as_str()?.to_string(),
|
||||
reason: args
|
||||
.get("reason")
|
||||
.or_else(|| args.get("note"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a write decision permanently removes / forgets memory (so the live
|
||||
/// row may already be gone when the gate runs).
|
||||
fn is_destructive_decision(label: &str) -> bool {
|
||||
|
|
@ -719,7 +912,10 @@ fn extract_veto(result: &Value) -> Option<(String, Vec<String>, f64)> {
|
|||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let confidence = veto.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let confidence = veto
|
||||
.get("confidence")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
Some((claim, evidence_ids, confidence))
|
||||
}
|
||||
|
||||
|
|
@ -771,7 +967,9 @@ fn extract_dream_proposals(result: &Value, tool: &str) -> Vec<String> {
|
|||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|m| m.as_str())
|
||||
.map(|s| &s[..s.len().min(8)])
|
||||
// char-boundary-safe: byte-slicing &s[..8] panics when a
|
||||
// multi-byte UTF-8 char straddles byte 8.
|
||||
.map(|s| s.chars().take(8).collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("+")
|
||||
})
|
||||
|
|
@ -875,7 +1073,10 @@ mod tests {
|
|||
#[test]
|
||||
fn extract_writes_single_and_batch() {
|
||||
let single = serde_json::json!({ "decision": "create", "nodeId": "n1" });
|
||||
assert_eq!(extract_writes(&single), vec![("n1".into(), "create".into())]);
|
||||
assert_eq!(
|
||||
extract_writes(&single),
|
||||
vec![("n1".into(), "create".into())]
|
||||
);
|
||||
let batch = serde_json::json!({
|
||||
"results": [ { "decision": "update", "id": "n2" } ]
|
||||
});
|
||||
|
|
@ -886,9 +1087,15 @@ mod tests {
|
|||
fn extract_writes_recognizes_action_shape_b2() {
|
||||
// B2: memory promote/demote return `action` + `nodeId`, not `decision`.
|
||||
let promoted = serde_json::json!({ "action": "promoted", "nodeId": "m1" });
|
||||
assert_eq!(extract_writes(&promoted), vec![("m1".into(), "promoted".into())]);
|
||||
assert_eq!(
|
||||
extract_writes(&promoted),
|
||||
vec![("m1".into(), "promoted".into())]
|
||||
);
|
||||
let demoted = serde_json::json!({ "action": "demoted", "nodeId": "m2" });
|
||||
assert_eq!(extract_writes(&demoted), vec![("m2".into(), "demoted".into())]);
|
||||
assert_eq!(
|
||||
extract_writes(&demoted),
|
||||
vec![("m2".into(), "demoted".into())]
|
||||
);
|
||||
// codebase remember_decision returns action + nodeId.
|
||||
let decision = serde_json::json!({ "action": "remember_decision", "nodeId": "c1" });
|
||||
assert_eq!(
|
||||
|
|
@ -908,7 +1115,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn destructive_decision_classification_c2() {
|
||||
for d in ["purge", "delete", "forget", "purged", "deleted", "forgotten"] {
|
||||
for d in [
|
||||
"purge",
|
||||
"delete",
|
||||
"forget",
|
||||
"purged",
|
||||
"deleted",
|
||||
"forgotten",
|
||||
] {
|
||||
assert!(is_destructive_decision(d), "{d} is destructive");
|
||||
}
|
||||
for d in ["create", "update", "promote", "reinforce"] {
|
||||
|
|
@ -987,8 +1201,14 @@ mod tests {
|
|||
vestige_core::ReviewMode::RiskGated,
|
||||
);
|
||||
|
||||
assert_eq!(opened.len(), 1, "destructive write must open a PR even with the node gone");
|
||||
let pr = s.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10).unwrap();
|
||||
assert_eq!(
|
||||
opened.len(),
|
||||
1,
|
||||
"destructive write must open a PR even with the node gone"
|
||||
);
|
||||
let pr = s
|
||||
.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10)
|
||||
.unwrap();
|
||||
assert_eq!(pr.len(), 1);
|
||||
assert_eq!(pr[0].subject_id.as_deref(), Some(node.id.as_str()));
|
||||
// The diff marks the node as deleted and carries no resurrected content.
|
||||
|
|
@ -1035,6 +1255,110 @@ mod tests {
|
|||
assert!(pr.diff["node"]["contentHash"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_gate_blocks_purge_before_deleting_c2() {
|
||||
let s = store();
|
||||
let node = s
|
||||
.ingest(vestige_core::IngestInput {
|
||||
content: "A memory awaiting destructive review.".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let args = Some(serde_json::json!({
|
||||
"action": "purge",
|
||||
"id": node.id,
|
||||
"confirm": true,
|
||||
"reason": "test purge"
|
||||
}));
|
||||
|
||||
let response = gate_pending_memory_mutation(
|
||||
&s,
|
||||
None,
|
||||
"run_pre_gate",
|
||||
"memory",
|
||||
&args,
|
||||
vestige_core::ReviewMode::RiskGated,
|
||||
)
|
||||
.unwrap()
|
||||
.expect("purge should be pre-gated");
|
||||
|
||||
assert_eq!(response["pendingReview"], serde_json::json!(true));
|
||||
assert!(
|
||||
s.get_node(&node.id).unwrap().is_some(),
|
||||
"pre-gating must not delete before review"
|
||||
);
|
||||
let pr = s
|
||||
.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10)
|
||||
.unwrap();
|
||||
assert_eq!(pr.len(), 1);
|
||||
assert_eq!(pr[0].diff["pendingAction"], serde_json::json!("purge"));
|
||||
assert_eq!(pr[0].diff["node"]["deleted"], serde_json::json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_gate_leaves_fast_mode_direct() {
|
||||
let s = store();
|
||||
let node = s
|
||||
.ingest(vestige_core::IngestInput {
|
||||
content: "Fast mode purge target.".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let args = Some(serde_json::json!({
|
||||
"action": "purge",
|
||||
"id": node.id,
|
||||
"confirm": true
|
||||
}));
|
||||
|
||||
assert!(
|
||||
gate_pending_memory_mutation(
|
||||
&s,
|
||||
None,
|
||||
"run_fast",
|
||||
"memory",
|
||||
&args,
|
||||
vestige_core::ReviewMode::Fast,
|
||||
)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"Fast mode should preserve direct execution"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_gate_blocks_direct_suppress_before_mutating() {
|
||||
let s = store();
|
||||
let node = s
|
||||
.ingest(vestige_core::IngestInput {
|
||||
content: "A memory awaiting suppress review.".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let args = Some(serde_json::json!({ "id": node.id, "reason": "test suppress" }));
|
||||
|
||||
let response = gate_pending_memory_mutation(
|
||||
&s,
|
||||
None,
|
||||
"run_suppress",
|
||||
"suppress",
|
||||
&args,
|
||||
vestige_core::ReviewMode::RiskGated,
|
||||
)
|
||||
.unwrap()
|
||||
.expect("suppress should be pre-gated");
|
||||
|
||||
assert_eq!(response["pendingReview"], serde_json::json!(true));
|
||||
let kept = s.get_node(&node.id).unwrap().unwrap();
|
||||
assert_eq!(kept.suppression_count, 0, "pre-gate must not suppress yet");
|
||||
let pr = s
|
||||
.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10)
|
||||
.unwrap();
|
||||
assert_eq!(pr[0].diff["pendingAction"], serde_json::json!("suppress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_tool_set_includes_codebase_b2() {
|
||||
assert!(is_write_tool("codebase"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue