1use std::collections::HashMap;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use tokio::sync::{broadcast, Mutex};
7
8use crate::core::audit::HashChain;
9use crate::core::executor::ModuleExecutor;
10use crate::core::governance::{ApprovalQueue, ApprovalRequest, ApprovalStatus, Role};
11use crate::core::module::{Capability, Intent};
12use crate::core::safety::{
13 now_secs, Charter, ConfigPolicy, CvssScore, DecisionRecord, PolicyContext, PolicyDecision,
14 PolicyEngine, PolicyRequest, PolicyRule, PolicySet, RiskLevel, ScopeManager,
15};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct GovernAction {
20 pub action: String,
21 pub target: String,
22 pub capability: String,
23 pub impact: RiskLevel,
24 pub destructive: bool,
25 #[serde(default)]
26 pub metadata: HashMap<String, String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct GovernResult {
32 pub approved: bool,
33 pub decision: String,
34 pub reason: Option<String>,
35 pub decision_id: u64,
36 pub chain_tip: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ActionOutcome {
42 pub success: bool,
43 pub evidence: Vec<String>,
44 pub data: serde_json::Value,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct RecordResult {
50 pub decision_id: u64,
51 pub chain_tip: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TaskSpec {
56 pub name: String,
57 pub target: String,
58 pub capabilities: Vec<Capability>,
59 pub impact: RiskLevel,
60 pub destructive: bool,
61 #[serde(default)]
62 pub options: HashMap<String, String>,
63 #[serde(default)]
64 pub agent_id: Option<String>,
65 #[serde(default)]
66 pub context: PolicyContext,
67 #[serde(default)]
68 pub approved: bool,
69 #[serde(default)]
70 pub cvss: Option<CvssScore>,
71}
72
73impl Default for TaskSpec {
74 fn default() -> Self {
75 TaskSpec {
76 name: String::new(),
77 target: String::new(),
78 capabilities: Vec::new(),
79 impact: RiskLevel::Low,
80 destructive: false,
81 options: HashMap::new(),
82 agent_id: None,
83 context: PolicyContext::Autonomous,
84 approved: false,
85 cvss: None,
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct GovernanceConfig {
92 pub charter: Charter,
93 pub scope: ScopeManager,
94 #[serde(default)]
95 pub max_risk: RiskLevel,
96 #[serde(default)]
97 pub role: Role,
98 #[serde(default)]
99 pub policy_set: PolicySet,
100 #[serde(default)]
101 pub rate_limits: HashMap<String, u64>,
102}
103
104impl Default for GovernanceConfig {
105 fn default() -> Self {
106 GovernanceConfig {
107 charter: Charter::default(),
108 scope: ScopeManager::default(),
109 max_risk: RiskLevel::Critical,
110 role: Role::Operator,
111 policy_set: PolicySet::default(),
112 rate_limits: HashMap::new(),
113 }
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub enum GovernedOutcome {
119 Allowed {
120 result: Value,
121 decision_id: u64,
122 },
123 Blocked {
124 reason: String,
125 decision_id: u64,
126 },
127 NeedsApproval {
128 reason: String,
129 decision_id: u64,
130 approval_id: u64,
131 },
132}
133
134#[derive(Debug, Default)]
135struct RateState {
136 count: u64,
137 window_start: u64,
138}
139
140#[derive(Debug)]
141struct RuntimeState {
142 config: GovernanceConfig,
143 audit: HashChain,
144 approvals: ApprovalQueue,
145 rate: HashMap<String, RateState>,
146 audit_tx: broadcast::Sender<DecisionRecord>,
147 next_decision_id: u64,
148}
149
150impl RuntimeState {
151 fn check_rate_limit(&mut self, name: &str) -> Option<String> {
152 let limit = *self.config.rate_limits.get(name)?;
153 let now = now_secs();
154 let st = self.rate.entry(name.to_string()).or_default();
155 if now.saturating_sub(st.window_start) >= 60 {
156 st.count = 0;
157 st.window_start = now;
158 }
159 if st.count >= limit {
160 return Some(format!("rate limit of {limit}/min exceeded for {name}"));
161 }
162 st.count += 1;
163 None
164 }
165}
166
167fn derive_intents(caps: &[Capability]) -> Vec<Intent> {
168 caps.iter().map(|c| c.intent()).collect()
169}
170
171#[derive(Debug, Clone)]
172pub struct GovernanceRuntime {
173 state: Arc<Mutex<RuntimeState>>,
174}
175
176impl GovernanceRuntime {
177 pub fn builder() -> GovernanceBuilder {
178 GovernanceBuilder::new()
179 }
180
181 pub async fn execute<F, Fut>(&self, task: TaskSpec, action: F) -> GovernedOutcome
182 where
183 F: FnOnce() -> Fut,
184 Fut: std::future::Future<Output = Result<Value, String>> + Send + 'static,
185 {
186 self.enforce(task, action, false).await
187 }
188
189 pub async fn execute_module(
190 &self,
191 name: &str,
192 target: &str,
193 options: &HashMap<String, String>,
194 ) -> Result<String, String> {
195 let cfg = self.state.lock().await.config.clone();
196 let mut exec = ModuleExecutor::new(cfg.charter, cfg.scope, cfg.max_risk);
197 exec.policy_set = cfg.policy_set;
198 let Some(mut loaded) = crate::modules::load(name) else {
199 return Err(format!("module not found: {name}"));
200 };
201 for (k, v) in options {
202 let _ = loaded.module.set_option(k, v);
203 }
204 match exec
205 .execute(
206 &mut loaded,
207 target,
208 None,
209 true,
210 PolicyContext::Rest,
211 None,
212 false,
213 None,
214 )
215 .await
216 {
217 Ok(r) => Ok(serde_json::to_string(&r).unwrap_or_default()),
218 Err(e) => Err(e.to_string()),
219 }
220 }
221
222 pub async fn preflight(&self, task: TaskSpec) -> GovernedOutcome {
226 let mut st = self.state.lock().await;
227
228 if let Some(reason) = st.check_rate_limit(&task.name) {
229 return st.record_and_block(task, reason);
230 }
231
232 let approved = task.approved;
233 let req = PolicyRequest {
234 target: task.target.clone(),
235 capabilities: task.capabilities.clone(),
236 impact: task.impact,
237 destructive: task.destructive,
238 charter_accepted: st.config.charter.accepted,
239 in_scope: st.config.scope.is_in_scope(&task.target),
240 approved,
241 context: task.context,
242 cvss: None,
243 };
244 let policy = ConfigPolicy {
245 max_risk: st.config.policy_set.max_risk(st.config.max_risk),
246 context: task.context,
247 rules: st.config.policy_set.clone(),
248 };
249 let decision = policy.evaluate(&req);
250
251 let in_scope = st.config.scope.is_in_scope(&task.target);
252 if !in_scope {
253 let target = task.target.clone();
254 return st.record_and_block(
255 task,
256 format!("target out of scope: {target}"),
257 );
258 }
259
260 let decision_id = st.next_decision_id;
261 st.next_decision_id += 1;
262 let rec = DecisionRecord {
263 at: now_secs(),
264 target: task.target.clone(),
265 module: task.name.clone(),
266 capabilities: task.capabilities.clone(),
267 intents: derive_intents(&task.capabilities),
268 impact: task.impact,
269 context: task.context,
270 decision: decision.clone(),
271 };
272 st.audit.append(rec.clone());
273 let _ = st.audit_tx.send(rec);
274
275 match decision {
276 PolicyDecision::Deny(reason) => GovernedOutcome::Blocked {
277 reason,
278 decision_id,
279 },
280 PolicyDecision::RequireApproval(reason) if !approved => {
281 let approval_id = st.approvals.request(
282 task.name.clone(),
283 task.target.clone(),
284 reason.clone(),
285 task.options.clone(),
286 );
287 GovernedOutcome::NeedsApproval {
288 reason,
289 decision_id,
290 approval_id,
291 }
292 }
293 _ => GovernedOutcome::Allowed {
294 result: Value::Null,
295 decision_id,
296 },
297 }
298 }
299
300 pub async fn complete(
303 &self,
304 task: TaskSpec,
305 result: Value,
306 ) -> GovernedOutcome {
307 let mut st = self.state.lock().await;
308 let intents = derive_intents(&task.capabilities);
309 let decision_id = st.next_decision_id;
310 st.next_decision_id += 1;
311 let rec = DecisionRecord {
312 at: now_secs(),
313 target: task.target,
314 module: task.name,
315 capabilities: task.capabilities,
316 intents,
317 impact: task.impact,
318 context: task.context,
319 decision: PolicyDecision::Allow,
320 };
321 st.audit.append(rec.clone());
322 let _ = st.audit_tx.send(rec);
323 GovernedOutcome::Allowed { result, decision_id }
324 }
325
326 pub async fn run<F, Fut>(&self, task: TaskSpec, action: F) -> GovernedOutcome
327 where
328 F: FnOnce() -> Fut,
329 Fut: std::future::Future<Output = Result<Value, String>> + Send + 'static,
330 {
331 self.enforce(task, action, true).await
332 }
333
334 async fn enforce<F, Fut>(
335 &self,
336 task: TaskSpec,
337 action: F,
338 auto_approve: bool,
339 ) -> GovernedOutcome
340 where
341 F: FnOnce() -> Fut,
342 Fut: std::future::Future<Output = Result<Value, String>> + Send + 'static,
343 {
344 let mut st = self.state.lock().await;
345
346 if let Some(reason) = st.check_rate_limit(&task.name) {
347 return st.record_and_block(task, reason);
348 }
349
350 let approved = auto_approve || task.approved;
351 let req = PolicyRequest {
352 target: task.target.clone(),
353 capabilities: task.capabilities.clone(),
354 impact: task.impact,
355 destructive: task.destructive,
356 charter_accepted: st.config.charter.accepted,
357 in_scope: st.config.scope.is_in_scope(&task.target),
358 approved,
359 context: task.context,
360 cvss: task.cvss.clone(),
361 };
362 let policy = ConfigPolicy {
363 max_risk: st.config.policy_set.max_risk(st.config.max_risk),
364 context: task.context,
365 rules: st.config.policy_set.clone(),
366 };
367 let decision = policy.evaluate(&req);
368
369 let in_scope = st.config.scope.is_in_scope(&task.target);
370 if !in_scope {
371 let target = task.target.clone();
372 return st.record_and_block(
373 task,
374 format!("target out of scope: {target}"),
375 );
376 }
377
378 let decision_id = st.next_decision_id;
379 st.next_decision_id += 1;
380 let rec = DecisionRecord {
381 at: now_secs(),
382 target: task.target.clone(),
383 module: task.name.clone(),
384 capabilities: task.capabilities.clone(),
385 intents: derive_intents(&task.capabilities),
386 impact: task.impact,
387 context: task.context,
388 decision: decision.clone(),
389 };
390 st.audit.append(rec.clone());
391 let _ = st.audit_tx.send(rec);
392
393 match decision {
394 PolicyDecision::Deny(reason) => GovernedOutcome::Blocked {
395 reason,
396 decision_id,
397 },
398 PolicyDecision::RequireApproval(reason) if !approved => {
399 let approval_id = st.approvals.request(
400 task.name.clone(),
401 task.target.clone(),
402 reason.clone(),
403 task.options.clone(),
404 );
405 GovernedOutcome::NeedsApproval {
406 reason,
407 decision_id,
408 approval_id,
409 }
410 }
411 PolicyDecision::RequireApproval(_) | PolicyDecision::Allow => {
412 drop(st);
413 match action().await {
414 Ok(result) => GovernedOutcome::Allowed {
415 result,
416 decision_id,
417 },
418 Err(e) => GovernedOutcome::Blocked {
419 reason: format!("task action failed: {e}"),
420 decision_id,
421 },
422 }
423 }
424 }
425 }
426
427 pub async fn approve(&self, id: u64) -> bool {
428 self.state.lock().await.approvals.approve(id)
429 }
430
431 pub async fn deny(&self, id: u64) -> bool {
432 self.state.lock().await.approvals.deny(id)
433 }
434
435 pub async fn pending_approvals(&self) -> Vec<ApprovalRequest> {
436 self.state
437 .lock()
438 .await
439 .approvals
440 .list()
441 .into_iter()
442 .filter(|a| matches!(a.status, ApprovalStatus::Pending))
443 .collect()
444 }
445
446 pub async fn audit(&self) -> Vec<DecisionRecord> {
447 self.state.lock().await.audit.records()
448 }
449
450 pub async fn export_audit_json(&self) -> String {
451 let d = self.state.lock().await.audit.records();
452 serde_json::to_string_pretty(&d).unwrap_or_else(|_| "[]".into())
453 }
454
455 pub async fn export_audit_csv(&self) -> String {
456 let d = self.state.lock().await.audit.records();
457 crate::core::governance::audit_to_csv(&d)
458 }
459
460 pub async fn audit_stream(&self) -> broadcast::Receiver<DecisionRecord> {
461 self.state.lock().await.audit_tx.subscribe()
462 }
463
464 pub async fn add_rule(&self, rule: PolicyRule) {
465 self.state.lock().await.config.policy_set.add_rule(rule);
466 }
467
468 pub async fn policy_set(&self) -> PolicySet {
469 self.state.lock().await.config.policy_set.clone()
470 }
471
472 pub async fn role(&self) -> Role {
473 self.state.lock().await.config.role
474 }
475
476 pub async fn config(&self) -> GovernanceConfig {
477 self.state.lock().await.config.clone()
478 }
479}
480
481impl RuntimeState {
482 fn record_and_block(&mut self, task: TaskSpec, reason: String) -> GovernedOutcome {
483 let decision_id = self.next_decision_id;
484 self.next_decision_id += 1;
485 let rec = DecisionRecord {
486 at: now_secs(),
487 target: task.target.clone(),
488 module: task.name.clone(),
489 capabilities: task.capabilities.clone(),
490 intents: derive_intents(&task.capabilities),
491 impact: task.impact,
492 context: task.context,
493 decision: PolicyDecision::Deny(reason.clone()),
494 };
495 self.audit.append(rec.clone());
496 let _ = self.audit_tx.send(rec);
497 GovernedOutcome::Blocked {
498 reason,
499 decision_id,
500 }
501 }
502}
503
504pub fn govern(config: GovernanceConfig) -> GovernanceRuntime {
505 let (audit_tx, _rx) = broadcast::channel(1024);
506 GovernanceRuntime {
507 state: Arc::new(Mutex::new(RuntimeState {
508 config,
509 audit: HashChain::new(),
510 approvals: ApprovalQueue::default(),
511 rate: HashMap::new(),
512 audit_tx,
513 next_decision_id: 1,
514 })),
515 }
516}
517
518pub struct GovernanceBuilder {
519 config: GovernanceConfig,
520}
521
522impl GovernanceBuilder {
523 pub fn new() -> Self {
524 GovernanceBuilder {
525 config: GovernanceConfig::default(),
526 }
527 }
528
529 pub fn charter(mut self, charter: Charter) -> Self {
530 self.config.charter = charter;
531 self
532 }
533
534 pub fn scope(mut self, allow: Vec<String>) -> Self {
535 self.config.scope = ScopeManager::new(allow);
536 self
537 }
538
539 pub fn max_risk(mut self, max_risk: RiskLevel) -> Self {
540 self.config.max_risk = max_risk;
541 self
542 }
543
544 pub fn role(mut self, role: Role) -> Self {
545 self.config.role = role;
546 self
547 }
548
549 pub fn deny_capability(mut self, cap: Capability) -> Self {
550 self.config
551 .policy_set
552 .add_rule(PolicyRule::DenyCapability(cap));
553 self
554 }
555
556 pub fn allow_capability(mut self, cap: Capability) -> Self {
557 self.config
558 .policy_set
559 .add_rule(PolicyRule::AllowCapability(cap));
560 self
561 }
562
563 pub fn require_approval(mut self, cap: Capability, target_pattern: &str) -> Self {
564 self.config
565 .policy_set
566 .add_rule(PolicyRule::RequireApproval {
567 capability: cap,
568 target_pattern: target_pattern.to_string(),
569 });
570 self
571 }
572
573 pub fn max_risk_ceiling(mut self, max_risk: RiskLevel) -> Self {
574 self.config
575 .policy_set
576 .add_rule(PolicyRule::MaxRisk(max_risk));
577 self
578 }
579
580 pub fn deny_if_cvss_above(mut self, threshold: f64) -> Self {
581 self.config
582 .policy_set
583 .add_rule(PolicyRule::DenyIfCvssAbove(threshold));
584 self
585 }
586
587 pub fn require_approval_if(
588 mut self,
589 cvss_above: Option<f64>,
590 epss_above: Option<f64>,
591 kev: bool,
592 ) -> Self {
593 self.config
594 .policy_set
595 .add_rule(PolicyRule::RequireApprovalIf {
596 cvss_above,
597 epss_above,
598 kev,
599 });
600 self
601 }
602
603 pub fn rate_limit(mut self, name: &str, per_minute: u64) -> Self {
604 self.config.rate_limits.insert(name.to_string(), per_minute);
605 self
606 }
607
608 pub fn build(self) -> GovernanceRuntime {
609 govern(self.config)
610 }
611}
612
613impl Default for GovernanceBuilder {
614 fn default() -> Self {
615 Self::new()
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use serde_json::json;
623
624 fn base_runtime() -> GovernanceRuntime {
625 GovernanceRuntime::builder()
626 .charter(Charter::accept("eng", vec!["no destruction".into()]))
627 .scope(vec!["10.0.0.0/8".into()])
628 .max_risk(RiskLevel::Critical)
629 .role(Role::Admin)
630 .build()
631 }
632
633 fn low_risk_task(target: &str) -> TaskSpec {
634 TaskSpec {
635 name: "scan".into(),
636 target: target.into(),
637 capabilities: vec![Capability::NetworkScan],
638 impact: RiskLevel::Low,
639 ..Default::default()
640 }
641 }
642
643 #[tokio::test]
644 async fn supervised_low_risk_runs_and_audits() {
645 let rt = base_runtime();
646 let out = rt
647 .execute(low_risk_task("10.0.0.5"), || async {
648 Ok(json!({"open_ports": [22, 80]}))
649 })
650 .await;
651 match out {
652 GovernedOutcome::Allowed { result, .. } => {
653 assert_eq!(result["open_ports"][0], 22);
654 }
655 other => panic!("expected Allowed, got {other:?}"),
656 }
657 assert_eq!(rt.audit().await.len(), 1);
658 }
659
660 #[tokio::test]
661 async fn out_of_scope_is_blocked() {
662 let rt = base_runtime();
663 let out = rt
664 .execute(low_risk_task("8.8.8.8"), || async { Ok(json!(null)) })
665 .await;
666 assert!(matches!(out, GovernedOutcome::Blocked { .. }));
667 }
668
669 #[tokio::test]
670 async fn deny_capability_blocks() {
671 let rt = GovernanceRuntime::builder()
672 .charter(Charter::accept("eng", vec![]))
673 .scope(vec!["10.0.0.0/8".into()])
674 .deny_capability(Capability::Persistence)
675 .build();
676 let task = TaskSpec {
677 name: "implant".into(),
678 target: "10.0.0.9".into(),
679 capabilities: vec![Capability::Persistence],
680 impact: RiskLevel::High,
681 ..Default::default()
682 };
683 let out = rt.execute(task, || async { Ok(json!(null)) }).await;
684 assert!(matches!(out, GovernedOutcome::Blocked { .. }));
685 }
686
687 #[tokio::test]
688 async fn destructive_requires_approval_then_runs_after_approve() {
689 let rt = base_runtime();
690 let task = TaskSpec {
691 name: "wipe".into(),
692 target: "10.0.0.7".into(),
693 capabilities: vec![Capability::FilesystemModification],
694 impact: RiskLevel::High,
695 destructive: true,
696 ..Default::default()
697 };
698 let out = rt
699 .execute(task.clone(), || async { Ok(json!({"wiped": true})) })
700 .await;
701 let id = match out {
702 GovernedOutcome::NeedsApproval { approval_id, .. } => approval_id,
703 other => panic!("expected NeedsApproval, got {other:?}"),
704 };
705 assert!(rt.approve(id).await);
706 assert!(rt.pending_approvals().await.is_empty());
707 }
708
709 #[tokio::test]
710 async fn require_approval_rule_gates_specific_target() {
711 let rt = GovernanceRuntime::builder()
712 .charter(Charter::accept("eng", vec![]))
713 .scope(vec!["0.0.0.0/0".into()])
714 .require_approval(Capability::CredentialAccess, "192.168.*")
715 .build();
716 let in_scope = TaskSpec {
717 name: "dump".into(),
718 target: "192.168.1.5".into(),
719 capabilities: vec![Capability::CredentialAccess],
720 impact: RiskLevel::Low,
721 ..Default::default()
722 };
723 let out = rt.execute(in_scope, || async { Ok(json!(null)) }).await;
724 assert!(matches!(out, GovernedOutcome::NeedsApproval { .. }));
725
726 let other = TaskSpec {
727 name: "dump".into(),
728 target: "10.0.0.5".into(),
729 capabilities: vec![Capability::CredentialAccess],
730 impact: RiskLevel::Low,
731 ..Default::default()
732 };
733 let out = rt.execute(other, || async { Ok(json!(null)) }).await;
734 assert!(matches!(out, GovernedOutcome::Allowed { .. }));
735 }
736
737 #[tokio::test]
738 async fn unsupervised_run_auto_grants_approval() {
739 let rt = base_runtime();
740 let task = TaskSpec {
741 name: "wipe".into(),
742 target: "10.0.0.7".into(),
743 capabilities: vec![Capability::FilesystemModification],
744 impact: RiskLevel::High,
745 destructive: true,
746 ..Default::default()
747 };
748 let out = rt.run(task, || async { Ok(json!({"ok": true})) }).await;
749 assert!(matches!(out, GovernedOutcome::Allowed { .. }));
750 }
751
752 #[tokio::test]
753 async fn rate_limit_blocks_after_ceiling() {
754 let rt = GovernanceRuntime::builder()
755 .charter(Charter::accept("eng", vec![]))
756 .scope(vec!["0.0.0.0/0".into()])
757 .rate_limit("scan", 2)
758 .build();
759 let task = low_risk_task("10.0.0.5");
760 for _ in 0..2 {
761 let out = rt.execute(task.clone(), || async { Ok(json!(null)) }).await;
762 assert!(matches!(out, GovernedOutcome::Allowed { .. }));
763 }
764 let out = rt.execute(task.clone(), || async { Ok(json!(null)) }).await;
765 assert!(matches!(out, GovernedOutcome::Blocked { .. }));
766 }
767
768 #[tokio::test]
769 async fn audit_stream_delivers_records() {
770 let rt = base_runtime();
771 let mut rx = rt.audit_stream().await;
772 let _ = rt
773 .execute(low_risk_task("10.0.0.5"), || async { Ok(json!(null)) })
774 .await;
775 let rec = rx.recv().await.expect("audit record");
776 assert_eq!(rec.target, "10.0.0.5");
777 }
778
779 #[tokio::test]
780 async fn config_round_trips_through_json() {
781 let rt = base_runtime();
782 let json = serde_json::to_string(&rt.config().await).unwrap();
783 let cfg: GovernanceConfig = serde_json::from_str(&json).unwrap();
784 assert!(cfg.charter.accepted);
785 assert_eq!(cfg.role, Role::Admin);
786 }
787}