1use std::sync::Arc;
2
3use anyhow::{Context, Result};
4use base64::engine::general_purpose::STANDARD as BASE64;
5use base64::Engine;
6use chrono::Utc;
7use fleetforge_trust::{
8 digest_json, trust_signer, Attestation, TrustDecision, TrustSubject, TrustVerdict,
9};
10use serde_json::{json, Value};
11use uuid::Uuid;
12
13use crate::model::{RunId, StepId, StepSpec};
14
15pub use fleetforge_policy::{
18 AllowAllPolicy, BasicPiiPolicy, BudgetCapsPolicy, Decision as PolicyDecision, DecisionEffect,
19 PiiMode, PolicyEngine, PolicyRequest, ToolAclPolicy,
20};
21
22#[derive(Clone)]
24pub struct RuntimePolicyPack {
25 id: String,
26 engine: Arc<dyn PolicyEngine>,
27}
28
29impl RuntimePolicyPack {
30 pub fn new(id: impl Into<String>, engine: Arc<dyn PolicyEngine>) -> Self {
32 Self {
33 id: id.into(),
34 engine,
35 }
36 }
37
38 pub fn id(&self) -> &str {
40 &self.id
41 }
42
43 pub fn engine(&self) -> &Arc<dyn PolicyEngine> {
45 &self.engine
46 }
47}
48
49#[derive(Debug, Clone)]
51pub struct PolicyOutcome {
52 pub decision: PolicyDecision,
53 pub trust: TrustDecision,
54 pub attestation: Attestation,
55}
56
57fn effect_to_verdict(effect: DecisionEffect) -> TrustVerdict {
58 match effect {
59 DecisionEffect::Allow => TrustVerdict::Allow,
60 DecisionEffect::Deny => TrustVerdict::Deny,
61 DecisionEffect::Redact => TrustVerdict::Redact,
62 }
63}
64
65fn effect_label(effect: DecisionEffect) -> &'static str {
66 match effect {
67 DecisionEffect::Allow => "allow",
68 DecisionEffect::Deny => "deny",
69 DecisionEffect::Redact => "redact",
70 }
71}
72
73fn make_policy_request(run_id: RunId, step_id: StepId, step: &StepSpec) -> PolicyRequest {
74 let payload = json!({
75 "run_id": run_id.to_string(),
76 "step_id": step_id.to_string(),
77 "step": step,
78 "inputs": &step.inputs,
79 "policy": &step.policy,
80 "trust": step.trust,
81 "trust_origin": step.trust_origin,
82 });
83
84 PolicyRequest::new(Uuid::from(run_id), Uuid::from(step_id), payload)
85}
86
87pub async fn enforce_policy(
89 pack: &RuntimePolicyPack,
90 run_id: RunId,
91 step_spec: &StepSpec,
92) -> Result<PolicyOutcome> {
93 let request = make_policy_request(run_id, step_spec.id, step_spec);
94 let decision = pack.engine().evaluate(&request).await?;
95
96 let subject = TrustSubject::Step {
97 run_id: Uuid::from(run_id),
98 step_id: Uuid::from(step_spec.id),
99 };
100 let attestation_id = Uuid::new_v4();
101 let issued_at = Utc::now();
102 let mut attestation = Attestation::new(attestation_id, issued_at).with_subject(subject.clone());
103
104 attestation.claims.insert(
105 "policy_id".to_string(),
106 Value::String(pack.id().to_string()),
107 );
108 attestation.claims.insert(
109 "effect".to_string(),
110 Value::String(effect_label(decision.effect).to_string()),
111 );
112 attestation
113 .claims
114 .insert("run_id".to_string(), Value::String(run_id.to_string()));
115 attestation.claims.insert(
116 "step_id".to_string(),
117 Value::String(step_spec.id.to_string()),
118 );
119 if let Some(reason) = decision.reason.clone() {
120 attestation
121 .claims
122 .insert("reason".to_string(), Value::String(reason));
123 }
124 if !decision.patches.is_empty() {
125 attestation.claims.insert(
126 "patches".to_string(),
127 serde_json::to_value(&decision.patches)?,
128 );
129 }
130
131 let signature_payload = serde_json::to_value(&attestation.claims)?;
132 let signer = trust_signer();
133 let envelope = signer
134 .sign_json(&signature_payload)
135 .context("failed to sign policy attestation")?;
136 let signature_b64 = BASE64.encode(&envelope.signature);
137 attestation = attestation.with_signature(signature_b64);
138 attestation.claims.insert(
139 "signature_key_id".to_string(),
140 Value::String(envelope.key_id.clone()),
141 );
142
143 let mut trust_decision = TrustDecision::new(
144 subject,
145 pack.id().to_string(),
146 effect_to_verdict(decision.effect),
147 );
148 if let Some(reason) = decision.reason.clone() {
149 trust_decision = trust_decision.with_reason(reason);
150 }
151 trust_decision = trust_decision.with_attestation(attestation.id);
152
153 Ok(PolicyOutcome {
154 decision,
155 trust: trust_decision,
156 attestation,
157 })
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::model::StepType;
164 use async_trait::async_trait;
165 use fleetforge_policy::{Decision, DecisionEffect};
166 use serde_json::json;
167
168 struct DenyPolicy;
169
170 #[async_trait]
171 impl PolicyEngine for DenyPolicy {
172 async fn evaluate(&self, _request: &PolicyRequest) -> Result<Decision> {
173 Ok(Decision {
174 effect: DecisionEffect::Deny,
175 reason: Some("forbidden tool".into()),
176 patches: Vec::new(),
177 })
178 }
179 }
180
181 #[tokio::test]
182 async fn enforce_policy_denies_with_reason() {
183 let run_id = RunId(uuid::Uuid::new_v4());
184 let step_id = StepId(uuid::Uuid::new_v4());
185 let step_spec = StepSpec {
186 id: step_id,
187 r#type: StepType::Tool,
188 inputs: json!({"command": ["rm", "-rf", "/"]}),
189 policy: json!({}),
190 trust: None,
191 trust_origin: None,
192 llm_inputs: None,
193 };
194
195 let engine: Arc<dyn PolicyEngine> = Arc::new(DenyPolicy);
196 let pack = RuntimePolicyPack::new("test.policy", engine);
197 let outcome = enforce_policy(&pack, run_id, &step_spec)
198 .await
199 .expect("policy outcome should be returned");
200
201 assert_eq!(outcome.decision.effect, DecisionEffect::Deny);
202 assert_eq!(outcome.trust.policy_id, "test.policy");
203 assert_eq!(outcome.trust.attestation_id.is_some(), true);
204 }
205}