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
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct PassQuotas {
131 pub cpu_ms: u64,
133 pub mem_mb: u64,
135 pub disk_mb: u64,
137 pub wall_ms: u64,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct PassBudget {
144 pub minor: u64,
146 pub currency: String,
148}
149
150#[derive(Debug, Clone)]
152pub struct PassEvidence {
153 pub issuer: String,
155 pub subject: String,
157 pub audience: String,
159 pub gate_class: String,
161 pub slot_class: String,
163 pub gate_did: String,
165 pub policy_version: String,
167 pub expires_at: i64,
169 pub quotas: PassQuotas,
171 pub budget: Option<PassBudget>,
173 pub jti: String,
175 pub token_digest: String,
177}
178
179pub trait PassGate: Send + Sync {
181 fn verify(&self, token: &str, tool: &str) -> std::result::Result<PassEvidence, String>;
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
188pub enum PassMode {
189 #[default]
191 Off,
192 Optional,
194 Required,
196}
197
198impl PassMode {
199 #[must_use]
201 pub fn from_env() -> Self {
202 match std::env::var("WM_MANDALA_PASS") {
203 Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
204 "off" | "0" | "false" => Self::Off,
205 "required" | "require" => Self::Required,
206 "optional" | "1" | "true" => Self::Optional,
207 other => {
208 tracing::warn!(
209 value = other,
210 "WM_MANDALA_PASS is not off|optional|required — using optional"
211 );
212 Self::Optional
213 }
214 },
215 Err(_) => Self::Optional,
216 }
217 }
218}
219
220pub struct AuthorityDispatch<'a> {
222 pub tool: &'a str,
224 pub args: &'a serde_json::Value,
226 pub output: Option<&'a serde_json::Value>,
228 pub success: bool,
230 pub elapsed: Duration,
232 pub pass: Option<&'a PassEvidence>,
234}
235
236pub trait ReceiptDispatchHook: Send + Sync {
244 fn on_authority_dispatch(&self, dispatch: AuthorityDispatch<'_>);
246}
247
248pub struct DispatchPipeline {
252 rate_limiter: Arc<RateLimiter>,
253 circuit_breakers: Arc<CircuitBreakerRegistry>,
254 dharma_gate: Arc<DharmaGate>,
255 karma_ledger: Option<Arc<KarmaLedger>>,
256 resource_rules: Option<Arc<ResourceRules>>,
259 write_gate: Option<Arc<crate::write_gate::WriteGate>>,
262 write_audit: Option<Arc<wm_governance::WriteAuditJournal>>,
265 secret_scan: Option<crate::secret_scan::SharedSampler>,
268 sandbox_exec: Option<Arc<crate::sandbox_exec::ScopedSandboxExecutor>>,
272 subprocess_sandbox: Option<Arc<crate::subprocess_sandbox::SubprocessSandbox>>,
278 flight_recorder: Option<Arc<crate::flight::FlightRecorder>>,
282 firebreak: Option<Arc<wm_governance::Firebreak>>,
286 capability_mode: CapabilityGateMode,
291 gana_registry: Option<Arc<std::sync::Mutex<wm_core::GanaRegistry>>>,
293 dispatch_timeout: Option<Duration>,
297 receipt_hook: Option<Arc<dyn ReceiptDispatchHook>>,
301 pass_gate: Option<Arc<dyn PassGate>>,
303 pass_mode: PassMode,
304}
305
306impl DispatchPipeline {
307 pub fn new(
312 rate_limiter: Arc<RateLimiter>,
313 circuit_breakers: Arc<CircuitBreakerRegistry>,
314 dharma_gate: Arc<DharmaGate>,
315 karma_ledger: Option<Arc<KarmaLedger>>,
316 ) -> Self {
317 Self {
318 rate_limiter,
319 circuit_breakers,
320 dharma_gate,
321 karma_ledger,
322 resource_rules: None,
323 write_gate: None,
324 write_audit: None,
325 flight_recorder: None,
326 secret_scan: Some(Arc::new(crate::secret_scan::SecretSampler::from_env())),
331 sandbox_exec: None,
335 subprocess_sandbox: None,
339 firebreak: Some(Arc::new(wm_governance::Firebreak::promoted())),
345 capability_mode: CapabilityGateMode::from_env(),
346 gana_registry: None,
347 dispatch_timeout: None,
348 receipt_hook: None,
349 pass_gate: None,
350 pass_mode: PassMode::Off,
351 }
352 }
353
354 #[must_use]
359 pub fn timeout_from_env() -> Option<Duration> {
360 match std::env::var("WM_DISPATCH_TIMEOUT_MS") {
361 Ok(v) => match v.trim().parse::<u64>() {
362 Ok(0) => None,
363 Ok(ms) => Some(Duration::from_millis(ms)),
364 Err(_) => {
365 tracing::warn!(
366 value = %v,
367 "WM_DISPATCH_TIMEOUT_MS is not a valid millisecond count — using default"
368 );
369 Some(DEFAULT_DISPATCH_TIMEOUT)
370 }
371 },
372 Err(_) => Some(DEFAULT_DISPATCH_TIMEOUT),
373 }
374 }
375
376 #[must_use]
378 pub const fn with_dispatch_timeout(mut self, timeout: Option<Duration>) -> Self {
379 self.dispatch_timeout = timeout;
380 self
381 }
382
383 #[must_use]
385 pub fn with_defaults() -> Self {
386 Self::new(
387 Arc::new(RateLimiter::default()),
388 Arc::new(CircuitBreakerRegistry::default()),
389 Arc::new(DharmaGate::default()),
390 None,
391 )
392 }
393
394 #[must_use]
396 pub const fn with_capability_mode(mut self, mode: CapabilityGateMode) -> Self {
397 self.capability_mode = mode;
398 self
399 }
400
401 #[must_use]
403 pub fn with_gana_registry(
404 mut self,
405 registry: Arc<std::sync::Mutex<wm_core::GanaRegistry>>,
406 ) -> Self {
407 self.gana_registry = Some(registry);
408 self
409 }
410
411 #[must_use]
413 pub fn with_resource_rules(mut self, rules: Arc<ResourceRules>) -> Self {
414 self.resource_rules = Some(rules);
415 self
416 }
417
418 #[must_use]
422 pub fn with_write_gate(mut self, gate: Arc<crate::write_gate::WriteGate>) -> Self {
423 self.write_gate = Some(gate);
424 self
425 }
426
427 #[must_use]
430 pub fn with_write_audit(mut self, journal: Arc<wm_governance::WriteAuditJournal>) -> Self {
431 self.write_audit = Some(journal);
432 self
433 }
434
435 #[must_use]
439 pub fn with_flight_recorder(
440 mut self,
441 recorder: Option<Arc<crate::flight::FlightRecorder>>,
442 ) -> Self {
443 self.flight_recorder = recorder;
444 self
445 }
446
447 #[must_use]
450 pub fn with_secret_scan_option(
451 mut self,
452 scanner: Option<crate::secret_scan::SharedSampler>,
453 ) -> Self {
454 self.secret_scan = scanner;
455 self
456 }
457
458 #[must_use]
460 pub fn secret_scan(&self) -> Option<&crate::secret_scan::SecretSampler> {
461 self.secret_scan.as_deref()
462 }
463
464 #[must_use]
468 pub fn with_receipt_hook_option(mut self, hook: Option<Arc<dyn ReceiptDispatchHook>>) -> Self {
469 self.receipt_hook = hook;
470 self
471 }
472
473 #[must_use]
475 pub fn receipt_hook(&self) -> Option<&Arc<dyn ReceiptDispatchHook>> {
476 self.receipt_hook.as_ref()
477 }
478
479 #[must_use]
482 pub fn with_pass_gate_option(
483 mut self,
484 gate: Option<Arc<dyn PassGate>>,
485 mode: PassMode,
486 ) -> Self {
487 self.pass_gate = gate;
488 self.pass_mode = mode;
489 self
490 }
491
492 #[must_use]
494 pub fn pass_gate(&self) -> Option<&Arc<dyn PassGate>> {
495 self.pass_gate.as_ref()
496 }
497
498 #[must_use]
500 pub const fn pass_mode(&self) -> PassMode {
501 self.pass_mode
502 }
503
504 #[must_use]
508 pub fn with_sandbox_executor(
509 mut self,
510 executor: Option<Arc<crate::sandbox_exec::ScopedSandboxExecutor>>,
511 ) -> Self {
512 self.sandbox_exec = executor;
513 self
514 }
515
516 #[must_use]
518 pub fn sandbox_executor(&self) -> Option<&crate::sandbox_exec::ScopedSandboxExecutor> {
519 self.sandbox_exec.as_deref()
520 }
521
522 #[must_use]
526 pub fn with_subprocess_sandbox(
527 mut self,
528 sandbox: Option<Arc<crate::subprocess_sandbox::SubprocessSandbox>>,
529 ) -> Self {
530 self.subprocess_sandbox = sandbox;
531 self
532 }
533
534 #[must_use]
536 pub fn subprocess_sandbox(&self) -> Option<&crate::subprocess_sandbox::SubprocessSandbox> {
537 self.subprocess_sandbox.as_deref()
538 }
539
540 #[must_use]
543 pub fn with_firebreak(mut self, firebreak: Arc<wm_governance::Firebreak>) -> Self {
544 self.firebreak = Some(firebreak);
545 self
546 }
547
548 #[must_use]
553 pub fn with_firebreak_option(
554 mut self,
555 firebreak: Option<Arc<wm_governance::Firebreak>>,
556 ) -> Self {
557 self.firebreak = firebreak;
558 self
559 }
560
561 #[must_use]
563 pub fn firebreak(&self) -> Option<&wm_governance::Firebreak> {
564 self.firebreak.as_deref()
565 }
566
567 #[must_use]
570 pub fn with_write_audit_option(
571 mut self,
572 journal: Option<Arc<wm_governance::WriteAuditJournal>>,
573 ) -> Self {
574 self.write_audit = journal;
575 self
576 }
577
578 #[must_use]
580 pub fn resource_rules(&self) -> Option<&ResourceRules> {
581 self.resource_rules.as_deref()
582 }
583
584 #[must_use]
586 pub fn write_audit(&self) -> Option<&wm_governance::WriteAuditJournal> {
587 self.write_audit.as_deref()
588 }
589
590 pub async fn dispatch(&self, tool: &dyn Tool, ctx: &mut Context, args: Args) -> Result<Output> {
592 let start = Instant::now();
593 let mut args = args;
594
595 let confirmed = args
602 .get("confirm")
603 .and_then(serde_json::Value::as_bool)
604 .unwrap_or(false);
605 ctx.explicit_confirm = confirmed;
606 if !tool.effects().is_available_in(ctx.brain_wave) && !confirmed {
607 return Err(CoreError::Governance(format!(
608 "tool '{}' not available in {:?} brain-wave state",
609 tool.name(),
610 ctx.brain_wave
611 )));
612 }
613
614 const COHERENCE_THRESHOLD: f32 = 0.3;
616 if !tool.effects().writes.is_empty() && ctx.citta_coherence < COHERENCE_THRESHOLD {
617 return Err(CoreError::Governance(format!(
618 "tool '{}' requires write access but citta coherence is {:.2} (minimum {:.2})",
619 tool.name(),
620 ctx.citta_coherence,
621 COHERENCE_THRESHOLD
622 )));
623 }
624
625 if ctx.readonly && !tool.effects().writes.is_empty() {
629 return Err(CoreError::Governance(format!(
630 "server is read-only: tool '{}' requires write access",
631 tool.name()
632 )));
633 }
634
635 const CONFIDENCE_THRESHOLD: f32 = 0.5;
637 if ctx.self_model_confidence < CONFIDENCE_THRESHOLD {
638 tracing::warn!(
639 tool = tool.name(),
640 confidence = ctx.self_model_confidence,
641 "low self-model confidence — conservative dispatch mode"
642 );
643 if !tool.effects().writes.is_empty() {
645 return Err(CoreError::Governance(format!(
646 "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)",
647 tool.name(),
648 ctx.self_model_confidence,
649 CONFIDENCE_THRESHOLD
650 )));
651 }
652 }
653
654 const DRIVE_CAUTION_THRESHOLD: f32 = 0.85;
656 if !tool.effects().writes.is_empty() && ctx.drive_caution > DRIVE_CAUTION_THRESHOLD {
657 tracing::warn!(
658 tool = tool.name(),
659 drive_caution = ctx.drive_caution,
660 "high drive caution — write operation flagged for review"
661 );
662 }
663
664 const DRIVE_ENERGY_THRESHOLD: f32 = 0.15;
666 if !tool.effects().writes.is_empty() && ctx.drive_energy < DRIVE_ENERGY_THRESHOLD {
667 tracing::warn!(
668 tool = tool.name(),
669 drive_energy = ctx.drive_energy,
670 "low drive energy — write operation may be resource-constrained"
671 );
672 }
673
674 match crate::capability_gate::evaluate(
682 tool.effects(),
683 &mut args,
684 self.capability_mode,
685 chrono::Utc::now().timestamp(),
686 ) {
687 Ok(GateOutcome::AdvisoryMissing { required }) => {
688 tracing::debug!(
689 tool = tool.name(),
690 required = %required.labels().join(", "),
691 mode = self.capability_mode.label(),
692 "capability gate: requirement unmet (advisory)"
693 );
694 }
695 Ok(_) => {}
696 Err(reason) => {
697 return Err(CoreError::Governance(format!("capability gate: {reason}")));
698 }
699 }
700
701 let verdict = self.dharma_gate.evaluate(tool.effects(), ctx);
705 match verdict {
706 ActionVerdict::Panic(reason) => {
707 tracing::error!(tool = tool.name(), reason = %reason, "Dharma PANIC");
708 return Err(CoreError::Governance(reason));
709 }
710 ActionVerdict::Intervene(reason) => {
711 tracing::warn!(tool = tool.name(), reason = %reason, "Dharma INTERVENE");
712 return Err(CoreError::Governance(reason));
713 }
714 ActionVerdict::Correct(reason) => {
715 tracing::info!(tool = tool.name(), reason = %reason, "Dharma CORRECT — proceeding with restrictions");
716 }
717 ActionVerdict::Advise(reason) => {
718 tracing::debug!(tool = tool.name(), reason = %reason, "Dharma ADVISE");
719 }
720 ActionVerdict::Observe => {}
721 }
722
723 let mut novelty_flag: Option<String> = None;
729 if let Some(ref rules) = self.resource_rules {
730 let effects = tool.effects();
731 let is_write = !effects.writes.is_empty();
732 let is_spawn = effects.spawns
733 || effects
734 .writes
735 .iter()
736 .chain(effects.reads.iter())
737 .any(|r| matches!(r, wm_core::Resource::Process));
738 let is_network = effects
739 .writes
740 .iter()
741 .chain(effects.reads.iter())
742 .any(|r| matches!(r, wm_core::Resource::Network));
743 let has_purpose = [args.get("purpose"), ctx.meta.get("purpose")]
744 .into_iter()
745 .flatten()
746 .filter_map(serde_json::Value::as_str)
747 .any(|p| !p.trim().is_empty());
748 let homeostasis = self.dharma_gate.homeostasis();
749 let verdict = rules.evaluate(
750 tool.name(),
751 hash_args(&args),
752 is_write,
753 is_spawn,
754 is_network,
755 has_purpose,
756 &homeostasis,
757 ctx.brain_wave,
758 );
759 match verdict {
760 ResourceVerdict::Allow => {}
761 ResourceVerdict::NotNovel { .. } => {
762 novelty_flag = Some(verdict.reason());
763 tracing::warn!(
764 tool = tool.name(),
765 reason = %verdict.reason(),
766 "resource rules: novelty flag on response"
767 );
768 }
769 ResourceVerdict::BudgetExceeded { .. }
770 | ResourceVerdict::RequiresHumanReview { .. }
771 | ResourceVerdict::NoPurpose { .. } => {
772 tracing::warn!(
773 tool = tool.name(),
774 reason = %verdict.reason(),
775 "resource rules: dispatch blocked"
776 );
777 return Err(CoreError::Governance(format!(
778 "resource rules: {}",
779 verdict.reason()
780 )));
781 }
782 }
783 }
784
785 let gate_disclosure: Option<serde_json::Value> = if let Some(ref gate) = self.write_gate {
791 let outcome = gate.enforce(tool.name(), &mut args)?;
792 if let Some(sc) = outcome.short_circuit {
793 return Ok(sc);
794 }
795 outcome.disclosure
796 } else {
797 None
798 };
799
800 if let Err(retry_after_ms) = self.rate_limiter.try_acquire(tool.name()) {
807 return Err(CoreError::RateLimited(format!(
808 "request rate limit (per-tool dispatch governor): '{}' — retry after {}ms",
809 tool.name(),
810 retry_after_ms
811 )));
812 }
813
814 if self.circuit_breakers.is_open(tool.name()) {
816 let retry_after_ms = self
817 .circuit_breakers
818 .remaining_cooldown(tool.name())
819 .as_millis();
820 return Err(CoreError::CircuitBreaker(format!(
821 "{} — repeated execution failures opened the breaker; retry after {}ms",
822 tool.name(),
823 retry_after_ms
824 )));
825 }
826
827 let confirm_gated = if tool.effects().destructive {
830 if !confirmed {
831 return Err(CoreError::Governance(format!(
832 "tool '{}' is destructive — pass `\"confirm\": true` in args to proceed",
833 tool.name()
834 )));
835 }
836 Some(true)
839 } else {
840 None
841 };
842
843 let mut pass_evidence: Option<PassEvidence> = None;
848 if let Some(ref gate) = self.pass_gate {
849 let token = args
850 .get("mandala_pass")
851 .and_then(serde_json::Value::as_str)
852 .map(str::to_string);
853 if let Some(token) = token {
854 if let Some(object) = args.as_object_mut() {
855 object.remove("mandala_pass");
856 }
857 if tool.effects().destructive {
858 match gate.verify(&token, tool.name()) {
859 Ok(evidence) => pass_evidence = Some(evidence),
860 Err(reason) => {
861 return Err(CoreError::Governance(format!(
862 "mandala pass refused for '{}': {reason}",
863 tool.name()
864 )));
865 }
866 }
867 }
868 } else if tool.effects().destructive && self.pass_mode == PassMode::Required {
869 return Err(CoreError::Governance(format!(
870 "tool '{}' requires a `mandala_pass` (WM_MANDALA_PASS=required)",
871 tool.name()
872 )));
873 }
874 }
875
876 let mut firebreak_advisories: Vec<String> = Vec::new();
884 if let Some(ref firebreak) = self.firebreak {
885 match firebreak.enforce(tool.name(), tool.effects(), &args) {
886 FirebreakOutcome::Blocked(reason) => {
887 tracing::warn!(tool = tool.name(), reason = %reason, "firebreak VETO");
888 return Err(CoreError::Governance(reason));
889 }
890 FirebreakOutcome::Proceed { advisories } if !advisories.is_empty() => {
891 tracing::info!(tool = tool.name(), advisories = ?advisories, "firebreak advisories");
892 firebreak_advisories = advisories;
893 }
894 FirebreakOutcome::Proceed { .. } => {}
895 }
896 }
897
898 let has_runtime_galaxy = args
912 .get("galaxy")
913 .and_then(serde_json::Value::as_str)
914 .is_some_and(|g| !g.is_empty());
915 let mut checked_galaxies: Vec<wm_core::Galaxy> = Vec::new();
916
917 if !has_runtime_galaxy {
918 for resource in &tool.effects().reads {
919 if let wm_core::Resource::Galaxy(name) = resource {
920 if let Some(galaxy) = wm_core::Galaxy::from_db_name(name) {
921 if !ctx.can_access_galaxy(galaxy) {
922 return Err(CoreError::Governance(format!(
923 "compartment '{}' cannot read galaxy '{}' (tool '{}')",
924 ctx.compartment.as_deref().unwrap_or("none"),
925 name,
926 tool.name()
927 )));
928 }
929 checked_galaxies.push(galaxy);
930 }
931 }
932 }
933 for resource in &tool.effects().writes {
934 if let wm_core::Resource::Galaxy(name) = resource {
935 if let Some(galaxy) = wm_core::Galaxy::from_db_name(name) {
936 if !ctx.can_write_galaxy(galaxy) {
937 return Err(CoreError::Governance(format!(
938 "compartment '{}' cannot write to galaxy '{}' (tool '{}')",
939 ctx.compartment.as_deref().unwrap_or("none"),
940 name,
941 tool.name()
942 )));
943 }
944 checked_galaxies.push(galaxy);
945 }
946 }
947 }
948 }
949
950 if let Some(galaxy_str) = args.get("galaxy").and_then(serde_json::Value::as_str) {
952 if !galaxy_str.is_empty() {
953 if let Some(runtime_galaxy) = wm_core::Galaxy::from_db_name(galaxy_str) {
954 if !checked_galaxies.contains(&runtime_galaxy) {
955 let has_writes = !tool.effects().writes.is_empty();
957 if has_writes {
958 if !ctx.can_write_galaxy(runtime_galaxy) {
959 return Err(CoreError::Governance(format!(
960 "compartment '{}' cannot write to galaxy '{}' (tool '{}' runtime arg)",
961 ctx.compartment.as_deref().unwrap_or("none"),
962 galaxy_str,
963 tool.name()
964 )));
965 }
966 } else if !ctx.can_access_galaxy(runtime_galaxy) {
967 return Err(CoreError::Governance(format!(
968 "compartment '{}' cannot read galaxy '{}' (tool '{}' runtime arg)",
969 ctx.compartment.as_deref().unwrap_or("none"),
970 galaxy_str,
971 tool.name()
972 )));
973 }
974 }
975 }
976 }
977 }
978
979 if !tool.effects().writes.is_empty()
985 && let Some(galaxy_str) = args.get("galaxy").and_then(serde_json::Value::as_str)
986 && galaxy_str == "citta"
987 && !tool
988 .effects()
989 .reads
990 .iter()
991 .any(|r| matches!(r, wm_core::Resource::Galaxy(g) if g == "citta"))
992 {
993 return Err(CoreError::Governance(
994 "VIOLATION_SATYA: writing to citta (runtime galaxy) without reading — memory fabrication is forbidden"
995 .to_string(),
996 ));
997 }
998
999 let args_memory_id = first_str(&args, &["id", "memory_id", "memory"]);
1007 let args_content_hash = first_str(&args, &["content_hash", "hash", "sha256"]);
1008 let args_digest = wm_governance::args_digest(tool.name(), &args);
1013 if let Some(ref flight) = self.flight_recorder {
1019 if let Err(e) = flight.record(tool.name(), &args) {
1020 tracing::warn!(error = %e, "Flight recorder capture failed (replay will refuse)");
1021 }
1022 }
1023 let write_audit_baseline = self
1024 .write_audit
1025 .as_ref()
1026 .map_or(0, |j| j.dispatch_baseline());
1027 let mut spawn_disclosure: Option<serde_json::Value> = None;
1033 if let Some(sb) = self.subprocess_sandbox.as_deref() {
1034 if crate::subprocess_sandbox::SubprocessSandbox::declared(tool.effects()) {
1035 let policy = sb.policy_for(tool.effects());
1036 if policy.is_active() {
1037 let mut disclosure = serde_json::json!({
1038 "net": policy.allow_net(),
1039 "envelope": wm_core::sandbox::ENVELOPE_SCHEMA,
1040 });
1041 if let Some(runner) = policy.runner()
1042 && let Some(obj) = disclosure.as_object_mut()
1043 {
1044 obj.insert(
1045 "runner".to_string(),
1046 serde_json::Value::String(runner.display().to_string()),
1047 );
1048 }
1049 sb.note_confined();
1050 spawn_disclosure = Some(disclosure);
1051 } else {
1052 sb.note_degraded(tool.name());
1053 }
1054 ctx.spawn = policy;
1055 } else if tool.effects().spawns {
1056 sb.note_unconfined_spawn(tool.name());
1057 }
1058 }
1059 let authority_args = if self.receipt_hook.is_some() && tool.effects().destructive {
1064 Some(args.clone())
1065 } else {
1066 None
1067 };
1068 let null_output = serde_json::Value::Null;
1069 let result = if crate::sandbox_exec::ScopedSandboxExecutor::handles(tool)
1070 && let Some(executor) = self.sandbox_exec.as_deref()
1071 {
1072 executor.run(tool, ctx, args)
1073 } else if let Some(timeout) = self.dispatch_timeout {
1074 if let Ok(res) = tokio::time::timeout(timeout, tool.call(ctx, args)).await {
1075 res
1076 } else {
1077 tracing::error!(
1078 tool = tool.name(),
1079 timeout_ms = timeout.as_millis(),
1080 "tool dispatch timed out"
1081 );
1082 self.circuit_breakers.record_failure(tool.name());
1083 return Err(CoreError::Tool(format!(
1084 "tool '{}' timed out after {}ms",
1085 tool.name(),
1086 timeout.as_millis()
1087 )));
1088 }
1089 } else {
1090 tool.call(ctx, args).await
1091 };
1092 let elapsed = start.elapsed();
1093
1094 if let Some(ref scanner) = self.secret_scan {
1099 if let Ok(ref output) = result {
1100 scanner.scan(tool.name(), output);
1101 }
1102 }
1103
1104 let result = match (result, novelty_flag) {
1106 (Ok(mut output), Some(flag)) => {
1107 if let serde_json::Value::Object(ref mut map) = output {
1108 match map.get_mut("resource_flags") {
1109 Some(serde_json::Value::Array(arr)) => {
1110 arr.push(serde_json::Value::String(flag));
1111 }
1112 Some(_) => {}
1113 None => {
1114 map.insert(
1115 "resource_flags".to_string(),
1116 serde_json::Value::Array(vec![serde_json::Value::String(flag)]),
1117 );
1118 }
1119 }
1120 }
1121 Ok(output)
1122 }
1123 (result, _) => result,
1124 };
1125
1126 let result = match (result, gate_disclosure) {
1129 (Ok(mut output), Some(disclosure)) => {
1130 if let serde_json::Value::Object(ref mut map) = output {
1131 map.insert("write_gate".to_string(), disclosure);
1132 }
1133 Ok(output)
1134 }
1135 (result, _) => result,
1136 };
1137
1138 let result = match (result, firebreak_advisories) {
1142 (Ok(mut output), advisories) if !advisories.is_empty() => {
1143 if let serde_json::Value::Object(ref mut map) = output {
1144 map.insert(
1145 "firebreak".to_string(),
1146 serde_json::json!({ "advisories": advisories }),
1147 );
1148 }
1149 Ok(output)
1150 }
1151 (result, _) => result,
1152 };
1153
1154 let result = match (result, spawn_disclosure) {
1157 (Ok(mut output), Some(disclosure)) => {
1158 if let serde_json::Value::Object(ref mut map) = output {
1159 map.insert("sandbox".to_string(), disclosure);
1160 }
1161 Ok(output)
1162 }
1163 (result, _) => result,
1164 };
1165
1166 if let Ok(output) = &result {
1168 tool.stats().record_success(elapsed, elapsed);
1169 self.circuit_breakers.record_success(tool.name());
1170
1171 if let Some(ref ledger) = self.karma_ledger {
1172 let declared_writes = !tool.effects().writes.is_empty();
1173 let actual_writes = output
1174 .get("writes")
1175 .and_then(|w| w.as_array())
1176 .map_or(0, |a| a.len() as u32);
1177 if let Err(e) = ledger.record(tool.name(), declared_writes, actual_writes, true) {
1178 tracing::warn!(error = %e, "Karma ledger record failed");
1179 }
1180 ctx.karma_debt = ledger.total_debt();
1181 }
1182
1183 if let Some(ref journal) = self.write_audit {
1184 let declared_writes = !tool.effects().writes.is_empty();
1185 record_write_audit(
1186 journal,
1187 write_audit_baseline,
1188 tool.name(),
1189 wm_governance::ActorIdentity::from_context(ctx),
1190 declared_writes,
1191 args_memory_id.as_deref(),
1192 args_content_hash.as_deref(),
1193 Some(args_digest),
1194 output,
1195 true,
1196 confirm_gated,
1197 );
1198 }
1199
1200 if tool.effects().destructive {
1203 if let Some(ref hook) = self.receipt_hook {
1204 hook.on_authority_dispatch(AuthorityDispatch {
1205 tool: tool.name(),
1206 args: authority_args.as_ref().unwrap_or(&null_output),
1207 output: Some(output),
1208 success: true,
1209 elapsed,
1210 pass: pass_evidence.as_ref(),
1211 });
1212 }
1213 }
1214 } else {
1215 tool.stats().record_failure(elapsed);
1216 if let Err(err) = &result {
1220 if err.counts_as_breaker_failure() {
1221 self.circuit_breakers.record_failure(tool.name());
1222 }
1223 }
1224
1225 if let Some(ref ledger) = self.karma_ledger {
1226 let declared_writes = !tool.effects().writes.is_empty();
1227 if let Err(ke) = ledger.record(tool.name(), declared_writes, 0, false) {
1228 tracing::warn!(error = %ke, "Karma ledger record failed");
1229 }
1230 ctx.karma_debt = ledger.total_debt();
1231 }
1232
1233 if let Some(ref journal) = self.write_audit {
1234 let declared_writes = !tool.effects().writes.is_empty();
1235 record_write_audit(
1236 journal,
1237 write_audit_baseline,
1238 tool.name(),
1239 wm_governance::ActorIdentity::from_context(ctx),
1240 declared_writes,
1241 args_memory_id.as_deref(),
1242 args_content_hash.as_deref(),
1243 Some(args_digest),
1244 &serde_json::Value::Null,
1245 false,
1246 confirm_gated,
1247 );
1248 }
1249
1250 if tool.effects().destructive && pass_evidence.is_some() {
1253 if let Some(ref hook) = self.receipt_hook {
1254 hook.on_authority_dispatch(AuthorityDispatch {
1255 tool: tool.name(),
1256 args: authority_args.as_ref().unwrap_or(&null_output),
1257 output: None,
1258 success: false,
1259 elapsed,
1260 pass: pass_evidence.as_ref(),
1261 });
1262 }
1263 }
1264 }
1265
1266 if let Some(ref registry) = self.gana_registry {
1268 if let Ok(mut reg) = registry.lock() {
1269 let gana = tool.gana();
1270 reg.record_usage(gana, result.is_ok());
1271 if let Some(prev) = ctx.last_gana {
1273 reg.record_co_usage(prev, gana);
1274 }
1275 ctx.last_gana = Some(gana);
1276 }
1277 }
1278
1279 result
1280 }
1281
1282 pub async fn dispatch_by_name(
1287 &self,
1288 registry: &crate::ToolRegistry,
1289 name: &str,
1290 ctx: &mut Context,
1291 args: Args,
1292 ) -> Result<Output> {
1293 let tool = registry
1294 .get(name)
1295 .ok_or_else(|| CoreError::NotFound(format!("tool '{name}' not registered")))?;
1296 self.dispatch(tool.as_ref(), ctx, args).await
1297 }
1298
1299 #[must_use]
1301 pub fn rate_limiter(&self) -> &RateLimiter {
1302 &self.rate_limiter
1303 }
1304
1305 #[must_use]
1307 pub fn circuit_breakers(&self) -> &CircuitBreakerRegistry {
1308 &self.circuit_breakers
1309 }
1310
1311 #[must_use]
1313 pub fn dharma_gate(&self) -> &DharmaGate {
1314 &self.dharma_gate
1315 }
1316
1317 #[must_use]
1319 pub fn karma_ledger(&self) -> Option<&KarmaLedger> {
1320 self.karma_ledger.as_deref()
1321 }
1322}
1323
1324impl Default for DispatchPipeline {
1325 fn default() -> Self {
1326 Self::with_defaults()
1327 }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use super::*;
1333 use wm_core::{BrainWave, EffectRow, Gana, Sandbox, ToolStats};
1334 use wm_governance::{ResourceRulesConfig, WriteAuditJournal};
1335
1336 struct TestTool {
1337 name: String,
1338 effects: EffectRow,
1339 stats: ToolStats,
1340 should_fail: bool,
1341 error: Option<fn() -> CoreError>,
1343 output: Option<Output>,
1344 store: Option<Arc<wm_memory::MemoryStore>>,
1347 }
1348
1349 impl TestTool {
1350 fn new(name: &str, effects: EffectRow) -> Self {
1351 Self {
1352 name: name.to_string(),
1353 effects,
1354 stats: ToolStats::default(),
1355 should_fail: false,
1356 error: None,
1357 output: None,
1358 store: None,
1359 }
1360 }
1361
1362 fn returning_error(name: &str, error: fn() -> CoreError) -> Self {
1363 Self {
1364 name: name.to_string(),
1365 effects: EffectRow::pure(),
1366 stats: ToolStats::default(),
1367 should_fail: false,
1368 error: Some(error),
1369 output: None,
1370 store: None,
1371 }
1372 }
1373
1374 fn with_output(mut self, output: Output) -> Self {
1375 self.output = Some(output);
1376 self
1377 }
1378
1379 fn with_store(mut self, store: Arc<wm_memory::MemoryStore>) -> Self {
1380 self.store = Some(store);
1381 self
1382 }
1383
1384 fn failing(name: &str) -> Self {
1385 Self {
1386 name: name.to_string(),
1387 effects: EffectRow::pure(),
1388 stats: ToolStats::default(),
1389 should_fail: true,
1390 error: None,
1391 output: None,
1392 store: None,
1393 }
1394 }
1395 }
1396
1397 #[async_trait]
1398 impl Tool for TestTool {
1399 fn name(&self) -> &str {
1400 &self.name
1401 }
1402 fn gana(&self) -> Gana {
1403 Gana::Heart
1404 }
1405 fn effects(&self) -> &EffectRow {
1406 &self.effects
1407 }
1408 async fn call(&self, _ctx: &mut Context, _args: Args) -> Result<Output> {
1409 if let Some(store) = &self.store {
1410 let mem = wm_memory::Memory::new(
1411 wm_core::Galaxy::Codex,
1412 format!("misdeclared write from {}", self.name),
1413 );
1414 store.put(wm_core::Galaxy::Codex, &mem).ok();
1415 }
1416 if let Some(error) = self.error {
1417 Err(error())
1418 } else if self.should_fail {
1419 Err(CoreError::Tool(self.name.clone()))
1420 } else {
1421 Ok(self
1422 .output
1423 .clone()
1424 .unwrap_or_else(|| serde_json::json!("ok")))
1425 }
1426 }
1427 fn stats(&self) -> &ToolStats {
1428 &self.stats
1429 }
1430 }
1431
1432 #[tokio::test]
1433 async fn pipeline_dispatch_success() {
1434 let pipeline = DispatchPipeline::with_defaults();
1435 let mut ctx = Context::new(BrainWave::Gamma);
1436 let tool = TestTool::new("test_tool", EffectRow::pure());
1437
1438 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1439 assert!(result.is_ok());
1440 }
1441
1442 struct HangingTool {
1443 effects: EffectRow,
1444 stats: ToolStats,
1445 }
1446
1447 impl HangingTool {
1448 fn new() -> Self {
1449 Self {
1450 effects: EffectRow::pure(),
1451 stats: ToolStats::default(),
1452 }
1453 }
1454 }
1455
1456 #[async_trait]
1457 impl Tool for HangingTool {
1458 fn name(&self) -> &str {
1459 "hanging_tool"
1460 }
1461 fn gana(&self) -> Gana {
1462 Gana::Heart
1463 }
1464 fn effects(&self) -> &EffectRow {
1465 &self.effects
1466 }
1467 async fn call(&self, _ctx: &mut Context, _args: Args) -> Result<Output> {
1468 tokio::time::sleep(Duration::from_secs(30)).await;
1469 Ok(serde_json::json!("never reached"))
1470 }
1471 fn stats(&self) -> &ToolStats {
1472 &self.stats
1473 }
1474 }
1475
1476 #[tokio::test]
1477 async fn pipeline_dispatch_timeout_bounds_hung_tool() {
1478 let pipeline = DispatchPipeline::with_defaults()
1479 .with_dispatch_timeout(Some(Duration::from_millis(50)));
1480 let mut ctx = Context::new(BrainWave::Gamma);
1481 let tool = HangingTool::new();
1482
1483 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1484 assert!(result.is_err());
1485 let msg = result.err().unwrap().to_string();
1486 assert!(
1487 msg.contains("timed out"),
1488 "expected timeout error, got: {msg}"
1489 );
1490 }
1491
1492 #[tokio::test]
1493 async fn pipeline_dispatch_with_timeout_allows_fast_tool() {
1494 let pipeline = DispatchPipeline::with_defaults()
1495 .with_dispatch_timeout(Some(Duration::from_millis(500)));
1496 let mut ctx = Context::new(BrainWave::Gamma);
1497 let tool = TestTool::new("fast_tool", EffectRow::pure());
1498
1499 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1500 assert!(result.is_ok());
1501 }
1502
1503 #[tokio::test]
1504 async fn pipeline_dispatch_failure_records_stats() {
1505 let pipeline = DispatchPipeline::with_defaults();
1506 let mut ctx = Context::new(BrainWave::Gamma);
1507 let tool = TestTool::failing("failing_tool");
1508
1509 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1510 assert!(result.is_err());
1511 assert_eq!(
1512 tool.stats()
1513 .call_count
1514 .load(std::sync::atomic::Ordering::Relaxed),
1515 1
1516 );
1517 }
1518
1519 #[tokio::test]
1520 async fn pipeline_blocks_incompatible_brain_wave() {
1521 let pipeline = DispatchPipeline::with_defaults();
1522 let mut ctx = Context::new(BrainWave::Delta);
1523 let tool = TestTool::new("test_tool", EffectRow::pure());
1524
1525 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1526 assert!(result.is_err());
1527 match result {
1528 Err(CoreError::Governance(_)) => {}
1529 other => panic!("Expected Governance error, got {other:?}"),
1530 }
1531 }
1532
1533 #[tokio::test]
1534 async fn pipeline_dharma_blocks_destructive_in_strict_mode() {
1535 let pipeline = DispatchPipeline::with_defaults();
1536 let mut ctx = Context::new(BrainWave::Theta);
1537 let tool = TestTool::new(
1538 "destructive_tool",
1539 EffectRow {
1540 writes: vec![wm_core::Resource::Filesystem],
1541 ..Default::default()
1542 },
1543 );
1544
1545 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1546 assert!(result.is_err());
1547 match result {
1548 Err(CoreError::Governance(_)) => {}
1549 other => panic!("Expected Governance error, got {other:?}"),
1550 }
1551 }
1552
1553 #[tokio::test]
1554 async fn pipeline_strict_refusal_is_typed_and_distinct_from_starvation() {
1555 let pipeline = DispatchPipeline::with_defaults();
1559 pipeline
1560 .dharma_gate()
1561 .update_homeostasis(wm_governance::Homeostasis {
1562 cpu_load: 0.95,
1563 memory_pressure: 0.95,
1564 active: true,
1565 });
1566 let mut ctx = Context::new(BrainWave::Beta);
1567 let tool = TestTool::new(
1568 "stress_probe",
1569 EffectRow {
1570 reads: vec![wm_core::Resource::Filesystem],
1571 writes: vec![wm_core::Resource::CoordinationLease],
1572 ..Default::default()
1573 },
1574 );
1575 let governance = pipeline
1576 .dispatch(&tool, &mut ctx, Args::default())
1577 .await
1578 .expect_err("strict mode must refuse coordination lease acquisition");
1579 let text = governance.to_string();
1580 assert!(text.contains("VIOLATION_AHIMSA"), "{text}");
1581
1582 let pipeline = DispatchPipeline::with_defaults();
1586 let mut ctx = Context::new(BrainWave::Gamma);
1587 ctx.self_model_confidence = 0.3;
1588 let write_tool = TestTool::new(
1589 "stress_probe",
1590 EffectRow {
1591 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1592 ..Default::default()
1593 },
1594 );
1595 let starvation = pipeline
1596 .dispatch(&write_tool, &mut ctx, Args::default())
1597 .await
1598 .expect_err("low confidence must refuse writes");
1599 let text = starvation.to_string();
1600 assert!(text.contains("self-model confidence"), "{text}");
1601 assert!(text.contains("WM_HOMEOSTASIS_FROZEN"), "{text}");
1602 assert!(
1603 !text.contains("VIOLATION_AHIMSA"),
1604 "refusal classes must be distinguishable: {text}"
1605 );
1606
1607 let read_tool = TestTool::new(
1608 "stress_probe_read",
1609 EffectRow {
1610 reads: vec![wm_core::Resource::Galaxy("codex".into())],
1611 ..Default::default()
1612 },
1613 );
1614 assert!(
1615 pipeline
1616 .dispatch(&read_tool, &mut ctx, Args::default())
1617 .await
1618 .is_ok(),
1619 "starvation must not block reads"
1620 );
1621 }
1622
1623 #[tokio::test]
1624 async fn pipeline_dharma_confirm_passes_brain_wave_strict_for_destructive() {
1625 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(Arc::new(
1629 ResourceRules::new(ResourceRulesConfig {
1630 require_human_review: false,
1631 ..Default::default()
1632 }),
1633 ));
1634 let mut ctx = Context::new(BrainWave::Theta);
1635 let tool = TestTool::new(
1636 "destructive_tool",
1637 EffectRow {
1638 writes: vec![wm_core::Resource::Filesystem],
1639 ..Default::default()
1640 },
1641 );
1642 let result = pipeline
1643 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
1644 .await;
1645 assert!(
1646 result.is_ok(),
1647 "confirmed destructive dispatch must pass brain-wave strict: {result:?}"
1648 );
1649 }
1650
1651 #[tokio::test]
1652 async fn pipeline_capability_gate_strict_blocks_uncredentialed() {
1653 let pipeline =
1654 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1655 let mut ctx = Context::new(BrainWave::Gamma);
1656 let tool = TestTool::new(
1657 "capability_tool",
1658 EffectRow {
1659 invokes: vec![wm_core::Capability::MemoryWrite],
1660 ..Default::default()
1661 },
1662 );
1663
1664 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1665 match result {
1666 Err(CoreError::Governance(msg)) => {
1667 assert!(msg.contains("capability gate"), "{msg}");
1668 assert!(msg.contains("memory:write"), "{msg}");
1669 }
1670 other => panic!("Expected capability refusal, got {other:?}"),
1671 }
1672 }
1673
1674 #[tokio::test]
1675 async fn pipeline_capability_gate_strict_allows_valid_token() {
1676 let pipeline =
1677 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1678 let mut ctx = Context::new(BrainWave::Gamma);
1679 let tool = TestTool::new(
1680 "capability_tool_ok",
1681 EffectRow {
1682 invokes: vec![wm_core::Capability::MemoryWrite],
1683 ..Default::default()
1684 },
1685 );
1686
1687 let mut issuer = wm_governance::engagement_tokens::EngagementIssuer::with_keypair(
1688 wm_governance::network_profile::AgentKeypair::from_seed([7u8; 32]),
1689 );
1690 let issuer_key = issuer.signer_public_key_hex();
1691 let token = issuer.issue(
1692 "tester",
1693 wm_governance::engagement_tokens::EngagementScope::Poc,
1694 "rules-hash",
1695 Some(3600),
1696 );
1697 let args = serde_json::json!({
1698 "_engagement": { "token": token, "issuer_public_key": issuer_key }
1699 });
1700
1701 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
1702 assert!(result.is_ok(), "valid Poc token should pass: {result:?}");
1703 }
1704
1705 #[tokio::test]
1706 async fn pipeline_capability_gate_advisory_allows_uncredentialed() {
1707 let pipeline =
1708 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Advisory);
1709 let mut ctx = Context::new(BrainWave::Gamma);
1710 let tool = TestTool::new(
1711 "capability_tool_advisory",
1712 EffectRow {
1713 invokes: vec![wm_core::Capability::MemoryWrite],
1714 ..Default::default()
1715 },
1716 );
1717
1718 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1719 assert!(result.is_ok(), "advisory mode must not block: {result:?}");
1720 }
1721
1722 #[tokio::test]
1723 async fn pipeline_rate_limit_blocks_excess() {
1724 let rate_limiter = Arc::new(RateLimiter::new(1000, 2, 0));
1725 let pipeline = DispatchPipeline::new(
1726 rate_limiter,
1727 Arc::new(CircuitBreakerRegistry::default()),
1728 Arc::new(DharmaGate::default()),
1729 None,
1730 );
1731
1732 let mut ctx = Context::new(BrainWave::Gamma);
1733 let tool = TestTool::new("limited_tool", EffectRow::pure());
1734
1735 assert!(
1736 pipeline
1737 .dispatch(&tool, &mut ctx, Args::default())
1738 .await
1739 .is_ok()
1740 );
1741 assert!(
1742 pipeline
1743 .dispatch(&tool, &mut ctx, Args::default())
1744 .await
1745 .is_ok()
1746 );
1747 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1748 assert!(result.is_err());
1749 match result {
1750 Err(CoreError::RateLimited(_)) => {}
1751 other => panic!("Expected RateLimited error, got {other:?}"),
1752 }
1753 }
1754
1755 #[tokio::test]
1756 async fn pipeline_circuit_breaker_opens_on_repeated_failures() {
1757 let breakers = Arc::new(CircuitBreakerRegistry::new(
1758 crate::circuit_breaker::BreakerConfig {
1759 failure_threshold: 3,
1760 window: std::time::Duration::from_secs(10),
1761 cooldown: std::time::Duration::from_secs(30),
1762 },
1763 ));
1764 let pipeline = DispatchPipeline::new(
1765 Arc::new(RateLimiter::new(10000, 100, 100)),
1766 breakers.clone(),
1767 Arc::new(DharmaGate::default()),
1768 None,
1769 );
1770
1771 let mut ctx = Context::new(BrainWave::Gamma);
1772 let tool = TestTool::failing("flaky_tool");
1773
1774 for _ in 0..3 {
1775 let _ = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1776 }
1777
1778 assert_eq!(
1779 breakers.state("flaky_tool"),
1780 crate::circuit_breaker::BreakerState::Open
1781 );
1782
1783 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1784 assert!(result.is_err());
1785 match result {
1786 Err(CoreError::CircuitBreaker(_)) => {}
1787 other => panic!("Expected CircuitBreaker error, got {other:?}"),
1788 }
1789 }
1790
1791 #[tokio::test]
1794 async fn client_validation_errors_do_not_trip_the_breaker() {
1795 let breakers = Arc::new(CircuitBreakerRegistry::new(
1796 crate::circuit_breaker::BreakerConfig {
1797 failure_threshold: 3,
1798 window: std::time::Duration::from_secs(10),
1799 cooldown: std::time::Duration::from_secs(30),
1800 },
1801 ));
1802 let pipeline = DispatchPipeline::new(
1803 Arc::new(RateLimiter::new(10000, 100, 100)),
1804 breakers.clone(),
1805 Arc::new(DharmaGate::default()),
1806 None,
1807 );
1808 let mut ctx = Context::new(BrainWave::Gamma);
1809
1810 let bad = TestTool::returning_error("validated_tool", || {
1813 CoreError::InvalidArgs("unknown galaxy".into())
1814 });
1815 for _ in 0..5 {
1816 let err = pipeline
1817 .dispatch(&bad, &mut ctx, Args::default())
1818 .await
1819 .unwrap_err();
1820 assert!(matches!(err, CoreError::InvalidArgs(_)));
1821 }
1822 assert_eq!(
1823 breakers.state("validated_tool"),
1824 crate::circuit_breaker::BreakerState::Closed,
1825 "caller errors must not open the breaker"
1826 );
1827
1828 let governed = TestTool::returning_error("validated_tool", || {
1830 CoreError::Governance("budget exceeded for writes".into())
1831 });
1832 for _ in 0..5 {
1833 let _ = pipeline
1834 .dispatch(&governed, &mut ctx, Args::default())
1835 .await;
1836 }
1837 assert_eq!(
1838 breakers.state("validated_tool"),
1839 crate::circuit_breaker::BreakerState::Closed,
1840 "governance refusals must not open the breaker"
1841 );
1842
1843 let good = TestTool::new("validated_tool", EffectRow::pure());
1845 pipeline
1846 .dispatch(&good, &mut ctx, Args::default())
1847 .await
1848 .expect("valid call after caller errors");
1849 }
1850
1851 #[tokio::test]
1852 async fn rate_limit_error_names_its_governor() {
1853 let pipeline = DispatchPipeline::new(
1854 Arc::new(RateLimiter::new(1000, 1, 0)),
1855 Arc::new(CircuitBreakerRegistry::default()),
1856 Arc::new(DharmaGate::default()),
1857 None,
1858 );
1859 let mut ctx = Context::new(BrainWave::Gamma);
1860 let tool = TestTool::new("bursty_tool", EffectRow::pure());
1861 pipeline
1862 .dispatch(&tool, &mut ctx, Args::default())
1863 .await
1864 .unwrap();
1865 let err = pipeline
1866 .dispatch(&tool, &mut ctx, Args::default())
1867 .await
1868 .unwrap_err();
1869 let text = err.to_string();
1870 assert!(
1871 text.contains("request rate limit") && text.contains("retry after"),
1872 "rate limit must name its category and retry hint: {text}"
1873 );
1874 }
1875
1876 #[tokio::test]
1877 async fn pipeline_karma_ledger_records() {
1878 let tmp = tempfile::tempdir().unwrap();
1879 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1880 let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1881
1882 let pipeline = DispatchPipeline::new(
1883 Arc::new(RateLimiter::default()),
1884 Arc::new(CircuitBreakerRegistry::default()),
1885 Arc::new(DharmaGate::default()),
1886 Some(ledger.clone()),
1887 );
1888
1889 let mut ctx = Context::new(BrainWave::Gamma);
1890 let tool = TestTool::new("karma_test_tool", EffectRow::pure());
1891
1892 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1893 assert!(result.is_ok());
1894 assert_eq!(ledger.next_id(), 1);
1895 assert_eq!(ctx.karma_debt, 0.0);
1896 }
1897
1898 #[tokio::test]
1899 async fn pipeline_karma_debt_updates_context() {
1900 let tmp = tempfile::tempdir().unwrap();
1901 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1902 let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1903
1904 let pipeline = DispatchPipeline::new(
1905 Arc::new(RateLimiter::default()),
1906 Arc::new(CircuitBreakerRegistry::default()),
1907 Arc::new(DharmaGate::default()),
1908 Some(ledger),
1909 );
1910
1911 let mut ctx = Context::new(BrainWave::Gamma);
1912 let tool = TestTool::new(
1913 "wasteful_tool",
1914 EffectRow {
1915 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1916 ..Default::default()
1917 },
1918 );
1919
1920 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1921 assert!(result.is_ok());
1922 assert!(
1923 (ctx.karma_debt - 0.2).abs() < 0.001,
1924 "Context karma_debt should be 0.2, got {}",
1925 ctx.karma_debt
1926 );
1927 }
1928
1929 #[tokio::test]
1930 async fn pipeline_karma_batched_e2e() {
1931 let tmp = tempfile::tempdir().unwrap();
1934 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1935 let ledger = Arc::new(KarmaLedger::with_flush_threshold(store.clone(), 100).unwrap());
1936
1937 let pipeline = DispatchPipeline::new(
1938 Arc::new(RateLimiter::default()),
1939 Arc::new(CircuitBreakerRegistry::default()),
1940 Arc::new(DharmaGate::default()),
1941 Some(ledger.clone()),
1942 );
1943
1944 let mut ctx = Context::new(BrainWave::Gamma);
1945
1946 let honest_tool = TestTool::new("honest_tool", EffectRow::pure());
1948 let wasteful_tool = TestTool::new(
1949 "wasteful_tool",
1950 EffectRow {
1951 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1952 ..Default::default()
1953 },
1954 );
1955
1956 for _ in 0..10 {
1957 let result = pipeline
1958 .dispatch(&honest_tool, &mut ctx, Args::default())
1959 .await;
1960 assert!(result.is_ok());
1961 }
1962 for _ in 0..10 {
1963 let result = pipeline
1964 .dispatch(&wasteful_tool, &mut ctx, Args::default())
1965 .await;
1966 assert!(result.is_ok());
1967 }
1968
1969 assert_eq!(ledger.next_id(), 20);
1971 assert_eq!(
1972 ledger.pending_count(),
1973 20,
1974 "All 20 entries should be pending before flush"
1975 );
1976
1977 let debt = ledger.total_debt();
1979 assert!(
1980 (debt - 2.0).abs() < 0.001,
1981 "Total debt should be 2.0 (10 x 0.2), got {debt}"
1982 );
1983
1984 ledger.flush().unwrap();
1986 assert_eq!(ledger.pending_count(), 0);
1987
1988 let result = ledger.verify_integrity().unwrap();
1990 assert!(
1991 result.valid,
1992 "Chain should be valid after batched flush: {:?}",
1993 result.violation
1994 );
1995 assert_eq!(result.entries_verified, 20);
1996
1997 let ledger2 = KarmaLedger::new(store).unwrap();
1999 assert_eq!(
2000 ledger2.next_id(),
2001 20,
2002 "Next ID should persist across instances"
2003 );
2004 let entries = ledger2.scan_entries().unwrap();
2005 assert_eq!(
2006 entries.len(),
2007 20,
2008 "All 20 entries should be persisted in LMDB"
2009 );
2010
2011 let debt2 = ledger2.total_debt();
2013 assert!(
2014 (debt2 - 2.0).abs() < 0.001,
2015 "Total debt should persist as 2.0, got {debt2}"
2016 );
2017
2018 let result2 = ledger2.verify_integrity().unwrap();
2020 assert!(result2.valid, "Chain should be valid on reloaded ledger");
2021 assert_eq!(result2.entries_verified, 20);
2022 }
2023
2024 #[tokio::test]
2025 async fn pipeline_coherence_gate_blocks_writes() {
2026 let pipeline = DispatchPipeline::with_defaults();
2027 let mut ctx = Context::new(BrainWave::Gamma);
2028 ctx.citta_coherence = 0.1; let tool = TestTool::new(
2030 "write_tool",
2031 EffectRow {
2032 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2033 ..Default::default()
2034 },
2035 );
2036
2037 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2038 assert!(result.is_err());
2039 match result {
2040 Err(CoreError::Governance(msg)) => {
2041 assert!(msg.contains("coherence"));
2042 }
2043 other => panic!("Expected Governance error, got {other:?}"),
2044 }
2045 }
2046
2047 #[tokio::test]
2048 async fn pipeline_coherence_gate_allows_reads() {
2049 let pipeline = DispatchPipeline::with_defaults();
2050 let mut ctx = Context::new(BrainWave::Gamma);
2051 ctx.citta_coherence = 0.1; let tool = TestTool::new("read_tool", EffectRow::pure());
2053
2054 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2055 assert!(result.is_ok());
2056 }
2057
2058 #[tokio::test]
2059 async fn pipeline_coherence_gate_allows_writes_when_coherent() {
2060 let pipeline = DispatchPipeline::with_defaults();
2061 let mut ctx = Context::new(BrainWave::Gamma);
2062 ctx.citta_coherence = 0.5; let tool = TestTool::new(
2064 "write_tool",
2065 EffectRow {
2066 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2067 ..Default::default()
2068 },
2069 );
2070
2071 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2072 assert!(result.is_ok());
2073 }
2074
2075 #[tokio::test]
2076 async fn pipeline_low_confidence_blocks_writes() {
2077 let pipeline = DispatchPipeline::with_defaults();
2078 let mut ctx = Context::new(BrainWave::Gamma);
2079 ctx.self_model_confidence = 0.3; let tool = TestTool::new(
2081 "write_tool",
2082 EffectRow {
2083 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2084 ..Default::default()
2085 },
2086 );
2087
2088 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2089 assert!(result.is_err());
2090 match result {
2091 Err(CoreError::Governance(msg)) => {
2092 assert!(msg.contains("confidence"));
2093 assert!(msg.contains("conservative"));
2094 }
2095 other => panic!("Expected Governance error, got {other:?}"),
2096 }
2097 }
2098
2099 #[tokio::test]
2100 async fn pipeline_low_confidence_allows_reads() {
2101 let pipeline = DispatchPipeline::with_defaults();
2102 let mut ctx = Context::new(BrainWave::Gamma);
2103 ctx.self_model_confidence = 0.3; let tool = TestTool::new("read_tool", EffectRow::pure());
2105
2106 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2107 assert!(result.is_ok());
2108 }
2109
2110 #[tokio::test]
2111 async fn pipeline_high_confidence_allows_writes() {
2112 let pipeline = DispatchPipeline::with_defaults();
2113 let mut ctx = Context::new(BrainWave::Gamma);
2114 ctx.self_model_confidence = 0.8; let tool = TestTool::new(
2116 "write_tool",
2117 EffectRow {
2118 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2119 ..Default::default()
2120 },
2121 );
2122
2123 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2124 assert!(result.is_ok());
2125 }
2126
2127 #[tokio::test]
2128 async fn pipeline_high_caution_warns_on_writes() {
2129 let pipeline = DispatchPipeline::with_defaults();
2130 let mut ctx = Context::new(BrainWave::Gamma);
2131 ctx.drive_caution = 0.9; let tool = TestTool::new(
2133 "write_tool",
2134 EffectRow {
2135 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2136 ..Default::default()
2137 },
2138 );
2139
2140 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2142 assert!(result.is_ok());
2143 }
2144
2145 #[tokio::test]
2146 async fn pipeline_low_energy_warns_on_writes() {
2147 let pipeline = DispatchPipeline::with_defaults();
2148 let mut ctx = Context::new(BrainWave::Gamma);
2149 ctx.drive_energy = 0.1; let tool = TestTool::new(
2151 "write_tool",
2152 EffectRow {
2153 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2154 ..Default::default()
2155 },
2156 );
2157
2158 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2160 assert!(result.is_ok());
2161 }
2162
2163 #[tokio::test]
2164 async fn pipeline_drive_gates_dont_affect_reads() {
2165 let pipeline = DispatchPipeline::with_defaults();
2166 let mut ctx = Context::new(BrainWave::Gamma);
2167 ctx.drive_caution = 0.95;
2168 ctx.drive_energy = 0.05;
2169 let tool = TestTool::new("read_tool", EffectRow::pure());
2170
2171 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2172 assert!(result.is_ok());
2173 }
2174
2175 #[tokio::test]
2176 async fn pipeline_destructive_blocked_without_confirm() {
2177 let pipeline = DispatchPipeline::with_defaults();
2178 let mut ctx = Context::new(BrainWave::Gamma);
2179 let tool = TestTool::new(
2180 "destructive_tool",
2181 EffectRow {
2182 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2183 destructive: true,
2184 ..Default::default()
2185 },
2186 );
2187
2188 let result = pipeline
2189 .dispatch(&tool, &mut ctx, serde_json::json!({}))
2190 .await;
2191 assert!(result.is_err());
2192 match result {
2193 Err(CoreError::Governance(msg)) => {
2194 assert!(msg.contains("destructive"));
2195 assert!(msg.contains("confirm"));
2196 }
2197 other => panic!("Expected Governance error, got {other:?}"),
2198 }
2199 }
2200
2201 #[tokio::test]
2202 async fn pipeline_destructive_allowed_with_confirm() {
2203 let pipeline = DispatchPipeline::with_defaults();
2204 let mut ctx = Context::new(BrainWave::Gamma);
2205 let tool = TestTool::new(
2206 "destructive_tool",
2207 EffectRow {
2208 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2209 destructive: true,
2210 ..Default::default()
2211 },
2212 );
2213
2214 let result = pipeline
2215 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
2216 .await;
2217 assert!(result.is_ok());
2218 }
2219
2220 #[tokio::test]
2221 async fn pipeline_destructive_blocked_with_false_confirm() {
2222 let pipeline = DispatchPipeline::with_defaults();
2223 let mut ctx = Context::new(BrainWave::Gamma);
2224 let tool = TestTool::new(
2225 "destructive_tool",
2226 EffectRow {
2227 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2228 destructive: true,
2229 ..Default::default()
2230 },
2231 );
2232
2233 let result = pipeline
2234 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": false}))
2235 .await;
2236 assert!(result.is_err());
2237 }
2238
2239 #[tokio::test]
2240 async fn pipeline_compartment_no_restriction_allows_all() {
2241 let pipeline = DispatchPipeline::with_defaults();
2242 let mut ctx = Context::new(BrainWave::Gamma);
2243 let tool = TestTool::new(
2245 "write_tool",
2246 EffectRow {
2247 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2248 ..Default::default()
2249 },
2250 );
2251
2252 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2253 assert!(result.is_ok());
2254 }
2255
2256 #[tokio::test]
2257 async fn pipeline_compartment_sandbox_blocks_write_to_codex() {
2258 let pipeline = DispatchPipeline::with_defaults();
2259 let mut ctx = Context::new(BrainWave::Gamma);
2260 ctx.compartment = Some("sandbox".into());
2261 let tool = TestTool::new(
2262 "write_tool",
2263 EffectRow {
2264 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2265 ..Default::default()
2266 },
2267 );
2268
2269 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2270 assert!(result.is_err());
2271 match result {
2272 Err(CoreError::Governance(msg)) => {
2273 assert!(msg.contains("sandbox"));
2274 assert!(msg.contains("codex"));
2275 }
2276 other => panic!("Expected Governance error, got {other:?}"),
2277 }
2278 }
2279
2280 #[tokio::test]
2281 async fn pipeline_asserted_user_id_confers_no_authority() {
2282 let pipeline = DispatchPipeline::with_defaults();
2287 let mut ctx = Context::new(BrainWave::Gamma);
2288 ctx.compartment = Some("sandbox".into());
2289 ctx.user_id = Some("ceo".into());
2290 let tool = TestTool::new(
2291 "write_tool",
2292 EffectRow {
2293 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2294 ..Default::default()
2295 },
2296 );
2297
2298 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2299 assert!(result.is_err());
2300 match result {
2301 Err(CoreError::Governance(msg)) => {
2302 assert!(msg.contains("sandbox"));
2303 assert!(msg.contains("codex"));
2304 }
2305 other => panic!("Expected Governance error, got {other:?}"),
2306 }
2307 }
2308
2309 #[tokio::test]
2310 async fn pipeline_routes_store_scoped_tools_through_executor() {
2311 use crate::sandbox_exec::ScopedSandboxExecutor;
2314 use std::sync::atomic::{AtomicU64, Ordering};
2315 let calls = Arc::new(AtomicU64::new(0));
2316 let counter = Arc::clone(&calls);
2317 let executor = Arc::new(ScopedSandboxExecutor::new(move || {
2318 counter.fetch_add(1, Ordering::SeqCst);
2319 Ok(())
2320 }));
2321 let pipeline =
2322 DispatchPipeline::with_defaults().with_sandbox_executor(Some(Arc::clone(&executor)));
2323 let mut ctx = Context::new(BrainWave::Gamma);
2324
2325 let scoped = TestTool::new(
2326 "scoped_tool",
2327 EffectRow {
2328 sandbox: Sandbox::StoreScoped,
2329 ..Default::default()
2330 },
2331 );
2332 assert!(
2333 pipeline
2334 .dispatch(&scoped, &mut ctx, Args::default())
2335 .await
2336 .is_ok()
2337 );
2338 assert_eq!(calls.load(Ordering::SeqCst), 1, "scoped tool must confine");
2339
2340 let plain = TestTool::new("plain_tool", EffectRow::pure());
2341 assert!(
2342 pipeline
2343 .dispatch(&plain, &mut ctx, Args::default())
2344 .await
2345 .is_ok()
2346 );
2347 assert_eq!(
2348 calls.load(Ordering::SeqCst),
2349 1,
2350 "plain tools must not ride the sandbox path"
2351 );
2352 assert_eq!(executor.stats(), (1, 0, 0));
2353
2354 let bare = DispatchPipeline::with_defaults();
2356 let scoped2 = TestTool::new(
2357 "scoped_tool",
2358 EffectRow {
2359 sandbox: Sandbox::StoreScoped,
2360 ..Default::default()
2361 },
2362 );
2363 assert!(
2364 bare.dispatch(&scoped2, &mut ctx, Args::default())
2365 .await
2366 .is_ok()
2367 );
2368 }
2369
2370 #[tokio::test]
2371 async fn pipeline_injects_subprocess_policy_and_discloses() {
2372 use crate::subprocess_sandbox::SubprocessSandbox;
2376 use std::path::PathBuf;
2377 use wm_core::sandbox::RunnerSource;
2378 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
2379 wm_core::sandbox::RunnerInfo {
2380 path: PathBuf::from("/opt/mandala-sandbox"),
2381 source: RunnerSource::Env,
2382 },
2383 )));
2384 let pipeline =
2385 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2386 let mut ctx = Context::new(BrainWave::Gamma);
2387 let tool = TestTool::new(
2388 "spawn_tool",
2389 EffectRow {
2390 reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
2391 spawns: true,
2392 sandbox: Sandbox::Subprocess,
2393 ..Default::default()
2394 },
2395 )
2396 .with_output(serde_json::json!({"ok": true}));
2397
2398 let out = pipeline
2399 .dispatch(&tool, &mut ctx, Args::default())
2400 .await
2401 .expect("declared spawn tool dispatches");
2402 assert!(ctx.spawn.is_active(), "policy must ride the context");
2403 assert!(ctx.spawn.allow_net(), "network read grants the runner net");
2404 assert_eq!(out["sandbox"]["runner"], "/opt/mandala-sandbox");
2405 assert_eq!(out["sandbox"]["net"], true);
2406 assert_eq!(
2407 out["sandbox"]["envelope"],
2408 wm_core::sandbox::ENVELOPE_SCHEMA
2409 );
2410 assert_eq!(sandbox.status()["dispatches"], 1);
2411 assert_eq!(sandbox.status()["degraded"], 0);
2412 }
2413
2414 #[tokio::test]
2415 async fn pipeline_degrades_loudly_when_runner_missing() {
2416 use crate::subprocess_sandbox::SubprocessSandbox;
2419 let sandbox = Arc::new(SubprocessSandbox::with_runner(None));
2420 let pipeline =
2421 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2422 let mut ctx = Context::new(BrainWave::Gamma);
2423 let tool = TestTool::new(
2424 "spawn_tool",
2425 EffectRow {
2426 reads: vec![wm_core::Resource::Process],
2427 spawns: true,
2428 sandbox: Sandbox::Subprocess,
2429 ..Default::default()
2430 },
2431 )
2432 .with_output(serde_json::json!({"ok": true}));
2433
2434 let out = pipeline
2435 .dispatch(&tool, &mut ctx, Args::default())
2436 .await
2437 .expect("degrade keeps availability up");
2438 assert!(!ctx.spawn.is_active());
2439 assert!(
2440 out.get("sandbox").is_none(),
2441 "no runner means no confinement claim"
2442 );
2443 assert_eq!(sandbox.status()["dispatches"], 1);
2444 assert_eq!(sandbox.status()["degraded"], 1);
2445 }
2446
2447 #[tokio::test]
2448 async fn pipeline_surfaces_unmigrated_spawn_tools() {
2449 use crate::subprocess_sandbox::SubprocessSandbox;
2453 use std::path::PathBuf;
2454 use wm_core::sandbox::RunnerSource;
2455 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
2456 wm_core::sandbox::RunnerInfo {
2457 path: PathBuf::from("/opt/mandala-sandbox"),
2458 source: RunnerSource::Env,
2459 },
2460 )));
2461 let pipeline =
2462 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2463 let mut ctx = Context::new(BrainWave::Gamma);
2464 let tool = TestTool::new(
2465 "legacy_git_tool",
2466 EffectRow {
2467 reads: vec![wm_core::Resource::Process],
2468 spawns: true,
2469 ..Default::default()
2470 },
2471 );
2472
2473 assert!(
2474 pipeline
2475 .dispatch(&tool, &mut ctx, Args::default())
2476 .await
2477 .is_ok()
2478 );
2479 assert!(!ctx.spawn.is_active());
2480 assert_eq!(sandbox.status()["unconfined_spawns"], 1);
2481 assert_eq!(sandbox.status()["dispatches"], 0);
2482 }
2483
2484 #[cfg(unix)]
2485 #[tokio::test]
2486 async fn declared_spawn_executes_through_the_runner_envelope() {
2487 use crate::subprocess_sandbox::SubprocessSandbox;
2491 use std::os::unix::fs::PermissionsExt;
2492 use wm_core::sandbox::{RunnerInfo, RunnerSource};
2493
2494 let dir = tempfile::tempdir().expect("tempdir");
2495 let marker = dir.path().join("envelope.json");
2496 let runner = dir.path().join("fake-runner");
2497 std::fs::write(
2498 &runner,
2499 format!(
2500 "#!/bin/sh\nprintf '%s' \"$2\" > '{}'\nexit 0\n",
2501 marker.display()
2502 ),
2503 )
2504 .expect("write fake runner");
2505 std::fs::set_permissions(&runner, std::fs::Permissions::from_mode(0o755))
2506 .expect("chmod fake runner");
2507
2508 struct SpawnProbeTool {
2509 effects: EffectRow,
2510 stats: ToolStats,
2511 }
2512 #[async_trait]
2513 impl Tool for SpawnProbeTool {
2514 fn name(&self) -> &str {
2515 "spawn_probe"
2516 }
2517 fn gana(&self) -> Gana {
2518 Gana::Heart
2519 }
2520 fn effects(&self) -> &EffectRow {
2521 &self.effects
2522 }
2523 async fn call(&self, ctx: &mut Context, _args: Args) -> Result<Output> {
2524 let out = ctx
2525 .spawn
2526 .command("printf", &["%s", "hi"])
2527 .output()
2528 .map_err(|e| CoreError::Tool(format!("spawn failed: {e}")))?;
2529 if !out.status.success() {
2530 return Err(CoreError::Tool("wrapped command failed".into()));
2531 }
2532 Ok(serde_json::json!({"ok": true}))
2533 }
2534 fn stats(&self) -> &ToolStats {
2535 &self.stats
2536 }
2537 }
2538
2539 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(RunnerInfo {
2540 path: runner,
2541 source: RunnerSource::Env,
2542 })));
2543 let pipeline =
2544 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2545 let mut ctx = Context::new(BrainWave::Gamma);
2546 let tool = SpawnProbeTool {
2547 effects: EffectRow {
2548 reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
2549 spawns: true,
2550 sandbox: Sandbox::Subprocess,
2551 ..Default::default()
2552 },
2553 stats: ToolStats::default(),
2554 };
2555 let out = pipeline
2556 .dispatch(&tool, &mut ctx, Args::default())
2557 .await
2558 .expect("wrapped spawn succeeds");
2559 assert_eq!(out["ok"], true);
2560 assert_eq!(out["sandbox"]["net"], true);
2561
2562 let captured = std::fs::read_to_string(&marker).expect("runner captured the envelope");
2563 let envelope: serde_json::Value = serde_json::from_str(&captured).expect("envelope JSON");
2564 assert_eq!(envelope["schema"], wm_core::sandbox::ENVELOPE_SCHEMA);
2565 assert_eq!(envelope["program"], "printf");
2566 assert_eq!(envelope["args"], serde_json::json!(["%s", "hi"]));
2567 assert_eq!(envelope["net"], true);
2568 }
2569
2570 #[tokio::test]
2571 async fn pipeline_secret_scan_warns_without_blocking() {
2572 use crate::secret_scan::SecretSampler;
2576 let sampler = Arc::new(SecretSampler::new(1));
2577 let pipeline =
2578 DispatchPipeline::with_defaults().with_secret_scan_option(Some(Arc::clone(&sampler)));
2579 let mut ctx = Context::new(BrainWave::Gamma);
2580 let tool = TestTool::new("key_tool", EffectRow::pure())
2581 .with_output(serde_json::json!({"data": "key=AKIAIOSFODNN7EXAMPLE"}));
2582 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2583 assert!(result.is_ok(), "warn-only scan must never block");
2584 assert_eq!(sampler.stats(), (1, 1, 1));
2585
2586 let clean = TestTool::new("clean_tool", EffectRow::pure())
2588 .with_output(serde_json::json!({"results": []}));
2589 assert!(
2590 pipeline
2591 .dispatch(&clean, &mut ctx, Args::default())
2592 .await
2593 .is_ok()
2594 );
2595 assert_eq!(sampler.stats(), (2, 2, 1));
2596 }
2597
2598 #[tokio::test]
2599 async fn pipeline_compartment_sandbox_blocks_read_from_karma() {
2600 let pipeline = DispatchPipeline::with_defaults();
2601 let mut ctx = Context::new(BrainWave::Gamma);
2602 ctx.compartment = Some("sandbox".into());
2603 let tool = TestTool::new(
2604 "read_tool",
2605 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2606 );
2607
2608 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2609 assert!(result.is_err());
2610 match result {
2611 Err(CoreError::Governance(msg)) => {
2612 assert!(msg.contains("sandbox"));
2613 assert!(msg.contains("karma"));
2614 }
2615 other => panic!("Expected Governance error, got {other:?}"),
2616 }
2617 }
2618
2619 #[tokio::test]
2620 async fn pipeline_compartment_sandbox_allows_write_to_tutorial() {
2621 let pipeline = DispatchPipeline::with_defaults();
2622 let mut ctx = Context::new(BrainWave::Gamma);
2623 ctx.compartment = Some("sandbox".into());
2624 let tool = TestTool::new(
2625 "write_tool",
2626 EffectRow {
2627 writes: vec![wm_core::Resource::Galaxy("tutorial".into())],
2628 ..Default::default()
2629 },
2630 );
2631
2632 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2633 assert!(result.is_ok());
2634 }
2635
2636 #[tokio::test]
2637 async fn pipeline_compartment_sandbox_allows_read_from_research() {
2638 let pipeline = DispatchPipeline::with_defaults();
2639 let mut ctx = Context::new(BrainWave::Gamma);
2640 ctx.compartment = Some("sandbox".into());
2641 let tool = TestTool::new(
2642 "read_tool",
2643 EffectRow::read_only(vec![wm_core::Resource::Galaxy("research".into())]),
2644 );
2645
2646 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2647 assert!(result.is_ok());
2648 }
2649
2650 #[tokio::test]
2651 async fn pipeline_compartment_production_blocks_read_from_karma() {
2652 let pipeline = DispatchPipeline::with_defaults();
2653 let mut ctx = Context::new(BrainWave::Gamma);
2654 ctx.compartment = Some("production".into());
2655 let tool = TestTool::new(
2656 "read_tool",
2657 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2658 );
2659
2660 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2661 assert!(result.is_err());
2662 match result {
2663 Err(CoreError::Governance(msg)) => {
2664 assert!(msg.contains("production"));
2665 assert!(msg.contains("karma"));
2666 }
2667 other => panic!("Expected Governance error, got {other:?}"),
2668 }
2669 }
2670
2671 #[tokio::test]
2672 async fn pipeline_compartment_production_allows_write_to_codex() {
2673 let pipeline = DispatchPipeline::with_defaults();
2674 let mut ctx = Context::new(BrainWave::Gamma);
2675 ctx.compartment = Some("production".into());
2676 let tool = TestTool::new(
2677 "write_tool",
2678 EffectRow {
2679 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2680 ..Default::default()
2681 },
2682 );
2683
2684 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2685 assert!(result.is_ok());
2686 }
2687
2688 #[tokio::test]
2689 async fn pipeline_compartment_secure_allows_write_to_codex() {
2690 let pipeline = DispatchPipeline::with_defaults();
2691 let mut ctx = Context::new(BrainWave::Gamma);
2692 ctx.compartment = Some("secure".into());
2693 let tool = TestTool::new(
2694 "write_tool",
2695 EffectRow {
2696 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2697 ..Default::default()
2698 },
2699 );
2700
2701 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2702 assert!(result.is_ok());
2703 }
2704
2705 #[tokio::test]
2706 async fn pipeline_compartment_secure_blocks_read_from_karma() {
2707 let pipeline = DispatchPipeline::with_defaults();
2708 let mut ctx = Context::new(BrainWave::Gamma);
2709 ctx.compartment = Some("secure".into());
2710 let tool = TestTool::new(
2711 "read_tool",
2712 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2713 );
2714
2715 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2716 assert!(result.is_err());
2717 match result {
2718 Err(CoreError::Governance(msg)) => {
2719 assert!(msg.contains("secure"));
2720 assert!(msg.contains("karma"));
2721 }
2722 other => panic!("Expected Governance error, got {other:?}"),
2723 }
2724 }
2725
2726 fn rules_with(max_writes: u32, max_repeats: u32) -> Arc<ResourceRules> {
2729 Arc::new(ResourceRules::new(ResourceRulesConfig {
2730 max_writes_per_minute: max_writes,
2731 max_spawns_per_minute: 100,
2732 max_network_per_minute: 100,
2733 novelty_window: 50,
2734 max_repeats,
2735 require_human_review: false,
2736 }))
2737 }
2738
2739 #[tokio::test]
2740 async fn pipeline_resource_rules_budget_exceeding_write_refused() {
2741 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(2, 1000));
2742 let mut ctx = Context::new(BrainWave::Gamma);
2743 let tool = TestTool::new(
2744 "write_tool",
2745 EffectRow {
2746 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2747 ..Default::default()
2748 },
2749 );
2750
2751 assert!(
2752 pipeline
2753 .dispatch(&tool, &mut ctx, Args::default())
2754 .await
2755 .is_ok(),
2756 "first write within budget"
2757 );
2758 assert!(
2759 pipeline
2760 .dispatch(&tool, &mut ctx, Args::default())
2761 .await
2762 .is_ok(),
2763 "second write within budget"
2764 );
2765 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2766 assert!(result.is_err(), "third write must exceed the budget");
2767 match result {
2768 Err(CoreError::Governance(msg)) => {
2769 assert!(msg.contains("resource rules"), "got: {msg}");
2770 assert!(msg.contains("writes"), "got: {msg}");
2771 }
2772 other => panic!("Expected Governance error, got {other:?}"),
2773 }
2774 }
2775
2776 #[tokio::test]
2777 async fn pipeline_resource_rules_novelty_flag_reaches_response() {
2778 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(1000, 1));
2779 let mut ctx = Context::new(BrainWave::Gamma);
2780 let tool = TestTool::new("read_tool", EffectRow::pure())
2781 .with_output(serde_json::json!({"status": "ok"}));
2782
2783 let first = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2784 assert!(first.is_ok());
2785 assert!(
2786 first.unwrap().get("resource_flags").is_none(),
2787 "first call is novel — no flag"
2788 );
2789
2790 let second = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2791 let output = second.expect("repeated call must still succeed (flag, not block)");
2792 let flags = output
2793 .get("resource_flags")
2794 .and_then(|f| f.as_array())
2795 .expect("novelty flag must reach the response");
2796 assert_eq!(flags.len(), 1);
2797 assert!(flags[0].as_str().unwrap().contains("not novel"));
2798 }
2799
2800 #[tokio::test]
2801 async fn pipeline_resource_rules_blocks_unapproved_autonomous() {
2802 let rules = Arc::new(ResourceRules::default());
2803 rules.set_user_initiated(false);
2804 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2805 let mut ctx = Context::new(BrainWave::Gamma);
2806 let tool = TestTool::new(
2807 "memory.consolidate",
2808 EffectRow {
2809 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2810 ..Default::default()
2811 },
2812 );
2813
2814 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2815 assert!(result.is_err());
2816 match result {
2817 Err(CoreError::Governance(msg)) => {
2818 assert!(msg.contains("human review"), "got: {msg}");
2819 }
2820 other => panic!("Expected Governance error, got {other:?}"),
2821 }
2822 }
2823
2824 #[tokio::test]
2825 async fn pipeline_resource_rules_allows_approved_autonomous() {
2826 let rules = Arc::new(ResourceRules::default());
2827 rules.set_user_initiated(false);
2828 rules.set_human_approved(true);
2829 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2830 let mut ctx = Context::new(BrainWave::Gamma);
2831 let tool = TestTool::new(
2832 "memory.consolidate",
2833 EffectRow {
2834 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2835 ..Default::default()
2836 },
2837 );
2838
2839 let result = pipeline
2840 .dispatch(
2841 &tool,
2842 &mut ctx,
2843 serde_json::json!({"purpose": "consolidate codex"}),
2844 )
2845 .await;
2846 assert!(result.is_ok());
2847 }
2848
2849 #[tokio::test]
2850 async fn pipeline_resource_rules_user_initiated_writes_allowed_by_default() {
2851 let pipeline = DispatchPipeline::with_defaults()
2853 .with_resource_rules(Arc::new(ResourceRules::default()));
2854 let mut ctx = Context::new(BrainWave::Gamma);
2855 let tool = TestTool::new(
2856 "write_tool",
2857 EffectRow {
2858 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2859 ..Default::default()
2860 },
2861 );
2862
2863 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2864 assert!(result.is_ok());
2865 }
2866
2867 #[tokio::test]
2870 async fn pipeline_runtime_satya_blocks_citta_write_without_read() {
2871 let pipeline = DispatchPipeline::with_defaults();
2872 let mut ctx = Context::new(BrainWave::Gamma);
2873 let tool = TestTool::new(
2874 "memory.create",
2875 EffectRow {
2876 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2877 ..Default::default()
2878 },
2879 );
2880
2881 let result = pipeline
2882 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2883 .await;
2884 assert!(result.is_err());
2885 match result {
2886 Err(CoreError::Governance(msg)) => {
2887 assert!(msg.contains("VIOLATION_SATYA"), "got: {msg}");
2888 }
2889 other => panic!("Expected Governance error, got {other:?}"),
2890 }
2891 }
2892
2893 #[tokio::test]
2894 async fn pipeline_runtime_satya_allows_citta_write_with_read_evidence() {
2895 let pipeline = DispatchPipeline::with_defaults();
2896 let mut ctx = Context::new(BrainWave::Gamma);
2897 let tool = TestTool::new(
2898 "consolidate_tool",
2899 EffectRow {
2900 reads: vec![wm_core::Resource::Galaxy("citta".into())],
2901 writes: vec![wm_core::Resource::Galaxy("citta".into())],
2902 ..Default::default()
2903 },
2904 );
2905
2906 let result = pipeline
2907 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2908 .await;
2909 assert!(result.is_ok());
2910 }
2911
2912 #[tokio::test]
2913 async fn pipeline_runtime_satya_allows_non_citta_runtime_galaxy() {
2914 let pipeline = DispatchPipeline::with_defaults();
2915 let mut ctx = Context::new(BrainWave::Gamma);
2916 let tool = TestTool::new(
2917 "memory.create",
2918 EffectRow {
2919 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2920 ..Default::default()
2921 },
2922 );
2923
2924 let result = pipeline
2925 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "research"}))
2926 .await;
2927 assert!(result.is_ok());
2928 }
2929
2930 #[tokio::test]
2933 async fn pipeline_write_audit_detects_misdeclaring_tool() {
2934 let tmp = tempfile::tempdir().unwrap();
2935 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2936 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2937 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2938 let mut ctx = Context::new(BrainWave::Gamma);
2939
2940 let tool = TestTool::new("sneaky_tool", EffectRow::pure()).with_store(store);
2942
2943 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2944 assert!(result.is_ok());
2945
2946 let mis = journal.misdeclarations().unwrap();
2947 assert!(!mis.is_empty(), "misdeclaring tool must be detected");
2948 assert_eq!(mis.last().unwrap().tool, "sneaky_tool");
2949 assert!(mis.last().unwrap().undeclared_mutation());
2950 }
2951
2952 #[tokio::test]
2953 async fn pipeline_write_audit_skips_meta_router() {
2954 let tmp = tempfile::tempdir().unwrap();
2955 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2956 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2957 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2958 let mut ctx = Context::new(BrainWave::Gamma);
2959
2960 let tool = TestTool::new("wm", EffectRow::pure()).with_store(store);
2964 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2965 assert!(result.is_ok());
2966
2967 let mis = journal.misdeclarations().unwrap();
2968 assert!(
2969 mis.iter().all(|m| m.tool != "wm"),
2970 "meta router must not appear as a misdeclaration: {mis:?}"
2971 );
2972 }
2973
2974 #[tokio::test]
2975 async fn pipeline_write_audit_records_declared_writes_with_identity() {
2976 let tmp = tempfile::tempdir().unwrap();
2977 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2978 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2979 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2980 let mut ctx = Context::new(BrainWave::Gamma);
2981
2982 let tool = TestTool::new(
2983 "honest_tool",
2984 EffectRow {
2985 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2986 ..Default::default()
2987 },
2988 )
2989 .with_store(store);
2990
2991 let args = serde_json::json!({"id": "abc-123", "content_hash": "hash-xyz"});
2992 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
2993 assert!(result.is_ok());
2994
2995 let entries = journal.scan_entries().unwrap();
2996 assert_eq!(entries.len(), 1);
2997 let entry = &entries[0];
2998 assert!(entry.declared_writes);
2999 assert!(entry.store_write_delta >= 1);
3000 assert_eq!(entry.memory_id.as_deref(), Some("abc-123"));
3001 assert_eq!(entry.content_hash.as_deref(), Some("hash-xyz"));
3002 assert!(journal.misdeclarations().unwrap().is_empty());
3003 }
3004
3005 #[tokio::test]
3006 async fn pipeline_write_audit_captures_actor_identity() {
3007 let tmp = tempfile::tempdir().unwrap();
3010 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
3011 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
3012 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
3013 let mut ctx = Context::new(BrainWave::Gamma);
3014 ctx.session_id = Some(uuid::Uuid::nil());
3015 ctx.user_id = Some("agent-b".to_string());
3016 ctx.compartment = Some("production".to_string());
3017
3018 let tool = TestTool::new(
3019 "honest_tool",
3020 EffectRow {
3021 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3022 ..Default::default()
3023 },
3024 )
3025 .with_store(store);
3026
3027 let result = pipeline
3028 .dispatch(&tool, &mut ctx, serde_json::json!({"id": "abc-123"}))
3029 .await;
3030 assert!(result.is_ok());
3031
3032 let entries = journal.scan_entries().unwrap();
3033 assert_eq!(entries.len(), 1);
3034 let entry = &entries[0];
3035 assert_eq!(
3036 entry.actor_session.as_deref(),
3037 Some(uuid::Uuid::nil().to_string().as_str())
3038 );
3039 assert_eq!(entry.actor_user.as_deref(), Some("agent-b"));
3040 assert_eq!(entry.actor_compartment.as_deref(), Some("production"));
3041 }
3042
3043 #[tokio::test]
3044 async fn pipeline_write_audit_read_dispatch_not_flagged_after_external_writes() {
3045 let tmp = tempfile::tempdir().unwrap();
3050 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
3051 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
3052 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
3053 let mut ctx = Context::new(BrainWave::Gamma);
3054
3055 for i in 0..3 {
3057 let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, format!("other session {i}"));
3058 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
3059 }
3060
3061 let read_tool = TestTool::new("memory.search", EffectRow::pure());
3062 let result = pipeline
3063 .dispatch(&read_tool, &mut ctx, Args::default())
3064 .await;
3065 assert!(result.is_ok());
3066
3067 let mis = journal.misdeclarations().unwrap();
3068 assert!(
3069 mis.is_empty(),
3070 "read-only dispatch must not inherit the other session's writes: {mis:?}"
3071 );
3072 let entries = journal.scan_entries().unwrap();
3073 assert_eq!(entries.last().unwrap().store_write_delta, 0);
3074 }
3075
3076 #[tokio::test]
3079 async fn pipeline_firebreak_forbidden_blocks_even_with_confirm() {
3080 let pipeline = DispatchPipeline::with_defaults();
3081 let mut ctx = Context::new(BrainWave::Gamma);
3082 let tool = TestTool::new(
3083 "destructive_tool",
3084 EffectRow {
3085 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3086 destructive: true,
3087 ..Default::default()
3088 },
3089 );
3090
3091 let result = pipeline
3092 .dispatch(
3093 &tool,
3094 &mut ctx,
3095 serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
3096 )
3097 .await;
3098 match result {
3099 Err(CoreError::Governance(msg)) => {
3100 assert!(msg.contains("FORBIDDEN"), "got: {msg}");
3101 assert!(msg.contains("never allowed"), "got: {msg}");
3102 }
3103 other => panic!("Expected Governance error, got {other:?}"),
3104 }
3105 }
3106
3107 #[tokio::test]
3108 async fn pipeline_firebreak_scope_law_blocks_unscoped_destructive() {
3109 let pipeline = DispatchPipeline::with_defaults();
3110 let mut ctx = Context::new(BrainWave::Gamma);
3111 let tool = TestTool::new(
3113 "memory.delete",
3114 EffectRow {
3115 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3116 destructive: true,
3117 ..Default::default()
3118 },
3119 );
3120
3121 let result = pipeline
3122 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
3123 .await;
3124 match result {
3125 Err(CoreError::Governance(msg)) => {
3126 assert!(msg.contains("no explicit scope"), "got: {msg}");
3127 assert!(msg.contains("id"), "names the scope field: {msg}");
3128 }
3129 other => panic!("Expected Governance error, got {other:?}"),
3130 }
3131 }
3132
3133 #[tokio::test]
3134 async fn pipeline_firebreak_scope_law_allows_scoped_destructive() {
3135 let pipeline = DispatchPipeline::with_defaults();
3136 let mut ctx = Context::new(BrainWave::Gamma);
3137 let tool = TestTool::new(
3138 "memory.delete",
3139 EffectRow {
3140 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3141 destructive: true,
3142 ..Default::default()
3143 },
3144 );
3145
3146 let result = pipeline
3147 .dispatch(
3148 &tool,
3149 &mut ctx,
3150 serde_json::json!({"confirm": true, "id": "0f0e0d0c-0000-0000-0000-000000000000"}),
3151 )
3152 .await;
3153 assert!(result.is_ok());
3154 }
3155
3156 #[tokio::test]
3157 async fn pipeline_firebreak_caution_disclosed_in_response() {
3158 let pipeline = DispatchPipeline::with_defaults();
3159 let mut ctx = Context::new(BrainWave::Gamma);
3160 let tool = TestTool::new(
3161 "galaxy.transfer",
3162 EffectRow {
3163 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3164 destructive: true,
3165 ..Default::default()
3166 },
3167 )
3168 .with_output(serde_json::json!({"status": "success"}));
3169
3170 let result = pipeline
3171 .dispatch(
3172 &tool,
3173 &mut ctx,
3174 serde_json::json!({"confirm": true, "from_galaxy": "codex", "note": "mv old new"}),
3175 )
3176 .await;
3177 let output = result.expect("caution must not block");
3178 let advisories = output
3179 .get("firebreak")
3180 .and_then(|f| f.get("advisories"))
3181 .and_then(|a| a.as_array())
3182 .expect("advisories must reach the response");
3183 assert_eq!(advisories.len(), 1);
3184 }
3185
3186 #[tokio::test]
3187 async fn pipeline_firebreak_dangerous_escalates_off_confirm_gate() {
3188 let pipeline = DispatchPipeline::with_defaults();
3192 let mut ctx = Context::new(BrainWave::Gamma);
3193 let tool = TestTool::new(
3194 "spawn_tool",
3195 EffectRow {
3196 spawns: true,
3197 ..Default::default()
3198 },
3199 );
3200
3201 let blocked = pipeline
3202 .dispatch(
3203 &tool,
3204 &mut ctx,
3205 serde_json::json!({"cmd": "sudo rm -r /tmp/build"}),
3206 )
3207 .await;
3208 match blocked {
3209 Err(CoreError::Governance(msg)) => {
3210 assert!(msg.contains("dangerous"), "got: {msg}");
3211 assert!(msg.contains("confirm"), "got: {msg}");
3212 }
3213 other => panic!("Expected Governance error, got {other:?}"),
3214 }
3215
3216 let allowed = pipeline
3217 .dispatch(
3218 &tool,
3219 &mut ctx,
3220 serde_json::json!({"cmd": "sudo rm -r /tmp/build", "confirm": true}),
3221 )
3222 .await;
3223 assert!(allowed.is_ok());
3224 }
3225
3226 #[tokio::test]
3227 async fn pipeline_firebreak_never_scans_prose() {
3228 let pipeline = DispatchPipeline::with_defaults();
3231 let mut ctx = Context::new(BrainWave::Gamma);
3232 let tool = TestTool::new(
3233 "memory.create",
3234 EffectRow {
3235 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3236 ..Default::default()
3237 },
3238 );
3239
3240 let result = pipeline
3241 .dispatch(
3242 &tool,
3243 &mut ctx,
3244 serde_json::json!({"content": "incident: operator ran rm -rf / on the store"}),
3245 )
3246 .await;
3247 assert!(result.is_ok(), "prose is never vetoed");
3248 }
3249
3250 #[tokio::test]
3251 async fn pipeline_firebreak_disarmable_per_pipeline() {
3252 let pipeline = DispatchPipeline::with_defaults()
3253 .with_firebreak_option(None::<Arc<wm_governance::Firebreak>>);
3254 let mut ctx = Context::new(BrainWave::Gamma);
3255 let tool = TestTool::new(
3256 "destructive_tool",
3257 EffectRow {
3258 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3259 destructive: true,
3260 ..Default::default()
3261 },
3262 );
3263
3264 let result = pipeline
3266 .dispatch(&tool, &mut ctx, serde_json::json!({}))
3267 .await;
3268 assert!(result.is_err());
3269
3270 let result = pipeline
3272 .dispatch(
3273 &tool,
3274 &mut ctx,
3275 serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
3276 )
3277 .await;
3278 assert!(result.is_ok(), "disarmed pipeline must not veto");
3279 }
3280
3281 #[tokio::test]
3282 async fn pipeline_write_audit_records_destructive_confirm() {
3283 let tmp = tempfile::tempdir().unwrap();
3286 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
3287 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
3288 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
3289 let mut ctx = Context::new(BrainWave::Gamma);
3290
3291 let tool = TestTool::new(
3292 "memory.delete",
3293 EffectRow {
3294 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3295 destructive: true,
3296 ..Default::default()
3297 },
3298 )
3299 .with_store(store);
3300
3301 let result = pipeline
3302 .dispatch(
3303 &tool,
3304 &mut ctx,
3305 serde_json::json!({"confirm": true, "id": "abc-123"}),
3306 )
3307 .await;
3308 assert!(result.is_ok());
3309
3310 let entries = journal.scan_entries().unwrap();
3311 assert_eq!(entries.len(), 1);
3312 assert_eq!(
3313 entries[0].confirmed,
3314 Some(true),
3315 "destructive entry must record the confirm"
3316 );
3317 }
3318
3319 #[tokio::test]
3322 async fn pipeline_compartment_production_blocks_runtime_galaxy_write_bypass() {
3323 let pipeline = DispatchPipeline::with_defaults();
3326 let mut ctx = Context::new(BrainWave::Gamma);
3327 ctx.compartment = Some("production".into());
3328 let tool = TestTool::new(
3329 "memory_update",
3330 EffectRow {
3331 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3332 ..Default::default()
3333 },
3334 );
3335
3336 let args = serde_json::json!({"galaxy": "karma"});
3337 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3338 assert!(result.is_err());
3339 match result {
3340 Err(CoreError::Governance(msg)) => {
3341 assert!(msg.contains("production"));
3342 assert!(msg.contains("karma"));
3343 assert!(msg.contains("runtime"));
3344 }
3345 other => panic!("Expected Governance error, got {other:?}"),
3346 }
3347 }
3348
3349 #[tokio::test]
3350 async fn pipeline_compartment_production_blocks_runtime_galaxy_read_bypass() {
3351 let pipeline = DispatchPipeline::with_defaults();
3354 let mut ctx = Context::new(BrainWave::Gamma);
3355 ctx.compartment = Some("production".into());
3356 let tool = TestTool::new(
3357 "memory_read",
3358 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3359 );
3360
3361 let args = serde_json::json!({"galaxy": "karma"});
3362 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3363 assert!(result.is_err());
3364 match result {
3365 Err(CoreError::Governance(msg)) => {
3366 assert!(msg.contains("production"));
3367 assert!(msg.contains("karma"));
3368 assert!(msg.contains("runtime"));
3369 }
3370 other => panic!("Expected Governance error, got {other:?}"),
3371 }
3372 }
3373
3374 #[tokio::test]
3375 async fn pipeline_compartment_production_allows_runtime_galaxy_same_as_declared() {
3376 let pipeline = DispatchPipeline::with_defaults();
3379 let mut ctx = Context::new(BrainWave::Gamma);
3380 ctx.compartment = Some("production".into());
3381 let tool = TestTool::new(
3382 "memory_read",
3383 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3384 );
3385
3386 let args = serde_json::json!({"galaxy": "codex"});
3387 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3388 assert!(result.is_ok());
3389 }
3390
3391 #[tokio::test]
3392 async fn pipeline_compartment_no_restriction_allows_runtime_galaxy() {
3393 let pipeline = DispatchPipeline::with_defaults();
3395 let mut ctx = Context::new(BrainWave::Gamma);
3396 let tool = TestTool::new(
3397 "memory_read",
3398 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3399 );
3400
3401 let args = serde_json::json!({"galaxy": "karma"});
3402 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3403 assert!(result.is_ok());
3404 }
3405
3406 #[tokio::test]
3407 async fn pipeline_compartment_production_allows_runtime_memory_galaxy() {
3408 let pipeline = DispatchPipeline::with_defaults();
3411 let mut ctx = Context::new(BrainWave::Gamma);
3412 ctx.compartment = Some("production".into());
3413 let tool = TestTool::new(
3414 "memory_read",
3415 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3416 );
3417
3418 let args = serde_json::json!({"galaxy": "research"});
3419 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3420 assert!(result.is_ok());
3421 }
3422
3423 #[tokio::test]
3424 async fn pipeline_compartment_production_blocks_runtime_system_galaxy() {
3425 let pipeline = DispatchPipeline::with_defaults();
3428 let mut ctx = Context::new(BrainWave::Gamma);
3429 ctx.compartment = Some("production".into());
3430 let tool = TestTool::new(
3431 "memory_read",
3432 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3433 );
3434
3435 let args = serde_json::json!({"galaxy": "karma"});
3436 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3437 assert!(result.is_err());
3438 match result {
3439 Err(CoreError::Governance(msg)) => {
3440 assert!(msg.contains("production"));
3441 assert!(msg.contains("karma"));
3442 assert!(msg.contains("runtime"));
3443 }
3444 other => panic!("Expected Governance error, got {other:?}"),
3445 }
3446 }
3447
3448 #[tokio::test]
3449 async fn benchmark_pipeline_overhead() {
3450 let pipeline = DispatchPipeline::with_defaults();
3451 let tool = TestTool::new("bench_tool", EffectRow::pure());
3452 let args = Args::default();
3453
3454 for _ in 0..100 {
3456 let mut ctx = Context::new(BrainWave::Gamma);
3457 let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3458 }
3459
3460 let n = 10_000;
3462 let start = std::time::Instant::now();
3463 for _ in 0..n {
3464 let mut ctx = Context::new(BrainWave::Gamma);
3465 let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3466 }
3467 let pipeline_ns = start.elapsed().as_nanos() / n;
3468
3469 let start = std::time::Instant::now();
3471 for _ in 0..n {
3472 let mut ctx = Context::new(BrainWave::Gamma);
3473 let _ = tool.call(&mut ctx, args.clone()).await;
3474 }
3475 let direct_ns = start.elapsed().as_nanos() / n;
3476
3477 let overhead_ns = pipeline_ns.saturating_sub(direct_ns);
3478 println!(
3479 "\n Pipeline: {pipeline_ns} ns/call | Direct: {direct_ns} ns/call | Overhead: {overhead_ns} ns/call"
3480 );
3481
3482 #[cfg(not(debug_assertions))]
3486 assert!(
3487 overhead_ns < 5_000,
3488 "Pipeline overhead {overhead_ns} ns/call exceeds 5µs budget"
3489 );
3490 }
3491
3492 struct CountingReceiptHook {
3494 calls: std::sync::atomic::AtomicU64,
3495 passes: std::sync::atomic::AtomicU64,
3496 pass_arg_seen: std::sync::atomic::AtomicBool,
3497 }
3498
3499 impl ReceiptDispatchHook for CountingReceiptHook {
3500 fn on_authority_dispatch(&self, dispatch: AuthorityDispatch<'_>) {
3501 self.calls
3502 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3503 if dispatch.pass.is_some() {
3504 self.passes
3505 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3506 }
3507 if dispatch.args.get("mandala_pass").is_some() {
3508 self.pass_arg_seen
3509 .store(true, std::sync::atomic::Ordering::Relaxed);
3510 }
3511 }
3512 }
3513
3514 fn counting_hook() -> Arc<CountingReceiptHook> {
3515 Arc::new(CountingReceiptHook {
3516 calls: std::sync::atomic::AtomicU64::new(0),
3517 passes: std::sync::atomic::AtomicU64::new(0),
3518 pass_arg_seen: std::sync::atomic::AtomicBool::new(false),
3519 })
3520 }
3521
3522 fn destructive_tool(name: &str) -> TestTool {
3523 TestTool::new(
3524 name,
3525 EffectRow {
3526 destructive: true,
3527 ..EffectRow::default()
3528 },
3529 )
3530 }
3531
3532 #[tokio::test]
3533 async fn receipt_hook_fires_only_for_successful_destructive_dispatch() {
3534 let hook = counting_hook();
3535 let pipeline =
3536 DispatchPipeline::with_defaults().with_receipt_hook_option(Some(hook.clone()));
3537 assert!(pipeline.receipt_hook().is_some());
3538
3539 let read_tool = TestTool::new(
3541 "test.read",
3542 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3543 );
3544 let result = pipeline
3545 .dispatch(
3546 &read_tool,
3547 &mut Context::new(BrainWave::Beta),
3548 Args::default(),
3549 )
3550 .await;
3551 assert!(result.is_ok());
3552 assert_eq!(
3553 hook.calls.load(std::sync::atomic::Ordering::Relaxed),
3554 0,
3555 "read dispatch must not hit the authority seam"
3556 );
3557
3558 let tool = destructive_tool("test.destructive");
3560 let result = pipeline
3561 .dispatch(
3562 &tool,
3563 &mut Context::new(BrainWave::Beta),
3564 serde_json::json!({"confirm": true}),
3565 )
3566 .await;
3567 assert!(result.is_ok(), "{result:?}");
3568 assert_eq!(
3569 hook.calls.load(std::sync::atomic::Ordering::Relaxed),
3570 1,
3571 "one authority-seam emission per successful destructive dispatch"
3572 );
3573 }
3574
3575 #[tokio::test]
3576 async fn receipt_hook_disabled_adds_no_measurable_dispatch_cost() {
3577 let tool = destructive_tool("test.destructive.timing");
3578 let args = serde_json::json!({"confirm": true});
3579 let n: u128 = 2_000;
3580
3581 let plain = DispatchPipeline::with_defaults();
3583 assert!(plain.receipt_hook().is_none());
3584 let start = std::time::Instant::now();
3585 for _ in 0..n {
3586 let _ = plain
3587 .dispatch(&tool, &mut Context::new(BrainWave::Beta), args.clone())
3588 .await;
3589 }
3590 let disabled_ns = start.elapsed().as_nanos() / n;
3591
3592 let hook = counting_hook();
3594 let hooked = DispatchPipeline::with_defaults().with_receipt_hook_option(Some(hook.clone()));
3595 let start = std::time::Instant::now();
3596 let mut successes: u128 = 0;
3597 for _ in 0..n {
3598 if hooked
3599 .dispatch(&tool, &mut Context::new(BrainWave::Beta), args.clone())
3600 .await
3601 .is_ok()
3602 {
3603 successes += 1;
3604 }
3605 }
3606 let enabled_ns = start.elapsed().as_nanos() / n;
3607
3608 println!(
3609 "\n receipts hook: disabled {disabled_ns} ns/call | noop-hook {enabled_ns} ns/call"
3610 );
3611 assert!(
3615 enabled_ns.saturating_sub(disabled_ns) < 5_000,
3616 "no-op receipt hook added {} ns/call (budget 5000)",
3617 enabled_ns.saturating_sub(disabled_ns)
3618 );
3619 assert!(
3620 successes > 0,
3621 "timing loop observed no successful destructive dispatches"
3622 );
3623 assert_eq!(
3624 u128::from(hook.calls.load(std::sync::atomic::Ordering::Relaxed)),
3625 successes,
3626 "hook must observe exactly the successful authority-seam dispatches"
3627 );
3628 }
3629
3630 struct FakePassGate;
3632
3633 impl PassGate for FakePassGate {
3634 fn verify(&self, token: &str, _tool: &str) -> std::result::Result<PassEvidence, String> {
3635 if token == "good-token" {
3636 Ok(PassEvidence {
3637 issuer: "gate:gate-lite-1".into(),
3638 subject: "did:key:zSubject".into(),
3639 audience: "gate-lite".into(),
3640 gate_class: "gate-lite".into(),
3641 slot_class: "small".into(),
3642 gate_did: "did:key:zGate".into(),
3643 policy_version: "2026-09-17.1".into(),
3644 expires_at: 4_102_444_800,
3645 quotas: PassQuotas {
3646 cpu_ms: 1,
3647 mem_mb: 2,
3648 disk_mb: 3,
3649 wall_ms: 4,
3650 },
3651 budget: None,
3652 jti: "jti".into(),
3653 token_digest: "sha256:00".into(),
3654 })
3655 } else {
3656 Err("bad pass".into())
3657 }
3658 }
3659 }
3660
3661 #[tokio::test]
3662 async fn pass_gate_required_refuses_without_a_pass_and_optional_is_unchanged() {
3663 let tool = destructive_tool("test.pass.required");
3664 let args = serde_json::json!({"confirm": true});
3665
3666 let required = DispatchPipeline::with_defaults()
3667 .with_pass_gate_option(Some(Arc::new(FakePassGate)), PassMode::Required);
3668 let result = required
3669 .dispatch(&tool, &mut Context::new(BrainWave::Beta), args.clone())
3670 .await;
3671 assert!(
3672 matches!(result, Err(CoreError::Governance(_))),
3673 "{result:?}"
3674 );
3675
3676 let optional = DispatchPipeline::with_defaults()
3677 .with_pass_gate_option(Some(Arc::new(FakePassGate)), PassMode::Optional);
3678 let result = optional
3679 .dispatch(&tool, &mut Context::new(BrainWave::Beta), args)
3680 .await;
3681 assert!(result.is_ok(), "{result:?}");
3682 }
3683
3684 #[tokio::test]
3685 async fn pass_gate_verifies_and_strips_the_token_before_dispatch() {
3686 let hook = counting_hook();
3687 let tool = destructive_tool("test.pass.governed");
3688 let pipeline = DispatchPipeline::with_defaults()
3689 .with_receipt_hook_option(Some(hook.clone()))
3690 .with_pass_gate_option(Some(Arc::new(FakePassGate)), PassMode::Optional);
3691
3692 let result = pipeline
3693 .dispatch(
3694 &tool,
3695 &mut Context::new(BrainWave::Beta),
3696 serde_json::json!({"confirm": true, "mandala_pass": "good-token"}),
3697 )
3698 .await;
3699 assert!(result.is_ok(), "{result:?}");
3700 assert_eq!(hook.calls.load(std::sync::atomic::Ordering::Relaxed), 1);
3701 assert_eq!(
3702 hook.passes.load(std::sync::atomic::Ordering::Relaxed),
3703 1,
3704 "pass evidence must reach the authority-seam hook"
3705 );
3706 assert!(
3707 !hook
3708 .pass_arg_seen
3709 .load(std::sync::atomic::Ordering::Relaxed),
3710 "the pass token must be stripped before the tool/args digest"
3711 );
3712
3713 let result = pipeline
3715 .dispatch(
3716 &tool,
3717 &mut Context::new(BrainWave::Beta),
3718 serde_json::json!({"confirm": true, "mandala_pass": "bad-token"}),
3719 )
3720 .await;
3721 assert!(
3722 matches!(result, Err(CoreError::Governance(_))),
3723 "{result:?}"
3724 );
3725 assert_eq!(
3726 hook.calls.load(std::sync::atomic::Ordering::Relaxed),
3727 1,
3728 "refused dispatch must not emit evidence"
3729 );
3730 }
3731}