fleetforge_runtime/
guardrails.rs

1use std::any::type_name_of_val;
2use std::sync::{Arc, Mutex};
3
4use anyhow::{anyhow, Result};
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use fleetforge_policy::packs::embedded_prompt_injection;
8use fleetforge_policy::{
9    BasicPiiPolicy, BudgetCapsPolicy, Decision, Decision as PolicyDecision, DecisionEffect,
10    PiiMode, PolicyEngine, PolicyRequest, ToolAclPolicy,
11};
12use json_patch::Patch;
13use serde::Serialize;
14use serde_json::{json, Value};
15use uuid::Uuid;
16
17use crate::model::{RunId, StepId};
18use fleetforge_trust::TrustBoundary;
19use futures::future::join_all;
20use once_cell::sync::Lazy;
21use regex::Regex;
22use tracing::warn;
23use url::Url;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26enum PolicyHook {
27    Pre,
28    Mid,
29    Post,
30    Any,
31}
32
33impl PolicyHook {
34    fn applies_to(self, boundary: &TrustBoundary) -> bool {
35        match self {
36            PolicyHook::Any => true,
37            PolicyHook::Pre => matches!(
38                boundary,
39                TrustBoundary::IngressPrompt
40                    | TrustBoundary::IngressTool
41                    | TrustBoundary::IngressMemory
42                    | TrustBoundary::IngressDocument
43                    | TrustBoundary::Scheduler
44            ),
45            PolicyHook::Mid => matches!(boundary, TrustBoundary::Executor),
46            PolicyHook::Post => matches!(
47                boundary,
48                TrustBoundary::EgressPrompt
49                    | TrustBoundary::EgressTool
50                    | TrustBoundary::EgressMemory
51                    | TrustBoundary::Artifact
52                    | TrustBoundary::Gateway
53            ),
54        }
55    }
56}
57
58/// Allowlist constraint for outbound HTTP calls.
59#[derive(Clone, Debug)]
60pub struct HttpAllowRule {
61    pub host: String,
62    pub path_prefix: Option<String>,
63}
64
65/// Captures a single policy engine's contribution to a bundle decision.
66#[derive(Debug, Clone, Serialize)]
67pub struct PolicyDecisionTrace {
68    pub policy: String,
69    pub effect: DecisionEffect,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub reason: Option<String>,
72    #[serde(skip_serializing_if = "Vec::is_empty")]
73    pub patches: Vec<Value>,
74}
75
76/// Structured audit record for guardrail evaluations.
77#[derive(Debug, Clone, Serialize)]
78pub struct PolicyEvaluationRecord {
79    pub boundary: TrustBoundary,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub run_id: Option<Uuid>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub step_id: Option<Uuid>,
84    pub effect: DecisionEffect,
85    #[serde(skip_serializing_if = "Vec::is_empty")]
86    pub reasons: Vec<String>,
87    #[serde(skip_serializing_if = "Vec::is_empty")]
88    pub decisions: Vec<PolicyDecisionTrace>,
89    #[serde(skip_serializing_if = "Vec::is_empty")]
90    pub patches: Vec<Value>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub payload: Option<Value>,
93    pub timestamp: DateTime<Utc>,
94    #[serde(skip_serializing_if = "Vec::is_empty")]
95    pub origins: Vec<Value>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub preview: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub attestation_id: Option<Uuid>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub attestation: Option<Value>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub trust_decision: Option<Value>,
104}
105
106#[derive(Clone)]
107struct PolicyStage {
108    name: String,
109    engine: Arc<dyn PolicyEngine>,
110    hooks: Vec<PolicyHook>,
111}
112
113impl PolicyStage {
114    fn new(name: String, engine: Arc<dyn PolicyEngine>) -> Self {
115        Self {
116            name,
117            engine,
118            hooks: vec![PolicyHook::Any],
119        }
120    }
121
122    fn with_hooks(name: String, engine: Arc<dyn PolicyEngine>, hooks: Vec<PolicyHook>) -> Self {
123        Self {
124            name,
125            engine,
126            hooks,
127        }
128    }
129
130    fn applies(&self, boundary: &TrustBoundary) -> bool {
131        if self.hooks.is_empty() {
132            return true;
133        }
134        self.hooks.iter().any(|hook| hook.applies_to(boundary))
135    }
136}
137
138/// Outcome of evaluating all guardrail policies in a bundle.
139#[derive(Debug)]
140pub struct BundleOutcome {
141    pub effect: DecisionEffect,
142    pub value: Value,
143    pub reasons: Vec<String>,
144    pub decisions: Vec<PolicyDecisionTrace>,
145    pub patches: Vec<Value>,
146}
147
148impl BundleOutcome {
149    pub fn allow(value: Value) -> Self {
150        Self {
151            effect: DecisionEffect::Allow,
152            value,
153            reasons: Vec::new(),
154            decisions: Vec::new(),
155            patches: Vec::new(),
156        }
157    }
158
159    pub fn summary(&self) -> String {
160        if self.reasons.is_empty() {
161            format!("{:?}", self.effect)
162        } else {
163            self.reasons.join("; ")
164        }
165    }
166}
167
168/// Aggregates multiple policy engines and folds their decisions.
169#[derive(Clone)]
170pub struct PolicyBundle {
171    policies: Vec<PolicyStage>,
172    events: Arc<Mutex<Vec<PolicyEvaluationRecord>>>,
173}
174
175impl PolicyBundle {
176    /// Builds a bundle from a list of engines, using their type names as labels.
177    pub fn new(policies: Vec<Arc<dyn PolicyEngine>>) -> Self {
178        let mut bundle = Self::empty();
179        for policy in policies {
180            let name = default_policy_name(policy.as_ref());
181            bundle.add_policy(name, policy);
182        }
183        bundle
184    }
185
186    /// Constructs a bundle by inspecting inline guardrail directives in a step policy blob.
187    pub fn for_policy(policy: &Value) -> Self {
188        let mut bundle = Self::empty();
189        bundle.load_packs(policy);
190
191        let guardrails = guardrails_from_policy(policy);
192        let mut include_output_guard = false;
193
194        if guardrails
195            .iter()
196            .any(|g| matches!(g.as_str(), "redact_pii" | "deny_on_pii"))
197        {
198            bundle.add_policy_with_hooks(
199                "pii_guardrail",
200                Arc::new(PiiGuardrailPolicy::default()),
201                vec![PolicyHook::Pre, PolicyHook::Post],
202            );
203        }
204
205        if guardrails.iter().any(|g| g == "block_injection") {
206            include_output_guard = true;
207            match embedded_prompt_injection() {
208                Ok(engine) => bundle.add_policy_with_hooks(
209                    "prompt_injection_wasm",
210                    engine,
211                    vec![PolicyHook::Pre],
212                ),
213                Err(err) => {
214                    warn!(
215                        error = %err,
216                        "prompt injection wasm policy unavailable; using heuristic fallback"
217                    );
218                    bundle.add_policy_with_hooks(
219                        "prompt_injection_fallback",
220                        Arc::new(PromptInjectionFallbackPolicy::default()),
221                        vec![PolicyHook::Pre],
222                    );
223                }
224            }
225        }
226
227        if guardrails.iter().any(|g| g == "block_command_output") {
228            include_output_guard = true;
229            bundle.add_policy_with_hooks(
230                "command_output_guard",
231                Arc::new(InstructionGuardPolicy::default()),
232                vec![PolicyHook::Post],
233            );
234        }
235
236        let allowlists: Vec<String> = guardrails
237            .iter()
238            .filter_map(|entry| entry.strip_prefix("egress_http_allowlist:"))
239            .map(|value| value.trim().to_string())
240            .filter(|value| !value.is_empty())
241            .collect();
242
243        if !allowlists.is_empty() {
244            include_output_guard = true;
245            bundle.add_policy_with_hooks(
246                "http_allowlist",
247                Arc::new(HttpAllowlistPolicy::new(allowlists)),
248                vec![PolicyHook::Post],
249            );
250        }
251
252        if include_output_guard {
253            bundle.add_policy_with_hooks(
254                "output_guard",
255                Arc::new(OutputGuardPolicy::default()),
256                vec![PolicyHook::Post],
257            );
258        }
259
260        bundle
261    }
262
263    pub fn empty() -> Self {
264        Self {
265            policies: Vec::new(),
266            events: Arc::new(Mutex::new(Vec::new())),
267        }
268    }
269
270    pub fn standard() -> Self {
271        Self::empty()
272    }
273
274    pub fn http_allow_rules(policy: &Value) -> Vec<HttpAllowRule> {
275        guardrails_from_policy(policy)
276            .iter()
277            .filter_map(|entry| parse_http_allow_rule(entry))
278            .collect()
279    }
280
281    pub fn http_content_types(policy: &Value) -> Vec<String> {
282        guardrails_from_policy(policy)
283            .into_iter()
284            .filter_map(|entry| entry.strip_prefix("egress_http_content_type:"))
285            .map(|value| value.trim().to_ascii_lowercase())
286            .filter(|value| !value.is_empty())
287            .collect()
288    }
289
290    pub fn http_max_bytes(policy: &Value) -> Option<usize> {
291        guardrails_from_policy(policy)
292            .into_iter()
293            .filter_map(|entry| entry.strip_prefix("egress_http_max_bytes:"))
294            .filter_map(|value| value.trim().parse::<usize>().ok())
295            .max()
296    }
297
298    pub fn is_empty(&self) -> bool {
299        self.policies.is_empty()
300    }
301
302    pub fn add_policy(&mut self, name: impl Into<String>, policy: Arc<dyn PolicyEngine>) {
303        self.add_policy_with_hooks(name, policy, vec![PolicyHook::Any]);
304    }
305
306    pub fn add_policy_with_hooks(
307        &mut self,
308        name: impl Into<String>,
309        policy: Arc<dyn PolicyEngine>,
310        hooks: Vec<PolicyHook>,
311    ) {
312        let name_string = name.into();
313        if self.policies.iter().any(|stage| stage.name == name_string) {
314            return;
315        }
316        self.policies
317            .push(PolicyStage::with_hooks(name_string, policy, hooks));
318    }
319
320    fn load_packs(&mut self, policy: &Value) {
321        for config in parse_pack_configs(policy) {
322            match resolve_pack(&config) {
323                Ok(Some((name, engine, hooks))) => {
324                    self.add_policy_with_hooks(name, engine, hooks);
325                }
326                Ok(None) => {}
327                Err(err) => {
328                    warn!(error = %err, pack = %config.name, "failed to initialise policy pack");
329                }
330            }
331        }
332    }
333
334    pub fn drain_events(&self) -> Vec<PolicyEvaluationRecord> {
335        let mut guard = self
336            .events
337            .lock()
338            .expect("policy bundle event mutex poisoned");
339        guard.drain(..).collect()
340    }
341
342    pub async fn evaluate(
343        &self,
344        boundary: TrustBoundary,
345        run_id: Option<RunId>,
346        step_id: Option<StepId>,
347        mut payload: Value,
348    ) -> Result<BundleOutcome> {
349        if self.policies.is_empty() {
350            return Ok(BundleOutcome::allow(payload));
351        }
352
353        let run_uuid = run_id.map(Uuid::from).unwrap_or_else(Uuid::nil);
354        let step_uuid = step_id.map(Uuid::from).unwrap_or_else(Uuid::nil);
355
356        let applicable: Vec<&PolicyStage> = self
357            .policies
358            .iter()
359            .filter(|stage| stage.applies(&boundary))
360            .collect();
361
362        if applicable.is_empty() {
363            return Ok(BundleOutcome::allow(payload));
364        }
365
366        let base_payload = payload.clone();
367        let futures = applicable.into_iter().map(|policy| {
368            let engine = Arc::clone(&policy.engine);
369            let policy_name = policy.name.clone();
370            let payload_snapshot = base_payload.clone();
371            let boundary_snapshot = boundary.clone();
372            async move {
373                let context = json!({
374                    "boundary": boundary_snapshot,
375                    "payload": payload_snapshot,
376                });
377                let request = PolicyRequest::new(run_uuid, step_uuid, context);
378                let decision = engine.evaluate(&request).await;
379                (policy_name, decision)
380            }
381        });
382
383        let results = join_all(futures).await;
384
385        let mut decision_traces: Vec<PolicyDecisionTrace> = Vec::new();
386        let mut collected_reasons: Vec<String> = Vec::new();
387        let mut collected_decisions: Vec<Decision> = Vec::new();
388
389        for (policy_name, decision_result) in results {
390            let decision = decision_result?;
391            if let Some(reason) = decision.reason.clone() {
392                if !reason.is_empty() {
393                    collected_reasons.push(reason.clone());
394                }
395            }
396            decision_traces.push(PolicyDecisionTrace {
397                policy: policy_name,
398                effect: decision.effect,
399                reason: decision.reason.clone(),
400                patches: decision.patches.clone(),
401            });
402            collected_decisions.push(decision);
403        }
404
405        let merged = Decision::merge(collected_decisions.into_iter());
406
407        if let Some(reason) = merged.reason.clone() {
408            if !collected_reasons.iter().any(|existing| existing == &reason) {
409                collected_reasons.push(reason);
410            }
411        }
412
413        if merged.effect == DecisionEffect::Redact {
414            apply_patches(&mut payload, &merged.patches)?;
415        }
416
417        if merged.effect == DecisionEffect::Deny && collected_reasons.is_empty() {
418            collected_reasons.push(format!("{boundary:?} guardrail denied"));
419        }
420
421        let outcome = BundleOutcome {
422            effect: merged.effect,
423            value: payload.clone(),
424            reasons: collected_reasons,
425            decisions: decision_traces,
426            patches: merged.patches.clone(),
427        };
428        self.record_event(boundary, run_uuid, step_uuid, &outcome);
429        Ok(outcome)
430    }
431}
432
433fn apply_patches(target: &mut Value, patches: &[Value]) -> Result<()> {
434    if patches.is_empty() {
435        return Ok(());
436    }
437    let patch_value = Value::Array(patches.to_vec());
438    let patch: Patch = serde_json::from_value(patch_value)?;
439    json_patch::patch(target, &patch)?;
440    Ok(())
441}
442
443fn flatten_strings(value: &Value, buffer: &mut String) {
444    match value {
445        Value::String(s) => {
446            buffer.push(' ');
447            buffer.push_str(s);
448        }
449        Value::Array(items) => {
450            for item in items {
451                flatten_strings(item, buffer);
452            }
453        }
454        Value::Object(map) => {
455            for item in map.values() {
456                flatten_strings(item, buffer);
457            }
458        }
459        _ => {}
460    }
461}
462
463fn sanitize_output(value: Value) -> Value {
464    match value {
465        Value::String(s) => {
466            let mut lowered = s.clone();
467            let mut needs_redact = false;
468            let lower = lowered.to_ascii_lowercase();
469            if lower.contains("api_key") || lower.contains("secret") || lower.contains("token") {
470                needs_redact = true;
471            }
472            if needs_redact {
473                Value::String("[redacted-output]".into())
474            } else {
475                Value::String(s)
476            }
477        }
478        Value::Array(items) => {
479            let sanitized = items.into_iter().map(sanitize_output).collect();
480            Value::Array(sanitized)
481        }
482        Value::Object(map) => {
483            let sanitized = map
484                .into_iter()
485                .map(|(k, v)| (k, sanitize_output(v)))
486                .collect();
487            Value::Object(sanitized)
488        }
489        other => other,
490    }
491}
492
493struct PackConfig {
494    name: String,
495    hooks: Option<Vec<PolicyHook>>,
496    options: Value,
497}
498
499impl PackConfig {
500    fn try_from(value: &Value) -> Result<Self> {
501        let obj = value
502            .as_object()
503            .ok_or_else(|| anyhow!("policy pack entry must be an object"))?;
504
505        let name = obj
506            .get("name")
507            .or_else(|| obj.get("id"))
508            .and_then(Value::as_str)
509            .map(|s| s.trim().to_ascii_lowercase())
510            .filter(|s| !s.is_empty())
511            .ok_or_else(|| anyhow!("policy pack requires a non-empty 'name'"))?;
512
513        let hooks_value = obj.get("phase").or_else(|| obj.get("hooks"));
514        let hooks = match hooks_value {
515            Some(value) => Some(parse_hooks_value(value)?),
516            None => None,
517        };
518
519        let options = obj.get("options").cloned().unwrap_or(Value::Null);
520
521        Ok(Self {
522            name,
523            hooks,
524            options,
525        })
526    }
527}
528
529fn parse_pack_configs(policy: &Value) -> Vec<PackConfig> {
530    policy
531        .as_object()
532        .and_then(|obj| obj.get("packs"))
533        .and_then(Value::as_array)
534        .map(|items| {
535            items
536                .iter()
537                .filter_map(|value| match PackConfig::try_from(value) {
538                    Ok(config) => Some(config),
539                    Err(err) => {
540                        warn!(error = %err, "ignoring invalid policy pack entry");
541                        None
542                    }
543                })
544                .collect()
545        })
546        .unwrap_or_default()
547}
548
549fn parse_hooks_value(value: &Value) -> Result<Vec<PolicyHook>> {
550    match value {
551        Value::String(s) => Ok(vec![parse_hook_str(s)?]),
552        Value::Array(items) => {
553            if items.is_empty() {
554                return Err(anyhow!("hooks array cannot be empty"));
555            }
556            let mut hooks = Vec::with_capacity(items.len());
557            for item in items {
558                hooks.push(parse_hook_str(
559                    item.as_str()
560                        .ok_or_else(|| anyhow!("hooks entries must be strings"))?,
561                )?);
562            }
563            Ok(hooks)
564        }
565        _ => Err(anyhow!("hooks must be a string or array of strings")),
566    }
567}
568
569fn parse_hook_str(value: &str) -> Result<PolicyHook> {
570    match value.trim().to_ascii_lowercase().as_str() {
571        "pre" => Ok(PolicyHook::Pre),
572        "mid" => Ok(PolicyHook::Mid),
573        "post" => Ok(PolicyHook::Post),
574        other => Err(anyhow!("unknown policy hook '{}'", other)),
575    }
576}
577
578fn resolve_pack(
579    config: &PackConfig,
580) -> Result<Option<(String, Arc<dyn PolicyEngine>, Vec<PolicyHook>)>> {
581    let stage_name = format!("pack::{}", config.name);
582
583    match config.name.as_str() {
584        "prompt_injection" => {
585            let hooks = config
586                .hooks
587                .clone()
588                .unwrap_or_else(|| vec![PolicyHook::Pre]);
589            let engine: Arc<dyn PolicyEngine> = match embedded_prompt_injection() {
590                Ok(engine) => engine,
591                Err(err) => {
592                    warn!(
593                        error = %err,
594                        "prompt injection wasm policy unavailable; using heuristic fallback"
595                    );
596                    Arc::new(PromptInjectionFallbackPolicy::default())
597                }
598            };
599            Ok(Some((stage_name, engine, hooks)))
600        }
601        "pii_redaction" => {
602            let mode = config
603                .options
604                .as_object()
605                .and_then(|obj| obj.get("mode"))
606                .and_then(Value::as_str)
607                .map(|value| match value.trim().to_ascii_lowercase().as_str() {
608                    "deny" => PiiMode::Deny,
609                    "allow" => PiiMode::Allow,
610                    _ => PiiMode::Redact,
611                })
612                .unwrap_or(PiiMode::Redact);
613            let hooks = config
614                .hooks
615                .clone()
616                .unwrap_or_else(|| vec![PolicyHook::Pre, PolicyHook::Post]);
617            let engine: Arc<dyn PolicyEngine> = Arc::new(BasicPiiPolicy::new(mode));
618            Ok(Some((stage_name, engine, hooks)))
619        }
620        "tool_acl" => {
621            let engine: Arc<dyn PolicyEngine> =
622                Arc::new(ToolAclPolicy::from_config(&config.options)?);
623            let hooks = config
624                .hooks
625                .clone()
626                .unwrap_or_else(|| vec![PolicyHook::Pre]);
627            Ok(Some((stage_name, engine, hooks)))
628        }
629        "budget_caps" => {
630            let engine: Arc<dyn PolicyEngine> =
631                Arc::new(BudgetCapsPolicy::from_config(&config.options)?);
632            let hooks = config
633                .hooks
634                .clone()
635                .unwrap_or_else(|| vec![PolicyHook::Pre, PolicyHook::Post]);
636            Ok(Some((stage_name, engine, hooks)))
637        }
638        other => {
639            warn!(pack = %other, "unknown policy pack requested");
640            Ok(None)
641        }
642    }
643}
644
645fn guardrails_from_policy(policy: &Value) -> Vec<String> {
646    policy
647        .as_object()
648        .and_then(|obj| obj.get("guardrails"))
649        .and_then(Value::as_array)
650        .map(|arr| {
651            arr.iter()
652                .filter_map(Value::as_str)
653                .map(|s| s.to_string())
654                .collect::<Vec<_>>()
655        })
656        .unwrap_or_default()
657}
658
659fn guardrails_from_context(context: &Value) -> Vec<String> {
660    context
661        .get("payload")
662        .and_then(|payload| payload.get("policy"))
663        .map(|policy| guardrails_from_policy(policy))
664        .unwrap_or_default()
665}
666
667fn parse_http_allow_rule(entry: &str) -> Option<HttpAllowRule> {
668    let value = entry.strip_prefix("egress_http_allowlist:")?.trim();
669    if value.is_empty() {
670        return None;
671    }
672
673    let (host_part, path_part) = match value.find('/') {
674        Some(index) => (&value[..index], Some(&value[index..])),
675        None => (value, None),
676    };
677
678    let host = host_part.trim().to_ascii_lowercase();
679    if host.is_empty() {
680        return None;
681    }
682
683    let path_prefix = path_part.and_then(|path| {
684        let trimmed = path.trim();
685        if trimmed.is_empty() {
686            None
687        } else if trimmed.starts_with('/') {
688            Some(trimmed.to_string())
689        } else {
690            Some(format!("/{}", trimmed))
691        }
692    });
693
694    Some(HttpAllowRule { host, path_prefix })
695}
696
697fn collect_hosts(value: &Value, hosts: &mut Vec<String>) {
698    match value {
699        Value::String(s) => {
700            for capture in HTTP_URL_PATTERN.find_iter(s) {
701                if let Ok(url) = Url::parse(capture.as_str()) {
702                    if let Some(host) = url.host_str() {
703                        hosts.push(host.to_ascii_lowercase());
704                    }
705                }
706            }
707        }
708        Value::Array(items) => {
709            for item in items {
710                collect_hosts(item, hosts);
711            }
712        }
713        Value::Object(map) => {
714            for item in map.values() {
715                collect_hosts(item, hosts);
716            }
717        }
718        _ => {}
719    }
720}
721
722fn host_allowed(host: &str, allowlist: &[String]) -> bool {
723    allowlist.iter().any(|allowed| {
724        let allowed = allowed.to_ascii_lowercase();
725        host == allowed || host.ends_with(&format!(".{allowed}"))
726    })
727}
728
729fn boundary_from_request(request: &PolicyRequest) -> Option<TrustBoundary> {
730    request
731        .context()
732        .get("boundary")
733        .and_then(Value::as_str)
734        .and_then(|s| serde_json::from_value(Value::String(s.to_string())).ok())
735}
736
737#[derive(Clone, Default)]
738pub struct PiiGuardrailPolicy {
739    inner: BasicPiiPolicy,
740}
741
742#[async_trait]
743impl PolicyEngine for PiiGuardrailPolicy {
744    async fn evaluate(&self, request: &PolicyRequest) -> Result<PolicyDecision> {
745        let context = request.context();
746        if !should_apply_pii(context) {
747            return Ok(PolicyDecision::allow());
748        }
749        self.inner.evaluate(request).await
750    }
751}
752
753fn should_apply_pii(context: &Value) -> bool {
754    guardrails_from_context(context).iter().any(|rule| {
755        matches!(
756            rule.as_str(),
757            "redact_pii" | "deny_on_pii" | "skip_redaction"
758        )
759    })
760}
761
762fn should_apply_injection(context: &Value) -> bool {
763    guardrails_from_context(context)
764        .iter()
765        .any(|rule| rule == "block_injection")
766}
767
768fn should_apply_command_guard(context: &Value) -> bool {
769    guardrails_from_context(context)
770        .iter()
771        .any(|rule| rule == "block_command_output")
772}
773
774fn match_patterns(text: &str, patterns: &[(&'static str, Regex)]) -> Vec<String> {
775    patterns
776        .iter()
777        .filter(|(_, regex)| regex.is_match(text))
778        .map(|(name, _)| (*name).to_string())
779        .collect()
780}
781
782fn default_policy_name(policy: &dyn PolicyEngine) -> String {
783    let name = type_name_of_val(policy);
784    name.rsplit("::").next().unwrap_or(name).to_string()
785}
786
787fn preview_payload(value: &Value) -> Option<String> {
788    let mut buffer = String::new();
789    flatten_strings(value, &mut buffer);
790    let trimmed = buffer.trim();
791    if trimmed.is_empty() {
792        None
793    } else {
794        let preview: String = trimmed.chars().take(300).collect();
795        Some(preview)
796    }
797}
798
799fn collect_value_origins(value: &Value) -> Vec<Value> {
800    let mut origins = Vec::new();
801    walk_for_origins(value, &mut origins);
802    origins
803}
804
805fn walk_for_origins(value: &Value, origins: &mut Vec<Value>) {
806    match value {
807        Value::Object(map) => {
808            if let Some(origin) = map.get("trust_origin") {
809                push_unique_json(origins, origin);
810            }
811            for item in map.values() {
812                walk_for_origins(item, origins);
813            }
814        }
815        Value::Array(items) => {
816            for item in items {
817                walk_for_origins(item, origins);
818            }
819        }
820        _ => {}
821    }
822}
823
824fn push_unique_json(list: &mut Vec<Value>, value: &Value) {
825    if !list.iter().any(|existing| existing == value) {
826        list.push(value.clone());
827    }
828}
829
830impl PolicyBundle {
831    fn record_event(
832        &self,
833        boundary: TrustBoundary,
834        run_id: Uuid,
835        step_id: Uuid,
836        outcome: &BundleOutcome,
837    ) {
838        let payload_snapshot = if outcome.effect == DecisionEffect::Allow {
839            None
840        } else {
841            Some(outcome.value.clone())
842        };
843        let preview = preview_payload(&outcome.value);
844        let origins = collect_value_origins(&outcome.value);
845        let record = PolicyEvaluationRecord {
846            boundary,
847            run_id: (run_id != Uuid::nil()).then_some(run_id),
848            step_id: (step_id != Uuid::nil()).then_some(step_id),
849            effect: outcome.effect,
850            reasons: outcome.reasons.clone(),
851            decisions: outcome.decisions.clone(),
852            patches: outcome.patches.clone(),
853            payload: payload_snapshot,
854            timestamp: Utc::now(),
855            origins,
856            preview,
857            attestation_id: None,
858            attestation: None,
859            trust_decision: None,
860        };
861        let mut guard = self
862            .events
863            .lock()
864            .expect("policy bundle event mutex poisoned");
865        guard.push(record);
866    }
867}
868
869fn sanitize_command_output(value: &Value) -> Value {
870    match value {
871        Value::String(s) => {
872            if needs_command_redaction(s) {
873                Value::String("[filtered-command-output]".to_string())
874            } else {
875                Value::String(s.clone())
876            }
877        }
878        Value::Array(items) => Value::Array(items.iter().map(sanitize_command_output).collect()),
879        Value::Object(map) => {
880            let mut sanitized = serde_json::Map::with_capacity(map.len());
881            for (key, val) in map {
882                sanitized.insert(key.clone(), sanitize_command_output(val));
883            }
884            Value::Object(sanitized)
885        }
886        other => other.clone(),
887    }
888}
889
890fn needs_command_redaction(text: &str) -> bool {
891    COMMAND_REDACT_PATTERNS
892        .iter()
893        .any(|(_, regex)| regex.is_match(text))
894        || COMMAND_DENY_PATTERNS
895            .iter()
896            .any(|(_, regex)| regex.is_match(text))
897}
898
899#[derive(Clone, Default)]
900pub struct PromptInjectionFallbackPolicy;
901
902#[async_trait]
903impl PolicyEngine for PromptInjectionFallbackPolicy {
904    async fn evaluate(&self, request: &PolicyRequest) -> Result<PolicyDecision> {
905        if !matches!(
906            boundary_from_request(request),
907            Some(TrustBoundary::IngressPrompt)
908        ) {
909            return Ok(PolicyDecision::allow());
910        }
911        if !should_apply_injection(request.context()) {
912            return Ok(PolicyDecision::allow());
913        }
914        let payload = request
915            .context()
916            .get("payload")
917            .and_then(|p| p.get("inputs"))
918            .and_then(|inputs| inputs.get("messages"))
919            .cloned()
920            .unwrap_or(Value::Null);
921        let mut text = String::new();
922        flatten_strings(&payload, &mut text);
923        let lower = text.to_ascii_lowercase();
924        if lower.contains("ignore previous instructions")
925            || lower.contains("override guardrails")
926            || lower.contains("begin jailbreak")
927        {
928            return Ok(PolicyDecision {
929                effect: DecisionEffect::Deny,
930                reason: Some("prompt_injection_detected".into()),
931                patches: Vec::new(),
932            });
933        }
934        Ok(PolicyDecision::allow())
935    }
936}
937
938#[derive(Clone, Default)]
939pub struct OutputGuardPolicy;
940
941#[async_trait]
942impl PolicyEngine for OutputGuardPolicy {
943    async fn evaluate(&self, request: &PolicyRequest) -> Result<PolicyDecision> {
944        let boundary = match boundary_from_request(request) {
945            Some(boundary) => boundary,
946            None => return Ok(PolicyDecision::allow()),
947        };
948        if !matches!(
949            boundary,
950            TrustBoundary::EgressPrompt | TrustBoundary::EgressTool
951        ) {
952            return Ok(PolicyDecision::allow());
953        }
954
955        let output = request
956            .context()
957            .get("payload")
958            .and_then(|p| p.get("output"))
959            .cloned()
960            .unwrap_or(Value::Null);
961        let mut text = String::new();
962        flatten_strings(&output, &mut text);
963        let lower = text.to_ascii_lowercase();
964        if lower.contains("api_key") || lower.contains("secret") || lower.contains("token") {
965            let sanitized = sanitize_output(output);
966            return Ok(PolicyDecision {
967                effect: DecisionEffect::Redact,
968                reason: Some("sensitive_output_redacted".into()),
969                patches: vec![json!({
970                    "op": "replace",
971                    "path": "/output",
972                    "value": sanitized,
973                })],
974            });
975        }
976
977        Ok(PolicyDecision::allow())
978    }
979}
980
981#[derive(Clone, Default)]
982pub struct InstructionGuardPolicy;
983
984#[async_trait]
985impl PolicyEngine for InstructionGuardPolicy {
986    async fn evaluate(&self, request: &PolicyRequest) -> Result<PolicyDecision> {
987        let boundary = match boundary_from_request(request) {
988            Some(boundary) => boundary,
989            None => return Ok(PolicyDecision::allow()),
990        };
991
992        if !matches!(
993            boundary,
994            TrustBoundary::EgressPrompt | TrustBoundary::EgressTool
995        ) {
996            return Ok(PolicyDecision::allow());
997        }
998
999        if !should_apply_command_guard(request.context()) {
1000            return Ok(PolicyDecision::allow());
1001        }
1002
1003        let output = request
1004            .context()
1005            .get("payload")
1006            .and_then(|p| p.get("output"))
1007            .cloned()
1008            .unwrap_or(Value::Null);
1009
1010        let mut text = String::new();
1011        flatten_strings(&output, &mut text);
1012
1013        let deny_matches = match_patterns(&text, &COMMAND_DENY_PATTERNS);
1014        if !deny_matches.is_empty() {
1015            return Ok(PolicyDecision {
1016                effect: DecisionEffect::Deny,
1017                reason: Some(format!("command_output_denied: {}", deny_matches.join(","))),
1018                patches: Vec::new(),
1019            });
1020        }
1021
1022        let redact_matches = match_patterns(&text, &COMMAND_REDACT_PATTERNS);
1023        if !redact_matches.is_empty() {
1024            let sanitized = sanitize_command_output(&output);
1025            return Ok(PolicyDecision {
1026                effect: DecisionEffect::Redact,
1027                reason: Some(format!(
1028                    "command_output_redacted: {}",
1029                    redact_matches.join(",")
1030                )),
1031                patches: vec![json!({
1032                    "op": "replace",
1033                    "path": "/output",
1034                    "value": sanitized,
1035                })],
1036            });
1037        }
1038
1039        Ok(PolicyDecision::allow())
1040    }
1041}
1042
1043static COMMAND_DENY_PATTERNS: Lazy<Vec<(&'static str, Regex)>> = Lazy::new(|| {
1044    vec![
1045        (
1046            "shell_rm_root",
1047            Regex::new(r"(?i)\brm\s+-rf\s+/").expect("valid regex"),
1048        ),
1049        (
1050            "shell_shutdown",
1051            Regex::new(r"(?i)\bshutdown\b").expect("valid regex"),
1052        ),
1053        (
1054            "shell_reboot",
1055            Regex::new(r"(?i)\breboot\b").expect("valid regex"),
1056        ),
1057        (
1058            "sql_drop_table",
1059            Regex::new(r"(?i)\bdrop\s+table\b").expect("valid regex"),
1060        ),
1061        (
1062            "sql_truncate_table",
1063            Regex::new(r"(?i)\btruncate\s+table\b").expect("valid regex"),
1064        ),
1065        (
1066            "sql_delete_all",
1067            Regex::new(r"(?i)\bdelete\s+from\s+\w+\b").expect("valid regex"),
1068        ),
1069    ]
1070});
1071
1072static COMMAND_REDACT_PATTERNS: Lazy<Vec<(&'static str, Regex)>> = Lazy::new(|| {
1073    vec![
1074        (
1075            "shell_command",
1076            Regex::new(r"(?i)\b(?:bash|sh|zsh|cmd|powershell)\b").expect("valid regex"),
1077        ),
1078        (
1079            "shell_curl",
1080            Regex::new(r"(?i)\bcurl\s+https?://").expect("valid regex"),
1081        ),
1082        (
1083            "shell_wget",
1084            Regex::new(r"(?i)\bwget\s+").expect("valid regex"),
1085        ),
1086        (
1087            "shell_exec",
1088            Regex::new(r"(?i)\bos\.system|subprocess\.(run|call)").expect("valid regex"),
1089        ),
1090        (
1091            "sql_select",
1092            Regex::new(r"(?i)\bselect\b.*\bfrom\b").expect("valid regex"),
1093        ),
1094        (
1095            "sql_insert",
1096            Regex::new(r"(?i)\binsert\s+into\b").expect("valid regex"),
1097        ),
1098        (
1099            "sql_update",
1100            Regex::new(r"(?i)\bupdate\s+\w+\s+set\b").expect("valid regex"),
1101        ),
1102        (
1103            "http_url",
1104            Regex::new(r"(?i)https?://").expect("valid regex"),
1105        ),
1106    ]
1107});
1108
1109#[derive(Clone)]
1110pub struct HttpAllowlistPolicy {
1111    allowed: Vec<String>,
1112}
1113
1114impl HttpAllowlistPolicy {
1115    pub fn new(allowed: Vec<String>) -> Self {
1116        Self { allowed }
1117    }
1118}
1119
1120static HTTP_URL_PATTERN: Lazy<Regex> =
1121    Lazy::new(|| Regex::new(r#"https?://[^\s\)\]\}"'>]+"#).expect("valid http url regex"));
1122
1123#[async_trait]
1124impl PolicyEngine for HttpAllowlistPolicy {
1125    async fn evaluate(&self, request: &PolicyRequest) -> Result<PolicyDecision> {
1126        let boundary = match boundary_from_request(request) {
1127            Some(boundary) => boundary,
1128            None => return Ok(PolicyDecision::allow()),
1129        };
1130
1131        if !matches!(boundary, TrustBoundary::EgressTool) {
1132            return Ok(PolicyDecision::allow());
1133        }
1134
1135        let mut hosts = Vec::new();
1136        if let Some(payload) = request.context().get("payload") {
1137            collect_hosts(payload, &mut hosts);
1138        }
1139
1140        let disallowed: Vec<String> = hosts
1141            .into_iter()
1142            .filter(|host| !host_allowed(host, &self.allowed))
1143            .collect();
1144
1145        if disallowed.is_empty() {
1146            return Ok(PolicyDecision::allow());
1147        }
1148
1149        Ok(PolicyDecision {
1150            effect: DecisionEffect::Deny,
1151            reason: Some(format!("http_egress_not_allowed:{}", disallowed.join(","))),
1152            patches: Vec::new(),
1153        })
1154    }
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160    use async_trait::async_trait;
1161    use serde_json::{json, Value};
1162    use std::sync::Arc;
1163    use uuid::Uuid;
1164
1165    use crate::model::{RunId, StepId};
1166    use fleetforge_policy::Decision as PolicyDecision;
1167
1168    struct DenyPolicy;
1169
1170    #[async_trait]
1171    impl PolicyEngine for DenyPolicy {
1172        async fn evaluate(&self, _request: &PolicyRequest) -> Result<PolicyDecision> {
1173            Ok(PolicyDecision {
1174                effect: DecisionEffect::Deny,
1175                reason: Some("blocked".into()),
1176                patches: Vec::new(),
1177            })
1178        }
1179    }
1180
1181    struct RedactingPolicy;
1182
1183    #[async_trait]
1184    impl PolicyEngine for RedactingPolicy {
1185        async fn evaluate(&self, _request: &PolicyRequest) -> Result<PolicyDecision> {
1186            Ok(PolicyDecision {
1187                effect: DecisionEffect::Redact,
1188                reason: Some("mask".into()),
1189                patches: vec![json!({
1190                    "op": "replace",
1191                    "path": "/inputs/secret",
1192                    "value": "[masked]"
1193                })],
1194            })
1195        }
1196    }
1197
1198    #[tokio::test]
1199    async fn deny_evaluation_records_event() {
1200        let bundle = PolicyBundle::new(vec![Arc::new(DenyPolicy)]);
1201        let outcome = bundle
1202            .evaluate(
1203                TrustBoundary::IngressPrompt,
1204                Some(RunId(Uuid::nil())),
1205                Some(StepId(Uuid::nil())),
1206                json!({"inputs": {}}),
1207            )
1208            .await
1209            .expect("evaluate deny policy");
1210
1211        assert_eq!(outcome.effect, DecisionEffect::Deny);
1212        assert!(outcome
1213            .decisions
1214            .iter()
1215            .any(|d| d.reason.as_deref() == Some("blocked")));
1216
1217        let events = bundle.drain_events();
1218        assert_eq!(events.len(), 1);
1219        let event = &events[0];
1220        assert_eq!(event.effect, DecisionEffect::Deny);
1221        assert!(event
1222            .reasons
1223            .iter()
1224            .any(|reason| reason.contains("blocked")));
1225        assert!(event.patches.is_empty());
1226        assert_eq!(event.decisions.len(), 1);
1227        assert_eq!(event.decisions[0].effect, DecisionEffect::Deny);
1228    }
1229
1230    #[tokio::test]
1231    async fn redact_evaluation_captures_patches() {
1232        let bundle = PolicyBundle::new(vec![Arc::new(RedactingPolicy)]);
1233        let outcome = bundle
1234            .evaluate(
1235                TrustBoundary::IngressTool,
1236                Some(RunId(Uuid::nil())),
1237                Some(StepId(Uuid::nil())),
1238                json!({"inputs": {"secret": "top"}}),
1239            )
1240            .await
1241            .expect("evaluate redact policy");
1242
1243        let updated = outcome
1244            .value
1245            .get("inputs")
1246            .and_then(|inputs| inputs.get("secret"))
1247            .and_then(Value::as_str)
1248            .expect("secret present");
1249        assert_eq!(updated, "[masked]");
1250        assert!(outcome.effect == DecisionEffect::Redact);
1251        assert_eq!(outcome.patches.len(), 1);
1252
1253        let events = bundle.drain_events();
1254        assert_eq!(events.len(), 1);
1255        let event = &events[0];
1256        assert_eq!(event.effect, DecisionEffect::Redact);
1257        assert_eq!(event.patches.len(), 1);
1258        assert!(event.preview.as_ref().is_some());
1259    }
1260}