fleetforge_runtime/
policy.rs

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
15/// Thin façade over `fleetforge_policy` that re-exports the public policy surface
16/// while hosting runtime-specific helpers such as attestation wiring.
17pub use fleetforge_policy::{
18    AllowAllPolicy, BasicPiiPolicy, BudgetCapsPolicy, Decision as PolicyDecision, DecisionEffect,
19    PiiMode, PolicyEngine, PolicyRequest, ToolAclPolicy,
20};
21
22/// Runtime wrapper around a single policy engine plus identifier metadata.
23#[derive(Clone)]
24pub struct RuntimePolicyPack {
25    id: String,
26    engine: Arc<dyn PolicyEngine>,
27}
28
29impl RuntimePolicyPack {
30    /// Creates a new pack that can be referenced in audit records.
31    pub fn new(id: impl Into<String>, engine: Arc<dyn PolicyEngine>) -> Self {
32        Self {
33            id: id.into(),
34            engine,
35        }
36    }
37
38    /// Stable identifier recorded inside trust attestations.
39    pub fn id(&self) -> &str {
40        &self.id
41    }
42
43    /// Returns the underlying [`PolicyEngine`] implementation.
44    pub fn engine(&self) -> &Arc<dyn PolicyEngine> {
45        &self.engine
46    }
47}
48
49/// Result bundle produced by `enforce_policy`, combining policy/trust views.
50#[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
87/// Evaluates the supplied step against the runtime policy pack and records trust data.
88pub 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}