Skip to main content

icebox/core/
sdk.rs

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/// An action to be governed by ICEBOX — the "Stripe-style" request.
18#[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/// Decision returned after running an action through the GEE lifecycle.
30#[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/// Outcome of executing an action outside ICEBOX, to be recorded.
40#[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/// Result after recording an action outcome into the audit chain.
48#[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    /// Evaluate policy and record the decision without running the action.
223    /// Returns Allowed (with null result), Blocked, or NeedsApproval.
224    /// The caller runs the action only when Allowed, then calls `complete()`.
225    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    /// Record that a preflighted action completed successfully.
301    /// Creates a new audit entry linked to the preflight decision.
302    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}