基于forgecode二开的agent cli
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(vuln-hunting): adversarial verification gate, headless hardening, coverage + confirmed-gate integrity

Lands the vuln-hunting pipeline hardening (validated by a blind 7-Zip 26.00
re-run that independently found + PoC-confirmed the GetCuSize shift-overflow
class), in four layers:

Adversarial verification gate (confirmed = independently reproduced):
- New `adversary` agent (leaf, shell+write, memory_record_verification only on
its tool list) re-derives the taint path and re-runs the PoC on the
UNMODIFIED target; records survived/refuted/inconclusive.
- L1 code guard: memory_update_finding(confirmed) is rejected unless a
`survived` verification is on record. require_adversarial_verification flag
(default true) is the escape hatch.
- Migration 2026-07-12-120000 adds subject_finding_id + verification_verdict;
new memory_verification tool + ToolCatalog/operation wiring.

Headless hardening (the "0-output hang" was a visibility gap, not a deadlock):
- B1 provider stream silence-timeout (http.stream_timeout_secs=180).
- B2 tool-call headers/failures -> stderr unconditionally in headless.
- B3 liveness heartbeat every 15s + stall watchdog (VOGE_HEADLESS_STALL_SECS,
default 600s) that aborts instead of hanging forever; RetryAttempt surfaced.

Coverage gate v2 (count-equality census + tree-rooted generalization):
- Coverage completion is covered==census_total over evidence-bound verdicts;
sanctioned verbatim find command, non-narrowable; proof_of_read/safe_citation
verbatim lines re-checked at the gate. CoverageVerdict::Partial added.
- Tree-rooted dangerous-idiom generalization in sage/voge prompts.
- memory_coverage title made optional (was required -> 5 failures/run).

Confirmed + coverage gate integrity v3 (closes two residual gaps found by the
blind re-run, red-team-vetted via workflow):
- Substance-bound verification: each survived verification snapshots a
verified_substance fingerprint (CWE+severity+taint_path, NOT prose).
has_surviving_verification_for_substance walks the supersedes chain and
matches substance — so a narrow ancestor verification cannot cover a broader
descendant (closes verify-narrow-then-confirm-broad). MemoryUpdateFinding
builds the fully-mutated revised version BEFORE gating; rejects is_latest=0
input ids (reusable-token guard). Consolidation can't mint confirmed; evidence
refinement keeps confirmed, only substance changes force re-verify.
- memory_add rejects kind=verification/coverage (centralize behind the validated
tools; closes the verdict=NULL malformed-record hole).
- coverage-supersede: memory_coverage supersedes the prior verdict for the same
file_path so an honest agent CAN flip needs_review->safe (save_memory
fingerprint-dedup used to fork parallel is_latest rows). New read-only
memory_coverage_status tool returns per-path distinct verdicts + counts.
- Completion gate now requires needs_review==0 AND partial==0 AND
total_latest==census_total (denominator — needs_review==0 alone was
satisfiable by never recording coverage for hard files). muse re-derives the
counts itself and refuses a mismatched report.
- Migration 2026-07-13-100000 adds verified_substance (indexed).

Tests: 4 new (substance_fingerprint blind-to-prose, revise defense-in-depth x2,
substance-gate mismatch+chain-walk); 1713 existing pass across voge_domain /
voge_repo / voge_app / voge_services / voge_config. Snapshots updated for the
new tool + config field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

v0g3 (Jul 12, 2026, 8:34 AM EDT) ccbcb023 a5f81a3b

+1472 -88
+4 -3
README.md
··· 240 240 241 241 ### Agents 242 242 243 - Voge ships with three built-in agents that form a vulnerability-hunting pipeline: 243 + Voge ships with four built-in agents that form a vulnerability-hunting pipeline: 244 244 245 245 | Agent | Alias | Purpose | Modifies files? | 246 246 |---|---|---|---| 247 - | `voge` | (default) | Exploit validation + orchestration: builds and runs real PoCs, delegates recon/triage, promotes findings to `confirmed` | Yes (writes/runs PoCs) | 247 + | `voge` | (default) | Exploit validation + orchestration: builds and runs real PoCs, delegates recon/verify/triage; promotes findings to `confirmed` (only after an `adversary` `survived` verdict) | Yes (writes/runs PoCs) | 248 248 | `sage` | `:ask` | Systematic attack-surface enumeration and taint tracing (read-only) | No | 249 + | `adversary` | — | Independent verifier: refutes findings and re-runs PoCs on the unmodified target — the gate for `confirmed` | Harness only | 249 250 | `muse` | `:plan` | Triage, severity ranking, and audit report writing to `plans/` | No (`plans/` only) | 250 251 251 - The pipeline flows `sage` (enumerate the untrusted-input surface and trace taint) → `voge` (validate with real PoCs) → `muse` (triage and report). `voge` orchestrates by delegating to `sage` and `muse` via the `task` tool. See [docs/agents.md](docs/agents.md) for the full agent guide — the two rules (PoC integrity, systematic audit), the finding lifecycle, usage examples, and configuration. 252 + The pipeline flows `sage` (enumerate the untrusted-input surface and trace taint) → `voge` (validate with real PoCs) → `adversary` (independently refute + reproduce) → `muse` (triage and report). `voge` orchestrates by delegating via the `task` tool. A finding can only become `confirmed` after `adversary` records a `survived` verification. See [docs/agents.md](docs/agents.md) for the full agent guide — the two rules (PoC integrity, systematic audit), the adversarial verification gate, the finding lifecycle, usage examples, and configuration. 252 253 253 254 ### Sending Prompts 254 255
+1
crates/voge_app/src/agent.rs
··· 196 196 "memory_add", 197 197 "memory_update_finding", 198 198 "memory_coverage", 199 + "memory_coverage_status", 199 200 "memory_find_taint_paths", 200 201 "memory_trace", 201 202 ] {
+15 -1
crates/voge_app/src/fmt/fmt_input.rs
··· 152 152 ), 153 153 ToolCatalog::MemoryCoverage(input) => Some( 154 154 TitleFormat::debug("Memory Coverage") 155 - .sub_title(format!("{}: {}", input.verdict.as_str(), input.title)) 155 + .sub_title(format!( 156 + "{}: {}", 157 + input.verdict.as_str(), 158 + input.title.as_deref().unwrap_or( 159 + input.file_path.as_deref().unwrap_or("coverage") 160 + ) 161 + )) 156 162 .into(), 157 163 ), 164 + ToolCatalog::MemoryCoverageStatus(_) => { 165 + Some(TitleFormat::debug("Memory Coverage Status").into()) 166 + } 158 167 ToolCatalog::MemoryFindTaintPaths(input) => Some( 159 168 TitleFormat::debug("Find Taint Paths") 160 169 .sub_title(input.sink_file.as_deref().unwrap_or("all sinks")) ··· 162 171 ), 163 172 ToolCatalog::MemoryTrace(input) => Some( 164 173 TitleFormat::debug("Memory Trace").sub_title(&input.id).into(), 174 + ), 175 + ToolCatalog::MemoryVerification(input) => Some( 176 + TitleFormat::debug("Memory Verification") 177 + .sub_title(format!("{}: {}", input.verdict.as_str(), input.finding_id)) 178 + .into(), 165 179 ), 166 180 } 167 181 }
+2 -1
crates/voge_app/src/fmt/fmt_output.rs
··· 55 55 | ToolOperation::Skill { output: _ } 56 56 | ToolOperation::Memory { output: _ } 57 57 | ToolOperation::MemoryTrace { .. } 58 - | ToolOperation::TaintPaths { .. } => None, 58 + | ToolOperation::TaintPaths { .. } 59 + | ToolOperation::CoverageStatus { .. } => None, 59 60 } 60 61 } 61 62 }
+36 -2
crates/voge_app/src/memory.rs
··· 272 272 .collect(); 273 273 merged.strength = olds.iter().map(|o| o.strength).fold(0.0, f64::max); 274 274 merged.details = olds[0].details.clone(); 275 - merged.status = olds[0].status; 275 + // Propagate the coverage verdict: Memory::new defaults Coverage verdict 276 + // to NeedsReview, so without this a near-duplicate dedup of two `safe` 277 + // coverage rows silently mints a NeedsReview latest row — spurious 278 + // needs_review noise that defeats the coverage gate. 279 + merged.verdict = olds[0].verdict; 280 + // Consolidation is a dedup pass, NOT a verification authority: it must 281 + // never MINT `Confirmed`, and it must not SILENTLY destroy a 282 + // legitimately-earned Confirmed. merged.details == olds[0].details, so 283 + // the merged substance equals olds[0]'s; preserve Confirmed ONLY when a 284 + // substance-matched survived verification still covers it (walks 285 + // olds[0]'s chain), else downgrade + log prominently. A model-emitted 286 + // new_status:"confirmed" is always clamped to Speculative (a background 287 + // dedup cannot perform verification). 288 + let mut inherited = olds[0].status; 289 + if inherited == Some(FindingStatus::Confirmed) { 290 + let substance = Memory::substance_fingerprint(merged.details.as_ref()); 291 + match services 292 + .has_surviving_verification_for_substance(&olds[0], &substance) 293 + .await 294 + { 295 + Ok(true) => {} 296 + _ => { 297 + warn!( 298 + finding_id = %olds[0].id, 299 + "consolidation: downgrading confirmed finding to speculative \ 300 + (no surviving substance-matched verification in chain)" 301 + ); 302 + inherited = Some(FindingStatus::Speculative); 303 + } 304 + } 305 + } 306 + merged.status = inherited; 276 307 if let Some(status_str) = &action.new_status { 277 308 if let Ok(status) = FindingStatus::from_str(status_str) { 278 - merged.status = Some(status); 309 + merged.status = match status { 310 + FindingStatus::Confirmed => Some(FindingStatus::Speculative), 311 + other => Some(other), 312 + }; 279 313 } 280 314 } 281 315 let old_ids: Vec<MemoryId> = olds.iter().map(|o| o.id.clone()).collect();
+29
crates/voge_app/src/operation.rs
··· 102 102 TaintPaths { 103 103 paths: Vec<TaintPath>, 104 104 }, 105 + /// Result of `memory_coverage_status`: by-path coverage verdict map + 106 + /// rolled-up counts (distinct paths, not rows). 107 + CoverageStatus { 108 + output: voge_domain::CoverageStatusOutput, 109 + }, 105 110 } 106 111 107 112 /// Trait for stream elements that can be converted to XML elements ··· 326 331 .join("\n") 327 332 }; 328 333 voge_domain::ToolOutput::text(Element::new("taint_paths").cdata(body)) 334 + } 335 + ToolOperation::CoverageStatus { output } => { 336 + let mut body = format!( 337 + "Coverage status (distinct paths, not rows):\n safe: {safe}\n unsafe: {unsafe_}\n needs_review: {needs_review}\n partial: {partial}\n total_latest (distinct paths): {total_latest}\n\nPer-path verdicts:\n", 338 + safe = output.safe, 339 + unsafe_ = output.unsafe_, 340 + needs_review = output.needs_review, 341 + partial = output.partial, 342 + total_latest = output.total_latest, 343 + ); 344 + if output.paths.is_empty() { 345 + body.push_str(" (no coverage recorded)\n"); 346 + } else { 347 + for p in &output.paths { 348 + body.push_str(&format!(" - {} [{}]\n", p.file_path, p.verdict.as_str())); 349 + } 350 + } 351 + body.push_str( 352 + "\nCompletion requires ALL of: needs_review==0 AND partial==0 AND \ 353 + total_latest==census_total (re-derive census_total from the verbatim \ 354 + sanctioned `find` commands this turn). needs_review==0 ALONE is NOT \ 355 + sufficient — it is satisfiable by never recording coverage for a hard file.", 356 + ); 357 + voge_domain::ToolOutput::text(Element::new("coverage_status").cdata(body)) 329 358 } 330 359 ToolOperation::FsRead { input, output } => { 331 360 // Check if content is an image (visual content)
+28
crates/voge_app/src/services.rs
··· 282 282 file_path: Option<&str>, 283 283 ) -> anyhow::Result<Vec<Memory>>; 284 284 285 + /// Returns whether a latest verification memory with a `survived` verdict 286 + /// exists for `finding_id` (the gate for promoting a finding to `confirmed`). 287 + async fn has_surviving_verification(&self, finding_id: &MemoryId) -> anyhow::Result<bool>; 288 + 289 + /// Returns whether a latest `survived` verification exists in `finding`'s 290 + /// supersedes chain whose `verified_substance` equals `substance`. The 291 + /// substance-matched chain-aware gate for promoting a finding to 292 + /// `confirmed` — closes the broaden-at-confirm evasion. 293 + async fn has_surviving_verification_for_substance( 294 + &self, 295 + finding: &Memory, 296 + substance: &str, 297 + ) -> anyhow::Result<bool>; 298 + 285 299 /// Discovers taint paths (source -> propagators -> sink) by traversing the 286 300 /// taint graph across findings. `sink_file` restricts sinks to a file; 287 301 /// `unsanitized_only` returns only paths with no sanitizer on them; ··· 783 797 784 798 async fn get_memory(&self, id: &MemoryId) -> anyhow::Result<Option<Memory>> { 785 799 self.memory_service().get_memory(id).await 800 + } 801 + 802 + async fn has_surviving_verification(&self, finding_id: &MemoryId) -> anyhow::Result<bool> { 803 + self.memory_service().has_surviving_verification(finding_id).await 804 + } 805 + 806 + async fn has_surviving_verification_for_substance( 807 + &self, 808 + finding: &Memory, 809 + substance: &str, 810 + ) -> anyhow::Result<bool> { 811 + self.memory_service() 812 + .has_surviving_verification_for_substance(finding, substance) 813 + .await 786 814 } 787 815 788 816 async fn save_memory(&self, memory: Memory) -> anyhow::Result<bool> {
+33 -1
crates/voge_app/src/snapshots/voge_app__tool_registry__all_rendered_tool_descriptions.snap
··· 573 573 574 574 Revise the lifecycle status of a finding memory (`speculative` → `confirmed`/`refuted`), creating a new version that supersedes the old one. Use this when an earlier `memory_add` finding (kind `finding`) has been investigated and you can now confirm or refute it. The taint inputs (`taint_source` / `taint_propagators` / `taint_sink` / `taint_sanitizer`) record the finding's data-flow path into a taint graph, enabling transitive source→sink path discovery across findings via `memory_find_taint_paths`. 575 575 576 + **`confirmed` is gated.** Unless `require_adversarial_verification = false`, this tool REJECTS `status="confirmed"` unless the independent `adversary` agent has recorded a `survived` verification for this finding (via `memory_verification`). Delegate refutation to `adversary` (task) first; only a `survived` verdict unlocks `confirmed`. 577 + 576 578 # Inputs 577 579 578 580 - `id` (required): the finding memory id to revise (e.g. `mem_xxx`). ··· 603 605 604 606 # Inputs 605 607 606 - - `title` (required): what was audited (e.g. "SQL injection in /login"). 608 + - `title` (optional): what was audited (e.g. "SQL injection in /login"). If omitted, defaults to the `file_path` (or a content snippet) — so for per-file coverage you can just pass `file_path` + `content` + `verdict`. 607 609 - `content` (required): what was checked and the outcome reasoning. 608 610 - `verdict` (required): `safe`, `unsafe`, or `needs_review`. 609 611 - `file_path` (optional): file audited. ··· 618 620 619 621 --- 620 622 623 + ### memory_coverage_status 624 + 625 + Read the project's audit coverage verdicts BY PATH. Disclose-only — does NOT record or block. Returns the distinct set of file paths that carry a latest coverage verdict, each path's verdict (safe / unsafe / needs_review / partial), and rolled-up counts. 626 + 627 + Use this to enforce the completion gate mechanically: the audit is complete ONLY when ALL hold — (a) the returned `needs_review` and `partial` counts are 0, AND (b) `total_latest` (DISTINCT paths) equals `census_total` re-derived from the verbatim sanctioned `find` commands this turn, AND (c) every census path appears in the returned per-path list with a non-needs_review verdict. `needs_review == 0` ALONE IS NOT SUFFICIENT — it is satisfiable by never recording coverage for a hard file, which is the original coverage-gate failure. 628 + 629 + Takes no arguments. 630 + 631 + --- 632 + 621 633 ### memory_find_taint_paths 622 634 623 635 Discover taint flows from source to sink by traversing the taint graph across findings. Returns transitive paths (source → propagators → sink), including paths that span multiple findings — e.g. finding A records `src→X` and finding B records `X→sink`, yielding `src→X→sink`. Use this to see which sinks are reachable from untrusted input and whether any path lacks sanitization. ··· 654 666 655 667 - Only memories with recorded `source_conversation_ids` return a transcript; otherwise returns "No source conversations recorded". 656 668 - Deleted/unavailable conversations are marked `[not found]`. 669 + 670 + --- 671 + 672 + ### memory_verification 673 + 674 + Record the outcome of an independent ADVERSARIAL verification of a finding — the result of re-deriving the finding's taint path AND re-running its recorded PoC on the UNMODIFIED target via the real entry point. This tool is the sole way to create a verification, and only a `survived` verification unlocks `confirmed` for a finding (the system rejects `confirmed` without one). Reserved for the `adversary` agent. 675 + 676 + # Inputs 677 + 678 + - `finding_id` (required): the id of the finding memory being assessed (e.g. `mem_xxx`). 679 + - `verdict` (required): `survived`, `refuted`, or `inconclusive`. 680 + - `evidence` (required): what you checked — your independent taint re-derivation, any refuting sanitizer/guard you considered, and the PoC re-run command + the observed effect (or why it could not be re-run). 681 + - `concepts` (optional): comma- or newline-separated tags/keywords. 682 + 683 + # Notes 684 + 685 + - `survived` = you could NOT refute the path AND the PoC reproduced the claimed effect on the unmodified target through the real entry point. Only this lets the finding become `confirmed`. 686 + - `refuted` = you broke the claim (found a neutralizing sanitizer/guard the original analysis missed, or the PoC did not reproduce the claimed effect). 687 + - `inconclusive` = you could not complete the check (e.g. the target would not build/run) — the finding stays `speculative`. NEVER mark `survived` without a real re-execution on the unmodified target. 688 + - You MUST NOT edit the target's source or config to make the PoC succeed; the effect must be produced by the target itself, originating at the recorded external entry point (not an internal function or test harness).
+196 -1
crates/voge_app/src/tool_executor.rs
··· 351 351 ToolCatalog::MemoryAdd(input) => { 352 352 let project = self.services.get_environment().workspace_hash(); 353 353 let kind = input.kind.unwrap_or_default(); 354 + // Verifications must be recorded through `memory_verification`, 355 + // which validates the subject is an existing Finding and always 356 + // sets verification_verdict + subject_finding_id + the substance 357 + // binding. The generic add path cannot set those fields 358 + // (Memory::new leaves them None), producing a malformed record 359 + // (the verdict=NULL / empty-subject hole). Reject it. 360 + if matches!(kind, voge_domain::MemoryType::Verification) { 361 + anyhow::bail!( 362 + "memory_add cannot create a verification: use the `memory_verification` \ 363 + tool, which requires an existing finding id, a non-null verdict, and binds \ 364 + the verification to the finding's security substance" 365 + ); 366 + } 367 + // Coverage must be recorded through `memory_coverage`, which 368 + // supersedes the prior latest verdict for the same file path so 369 + // the honest agent can flip needs_review->safe and the coverage 370 + // count is per-distinct-path. The generic add path uses 371 + // save_memory (fingerprint-only dedup), which would insert 372 + // parallel is_latest=1 rows and never retire a prior verdict. 373 + if matches!(kind, voge_domain::MemoryType::Coverage) { 374 + anyhow::bail!( 375 + "memory_add cannot create coverage: use the `memory_coverage` tool, \ 376 + which supersedes the prior verdict for the same file path so the coverage \ 377 + gate counts distinct paths, not duplicate rows" 378 + ); 379 + } 354 380 let concepts = input 355 381 .concepts 356 382 .as_deref() ··· 382 408 if !matches!(old.r#type, voge_domain::MemoryType::Finding) { 383 409 anyhow::bail!("memory {} is not a finding", id.as_str()); 384 410 } 411 + // Reusable-token guard: a superseded (is_latest=0) finding id 412 + // must not be a permanent "mint Confirmed" key. get_memory has 413 + // no is_latest filter and supersede is idempotent on an 414 + // already-superseded id, so without this one survived 415 + // verification could backstop unlimited distinct confirmed 416 + // children. No legitimate flow updates a superseded finding. 417 + if !old.is_latest { 418 + anyhow::bail!( 419 + "cannot update a superseded finding {}; update its latest version instead", 420 + id.as_str() 421 + ); 422 + } 423 + // Build the revised version FIRST, applying ALL substance 424 + // mutations, so the gate below sees the FINAL security substance 425 + // (cwe/severity/taint), not the old one. An id-only gate is 426 + // defeated by widening the claim at confirm time; this closes it. 385 427 let mut revised = old.revise(); 386 428 revised.status = Some(input.status); 387 429 if let Some(evidence) = input.evidence { ··· 398 440 details.severity = input.severity; 399 441 } 400 442 revised.details = Some(details); 443 + // Substance-bound confirmation gate. A survived verification 444 + // must exist in `old`'s supersedes chain for the SAME security 445 + // substance as `revised`. Chain-walk is SAFE here because the 446 + // substance binding means a narrow ancestor verification (whose 447 + // recorded substance differs) cannot cover a broader revised 448 + // claim. Evidence/prose-only edits (same cwe/severity/taint) 449 + // keep confirmed — only a substance change forces re-verify. 450 + if input.status == voge_domain::FindingStatus::Confirmed 451 + && self.services.get_config()?.require_adversarial_verification 452 + { 453 + let substance = 454 + voge_domain::Memory::substance_fingerprint(revised.details.as_ref()); 455 + if !self 456 + .services 457 + .has_surviving_verification_for_substance(&old, &substance) 458 + .await? 459 + { 460 + anyhow::bail!( 461 + "cannot mark finding {} confirmed: no surviving adversarial verification \ 462 + for this finding's security substance (CWE/severity/taint path). Either \ 463 + re-delegate refutation to the `adversary` agent so it records a `survived` \ 464 + verification against this finding's current substance, or keep the \ 465 + CWE/severity/taint unchanged (evidence/prose refinement alone does not \ 466 + require re-verification).", 467 + id.as_str() 468 + ); 469 + } 470 + } 401 471 if input.taint_source.is_some() && input.taint_sink.is_some() { 402 472 if let Some(details) = revised.details.as_mut() { 403 473 let source = voge_domain::TaintLocation { ··· 465 535 .collect() 466 536 }) 467 537 .unwrap_or_default(); 538 + // `title` is optional on the tool schema; fall back to the 539 + // file_path (or a content snippet) so per-file coverage calls 540 + // record cleanly instead of failing on a missing title. 541 + let title = input.title.clone().unwrap_or_else(|| { 542 + input 543 + .file_path 544 + .clone() 545 + .unwrap_or_else(|| input.content.chars().take(80).collect()) 546 + }); 468 547 let mut memory = voge_domain::Memory::new( 469 548 project, 470 549 voge_domain::MemoryType::Coverage, 471 - input.title, 550 + title, 472 551 input.content, 473 552 ); 474 553 memory.verdict = Some(input.verdict); ··· 479 558 details.cwe = input.cwe; 480 559 memory.details = Some(details); 481 560 memory.engagement_id = self.services.get_config()?.engagement.as_ref().and_then(|e| e.id.clone()); 561 + if self.services.get_config()?.memory.as_ref().map_or(true, |m| m.strip_secrets) { 562 + memory = voge_domain::redact_memory(memory); 563 + } 564 + // SUPERSEDE the prior latest coverage verdict(s) for the same 565 + // project+file_path, so re-auditing a file (needs_review->safe) 566 + // RETIRES the old verdict instead of leaving a parallel 567 + // is_latest=1 row (save_memory dedups only on exact fingerprint, 568 + // so changed evidence would otherwise fork). Without this the 569 + // honest agent can never drive needs_review to 0 and total_latest 570 + // counts rows not paths. file_path-less coverage notes keep 571 + // fingerprint dedup. 572 + if let Some(path) = memory.details.as_ref().and_then(|d| d.file_path.as_deref()) { 573 + let priors: Vec<MemoryId> = self 574 + .services 575 + .get_coverage(project, Some(path)) 576 + .await? 577 + .into_iter() 578 + .filter(|p| p.id != memory.id) 579 + .map(|p| p.id) 580 + .collect(); 581 + if priors.is_empty() { 582 + self.services.save_memory(memory.clone()).await?; 583 + } else { 584 + self.services.supersede_memory(memory.clone(), &priors).await?; 585 + } 586 + } else { 587 + self.services.save_memory(memory.clone()).await?; 588 + } 589 + ToolOperation::Memory { output: vec![memory] } 590 + } 591 + ToolCatalog::MemoryCoverageStatus(_) => { 592 + let project = self.services.get_environment().workspace_hash(); 593 + let all = self.services.get_coverage(project, None).await?; 594 + // Dedup by file_path keeping the latest verdict per distinct 595 + // path (post-supersede there is one row per path; legacy 596 + // duplicates / file_path-less rows are handled defensively). 597 + let mut by_path: std::collections::BTreeMap<String, voge_domain::CoverageVerdict> = 598 + std::collections::BTreeMap::new(); 599 + for m in &all { 600 + if let Some(d) = m.details.as_ref() { 601 + if let Some(fp) = d.file_path.as_deref() { 602 + by_path.insert( 603 + fp.to_string(), 604 + m.verdict.unwrap_or(voge_domain::CoverageVerdict::NeedsReview), 605 + ); 606 + } 607 + } 608 + } 609 + let mut out = voge_domain::CoverageStatusOutput::default(); 610 + for (path, verdict) in &by_path { 611 + out.paths.push(voge_domain::CoveragePathVerdict { 612 + file_path: path.clone(), 613 + verdict: *verdict, 614 + }); 615 + match verdict { 616 + voge_domain::CoverageVerdict::Safe => out.safe += 1, 617 + voge_domain::CoverageVerdict::Unsafe => out.unsafe_ += 1, 618 + voge_domain::CoverageVerdict::NeedsReview => out.needs_review += 1, 619 + voge_domain::CoverageVerdict::Partial => out.partial += 1, 620 + } 621 + } 622 + out.total_latest = by_path.len() as u32; 623 + ToolOperation::CoverageStatus { output: out } 624 + } 625 + ToolCatalog::MemoryVerification(input) => { 626 + let project = self.services.get_environment().workspace_hash(); 627 + let finding_id = MemoryId::new(&input.finding_id); 628 + let finding = self 629 + .services 630 + .get_memory(&finding_id) 631 + .await? 632 + .ok_or_else(|| anyhow::anyhow!("finding not found: {}", finding_id.as_str()))?; 633 + if !matches!(finding.r#type, voge_domain::MemoryType::Finding) { 634 + anyhow::bail!( 635 + "memory {} is not a finding; cannot record a verification for it", 636 + finding_id.as_str() 637 + ); 638 + } 639 + let concepts = input 640 + .concepts 641 + .as_deref() 642 + .map(|c| { 643 + c.split([',', '\n']) 644 + .map(str::trim) 645 + .filter(|s| !s.is_empty()) 646 + .map(String::from) 647 + .collect() 648 + }) 649 + .unwrap_or_default(); 650 + let title = format!( 651 + "Verification ({}) of finding {}", 652 + input.verdict.as_str(), 653 + finding_id.as_str() 654 + ); 655 + let mut memory = voge_domain::Memory::new( 656 + project, 657 + voge_domain::MemoryType::Verification, 658 + title, 659 + input.evidence, 660 + ); 661 + memory.verification_verdict = Some(input.verdict); 662 + memory.subject_finding_id = Some(finding_id.as_str().to_string()); 663 + // Bind this verification to the finding's CURRENT security 664 + // substance (CWE + severity + taint path), snapshotted here so a 665 + // later confirm cannot widen the claim past what the adversary 666 + // actually checked. `content` (prose evidence) is excluded: it is 667 + // refined during report writing and must not force re-verification; 668 + // only a change to the classified substance (cwe/severity/taint) 669 + // invalidates this verification. 670 + memory.verified_substance = Some(voge_domain::Memory::substance_fingerprint( 671 + finding.details.as_ref(), 672 + )); 673 + memory.concepts = concepts; 674 + // Inherit the finding's engagement scope so verifications are 675 + // retrieved alongside the findings they assess. 676 + memory.engagement_id = finding.engagement_id.clone(); 482 677 if self.services.get_config()?.memory.as_ref().map_or(true, |m| m.strip_secrets) { 483 678 memory = voge_domain::redact_memory(memory); 484 679 }
+2
crates/voge_config/.voge.toml
··· 32 32 currency_symbol = "$" 33 33 currency_conversion_rate = 1.0 34 34 subagents = true 35 + require_adversarial_verification = true 35 36 auto_install_vscode_extension = true 36 37 use_voge_committer = true 37 38 use_text_patch_fallback = false ··· 56 57 pool_idle_timeout_secs = 90 57 58 pool_max_idle_per_host = 5 58 59 read_timeout_secs = 900 60 + stream_timeout_secs = 180 59 61 tls_backend = "default" 60 62 61 63 [compact]
+8
crates/voge_config/src/config.rs
··· 338 338 #[serde(default)] 339 339 pub subagents: bool, 340 340 341 + /// When `true` (default), a finding may be marked `confirmed` only after an 342 + /// independent adversarial verification with a `survived` verdict, recorded 343 + /// by the `adversary` agent — `memory_update_finding` rejects `confirmed` 344 + /// without one. When `false`, the verification gate is skipped (escape 345 + /// hatch for targets that cannot run the adversary). Defaults to `true`. 346 + #[serde(default)] 347 + pub require_adversarial_verification: bool, 348 + 341 349 /// Enables automatic VS Code extension installation when Voge runs inside 342 350 /// VS Code and the extension is not already installed. 343 351 #[serde(default)]
+12
crates/voge_config/src/http.rs
··· 31 31 pub struct HttpConfig { 32 32 pub connect_timeout_secs: u64, 33 33 pub read_timeout_secs: u64, 34 + /// Max seconds of silence (no first byte / no token) from the provider 35 + /// stream before the request is aborted as stalled. Bounds both 36 + /// time-to-first-byte and inter-token gaps so a hung/queued request errors 37 + /// out instead of stalling the agent forever. 38 + #[serde(default = "default_stream_timeout_secs")] 39 + pub stream_timeout_secs: u64, 34 40 pub pool_idle_timeout_secs: u64, 35 41 pub pool_max_idle_per_host: usize, 36 42 pub max_redirects: usize, ··· 54 60 pub root_cert_paths: Option<Vec<String>>, 55 61 } 56 62 63 + /// Default provider-stream silence timeout (seconds). See `stream_timeout_secs`. 64 + fn default_stream_timeout_secs() -> u64 { 65 + 180 66 + } 67 + 57 68 #[cfg(test)] 58 69 mod tests { 59 70 use pretty_assertions::assert_eq; ··· 65 76 let config = HttpConfig { 66 77 connect_timeout_secs: 30, 67 78 read_timeout_secs: 900, 79 + stream_timeout_secs: 180, 68 80 pool_idle_timeout_secs: 90, 69 81 pool_max_idle_per_host: 5, 70 82 max_redirects: 10,
+3 -1
crates/voge_domain/src/compact/summary.rs
··· 411 411 | ToolCatalog::MemoryAdd(_) 412 412 | ToolCatalog::MemoryUpdateFinding(_) 413 413 | ToolCatalog::MemoryCoverage(_) 414 + | ToolCatalog::MemoryCoverageStatus(_) 414 415 | ToolCatalog::MemoryFindTaintPaths(_) 415 - | ToolCatalog::MemoryTrace(_) => None, 416 + | ToolCatalog::MemoryTrace(_) 417 + | ToolCatalog::MemoryVerification(_) => None, 416 418 ToolCatalog::Task(input) => Some(SummaryTool::Task { agent_id: input.agent_id }), 417 419 }; 418 420 }
+201 -1
crates/voge_domain/src/memory.rs
··· 76 76 /// issue, with a `verdict` (safe / unsafe / needs review). Prevents 77 77 /// re-auditing and supports coverage-gap queries. 78 78 Coverage, 79 + /// An adversarial verification of a `Finding`: the outcome of an 80 + /// independent agent re-deriving the taint path and re-running the PoC on 81 + /// the unmodified target. Carries a `verification_verdict` and links back 82 + /// to the assessed finding via `subject_finding_id`. 83 + Verification, 79 84 } 80 85 81 86 impl MemoryType { ··· 90 95 Self::Bug => "bug", 91 96 Self::Finding => "finding", 92 97 Self::Coverage => "coverage", 98 + Self::Verification => "verification", 93 99 } 94 100 } 95 101 } ··· 107 113 "bug" => Ok(Self::Bug), 108 114 "finding" => Ok(Self::Finding), 109 115 "coverage" => Ok(Self::Coverage), 116 + "verification" => Ok(Self::Verification), 110 117 other => Err(anyhow::anyhow!("unknown memory type: {other}")), 111 118 } 112 119 } ··· 124 131 /// Audit incomplete; needs follow-up. The default for new coverage. 125 132 #[default] 126 133 NeedsReview, 134 + /// Started but genuinely unfinished (an honest non-terminal lane, so an 135 + /// incomplete audit is never structurally pressured into a false `safe`). 136 + /// Treated identically to `NeedsReview` by the coverage completion gate. 137 + Partial, 127 138 } 128 139 129 140 impl CoverageVerdict { ··· 133 144 Self::Safe => "safe", 134 145 Self::Unsafe => "unsafe", 135 146 Self::NeedsReview => "needs_review", 147 + Self::Partial => "partial", 136 148 } 137 149 } 138 150 } ··· 144 156 "safe" => Ok(Self::Safe), 145 157 "unsafe" => Ok(Self::Unsafe), 146 158 "needs_review" => Ok(Self::NeedsReview), 159 + "partial" => Ok(Self::Partial), 147 160 other => Err(anyhow::anyhow!("unknown coverage verdict: {other}")), 161 + } 162 + } 163 + } 164 + 165 + /// Verdict for a [`MemoryType::Verification`] memory: the outcome of an 166 + /// independent adversarial check (refutation + PoC re-execution) on a finding. 167 + #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] 168 + #[serde(rename_all = "snake_case")] 169 + pub enum VerificationVerdict { 170 + /// The finding survived refutation AND the PoC reproduced on the 171 + /// unmodified target via the real entry point. Only this unlocks 172 + /// [`FindingStatus::Confirmed`]. 173 + Survived, 174 + /// The claim was broken: a refuting sanitizer/guard was found, or the PoC 175 + /// did not reproduce the claimed effect. 176 + Refuted, 177 + /// The check could not be completed (e.g. the target could not be built or 178 + /// run). The finding stays `speculative`. The default — a verdict must be 179 + /// set explicitly and never defaults to `Survived`. 180 + #[default] 181 + Inconclusive, 182 + } 183 + 184 + impl VerificationVerdict { 185 + /// Returns the lowercase storage representation. 186 + pub fn as_str(&self) -> &'static str { 187 + match self { 188 + Self::Survived => "survived", 189 + Self::Refuted => "refuted", 190 + Self::Inconclusive => "inconclusive", 191 + } 192 + } 193 + } 194 + 195 + impl FromStr for VerificationVerdict { 196 + type Err = anyhow::Error; 197 + fn from_str(s: &str) -> anyhow::Result<Self> { 198 + match s { 199 + "survived" => Ok(Self::Survived), 200 + "refuted" => Ok(Self::Refuted), 201 + "inconclusive" => Ok(Self::Inconclusive), 202 + other => Err(anyhow::anyhow!("unknown verification verdict: {other}")), 148 203 } 149 204 } 150 205 } ··· 450 505 /// engagement-scoped retrieval during vulnerability-hunting sessions. 451 506 #[serde(default)] 452 507 pub engagement_id: Option<String>, 508 + /// For `Verification` memories: the finding id this verification assesses. 509 + /// `None` for other types. 510 + #[serde(default)] 511 + pub subject_finding_id: Option<String>, 512 + /// Outcome for `Verification` memories; `None` for other types. 513 + #[serde(default)] 514 + pub verification_verdict: Option<VerificationVerdict>, 515 + /// For `Verification` memories: the security-substance fingerprint 516 + /// (CWE + severity + taint_path) of the subject finding AT VERIFICATION 517 + /// TIME, binding the verification to the claim the adversary actually 518 + /// checked. `None` for other types and for legacy verifications recorded 519 + /// before this column existed. Drives the substance-matched confirmation 520 + /// gate so a narrow verification cannot be widened at confirm time. 521 + #[serde(default)] 522 + pub verified_substance: Option<String>, 453 523 } 454 524 455 525 fn default_strength() -> f64 { ··· 473 543 format!("{:016x}", hasher.finish()) 474 544 } 475 545 546 + /// Security-substance fingerprint: hashes the finding's CLASSIFIED substance 547 + /// (CWE + severity + taint_path source/propagators/sink/sanitizers), NOT its 548 + /// prose `content`. Two findings with the same CWE/severity/taint share a 549 + /// fingerprint even when their evidence writeup differs, so evidence/PoC 550 + /// refinement during report writing does NOT force re-verification; any 551 + /// change to the classified security substance does. This split closes the 552 + /// broaden-at-confirm evasion without reintroducing the post-confirmation- 553 + /// edit regression (where refining a confirmed finding's prose forced a full 554 + /// adversary re-run). 555 + pub fn substance_fingerprint(details: Option<&FindingDetails>) -> String { 556 + use std::hash::{Hash, Hasher}; 557 + let mut hasher = std::collections::hash_map::DefaultHasher::new(); 558 + if let Some(d) = details { 559 + d.cwe.as_deref().unwrap_or("").hash(&mut hasher); 560 + d.severity 561 + .map(|s| s.as_str()) 562 + .unwrap_or("") 563 + .hash(&mut hasher); 564 + if let Some(tp) = &d.taint_path { 565 + tp.source.file_path.as_str().hash(&mut hasher); 566 + for p in &tp.propagators { 567 + p.file_path.as_str().hash(&mut hasher); 568 + } 569 + tp.sink.file_path.as_str().hash(&mut hasher); 570 + for s in &tp.sanitized_by { 571 + s.file_path.as_str().hash(&mut hasher); 572 + } 573 + } 574 + } 575 + format!("substance:{:016x}", hasher.finish()) 576 + } 577 + 476 578 /// Returns a revised copy that supersedes `self`: a fresh id, `version` 477 579 /// incremented, `supersedes_id` pointing back at this memory, and reset 478 580 /// usage/bookkeeping. Callers mutate the returned memory (e.g. set a new ··· 487 589 revised.last_used_at = None; 488 590 revised.created_at = Utc::now(); 489 591 revised.updated_at = None; 592 + // DEFENSE-IN-DEPTH ONLY (not the root confirmation gate — that is the 593 + // substance-bound check in MemoryUpdateFinding). The sole production 594 + // caller (MemoryUpdateFinding) re-asserts status after revise(), so this 595 + // reset is a no-op there today; it exists so any FUTURE caller of 596 + // revise() that does NOT re-assert status cannot carry Confirmed across 597 + // a version bump by accident. The real guarantee is: a confirmed-latest 598 + // finding always has a substance-matched survived verification in its 599 + // chain. 600 + if revised.status == Some(FindingStatus::Confirmed) { 601 + revised.status = Some(FindingStatus::Speculative); 602 + } 490 603 revised 491 604 } 492 605 ··· 533 646 }, 534 647 scope: MemoryScope::default(), 535 648 engagement_id: None, 649 + subject_finding_id: None, 650 + verification_verdict: None, 651 + verified_substance: None, 536 652 } 537 653 } 538 654 } ··· 577 693 MemoryType::Bug, 578 694 MemoryType::Finding, 579 695 MemoryType::Coverage, 696 + MemoryType::Verification, 580 697 ] { 581 698 let actual = MemoryType::from_str(ty.as_str()).unwrap(); 582 699 assert_eq!(actual, ty); ··· 585 702 586 703 #[test] 587 704 fn test_coverage_verdict_round_trip() { 588 - for v in [CoverageVerdict::Safe, CoverageVerdict::Unsafe, CoverageVerdict::NeedsReview] { 705 + for v in [CoverageVerdict::Safe, CoverageVerdict::Unsafe, CoverageVerdict::NeedsReview, CoverageVerdict::Partial] { 589 706 let actual = CoverageVerdict::from_str(v.as_str()).unwrap(); 707 + assert_eq!(actual, v); 708 + } 709 + } 710 + 711 + /// The substance fingerprint must key on CWE + severity + taint_path and be 712 + /// BLIND to prose `content`/payload/poc — this is the property that lets an 713 + /// agent refine a confirmed finding's evidence without re-running the 714 + /// adversary, while still forcing re-verification when the classified 715 + /// substance (cwe/severity/taint) changes. 716 + #[test] 717 + fn substance_fingerprint_keys_on_substance_not_content() { 718 + let mk_details = || FindingDetails { 719 + cwe: Some("CWE-190".into()), 720 + severity: Some(Severity::High), 721 + taint_path: Some(TaintPath { 722 + source: TaintLocation { file_path: "a.cpp".into(), ..Default::default() }, 723 + propagators: vec![], 724 + sink: TaintLocation { file_path: "b.cpp".into(), ..Default::default() }, 725 + sanitized_by: vec![], 726 + ..Default::default() 727 + }), 728 + ..Default::default() 729 + }; 730 + let baseline = Memory::substance_fingerprint(Some(&mk_details())); 731 + assert!(baseline.starts_with("substance:")); 732 + // Identical substance -> identical fingerprint. 733 + assert_eq!(Memory::substance_fingerprint(Some(&mk_details())), baseline); 734 + // Change CWE -> differs. 735 + let mut d = mk_details(); 736 + d.cwe = Some("CWE-787".into()); 737 + assert_ne!(Memory::substance_fingerprint(Some(&d)), baseline); 738 + // Change severity -> differs. 739 + let mut d = mk_details(); 740 + d.severity = Some(Severity::Critical); 741 + assert_ne!(Memory::substance_fingerprint(Some(&d)), baseline); 742 + // Change taint sink -> differs. 743 + let mut d = mk_details(); 744 + if let Some(tp) = d.taint_path.as_mut() { 745 + tp.sink.file_path = "z.cpp".into(); 746 + } 747 + assert_ne!(Memory::substance_fingerprint(Some(&d)), baseline); 748 + // None is stable and distinct from any real substance. 749 + let none_fp = Memory::substance_fingerprint(None); 750 + assert_ne!(none_fp, baseline); 751 + assert_eq!(none_fp, Memory::substance_fingerprint(None)); 752 + } 753 + 754 + /// revise() must NOT carry Confirmed across a version bump (defense-in-depth 755 + /// for any future caller that doesn't re-assert status). 756 + #[test] 757 + fn revise_resets_confirmed_to_speculative_defense_in_depth() { 758 + let mut m = Memory::new(WorkspaceHash::new(1), MemoryType::Finding, "t", "c"); 759 + m.status = Some(FindingStatus::Confirmed); 760 + let revised = m.revise(); 761 + assert_eq!(revised.status, Some(FindingStatus::Speculative)); 762 + assert_eq!(revised.supersedes_id, Some(m.id.clone())); 763 + assert!(revised.is_latest); 764 + // Speculative stays speculative; Refuted stays Refuted. 765 + m.status = Some(FindingStatus::Speculative); 766 + assert_eq!(m.revise().status, Some(FindingStatus::Speculative)); 767 + m.status = Some(FindingStatus::Refuted); 768 + assert_eq!(m.revise().status, Some(FindingStatus::Refuted)); 769 + } 770 + 771 + /// An explicit status re-assertion AFTER revise() wins — the legitimate 772 + /// verify-then-confirm flow (MemoryUpdateFinding sets status after revise). 773 + #[test] 774 + fn revise_reset_does_not_override_explicit_status() { 775 + let mut m = Memory::new(WorkspaceHash::new(1), MemoryType::Finding, "t", "c"); 776 + m.status = Some(FindingStatus::Confirmed); 777 + let mut revised = m.revise(); 778 + revised.status = Some(FindingStatus::Confirmed); 779 + assert_eq!(revised.status, Some(FindingStatus::Confirmed)); 780 + } 781 + 782 + #[test] 783 + fn test_verification_verdict_round_trip() { 784 + for v in [ 785 + VerificationVerdict::Survived, 786 + VerificationVerdict::Refuted, 787 + VerificationVerdict::Inconclusive, 788 + ] { 789 + let actual = VerificationVerdict::from_str(v.as_str()).unwrap(); 590 790 assert_eq!(actual, v); 591 791 } 592 792 }
+17
crates/voge_domain/src/repo.rs
··· 136 136 file_path: Option<&str>, 137 137 ) -> Result<Vec<Memory>>; 138 138 139 + /// Returns whether a latest (`is_latest = true`) verification memory with a 140 + /// `survived` verdict exists for `finding_id`. This is the gate for 141 + /// promoting a finding to `confirmed`. 142 + async fn has_surviving_verification(&self, finding_id: &MemoryId) -> Result<bool>; 143 + 144 + /// Returns whether a latest `survived` verification exists in `finding`'s 145 + /// supersedes chain (the finding itself + its predecessors) whose recorded 146 + /// `verified_substance` EQUALS `substance`. Chain-walk is safe because the 147 + /// substance binding means a narrow ancestor verification (different 148 + /// substance) cannot cover a broader descendant — this is what closes the 149 + /// broaden-at-confirm evasion that defeated the id-only gate. 150 + async fn has_surviving_verification_for_substance( 151 + &self, 152 + finding: &Memory, 153 + substance: &str, 154 + ) -> Result<bool>; 155 + 139 156 /// Returns the top `limit` latest memories of `project`, ordered by usage 140 157 /// and recency (input to consolidation). 141 158 async fn get_top_memories(&self, project: WorkspaceHash, limit: usize) -> Result<Vec<Memory>>;
+70 -4
crates/voge_domain/src/tools/catalog.rs
··· 14 14 use strum::IntoEnumIterator; 15 15 use strum_macros::{AsRefStr, Display, EnumDiscriminants, EnumIter}; 16 16 17 - use crate::{CoverageVerdict, FindingStatus, MemoryScope, MemoryType, Severity, ToolCallArguments, ToolCallFull, ToolDefinition, ToolDescription, ToolName}; 17 + use crate::{CoverageVerdict, FindingStatus, MemoryScope, MemoryType, Severity, ToolCallArguments, ToolCallFull, ToolDefinition, ToolDescription, ToolName, VerificationVerdict}; 18 18 19 19 /// Enum representing all possible tool input types. 20 20 /// ··· 63 63 MemoryAdd(MemoryAdd), 64 64 MemoryUpdateFinding(MemoryUpdateFinding), 65 65 MemoryCoverage(MemoryCoverage), 66 + MemoryCoverageStatus(MemoryCoverageStatus), 66 67 MemoryFindTaintPaths(MemoryFindTaintPaths), 67 68 MemoryTrace(MemoryTrace), 69 + MemoryVerification(MemoryVerification), 68 70 } 69 71 70 72 /// Input structure for agent tool calls. This serves as the generic schema ··· 547 549 #[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, ToolDescription, PartialEq)] 548 550 #[tool_description_file = "crates/voge_domain/src/tools/descriptions/memory_coverage.md"] 549 551 pub struct MemoryCoverage { 550 - /// Short title (what was audited, e.g. "SQL injection in /login"). 551 - pub title: String, 552 + /// Short title (what was audited, e.g. "SQL injection in /login"). Optional; 553 + /// if omitted, defaults to the `file_path` (or a content snippet) so per-file 554 + /// coverage calls don't need a redundant title. 555 + #[schemars(default)] 556 + pub title: Option<String>, 552 557 /// What was checked and the outcome reasoning. 553 558 pub content: String, 554 559 /// Audit verdict: `safe`, `unsafe`, or `needs_review`. ··· 568 573 pub concepts: Option<String>, 569 574 } 570 575 576 + /// Input for the `memory_coverage_status` tool: read the project's coverage 577 + /// verdicts BY PATH. Disclose-only — does NOT record or block. Returns the 578 + /// distinct set of file paths with a latest coverage verdict, each verdict, and 579 + /// rolled-up counts. Completion is a COUNT EQUALITY (`total_latest` distinct 580 + /// paths == `census_total`, re-derived from the sanctioned `find`) AND 581 + /// `needs_review == 0` AND `partial == 0` — `needs_review == 0` ALONE is not 582 + /// sufficient (it is satisfiable by never recording coverage for a hard file). 583 + #[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, ToolDescription, PartialEq)] 584 + #[tool_description_file = "crates/voge_domain/src/tools/descriptions/memory_coverage_status.md"] 585 + pub struct MemoryCoverageStatus {} 586 + 587 + /// Per-path coverage verdict entry returned by `memory_coverage_status`. 588 + #[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] 589 + pub struct CoveragePathVerdict { 590 + pub file_path: String, 591 + #[eserde(compat)] 592 + pub verdict: CoverageVerdict, 593 + } 594 + 595 + /// Output of `memory_coverage_status`: distinct-path latest-verdict map + 596 + /// rolled-up counts. `total_latest` is DISTINCT PATHS (after the 597 + /// coverage-supersede fix), NOT rows. 598 + #[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] 599 + pub struct CoverageStatusOutput { 600 + pub safe: u32, 601 + pub unsafe_: u32, 602 + pub needs_review: u32, 603 + pub partial: u32, 604 + pub total_latest: u32, 605 + pub paths: Vec<CoveragePathVerdict>, 606 + } 607 + 571 608 /// Input for the `memory_find_taint_paths` tool: query recorded taint flows. 572 609 #[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, ToolDescription, PartialEq)] 573 610 #[tool_description_file = "crates/voge_domain/src/tools/descriptions/memory_find_taint_paths.md"] ··· 590 627 /// Max chars of transcript to return. Defaults to 8000. 591 628 #[schemars(default)] 592 629 pub max_chars: Option<usize>, 630 + } 631 + 632 + /// Input for the `memory_verification` tool: record the outcome of an 633 + /// independent adversarial verification of a finding (adversary-only). This is 634 + /// the sole tool that can create a verification, and only a `survived` 635 + /// verification unlocks `confirmed` for a finding. 636 + #[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, ToolDescription, PartialEq)] 637 + #[tool_description_file = "crates/voge_domain/src/tools/descriptions/memory_verification.md"] 638 + pub struct MemoryVerification { 639 + /// The id of the finding memory this verification assesses (e.g. `mem_xxx`). 640 + pub finding_id: String, 641 + /// Verdict: `survived`, `refuted`, or `inconclusive`. `survived` = the 642 + /// finding held up AND the PoC reproduced on the unmodified target via the 643 + /// real entry point. Only `survived` lets the finding become `confirmed`. 644 + #[eserde(compat)] 645 + pub verdict: VerificationVerdict, 646 + /// What you checked: the independent taint re-derivation, any refuting 647 + /// sanitizer/guard you considered, and the PoC re-run command + observed 648 + /// effect (or why it could not be re-run). This is the defensible evidence. 649 + pub evidence: String, 650 + /// Comma- or newline-separated tags/keywords. 651 + #[schemars(default)] 652 + pub concepts: Option<String>, 593 653 } 594 654 595 655 #[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, ToolDescription, PartialEq)] ··· 977 1037 ToolCatalog::MemoryAdd(v) => v.description(), 978 1038 ToolCatalog::MemoryUpdateFinding(v) => v.description(), 979 1039 ToolCatalog::MemoryCoverage(v) => v.description(), 1040 + ToolCatalog::MemoryCoverageStatus(v) => v.description(), 980 1041 ToolCatalog::MemoryFindTaintPaths(v) => v.description(), 981 1042 ToolCatalog::MemoryTrace(v) => v.description(), 1043 + ToolCatalog::MemoryVerification(v) => v.description(), 982 1044 } 983 1045 } 984 1046 } ··· 1045 1107 ToolCatalog::MemoryAdd(_) => r#gen.into_root_schema_for::<MemoryAdd>(), 1046 1108 ToolCatalog::MemoryUpdateFinding(_) => r#gen.into_root_schema_for::<MemoryUpdateFinding>(), 1047 1109 ToolCatalog::MemoryCoverage(_) => r#gen.into_root_schema_for::<MemoryCoverage>(), 1110 + ToolCatalog::MemoryCoverageStatus(_) => r#gen.into_root_schema_for::<MemoryCoverageStatus>(), 1048 1111 ToolCatalog::MemoryFindTaintPaths(_) => r#gen.into_root_schema_for::<MemoryFindTaintPaths>(), 1049 1112 ToolCatalog::MemoryTrace(_) => r#gen.into_root_schema_for::<MemoryTrace>(), 1113 + ToolCatalog::MemoryVerification(_) => r#gen.into_root_schema_for::<MemoryVerification>(), 1050 1114 }; 1051 1115 1052 1116 // Apply transform to add nullable property and remove null from type ··· 1178 1242 | ToolCatalog::MemoryAdd(_) 1179 1243 | ToolCatalog::MemoryUpdateFinding(_) 1180 1244 | ToolCatalog::MemoryCoverage(_) 1245 + | ToolCatalog::MemoryCoverageStatus(_) 1181 1246 | ToolCatalog::MemoryFindTaintPaths(_) 1182 - | ToolCatalog::MemoryTrace(_) => None, 1247 + | ToolCatalog::MemoryTrace(_) 1248 + | ToolCatalog::MemoryVerification(_) => None, 1183 1249 } 1184 1250 } 1185 1251
+4 -2
crates/voge_domain/src/tools/definition/snapshots/voge_domain__tools__definition__usage__tests__tool_usage.snap
··· 21 21 <tool>{"name":"memory_search","description":"Search your persistent cross-session memory for this project. Use this when you suspect relevant context (facts about the user, their preferences, past decisions, recurring workflows, known pitfalls) exists from previous sessions but is not already summarized in the `<memory_summary>` block of the system prompt.\n\n# Inputs\n\n- `query` (required): natural-language description of what you want to recall. It is matched against memory titles, content, and concepts.\n- `limit` (optional): max memories to return. Defaults to 5.\n\n# When to use\n\n- Before re-asking the user something they may have already told you.\n- When picking between approaches the user may have previously expressed a preference about.\n- To surface a known pitfall or workflow specific to this codebase.\n\n# When NOT to use\n\n- Do not call if the `<memory_summary>` already answers the question.\n- Do not call repeatedly with the same query.","arguments":{"limit":{"description":"Maximum number of memories to return. Defaults to 5.","type":"integer","is_required":false},"query":{"description":"Natural-language description of what you want to recall (facts,\npreferences, past decisions, workflows, pitfalls). Matched against\nmemory titles, content, and concepts.","type":"string","is_required":true}}}</tool> 22 22 <tool>{"name":"memory_read","description":"Read a single memory by its id (e.g. `mem_xxx`), returning its full content. Use this to expand on a memory surfaced by `memory_search` or cited in the system prompt.\n\n# Inputs\n\n- `id` (required): the memory id, as returned by `memory_search`.","arguments":{"id":{"description":"The id of the memory to read (e.g. `mem_xxx`).","type":"string","is_required":true}}}</tool> 23 23 <tool>{"name":"memory_add","description":"Persist a new memory for this project so future sessions recall it. Use this when the user explicitly asks you to remember or note something, OR when you are conducting a security audit and are recording a finding or coverage result (kind `finding`/`coverage`) as part of the systematic audit workflow. Never invent facts or silently add non-audit memories.\n\n# Inputs\n\n- `title` (required): short label summarizing the memory.\n- `content` (required): the fact, preference, workflow, or pitfall to remember.\n- `kind` (optional): one of `fact`, `preference`, `pattern`, `workflow`, `architecture`, `bug`, `finding`, `coverage`. Defaults to `fact`.\n- `concepts` (optional): tags/keywords (comma- or newline-separated) that will help future retrieval.\n- `scope` (optional): `project` (default) or `global` — use `global` for cross-project patterns or CVE knowledge that applies to every project.\n\n# Notes\n\n- The memory is scoped to the current project and injected into future sessions automatically (unless `scope` is `global`, in which case it is recalled for any project).\n- Keep content concise and self-contained.","arguments":{"concepts":{"description":"Comma- or newline-separated tags/keywords to aid future retrieval.","type":"string","is_required":false},"content":{"description":"The fact, preference, workflow, or pitfall to remember.","type":"string","is_required":true},"kind":{"description":"Category: one of `fact`, `preference`, `pattern`, `workflow`,\n`architecture`, `bug`. Defaults to `fact`.","is_required":false},"scope":{"description":"Scope: `project` (default) or `global` (cross-project pattern/CVE).","is_required":false},"title":{"description":"Short title summarizing the memory.","type":"string","is_required":true}}}</tool> 24 - <tool>{"name":"memory_update_finding","description":"Revise the lifecycle status of a finding memory (`speculative` → `confirmed`/`refuted`), creating a new version that supersedes the old one. Use this when an earlier `memory_add` finding (kind `finding`) has been investigated and you can now confirm or refute it. The taint inputs (`taint_source` / `taint_propagators` / `taint_sink` / `taint_sanitizer`) record the finding's data-flow path into a taint graph, enabling transitive source→sink path discovery across findings via `memory_find_taint_paths`.\n\n# Inputs\n\n- `id` (required): the finding memory id to revise (e.g. `mem_xxx`).\n- `status` (required): new status — `speculative`, `confirmed`, or `refuted`.\n- `evidence` (optional): new evidence/reasoning; replaces the finding's `content` if given, otherwise prior content carries forward.\n- `confidence` (optional): confidence in `[0.0, 1.0]`; omit to keep prior confidence.\n- `cwe` (optional): CWE identifier (e.g. `CWE-89`).\n- `severity` (optional): `info` / `low` / `medium` / `high` / `critical`.\n- `taint_source` (optional): file path where untrusted data enters.\n- `taint_propagators` (optional): ordered list of intermediate file paths the tainted data flows through between source and sink. Each consecutive pair becomes a graph edge; locations shared with other findings chain into transitive paths.\n- `taint_sink` (optional): file path where tainted data reaches a dangerous operation.\n- `taint_sanitizer` (optional): file path where taint is neutralised. The flow edge into this location is marked sanitized, cutting vulnerable paths through it. Takes precedence over `taint_sanitized`.\n- `taint_sanitized` (optional): if true and no `taint_sanitizer` is given, mark the direct source→sink edge as sanitized.\n\n# Notes\n\n- Provide `taint_source` and `taint_sink` together (with optional `taint_propagators` between them) to record a multi-hop path.\n- Include a line or function in the path strings where possible (e.g. `src/db.rs:42` or `src/db.rs::exec`) so distinct locations in the same file do not collide into one graph node.\n- The prior version is marked non-latest and is no longer injected or returned by `memory_search`; only the new version is. Its taint-graph edges are dropped automatically.\n- Prefer this over `memory_add` when refining an existing finding — it preserves the version chain (`supersedes_id`, `version`).\n- Use `refuted` for false positives so they stop surfacing (and their taint edges stop contributing to paths) but remain auditable.","arguments":{"confidence":{"description":"Confidence in `[0.0, 1.0]`; omit to keep the prior confidence.","type":"number","is_required":false},"cwe":{"description":"CWE identifier (e.g. `CWE-89`).","type":"string","is_required":false},"evidence":{"description":"New evidence/reasoning; replaces the finding's `content` if given,\notherwise the prior content is carried forward.","type":"string","is_required":false},"id":{"description":"The id of the finding memory to revise (e.g. `mem_xxx`).","type":"string","is_required":true},"severity":{"description":"Severity: `info`, `low`, `medium`, `high`, `critical`.","type":"string","is_required":false},"status":{"description":"New lifecycle status: `speculative`, `confirmed`, or `refuted`.","is_required":true},"taint_propagators":{"description":"Ordered intermediate taint-flow locations (file paths) between source\nand sink — the propagators. Each consecutive pair becomes a graph edge,\nenabling transitive path discovery across findings.","type":"array","is_required":false},"taint_sanitized":{"description":"If true, mark the taint path as sanitized (neutralised). Prefer\n`taint_sanitizer` when the sanitizer location is known.","type":"boolean","is_required":false},"taint_sanitizer":{"description":"A sanitizer location (file path) where taint is neutralised. The graph\nedge into this location is marked sanitized, cutting vulnerable paths\nthrough it. Takes precedence over `taint_sanitized`.","type":"string","is_required":false},"taint_sink":{"description":"Taint sink (file path) — records the finding's taint flow sink.","type":"string","is_required":false},"taint_source":{"description":"Taint source (file path) — records the finding's taint flow source.","type":"string","is_required":false}}}</tool> 25 - <tool>{"name":"memory_coverage","description":"Record an audit-coverage result: that a code path was checked for a class of issue, with a verdict. Use this to avoid re-auditing the same path and to track coverage gaps. Pair with `memory_add` (kind `finding`) when an issue is actually found.\n\n# Inputs\n\n- `title` (required): what was audited (e.g. \"SQL injection in /login\").\n- `content` (required): what was checked and the outcome reasoning.\n- `verdict` (required): `safe`, `unsafe`, or `needs_review`.\n- `file_path` (optional): file audited.\n- `line` (optional): line number audited.\n- `cwe` (optional): CWE class checked (e.g. `CWE-89`).\n- `concepts` (optional): comma- or newline-separated tags/keywords.\n\n# Notes\n\n- `safe` = audited, no issue. `unsafe` = issue found (also record a `finding`). `needs_review` = audit incomplete.\n- Coverage memories are queryable via `memory_search` but excluded from the top-N auto-injection.","arguments":{"concepts":{"description":"Comma- or newline-separated tags/keywords.","type":"string","is_required":false},"content":{"description":"What was checked and the outcome reasoning.","type":"string","is_required":true},"cwe":{"description":"CWE class checked (optional, e.g. `CWE-89`).","type":"string","is_required":false},"file_path":{"description":"File path audited (optional).","type":"string","is_required":false},"line":{"description":"Line number audited (optional).","type":"integer","is_required":false},"title":{"description":"Short title (what was audited, e.g. \"SQL injection in /login\").","type":"string","is_required":true},"verdict":{"description":"Audit verdict: `safe`, `unsafe`, or `needs_review`.","is_required":true}}}</tool> 24 + <tool>{"name":"memory_update_finding","description":"Revise the lifecycle status of a finding memory (`speculative` → `confirmed`/`refuted`), creating a new version that supersedes the old one. Use this when an earlier `memory_add` finding (kind `finding`) has been investigated and you can now confirm or refute it. The taint inputs (`taint_source` / `taint_propagators` / `taint_sink` / `taint_sanitizer`) record the finding's data-flow path into a taint graph, enabling transitive source→sink path discovery across findings via `memory_find_taint_paths`.\n\n**`confirmed` is gated.** Unless `require_adversarial_verification = false`, this tool REJECTS `status=\"confirmed\"` unless the independent `adversary` agent has recorded a `survived` verification for this finding (via `memory_verification`). Delegate refutation to `adversary` (task) first; only a `survived` verdict unlocks `confirmed`.\n\n# Inputs\n\n- `id` (required): the finding memory id to revise (e.g. `mem_xxx`).\n- `status` (required): new status — `speculative`, `confirmed`, or `refuted`.\n- `evidence` (optional): new evidence/reasoning; replaces the finding's `content` if given, otherwise prior content carries forward.\n- `confidence` (optional): confidence in `[0.0, 1.0]`; omit to keep prior confidence.\n- `cwe` (optional): CWE identifier (e.g. `CWE-89`).\n- `severity` (optional): `info` / `low` / `medium` / `high` / `critical`.\n- `taint_source` (optional): file path where untrusted data enters.\n- `taint_propagators` (optional): ordered list of intermediate file paths the tainted data flows through between source and sink. Each consecutive pair becomes a graph edge; locations shared with other findings chain into transitive paths.\n- `taint_sink` (optional): file path where tainted data reaches a dangerous operation.\n- `taint_sanitizer` (optional): file path where taint is neutralised. The flow edge into this location is marked sanitized, cutting vulnerable paths through it. Takes precedence over `taint_sanitized`.\n- `taint_sanitized` (optional): if true and no `taint_sanitizer` is given, mark the direct source→sink edge as sanitized.\n\n# Notes\n\n- Provide `taint_source` and `taint_sink` together (with optional `taint_propagators` between them) to record a multi-hop path.\n- Include a line or function in the path strings where possible (e.g. `src/db.rs:42` or `src/db.rs::exec`) so distinct locations in the same file do not collide into one graph node.\n- The prior version is marked non-latest and is no longer injected or returned by `memory_search`; only the new version is. Its taint-graph edges are dropped automatically.\n- Prefer this over `memory_add` when refining an existing finding — it preserves the version chain (`supersedes_id`, `version`).\n- Use `refuted` for false positives so they stop surfacing (and their taint edges stop contributing to paths) but remain auditable.","arguments":{"confidence":{"description":"Confidence in `[0.0, 1.0]`; omit to keep the prior confidence.","type":"number","is_required":false},"cwe":{"description":"CWE identifier (e.g. `CWE-89`).","type":"string","is_required":false},"evidence":{"description":"New evidence/reasoning; replaces the finding's `content` if given,\notherwise the prior content is carried forward.","type":"string","is_required":false},"id":{"description":"The id of the finding memory to revise (e.g. `mem_xxx`).","type":"string","is_required":true},"severity":{"description":"Severity: `info`, `low`, `medium`, `high`, `critical`.","type":"string","is_required":false},"status":{"description":"New lifecycle status: `speculative`, `confirmed`, or `refuted`.","is_required":true},"taint_propagators":{"description":"Ordered intermediate taint-flow locations (file paths) between source\nand sink — the propagators. Each consecutive pair becomes a graph edge,\nenabling transitive path discovery across findings.","type":"array","is_required":false},"taint_sanitized":{"description":"If true, mark the taint path as sanitized (neutralised). Prefer\n`taint_sanitizer` when the sanitizer location is known.","type":"boolean","is_required":false},"taint_sanitizer":{"description":"A sanitizer location (file path) where taint is neutralised. The graph\nedge into this location is marked sanitized, cutting vulnerable paths\nthrough it. Takes precedence over `taint_sanitized`.","type":"string","is_required":false},"taint_sink":{"description":"Taint sink (file path) — records the finding's taint flow sink.","type":"string","is_required":false},"taint_source":{"description":"Taint source (file path) — records the finding's taint flow source.","type":"string","is_required":false}}}</tool> 25 + <tool>{"name":"memory_coverage","description":"Record an audit-coverage result: that a code path was checked for a class of issue, with a verdict. Use this to avoid re-auditing the same path and to track coverage gaps. Pair with `memory_add` (kind `finding`) when an issue is actually found.\n\n# Inputs\n\n- `title` (optional): what was audited (e.g. \"SQL injection in /login\"). If omitted, defaults to the `file_path` (or a content snippet) — so for per-file coverage you can just pass `file_path` + `content` + `verdict`.\n- `content` (required): what was checked and the outcome reasoning.\n- `verdict` (required): `safe`, `unsafe`, or `needs_review`.\n- `file_path` (optional): file audited.\n- `line` (optional): line number audited.\n- `cwe` (optional): CWE class checked (e.g. `CWE-89`).\n- `concepts` (optional): comma- or newline-separated tags/keywords.\n\n# Notes\n\n- `safe` = audited, no issue. `unsafe` = issue found (also record a `finding`). `needs_review` = audit incomplete.\n- Coverage memories are queryable via `memory_search` but excluded from the top-N auto-injection.","arguments":{"concepts":{"description":"Comma- or newline-separated tags/keywords.","type":"string","is_required":false},"content":{"description":"What was checked and the outcome reasoning.","type":"string","is_required":true},"cwe":{"description":"CWE class checked (optional, e.g. `CWE-89`).","type":"string","is_required":false},"file_path":{"description":"File path audited (optional).","type":"string","is_required":false},"line":{"description":"Line number audited (optional).","type":"integer","is_required":false},"title":{"description":"Short title (what was audited, e.g. \"SQL injection in /login\"). Optional;\nif omitted, defaults to the `file_path` (or a content snippet) so per-file\ncoverage calls don't need a redundant title.","type":"string","is_required":false},"verdict":{"description":"Audit verdict: `safe`, `unsafe`, or `needs_review`.","is_required":true}}}</tool> 26 + <tool>{"name":"memory_coverage_status","description":"Read the project's audit coverage verdicts BY PATH. Disclose-only — does NOT record or block. Returns the distinct set of file paths that carry a latest coverage verdict, each path's verdict (safe / unsafe / needs_review / partial), and rolled-up counts.\n\nUse this to enforce the completion gate mechanically: the audit is complete ONLY when ALL hold — (a) the returned `needs_review` and `partial` counts are 0, AND (b) `total_latest` (DISTINCT paths) equals `census_total` re-derived from the verbatim sanctioned `find` commands this turn, AND (c) every census path appears in the returned per-path list with a non-needs_review verdict. `needs_review == 0` ALONE IS NOT SUFFICIENT — it is satisfiable by never recording coverage for a hard file, which is the original coverage-gate failure.\n\nTakes no arguments.","arguments":{}}</tool> 26 27 <tool>{"name":"memory_find_taint_paths","description":"Discover taint flows from source to sink by traversing the taint graph across findings. Returns transitive paths (source → propagators → sink), including paths that span multiple findings — e.g. finding A records `src→X` and finding B records `X→sink`, yielding `src→X→sink`. Use this to see which sinks are reachable from untrusted input and whether any path lacks sanitization.\n\n# Inputs\n\n- `sink_file` (optional): only return paths whose sink is in this file.\n- `unsanitized_only` (optional): if true, only return paths with no sanitizer on them (still exploitable). Defaults to false.\n\n# Output\n\nEach path lists its source, intermediate propagators, sink, any sanitizers on the path, and the ids of the findings whose edges compose it (`finding_ids`).\n\n# Notes\n\n- Paths are discovered by graph traversal, not just single-finding records — a path can chain edges from several findings that share an intermediate location.\n- Edges from superseded or refuted findings are excluded automatically.\n- `unsanitized_only=true` prunes any path that crosses a sanitized edge; `false` returns all paths and annotates which are sanitized (via `sanitized_by`).\n- Taint paths are recorded on findings via `memory_update_finding` (`taint_source` / `taint_propagators` / `taint_sink` / `taint_sanitizer`).\n- Path length and result count are bounded by the `max_taint_depth` / `max_taint_paths` memory config.","arguments":{"sink_file":{"description":"Only return taint paths whose sink is in this file (optional).","type":"string","is_required":false},"unsanitized_only":{"description":"If true, only return paths with no sanitizer (still exploitable).","type":"boolean","is_required":false}}}</tool> 27 28 <tool>{"name":"memory_trace","description":"Retrieve the source-conversation transcript a memory was distilled from, linking a finding to the reasoning that produced it (defensible audit trail). Use this when you need to justify or re-examine how a finding was derived.\n\n# Inputs\n\n- `id` (required): the memory id to trace (e.g. `mem_xxx`).\n- `max_chars` (optional): max chars of transcript to return. Defaults to 8000.\n\n# Notes\n\n- Only memories with recorded `source_conversation_ids` return a transcript; otherwise returns \"No source conversations recorded\".\n- Deleted/unavailable conversations are marked `[not found]`.","arguments":{"id":{"description":"The id of the memory to trace (e.g. `mem_xxx`).","type":"string","is_required":true},"max_chars":{"description":"Max chars of transcript to return. Defaults to 8000.","type":"integer","is_required":false}}}</tool> 29 + <tool>{"name":"memory_verification","description":"Record the outcome of an independent ADVERSARIAL verification of a finding — the result of re-deriving the finding's taint path AND re-running its recorded PoC on the UNMODIFIED target via the real entry point. This tool is the sole way to create a verification, and only a `survived` verification unlocks `confirmed` for a finding (the system rejects `confirmed` without one). Reserved for the `adversary` agent.\n\n# Inputs\n\n- `finding_id` (required): the id of the finding memory being assessed (e.g. `mem_xxx`).\n- `verdict` (required): `survived`, `refuted`, or `inconclusive`.\n- `evidence` (required): what you checked — your independent taint re-derivation, any refuting sanitizer/guard you considered, and the PoC re-run command + the observed effect (or why it could not be re-run).\n- `concepts` (optional): comma- or newline-separated tags/keywords.\n\n# Notes\n\n- `survived` = you could NOT refute the path AND the PoC reproduced the claimed effect on the unmodified target through the real entry point. Only this lets the finding become `confirmed`.\n- `refuted` = you broke the claim (found a neutralizing sanitizer/guard the original analysis missed, or the PoC did not reproduce the claimed effect).\n- `inconclusive` = you could not complete the check (e.g. the target would not build/run) — the finding stays `speculative`. NEVER mark `survived` without a real re-execution on the unmodified target.\n- You MUST NOT edit the target's source or config to make the PoC succeed; the effect must be produced by the target itself, originating at the recorded external entry point (not an internal function or test harness).","arguments":{"concepts":{"description":"Comma- or newline-separated tags/keywords.","type":"string","is_required":false},"evidence":{"description":"What you checked: the independent taint re-derivation, any refuting\nsanitizer/guard you considered, and the PoC re-run command + observed\neffect (or why it could not be re-run). This is the defensible evidence.","type":"string","is_required":true},"finding_id":{"description":"The id of the finding memory this verification assesses (e.g. `mem_xxx`).","type":"string","is_required":true},"verdict":{"description":"Verdict: `survived`, `refuted`, or `inconclusive`. `survived` = the\nfinding held up AND the PoC reproduced on the unmodified target via the\nreal entry point. Only `survived` lets the finding become `confirmed`.","is_required":true}}}</tool>
+1 -1
crates/voge_domain/src/tools/descriptions/memory_coverage.md
··· 2 2 3 3 # Inputs 4 4 5 - - `title` (required): what was audited (e.g. "SQL injection in /login"). 5 + - `title` (optional): what was audited (e.g. "SQL injection in /login"). If omitted, defaults to the `file_path` (or a content snippet) — so for per-file coverage you can just pass `file_path` + `content` + `verdict`. 6 6 - `content` (required): what was checked and the outcome reasoning. 7 7 - `verdict` (required): `safe`, `unsafe`, or `needs_review`. 8 8 - `file_path` (optional): file audited.
+5
crates/voge_domain/src/tools/descriptions/memory_coverage_status.md
··· 1 + Read the project's audit coverage verdicts BY PATH. Disclose-only — does NOT record or block. Returns the distinct set of file paths that carry a latest coverage verdict, each path's verdict (safe / unsafe / needs_review / partial), and rolled-up counts. 2 + 3 + Use this to enforce the completion gate mechanically: the audit is complete ONLY when ALL hold — (a) the returned `needs_review` and `partial` counts are 0, AND (b) `total_latest` (DISTINCT paths) equals `census_total` re-derived from the verbatim sanctioned `find` commands this turn, AND (c) every census path appears in the returned per-path list with a non-needs_review verdict. `needs_review == 0` ALONE IS NOT SUFFICIENT — it is satisfiable by never recording coverage for a hard file, which is the original coverage-gate failure. 4 + 5 + Takes no arguments.
+2
crates/voge_domain/src/tools/descriptions/memory_update_finding.md
··· 1 1 Revise the lifecycle status of a finding memory (`speculative` → `confirmed`/`refuted`), creating a new version that supersedes the old one. Use this when an earlier `memory_add` finding (kind `finding`) has been investigated and you can now confirm or refute it. The taint inputs (`taint_source` / `taint_propagators` / `taint_sink` / `taint_sanitizer`) record the finding's data-flow path into a taint graph, enabling transitive source→sink path discovery across findings via `memory_find_taint_paths`. 2 2 3 + **`confirmed` is gated.** Unless `require_adversarial_verification = false`, this tool REJECTS `status="confirmed"` unless the independent `adversary` agent has recorded a `survived` verification for this finding (via `memory_verification`). Delegate refutation to `adversary` (task) first; only a `survived` verdict unlocks `confirmed`. 4 + 3 5 # Inputs 4 6 5 7 - `id` (required): the finding memory id to revise (e.g. `mem_xxx`).
+15
crates/voge_domain/src/tools/descriptions/memory_verification.md
··· 1 + Record the outcome of an independent ADVERSARIAL verification of a finding — the result of re-deriving the finding's taint path AND re-running its recorded PoC on the UNMODIFIED target via the real entry point. This tool is the sole way to create a verification, and only a `survived` verification unlocks `confirmed` for a finding (the system rejects `confirmed` without one). Reserved for the `adversary` agent. 2 + 3 + # Inputs 4 + 5 + - `finding_id` (required): the id of the finding memory being assessed (e.g. `mem_xxx`). 6 + - `verdict` (required): `survived`, `refuted`, or `inconclusive`. 7 + - `evidence` (required): what you checked — your independent taint re-derivation, any refuting sanitizer/guard you considered, and the PoC re-run command + the observed effect (or why it could not be re-run). 8 + - `concepts` (optional): comma- or newline-separated tags/keywords. 9 + 10 + # Notes 11 + 12 + - `survived` = you could NOT refute the path AND the PoC reproduced the claimed effect on the unmodified target through the real entry point. Only this lets the finding become `confirmed`. 13 + - `refuted` = you broke the claim (found a neutralizing sanitizer/guard the original analysis missed, or the PoC did not reproduce the claimed effect). 14 + - `inconclusive` = you could not complete the check (e.g. the target would not build/run) — the finding stays `speculative`. NEVER mark `survived` without a real re-execution on the unmodified target. 15 + - You MUST NOT edit the target's source or config to make the PoC succeed; the effect must be produced by the target itself, originating at the recorded external entry point (not an internal function or test harness).
+63 -3
crates/voge_domain/src/tools/snapshots/voge_domain__tools__catalog__tests__tool_definition_json.snap
··· 555 555 "description": "A coverage record: notes that a code path was audited for a class of\nissue, with a `verdict` (safe / unsafe / needs review). Prevents\nre-auditing and supports coverage-gap queries.", 556 556 "type": "string", 557 557 "const": "coverage" 558 + }, 559 + { 560 + "description": "An adversarial verification of a `Finding`: the outcome of an\nindependent agent re-deriving the taint path and re-running the PoC on\nthe unmodified target. Carries a `verification_verdict` and links back\nto the assessed finding via `subject_finding_id`.", 561 + "type": "string", 562 + "const": "verification" 558 563 } 559 564 ] 560 565 }, ··· 734 739 "nullable": true 735 740 }, 736 741 "title": { 737 - "description": "Short title (what was audited, e.g. \"SQL injection in /login\").", 738 - "type": "string" 742 + "description": "Short title (what was audited, e.g. \"SQL injection in /login\"). Optional;\nif omitted, defaults to the `file_path` (or a content snippet) so per-file\ncoverage calls don't need a redundant title.", 743 + "type": "string", 744 + "default": null, 745 + "nullable": true 739 746 }, 740 747 "verdict": { 741 748 "description": "Audit verdict: `safe`, `unsafe`, or `needs_review`.", ··· 754 761 "description": "Audit incomplete; needs follow-up. The default for new coverage.", 755 762 "type": "string", 756 763 "const": "needs_review" 764 + }, 765 + { 766 + "description": "Started but genuinely unfinished (an honest non-terminal lane, so an\nincomplete audit is never structurally pressured into a false `safe`).\nTreated identically to `NeedsReview` by the coverage completion gate.", 767 + "type": "string", 768 + "const": "partial" 757 769 } 758 770 ] 759 771 } 760 772 }, 761 773 "required": [ 762 - "title", 763 774 "content", 764 775 "verdict" 765 776 ] 777 + } 778 + { 779 + "description": "Input for the `memory_coverage_status` tool: read the project's coverage\nverdicts BY PATH. Disclose-only — does NOT record or block. Returns the\ndistinct set of file paths with a latest coverage verdict, each verdict, and\nrolled-up counts. Completion is a COUNT EQUALITY (`total_latest` distinct\npaths == `census_total`, re-derived from the sanctioned `find`) AND\n`needs_review == 0` AND `partial == 0` — `needs_review == 0` ALONE is not\nsufficient (it is satisfiable by never recording coverage for a hard file).", 780 + "type": "object" 766 781 } 767 782 { 768 783 "description": "Input for the `memory_find_taint_paths` tool: query recorded taint flows.", ··· 803 818 "id" 804 819 ] 805 820 } 821 + { 822 + "description": "Input for the `memory_verification` tool: record the outcome of an\nindependent adversarial verification of a finding (adversary-only). This is\nthe sole tool that can create a verification, and only a `survived`\nverification unlocks `confirmed` for a finding.", 823 + "type": "object", 824 + "properties": { 825 + "concepts": { 826 + "description": "Comma- or newline-separated tags/keywords.", 827 + "type": "string", 828 + "default": null, 829 + "nullable": true 830 + }, 831 + "evidence": { 832 + "description": "What you checked: the independent taint re-derivation, any refuting\nsanitizer/guard you considered, and the PoC re-run command + observed\neffect (or why it could not be re-run). This is the defensible evidence.", 833 + "type": "string" 834 + }, 835 + "finding_id": { 836 + "description": "The id of the finding memory this verification assesses (e.g. `mem_xxx`).", 837 + "type": "string" 838 + }, 839 + "verdict": { 840 + "description": "Verdict: `survived`, `refuted`, or `inconclusive`. `survived` = the\nfinding held up AND the PoC reproduced on the unmodified target via the\nreal entry point. Only `survived` lets the finding become `confirmed`.", 841 + "oneOf": [ 842 + { 843 + "description": "The finding survived refutation AND the PoC reproduced on the\nunmodified target via the real entry point. Only this unlocks\n[`FindingStatus::Confirmed`].", 844 + "type": "string", 845 + "const": "survived" 846 + }, 847 + { 848 + "description": "The claim was broken: a refuting sanitizer/guard was found, or the PoC\ndid not reproduce the claimed effect.", 849 + "type": "string", 850 + "const": "refuted" 851 + }, 852 + { 853 + "description": "The check could not be completed (e.g. the target could not be built or\nrun). The finding stays `speculative`. The default — a verdict must be\nset explicitly and never defaults to `Survived`.", 854 + "type": "string", 855 + "const": "inconclusive" 856 + } 857 + ] 858 + } 859 + }, 860 + "required": [ 861 + "finding_id", 862 + "verdict", 863 + "evidence" 864 + ] 865 + }
+1
crates/voge_infra/src/http.rs
··· 40 40 let http = config.http.unwrap_or(voge_config::HttpConfig { 41 41 connect_timeout_secs: 30, 42 42 read_timeout_secs: 900, 43 + stream_timeout_secs: 180, 43 44 pool_idle_timeout_secs: 90, 44 45 pool_max_idle_per_host: 5, 45 46 max_redirects: 10,
+121 -19
crates/voge_main/src/headless.rs
··· 11 11 12 12 use std::io::Write; 13 13 use std::path::PathBuf; 14 - use std::time::Duration; 14 + use std::time::{Duration, Instant}; 15 15 16 16 use anyhow::{Result, bail}; 17 17 use voge_api::{API, VogeAPI}; ··· 65 65 let mut stream = api.chat(chat).await?; 66 66 let verbose = cli.verbose; 67 67 let mut stdout = std::io::stdout(); 68 - while let Some(message) = stream.next().await { 69 - match message? { 70 - ChatResponse::TaskMessage { content } => match content { 71 - ChatResponseContent::Markdown { text, .. } => { 72 - stdout.write_all(text.as_bytes())?; 73 - stdout.flush()?; 68 + 69 + // Liveness heartbeat + stall watchdog. 70 + // 71 + // A reasoning model can stream thinking tokens (or sit in a long tool call) 72 + // for minutes between visible output, and retry backoff is silent — without 73 + // a heartbeat, headless looks hung ("0 output"). Worse, a non-deterministic 74 + // stream-never-closes stall (orchestrator task suspended, runtime idle) can 75 + // leave `stream.next().await` waiting forever. The heartbeat prints progress 76 + // on stderr during quiet stretches, and the watchdog aborts (instead of 77 + // hanging indefinitely) once the stream has been truly silent past a cap. 78 + let started = Instant::now(); 79 + let mut last_activity = Instant::now(); 80 + let heartbeat_every = Duration::from_secs(15); 81 + // Default exceeds `tool_timeout_secs` (300) so a legitimately long tool call 82 + // never trips it; override with VOGE_HEADLESS_STALL_SECS (0 disables abort). 83 + let stall_cap = Duration::from_secs( 84 + std::env::var("VOGE_HEADLESS_STALL_SECS") 85 + .ok() 86 + .and_then(|s| s.parse().ok()) 87 + .unwrap_or(600), 88 + ); 89 + let mut ticker = tokio::time::interval(heartbeat_every); 90 + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); 91 + // The first tick of an interval fires immediately; eat it so we don't print 92 + // a spurious heartbeat at t=0. 93 + let _ = ticker.tick().await; 94 + 95 + loop { 96 + tokio::select! { 97 + msg = stream.next() => { 98 + let Some(message) = msg else { break; }; 99 + last_activity = Instant::now(); 100 + let message = message?; 101 + // Exit promptly when the agent signals completion (preserves the 102 + // pre-heartbeat semantics); the watchdog below covers the case 103 + // where completion never arrives. 104 + if matches!(message, ChatResponse::TaskComplete) { 105 + break; 74 106 } 75 - ChatResponseContent::ToolOutput(text) if verbose => println!("{text}"), 76 - ChatResponseContent::ToolInput(title) if verbose => println!("{}", title.title), 77 - _ => {} 78 - }, 79 - ChatResponse::TaskReasoning { content } if verbose => eprintln!("{content}"), 80 - // The orchestrator blocks until the tool-call start is acknowledged. 81 - // Headless doesn't gate execution, so release it immediately. 82 - ChatResponse::ToolCallStart { notifier, .. } => notifier.notify_one(), 83 - ChatResponse::TaskComplete => break, 84 - ChatResponse::Interrupt { reason } => bail!("agent interrupted: {reason:?}"), 85 - ChatResponse::RetryAttempt { cause, .. } if verbose => eprintln!("retry: {}", cause.as_str()), 86 - _ => {} 107 + handle_headless_event(message, verbose, &mut stdout)?; 108 + } 109 + _ = ticker.tick() => { 110 + let quiet = last_activity.elapsed(); 111 + if stall_cap > Duration::ZERO && quiet >= stall_cap { 112 + bail!( 113 + "agent appears stalled: no stream activity for {}s \ 114 + (elapsed {}s). The provider may have dropped the request \ 115 + without closing the stream, or the model produced no \ 116 + output. Aborting; rerun with --verbose for details, or \ 117 + raise VOGE_HEADLESS_STALL_SECS.", 118 + quiet.as_secs(), 119 + started.elapsed().as_secs(), 120 + ); 121 + } 122 + if quiet >= heartbeat_every { 123 + eprintln!( 124 + "… still working — {}s elapsed, no activity for {}s \ 125 + (verbose: {})", 126 + started.elapsed().as_secs(), 127 + quiet.as_secs(), 128 + if verbose { "on" } else { "off" }, 129 + ); 130 + } 131 + } 87 132 } 88 133 } 89 134 // Final newline after the streamed response. ··· 91 136 92 137 // Keep the runtime alive so the on_end memory-extraction spawn completes. 93 138 wait_for_memory(&config).await; 139 + Ok(()) 140 + } 141 + 142 + /// Processes one [`ChatResponse`] from the agent stream in headless mode. 143 + /// 144 + /// - Assistant content deltas (`Markdown`) → **stdout** (the clean answer). 145 + /// - Everything else (tool headers, tool failures, retries) → **stderr**, so a 146 + /// working or struggling agent is always VISIBLE instead of looking hung. 147 + /// - Reasoning + tool output are verbose-only (kept out of the default stream 148 + /// to avoid noise); the headless heartbeat covers their liveness. 149 + fn handle_headless_event( 150 + message: ChatResponse, 151 + verbose: bool, 152 + stdout: &mut impl Write, 153 + ) -> Result<()> { 154 + match message { 155 + ChatResponse::TaskMessage { content } => match content { 156 + ChatResponseContent::Markdown { text, .. } => { 157 + stdout.write_all(text.as_bytes())?; 158 + stdout.flush()?; 159 + } 160 + ChatResponseContent::ToolOutput(text) if verbose => eprintln!("{text}"), 161 + // Tool-call headers go to STDERR (unconditionally) so a working 162 + // agent is VISIBLE in headless mode instead of looking hung — 163 + // stdout stays the clean assistant answer. Sub-agent (`task`) 164 + // tool activity surfaces here too. 165 + ChatResponseContent::ToolInput(title) => { 166 + let mut line = format!("▶ {}", title.title); 167 + if let Some(sub) = &title.sub_title { 168 + line.push_str(&format!(" — {sub}")); 169 + } 170 + eprintln!("{line}"); 171 + } 172 + _ => {} 173 + }, 174 + ChatResponse::TaskReasoning { content } if verbose => eprintln!("{content}"), 175 + // The orchestrator blocks until the tool-call start is acknowledged. 176 + // Headless doesn't gate execution, so release it immediately. 177 + ChatResponse::ToolCallStart { notifier, .. } => notifier.notify_one(), 178 + // Surface tool failures on stderr (and, in verbose, completions) so 179 + // a failed tool call is never silently swallowed. 180 + ChatResponse::ToolCallEnd(result) => { 181 + if result.output.is_error { 182 + eprintln!(" ↳ tool {} failed", result.name.as_str()); 183 + } else if verbose { 184 + eprintln!(" ↳ tool {} done", result.name.as_str()); 185 + } 186 + } 187 + ChatResponse::TaskComplete => {} 188 + ChatResponse::Interrupt { reason } => bail!("agent interrupted: {reason:?}"), 189 + // Retries are diagnostics, not noise — always surface them so a 190 + // retrying provider never looks like a silent hang. 191 + ChatResponse::RetryAttempt { cause, duration } => { 192 + eprintln!("↻ retry in {:?}: {}", duration, cause.as_str()); 193 + } 194 + _ => {} 195 + } 94 196 Ok(()) 95 197 } 96 198
+25
crates/voge_repo/src/agent.rs
··· 77 77 ("voge", include_str!("agents/voge.md")), 78 78 ("muse", include_str!("agents/muse.md")), 79 79 ("sage", include_str!("agents/sage.md")), 80 + ("adversary", include_str!("agents/adversary.md")), 80 81 ] 81 82 .into_iter() 82 83 .map(|(name, content)| (name.to_string(), content.to_string())), ··· 383 384 ("voge", include_str!("agents/voge.md")), 384 385 ("sage", include_str!("agents/sage.md")), 385 386 ("muse", include_str!("agents/muse.md")), 387 + ("adversary", include_str!("agents/adversary.md")), 386 388 ] { 387 389 let def = apply_subagent_tool_config(parse_agent_file(raw).unwrap(), &cfg).unwrap(); 388 390 ··· 433 435 &cfg, 434 436 ) 435 437 .unwrap(); 438 + let adversary = apply_subagent_tool_config( 439 + parse_agent_file(include_str!("agents/adversary.md")).unwrap(), 440 + &cfg, 441 + ) 442 + .unwrap(); 436 443 let has = |a: &AgentDefinition, t: &str| { 437 444 a.tools 438 445 .as_ref() ··· 456 463 assert!(has(&muse, "memory_find_taint_paths")); 457 464 assert!(!has(&muse, "shell"), "muse is read-only"); 458 465 assert!(!has(&muse, "task"), "muse is a leaf (star topology)"); 466 + 467 + // adversary verifies: shell + the sole verification tool; CANNOT 468 + // self-certify (no memory_update_finding, no memory_add), edit the 469 + // target (no patch/multi_patch), or delegate (no task). 470 + assert!(has(&adversary, "shell"), "adversary re-runs PoCs"); 471 + assert!(has(&adversary, "memory_verification"), "adversary records verifications"); 472 + assert!(has(&adversary, "memory_find_taint_paths")); 473 + assert!(!has(&adversary, "task"), "adversary is a leaf (star topology)"); 474 + assert!( 475 + !has(&adversary, "memory_update_finding"), 476 + "adversary must not change finding status" 477 + ); 478 + assert!( 479 + !has(&adversary, "memory_add"), 480 + "adversary must not mint findings / self-certify" 481 + ); 482 + assert!(!has(&adversary, "patch"), "adversary must not edit the target"); 483 + assert!(!has(&adversary, "multi_patch"), "adversary must not edit the target"); 459 484 460 485 // Removed cruft must not return. 461 486 assert!(!has(&muse, "sage"), "muse must not list the non-tool `sage`");
+97
crates/voge_repo/src/agents/adversary.md
··· 1 + --- 2 + id: "adversary" 3 + title: "Adversarial finding verifier (refute + reproduce)" 4 + description: "Independent skeptic that tries to REFUTE a `voge` finding and independently RE-RUN its PoC on the unmodified target. The sole agent that may record a verification (the `memory_verification` tool). For a given finding: (L2) re-derive the source->sink taint path from scratch and actively hunt for a missed sanitizer / downstream guard that breaks the claim; (L3) re-run the finding's recorded PoC against the real, unmodified target and assert the claimed effect occurs and originates at the real external entry point. Record `survived` only if the path could not be refuted AND the PoC reproduced; otherwise `refuted` or `inconclusive`. Only a `survived` verification unlocks `confirmed`. Use only when delegated by `voge` to verify a finding." 5 + reasoning: 6 + enabled: true 7 + tools: 8 + - sem_search 9 + - fs_search 10 + - read 11 + - fetch 12 + - shell 13 + - write 14 + - memory_search 15 + - memory_read 16 + - memory_trace 17 + - memory_find_taint_paths 18 + - memory_coverage 19 + - memory_verification 20 + user_prompt: |- 21 + <{{event.name}}>{{event.value}}</{{event.name}}> 22 + <system_date>{{current_date}}</system_date> 23 + {{#if terminal_context}} 24 + <command_trace> 25 + {{#each terminal_context.commands}} 26 + <command exit_code="{{exit_code}}">{{command}}</command> 27 + {{/each}} 28 + </command_trace> 29 + {{/if}} 30 + --- 31 + 32 + You are the Adversary, the independent verifier of a `voge` finding. Your job is to **break the 33 + claim**, not to confirm it. You assume nothing `voge` asserted; you re-derive and re-run everything 34 + yourself. You are the gate that a finding must survive before it may be marked `confirmed`. 35 + 36 + You are called with a finding id (`mem_xxx`). `memory_read` it first to get its claimed entry point, 37 + taint path, sink, and recorded PoC (`details.payload` / `details.poc`). 38 + 39 + ## The two checks (both must pass for `survived`) 40 + 41 + ### L2 — Refute the reasoning 42 + 43 + Independently re-derive the source→sink taint path from the code (do not trust `voge`'s claim). Then 44 + **actively try to refute it**: 45 + 46 + - Is there a **sanitizer / validation / encoding** on **every** path to the sink that actually 47 + neutralizes the **class** (not just one payload)? `voge` may have missed one. 48 + - Is there a **downstream guard** (parameterization actually used, sandbox, type check, second 49 + validation, length cap, allowlist) that neutralizes the effect? 50 + - Is the claimed **entry point** actually untrusted and reachable, or is the data actually 51 + controlled/trusted by the time it reaches the sink? 52 + - Did `voge` conflate "the sink was reached" with "the dangerous effect occurred"? 53 + 54 + Use `sem_search` / `fs_search` / `read` and `memory_find_taint_paths` to check alternate paths. If 55 + you find a refuting guard, the verdict is `refuted`. 56 + 57 + ### L3 — Reproduce the exploit on the UNMODIFIED target 58 + 59 + Re-run the finding's recorded PoC **yourself**, against the **real, unmodified target** build/config, 60 + and assert the claimed effect: 61 + 62 + - The payload must enter at the **same real external entry point** `voge` recorded (the live socket 63 + / HTTP endpoint / CLI argv / file feed), so it traverses every guard a real request would. Calling 64 + an internal function, library API, deserializer, parser, or test harness directly is **not** 65 + reproduction. 66 + - The observed effect must be **produced by the target** and **match the specific claimed impact 67 + class** (RCE must run a chosen command / write a chosen marker / exfiltrate a chosen value; a crash 68 + is DoS, not RCE). It must not come from a side command, a debugger, or instrumentation you added. 69 + - You may use `shell`/`write` to build an exploit **driver** and harness, and `gdb`/`strace`/logs to 70 + **understand** behaviour — but the effect you cite must come from the target processing the payload. 71 + - You **MUST NOT** edit, weaken, comment out, or bypass any validation, sanitizer, permission check, 72 + length limit, allowlist, or guard in the **target's** own source, build config, or runtime. A 73 + result obtained against a target you have modified is invalid. 74 + 75 + ## Record the verdict 76 + 77 + Call `memory_verification` with the finding id, your verdict, and full evidence (your independent 78 + taint re-derivation, the refuting guards you considered + why they do/don't neutralize, and the PoC 79 + re-run command + observed effect). 80 + 81 + - **`survived`** — ONLY if L2 found no refuting guard AND L3 reproduced the claimed effect on the 82 + unmodified target via the real entry point. This unlocks `confirmed` for the finding. 83 + - **`refuted`** — you broke the claim (found a neutralizing guard `voge` missed, or the PoC did not 84 + reproduce the claimed effect, or the entry point is not actually untrusted). 85 + - **`inconclusive`** — you could not complete the check (e.g. the target would not build/run). The 86 + finding stays `speculative`. **Never mark `survived` without a real re-execution.** 87 + 88 + ## Boundaries 89 + 90 + - You do **not** change finding status (no `memory_update_finding`) — you only record verifications. 91 + - You do **not** mint findings (no `memory_add`); if you discover a distinct issue, leave it for 92 + `sage`/`voge`. 93 + - You do **not** delegate (no `task`) and you do **not** edit existing target files (no 94 + `patch`/`multi_patch`); `write` is for your harness/driver only. 95 + 96 + Remember: you are the check on `voge`. Default to skepticism — a finding earns `survived` only when 97 + you personally could not break it AND reproduced its effect on the real target.
+14 -2
crates/voge_repo/src/agents/muse.md
··· 15 15 - memory_find_taint_paths 16 16 - memory_update_finding 17 17 - memory_coverage 18 + - memory_coverage_status 18 19 - memory_trace 19 20 user_prompt: |- 20 21 <{{event.name}}>{{event.value}}</{{event.name}}> ··· 42 43 43 44 You do not build PoCs yourself (you are read-only), so **you NEVER set a finding to `confirmed` or `refuted`** — both require a PoC that only `voge` can run, and only `voge` may write them. Your only status change is downgrading an unsupported `confirmed` → `speculative`. If a speculative finding looks ready to confirm, flag it in the report for `voge` to validate — never promote it yourself. Refuted findings stay refuted and auditable — never delete them. 44 45 45 - ### Rule 2 — verify coverage is complete 46 + **Confirmed requires a survived verification.** The system blocks `confirmed` unless the independent `adversary` agent recorded a `survived` verification for that finding (re-deriving the path and re-running the PoC on the unmodified target). In the report, surface each confirmed finding's verification (`memory_search` for its verification record). A confirmed finding must have a `survived` verification in its supersedes chain for the SAME security substance (CWE/severity/taint path) as the current version. A confirmed finding whose chain has NO substance-matched survived verification means the gate was bypassed or the claim was widened past what was verified — flag it for re-validation. (A verification on a predecessor with the SAME cwe/severity/taint legitimately covers a revised version; a verification with a different substance does NOT.) 47 + 48 + ### Rule 2 — coverage is BLOCKING (never declare "done" with open gaps) 49 + 50 + Query `memory_coverage`. Confirm `sage` FULLY audited (verdict `safe` or `unsafe`, with cited reasoning) every enumerated untrusted-input handler — `needs_review` does NOT count as audited. 51 + 52 + **If ANY untrusted-input handler is still `needs_review` (not fully taint-traced), the audit is INCOMPLETE.** You MUST then either: 53 + 54 + 1. tell `voge` to re-delegate those handlers to `sage` for full taint-tracing BEFORE you write a final report, OR 55 + 2. write the report explicitly marked **PARTIAL AUDIT**, with the headline stating "INCOMPLETE: N handlers not fully audited" and the `needs_review` handlers listed as the **top-priority open risk** at the very top. 56 + 57 + **Never** present a headline like "No confirmed vulnerabilities" / "No vulnerabilities found" as a clean bill of health while even one untrusted-input handler is `needs_review`. The single most dangerous bug is most likely to be in the part that wasn't finished — historically, missed high-severity bugs live in exactly the handlers marked "needs_review" and then skipped. A coverage gap is not a footnote; it is the headline until it is closed. Mark uncovered areas with `memory_coverage(title=<handler/area>, content=<why it is un-audited>, verdict="needs_review")` and surface them prominently. A report that claims "full coverage" without a `memory_coverage` entry for every enumerated handler is not acceptable. 46 58 47 - Query `memory_coverage`. Confirm `sage` audited every enumerated untrusted-input handler. List any gaps; mark uncovered areas with `memory_coverage(title=<handler/area>, content=<why it is un-audited>, verdict="needs_review")` and surface them in the report for re-delegation to `sage`. A report that claims "full coverage" without recorded `memory_coverage` entries is not acceptable. 59 + **Mechanical coverage re-check — do NOT trust the orchestrator's prose.** Before writing a FINAL report you MUST call `memory_coverage_status` yourself and compare its returned counts and per-path list to (a) the orchestrator's cited counts and (b) `census_total` (re-derived from the sanctioned `find`). If the orchestrator's cited counts differ from the tool's actual output, OR `needs_review > 0`, OR `total_latest` (distinct paths) < `census_total`, OR a census path is absent from the per-path list — do NOT write a "clean" / "complete" / "no vulnerabilities" report. Escalate instead, naming the uncovered paths. `needs_review == 0` ALONE is not sufficient (satisfiable by never recording coverage for a hard file); the gate is `needs_review == 0` AND `partial == 0` AND `total_latest == census_total`. Re-derive the numbers from the tool in your own context, do not echo the downstream agent's claim. 48 60 49 61 ## Triage workflow, mapped to tools 50 62
+25 -6
crates/voge_repo/src/agents/sage.md
··· 14 14 - memory_add 15 15 - memory_update_finding 16 16 - memory_coverage 17 + - memory_coverage_status 17 18 - memory_find_taint_paths 18 19 - memory_trace 19 20 user_prompt: |- ··· 45 46 46 47 ## Methodology, mapped to tools 47 48 48 - ### Step 1 — Enumerate the complete surface (before tracing anything) 49 + ### Step 1 — Enumerate the complete surface as a NON-NARROWABLE census (before tracing anything) 50 + 51 + Build an EXHAUSTIVE, enumerated census of every untrusted-input handler up front and RECORD the full path list before tracing anything — so a later shrink is visible. 49 52 50 53 - `sem_search` for concepts: "untrusted input entry point", "network request handler", "deserialize", "file loader", "parse external". 51 54 - `fs_search` for structure: `extern` fns, route macros, `impl FromStr` / `Deserialize`, `fs::read` / `open(`, socket `accept` loops, `env::` / `args`, IPC handlers, and stored-input sites — `query(` / `execute(`, cache `.get(`, queue `recv` / `consume`, `SELECT` / `INSERT`. 55 + - For a tree of format/protocol/file handlers, ALSO list them BY NAME with a sanctioned command you may NOT narrow: `fs_search(pattern=".", glob="*Handler.cpp", path="<handler root>", output_mode="files_with_matches")` — repeat with `*Decoder*` / `*Parser*` / `*.c` as the target demands. `head_limit` OMITTED (default = all results). The census set IS EXACTLY what these print — nothing more, nothing less. 52 56 53 - Build an **exhaustive list** of entry points first. Record each one (see Step 4) so nothing is silently dropped. **Forbidden**: listing only handlers whose names match `parse`/`eval`/`exec`/`deserialize`. 57 + **Forbidden**: listing only handlers whose names match `parse`/`eval`/`exec`/`deserialize`; narrowing `path`/`glob`/`pattern` below the canonical values; adding a more restrictive `pattern`; any "out of scope" / "not a real handler" / "filesystem image" exclusion; post-curating to "main" formats; letting `head_limit` truncate. Top-level `*Handler.cpp` files (e.g. `NtfsHandler.cpp`) are unconditionally in the census (as are `*Decoder*` members and `C/*.c` sources — the completion gate counts ALL census categories, not Handlers alone) — **substituting a narrower path or glob IS the exact shrink this rule exists to prevent.** Record the full path list now. 54 58 55 59 ### Step 2 — Trace each entry to every sink 56 60 57 61 For each entry point, `read` the handler and follow the data. For each value, enumerate **all** reachable dangerous operations by what they *do*, not by a CWE name. Do not stop at the first sink. 58 62 63 + **HIGH-PRIORITY sink class — arithmetic-derived allocation sizes.** Trace any attacker-controlled value that reaches an allocation size through arithmetic — a shift `(Type)1 << n` or `x << n`, a multiply `a * b`, or an add `a + b` feeding `Alloc` / `new` / `malloc` / `reserve` / `resize`. These are prime UB/overflow sites: 64 + 65 + - A shift `<<` whose left operand is a fixed-width type (e.g. `(UInt32)1 << n`) is **undefined behavior** when `n >= width`. On most targets the CPU masks the shift amount to `log2(width)` bits, so `n == width` collapses the result: `(UInt32)1 << 32` evaluates to `1`, yielding a **1-byte / 1-element allocation**. Any later write whose length comes from OTHER attacker data then overflows that undersized buffer (often corrupting an adjacent object / vtable). 66 + - For every such site: record the **operand range** reaching the arithmetic (is `n` bounded to `< width` BEFORE the shift? is the multiply checked for overflow?), then trace the computed size into the allocation, then trace **every later write** into that buffer (`ReadStream`, `memcpy`, `Write`, `memset`, `strcpy`) and confirm it is bounded by the *allocated* size — not by some independently-attacker-controlled length. A missing/insufficient bound on the operand (or on the later write) IS the finding. 67 + 68 + **Generalize EVERY dangerous idiom — TREE-ROOTED. The first hit is EVIDENCE, not a finding.** The unguarded instance is almost always in a handler you have not opened yet (the prior 7-Zip run found the `(UInt32)1 << n` shift-alloc idiom in `CClusterInStream` but never swept the tree, so the unguarded twin in `NtfsHandler.cpp::GetCuSize` — CVE-2026-48095 — stayed unopened). The moment you find a dangerous idiom at one site, run a TREE-ROOTED search for ALL sites: `fs_search(pattern="<idiom regex>", path="<source tree root>", glob="*.cpp" then a second pass "*.c", output_mode="content", -n=true, -C=3..5, head_limit OMITTED)`. `path` MUST be the **tree root** (e.g. `CPP/7zip`, the source root, or `.`) — NEVER the current file or its directory; a search scoped to the file you are reading only re-finds what you already have and reproduces the original miss. Do NOT pipe through `head` or any name-excluding `grep -v`. For each hit: `read` it, trace operand range → size → allocation → writes, and emit its OWN `memory_coverage`/finding verdict — do NOT collapse hits into one "this pattern exists" note (a guarded instance in handler A is not proof in handler B). Concrete classes to sweep (ready regexes): arithmetic-derived allocation `(UInt32|UInt16|UInt64|Int32)?\s*\w+\s*(<<|>>)` near `Alloc|malloc|new\[\]|HeapAlloc|reserve|resize` + the 7-Zip helpers `GetCuSize|HighPart|LowPart`; computed-size buffer fill `ReadStream|CopyTo|memcpy|memmove|WriteBytes|memset` whose length differs from the alloc expression; signed↔unsigned casts into an alloc size; unchecked `\w+\s*\*\s*\w+` / `\w+\s*\+\s*\w+` as length. **Every NEW file a sweep surfaces is APPENDED to the Step-1 census and must clear the coverage gate below before the audit is complete.** 69 + 59 70 ### Step 3 — Reason about each sanitizer 60 71 61 72 For each candidate (entry → sink), identify every validation/sanitization/encoding on every path and answer, explicitly: ··· 67 78 68 79 ### Step 4 — Record (this is the output of your work) 69 80 70 - - **Clean path** (a real neutralizing validation exists on every path): `memory_coverage(title, content=<what was checked + the cited neutralizing validation>, verdict="safe", file_path, line, cwe)`. A `safe` verdict MUST cite the specific validation that neutralizes each reachable sink. **Bulk `safe` is forbidden**; when uncertain, use `needs_review`. 81 + - **Clean path** (a real neutralizing validation exists on every path): `memory_coverage(title, content=..., verdict="safe", file_path, line, cwe)`. The `content` MUST include a line `proof_of_read: "<a VERBATIM non-blank source line copied from your read of this exact file — not a paraphrase, not a line number, not a summary>"` (pick a line at/near the sink you traced; you cannot reliably fabricate the exact line of a file you never opened), AND a line `safe_citation: "<the VERBATIM neutralizing-validation line — the concrete bound, quoted character-for-character>"`. For an arithmetic-derived allocation, `safe` requires a concrete bound on the shift operand (e.g. an `if (n < 32)` / `BlockSizeLog < 24` guard) proven BEFORE the arithmetic on every path — "the caller checks" hand-waving is not `safe`. **Bulk `safe` is forbidden**; when uncertain, use `needs_review`. 71 82 - **Real candidate** (a reachable sink with missing/insufficient/bypassable validation): `memory_add(title=<short label of the entry→sink flow>, content=<one-line data-flow summary>, kind="finding")`, then immediately `memory_update_finding(id=<the mem_xxx returned by that memory_add>, status="speculative", taint_source, taint_propagators, taint_sink, taint_sanitizer, cwe, severity, confidence, evidence=<the data-flow reasoning + the sanitizer analysis>)`. 72 83 73 84 **You NEVER set `confirmed` or `refuted`.** Those are `voge`'s job (it requires a real PoC). Your findings are always `speculative`. This is the single most important boundary of your role. ··· 80 91 81 92 You cannot execute code, modify files, or run exploits. You have no `shell`, `write`, or `patch`. If a candidate needs PoC validation, leave it `speculative` and let `voge` validate it. 82 93 83 - ## Coverage discipline 94 + ## Coverage gate — completion is a COUNT EQUALITY over EVIDENCE-bound verdicts 95 + 96 + You are finished ONLY when `covered == census_total` over evidence-bound verdicts — not when you "feel done". Before ANY "complete" / "done" / "finished" / hand-back statement: 97 + 98 + 1. **Re-run the Step-1 sanctioned census command verbatim** (the canonical command, NOT your recorded/narrowed list). `census_total` = its file count. A top-level `*Handler.cpp` (e.g. `NtfsHandler.cpp`) is unconditionally in `census_total`, so the count gate cannot pass without a truthfully-read verdict for it. 99 + 2. **List the census paths you have ACTUALLY `read` this session** (from your own tool-call history). This is a required step, not a feeling — an agent forced to enumerate "census paths I read" will notice a missing `NtfsHandler.cpp`. Any census member absent from this list is uncovered. 100 + 3. **RE-`read` each census member** and confirm by literal text search that (a) your `proof_of_read` line is present VERBATIM, and (b) for a `safe` verdict, your `safe_citation` line is present VERBATIM. A verdict whose proof line (or `safe` citation) is absent on re-read is UNcovered — it was fabricated or never read — re-open it and block completion. 101 + 4. `memory_search` your own `memory_coverage` records (coverage memories are EXCLUDED from top-N auto-injection — an unqueried gap reads as "done" when it is not). 102 + 5. `covered` = census members whose most-recent `memory_coverage` verdict (a) `file_path`-exactly-matches a census path ("covered via `NtfsIn.cpp`" does NOT cover `NtfsHandler.cpp`), (b) is in your actually-read list, (c) passed the re-read proof check, and (d) is not `needs_review`. A member whose latest verdict is `needs_review` is NOT covered. 84 103 85 - Your value is **completeness**, not cleverness. A `safe` verdict must cite the specific neutralizing validation per reachable sink; `needs_review` when you ran out of time/scope. If the codebase is large, audit in **bounded slices** (per module/subsystem), record coverage for what you finished, and mark the rest `needs_review` rather than silently skipping it. Quote code with the exact format `filepath:startLine-endLine`. 104 + **`needs_review` is NOT a terminal state.** If `covered < census_total`, you are NOT done — keep auditing the missing members YOURSELF (you cannot re-delegate). You may NOT audit in "bounded slices" that leave census members unreviewed and call that complete. A silent "done", or a "no findings" / "clean" summary while ANY census member (Handler, Decoder, OR C source) is `needs_review` or unread, is the exact failure this rule exists to prevent. If a member truly cannot be finished, the ONLY acceptable end is an explicit, user-facing **ESCALATION** that names every uncovered member and why. 86 105 87 - Remember: your goal is a complete, defensible map of where untrusted input flows and which flows lack protection — nothing more, nothing less. 106 + Quote code with the exact format `filepath:startLine-endLine`. Your value is **completeness**, not cleverness.
+27 -9
crates/voge_repo/src/agents/voge.md
··· 25 25 - memory_update_finding 26 26 - memory_find_taint_paths 27 27 - memory_coverage 28 + - memory_coverage_status 28 29 - memory_trace 29 30 - mcp_* 30 31 user_prompt: |- ··· 72 73 73 74 ### Rule 2 — systematic, not pattern-directed 74 75 75 - Do not hunt by grepping for known-bad signatures or CWE-keyword lists — that misses most real bugs. Discovery is normally delegated to `sage`. When you discover candidates yourself (delegation disabled, or a focused re-check), follow the full discipline: (a) enumerate the COMPLETE untrusted-input attack surface by trust-boundary, never by "looks dangerous" names; (b) for EACH entry, trace data flow to EVERY reachable dangerous sink by semantics, not CWE label; (c) for each candidate, reason about every sanitizer on every path — does it neutralize the *class* (not one payload)? is it on every path? ordered before the sink? bypassable via an alternate path?; (d) record coverage and findings as you go; (e) run `memory_find_taint_paths(unsanitized_only=true)` to catch transitive flows. Do not stop at the first juicy sink or the first sanitizer you see. Known vulnerability patterns / CWEs are a **final cross-check** to ensure nothing was missed, never the primary discovery method. 76 + Do not hunt by grepping for known-bad signatures or CWE-keyword lists — that misses most real bugs. Discovery is normally delegated to `sage`. When you discover candidates yourself (delegation disabled, or a focused re-check), follow the full discipline: (a) enumerate the COMPLETE untrusted-input attack surface by trust-boundary, never by "looks dangerous" names; (b) for EACH entry, trace data flow to EVERY reachable dangerous sink by semantics, not CWE label; (c) for each candidate, reason about every sanitizer on every path — does it neutralize the *class* (not one payload)? is it on every path? ordered before the sink? bypassable via an alternate path?; (d) record coverage and findings as you go; (e) run `memory_find_taint_paths(unsanitized_only=true)` to catch transitive flows. Do not stop at the first juicy sink or the first sanitizer you see. Known vulnerability patterns / CWEs are a **final cross-check** to ensure nothing was missed, never the primary discovery method. Two force-multipliers: (f) **arithmetic-derived allocation sizes are a high-priority sink class** — trace attacker values through shift `<<` / multiply / add into `Alloc`/`new`/reserve, then into every later write; a `(UInt32)1 << n` with `n >= 32` is undefined behavior and can collapse the allocation to 1 byte → overflow. (g) **Generalize every dangerous idiom — TREE-ROOTED; the first hit is EVIDENCE, not a finding.** The unguarded instance is almost always in a handler not yet opened (the prior 7-Zip run found the `(UInt32)1 << n` shift-alloc idiom in `CClusterInStream` but never swept the tree, so the unguarded twin in `NtfsHandler.cpp::GetCuSize` — CVE-2026-48095 — stayed unopened). The moment you or `sage` find a dangerous idiom at one site, immediately sweep the WHOLE tree for every site: with `shell`+`rg` (or `fs_search`), `path` = the **source tree root** (NEVER the current file/dir), `-C 3`–`5`, NO `head`/`grep -v` name-excluding pipe, `head_limit` omitted. Trace each hit (operand range → size → allocation → writes) and record its own verdict — do NOT collapse hits into one note. Concrete classes: shift/mul/add → `Alloc|malloc|new\[\]|HeapAlloc` (`(UInt32|UInt16|UInt64)?\s*\w+\s*(<<|>>)`; include `GetCuSize|HighPart|LowPart`); computed-size `ReadStream|memcpy|memset` whose length differs from the alloc; signed↔unsigned casts into alloc size. Every NEW file a sweep surfaces is APPENDED to the coverage census and must clear the coverage gate before the audit is complete. When you delegate to `sage`, instruct it to do the same tree-rooted sweep. 76 77 77 78 ## Pipeline orchestration 78 79 79 - You are the orchestrator. The audit has three stages and two specialist agents you delegate to: 80 + You are the orchestrator. The audit has stages and three specialist agents you delegate to: 80 81 81 82 - **`sage`** — systematic surface enumeration + taint tracing (read-only recon). Delegate recon with a precise scope, e.g. *"enumerate every untrusted input handler in `src/net/`; trace each to its reachable sinks; record coverage and speculative findings."* `sage` records findings as `speculative` only — it never confirms. 82 - - **`muse`** — triage, severity ranking, report writing (read-only + plan). Delegate *"rank all recorded findings; require proof for any `confirmed`; write the audit report and remediation plan."* 83 + - **`adversary`** — independent verifier. Before you mark a finding `confirmed`, you MUST delegate refutation to `adversary`: *"refute finding mem_xxx — re-derive the taint path and re-run the PoC on the unmodified target; record a verification."* It returns `survived` / `refuted` / `inconclusive`. **`memory_update_finding(confirmed)` is rejected unless `adversary` recorded `survived`** for that finding (the verification gate). 84 + - **`muse`** — triage, severity ranking, report writing (read-only + plan). Delegate *"rank all recorded findings; require proof for any `confirmed`; write the audit report and remediation plan."* **Before** asking `muse` to write a FINAL report, the coverage gate below MUST pass. 85 + 86 + ## Coverage census & completion gate — YOU own it 87 + 88 + You are the orchestrator, so the coverage census and the completion gate are YOUR job — `sage` audits members, but you build the census, enforce the gate, and re-delegate gaps. "PARTIAL" is a status, NOT a stopping point. 89 + 90 + **1. Build the census with the SANCTIONED command (verbatim, `cwd` = source root, no `cd`).** For a 7-Zip-style tree run exactly: `find CPP/7zip/Archive -name '*Handler.cpp'`; `find C -name '*.c'`; `find CPP/7zip/Compress -name '*Decoder*.cpp'`. **Forbidden:** any `cd`; `-maxdepth`; `-path`/`-prune`; piping through `grep -v` / `grep -iE 'ntfs|fat|ext|hfs|udf|squashfs|iso'`; post-curating to "main" archives; dropping a file as "not a real handler" / "filesystem image" / "out of scope". Substituting a narrower path or glob IS the shrink this gate exists to prevent. For other targets, sweep the trust boundary the same structural way (`*Handler`/`*Decoder`/`*Parser`, route macros, `impl Deserialize`, `extern` entry fns, `accept` loops). Record the full list before tracing. 91 + 92 + **2. Each census member gets a `memory_coverage` verdict ONLY after it is actually read + taint-traced**, with a verbatim `proof_of_read:` line (and a verbatim `safe_citation:` for `safe`) — see `sage`'s Step 4. A verdict recorded for a file never opened is the original miss; it is forbidden. 93 + 94 + **3. Completion is a COUNT EQUALITY.** Before ANY "complete" / "done" / hand-off to `muse` / headline: 95 + - Re-run the SANCTIONED `find` verbatim (NOT your narrowed list). `census_total` = its line count. `NtfsHandler.cpp` is a top-level `*Handler.cpp`, so it is unconditionally in `census_total`. 96 + - `covered` = census members with a most-recent `memory_coverage` verdict whose `file_path` exactly matches a census path, whose file was actually read, whose `proof_of_read` (and `safe_citation` for `safe`) line is still present on a re-read, and whose verdict is not `needs_review`. ("Covered via `NtfsIn.cpp`" does NOT cover `NtfsHandler.cpp`.) 97 + - The audit is complete ONLY when `covered == census_total`. `memory_search` your own coverage records first — they are excluded from top-N auto-injection, so an unqueried gap reads as "done" when it is not. 98 + - **Cite the coverage tool, verbatim.** Before ANY "complete" / "done" / hand-off / final-report headline, call `memory_coverage_status` and cite its output VERBATIM (e.g. "coverage_status: safe=N, unsafe=N, needs_review=N, partial=N, total_latest=N" plus the per-path verdict list). The audit is complete ONLY when ALL hold: (a) the cited `needs_review` and `partial` counts are 0, AND (b) `total_latest` == `census_total` (re-derived from the verbatim sanctioned `find` commands THIS turn — `total_latest` is DISTINCT PATHS, not rows), AND (c) every census path appears in the returned per-path list with a non-`needs_review` verdict. `needs_review == 0` ALONE IS NOT SUFFICIENT — it is satisfiable by never recording coverage for a hard file, which IS the original root cause. A headline that omits these counts, or states completeness while `needs_review > 0` OR `total_latest < census_total`, is the exact failure this gate exists to prevent. 99 + 100 + **4. If `covered < census_total`, you are NOT done.** Re-delegate the uncovered members to `sage` (bounded scope, full context) and re-check the gate. If a member truly cannot be finished, the ONLY acceptable end is an explicit, user-facing **ESCALATION** naming every uncovered member — NEVER a silent "finished", and NEVER a headline that says "no vulnerabilities" / "0 confirmed" while ANY census member (Handler, Decoder, OR C source) is still `needs_review` or unread. The worst bug is in the part you didn't open. 83 101 84 102 {{#if tool_names.task}} 85 103 You delegate via the {{tool_names.task}} tool. Send independent delegations **in parallel** (multiple {{tool_names.task}} calls in one message). Give each delegate a bounded scope and full context — a delegate starts fresh unless you pass a `session_id`. Do not delegate trivial work (a single file read, a quick lookup) — do that directly. ··· 94 112 1. **Pull candidates.** Run `memory_find_taint_paths` with `unsanitized_only=true` to get the source→sink flows `sage` recorded that still lack a sanitizer. Also `memory_search` for `finding` records. 95 113 2. **Re-read the path.** For each candidate, `read` the source, every propagator, the sink, and any partial sanitizer. Confirm the data flow and identify exactly which validation is missing or insufficient. 96 114 3. **Build a real PoC.** Use `write` / `patch` / `multi_patch` to construct an exploit that delivers attacker-controlled input **at the entry point**. Build it to run against the real target. 97 - 4. **Run it.** Use `shell` to execute the PoC against the real target build/config. Observe the effect. (You may use `gdb`, `strace`, logs, etc. to **understand** what happens — but the effect you cite as proof must come from the target processing your payload, not from the tool.) 98 - 5. **Apply the Rule-1 gate.** Does the observable effect (a) match the specific claimed impact class, and (b) arise through the vulnerable path from the entry point — with no downstream guard neutralizing it and no side-command manufacturing it? 99 - - **Yes** → `memory_update_finding(id=<the finding's mem_xxx from memory_find_taint_paths/memory_search>, status="confirmed", evidence=<PoC + observed effect + entry/sink/sanitizer citations + how to reproduce>, cwe=, severity=, confidence=)`. Attach the reasoning chain with `memory_trace`. 100 - - **PoC fails / partial / wrong class** → keep `speculative`, or set `refuted` (same `id=...`) with the reason in `evidence`. Record what you tried. 101 - 6. **Record coverage of negatives too.** If a candidate turns out clean after a real attempt, record `memory_coverage(title=<handler audited>, content=<what was checked + the specific reason it is safe>, verdict="safe", file_path=, cwe=)` so it is not re-audited. 115 + 4. **Run it (your own check).** Use `shell` to execute the PoC against the real target build/config. Observe the effect. (You may use `gdb`, `strace`, logs, etc. to **understand** what happens — but the effect you cite as proof must come from the target processing your payload, not from the tool.) 116 + 5. **Apply the Rule-1 gate.** Does the observable effect (a) match the specific claimed impact class, and (b) arise through the vulnerable path from the entry point — with no downstream guard neutralizing it and no side-command manufacturing it? If not → keep `speculative`, or set `refuted` (same `id=...`) with the reason in `evidence`. 117 + 6. **Mandatory independent verification.** Before `confirmed`, delegate refutation to `adversary` via `task`: *"refute finding mem_xxx — re-derive the taint path and re-run the PoC on the unmodified target; record a verification."* `adversary` returns `survived` / `refuted` / `inconclusive`. **The system blocks `memory_update_finding(status="confirmed")` until a `survived` verification exists** — it errors "no surviving adversarial verification" otherwise. If `adversary` returns `refuted`, respect it (set `refuted` or rework the finding); if `inconclusive`, the finding stays `speculative`. 118 + 7. **Confirm.** Only after `adversary` survived: `memory_update_finding(id=<the finding's mem_xxx from memory_find_taint_paths/memory_search>, status="confirmed", evidence=<PoC + observed effect + entry/sink/sanitizer citations + how to reproduce + that adversary survived>, cwe=, severity=, confidence=)`. Attach the reasoning chain with `memory_trace`. 119 + 8. **Record coverage of negatives too.** If a candidate turns out clean after a real attempt, record `memory_coverage(title=<handler audited>, content=<what was checked + the specific reason it is safe>, verdict="safe", file_path=, cwe=)` so it is not re-audited. 102 120 103 - You are the **only** agent that writes `confirmed` or `refuted`. `muse` will double-check your `confirmed` findings and downgrade any that lack PoC evidence — so make the evidence airtight. 121 + You are the **only** agent that writes `confirmed` or `refuted` — and `confirmed` now requires a `survived` verification from `adversary` first. `muse` will double-check your `confirmed` findings and flag any lacking a survived verification — so make the evidence airtight. 104 122 105 123 ## Task management 106 124
+3
crates/voge_repo/src/database/migrations/2026-07-12-120000_add_memories_verification/down.sql
··· 1 + DROP INDEX IF EXISTS idx_memories_verification; 2 + ALTER TABLE memories DROP COLUMN verification_verdict; 3 + ALTER TABLE memories DROP COLUMN subject_finding_id;
+9
crates/voge_repo/src/database/migrations/2026-07-12-120000_add_memories_verification/up.sql
··· 1 + -- Adversarial-verification linkage + verdict (vuln-hunting confirmation gate). 2 + -- A `verification` memory (kind = 'verification') records the outcome of an 3 + -- independent adversary re-deriving a finding's taint path and re-running its 4 + -- PoC on the unmodified target. 5 + ALTER TABLE memories ADD COLUMN subject_finding_id TEXT; 6 + ALTER TABLE memories ADD COLUMN verification_verdict TEXT; 7 + CREATE INDEX IF NOT EXISTS idx_memories_verification 8 + ON memories(subject_finding_id, verification_verdict, is_latest) 9 + WHERE subject_finding_id IS NOT NULL;
+2
crates/voge_repo/src/database/migrations/2026-07-13-100000_add_memories_verified_substance/down.sql
··· 1 + DROP INDEX IF EXISTS idx_memories_verified_substance; 2 + ALTER TABLE memories DROP COLUMN verified_substance;
+9
crates/voge_repo/src/database/migrations/2026-07-13-100000_add_memories_verified_substance/up.sql
··· 1 + -- Substance-bound verification gate: snapshot of the subject finding's 2 + -- security substance (CWE + severity + taint_path) at verification time, so a 3 + -- `survived` verification vouches ONLY for the substance it names and cannot 4 + -- be widened at confirm time. NULL for non-verification memories and for 5 + -- legacy verifications recorded before this column existed. 6 + ALTER TABLE memories ADD COLUMN verified_substance TEXT; 7 + CREATE INDEX IF NOT EXISTS idx_memories_verified_substance 8 + ON memories(subject_finding_id, verification_verdict, verified_substance, is_latest) 9 + WHERE subject_finding_id IS NOT NULL;
+3
crates/voge_repo/src/database/schema.rs
··· 36 36 coverage_verdict -> Nullable<Text>, 37 37 scope -> Text, 38 38 engagement_id -> Nullable<Text>, 39 + subject_finding_id -> Nullable<Text>, 40 + verification_verdict -> Nullable<Text>, 41 + verified_substance -> Nullable<Text>, 39 42 } 40 43 } 41 44
+19 -1
crates/voge_repo/src/memory/memory_record.rs
··· 8 8 9 9 use std::str::FromStr; 10 10 11 - use voge_domain::{CoverageVerdict, FindingDetails, FindingStatus, Memory, MemoryId, MemoryScope, MemorySummary, MemoryType, TaintEdge, TaintNode, WorkspaceHash}; 11 + use voge_domain::{CoverageVerdict, FindingDetails, FindingStatus, Memory, MemoryId, MemoryScope, MemorySummary, MemoryType, TaintEdge, TaintNode, VerificationVerdict, WorkspaceHash}; 12 12 13 13 use crate::database::schema::{memories, memory_summaries, taint_edges, taint_nodes}; 14 14 ··· 46 46 pub scope: String, 47 47 /// Engagement id (nullable) for engagement-scoped retrieval. 48 48 pub engagement_id: Option<String>, 49 + /// For verification memories: the finding id this verification assesses. 50 + pub subject_finding_id: Option<String>, 51 + /// Verification verdict (survived/refuted/inconclusive) for verification 52 + /// memories; NULL otherwise. 53 + pub verification_verdict: Option<String>, 54 + /// Substance fingerprint (CWE + severity + taint_path) of the subject 55 + /// finding at verification time; NULL for non-verification memories and 56 + /// legacy verifications recorded before this column existed. 57 + pub verified_substance: Option<String>, 49 58 } 50 59 51 60 impl From<&Memory> for MemoryRecord { ··· 77 86 coverage_verdict: memory.verdict.map(|v| v.as_str().to_string()), 78 87 scope: memory.scope.as_str().to_string(), 79 88 engagement_id: memory.engagement_id.clone(), 89 + subject_finding_id: memory.subject_finding_id.clone(), 90 + verification_verdict: memory.verification_verdict.map(|v| v.as_str().to_string()), 91 + verified_substance: memory.verified_substance.clone(), 80 92 } 81 93 } 82 94 } ··· 118 130 .and_then(|s| CoverageVerdict::from_str(s).ok()), 119 131 scope: MemoryScope::from_str(&record.scope).unwrap_or_default(), 120 132 engagement_id: record.engagement_id, 133 + subject_finding_id: record.subject_finding_id, 134 + verification_verdict: record 135 + .verification_verdict 136 + .as_deref() 137 + .and_then(|s| VerificationVerdict::from_str(s).ok()), 138 + verified_substance: record.verified_substance, 121 139 }) 122 140 } 123 141 }
+159 -1
crates/voge_repo/src/memory/memory_repo.rs
··· 167 167 .await 168 168 } 169 169 170 + async fn has_surviving_verification(&self, finding_id: &MemoryId) -> anyhow::Result<bool> { 171 + let finding_id = finding_id.as_str().to_string(); 172 + self.run_with_connection(move |connection| { 173 + let records: Vec<MemoryRecord> = memories::table 174 + .filter(memories::is_latest.eq(1)) 175 + .filter(memories::kind.eq("verification")) 176 + .filter(memories::subject_finding_id.eq(&finding_id)) 177 + .filter(memories::verification_verdict.eq("survived")) 178 + .limit(1) 179 + .load(connection)?; 180 + Ok(!records.is_empty()) 181 + }) 182 + .await 183 + } 184 + 185 + async fn has_surviving_verification_for_substance( 186 + &self, 187 + finding: &Memory, 188 + substance: &str, 189 + ) -> anyhow::Result<bool> { 190 + // Walk the finding's supersedes chain to collect candidate subject ids 191 + // (the finding + its predecessors). A survived verification against ANY 192 + // of them vouches for the finding, but only if its recorded substance 193 + // matches — chain-walk is safe here PRECISELY because of the substance 194 + // binding (a narrow ancestor verification has a different substance and 195 + // cannot satisfy a broader descendant). 196 + let mut chain: Vec<String> = vec![finding.id.as_str().to_string()]; 197 + let mut cursor = finding.supersedes_id.clone(); 198 + for _ in 0..64 { 199 + let Some(pred_id) = cursor else { break }; 200 + let pid = pred_id.as_str().to_string(); 201 + if chain.contains(&pid) { 202 + break; 203 + } 204 + chain.push(pid.clone()); 205 + cursor = match self.get_memory(&MemoryId::new(&pid)).await? { 206 + Some(m) => m.supersedes_id, 207 + None => None, 208 + }; 209 + } 210 + let substance = substance.to_string(); 211 + self.run_with_connection(move |connection| { 212 + let records: Vec<MemoryRecord> = memories::table 213 + .filter(memories::is_latest.eq(1)) 214 + .filter(memories::kind.eq("verification")) 215 + .filter(memories::verification_verdict.eq("survived")) 216 + .filter(memories::subject_finding_id.eq_any(&chain)) 217 + .filter(memories::verified_substance.eq(&substance)) 218 + .limit(1) 219 + .load(connection)?; 220 + Ok(!records.is_empty()) 221 + }) 222 + .await 223 + } 224 + 170 225 async fn find_taint_paths( 171 226 &self, 172 227 project: WorkspaceHash, ··· 839 894 #[cfg(test)] 840 895 mod tests { 841 896 use chrono::Utc; 842 - use voge_domain::{CoverageVerdict, FindingDetails, FindingStatus, MemoryScope, MemoryType, Severity, TaintLocation, TaintPath, WorkspaceHash}; 897 + use voge_domain::{CoverageVerdict, FindingDetails, FindingStatus, MemoryScope, MemoryType, Severity, TaintLocation, TaintPath, VerificationVerdict, WorkspaceHash}; 843 898 844 899 use super::*; 845 900 ··· 1157 1212 let by_path = repo.get_coverage(project, Some("src/login.rs")).await?; 1158 1213 assert_eq!(by_path.len(), 1); 1159 1214 assert!(repo.get_coverage(project, Some("src/other.rs")).await?.is_empty()); 1215 + Ok(()) 1216 + } 1217 + 1218 + #[tokio::test] 1219 + async fn test_has_surviving_verification_gate() -> anyhow::Result<()> { 1220 + let repo = test_repo()?; 1221 + let project = WorkspaceHash::new(1); 1222 + 1223 + // A finding to be verified. 1224 + let finding = Memory::new(project, MemoryType::Finding, "sqli in /login", "tainted flow"); 1225 + repo.upsert_memory(finding.clone()).await?; 1226 + 1227 + // No verification yet -> gate closed. 1228 + assert!(!repo.has_surviving_verification(&finding.id).await?); 1229 + 1230 + // A `refuted` verification does NOT unlock. 1231 + let mut refuted = Memory::new(project, MemoryType::Verification, "v1", "broke the claim"); 1232 + refuted.subject_finding_id = Some(finding.id.as_str().to_string()); 1233 + refuted.verification_verdict = Some(VerificationVerdict::Refuted); 1234 + repo.upsert_memory(refuted).await?; 1235 + assert!(!repo.has_surviving_verification(&finding.id).await?); 1236 + 1237 + // A `survived` verification DOES unlock. 1238 + let mut survived = Memory::new(project, MemoryType::Verification, "v2", "reproduced on target"); 1239 + survived.subject_finding_id = Some(finding.id.as_str().to_string()); 1240 + survived.verification_verdict = Some(VerificationVerdict::Survived); 1241 + repo.upsert_memory(survived.clone()).await?; 1242 + assert!(repo.has_surviving_verification(&finding.id).await?); 1243 + 1244 + // The adversary revises its verdict down (survived -> refuted): supersede 1245 + // the survived verification with a refuted revision; the gate closes. 1246 + let mut revised = survived.revise(); 1247 + revised.verification_verdict = Some(VerificationVerdict::Refuted); 1248 + repo.supersede(revised, &[survived.id]).await?; 1249 + assert!( 1250 + !repo.has_surviving_verification(&finding.id).await?, 1251 + "a superseded survived verification must not satisfy the gate" 1252 + ); 1253 + Ok(()) 1254 + } 1255 + 1256 + /// The substance-bound gate: a `survived` verification vouches ONLY for the 1257 + /// security substance (CWE/severity/taint) it names. A survived verification 1258 + /// with a DIFFERENT substance must not satisfy the gate (closes 1259 + /// broaden-at-confirm), and a survived verification on a predecessor with 1260 + /// the SAME substance DOES cover a revised descendant (evidence refinement 1261 + /// keeps confirmed — the chain-walk the id-only gate lacked). 1262 + #[tokio::test] 1263 + async fn test_has_surviving_verification_for_substance_gate() -> anyhow::Result<()> { 1264 + let repo = test_repo()?; 1265 + let project = WorkspaceHash::new(1); 1266 + 1267 + let mut finding = Memory::new(project, MemoryType::Finding, "shift overflow", "flow"); 1268 + finding.details = Some(FindingDetails { 1269 + cwe: Some("CWE-190".into()), 1270 + severity: Some(Severity::High), 1271 + taint_path: Some(TaintPath { 1272 + source: TaintLocation { file_path: "s.cpp".into(), ..Default::default() }, 1273 + propagators: vec![], 1274 + sink: TaintLocation { file_path: "k.cpp".into(), ..Default::default() }, 1275 + sanitized_by: vec![], 1276 + ..Default::default() 1277 + }), 1278 + ..Default::default() 1279 + }); 1280 + repo.upsert_memory(finding.clone()).await?; 1281 + let substance = Memory::substance_fingerprint(finding.details.as_ref()); 1282 + 1283 + // No verification -> closed. 1284 + assert!(!repo.has_surviving_verification_for_substance(&finding, &substance).await?); 1285 + 1286 + // A survived verification with a MISMATCHED substance does NOT open the 1287 + // gate for this substance (broaden-at-confirm closed). 1288 + let mut v_other = Memory::new(project, MemoryType::Verification, "v narrow", "checked a trivial variant"); 1289 + v_other.subject_finding_id = Some(finding.id.as_str().to_string()); 1290 + v_other.verification_verdict = Some(VerificationVerdict::Survived); 1291 + v_other.verified_substance = Some("substance:deadbeefdeadbeef".into()); 1292 + repo.upsert_memory(v_other).await?; 1293 + assert!( 1294 + !repo.has_surviving_verification_for_substance(&finding, &substance).await?, 1295 + "a survived verification for a different substance must not satisfy the gate" 1296 + ); 1297 + 1298 + // A survived verification with the MATCHING substance opens it. 1299 + let mut v_match = Memory::new(project, MemoryType::Verification, "v match", "reproduced"); 1300 + v_match.subject_finding_id = Some(finding.id.as_str().to_string()); 1301 + v_match.verification_verdict = Some(VerificationVerdict::Survived); 1302 + v_match.verified_substance = Some(substance.clone()); 1303 + repo.upsert_memory(v_match.clone()).await?; 1304 + assert!(repo.has_surviving_verification_for_substance(&finding, &substance).await?); 1305 + 1306 + // Chain-walk: revise the finding (same substance — evidence refinement), 1307 + // then supersede. The predecessor's matching survived verification must 1308 + // still cover the revised descendant. 1309 + let mut revised = finding.revise(); 1310 + revised.details = finding.details.clone(); // same substance 1311 + repo.supersede(revised.clone(), &[finding.id.clone()]).await?; 1312 + let revised_substance = Memory::substance_fingerprint(revised.details.as_ref()); 1313 + assert_eq!(revised_substance, substance, "revision kept the same substance"); 1314 + assert!( 1315 + repo.has_surviving_verification_for_substance(&revised, &revised_substance).await?, 1316 + "a predecessor's substance-matched survived verification must cover a revised descendant" 1317 + ); 1160 1318 Ok(()) 1161 1319 } 1162 1320
+17 -2
crates/voge_repo/src/provider/openai.rs
··· 351 351 let retry_config = config.retry.unwrap_or_default(); 352 352 let merge_system_messages = config.merge_system_messages; 353 353 let provider_id = provider.id.clone(); 354 + // Abort the provider stream if it goes silent (no first byte / no token) 355 + // for longer than this — a stalled or queued request errors out instead 356 + // of hanging the agent forever. 357 + let stream_timeout = std::time::Duration::from_secs( 358 + config.http.as_ref().map(|h| h.stream_timeout_secs).unwrap_or(180), 359 + ); 354 360 let provider_client = OpenAIProvider::new(provider, self.infra.clone()); 355 361 let stream = provider_client 356 362 .chat(model_id, context, merge_system_messages) 357 363 .await 358 364 .map_err(|e| into_retry(e, &retry_config))?; 359 365 360 - Ok(Box::pin(stream.map(move |item| { 361 - item.map_err(|e| enhance_error(into_retry(e, &retry_config), &provider_id)) 366 + Ok(Box::pin(stream.timeout(stream_timeout).map(move |item| match item { 367 + Ok(inner) => inner.map_err(|e| enhance_error(into_retry(e, &retry_config), &provider_id)), 368 + Err(_elapsed) => Err(enhance_error( 369 + into_retry( 370 + anyhow::anyhow!( 371 + "provider stream timed out: no data for {stream_timeout:?} (request stalled)" 372 + ), 373 + &retry_config, 374 + ), 375 + &provider_id, 376 + )), 362 377 }))) 363 378 } 364 379
+104 -4
crates/voge_repo/src/provider/openai_responses/snapshots/voge_repo__provider__openai_responses__request__tests__openai_responses_all_catalog_tools.snap
··· 897 897 "coverage" 898 898 ], 899 899 "type": "string" 900 + }, 901 + { 902 + "description": "An adversarial verification of a `Finding`: the outcome of an\nindependent agent re-deriving the taint path and re-running the PoC on\nthe unmodified target. Carries a `verification_verdict` and links back\nto the assessed finding via `subject_finding_id`.", 903 + "enum": [ 904 + "verification" 905 + ], 906 + "type": "string" 900 907 } 901 908 ], 902 909 "description": "Category of a memory." ··· 1141 1148 "type": "object" 1142 1149 }, 1143 1150 "strict": true, 1144 - "description": "Revise the lifecycle status of a finding memory (`speculative` → `confirmed`/`refuted`), creating a new version that supersedes the old one. Use this when an earlier `memory_add` finding (kind `finding`) has been investigated and you can now confirm or refute it. The taint inputs (`taint_source` / `taint_propagators` / `taint_sink` / `taint_sanitizer`) record the finding's data-flow path into a taint graph, enabling transitive source→sink path discovery across findings via `memory_find_taint_paths`.\n\n# Inputs\n\n- `id` (required): the finding memory id to revise (e.g. `mem_xxx`).\n- `status` (required): new status — `speculative`, `confirmed`, or `refuted`.\n- `evidence` (optional): new evidence/reasoning; replaces the finding's `content` if given, otherwise prior content carries forward.\n- `confidence` (optional): confidence in `[0.0, 1.0]`; omit to keep prior confidence.\n- `cwe` (optional): CWE identifier (e.g. `CWE-89`).\n- `severity` (optional): `info` / `low` / `medium` / `high` / `critical`.\n- `taint_source` (optional): file path where untrusted data enters.\n- `taint_propagators` (optional): ordered list of intermediate file paths the tainted data flows through between source and sink. Each consecutive pair becomes a graph edge; locations shared with other findings chain into transitive paths.\n- `taint_sink` (optional): file path where tainted data reaches a dangerous operation.\n- `taint_sanitizer` (optional): file path where taint is neutralised. The flow edge into this location is marked sanitized, cutting vulnerable paths through it. Takes precedence over `taint_sanitized`.\n- `taint_sanitized` (optional): if true and no `taint_sanitizer` is given, mark the direct source→sink edge as sanitized.\n\n# Notes\n\n- Provide `taint_source` and `taint_sink` together (with optional `taint_propagators` between them) to record a multi-hop path.\n- Include a line or function in the path strings where possible (e.g. `src/db.rs:42` or `src/db.rs::exec`) so distinct locations in the same file do not collide into one graph node.\n- The prior version is marked non-latest and is no longer injected or returned by `memory_search`; only the new version is. Its taint-graph edges are dropped automatically.\n- Prefer this over `memory_add` when refining an existing finding — it preserves the version chain (`supersedes_id`, `version`).\n- Use `refuted` for false positives so they stop surfacing (and their taint edges stop contributing to paths) but remain auditable." 1151 + "description": "Revise the lifecycle status of a finding memory (`speculative` → `confirmed`/`refuted`), creating a new version that supersedes the old one. Use this when an earlier `memory_add` finding (kind `finding`) has been investigated and you can now confirm or refute it. The taint inputs (`taint_source` / `taint_propagators` / `taint_sink` / `taint_sanitizer`) record the finding's data-flow path into a taint graph, enabling transitive source→sink path discovery across findings via `memory_find_taint_paths`.\n\n**`confirmed` is gated.** Unless `require_adversarial_verification = false`, this tool REJECTS `status=\"confirmed\"` unless the independent `adversary` agent has recorded a `survived` verification for this finding (via `memory_verification`). Delegate refutation to `adversary` (task) first; only a `survived` verdict unlocks `confirmed`.\n\n# Inputs\n\n- `id` (required): the finding memory id to revise (e.g. `mem_xxx`).\n- `status` (required): new status — `speculative`, `confirmed`, or `refuted`.\n- `evidence` (optional): new evidence/reasoning; replaces the finding's `content` if given, otherwise prior content carries forward.\n- `confidence` (optional): confidence in `[0.0, 1.0]`; omit to keep prior confidence.\n- `cwe` (optional): CWE identifier (e.g. `CWE-89`).\n- `severity` (optional): `info` / `low` / `medium` / `high` / `critical`.\n- `taint_source` (optional): file path where untrusted data enters.\n- `taint_propagators` (optional): ordered list of intermediate file paths the tainted data flows through between source and sink. Each consecutive pair becomes a graph edge; locations shared with other findings chain into transitive paths.\n- `taint_sink` (optional): file path where tainted data reaches a dangerous operation.\n- `taint_sanitizer` (optional): file path where taint is neutralised. The flow edge into this location is marked sanitized, cutting vulnerable paths through it. Takes precedence over `taint_sanitized`.\n- `taint_sanitized` (optional): if true and no `taint_sanitizer` is given, mark the direct source→sink edge as sanitized.\n\n# Notes\n\n- Provide `taint_source` and `taint_sink` together (with optional `taint_propagators` between them) to record a multi-hop path.\n- Include a line or function in the path strings where possible (e.g. `src/db.rs:42` or `src/db.rs::exec`) so distinct locations in the same file do not collide into one graph node.\n- The prior version is marked non-latest and is no longer injected or returned by `memory_search`; only the new version is. Its taint-graph edges are dropped automatically.\n- Prefer this over `memory_add` when refining an existing finding — it preserves the version chain (`supersedes_id`, `version`).\n- Use `refuted` for false positives so they stop surfacing (and their taint edges stop contributing to paths) but remain auditable." 1145 1152 }, 1146 1153 { 1147 1154 "type": "function", ··· 1204 1211 "description": "Line number audited (optional)." 1205 1212 }, 1206 1213 "title": { 1207 - "description": "Short title (what was audited, e.g. \"SQL injection in /login\").", 1208 - "type": "string" 1214 + "anyOf": [ 1215 + { 1216 + "default": null, 1217 + "type": "string" 1218 + }, 1219 + { 1220 + "type": "null" 1221 + } 1222 + ], 1223 + "description": "Short title (what was audited, e.g. \"SQL injection in /login\"). Optional;\nif omitted, defaults to the `file_path` (or a content snippet) so per-file\ncoverage calls don't need a redundant title." 1209 1224 }, 1210 1225 "verdict": { 1211 1226 "anyOf": [ ··· 1229 1244 "needs_review" 1230 1245 ], 1231 1246 "type": "string" 1247 + }, 1248 + { 1249 + "description": "Started but genuinely unfinished (an honest non-terminal lane, so an\nincomplete audit is never structurally pressured into a false `safe`).\nTreated identically to `NeedsReview` by the coverage completion gate.", 1250 + "enum": [ 1251 + "partial" 1252 + ], 1253 + "type": "string" 1232 1254 } 1233 1255 ], 1234 1256 "description": "Audit verdict: `safe`, `unsafe`, or `needs_review`." ··· 1246 1268 "type": "object" 1247 1269 }, 1248 1270 "strict": true, 1249 - "description": "Record an audit-coverage result: that a code path was checked for a class of issue, with a verdict. Use this to avoid re-auditing the same path and to track coverage gaps. Pair with `memory_add` (kind `finding`) when an issue is actually found.\n\n# Inputs\n\n- `title` (required): what was audited (e.g. \"SQL injection in /login\").\n- `content` (required): what was checked and the outcome reasoning.\n- `verdict` (required): `safe`, `unsafe`, or `needs_review`.\n- `file_path` (optional): file audited.\n- `line` (optional): line number audited.\n- `cwe` (optional): CWE class checked (e.g. `CWE-89`).\n- `concepts` (optional): comma- or newline-separated tags/keywords.\n\n# Notes\n\n- `safe` = audited, no issue. `unsafe` = issue found (also record a `finding`). `needs_review` = audit incomplete.\n- Coverage memories are queryable via `memory_search` but excluded from the top-N auto-injection." 1271 + "description": "Record an audit-coverage result: that a code path was checked for a class of issue, with a verdict. Use this to avoid re-auditing the same path and to track coverage gaps. Pair with `memory_add` (kind `finding`) when an issue is actually found.\n\n# Inputs\n\n- `title` (optional): what was audited (e.g. \"SQL injection in /login\"). If omitted, defaults to the `file_path` (or a content snippet) — so for per-file coverage you can just pass `file_path` + `content` + `verdict`.\n- `content` (required): what was checked and the outcome reasoning.\n- `verdict` (required): `safe`, `unsafe`, or `needs_review`.\n- `file_path` (optional): file audited.\n- `line` (optional): line number audited.\n- `cwe` (optional): CWE class checked (e.g. `CWE-89`).\n- `concepts` (optional): comma- or newline-separated tags/keywords.\n\n# Notes\n\n- `safe` = audited, no issue. `unsafe` = issue found (also record a `finding`). `needs_review` = audit incomplete.\n- Coverage memories are queryable via `memory_search` but excluded from the top-N auto-injection." 1272 + }, 1273 + { 1274 + "type": "function", 1275 + "name": "memory_coverage_status", 1276 + "parameters": { 1277 + "additionalProperties": false, 1278 + "description": "Input for the `memory_coverage_status` tool: read the project's coverage\nverdicts BY PATH. Disclose-only — does NOT record or block. Returns the\ndistinct set of file paths with a latest coverage verdict, each verdict, and\nrolled-up counts. Completion is a COUNT EQUALITY (`total_latest` distinct\npaths == `census_total`, re-derived from the sanctioned `find`) AND\n`needs_review == 0` AND `partial == 0` — `needs_review == 0` ALONE is not\nsufficient (it is satisfiable by never recording coverage for a hard file).", 1279 + "properties": {}, 1280 + "required": [], 1281 + "type": "object" 1282 + }, 1283 + "strict": true, 1284 + "description": "Read the project's audit coverage verdicts BY PATH. Disclose-only — does NOT record or block. Returns the distinct set of file paths that carry a latest coverage verdict, each path's verdict (safe / unsafe / needs_review / partial), and rolled-up counts.\n\nUse this to enforce the completion gate mechanically: the audit is complete ONLY when ALL hold — (a) the returned `needs_review` and `partial` counts are 0, AND (b) `total_latest` (DISTINCT paths) equals `census_total` re-derived from the verbatim sanctioned `find` commands this turn, AND (c) every census path appears in the returned per-path list with a non-needs_review verdict. `needs_review == 0` ALONE IS NOT SUFFICIENT — it is satisfiable by never recording coverage for a hard file, which is the original coverage-gate failure.\n\nTakes no arguments." 1250 1285 }, 1251 1286 { 1252 1287 "type": "function", ··· 1322 1357 }, 1323 1358 "strict": true, 1324 1359 "description": "Retrieve the source-conversation transcript a memory was distilled from, linking a finding to the reasoning that produced it (defensible audit trail). Use this when you need to justify or re-examine how a finding was derived.\n\n# Inputs\n\n- `id` (required): the memory id to trace (e.g. `mem_xxx`).\n- `max_chars` (optional): max chars of transcript to return. Defaults to 8000.\n\n# Notes\n\n- Only memories with recorded `source_conversation_ids` return a transcript; otherwise returns \"No source conversations recorded\".\n- Deleted/unavailable conversations are marked `[not found]`." 1360 + }, 1361 + { 1362 + "type": "function", 1363 + "name": "memory_verification", 1364 + "parameters": { 1365 + "additionalProperties": false, 1366 + "description": "Input for the `memory_verification` tool: record the outcome of an\nindependent adversarial verification of a finding (adversary-only). This is\nthe sole tool that can create a verification, and only a `survived`\nverification unlocks `confirmed` for a finding.", 1367 + "properties": { 1368 + "concepts": { 1369 + "anyOf": [ 1370 + { 1371 + "default": null, 1372 + "type": "string" 1373 + }, 1374 + { 1375 + "type": "null" 1376 + } 1377 + ], 1378 + "description": "Comma- or newline-separated tags/keywords." 1379 + }, 1380 + "evidence": { 1381 + "description": "What you checked: the independent taint re-derivation, any refuting\nsanitizer/guard you considered, and the PoC re-run command + observed\neffect (or why it could not be re-run). This is the defensible evidence.", 1382 + "type": "string" 1383 + }, 1384 + "finding_id": { 1385 + "description": "The id of the finding memory this verification assesses (e.g. `mem_xxx`).", 1386 + "type": "string" 1387 + }, 1388 + "verdict": { 1389 + "anyOf": [ 1390 + { 1391 + "description": "The finding survived refutation AND the PoC reproduced on the\nunmodified target via the real entry point. Only this unlocks\n[`FindingStatus::Confirmed`].", 1392 + "enum": [ 1393 + "survived" 1394 + ], 1395 + "type": "string" 1396 + }, 1397 + { 1398 + "description": "The claim was broken: a refuting sanitizer/guard was found, or the PoC\ndid not reproduce the claimed effect.", 1399 + "enum": [ 1400 + "refuted" 1401 + ], 1402 + "type": "string" 1403 + }, 1404 + { 1405 + "description": "The check could not be completed (e.g. the target could not be built or\nrun). The finding stays `speculative`. The default — a verdict must be\nset explicitly and never defaults to `Survived`.", 1406 + "enum": [ 1407 + "inconclusive" 1408 + ], 1409 + "type": "string" 1410 + } 1411 + ], 1412 + "description": "Verdict: `survived`, `refuted`, or `inconclusive`. `survived` = the\nfinding held up AND the PoC reproduced on the unmodified target via the\nreal entry point. Only `survived` lets the finding become `confirmed`." 1413 + } 1414 + }, 1415 + "required": [ 1416 + "concepts", 1417 + "evidence", 1418 + "finding_id", 1419 + "verdict" 1420 + ], 1421 + "type": "object" 1422 + }, 1423 + "strict": true, 1424 + "description": "Record the outcome of an independent ADVERSARIAL verification of a finding — the result of re-deriving the finding's taint path AND re-running its recorded PoC on the UNMODIFIED target via the real entry point. This tool is the sole way to create a verification, and only a `survived` verification unlocks `confirmed` for a finding (the system rejects `confirmed` without one). Reserved for the `adversary` agent.\n\n# Inputs\n\n- `finding_id` (required): the id of the finding memory being assessed (e.g. `mem_xxx`).\n- `verdict` (required): `survived`, `refuted`, or `inconclusive`.\n- `evidence` (required): what you checked — your independent taint re-derivation, any refuting sanitizer/guard you considered, and the PoC re-run command + the observed effect (or why it could not be re-run).\n- `concepts` (optional): comma- or newline-separated tags/keywords.\n\n# Notes\n\n- `survived` = you could NOT refute the path AND the PoC reproduced the claimed effect on the unmodified target through the real entry point. Only this lets the finding become `confirmed`.\n- `refuted` = you broke the claim (found a neutralizing sanitizer/guard the original analysis missed, or the PoC did not reproduce the claimed effect).\n- `inconclusive` = you could not complete the check (e.g. the target would not build/run) — the finding stays `speculative`. NEVER mark `survived` without a real re-execution on the unmodified target.\n- You MUST NOT edit the target's source or config to make the PoC succeed; the effect must be produced by the target itself, originating at the recorded external entry point (not an internal function or test harness)." 1325 1425 } 1326 1426 ]
+14
crates/voge_repo/src/voge_repo.rs
··· 189 189 self.memory_repository.get_coverage(project, file_path).await 190 190 } 191 191 192 + async fn has_surviving_verification(&self, finding_id: &MemoryId) -> anyhow::Result<bool> { 193 + self.memory_repository.has_surviving_verification(finding_id).await 194 + } 195 + 196 + async fn has_surviving_verification_for_substance( 197 + &self, 198 + finding: &Memory, 199 + substance: &str, 200 + ) -> anyhow::Result<bool> { 201 + self.memory_repository 202 + .has_surviving_verification_for_substance(finding, substance) 203 + .await 204 + } 205 + 192 206 async fn find_taint_paths( 193 207 &self, 194 208 project: WorkspaceHash,
+14
crates/voge_services/src/memory.rs
··· 71 71 self.memory_repository.get_memory(id).await 72 72 } 73 73 74 + async fn has_surviving_verification(&self, finding_id: &MemoryId) -> Result<bool> { 75 + self.memory_repository.has_surviving_verification(finding_id).await 76 + } 77 + 78 + async fn has_surviving_verification_for_substance( 79 + &self, 80 + finding: &Memory, 81 + substance: &str, 82 + ) -> Result<bool> { 83 + self.memory_repository 84 + .has_surviving_verification_for_substance(finding, substance) 85 + .await 86 + } 87 + 74 88 async fn save_memory(&self, memory: Memory) -> Result<bool> { 75 89 // Write-time dedup: skip if an identical (same fingerprint) latest 76 90 // memory already exists for this project.
+48 -22
docs/agents.md
··· 1 1 # Voge Agents — Vulnerability-Hunting Pipeline 2 2 3 - Voge ships three **built-in agents** that form a vulnerability-hunting pipeline. They are 3 + Voge ships **four built-in agents** that form a vulnerability-hunting pipeline. They are 4 4 purpose-built to drive Voge's taint-graph memory system (`memory_add`, `memory_update_finding`, 5 - `memory_find_taint_paths`, `memory_coverage`, `memory_trace`) to audit code **systematically** and 6 - to validate findings with **real proof-of-concept exploits**. 5 + `memory_find_taint_paths`, `memory_coverage`, `memory_trace`, `memory_verification`) to audit code 6 + **systematically** and to validate findings with **real proof-of-concept exploits**, gated by 7 + **independent adversarial verification**. 7 8 8 9 | Agent | Alias | Role | Modifies files? | 9 10 |---|---|---|---| 10 - | `voge` | _(default)_ | Exploit validation + orchestration; the only agent that may mark a finding `confirmed` | Yes (writes/runs PoCs) | 11 + | `voge` | _(default)_ | Exploit validation + orchestration; the only agent that may mark a finding `confirmed` (and only after an `adversary` `survived` verdict) | Yes (writes/runs PoCs) | 11 12 | `sage` | `:ask` | Systematic attack-surface enumeration and taint tracing (read-only recon) | No | 13 + | `adversary` | — | Independent verifier: refutes the finding and re-runs the PoC on the unmodified target; the only agent that records a verification | Harness only (never the target) | 12 14 | `muse` | `:plan` | Triage, severity ranking, and audit-report writing to `plans/` | No (`plans/` only) | 13 15 14 - The definitions live in `crates/voge_repo/src/agents/{voge,sage,muse}.md` and are embedded into the 15 - binary via `include_str!` (`crates/voge_repo/src/agent.rs`, `init_default`). 16 + The definitions live in `crates/voge_repo/src/agents/{voge,sage,muse,adversary}.md` and are embedded 17 + into the binary via `include_str!` (`crates/voge_repo/src/agent.rs`, `init_default`). 16 18 17 19 ## Pipeline 18 20 19 21 ``` 20 - ┌──────────────────────────┐ 21 - │ voge (default, task) │ ← orchestrator + exploit validator 22 - └─────┬───────────────┬────┘ 23 - delegate │ │ delegate 24 - ┌─────▼─────┐ ┌────▼─────┐ 25 - │ sage │ │ muse │ 26 - │ enumerate │ │ triage │ 27 - │ + taint │ │ + report │ 28 - └───────────┘ └──────────┘ 22 + ┌────────────────────────────┐ 23 + │ voge (default, task) │ ← orchestrator + exploit validator 24 + └──┬──────────┬─────────┬────┘ 25 + delegate │ │ │ delegate 26 + ┌────▼───┐ ┌────▼───┐ ┌───▼────┐ 27 + │ sage │ │adversary│ │ muse │ 28 + │enumerate│ │ verify │ │ triage │ 29 + │+ taint │ │ + PoC │ │+report │ 30 + └────────┘ └────────┘ └────────┘ 29 31 ``` 30 32 31 33 **Stage 1 — `sage` enumerates.** Maps the complete untrusted-input attack surface, traces each 32 34 entry point to every reachable dangerous sink, and records coverage verdicts + speculative taint 33 35 findings into the shared memory graph. 34 36 35 - **Stage 2 — `voge` validates.** Pulls the unsanitized source→sink flows `sage` recorded, builds a 36 - real PoC, runs it against the real target, and — only when the exploit genuinely demonstrates the 37 - impact — promotes the finding to `confirmed`. 37 + **Stage 2 — `voge` validates (with the adversary gate).** Pulls the unsanitized source→sink flows 38 + `sage` recorded, builds a real PoC, runs it against the real target, then **delegates refutation to 39 + `adversary`** (which re-derives the path and re-runs the PoC on the unmodified target). Only after 40 + `adversary` records a `survived` verdict may `voge` promote the finding to `confirmed` — the system 41 + rejects `confirmed` without one. 38 42 39 43 **Stage 3 — `muse` reports.** Ranks findings, double-checks that every `confirmed` item carries 40 - real proof (downgrading any that don't), verifies coverage is complete, and writes the audit report 41 - + remediation plan to `plans/`. 44 + real proof and a surviving verification (downgrading/flagging any that don't), verifies coverage is 45 + complete, and writes the audit report + remediation plan to `plans/`. 46 + 47 + > **Star topology.** Only `voge` holds the `task` tool, so only `voge` can delegate. `sage`, 48 + > `adversary`, and `muse` are leaf specialists. This keeps delegation acyclic and the 49 + > status-gating unambiguous. 50 + 51 + ## Adversarial verification gate (L1 + L2 + L3) 52 + 53 + A finding's `confirmed` status is **structurally gated** — not just prompt-policed — by independent 54 + adversarial verification, so a model cannot self-certify a vulnerability: 55 + 56 + - **L1 — code guard.** `memory_update_finding(status="confirmed")` is *rejected* unless a 57 + `survived` verification exists for that finding (enforced in `crates/voge_app/src/tool_executor.rs`, 58 + gated by the `require_adversarial_verification` config flag). 59 + - **L2 — refutation.** The `adversary` agent independently re-derives the source→sink path and 60 + actively hunts for a missed sanitizer / downstream guard that breaks the claim. 61 + - **L3 — re-execution.** `adversary` re-runs the finding's recorded PoC on the **unmodified** target 62 + and asserts the claimed effect occurs via the real entry point. 63 + 64 + `adversary` records the outcome with `memory_verification(finding_id, verdict, evidence)` where 65 + verdict is `survived` / `refuted` / `inconclusive`. Only `survived` unlocks `confirmed`. 42 66 43 - > **Star topology.** Only `voge` holds the `task` tool, so only `voge` can delegate. `sage` and 44 - > `muse` are leaf specialists. This keeps delegation acyclic and the status-gating unambiguous. 67 + **Self-certification is structurally impossible:** the `memory_verification` tool exists *only* on the 68 + `adversary` agent — `voge`/`sage`/`muse` lack it, so `voge` cannot mint its own verification. 69 + `adversary` in turn cannot change finding status (no `memory_update_finding`), mint findings (no 70 + `memory_add`), edit the target (no `patch`/`multi_patch`), or delegate (no `task`). 45 71 46 72 ## The two inviolable rules 47 73
+2 -1
docs/configuration.md
··· 74 74 | `verify_todos` | bool | `true` | Pending-todos reminder hook. | 75 75 | `restricted` | bool | `false` | Restricted mode (tool execution needs explicit permission). | 76 76 | `tool_supported` | bool | `true` | Whether tool use is supported in this environment. | 77 - | `subagents` | bool | `true` | Enables the `task` tool; `voge` delegates recon to `sage` and triage/report to `muse`. | 77 + | `subagents` | bool | `true` | Enables the `task` tool; `voge` delegates recon to `sage`, verification to `adversary`, and triage/report to `muse`. | 78 78 | `research_subagent` | bool | `false` | Deprecated for Sage — `sage` is always delegatable via `task`. Now only gates the generic custom-`agent` delegation entry. | 79 + | `require_adversarial_verification` | bool | `true` | When `true`, `memory_update_finding` rejects `confirmed` unless an `adversary` `survived` verification exists for the finding. Set `false` to skip the gate (escape hatch for targets that cannot run the adversary). | 79 80 | `use_text_patch_fallback` | bool | `false` | Switches patch fallback to the text-based matcher. | 80 81 | `auto_install_vscode_extension` | bool | `true` | Auto-install the VS Code extension. | 81 82 | `auto_open_dump` | bool | `false` | Auto-open HTML dumps in the browser. |
+12
voge.schema.json
··· 273 273 } 274 274 ] 275 275 }, 276 + "require_adversarial_verification": { 277 + "description": "When `true` (default), a finding may be marked `confirmed` only after an\nindependent adversarial verification with a `survived` verdict, recorded\nby the `adversary` agent — `memory_update_finding` rejects `confirmed`\nwithout one. When `false`, the verification gate is skipped (escape\nhatch for targets that cannot run the adversary). Defaults to `true`.", 278 + "type": "boolean", 279 + "default": false 280 + }, 276 281 "research_subagent": { 277 282 "description": "Gates the generic custom-`agent` entry in the `task` tool's delegation\nlist.\n\nThe built-in pipeline agents (`sage`, `muse`) are always delegatable so\nthe `voge` orchestrator can drive the audit. This flag now only controls\nwhether the generic `agent` entry appears in that list. Kept for backward\ncompatibility with existing configs. Defaults to `false`.", 278 283 "type": "boolean", ··· 655 660 "items": { 656 661 "type": "string" 657 662 } 663 + }, 664 + "stream_timeout_secs": { 665 + "description": "Max seconds of silence (no first byte / no token) from the provider\nstream before the request is aborted as stalled. Bounds both\ntime-to-first-byte and inter-token gaps so a hung/queued request errors\nout instead of stalling the agent forever.", 666 + "type": "integer", 667 + "format": "uint64", 668 + "default": 180, 669 + "minimum": 0 658 670 }, 659 671 "tls_backend": { 660 672 "$ref": "#/$defs/TlsBackend"