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:
Sam Valladares 2026-06-28 14:03:54 -05:00
parent 5c9e66108d
commit a3750378bd
11 changed files with 428 additions and 57 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

@ -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;
}
}

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
};

View file

@ -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,
};

View file

@ -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"));