1#[cfg(test)]
22use async_trait::async_trait;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25use wm_core::{Args, Context, CoreError, Output, Result, Tool};
26
27use crate::capability_gate::{CapabilityGateMode, GateOutcome};
28use crate::circuit_breaker::CircuitBreakerRegistry;
29use crate::rate_limiter::RateLimiter;
30use wm_governance::{
31 ActionVerdict, DharmaGate, FirebreakOutcome, KarmaLedger, ResourceRules, ResourceVerdict,
32};
33
34pub const DEFAULT_DISPATCH_TIMEOUT: Duration = Duration::from_secs(300);
40
41fn hash_args(args: &Args) -> u64 {
44 use std::hash::Hasher;
45 let bytes = serde_json::to_vec(args).unwrap_or_default();
46 let mut hasher = ahash::AHasher::default();
47 hasher.write(&bytes);
48 hasher.finish()
49}
50
51fn first_str(v: &serde_json::Value, keys: &[&str]) -> Option<String> {
53 keys.iter().find_map(|k| {
54 v.get(*k)
55 .and_then(serde_json::Value::as_str)
56 .map(str::to_string)
57 })
58}
59
60#[allow(clippy::too_many_arguments)]
69fn record_write_audit(
70 journal: &wm_governance::WriteAuditJournal,
71 store_write_baseline: u64,
72 tool: &str,
73 actor: wm_governance::ActorIdentity,
74 declared_writes: bool,
75 args_memory_id: Option<&str>,
76 args_content_hash: Option<&str>,
77 args_digest: Option<String>,
78 output: &serde_json::Value,
79 success: bool,
80 confirm_gated: Option<bool>,
81) {
82 if tool == "wm" {
88 return;
89 }
90 let reported_writes = output
91 .get("writes")
92 .and_then(|w| w.as_array())
93 .map_or(0, |a| a.len() as u32);
94 let memory_id = first_str(output, &["id", "memory_id", "memory"])
95 .or_else(|| args_memory_id.map(str::to_string));
96 let content_hash = first_str(output, &["content_hash", "hash", "sha256"])
97 .or_else(|| args_content_hash.map(str::to_string));
98 let result = match confirm_gated {
99 Some(confirmed) => journal.record_since_confirmed(
100 store_write_baseline,
101 tool,
102 actor,
103 memory_id.as_deref(),
104 content_hash.as_deref(),
105 declared_writes,
106 reported_writes,
107 success,
108 confirmed,
109 args_digest,
110 ),
111 None => journal.record_since(
112 store_write_baseline,
113 tool,
114 actor,
115 memory_id.as_deref(),
116 content_hash.as_deref(),
117 declared_writes,
118 reported_writes,
119 success,
120 args_digest,
121 ),
122 };
123 if let Err(e) = result {
124 tracing::warn!(error = %e, "Write-audit journal record failed");
125 }
126}
127
128pub struct DispatchPipeline {
132 rate_limiter: Arc<RateLimiter>,
133 circuit_breakers: Arc<CircuitBreakerRegistry>,
134 dharma_gate: Arc<DharmaGate>,
135 karma_ledger: Option<Arc<KarmaLedger>>,
136 resource_rules: Option<Arc<ResourceRules>>,
139 write_gate: Option<Arc<crate::write_gate::WriteGate>>,
142 write_audit: Option<Arc<wm_governance::WriteAuditJournal>>,
145 secret_scan: Option<crate::secret_scan::SharedSampler>,
148 sandbox_exec: Option<Arc<crate::sandbox_exec::ScopedSandboxExecutor>>,
152 subprocess_sandbox: Option<Arc<crate::subprocess_sandbox::SubprocessSandbox>>,
158 flight_recorder: Option<Arc<crate::flight::FlightRecorder>>,
162 firebreak: Option<Arc<wm_governance::Firebreak>>,
166 capability_mode: CapabilityGateMode,
171 gana_registry: Option<Arc<std::sync::Mutex<wm_core::GanaRegistry>>>,
173 dispatch_timeout: Option<Duration>,
177}
178
179impl DispatchPipeline {
180 pub fn new(
185 rate_limiter: Arc<RateLimiter>,
186 circuit_breakers: Arc<CircuitBreakerRegistry>,
187 dharma_gate: Arc<DharmaGate>,
188 karma_ledger: Option<Arc<KarmaLedger>>,
189 ) -> Self {
190 Self {
191 rate_limiter,
192 circuit_breakers,
193 dharma_gate,
194 karma_ledger,
195 resource_rules: None,
196 write_gate: None,
197 write_audit: None,
198 flight_recorder: None,
199 secret_scan: Some(Arc::new(crate::secret_scan::SecretSampler::from_env())),
204 sandbox_exec: None,
208 subprocess_sandbox: None,
212 firebreak: Some(Arc::new(wm_governance::Firebreak::promoted())),
218 capability_mode: CapabilityGateMode::from_env(),
219 gana_registry: None,
220 dispatch_timeout: None,
221 }
222 }
223
224 #[must_use]
229 pub fn timeout_from_env() -> Option<Duration> {
230 match std::env::var("WM_DISPATCH_TIMEOUT_MS") {
231 Ok(v) => match v.trim().parse::<u64>() {
232 Ok(0) => None,
233 Ok(ms) => Some(Duration::from_millis(ms)),
234 Err(_) => {
235 tracing::warn!(
236 value = %v,
237 "WM_DISPATCH_TIMEOUT_MS is not a valid millisecond count — using default"
238 );
239 Some(DEFAULT_DISPATCH_TIMEOUT)
240 }
241 },
242 Err(_) => Some(DEFAULT_DISPATCH_TIMEOUT),
243 }
244 }
245
246 #[must_use]
248 pub const fn with_dispatch_timeout(mut self, timeout: Option<Duration>) -> Self {
249 self.dispatch_timeout = timeout;
250 self
251 }
252
253 #[must_use]
255 pub fn with_defaults() -> Self {
256 Self::new(
257 Arc::new(RateLimiter::default()),
258 Arc::new(CircuitBreakerRegistry::default()),
259 Arc::new(DharmaGate::default()),
260 None,
261 )
262 }
263
264 #[must_use]
266 pub const fn with_capability_mode(mut self, mode: CapabilityGateMode) -> Self {
267 self.capability_mode = mode;
268 self
269 }
270
271 #[must_use]
273 pub fn with_gana_registry(
274 mut self,
275 registry: Arc<std::sync::Mutex<wm_core::GanaRegistry>>,
276 ) -> Self {
277 self.gana_registry = Some(registry);
278 self
279 }
280
281 #[must_use]
283 pub fn with_resource_rules(mut self, rules: Arc<ResourceRules>) -> Self {
284 self.resource_rules = Some(rules);
285 self
286 }
287
288 #[must_use]
292 pub fn with_write_gate(mut self, gate: Arc<crate::write_gate::WriteGate>) -> Self {
293 self.write_gate = Some(gate);
294 self
295 }
296
297 #[must_use]
300 pub fn with_write_audit(mut self, journal: Arc<wm_governance::WriteAuditJournal>) -> Self {
301 self.write_audit = Some(journal);
302 self
303 }
304
305 #[must_use]
309 pub fn with_flight_recorder(
310 mut self,
311 recorder: Option<Arc<crate::flight::FlightRecorder>>,
312 ) -> Self {
313 self.flight_recorder = recorder;
314 self
315 }
316
317 #[must_use]
320 pub fn with_secret_scan_option(
321 mut self,
322 scanner: Option<crate::secret_scan::SharedSampler>,
323 ) -> Self {
324 self.secret_scan = scanner;
325 self
326 }
327
328 #[must_use]
330 pub fn secret_scan(&self) -> Option<&crate::secret_scan::SecretSampler> {
331 self.secret_scan.as_deref()
332 }
333
334 #[must_use]
338 pub fn with_sandbox_executor(
339 mut self,
340 executor: Option<Arc<crate::sandbox_exec::ScopedSandboxExecutor>>,
341 ) -> Self {
342 self.sandbox_exec = executor;
343 self
344 }
345
346 #[must_use]
348 pub fn sandbox_executor(&self) -> Option<&crate::sandbox_exec::ScopedSandboxExecutor> {
349 self.sandbox_exec.as_deref()
350 }
351
352 #[must_use]
356 pub fn with_subprocess_sandbox(
357 mut self,
358 sandbox: Option<Arc<crate::subprocess_sandbox::SubprocessSandbox>>,
359 ) -> Self {
360 self.subprocess_sandbox = sandbox;
361 self
362 }
363
364 #[must_use]
366 pub fn subprocess_sandbox(&self) -> Option<&crate::subprocess_sandbox::SubprocessSandbox> {
367 self.subprocess_sandbox.as_deref()
368 }
369
370 #[must_use]
373 pub fn with_firebreak(mut self, firebreak: Arc<wm_governance::Firebreak>) -> Self {
374 self.firebreak = Some(firebreak);
375 self
376 }
377
378 #[must_use]
383 pub fn with_firebreak_option(
384 mut self,
385 firebreak: Option<Arc<wm_governance::Firebreak>>,
386 ) -> Self {
387 self.firebreak = firebreak;
388 self
389 }
390
391 #[must_use]
393 pub fn firebreak(&self) -> Option<&wm_governance::Firebreak> {
394 self.firebreak.as_deref()
395 }
396
397 #[must_use]
400 pub fn with_write_audit_option(
401 mut self,
402 journal: Option<Arc<wm_governance::WriteAuditJournal>>,
403 ) -> Self {
404 self.write_audit = journal;
405 self
406 }
407
408 #[must_use]
410 pub fn resource_rules(&self) -> Option<&ResourceRules> {
411 self.resource_rules.as_deref()
412 }
413
414 #[must_use]
416 pub fn write_audit(&self) -> Option<&wm_governance::WriteAuditJournal> {
417 self.write_audit.as_deref()
418 }
419
420 pub async fn dispatch(&self, tool: &dyn Tool, ctx: &mut Context, args: Args) -> Result<Output> {
422 let start = Instant::now();
423 let mut args = args;
424
425 let confirmed = args
432 .get("confirm")
433 .and_then(serde_json::Value::as_bool)
434 .unwrap_or(false);
435 ctx.explicit_confirm = confirmed;
436 if !tool.effects().is_available_in(ctx.brain_wave) && !confirmed {
437 return Err(CoreError::Governance(format!(
438 "tool '{}' not available in {:?} brain-wave state",
439 tool.name(),
440 ctx.brain_wave
441 )));
442 }
443
444 const COHERENCE_THRESHOLD: f32 = 0.3;
446 if !tool.effects().writes.is_empty() && ctx.citta_coherence < COHERENCE_THRESHOLD {
447 return Err(CoreError::Governance(format!(
448 "tool '{}' requires write access but citta coherence is {:.2} (minimum {:.2})",
449 tool.name(),
450 ctx.citta_coherence,
451 COHERENCE_THRESHOLD
452 )));
453 }
454
455 if ctx.readonly && !tool.effects().writes.is_empty() {
459 return Err(CoreError::Governance(format!(
460 "server is read-only: tool '{}' requires write access",
461 tool.name()
462 )));
463 }
464
465 const CONFIDENCE_THRESHOLD: f32 = 0.5;
467 if ctx.self_model_confidence < CONFIDENCE_THRESHOLD {
468 tracing::warn!(
469 tool = tool.name(),
470 confidence = ctx.self_model_confidence,
471 "low self-model confidence — conservative dispatch mode"
472 );
473 if !tool.effects().writes.is_empty() {
475 return Err(CoreError::Governance(format!(
476 "homeostasis limit (self-model confidence): tool '{}' requires write access but confidence is {:.2} (minimum {:.2}) — conservative dispatch blocks writes; this is load-sensitive, retry when the host settles (deterministic runs can pin WM_HOMEOSTASIS_FROZEN=1)",
477 tool.name(),
478 ctx.self_model_confidence,
479 CONFIDENCE_THRESHOLD
480 )));
481 }
482 }
483
484 const DRIVE_CAUTION_THRESHOLD: f32 = 0.85;
486 if !tool.effects().writes.is_empty() && ctx.drive_caution > DRIVE_CAUTION_THRESHOLD {
487 tracing::warn!(
488 tool = tool.name(),
489 drive_caution = ctx.drive_caution,
490 "high drive caution — write operation flagged for review"
491 );
492 }
493
494 const DRIVE_ENERGY_THRESHOLD: f32 = 0.15;
496 if !tool.effects().writes.is_empty() && ctx.drive_energy < DRIVE_ENERGY_THRESHOLD {
497 tracing::warn!(
498 tool = tool.name(),
499 drive_energy = ctx.drive_energy,
500 "low drive energy — write operation may be resource-constrained"
501 );
502 }
503
504 match crate::capability_gate::evaluate(
512 tool.effects(),
513 &mut args,
514 self.capability_mode,
515 chrono::Utc::now().timestamp(),
516 ) {
517 Ok(GateOutcome::AdvisoryMissing { required }) => {
518 tracing::debug!(
519 tool = tool.name(),
520 required = %required.labels().join(", "),
521 mode = self.capability_mode.label(),
522 "capability gate: requirement unmet (advisory)"
523 );
524 }
525 Ok(_) => {}
526 Err(reason) => {
527 return Err(CoreError::Governance(format!("capability gate: {reason}")));
528 }
529 }
530
531 let verdict = self.dharma_gate.evaluate(tool.effects(), ctx);
535 match verdict {
536 ActionVerdict::Panic(reason) => {
537 tracing::error!(tool = tool.name(), reason = %reason, "Dharma PANIC");
538 return Err(CoreError::Governance(reason));
539 }
540 ActionVerdict::Intervene(reason) => {
541 tracing::warn!(tool = tool.name(), reason = %reason, "Dharma INTERVENE");
542 return Err(CoreError::Governance(reason));
543 }
544 ActionVerdict::Correct(reason) => {
545 tracing::info!(tool = tool.name(), reason = %reason, "Dharma CORRECT — proceeding with restrictions");
546 }
547 ActionVerdict::Advise(reason) => {
548 tracing::debug!(tool = tool.name(), reason = %reason, "Dharma ADVISE");
549 }
550 ActionVerdict::Observe => {}
551 }
552
553 let mut novelty_flag: Option<String> = None;
559 if let Some(ref rules) = self.resource_rules {
560 let effects = tool.effects();
561 let is_write = !effects.writes.is_empty();
562 let is_spawn = effects.spawns
563 || effects
564 .writes
565 .iter()
566 .chain(effects.reads.iter())
567 .any(|r| matches!(r, wm_core::Resource::Process));
568 let is_network = effects
569 .writes
570 .iter()
571 .chain(effects.reads.iter())
572 .any(|r| matches!(r, wm_core::Resource::Network));
573 let has_purpose = [args.get("purpose"), ctx.meta.get("purpose")]
574 .into_iter()
575 .flatten()
576 .filter_map(serde_json::Value::as_str)
577 .any(|p| !p.trim().is_empty());
578 let homeostasis = self.dharma_gate.homeostasis();
579 let verdict = rules.evaluate(
580 tool.name(),
581 hash_args(&args),
582 is_write,
583 is_spawn,
584 is_network,
585 has_purpose,
586 &homeostasis,
587 ctx.brain_wave,
588 );
589 match verdict {
590 ResourceVerdict::Allow => {}
591 ResourceVerdict::NotNovel { .. } => {
592 novelty_flag = Some(verdict.reason());
593 tracing::warn!(
594 tool = tool.name(),
595 reason = %verdict.reason(),
596 "resource rules: novelty flag on response"
597 );
598 }
599 ResourceVerdict::BudgetExceeded { .. }
600 | ResourceVerdict::RequiresHumanReview { .. }
601 | ResourceVerdict::NoPurpose { .. } => {
602 tracing::warn!(
603 tool = tool.name(),
604 reason = %verdict.reason(),
605 "resource rules: dispatch blocked"
606 );
607 return Err(CoreError::Governance(format!(
608 "resource rules: {}",
609 verdict.reason()
610 )));
611 }
612 }
613 }
614
615 let gate_disclosure: Option<serde_json::Value> = if let Some(ref gate) = self.write_gate {
621 let outcome = gate.enforce(tool.name(), &mut args)?;
622 if let Some(sc) = outcome.short_circuit {
623 return Ok(sc);
624 }
625 outcome.disclosure
626 } else {
627 None
628 };
629
630 if let Err(retry_after_ms) = self.rate_limiter.try_acquire(tool.name()) {
637 return Err(CoreError::RateLimited(format!(
638 "request rate limit (per-tool dispatch governor): '{}' — retry after {}ms",
639 tool.name(),
640 retry_after_ms
641 )));
642 }
643
644 if self.circuit_breakers.is_open(tool.name()) {
646 let retry_after_ms = self
647 .circuit_breakers
648 .remaining_cooldown(tool.name())
649 .as_millis();
650 return Err(CoreError::CircuitBreaker(format!(
651 "{} — repeated execution failures opened the breaker; retry after {}ms",
652 tool.name(),
653 retry_after_ms
654 )));
655 }
656
657 let confirm_gated = if tool.effects().destructive {
660 if !confirmed {
661 return Err(CoreError::Governance(format!(
662 "tool '{}' is destructive — pass `\"confirm\": true` in args to proceed",
663 tool.name()
664 )));
665 }
666 Some(true)
669 } else {
670 None
671 };
672
673 let mut firebreak_advisories: Vec<String> = Vec::new();
681 if let Some(ref firebreak) = self.firebreak {
682 match firebreak.enforce(tool.name(), tool.effects(), &args) {
683 FirebreakOutcome::Blocked(reason) => {
684 tracing::warn!(tool = tool.name(), reason = %reason, "firebreak VETO");
685 return Err(CoreError::Governance(reason));
686 }
687 FirebreakOutcome::Proceed { advisories } if !advisories.is_empty() => {
688 tracing::info!(tool = tool.name(), advisories = ?advisories, "firebreak advisories");
689 firebreak_advisories = advisories;
690 }
691 FirebreakOutcome::Proceed { .. } => {}
692 }
693 }
694
695 let has_runtime_galaxy = args
709 .get("galaxy")
710 .and_then(serde_json::Value::as_str)
711 .is_some_and(|g| !g.is_empty());
712 let mut checked_galaxies: Vec<wm_core::Galaxy> = Vec::new();
713
714 if !has_runtime_galaxy {
715 for resource in &tool.effects().reads {
716 if let wm_core::Resource::Galaxy(name) = resource {
717 if let Some(galaxy) = wm_core::Galaxy::from_db_name(name) {
718 if !ctx.can_access_galaxy(galaxy) {
719 return Err(CoreError::Governance(format!(
720 "compartment '{}' cannot read galaxy '{}' (tool '{}')",
721 ctx.compartment.as_deref().unwrap_or("none"),
722 name,
723 tool.name()
724 )));
725 }
726 checked_galaxies.push(galaxy);
727 }
728 }
729 }
730 for resource in &tool.effects().writes {
731 if let wm_core::Resource::Galaxy(name) = resource {
732 if let Some(galaxy) = wm_core::Galaxy::from_db_name(name) {
733 if !ctx.can_write_galaxy(galaxy) {
734 return Err(CoreError::Governance(format!(
735 "compartment '{}' cannot write to galaxy '{}' (tool '{}')",
736 ctx.compartment.as_deref().unwrap_or("none"),
737 name,
738 tool.name()
739 )));
740 }
741 checked_galaxies.push(galaxy);
742 }
743 }
744 }
745 }
746
747 if let Some(galaxy_str) = args.get("galaxy").and_then(serde_json::Value::as_str) {
749 if !galaxy_str.is_empty() {
750 if let Some(runtime_galaxy) = wm_core::Galaxy::from_db_name(galaxy_str) {
751 if !checked_galaxies.contains(&runtime_galaxy) {
752 let has_writes = !tool.effects().writes.is_empty();
754 if has_writes {
755 if !ctx.can_write_galaxy(runtime_galaxy) {
756 return Err(CoreError::Governance(format!(
757 "compartment '{}' cannot write to galaxy '{}' (tool '{}' runtime arg)",
758 ctx.compartment.as_deref().unwrap_or("none"),
759 galaxy_str,
760 tool.name()
761 )));
762 }
763 } else if !ctx.can_access_galaxy(runtime_galaxy) {
764 return Err(CoreError::Governance(format!(
765 "compartment '{}' cannot read galaxy '{}' (tool '{}' runtime arg)",
766 ctx.compartment.as_deref().unwrap_or("none"),
767 galaxy_str,
768 tool.name()
769 )));
770 }
771 }
772 }
773 }
774 }
775
776 if !tool.effects().writes.is_empty()
782 && let Some(galaxy_str) = args.get("galaxy").and_then(serde_json::Value::as_str)
783 && galaxy_str == "citta"
784 && !tool
785 .effects()
786 .reads
787 .iter()
788 .any(|r| matches!(r, wm_core::Resource::Galaxy(g) if g == "citta"))
789 {
790 return Err(CoreError::Governance(
791 "VIOLATION_SATYA: writing to citta (runtime galaxy) without reading — memory fabrication is forbidden"
792 .to_string(),
793 ));
794 }
795
796 let args_memory_id = first_str(&args, &["id", "memory_id", "memory"]);
804 let args_content_hash = first_str(&args, &["content_hash", "hash", "sha256"]);
805 let args_digest = wm_governance::args_digest(tool.name(), &args);
810 if let Some(ref flight) = self.flight_recorder {
816 if let Err(e) = flight.record(tool.name(), &args) {
817 tracing::warn!(error = %e, "Flight recorder capture failed (replay will refuse)");
818 }
819 }
820 let write_audit_baseline = self
821 .write_audit
822 .as_ref()
823 .map_or(0, |j| j.dispatch_baseline());
824 let mut spawn_disclosure: Option<serde_json::Value> = None;
830 if let Some(sb) = self.subprocess_sandbox.as_deref() {
831 if crate::subprocess_sandbox::SubprocessSandbox::declared(tool.effects()) {
832 let policy = sb.policy_for(tool.effects());
833 if policy.is_active() {
834 let mut disclosure = serde_json::json!({
835 "net": policy.allow_net(),
836 "envelope": wm_core::sandbox::ENVELOPE_SCHEMA,
837 });
838 if let Some(runner) = policy.runner()
839 && let Some(obj) = disclosure.as_object_mut()
840 {
841 obj.insert(
842 "runner".to_string(),
843 serde_json::Value::String(runner.display().to_string()),
844 );
845 }
846 sb.note_confined();
847 spawn_disclosure = Some(disclosure);
848 } else {
849 sb.note_degraded(tool.name());
850 }
851 ctx.spawn = policy;
852 } else if tool.effects().spawns {
853 sb.note_unconfined_spawn(tool.name());
854 }
855 }
856 let result = if crate::sandbox_exec::ScopedSandboxExecutor::handles(tool)
860 && let Some(executor) = self.sandbox_exec.as_deref()
861 {
862 executor.run(tool, ctx, args)
863 } else if let Some(timeout) = self.dispatch_timeout {
864 if let Ok(res) = tokio::time::timeout(timeout, tool.call(ctx, args)).await {
865 res
866 } else {
867 tracing::error!(
868 tool = tool.name(),
869 timeout_ms = timeout.as_millis(),
870 "tool dispatch timed out"
871 );
872 self.circuit_breakers.record_failure(tool.name());
873 return Err(CoreError::Tool(format!(
874 "tool '{}' timed out after {}ms",
875 tool.name(),
876 timeout.as_millis()
877 )));
878 }
879 } else {
880 tool.call(ctx, args).await
881 };
882 let elapsed = start.elapsed();
883
884 if let Some(ref scanner) = self.secret_scan {
889 if let Ok(ref output) = result {
890 scanner.scan(tool.name(), output);
891 }
892 }
893
894 let result = match (result, novelty_flag) {
896 (Ok(mut output), Some(flag)) => {
897 if let serde_json::Value::Object(ref mut map) = output {
898 match map.get_mut("resource_flags") {
899 Some(serde_json::Value::Array(arr)) => {
900 arr.push(serde_json::Value::String(flag));
901 }
902 Some(_) => {}
903 None => {
904 map.insert(
905 "resource_flags".to_string(),
906 serde_json::Value::Array(vec![serde_json::Value::String(flag)]),
907 );
908 }
909 }
910 }
911 Ok(output)
912 }
913 (result, _) => result,
914 };
915
916 let result = match (result, gate_disclosure) {
919 (Ok(mut output), Some(disclosure)) => {
920 if let serde_json::Value::Object(ref mut map) = output {
921 map.insert("write_gate".to_string(), disclosure);
922 }
923 Ok(output)
924 }
925 (result, _) => result,
926 };
927
928 let result = match (result, firebreak_advisories) {
932 (Ok(mut output), advisories) if !advisories.is_empty() => {
933 if let serde_json::Value::Object(ref mut map) = output {
934 map.insert(
935 "firebreak".to_string(),
936 serde_json::json!({ "advisories": advisories }),
937 );
938 }
939 Ok(output)
940 }
941 (result, _) => result,
942 };
943
944 let result = match (result, spawn_disclosure) {
947 (Ok(mut output), Some(disclosure)) => {
948 if let serde_json::Value::Object(ref mut map) = output {
949 map.insert("sandbox".to_string(), disclosure);
950 }
951 Ok(output)
952 }
953 (result, _) => result,
954 };
955
956 if let Ok(output) = &result {
958 tool.stats().record_success(elapsed, elapsed);
959 self.circuit_breakers.record_success(tool.name());
960
961 if let Some(ref ledger) = self.karma_ledger {
962 let declared_writes = !tool.effects().writes.is_empty();
963 let actual_writes = output
964 .get("writes")
965 .and_then(|w| w.as_array())
966 .map_or(0, |a| a.len() as u32);
967 if let Err(e) = ledger.record(tool.name(), declared_writes, actual_writes, true) {
968 tracing::warn!(error = %e, "Karma ledger record failed");
969 }
970 ctx.karma_debt = ledger.total_debt();
971 }
972
973 if let Some(ref journal) = self.write_audit {
974 let declared_writes = !tool.effects().writes.is_empty();
975 record_write_audit(
976 journal,
977 write_audit_baseline,
978 tool.name(),
979 wm_governance::ActorIdentity::from_context(ctx),
980 declared_writes,
981 args_memory_id.as_deref(),
982 args_content_hash.as_deref(),
983 Some(args_digest),
984 output,
985 true,
986 confirm_gated,
987 );
988 }
989 } else {
990 tool.stats().record_failure(elapsed);
991 if let Err(err) = &result {
995 if err.counts_as_breaker_failure() {
996 self.circuit_breakers.record_failure(tool.name());
997 }
998 }
999
1000 if let Some(ref ledger) = self.karma_ledger {
1001 let declared_writes = !tool.effects().writes.is_empty();
1002 if let Err(ke) = ledger.record(tool.name(), declared_writes, 0, false) {
1003 tracing::warn!(error = %ke, "Karma ledger record failed");
1004 }
1005 ctx.karma_debt = ledger.total_debt();
1006 }
1007
1008 if let Some(ref journal) = self.write_audit {
1009 let declared_writes = !tool.effects().writes.is_empty();
1010 record_write_audit(
1011 journal,
1012 write_audit_baseline,
1013 tool.name(),
1014 wm_governance::ActorIdentity::from_context(ctx),
1015 declared_writes,
1016 args_memory_id.as_deref(),
1017 args_content_hash.as_deref(),
1018 Some(args_digest),
1019 &serde_json::Value::Null,
1020 false,
1021 confirm_gated,
1022 );
1023 }
1024 }
1025
1026 if let Some(ref registry) = self.gana_registry {
1028 if let Ok(mut reg) = registry.lock() {
1029 let gana = tool.gana();
1030 reg.record_usage(gana, result.is_ok());
1031 if let Some(prev) = ctx.last_gana {
1033 reg.record_co_usage(prev, gana);
1034 }
1035 ctx.last_gana = Some(gana);
1036 }
1037 }
1038
1039 result
1040 }
1041
1042 pub async fn dispatch_by_name(
1047 &self,
1048 registry: &crate::ToolRegistry,
1049 name: &str,
1050 ctx: &mut Context,
1051 args: Args,
1052 ) -> Result<Output> {
1053 let tool = registry
1054 .get(name)
1055 .ok_or_else(|| CoreError::NotFound(format!("tool '{name}' not registered")))?;
1056 self.dispatch(tool.as_ref(), ctx, args).await
1057 }
1058
1059 #[must_use]
1061 pub fn rate_limiter(&self) -> &RateLimiter {
1062 &self.rate_limiter
1063 }
1064
1065 #[must_use]
1067 pub fn circuit_breakers(&self) -> &CircuitBreakerRegistry {
1068 &self.circuit_breakers
1069 }
1070
1071 #[must_use]
1073 pub fn dharma_gate(&self) -> &DharmaGate {
1074 &self.dharma_gate
1075 }
1076
1077 #[must_use]
1079 pub fn karma_ledger(&self) -> Option<&KarmaLedger> {
1080 self.karma_ledger.as_deref()
1081 }
1082}
1083
1084impl Default for DispatchPipeline {
1085 fn default() -> Self {
1086 Self::with_defaults()
1087 }
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093 use wm_core::{BrainWave, EffectRow, Gana, Sandbox, ToolStats};
1094 use wm_governance::{ResourceRulesConfig, WriteAuditJournal};
1095
1096 struct TestTool {
1097 name: String,
1098 effects: EffectRow,
1099 stats: ToolStats,
1100 should_fail: bool,
1101 error: Option<fn() -> CoreError>,
1103 output: Option<Output>,
1104 store: Option<Arc<wm_memory::MemoryStore>>,
1107 }
1108
1109 impl TestTool {
1110 fn new(name: &str, effects: EffectRow) -> Self {
1111 Self {
1112 name: name.to_string(),
1113 effects,
1114 stats: ToolStats::default(),
1115 should_fail: false,
1116 error: None,
1117 output: None,
1118 store: None,
1119 }
1120 }
1121
1122 fn returning_error(name: &str, error: fn() -> CoreError) -> Self {
1123 Self {
1124 name: name.to_string(),
1125 effects: EffectRow::pure(),
1126 stats: ToolStats::default(),
1127 should_fail: false,
1128 error: Some(error),
1129 output: None,
1130 store: None,
1131 }
1132 }
1133
1134 fn with_output(mut self, output: Output) -> Self {
1135 self.output = Some(output);
1136 self
1137 }
1138
1139 fn with_store(mut self, store: Arc<wm_memory::MemoryStore>) -> Self {
1140 self.store = Some(store);
1141 self
1142 }
1143
1144 fn failing(name: &str) -> Self {
1145 Self {
1146 name: name.to_string(),
1147 effects: EffectRow::pure(),
1148 stats: ToolStats::default(),
1149 should_fail: true,
1150 error: None,
1151 output: None,
1152 store: None,
1153 }
1154 }
1155 }
1156
1157 #[async_trait]
1158 impl Tool for TestTool {
1159 fn name(&self) -> &str {
1160 &self.name
1161 }
1162 fn gana(&self) -> Gana {
1163 Gana::Heart
1164 }
1165 fn effects(&self) -> &EffectRow {
1166 &self.effects
1167 }
1168 async fn call(&self, _ctx: &mut Context, _args: Args) -> Result<Output> {
1169 if let Some(store) = &self.store {
1170 let mem = wm_memory::Memory::new(
1171 wm_core::Galaxy::Codex,
1172 format!("misdeclared write from {}", self.name),
1173 );
1174 store.put(wm_core::Galaxy::Codex, &mem).ok();
1175 }
1176 if let Some(error) = self.error {
1177 Err(error())
1178 } else if self.should_fail {
1179 Err(CoreError::Tool(self.name.clone()))
1180 } else {
1181 Ok(self
1182 .output
1183 .clone()
1184 .unwrap_or_else(|| serde_json::json!("ok")))
1185 }
1186 }
1187 fn stats(&self) -> &ToolStats {
1188 &self.stats
1189 }
1190 }
1191
1192 #[tokio::test]
1193 async fn pipeline_dispatch_success() {
1194 let pipeline = DispatchPipeline::with_defaults();
1195 let mut ctx = Context::new(BrainWave::Gamma);
1196 let tool = TestTool::new("test_tool", EffectRow::pure());
1197
1198 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1199 assert!(result.is_ok());
1200 }
1201
1202 struct HangingTool {
1203 effects: EffectRow,
1204 stats: ToolStats,
1205 }
1206
1207 impl HangingTool {
1208 fn new() -> Self {
1209 Self {
1210 effects: EffectRow::pure(),
1211 stats: ToolStats::default(),
1212 }
1213 }
1214 }
1215
1216 #[async_trait]
1217 impl Tool for HangingTool {
1218 fn name(&self) -> &str {
1219 "hanging_tool"
1220 }
1221 fn gana(&self) -> Gana {
1222 Gana::Heart
1223 }
1224 fn effects(&self) -> &EffectRow {
1225 &self.effects
1226 }
1227 async fn call(&self, _ctx: &mut Context, _args: Args) -> Result<Output> {
1228 tokio::time::sleep(Duration::from_secs(30)).await;
1229 Ok(serde_json::json!("never reached"))
1230 }
1231 fn stats(&self) -> &ToolStats {
1232 &self.stats
1233 }
1234 }
1235
1236 #[tokio::test]
1237 async fn pipeline_dispatch_timeout_bounds_hung_tool() {
1238 let pipeline = DispatchPipeline::with_defaults()
1239 .with_dispatch_timeout(Some(Duration::from_millis(50)));
1240 let mut ctx = Context::new(BrainWave::Gamma);
1241 let tool = HangingTool::new();
1242
1243 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1244 assert!(result.is_err());
1245 let msg = result.err().unwrap().to_string();
1246 assert!(
1247 msg.contains("timed out"),
1248 "expected timeout error, got: {msg}"
1249 );
1250 }
1251
1252 #[tokio::test]
1253 async fn pipeline_dispatch_with_timeout_allows_fast_tool() {
1254 let pipeline = DispatchPipeline::with_defaults()
1255 .with_dispatch_timeout(Some(Duration::from_millis(500)));
1256 let mut ctx = Context::new(BrainWave::Gamma);
1257 let tool = TestTool::new("fast_tool", EffectRow::pure());
1258
1259 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1260 assert!(result.is_ok());
1261 }
1262
1263 #[tokio::test]
1264 async fn pipeline_dispatch_failure_records_stats() {
1265 let pipeline = DispatchPipeline::with_defaults();
1266 let mut ctx = Context::new(BrainWave::Gamma);
1267 let tool = TestTool::failing("failing_tool");
1268
1269 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1270 assert!(result.is_err());
1271 assert_eq!(
1272 tool.stats()
1273 .call_count
1274 .load(std::sync::atomic::Ordering::Relaxed),
1275 1
1276 );
1277 }
1278
1279 #[tokio::test]
1280 async fn pipeline_blocks_incompatible_brain_wave() {
1281 let pipeline = DispatchPipeline::with_defaults();
1282 let mut ctx = Context::new(BrainWave::Delta);
1283 let tool = TestTool::new("test_tool", EffectRow::pure());
1284
1285 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1286 assert!(result.is_err());
1287 match result {
1288 Err(CoreError::Governance(_)) => {}
1289 other => panic!("Expected Governance error, got {other:?}"),
1290 }
1291 }
1292
1293 #[tokio::test]
1294 async fn pipeline_dharma_blocks_destructive_in_strict_mode() {
1295 let pipeline = DispatchPipeline::with_defaults();
1296 let mut ctx = Context::new(BrainWave::Theta);
1297 let tool = TestTool::new(
1298 "destructive_tool",
1299 EffectRow {
1300 writes: vec![wm_core::Resource::Filesystem],
1301 ..Default::default()
1302 },
1303 );
1304
1305 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1306 assert!(result.is_err());
1307 match result {
1308 Err(CoreError::Governance(_)) => {}
1309 other => panic!("Expected Governance error, got {other:?}"),
1310 }
1311 }
1312
1313 #[tokio::test]
1314 async fn pipeline_strict_refusal_is_typed_and_distinct_from_starvation() {
1315 let pipeline = DispatchPipeline::with_defaults();
1319 pipeline
1320 .dharma_gate()
1321 .update_homeostasis(wm_governance::Homeostasis {
1322 cpu_load: 0.95,
1323 memory_pressure: 0.95,
1324 active: true,
1325 });
1326 let mut ctx = Context::new(BrainWave::Beta);
1327 let tool = TestTool::new(
1328 "stress_probe",
1329 EffectRow {
1330 reads: vec![wm_core::Resource::Filesystem],
1331 writes: vec![wm_core::Resource::CoordinationLease],
1332 ..Default::default()
1333 },
1334 );
1335 let governance = pipeline
1336 .dispatch(&tool, &mut ctx, Args::default())
1337 .await
1338 .expect_err("strict mode must refuse coordination lease acquisition");
1339 let text = governance.to_string();
1340 assert!(text.contains("VIOLATION_AHIMSA"), "{text}");
1341
1342 let pipeline = DispatchPipeline::with_defaults();
1346 let mut ctx = Context::new(BrainWave::Gamma);
1347 ctx.self_model_confidence = 0.3;
1348 let write_tool = TestTool::new(
1349 "stress_probe",
1350 EffectRow {
1351 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1352 ..Default::default()
1353 },
1354 );
1355 let starvation = pipeline
1356 .dispatch(&write_tool, &mut ctx, Args::default())
1357 .await
1358 .expect_err("low confidence must refuse writes");
1359 let text = starvation.to_string();
1360 assert!(text.contains("self-model confidence"), "{text}");
1361 assert!(text.contains("WM_HOMEOSTASIS_FROZEN"), "{text}");
1362 assert!(
1363 !text.contains("VIOLATION_AHIMSA"),
1364 "refusal classes must be distinguishable: {text}"
1365 );
1366
1367 let read_tool = TestTool::new(
1368 "stress_probe_read",
1369 EffectRow {
1370 reads: vec![wm_core::Resource::Galaxy("codex".into())],
1371 ..Default::default()
1372 },
1373 );
1374 assert!(
1375 pipeline
1376 .dispatch(&read_tool, &mut ctx, Args::default())
1377 .await
1378 .is_ok(),
1379 "starvation must not block reads"
1380 );
1381 }
1382
1383 #[tokio::test]
1384 async fn pipeline_dharma_confirm_passes_brain_wave_strict_for_destructive() {
1385 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(Arc::new(
1389 ResourceRules::new(ResourceRulesConfig {
1390 require_human_review: false,
1391 ..Default::default()
1392 }),
1393 ));
1394 let mut ctx = Context::new(BrainWave::Theta);
1395 let tool = TestTool::new(
1396 "destructive_tool",
1397 EffectRow {
1398 writes: vec![wm_core::Resource::Filesystem],
1399 ..Default::default()
1400 },
1401 );
1402 let result = pipeline
1403 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
1404 .await;
1405 assert!(
1406 result.is_ok(),
1407 "confirmed destructive dispatch must pass brain-wave strict: {result:?}"
1408 );
1409 }
1410
1411 #[tokio::test]
1412 async fn pipeline_capability_gate_strict_blocks_uncredentialed() {
1413 let pipeline =
1414 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1415 let mut ctx = Context::new(BrainWave::Gamma);
1416 let tool = TestTool::new(
1417 "capability_tool",
1418 EffectRow {
1419 invokes: vec![wm_core::Capability::MemoryWrite],
1420 ..Default::default()
1421 },
1422 );
1423
1424 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1425 match result {
1426 Err(CoreError::Governance(msg)) => {
1427 assert!(msg.contains("capability gate"), "{msg}");
1428 assert!(msg.contains("memory:write"), "{msg}");
1429 }
1430 other => panic!("Expected capability refusal, got {other:?}"),
1431 }
1432 }
1433
1434 #[tokio::test]
1435 async fn pipeline_capability_gate_strict_allows_valid_token() {
1436 let pipeline =
1437 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1438 let mut ctx = Context::new(BrainWave::Gamma);
1439 let tool = TestTool::new(
1440 "capability_tool_ok",
1441 EffectRow {
1442 invokes: vec![wm_core::Capability::MemoryWrite],
1443 ..Default::default()
1444 },
1445 );
1446
1447 let mut issuer = wm_governance::engagement_tokens::EngagementIssuer::with_keypair(
1448 wm_governance::network_profile::AgentKeypair::from_seed([7u8; 32]),
1449 );
1450 let issuer_key = issuer.signer_public_key_hex();
1451 let token = issuer.issue(
1452 "tester",
1453 wm_governance::engagement_tokens::EngagementScope::Poc,
1454 "rules-hash",
1455 Some(3600),
1456 );
1457 let args = serde_json::json!({
1458 "_engagement": { "token": token, "issuer_public_key": issuer_key }
1459 });
1460
1461 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
1462 assert!(result.is_ok(), "valid Poc token should pass: {result:?}");
1463 }
1464
1465 #[tokio::test]
1466 async fn pipeline_capability_gate_advisory_allows_uncredentialed() {
1467 let pipeline =
1468 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Advisory);
1469 let mut ctx = Context::new(BrainWave::Gamma);
1470 let tool = TestTool::new(
1471 "capability_tool_advisory",
1472 EffectRow {
1473 invokes: vec![wm_core::Capability::MemoryWrite],
1474 ..Default::default()
1475 },
1476 );
1477
1478 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1479 assert!(result.is_ok(), "advisory mode must not block: {result:?}");
1480 }
1481
1482 #[tokio::test]
1483 async fn pipeline_rate_limit_blocks_excess() {
1484 let rate_limiter = Arc::new(RateLimiter::new(1000, 2, 0));
1485 let pipeline = DispatchPipeline::new(
1486 rate_limiter,
1487 Arc::new(CircuitBreakerRegistry::default()),
1488 Arc::new(DharmaGate::default()),
1489 None,
1490 );
1491
1492 let mut ctx = Context::new(BrainWave::Gamma);
1493 let tool = TestTool::new("limited_tool", EffectRow::pure());
1494
1495 assert!(
1496 pipeline
1497 .dispatch(&tool, &mut ctx, Args::default())
1498 .await
1499 .is_ok()
1500 );
1501 assert!(
1502 pipeline
1503 .dispatch(&tool, &mut ctx, Args::default())
1504 .await
1505 .is_ok()
1506 );
1507 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1508 assert!(result.is_err());
1509 match result {
1510 Err(CoreError::RateLimited(_)) => {}
1511 other => panic!("Expected RateLimited error, got {other:?}"),
1512 }
1513 }
1514
1515 #[tokio::test]
1516 async fn pipeline_circuit_breaker_opens_on_repeated_failures() {
1517 let breakers = Arc::new(CircuitBreakerRegistry::new(
1518 crate::circuit_breaker::BreakerConfig {
1519 failure_threshold: 3,
1520 window: std::time::Duration::from_secs(10),
1521 cooldown: std::time::Duration::from_secs(30),
1522 },
1523 ));
1524 let pipeline = DispatchPipeline::new(
1525 Arc::new(RateLimiter::new(10000, 100, 100)),
1526 breakers.clone(),
1527 Arc::new(DharmaGate::default()),
1528 None,
1529 );
1530
1531 let mut ctx = Context::new(BrainWave::Gamma);
1532 let tool = TestTool::failing("flaky_tool");
1533
1534 for _ in 0..3 {
1535 let _ = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1536 }
1537
1538 assert_eq!(
1539 breakers.state("flaky_tool"),
1540 crate::circuit_breaker::BreakerState::Open
1541 );
1542
1543 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1544 assert!(result.is_err());
1545 match result {
1546 Err(CoreError::CircuitBreaker(_)) => {}
1547 other => panic!("Expected CircuitBreaker error, got {other:?}"),
1548 }
1549 }
1550
1551 #[tokio::test]
1554 async fn client_validation_errors_do_not_trip_the_breaker() {
1555 let breakers = Arc::new(CircuitBreakerRegistry::new(
1556 crate::circuit_breaker::BreakerConfig {
1557 failure_threshold: 3,
1558 window: std::time::Duration::from_secs(10),
1559 cooldown: std::time::Duration::from_secs(30),
1560 },
1561 ));
1562 let pipeline = DispatchPipeline::new(
1563 Arc::new(RateLimiter::new(10000, 100, 100)),
1564 breakers.clone(),
1565 Arc::new(DharmaGate::default()),
1566 None,
1567 );
1568 let mut ctx = Context::new(BrainWave::Gamma);
1569
1570 let bad = TestTool::returning_error("validated_tool", || {
1573 CoreError::InvalidArgs("unknown galaxy".into())
1574 });
1575 for _ in 0..5 {
1576 let err = pipeline
1577 .dispatch(&bad, &mut ctx, Args::default())
1578 .await
1579 .unwrap_err();
1580 assert!(matches!(err, CoreError::InvalidArgs(_)));
1581 }
1582 assert_eq!(
1583 breakers.state("validated_tool"),
1584 crate::circuit_breaker::BreakerState::Closed,
1585 "caller errors must not open the breaker"
1586 );
1587
1588 let governed = TestTool::returning_error("validated_tool", || {
1590 CoreError::Governance("budget exceeded for writes".into())
1591 });
1592 for _ in 0..5 {
1593 let _ = pipeline
1594 .dispatch(&governed, &mut ctx, Args::default())
1595 .await;
1596 }
1597 assert_eq!(
1598 breakers.state("validated_tool"),
1599 crate::circuit_breaker::BreakerState::Closed,
1600 "governance refusals must not open the breaker"
1601 );
1602
1603 let good = TestTool::new("validated_tool", EffectRow::pure());
1605 pipeline
1606 .dispatch(&good, &mut ctx, Args::default())
1607 .await
1608 .expect("valid call after caller errors");
1609 }
1610
1611 #[tokio::test]
1612 async fn rate_limit_error_names_its_governor() {
1613 let pipeline = DispatchPipeline::new(
1614 Arc::new(RateLimiter::new(1000, 1, 0)),
1615 Arc::new(CircuitBreakerRegistry::default()),
1616 Arc::new(DharmaGate::default()),
1617 None,
1618 );
1619 let mut ctx = Context::new(BrainWave::Gamma);
1620 let tool = TestTool::new("bursty_tool", EffectRow::pure());
1621 pipeline
1622 .dispatch(&tool, &mut ctx, Args::default())
1623 .await
1624 .unwrap();
1625 let err = pipeline
1626 .dispatch(&tool, &mut ctx, Args::default())
1627 .await
1628 .unwrap_err();
1629 let text = err.to_string();
1630 assert!(
1631 text.contains("request rate limit") && text.contains("retry after"),
1632 "rate limit must name its category and retry hint: {text}"
1633 );
1634 }
1635
1636 #[tokio::test]
1637 async fn pipeline_karma_ledger_records() {
1638 let tmp = tempfile::tempdir().unwrap();
1639 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1640 let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1641
1642 let pipeline = DispatchPipeline::new(
1643 Arc::new(RateLimiter::default()),
1644 Arc::new(CircuitBreakerRegistry::default()),
1645 Arc::new(DharmaGate::default()),
1646 Some(ledger.clone()),
1647 );
1648
1649 let mut ctx = Context::new(BrainWave::Gamma);
1650 let tool = TestTool::new("karma_test_tool", EffectRow::pure());
1651
1652 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1653 assert!(result.is_ok());
1654 assert_eq!(ledger.next_id(), 1);
1655 assert_eq!(ctx.karma_debt, 0.0);
1656 }
1657
1658 #[tokio::test]
1659 async fn pipeline_karma_debt_updates_context() {
1660 let tmp = tempfile::tempdir().unwrap();
1661 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1662 let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1663
1664 let pipeline = DispatchPipeline::new(
1665 Arc::new(RateLimiter::default()),
1666 Arc::new(CircuitBreakerRegistry::default()),
1667 Arc::new(DharmaGate::default()),
1668 Some(ledger),
1669 );
1670
1671 let mut ctx = Context::new(BrainWave::Gamma);
1672 let tool = TestTool::new(
1673 "wasteful_tool",
1674 EffectRow {
1675 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1676 ..Default::default()
1677 },
1678 );
1679
1680 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1681 assert!(result.is_ok());
1682 assert!(
1683 (ctx.karma_debt - 0.2).abs() < 0.001,
1684 "Context karma_debt should be 0.2, got {}",
1685 ctx.karma_debt
1686 );
1687 }
1688
1689 #[tokio::test]
1690 async fn pipeline_karma_batched_e2e() {
1691 let tmp = tempfile::tempdir().unwrap();
1694 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1695 let ledger = Arc::new(KarmaLedger::with_flush_threshold(store.clone(), 100).unwrap());
1696
1697 let pipeline = DispatchPipeline::new(
1698 Arc::new(RateLimiter::default()),
1699 Arc::new(CircuitBreakerRegistry::default()),
1700 Arc::new(DharmaGate::default()),
1701 Some(ledger.clone()),
1702 );
1703
1704 let mut ctx = Context::new(BrainWave::Gamma);
1705
1706 let honest_tool = TestTool::new("honest_tool", EffectRow::pure());
1708 let wasteful_tool = TestTool::new(
1709 "wasteful_tool",
1710 EffectRow {
1711 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1712 ..Default::default()
1713 },
1714 );
1715
1716 for _ in 0..10 {
1717 let result = pipeline
1718 .dispatch(&honest_tool, &mut ctx, Args::default())
1719 .await;
1720 assert!(result.is_ok());
1721 }
1722 for _ in 0..10 {
1723 let result = pipeline
1724 .dispatch(&wasteful_tool, &mut ctx, Args::default())
1725 .await;
1726 assert!(result.is_ok());
1727 }
1728
1729 assert_eq!(ledger.next_id(), 20);
1731 assert_eq!(
1732 ledger.pending_count(),
1733 20,
1734 "All 20 entries should be pending before flush"
1735 );
1736
1737 let debt = ledger.total_debt();
1739 assert!(
1740 (debt - 2.0).abs() < 0.001,
1741 "Total debt should be 2.0 (10 x 0.2), got {debt}"
1742 );
1743
1744 ledger.flush().unwrap();
1746 assert_eq!(ledger.pending_count(), 0);
1747
1748 let result = ledger.verify_integrity().unwrap();
1750 assert!(
1751 result.valid,
1752 "Chain should be valid after batched flush: {:?}",
1753 result.violation
1754 );
1755 assert_eq!(result.entries_verified, 20);
1756
1757 let ledger2 = KarmaLedger::new(store).unwrap();
1759 assert_eq!(
1760 ledger2.next_id(),
1761 20,
1762 "Next ID should persist across instances"
1763 );
1764 let entries = ledger2.scan_entries().unwrap();
1765 assert_eq!(
1766 entries.len(),
1767 20,
1768 "All 20 entries should be persisted in LMDB"
1769 );
1770
1771 let debt2 = ledger2.total_debt();
1773 assert!(
1774 (debt2 - 2.0).abs() < 0.001,
1775 "Total debt should persist as 2.0, got {debt2}"
1776 );
1777
1778 let result2 = ledger2.verify_integrity().unwrap();
1780 assert!(result2.valid, "Chain should be valid on reloaded ledger");
1781 assert_eq!(result2.entries_verified, 20);
1782 }
1783
1784 #[tokio::test]
1785 async fn pipeline_coherence_gate_blocks_writes() {
1786 let pipeline = DispatchPipeline::with_defaults();
1787 let mut ctx = Context::new(BrainWave::Gamma);
1788 ctx.citta_coherence = 0.1; let tool = TestTool::new(
1790 "write_tool",
1791 EffectRow {
1792 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1793 ..Default::default()
1794 },
1795 );
1796
1797 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1798 assert!(result.is_err());
1799 match result {
1800 Err(CoreError::Governance(msg)) => {
1801 assert!(msg.contains("coherence"));
1802 }
1803 other => panic!("Expected Governance error, got {other:?}"),
1804 }
1805 }
1806
1807 #[tokio::test]
1808 async fn pipeline_coherence_gate_allows_reads() {
1809 let pipeline = DispatchPipeline::with_defaults();
1810 let mut ctx = Context::new(BrainWave::Gamma);
1811 ctx.citta_coherence = 0.1; let tool = TestTool::new("read_tool", EffectRow::pure());
1813
1814 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1815 assert!(result.is_ok());
1816 }
1817
1818 #[tokio::test]
1819 async fn pipeline_coherence_gate_allows_writes_when_coherent() {
1820 let pipeline = DispatchPipeline::with_defaults();
1821 let mut ctx = Context::new(BrainWave::Gamma);
1822 ctx.citta_coherence = 0.5; let tool = TestTool::new(
1824 "write_tool",
1825 EffectRow {
1826 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1827 ..Default::default()
1828 },
1829 );
1830
1831 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1832 assert!(result.is_ok());
1833 }
1834
1835 #[tokio::test]
1836 async fn pipeline_low_confidence_blocks_writes() {
1837 let pipeline = DispatchPipeline::with_defaults();
1838 let mut ctx = Context::new(BrainWave::Gamma);
1839 ctx.self_model_confidence = 0.3; let tool = TestTool::new(
1841 "write_tool",
1842 EffectRow {
1843 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1844 ..Default::default()
1845 },
1846 );
1847
1848 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1849 assert!(result.is_err());
1850 match result {
1851 Err(CoreError::Governance(msg)) => {
1852 assert!(msg.contains("confidence"));
1853 assert!(msg.contains("conservative"));
1854 }
1855 other => panic!("Expected Governance error, got {other:?}"),
1856 }
1857 }
1858
1859 #[tokio::test]
1860 async fn pipeline_low_confidence_allows_reads() {
1861 let pipeline = DispatchPipeline::with_defaults();
1862 let mut ctx = Context::new(BrainWave::Gamma);
1863 ctx.self_model_confidence = 0.3; let tool = TestTool::new("read_tool", EffectRow::pure());
1865
1866 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1867 assert!(result.is_ok());
1868 }
1869
1870 #[tokio::test]
1871 async fn pipeline_high_confidence_allows_writes() {
1872 let pipeline = DispatchPipeline::with_defaults();
1873 let mut ctx = Context::new(BrainWave::Gamma);
1874 ctx.self_model_confidence = 0.8; let tool = TestTool::new(
1876 "write_tool",
1877 EffectRow {
1878 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1879 ..Default::default()
1880 },
1881 );
1882
1883 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1884 assert!(result.is_ok());
1885 }
1886
1887 #[tokio::test]
1888 async fn pipeline_high_caution_warns_on_writes() {
1889 let pipeline = DispatchPipeline::with_defaults();
1890 let mut ctx = Context::new(BrainWave::Gamma);
1891 ctx.drive_caution = 0.9; let tool = TestTool::new(
1893 "write_tool",
1894 EffectRow {
1895 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1896 ..Default::default()
1897 },
1898 );
1899
1900 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1902 assert!(result.is_ok());
1903 }
1904
1905 #[tokio::test]
1906 async fn pipeline_low_energy_warns_on_writes() {
1907 let pipeline = DispatchPipeline::with_defaults();
1908 let mut ctx = Context::new(BrainWave::Gamma);
1909 ctx.drive_energy = 0.1; let tool = TestTool::new(
1911 "write_tool",
1912 EffectRow {
1913 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1914 ..Default::default()
1915 },
1916 );
1917
1918 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1920 assert!(result.is_ok());
1921 }
1922
1923 #[tokio::test]
1924 async fn pipeline_drive_gates_dont_affect_reads() {
1925 let pipeline = DispatchPipeline::with_defaults();
1926 let mut ctx = Context::new(BrainWave::Gamma);
1927 ctx.drive_caution = 0.95;
1928 ctx.drive_energy = 0.05;
1929 let tool = TestTool::new("read_tool", EffectRow::pure());
1930
1931 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1932 assert!(result.is_ok());
1933 }
1934
1935 #[tokio::test]
1936 async fn pipeline_destructive_blocked_without_confirm() {
1937 let pipeline = DispatchPipeline::with_defaults();
1938 let mut ctx = Context::new(BrainWave::Gamma);
1939 let tool = TestTool::new(
1940 "destructive_tool",
1941 EffectRow {
1942 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1943 destructive: true,
1944 ..Default::default()
1945 },
1946 );
1947
1948 let result = pipeline
1949 .dispatch(&tool, &mut ctx, serde_json::json!({}))
1950 .await;
1951 assert!(result.is_err());
1952 match result {
1953 Err(CoreError::Governance(msg)) => {
1954 assert!(msg.contains("destructive"));
1955 assert!(msg.contains("confirm"));
1956 }
1957 other => panic!("Expected Governance error, got {other:?}"),
1958 }
1959 }
1960
1961 #[tokio::test]
1962 async fn pipeline_destructive_allowed_with_confirm() {
1963 let pipeline = DispatchPipeline::with_defaults();
1964 let mut ctx = Context::new(BrainWave::Gamma);
1965 let tool = TestTool::new(
1966 "destructive_tool",
1967 EffectRow {
1968 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1969 destructive: true,
1970 ..Default::default()
1971 },
1972 );
1973
1974 let result = pipeline
1975 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
1976 .await;
1977 assert!(result.is_ok());
1978 }
1979
1980 #[tokio::test]
1981 async fn pipeline_destructive_blocked_with_false_confirm() {
1982 let pipeline = DispatchPipeline::with_defaults();
1983 let mut ctx = Context::new(BrainWave::Gamma);
1984 let tool = TestTool::new(
1985 "destructive_tool",
1986 EffectRow {
1987 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1988 destructive: true,
1989 ..Default::default()
1990 },
1991 );
1992
1993 let result = pipeline
1994 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": false}))
1995 .await;
1996 assert!(result.is_err());
1997 }
1998
1999 #[tokio::test]
2000 async fn pipeline_compartment_no_restriction_allows_all() {
2001 let pipeline = DispatchPipeline::with_defaults();
2002 let mut ctx = Context::new(BrainWave::Gamma);
2003 let tool = TestTool::new(
2005 "write_tool",
2006 EffectRow {
2007 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2008 ..Default::default()
2009 },
2010 );
2011
2012 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2013 assert!(result.is_ok());
2014 }
2015
2016 #[tokio::test]
2017 async fn pipeline_compartment_sandbox_blocks_write_to_codex() {
2018 let pipeline = DispatchPipeline::with_defaults();
2019 let mut ctx = Context::new(BrainWave::Gamma);
2020 ctx.compartment = Some("sandbox".into());
2021 let tool = TestTool::new(
2022 "write_tool",
2023 EffectRow {
2024 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2025 ..Default::default()
2026 },
2027 );
2028
2029 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2030 assert!(result.is_err());
2031 match result {
2032 Err(CoreError::Governance(msg)) => {
2033 assert!(msg.contains("sandbox"));
2034 assert!(msg.contains("codex"));
2035 }
2036 other => panic!("Expected Governance error, got {other:?}"),
2037 }
2038 }
2039
2040 #[tokio::test]
2041 async fn pipeline_asserted_user_id_confers_no_authority() {
2042 let pipeline = DispatchPipeline::with_defaults();
2047 let mut ctx = Context::new(BrainWave::Gamma);
2048 ctx.compartment = Some("sandbox".into());
2049 ctx.user_id = Some("ceo".into());
2050 let tool = TestTool::new(
2051 "write_tool",
2052 EffectRow {
2053 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2054 ..Default::default()
2055 },
2056 );
2057
2058 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2059 assert!(result.is_err());
2060 match result {
2061 Err(CoreError::Governance(msg)) => {
2062 assert!(msg.contains("sandbox"));
2063 assert!(msg.contains("codex"));
2064 }
2065 other => panic!("Expected Governance error, got {other:?}"),
2066 }
2067 }
2068
2069 #[tokio::test]
2070 async fn pipeline_routes_store_scoped_tools_through_executor() {
2071 use crate::sandbox_exec::ScopedSandboxExecutor;
2074 use std::sync::atomic::{AtomicU64, Ordering};
2075 let calls = Arc::new(AtomicU64::new(0));
2076 let counter = Arc::clone(&calls);
2077 let executor = Arc::new(ScopedSandboxExecutor::new(move || {
2078 counter.fetch_add(1, Ordering::SeqCst);
2079 Ok(())
2080 }));
2081 let pipeline =
2082 DispatchPipeline::with_defaults().with_sandbox_executor(Some(Arc::clone(&executor)));
2083 let mut ctx = Context::new(BrainWave::Gamma);
2084
2085 let scoped = TestTool::new(
2086 "scoped_tool",
2087 EffectRow {
2088 sandbox: Sandbox::StoreScoped,
2089 ..Default::default()
2090 },
2091 );
2092 assert!(
2093 pipeline
2094 .dispatch(&scoped, &mut ctx, Args::default())
2095 .await
2096 .is_ok()
2097 );
2098 assert_eq!(calls.load(Ordering::SeqCst), 1, "scoped tool must confine");
2099
2100 let plain = TestTool::new("plain_tool", EffectRow::pure());
2101 assert!(
2102 pipeline
2103 .dispatch(&plain, &mut ctx, Args::default())
2104 .await
2105 .is_ok()
2106 );
2107 assert_eq!(
2108 calls.load(Ordering::SeqCst),
2109 1,
2110 "plain tools must not ride the sandbox path"
2111 );
2112 assert_eq!(executor.stats(), (1, 0, 0));
2113
2114 let bare = DispatchPipeline::with_defaults();
2116 let scoped2 = TestTool::new(
2117 "scoped_tool",
2118 EffectRow {
2119 sandbox: Sandbox::StoreScoped,
2120 ..Default::default()
2121 },
2122 );
2123 assert!(
2124 bare.dispatch(&scoped2, &mut ctx, Args::default())
2125 .await
2126 .is_ok()
2127 );
2128 }
2129
2130 #[tokio::test]
2131 async fn pipeline_injects_subprocess_policy_and_discloses() {
2132 use crate::subprocess_sandbox::SubprocessSandbox;
2136 use std::path::PathBuf;
2137 use wm_core::sandbox::RunnerSource;
2138 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
2139 wm_core::sandbox::RunnerInfo {
2140 path: PathBuf::from("/opt/mandala-sandbox"),
2141 source: RunnerSource::Env,
2142 },
2143 )));
2144 let pipeline =
2145 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2146 let mut ctx = Context::new(BrainWave::Gamma);
2147 let tool = TestTool::new(
2148 "spawn_tool",
2149 EffectRow {
2150 reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
2151 spawns: true,
2152 sandbox: Sandbox::Subprocess,
2153 ..Default::default()
2154 },
2155 )
2156 .with_output(serde_json::json!({"ok": true}));
2157
2158 let out = pipeline
2159 .dispatch(&tool, &mut ctx, Args::default())
2160 .await
2161 .expect("declared spawn tool dispatches");
2162 assert!(ctx.spawn.is_active(), "policy must ride the context");
2163 assert!(ctx.spawn.allow_net(), "network read grants the runner net");
2164 assert_eq!(out["sandbox"]["runner"], "/opt/mandala-sandbox");
2165 assert_eq!(out["sandbox"]["net"], true);
2166 assert_eq!(
2167 out["sandbox"]["envelope"],
2168 wm_core::sandbox::ENVELOPE_SCHEMA
2169 );
2170 assert_eq!(sandbox.status()["dispatches"], 1);
2171 assert_eq!(sandbox.status()["degraded"], 0);
2172 }
2173
2174 #[tokio::test]
2175 async fn pipeline_degrades_loudly_when_runner_missing() {
2176 use crate::subprocess_sandbox::SubprocessSandbox;
2179 let sandbox = Arc::new(SubprocessSandbox::with_runner(None));
2180 let pipeline =
2181 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2182 let mut ctx = Context::new(BrainWave::Gamma);
2183 let tool = TestTool::new(
2184 "spawn_tool",
2185 EffectRow {
2186 reads: vec![wm_core::Resource::Process],
2187 spawns: true,
2188 sandbox: Sandbox::Subprocess,
2189 ..Default::default()
2190 },
2191 )
2192 .with_output(serde_json::json!({"ok": true}));
2193
2194 let out = pipeline
2195 .dispatch(&tool, &mut ctx, Args::default())
2196 .await
2197 .expect("degrade keeps availability up");
2198 assert!(!ctx.spawn.is_active());
2199 assert!(
2200 out.get("sandbox").is_none(),
2201 "no runner means no confinement claim"
2202 );
2203 assert_eq!(sandbox.status()["dispatches"], 1);
2204 assert_eq!(sandbox.status()["degraded"], 1);
2205 }
2206
2207 #[tokio::test]
2208 async fn pipeline_surfaces_unmigrated_spawn_tools() {
2209 use crate::subprocess_sandbox::SubprocessSandbox;
2213 use std::path::PathBuf;
2214 use wm_core::sandbox::RunnerSource;
2215 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
2216 wm_core::sandbox::RunnerInfo {
2217 path: PathBuf::from("/opt/mandala-sandbox"),
2218 source: RunnerSource::Env,
2219 },
2220 )));
2221 let pipeline =
2222 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2223 let mut ctx = Context::new(BrainWave::Gamma);
2224 let tool = TestTool::new(
2225 "legacy_git_tool",
2226 EffectRow {
2227 reads: vec![wm_core::Resource::Process],
2228 spawns: true,
2229 ..Default::default()
2230 },
2231 );
2232
2233 assert!(
2234 pipeline
2235 .dispatch(&tool, &mut ctx, Args::default())
2236 .await
2237 .is_ok()
2238 );
2239 assert!(!ctx.spawn.is_active());
2240 assert_eq!(sandbox.status()["unconfined_spawns"], 1);
2241 assert_eq!(sandbox.status()["dispatches"], 0);
2242 }
2243
2244 #[cfg(unix)]
2245 #[tokio::test]
2246 async fn declared_spawn_executes_through_the_runner_envelope() {
2247 use crate::subprocess_sandbox::SubprocessSandbox;
2251 use std::os::unix::fs::PermissionsExt;
2252 use wm_core::sandbox::{RunnerInfo, RunnerSource};
2253
2254 let dir = tempfile::tempdir().expect("tempdir");
2255 let marker = dir.path().join("envelope.json");
2256 let runner = dir.path().join("fake-runner");
2257 std::fs::write(
2258 &runner,
2259 format!(
2260 "#!/bin/sh\nprintf '%s' \"$2\" > '{}'\nexit 0\n",
2261 marker.display()
2262 ),
2263 )
2264 .expect("write fake runner");
2265 std::fs::set_permissions(&runner, std::fs::Permissions::from_mode(0o755))
2266 .expect("chmod fake runner");
2267
2268 struct SpawnProbeTool {
2269 effects: EffectRow,
2270 stats: ToolStats,
2271 }
2272 #[async_trait]
2273 impl Tool for SpawnProbeTool {
2274 fn name(&self) -> &str {
2275 "spawn_probe"
2276 }
2277 fn gana(&self) -> Gana {
2278 Gana::Heart
2279 }
2280 fn effects(&self) -> &EffectRow {
2281 &self.effects
2282 }
2283 async fn call(&self, ctx: &mut Context, _args: Args) -> Result<Output> {
2284 let out = ctx
2285 .spawn
2286 .command("printf", &["%s", "hi"])
2287 .output()
2288 .map_err(|e| CoreError::Tool(format!("spawn failed: {e}")))?;
2289 if !out.status.success() {
2290 return Err(CoreError::Tool("wrapped command failed".into()));
2291 }
2292 Ok(serde_json::json!({"ok": true}))
2293 }
2294 fn stats(&self) -> &ToolStats {
2295 &self.stats
2296 }
2297 }
2298
2299 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(RunnerInfo {
2300 path: runner,
2301 source: RunnerSource::Env,
2302 })));
2303 let pipeline =
2304 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2305 let mut ctx = Context::new(BrainWave::Gamma);
2306 let tool = SpawnProbeTool {
2307 effects: EffectRow {
2308 reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
2309 spawns: true,
2310 sandbox: Sandbox::Subprocess,
2311 ..Default::default()
2312 },
2313 stats: ToolStats::default(),
2314 };
2315 let out = pipeline
2316 .dispatch(&tool, &mut ctx, Args::default())
2317 .await
2318 .expect("wrapped spawn succeeds");
2319 assert_eq!(out["ok"], true);
2320 assert_eq!(out["sandbox"]["net"], true);
2321
2322 let captured = std::fs::read_to_string(&marker).expect("runner captured the envelope");
2323 let envelope: serde_json::Value = serde_json::from_str(&captured).expect("envelope JSON");
2324 assert_eq!(envelope["schema"], wm_core::sandbox::ENVELOPE_SCHEMA);
2325 assert_eq!(envelope["program"], "printf");
2326 assert_eq!(envelope["args"], serde_json::json!(["%s", "hi"]));
2327 assert_eq!(envelope["net"], true);
2328 }
2329
2330 #[tokio::test]
2331 async fn pipeline_secret_scan_warns_without_blocking() {
2332 use crate::secret_scan::SecretSampler;
2336 let sampler = Arc::new(SecretSampler::new(1));
2337 let pipeline =
2338 DispatchPipeline::with_defaults().with_secret_scan_option(Some(Arc::clone(&sampler)));
2339 let mut ctx = Context::new(BrainWave::Gamma);
2340 let tool = TestTool::new("key_tool", EffectRow::pure())
2341 .with_output(serde_json::json!({"data": "key=AKIAIOSFODNN7EXAMPLE"}));
2342 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2343 assert!(result.is_ok(), "warn-only scan must never block");
2344 assert_eq!(sampler.stats(), (1, 1, 1));
2345
2346 let clean = TestTool::new("clean_tool", EffectRow::pure())
2348 .with_output(serde_json::json!({"results": []}));
2349 assert!(
2350 pipeline
2351 .dispatch(&clean, &mut ctx, Args::default())
2352 .await
2353 .is_ok()
2354 );
2355 assert_eq!(sampler.stats(), (2, 2, 1));
2356 }
2357
2358 #[tokio::test]
2359 async fn pipeline_compartment_sandbox_blocks_read_from_karma() {
2360 let pipeline = DispatchPipeline::with_defaults();
2361 let mut ctx = Context::new(BrainWave::Gamma);
2362 ctx.compartment = Some("sandbox".into());
2363 let tool = TestTool::new(
2364 "read_tool",
2365 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2366 );
2367
2368 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2369 assert!(result.is_err());
2370 match result {
2371 Err(CoreError::Governance(msg)) => {
2372 assert!(msg.contains("sandbox"));
2373 assert!(msg.contains("karma"));
2374 }
2375 other => panic!("Expected Governance error, got {other:?}"),
2376 }
2377 }
2378
2379 #[tokio::test]
2380 async fn pipeline_compartment_sandbox_allows_write_to_tutorial() {
2381 let pipeline = DispatchPipeline::with_defaults();
2382 let mut ctx = Context::new(BrainWave::Gamma);
2383 ctx.compartment = Some("sandbox".into());
2384 let tool = TestTool::new(
2385 "write_tool",
2386 EffectRow {
2387 writes: vec![wm_core::Resource::Galaxy("tutorial".into())],
2388 ..Default::default()
2389 },
2390 );
2391
2392 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2393 assert!(result.is_ok());
2394 }
2395
2396 #[tokio::test]
2397 async fn pipeline_compartment_sandbox_allows_read_from_research() {
2398 let pipeline = DispatchPipeline::with_defaults();
2399 let mut ctx = Context::new(BrainWave::Gamma);
2400 ctx.compartment = Some("sandbox".into());
2401 let tool = TestTool::new(
2402 "read_tool",
2403 EffectRow::read_only(vec![wm_core::Resource::Galaxy("research".into())]),
2404 );
2405
2406 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2407 assert!(result.is_ok());
2408 }
2409
2410 #[tokio::test]
2411 async fn pipeline_compartment_production_blocks_read_from_karma() {
2412 let pipeline = DispatchPipeline::with_defaults();
2413 let mut ctx = Context::new(BrainWave::Gamma);
2414 ctx.compartment = Some("production".into());
2415 let tool = TestTool::new(
2416 "read_tool",
2417 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2418 );
2419
2420 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2421 assert!(result.is_err());
2422 match result {
2423 Err(CoreError::Governance(msg)) => {
2424 assert!(msg.contains("production"));
2425 assert!(msg.contains("karma"));
2426 }
2427 other => panic!("Expected Governance error, got {other:?}"),
2428 }
2429 }
2430
2431 #[tokio::test]
2432 async fn pipeline_compartment_production_allows_write_to_codex() {
2433 let pipeline = DispatchPipeline::with_defaults();
2434 let mut ctx = Context::new(BrainWave::Gamma);
2435 ctx.compartment = Some("production".into());
2436 let tool = TestTool::new(
2437 "write_tool",
2438 EffectRow {
2439 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2440 ..Default::default()
2441 },
2442 );
2443
2444 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2445 assert!(result.is_ok());
2446 }
2447
2448 #[tokio::test]
2449 async fn pipeline_compartment_secure_allows_write_to_codex() {
2450 let pipeline = DispatchPipeline::with_defaults();
2451 let mut ctx = Context::new(BrainWave::Gamma);
2452 ctx.compartment = Some("secure".into());
2453 let tool = TestTool::new(
2454 "write_tool",
2455 EffectRow {
2456 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2457 ..Default::default()
2458 },
2459 );
2460
2461 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2462 assert!(result.is_ok());
2463 }
2464
2465 #[tokio::test]
2466 async fn pipeline_compartment_secure_blocks_read_from_karma() {
2467 let pipeline = DispatchPipeline::with_defaults();
2468 let mut ctx = Context::new(BrainWave::Gamma);
2469 ctx.compartment = Some("secure".into());
2470 let tool = TestTool::new(
2471 "read_tool",
2472 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2473 );
2474
2475 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2476 assert!(result.is_err());
2477 match result {
2478 Err(CoreError::Governance(msg)) => {
2479 assert!(msg.contains("secure"));
2480 assert!(msg.contains("karma"));
2481 }
2482 other => panic!("Expected Governance error, got {other:?}"),
2483 }
2484 }
2485
2486 fn rules_with(max_writes: u32, max_repeats: u32) -> Arc<ResourceRules> {
2489 Arc::new(ResourceRules::new(ResourceRulesConfig {
2490 max_writes_per_minute: max_writes,
2491 max_spawns_per_minute: 100,
2492 max_network_per_minute: 100,
2493 novelty_window: 50,
2494 max_repeats,
2495 require_human_review: false,
2496 }))
2497 }
2498
2499 #[tokio::test]
2500 async fn pipeline_resource_rules_budget_exceeding_write_refused() {
2501 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(2, 1000));
2502 let mut ctx = Context::new(BrainWave::Gamma);
2503 let tool = TestTool::new(
2504 "write_tool",
2505 EffectRow {
2506 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2507 ..Default::default()
2508 },
2509 );
2510
2511 assert!(
2512 pipeline
2513 .dispatch(&tool, &mut ctx, Args::default())
2514 .await
2515 .is_ok(),
2516 "first write within budget"
2517 );
2518 assert!(
2519 pipeline
2520 .dispatch(&tool, &mut ctx, Args::default())
2521 .await
2522 .is_ok(),
2523 "second write within budget"
2524 );
2525 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2526 assert!(result.is_err(), "third write must exceed the budget");
2527 match result {
2528 Err(CoreError::Governance(msg)) => {
2529 assert!(msg.contains("resource rules"), "got: {msg}");
2530 assert!(msg.contains("writes"), "got: {msg}");
2531 }
2532 other => panic!("Expected Governance error, got {other:?}"),
2533 }
2534 }
2535
2536 #[tokio::test]
2537 async fn pipeline_resource_rules_novelty_flag_reaches_response() {
2538 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(1000, 1));
2539 let mut ctx = Context::new(BrainWave::Gamma);
2540 let tool = TestTool::new("read_tool", EffectRow::pure())
2541 .with_output(serde_json::json!({"status": "ok"}));
2542
2543 let first = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2544 assert!(first.is_ok());
2545 assert!(
2546 first.unwrap().get("resource_flags").is_none(),
2547 "first call is novel — no flag"
2548 );
2549
2550 let second = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2551 let output = second.expect("repeated call must still succeed (flag, not block)");
2552 let flags = output
2553 .get("resource_flags")
2554 .and_then(|f| f.as_array())
2555 .expect("novelty flag must reach the response");
2556 assert_eq!(flags.len(), 1);
2557 assert!(flags[0].as_str().unwrap().contains("not novel"));
2558 }
2559
2560 #[tokio::test]
2561 async fn pipeline_resource_rules_blocks_unapproved_autonomous() {
2562 let rules = Arc::new(ResourceRules::default());
2563 rules.set_user_initiated(false);
2564 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2565 let mut ctx = Context::new(BrainWave::Gamma);
2566 let tool = TestTool::new(
2567 "memory.consolidate",
2568 EffectRow {
2569 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2570 ..Default::default()
2571 },
2572 );
2573
2574 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2575 assert!(result.is_err());
2576 match result {
2577 Err(CoreError::Governance(msg)) => {
2578 assert!(msg.contains("human review"), "got: {msg}");
2579 }
2580 other => panic!("Expected Governance error, got {other:?}"),
2581 }
2582 }
2583
2584 #[tokio::test]
2585 async fn pipeline_resource_rules_allows_approved_autonomous() {
2586 let rules = Arc::new(ResourceRules::default());
2587 rules.set_user_initiated(false);
2588 rules.set_human_approved(true);
2589 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2590 let mut ctx = Context::new(BrainWave::Gamma);
2591 let tool = TestTool::new(
2592 "memory.consolidate",
2593 EffectRow {
2594 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2595 ..Default::default()
2596 },
2597 );
2598
2599 let result = pipeline
2600 .dispatch(
2601 &tool,
2602 &mut ctx,
2603 serde_json::json!({"purpose": "consolidate codex"}),
2604 )
2605 .await;
2606 assert!(result.is_ok());
2607 }
2608
2609 #[tokio::test]
2610 async fn pipeline_resource_rules_user_initiated_writes_allowed_by_default() {
2611 let pipeline = DispatchPipeline::with_defaults()
2613 .with_resource_rules(Arc::new(ResourceRules::default()));
2614 let mut ctx = Context::new(BrainWave::Gamma);
2615 let tool = TestTool::new(
2616 "write_tool",
2617 EffectRow {
2618 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2619 ..Default::default()
2620 },
2621 );
2622
2623 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2624 assert!(result.is_ok());
2625 }
2626
2627 #[tokio::test]
2630 async fn pipeline_runtime_satya_blocks_citta_write_without_read() {
2631 let pipeline = DispatchPipeline::with_defaults();
2632 let mut ctx = Context::new(BrainWave::Gamma);
2633 let tool = TestTool::new(
2634 "memory.create",
2635 EffectRow {
2636 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2637 ..Default::default()
2638 },
2639 );
2640
2641 let result = pipeline
2642 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2643 .await;
2644 assert!(result.is_err());
2645 match result {
2646 Err(CoreError::Governance(msg)) => {
2647 assert!(msg.contains("VIOLATION_SATYA"), "got: {msg}");
2648 }
2649 other => panic!("Expected Governance error, got {other:?}"),
2650 }
2651 }
2652
2653 #[tokio::test]
2654 async fn pipeline_runtime_satya_allows_citta_write_with_read_evidence() {
2655 let pipeline = DispatchPipeline::with_defaults();
2656 let mut ctx = Context::new(BrainWave::Gamma);
2657 let tool = TestTool::new(
2658 "consolidate_tool",
2659 EffectRow {
2660 reads: vec![wm_core::Resource::Galaxy("citta".into())],
2661 writes: vec![wm_core::Resource::Galaxy("citta".into())],
2662 ..Default::default()
2663 },
2664 );
2665
2666 let result = pipeline
2667 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2668 .await;
2669 assert!(result.is_ok());
2670 }
2671
2672 #[tokio::test]
2673 async fn pipeline_runtime_satya_allows_non_citta_runtime_galaxy() {
2674 let pipeline = DispatchPipeline::with_defaults();
2675 let mut ctx = Context::new(BrainWave::Gamma);
2676 let tool = TestTool::new(
2677 "memory.create",
2678 EffectRow {
2679 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2680 ..Default::default()
2681 },
2682 );
2683
2684 let result = pipeline
2685 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "research"}))
2686 .await;
2687 assert!(result.is_ok());
2688 }
2689
2690 #[tokio::test]
2693 async fn pipeline_write_audit_detects_misdeclaring_tool() {
2694 let tmp = tempfile::tempdir().unwrap();
2695 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2696 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2697 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2698 let mut ctx = Context::new(BrainWave::Gamma);
2699
2700 let tool = TestTool::new("sneaky_tool", EffectRow::pure()).with_store(store);
2702
2703 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2704 assert!(result.is_ok());
2705
2706 let mis = journal.misdeclarations().unwrap();
2707 assert!(!mis.is_empty(), "misdeclaring tool must be detected");
2708 assert_eq!(mis.last().unwrap().tool, "sneaky_tool");
2709 assert!(mis.last().unwrap().undeclared_mutation());
2710 }
2711
2712 #[tokio::test]
2713 async fn pipeline_write_audit_skips_meta_router() {
2714 let tmp = tempfile::tempdir().unwrap();
2715 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2716 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2717 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2718 let mut ctx = Context::new(BrainWave::Gamma);
2719
2720 let tool = TestTool::new("wm", EffectRow::pure()).with_store(store);
2724 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2725 assert!(result.is_ok());
2726
2727 let mis = journal.misdeclarations().unwrap();
2728 assert!(
2729 mis.iter().all(|m| m.tool != "wm"),
2730 "meta router must not appear as a misdeclaration: {mis:?}"
2731 );
2732 }
2733
2734 #[tokio::test]
2735 async fn pipeline_write_audit_records_declared_writes_with_identity() {
2736 let tmp = tempfile::tempdir().unwrap();
2737 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2738 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2739 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2740 let mut ctx = Context::new(BrainWave::Gamma);
2741
2742 let tool = TestTool::new(
2743 "honest_tool",
2744 EffectRow {
2745 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2746 ..Default::default()
2747 },
2748 )
2749 .with_store(store);
2750
2751 let args = serde_json::json!({"id": "abc-123", "content_hash": "hash-xyz"});
2752 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
2753 assert!(result.is_ok());
2754
2755 let entries = journal.scan_entries().unwrap();
2756 assert_eq!(entries.len(), 1);
2757 let entry = &entries[0];
2758 assert!(entry.declared_writes);
2759 assert!(entry.store_write_delta >= 1);
2760 assert_eq!(entry.memory_id.as_deref(), Some("abc-123"));
2761 assert_eq!(entry.content_hash.as_deref(), Some("hash-xyz"));
2762 assert!(journal.misdeclarations().unwrap().is_empty());
2763 }
2764
2765 #[tokio::test]
2766 async fn pipeline_write_audit_captures_actor_identity() {
2767 let tmp = tempfile::tempdir().unwrap();
2770 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2771 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2772 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2773 let mut ctx = Context::new(BrainWave::Gamma);
2774 ctx.session_id = Some(uuid::Uuid::nil());
2775 ctx.user_id = Some("agent-b".to_string());
2776 ctx.compartment = Some("production".to_string());
2777
2778 let tool = TestTool::new(
2779 "honest_tool",
2780 EffectRow {
2781 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2782 ..Default::default()
2783 },
2784 )
2785 .with_store(store);
2786
2787 let result = pipeline
2788 .dispatch(&tool, &mut ctx, serde_json::json!({"id": "abc-123"}))
2789 .await;
2790 assert!(result.is_ok());
2791
2792 let entries = journal.scan_entries().unwrap();
2793 assert_eq!(entries.len(), 1);
2794 let entry = &entries[0];
2795 assert_eq!(
2796 entry.actor_session.as_deref(),
2797 Some(uuid::Uuid::nil().to_string().as_str())
2798 );
2799 assert_eq!(entry.actor_user.as_deref(), Some("agent-b"));
2800 assert_eq!(entry.actor_compartment.as_deref(), Some("production"));
2801 }
2802
2803 #[tokio::test]
2804 async fn pipeline_write_audit_read_dispatch_not_flagged_after_external_writes() {
2805 let tmp = tempfile::tempdir().unwrap();
2810 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2811 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2812 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2813 let mut ctx = Context::new(BrainWave::Gamma);
2814
2815 for i in 0..3 {
2817 let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, format!("other session {i}"));
2818 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
2819 }
2820
2821 let read_tool = TestTool::new("memory.search", EffectRow::pure());
2822 let result = pipeline
2823 .dispatch(&read_tool, &mut ctx, Args::default())
2824 .await;
2825 assert!(result.is_ok());
2826
2827 let mis = journal.misdeclarations().unwrap();
2828 assert!(
2829 mis.is_empty(),
2830 "read-only dispatch must not inherit the other session's writes: {mis:?}"
2831 );
2832 let entries = journal.scan_entries().unwrap();
2833 assert_eq!(entries.last().unwrap().store_write_delta, 0);
2834 }
2835
2836 #[tokio::test]
2839 async fn pipeline_firebreak_forbidden_blocks_even_with_confirm() {
2840 let pipeline = DispatchPipeline::with_defaults();
2841 let mut ctx = Context::new(BrainWave::Gamma);
2842 let tool = TestTool::new(
2843 "destructive_tool",
2844 EffectRow {
2845 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2846 destructive: true,
2847 ..Default::default()
2848 },
2849 );
2850
2851 let result = pipeline
2852 .dispatch(
2853 &tool,
2854 &mut ctx,
2855 serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
2856 )
2857 .await;
2858 match result {
2859 Err(CoreError::Governance(msg)) => {
2860 assert!(msg.contains("FORBIDDEN"), "got: {msg}");
2861 assert!(msg.contains("never allowed"), "got: {msg}");
2862 }
2863 other => panic!("Expected Governance error, got {other:?}"),
2864 }
2865 }
2866
2867 #[tokio::test]
2868 async fn pipeline_firebreak_scope_law_blocks_unscoped_destructive() {
2869 let pipeline = DispatchPipeline::with_defaults();
2870 let mut ctx = Context::new(BrainWave::Gamma);
2871 let tool = TestTool::new(
2873 "memory.delete",
2874 EffectRow {
2875 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2876 destructive: true,
2877 ..Default::default()
2878 },
2879 );
2880
2881 let result = pipeline
2882 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
2883 .await;
2884 match result {
2885 Err(CoreError::Governance(msg)) => {
2886 assert!(msg.contains("no explicit scope"), "got: {msg}");
2887 assert!(msg.contains("id"), "names the scope field: {msg}");
2888 }
2889 other => panic!("Expected Governance error, got {other:?}"),
2890 }
2891 }
2892
2893 #[tokio::test]
2894 async fn pipeline_firebreak_scope_law_allows_scoped_destructive() {
2895 let pipeline = DispatchPipeline::with_defaults();
2896 let mut ctx = Context::new(BrainWave::Gamma);
2897 let tool = TestTool::new(
2898 "memory.delete",
2899 EffectRow {
2900 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2901 destructive: true,
2902 ..Default::default()
2903 },
2904 );
2905
2906 let result = pipeline
2907 .dispatch(
2908 &tool,
2909 &mut ctx,
2910 serde_json::json!({"confirm": true, "id": "0f0e0d0c-0000-0000-0000-000000000000"}),
2911 )
2912 .await;
2913 assert!(result.is_ok());
2914 }
2915
2916 #[tokio::test]
2917 async fn pipeline_firebreak_caution_disclosed_in_response() {
2918 let pipeline = DispatchPipeline::with_defaults();
2919 let mut ctx = Context::new(BrainWave::Gamma);
2920 let tool = TestTool::new(
2921 "galaxy.transfer",
2922 EffectRow {
2923 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2924 destructive: true,
2925 ..Default::default()
2926 },
2927 )
2928 .with_output(serde_json::json!({"status": "success"}));
2929
2930 let result = pipeline
2931 .dispatch(
2932 &tool,
2933 &mut ctx,
2934 serde_json::json!({"confirm": true, "from_galaxy": "codex", "note": "mv old new"}),
2935 )
2936 .await;
2937 let output = result.expect("caution must not block");
2938 let advisories = output
2939 .get("firebreak")
2940 .and_then(|f| f.get("advisories"))
2941 .and_then(|a| a.as_array())
2942 .expect("advisories must reach the response");
2943 assert_eq!(advisories.len(), 1);
2944 }
2945
2946 #[tokio::test]
2947 async fn pipeline_firebreak_dangerous_escalates_off_confirm_gate() {
2948 let pipeline = DispatchPipeline::with_defaults();
2952 let mut ctx = Context::new(BrainWave::Gamma);
2953 let tool = TestTool::new(
2954 "spawn_tool",
2955 EffectRow {
2956 spawns: true,
2957 ..Default::default()
2958 },
2959 );
2960
2961 let blocked = pipeline
2962 .dispatch(
2963 &tool,
2964 &mut ctx,
2965 serde_json::json!({"cmd": "sudo rm -r /tmp/build"}),
2966 )
2967 .await;
2968 match blocked {
2969 Err(CoreError::Governance(msg)) => {
2970 assert!(msg.contains("dangerous"), "got: {msg}");
2971 assert!(msg.contains("confirm"), "got: {msg}");
2972 }
2973 other => panic!("Expected Governance error, got {other:?}"),
2974 }
2975
2976 let allowed = pipeline
2977 .dispatch(
2978 &tool,
2979 &mut ctx,
2980 serde_json::json!({"cmd": "sudo rm -r /tmp/build", "confirm": true}),
2981 )
2982 .await;
2983 assert!(allowed.is_ok());
2984 }
2985
2986 #[tokio::test]
2987 async fn pipeline_firebreak_never_scans_prose() {
2988 let pipeline = DispatchPipeline::with_defaults();
2991 let mut ctx = Context::new(BrainWave::Gamma);
2992 let tool = TestTool::new(
2993 "memory.create",
2994 EffectRow {
2995 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2996 ..Default::default()
2997 },
2998 );
2999
3000 let result = pipeline
3001 .dispatch(
3002 &tool,
3003 &mut ctx,
3004 serde_json::json!({"content": "incident: operator ran rm -rf / on the store"}),
3005 )
3006 .await;
3007 assert!(result.is_ok(), "prose is never vetoed");
3008 }
3009
3010 #[tokio::test]
3011 async fn pipeline_firebreak_disarmable_per_pipeline() {
3012 let pipeline = DispatchPipeline::with_defaults()
3013 .with_firebreak_option(None::<Arc<wm_governance::Firebreak>>);
3014 let mut ctx = Context::new(BrainWave::Gamma);
3015 let tool = TestTool::new(
3016 "destructive_tool",
3017 EffectRow {
3018 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3019 destructive: true,
3020 ..Default::default()
3021 },
3022 );
3023
3024 let result = pipeline
3026 .dispatch(&tool, &mut ctx, serde_json::json!({}))
3027 .await;
3028 assert!(result.is_err());
3029
3030 let result = pipeline
3032 .dispatch(
3033 &tool,
3034 &mut ctx,
3035 serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
3036 )
3037 .await;
3038 assert!(result.is_ok(), "disarmed pipeline must not veto");
3039 }
3040
3041 #[tokio::test]
3042 async fn pipeline_write_audit_records_destructive_confirm() {
3043 let tmp = tempfile::tempdir().unwrap();
3046 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
3047 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
3048 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
3049 let mut ctx = Context::new(BrainWave::Gamma);
3050
3051 let tool = TestTool::new(
3052 "memory.delete",
3053 EffectRow {
3054 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3055 destructive: true,
3056 ..Default::default()
3057 },
3058 )
3059 .with_store(store);
3060
3061 let result = pipeline
3062 .dispatch(
3063 &tool,
3064 &mut ctx,
3065 serde_json::json!({"confirm": true, "id": "abc-123"}),
3066 )
3067 .await;
3068 assert!(result.is_ok());
3069
3070 let entries = journal.scan_entries().unwrap();
3071 assert_eq!(entries.len(), 1);
3072 assert_eq!(
3073 entries[0].confirmed,
3074 Some(true),
3075 "destructive entry must record the confirm"
3076 );
3077 }
3078
3079 #[tokio::test]
3082 async fn pipeline_compartment_production_blocks_runtime_galaxy_write_bypass() {
3083 let pipeline = DispatchPipeline::with_defaults();
3086 let mut ctx = Context::new(BrainWave::Gamma);
3087 ctx.compartment = Some("production".into());
3088 let tool = TestTool::new(
3089 "memory_update",
3090 EffectRow {
3091 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3092 ..Default::default()
3093 },
3094 );
3095
3096 let args = serde_json::json!({"galaxy": "karma"});
3097 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3098 assert!(result.is_err());
3099 match result {
3100 Err(CoreError::Governance(msg)) => {
3101 assert!(msg.contains("production"));
3102 assert!(msg.contains("karma"));
3103 assert!(msg.contains("runtime"));
3104 }
3105 other => panic!("Expected Governance error, got {other:?}"),
3106 }
3107 }
3108
3109 #[tokio::test]
3110 async fn pipeline_compartment_production_blocks_runtime_galaxy_read_bypass() {
3111 let pipeline = DispatchPipeline::with_defaults();
3114 let mut ctx = Context::new(BrainWave::Gamma);
3115 ctx.compartment = Some("production".into());
3116 let tool = TestTool::new(
3117 "memory_read",
3118 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3119 );
3120
3121 let args = serde_json::json!({"galaxy": "karma"});
3122 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3123 assert!(result.is_err());
3124 match result {
3125 Err(CoreError::Governance(msg)) => {
3126 assert!(msg.contains("production"));
3127 assert!(msg.contains("karma"));
3128 assert!(msg.contains("runtime"));
3129 }
3130 other => panic!("Expected Governance error, got {other:?}"),
3131 }
3132 }
3133
3134 #[tokio::test]
3135 async fn pipeline_compartment_production_allows_runtime_galaxy_same_as_declared() {
3136 let pipeline = DispatchPipeline::with_defaults();
3139 let mut ctx = Context::new(BrainWave::Gamma);
3140 ctx.compartment = Some("production".into());
3141 let tool = TestTool::new(
3142 "memory_read",
3143 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3144 );
3145
3146 let args = serde_json::json!({"galaxy": "codex"});
3147 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3148 assert!(result.is_ok());
3149 }
3150
3151 #[tokio::test]
3152 async fn pipeline_compartment_no_restriction_allows_runtime_galaxy() {
3153 let pipeline = DispatchPipeline::with_defaults();
3155 let mut ctx = Context::new(BrainWave::Gamma);
3156 let tool = TestTool::new(
3157 "memory_read",
3158 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3159 );
3160
3161 let args = serde_json::json!({"galaxy": "karma"});
3162 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3163 assert!(result.is_ok());
3164 }
3165
3166 #[tokio::test]
3167 async fn pipeline_compartment_production_allows_runtime_memory_galaxy() {
3168 let pipeline = DispatchPipeline::with_defaults();
3171 let mut ctx = Context::new(BrainWave::Gamma);
3172 ctx.compartment = Some("production".into());
3173 let tool = TestTool::new(
3174 "memory_read",
3175 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3176 );
3177
3178 let args = serde_json::json!({"galaxy": "research"});
3179 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3180 assert!(result.is_ok());
3181 }
3182
3183 #[tokio::test]
3184 async fn pipeline_compartment_production_blocks_runtime_system_galaxy() {
3185 let pipeline = DispatchPipeline::with_defaults();
3188 let mut ctx = Context::new(BrainWave::Gamma);
3189 ctx.compartment = Some("production".into());
3190 let tool = TestTool::new(
3191 "memory_read",
3192 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3193 );
3194
3195 let args = serde_json::json!({"galaxy": "karma"});
3196 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3197 assert!(result.is_err());
3198 match result {
3199 Err(CoreError::Governance(msg)) => {
3200 assert!(msg.contains("production"));
3201 assert!(msg.contains("karma"));
3202 assert!(msg.contains("runtime"));
3203 }
3204 other => panic!("Expected Governance error, got {other:?}"),
3205 }
3206 }
3207
3208 #[tokio::test]
3209 async fn benchmark_pipeline_overhead() {
3210 let pipeline = DispatchPipeline::with_defaults();
3211 let tool = TestTool::new("bench_tool", EffectRow::pure());
3212 let args = Args::default();
3213
3214 for _ in 0..100 {
3216 let mut ctx = Context::new(BrainWave::Gamma);
3217 let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3218 }
3219
3220 let n = 10_000;
3222 let start = std::time::Instant::now();
3223 for _ in 0..n {
3224 let mut ctx = Context::new(BrainWave::Gamma);
3225 let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3226 }
3227 let pipeline_ns = start.elapsed().as_nanos() / n;
3228
3229 let start = std::time::Instant::now();
3231 for _ in 0..n {
3232 let mut ctx = Context::new(BrainWave::Gamma);
3233 let _ = tool.call(&mut ctx, args.clone()).await;
3234 }
3235 let direct_ns = start.elapsed().as_nanos() / n;
3236
3237 let overhead_ns = pipeline_ns.saturating_sub(direct_ns);
3238 println!(
3239 "\n Pipeline: {pipeline_ns} ns/call | Direct: {direct_ns} ns/call | Overhead: {overhead_ns} ns/call"
3240 );
3241
3242 #[cfg(not(debug_assertions))]
3246 assert!(
3247 overhead_ns < 5_000,
3248 "Pipeline overhead {overhead_ns} ns/call exceeds 5µs budget"
3249 );
3250 }
3251}