Skip to main content

icebox/core/
executor.rs

1use std::str::FromStr;
2use tracing::info;
3
4use crate::core::audit::HashChain;
5use crate::core::gee::GeeStage;
6use crate::core::module::{Capability, Intent, LoadedModule, ModuleError, ModuleResult};
7use crate::core::safety::{
8    make_config_policy, now_secs, Charter, ConfigPolicy, DecisionRecord, Evidence, MemoryEntry,
9    MemoryKind, PolicyContext, PolicyDecision, PolicyEngine, PolicyRequest, PolicySet, Preflight,
10    PreflightError, ReasoningTrace, RiskLevel, ScopeManager,
11};
12use crate::core::sandbox::Sandbox;
13use crate::core::sdk::{ActionOutcome, GovernAction, GovernResult, RecordResult};
14
15const MAX_EVIDENCE: usize = 2000;
16const MAX_TRACES: usize = 1000;
17const MAX_MEMORIES: usize = 1000;
18
19#[derive(Debug, thiserror::Error)]
20pub enum ExecutorError {
21    #[error(transparent)]
22    Preflight(#[from] PreflightError),
23    #[error(transparent)]
24    Module(#[from] ModuleError),
25    #[error("sandbox error: {0}")]
26    Sandbox(String),
27}
28
29/// The execution seam of ICEBOX and its fundamental execution primitive:
30/// every action runs as a Governed Execution Environment (GEE). This type owns
31/// the GEE lifecycle (policy evaluation, sandbox provisioning, approval
32/// gating, execution, evidence collection, audit, validation, and teardown)
33/// and keeps the state required to enforce it.
34#[derive(Debug)]
35pub struct ModuleExecutor {
36    pub charter: Charter,
37    pub scope: ScopeManager,
38    pub max_risk: RiskLevel,
39    pub policy_set: PolicySet,
40    pub sandbox_required: bool,
41    pub tier: crate::core::safety::Tier,
42    pub audit: HashChain,
43    pub evidence: Vec<Evidence>,
44    pub traces: Vec<ReasoningTrace>,
45    pub memories: Vec<MemoryEntry>,
46    pub stage: GeeStage,
47}
48
49impl ModuleExecutor {
50    pub fn new(charter: Charter, scope: ScopeManager, max_risk: RiskLevel) -> Self {
51        ModuleExecutor {
52            charter,
53            scope,
54            max_risk,
55            policy_set: PolicySet::default(),
56            sandbox_required: false,
57            tier: crate::core::safety::Tier::Fridge,
58            audit: HashChain::new(),
59            evidence: Vec::new(),
60            traces: Vec::new(),
61            memories: Vec::new(),
62            stage: GeeStage::Request,
63        }
64    }
65
66    pub fn policy(&self, context: PolicyContext) -> ConfigPolicy {
67        let mut policy = make_config_policy(self.max_risk, context, &self.policy_set);
68        if let Some(thr) = self.tier.cvss_threshold() {
69            policy
70                .rules
71                .add_rule(crate::core::safety::PolicyRule::DenyIfCvssAbove(thr));
72        }
73        policy
74    }
75
76    pub fn recent_traces(&self, n: usize) -> Vec<ReasoningTrace> {
77        let end = self.traces.len();
78        let start = end.saturating_sub(n);
79        self.traces[start..].to_vec()
80    }
81
82    pub fn recent_memories(&self, n: usize) -> Vec<MemoryEntry> {
83        let end = self.memories.len();
84        let start = end.saturating_sub(n);
85        self.memories[start..].to_vec()
86    }
87
88    pub fn remember(&mut self, kind: MemoryKind, text: impl Into<String>) {
89        self.memories.push(MemoryEntry {
90            at: now_secs(),
91            kind,
92            text: text.into(),
93        });
94        let overflow = self.memories.len().saturating_sub(MAX_MEMORIES);
95        if overflow > 0 {
96            self.memories.drain(..overflow);
97        }
98    }
99
100    pub fn record_trace(&mut self, trace: ReasoningTrace) {
101        self.traces.push(trace);
102        let overflow = self.traces.len().saturating_sub(MAX_TRACES);
103        if overflow > 0 {
104            self.traces.drain(..overflow);
105        }
106    }
107
108    pub fn recent_decisions(&self, n: usize) -> Vec<DecisionRecord> {
109        self.audit.recent(n)
110    }
111
112    pub fn decisions(&self) -> Vec<DecisionRecord> {
113        self.audit.records()
114    }
115
116    pub fn append_decision(&mut self, record: DecisionRecord) -> u64 {
117        self.audit.append(record)
118    }
119
120    pub fn verify_audit(&self) -> bool {
121        self.audit.verify()
122    }
123
124    pub fn audit_chain(&self) -> &HashChain {
125        &self.audit
126    }
127
128    /// Pure governance check: evaluate policy, scope, and approval gates
129    /// without executing the action. Returns a decision the caller acts upon.
130    /// This is the single-entry "Stripe-style" govern() call.
131    pub fn govern_action(&mut self, action: &GovernAction, context: PolicyContext) -> GovernResult {
132        let capability = match Capability::from_str(&action.capability) {
133            Ok(c) => c,
134            Err(e) => {
135                let decision_id = self.audit.append(DecisionRecord {
136                    at: now_secs(),
137                    target: action.target.clone(),
138                    module: action.action.clone(),
139                    capabilities: vec![],
140                    intents: vec![],
141                    impact: action.impact,
142                    context,
143                    decision: PolicyDecision::Deny(format!("unknown capability: {e}")),
144                });
145                return GovernResult {
146                    approved: false,
147                    decision: "deny".into(),
148                    reason: Some(format!("unknown capability: {}", action.capability)),
149                    decision_id,
150                    chain_tip: self.audit.tip_hex(),
151                };
152            }
153        };
154
155        let in_scope = self.scope.is_in_scope(&action.target);
156
157        let req = PolicyRequest {
158            target: action.target.clone(),
159            capabilities: vec![capability],
160            impact: action.impact,
161            destructive: action.destructive,
162            charter_accepted: self.charter.accepted,
163            in_scope,
164            approved: false,
165            context,
166            cvss: None,
167        };
168
169        let policy = self.policy(context);
170        let decision = policy.evaluate(&req);
171
172        let intents: Vec<Intent> = req.capabilities.iter().map(|c| c.intent()).collect();
173
174        let decision_id = self.audit.append(DecisionRecord {
175            at: now_secs(),
176            target: action.target.clone(),
177            module: action.action.clone(),
178            capabilities: req.capabilities.clone(),
179            intents,
180            impact: action.impact,
181            context,
182            decision: decision.clone(),
183        });
184
185        match decision {
186            PolicyDecision::Deny(reason) => GovernResult {
187                approved: false,
188                decision: "deny".into(),
189                reason: Some(reason),
190                decision_id,
191                chain_tip: self.audit.tip_hex(),
192            },
193            PolicyDecision::RequireApproval(reason) => GovernResult {
194                approved: false,
195                decision: "require_approval".into(),
196                reason: Some(reason),
197                decision_id,
198                chain_tip: self.audit.tip_hex(),
199            },
200            PolicyDecision::Allow => GovernResult {
201                approved: true,
202                decision: "allow".into(),
203                reason: None,
204                decision_id,
205                chain_tip: self.audit.tip_hex(),
206            },
207        }
208    }
209
210    /// Record completion of a prior governed action.
211    /// Appends evidence and an audit-chain entry, then returns the chain tip.
212    pub fn record_action(&mut self, action: &GovernAction, outcome: ActionOutcome) -> RecordResult {
213        for content in &outcome.evidence {
214            let seq = self.evidence.len();
215            self.evidence.push(Evidence::new(
216                &action.action,
217                &action.target,
218                content,
219                None,
220                seq,
221            ));
222        }
223
224        let capability = Capability::from_str(&action.capability).unwrap_or(Capability::NetworkScan);
225        let intents = vec![capability.intent()];
226
227        let decision_id = self.audit.append(DecisionRecord {
228            at: now_secs(),
229            target: action.target.clone(),
230            module: action.action.clone(),
231            capabilities: vec![capability],
232            intents,
233            impact: action.impact,
234            context: PolicyContext::Rest,
235            decision: PolicyDecision::Allow,
236        });
237
238        RecordResult {
239            decision_id,
240            chain_tip: self.audit.tip_hex(),
241        }
242    }
243
244    pub fn recent_evidence(&self, n: usize) -> Vec<Evidence> {
245        let end = self.evidence.len();
246        let start = end.saturating_sub(n);
247        self.evidence[start..].to_vec()
248    }
249
250    pub async fn preflight(
251        &self,
252        loaded: &LoadedModule,
253        target: &str,
254        destructive_override: Option<bool>,
255        approved: bool,
256        context: PolicyContext,
257    ) -> Preflight {
258        let destructive = destructive_override
259            .unwrap_or_else(|| loaded.info.effective_intents().contains(&Intent::Modify));
260
261        Preflight {
262            target: target.to_string(),
263            charter_accepted: self.charter.accepted,
264            in_scope: self.scope.is_in_scope(target),
265            risk: loaded.info.effective_impact(),
266            destructive,
267            approved,
268            capabilities: loaded.info.capabilities.clone(),
269            intents: loaded.info.effective_intents(),
270            context,
271            cvss: None,
272        }
273    }
274
275    #[allow(clippy::too_many_arguments)]
276    pub async fn execute(
277        &mut self,
278        loaded: &mut LoadedModule,
279        target: &str,
280        destructive_override: Option<bool>,
281        approved: bool,
282        context: PolicyContext,
283        job_id: Option<u64>,
284        sandbox: bool,
285        engine: Option<crate::core::sandbox::SandboxEngineType>,
286    ) -> Result<ModuleResult, ExecutorError> {
287        self.stage = GeeStage::PolicyEvaluation;
288        let pf = self
289            .preflight(loaded, target, destructive_override, approved, context)
290            .await;
291        let policy = self.policy(context);
292        let decision = policy.evaluate(&pf.to_request());
293        self.record_decision(&loaded.info.name, &pf, &decision);
294        pf.check(&policy)?;
295
296        self.stage = GeeStage::ScopeEnforcement;
297        if !self.scope.is_in_scope(target) {
298            let reason = format!("target {target} is out of scope");
299            self.record_decision(
300                &loaded.info.name,
301                &pf,
302                &PolicyDecision::Deny(reason.clone()),
303            );
304            return Err(ExecutorError::Preflight(PreflightError::Denied(reason)));
305        }
306
307        self.stage = GeeStage::SandboxProvisioning;
308        if (self.sandbox_required || self.tier.requires_sandbox()) && !sandbox {
309            let reason = format!("operational tier {} requires sandbox isolation", self.tier);
310            self.record_decision(
311                &loaded.info.name,
312                &pf,
313                &PolicyDecision::Deny(reason.clone()),
314            );
315            return Err(ExecutorError::Preflight(PreflightError::Denied(reason)));
316        }
317
318        self.stage = GeeStage::ApprovalCheck;
319        if self.tier.requires_explicit_approval() && !approved {
320            let reason = format!(
321                "operational tier {} requires explicit operator approval",
322                self.tier
323            );
324            self.record_decision(
325                &loaded.info.name,
326                &pf,
327                &PolicyDecision::RequireApproval(reason.clone()),
328            );
329            return Err(ExecutorError::Preflight(PreflightError::ApprovalRequired));
330        }
331
332        self.stage = GeeStage::Execute;
333        info!(
334            target = %target,
335            module = %loaded.info.name,
336            risk = %pf.risk.as_str(),
337            destructive = pf.destructive,
338            sandbox = sandbox,
339            stage = %self.stage.as_str(),
340            "governed execution: preflight passed"
341        );
342
343        if policy.has_deny_payload() {
344            if let Ok(preview) = loaded.module.dry_run().await {
345                let denied = policy.denied_payload(&preview);
346                if !denied.is_empty() {
347                    let reason =
348                        format!("payload matched denied pattern (pre-execution): {denied}");
349                    self.record_decision(
350                        &loaded.info.name,
351                        &pf,
352                        &PolicyDecision::Deny(reason.clone()),
353                    );
354                    return Ok(ModuleResult {
355                        success: false,
356                        evidence: vec![format!("[BLOCKED:payload] {denied}")],
357                        data: serde_json::Value::Null,
358                        ..Default::default()
359                    });
360                }
361            }
362        }
363
364        let (result, active_sandbox): (ModuleResult, Option<Sandbox>) = if sandbox {
365            let (r, s) = self
366                .run_sandboxed(
367                    loaded,
368                    target,
369                    engine.unwrap_or(crate::core::sandbox::SandboxEngineType::Docker),
370                    context,
371                )
372                .await?;
373            if r.error.is_some() {
374                self.record_failure(
375                    &loaded.info.name,
376                    target,
377                    r.error.as_deref().unwrap_or("sandbox failure"),
378                    context,
379                );
380            }
381            (r, Some(s))
382        } else {
383            match loaded.module.run().await {
384                Ok(r) => (r, None),
385                Err(e) => {
386                    let reason = format!("module execution failed: {e}");
387                    self.record_decision(
388                        &loaded.info.name,
389                        &pf,
390                        &PolicyDecision::Deny(reason.clone()),
391                    );
392                    return Err(ExecutorError::Module(e));
393                }
394            }
395        };
396
397        self.stage = GeeStage::CollectEvidence;
398        let mut result = result;
399        if let Some(ref s) = active_sandbox {
400            let logs = s.capture_logs().await;
401            result.evidence.extend(logs);
402        }
403        let denied = policy.denied_payload(&result);
404        if !denied.is_empty() {
405            let reason = format!("payload matched denied pattern: {denied}");
406            self.record_decision(
407                &loaded.info.name,
408                &pf,
409                &PolicyDecision::Deny(reason.clone()),
410            );
411            let mut blocked = result;
412            blocked.success = false;
413            blocked.evidence.push(format!("[BLOCKED:payload] {denied}"));
414            blocked.data = serde_json::Value::Null;
415            self.record_evidence(&loaded.info.name, target, &blocked, job_id);
416            self.stage = GeeStage::Audit;
417            self.stage = GeeStage::Validate;
418            let _ = self.audit.verify();
419            self.stage = GeeStage::Destroy;
420            if let Some(s) = active_sandbox {
421                let _ = s.melt().await;
422            }
423            return Ok(blocked);
424        }
425
426        self.record_evidence(&loaded.info.name, target, &result, job_id);
427        self.stage = GeeStage::Audit;
428        self.stage = GeeStage::Validate;
429        let _ = self.audit.verify();
430        self.stage = GeeStage::Destroy;
431        if let Some(s) = active_sandbox {
432            let _ = s.melt().await;
433        }
434        Ok(result)
435    }
436
437    fn record_decision(
438        &mut self,
439        module: &str,
440        pf: &Preflight,
441        decision: &crate::core::safety::PolicyDecision,
442    ) {
443        self.audit.append(DecisionRecord {
444            at: now_secs(),
445            target: pf.target.clone(),
446            module: module.to_string(),
447            capabilities: pf.capabilities.clone(),
448            intents: pf.intents.clone(),
449            impact: pf.risk,
450            context: pf.context,
451            decision: decision.clone(),
452        });
453    }
454
455    fn record_evidence(
456        &mut self,
457        module: &str,
458        target: &str,
459        result: &ModuleResult,
460        job_id: Option<u64>,
461    ) {
462        let seq_start = self.evidence.len();
463        for (i, content) in result.evidence.iter().enumerate() {
464            self.evidence.push(Evidence::new(
465                module,
466                target,
467                content,
468                job_id,
469                seq_start + i,
470            ));
471        }
472        let overflow = self.evidence.len().saturating_sub(MAX_EVIDENCE);
473        if overflow > 0 {
474            self.evidence.drain(..overflow);
475        }
476    }
477
478    async fn run_sandboxed(
479        &mut self,
480        loaded: &crate::core::module::LoadedModule,
481        target: &str,
482        engine: crate::core::sandbox::SandboxEngineType,
483        context: PolicyContext,
484    ) -> Result<(ModuleResult, Sandbox), ExecutorError> {
485        let image = "icebox-sandbox:latest".to_string();
486        let module_name = loaded.info.name.clone();
487        match Sandbox::freeze(engine, target, &image).await {
488            Ok(sandbox) => {
489                info!(
490                    container = %sandbox.container_id(),
491                    stage = %GeeStage::SandboxProvisioning.as_str(),
492                    "[SANDBOX] container frozen"
493                );
494                let options = loaded.module.options_json();
495                let result = match sandbox
496                    .exec_module(&loaded.info.name, target, &options)
497                    .await
498                {
499                    Ok(r) => r,
500                    Err(e) => ModuleResult {
501                        error: Some(format!("sandbox module exec failed: {e}")),
502                        ..Default::default()
503                    },
504                };
505                Ok((result, sandbox))
506            }
507            Err(e) => {
508                let reason = format!("Sandbox initialization failed: {e}. Isolation is mandatory.");
509                self.record_failure(&module_name, target, &reason, context);
510                Err(ExecutorError::Sandbox(reason))
511            }
512        }
513    }
514
515    fn record_failure(&mut self, module: &str, target: &str, reason: &str, context: PolicyContext) {
516        self.audit.append(DecisionRecord {
517            at: now_secs(),
518            target: target.to_string(),
519            module: module.to_string(),
520            capabilities: Vec::new(),
521            intents: Vec::new(),
522            impact: RiskLevel::None,
523            context,
524            decision: PolicyDecision::Deny(reason.to_string()),
525        });
526    }
527}