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#[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 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 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}