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#[derive(Clone, Debug)]
60pub struct HttpAllowRule {
61 pub host: String,
62 pub path_prefix: Option<String>,
63}
64
65#[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#[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#[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#[derive(Clone)]
170pub struct PolicyBundle {
171 policies: Vec<PolicyStage>,
172 events: Arc<Mutex<Vec<PolicyEvaluationRecord>>>,
173}
174
175impl PolicyBundle {
176 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 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}