use std::path::Path;
use std::str::FromStr;
use anyhow::{Context as _, Result};
use cedar_policy::{
Authorizer, Context, Decision, Entities, EntityId, EntityTypeName, EntityUid, PolicySet,
Request,
};
use serde_json::{json, Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicyDecision {
Blocked,
Authorized,
Unset,
}
pub struct CallContext<'a> {
pub agent: &'a str,
pub tool: &'a str,
pub mutating: bool,
pub risk: Option<&'a str>,
pub tainted: bool,
pub session_calls: i64,
pub session_cost: i64,
pub arguments: &'a Value,
}
pub struct PolicyEngine {
policies: PolicySet,
authorizer: Authorizer,
}
impl PolicyEngine {
pub fn from_file(path: &Path) -> Result<Self> {
let src = std::fs::read_to_string(path)
.with_context(|| format!("reading policy file {}", path.display()))?;
Self::parse(&src).with_context(|| format!("in policy file {}", path.display()))
}
pub fn parse(src: &str) -> Result<Self> {
let policies =
PolicySet::from_str(src).map_err(|e| anyhow::anyhow!("parsing Cedar policy: {e}"))?;
Ok(Self {
policies,
authorizer: Authorizer::new(),
})
}
pub fn len(&self) -> usize {
self.policies.policies().count()
}
pub fn evaluate(&self, call: &CallContext) -> PolicyDecision {
match self.try_evaluate(call) {
Ok(d) => d,
Err(e) => {
eprintln!(
"⚠ foreguard: policy evaluation error ({e}); deferring to default handling."
);
PolicyDecision::Unset
}
}
}
fn try_evaluate(&self, call: &CallContext) -> Result<PolicyDecision> {
let principal = uid("Agent", call.agent);
let action = uid("Action", "invoke");
let resource = uid("Tool", call.tool);
let context = Context::from_json_value(self.context_json(call), None)
.map_err(|e| anyhow::anyhow!("building policy context: {e}"))?;
let request = Request::new(principal, action, resource, context, None)
.map_err(|e| anyhow::anyhow!("building policy request: {e}"))?;
let response = self
.authorizer
.is_authorized(&request, &self.policies, &Entities::empty());
let had_errors = response.diagnostics().errors().next().is_some();
if had_errors {
for e in response.diagnostics().errors() {
eprintln!(
"⚠ foreguard: a policy failed to evaluate ({e}); it will not authorize this \
call. Guard optional attributes with `has` (e.g. `context.args has x && …`)."
);
}
}
Ok(match response.decision() {
Decision::Allow if !had_errors => PolicyDecision::Authorized,
Decision::Allow => PolicyDecision::Unset,
Decision::Deny => {
if response.diagnostics().reason().next().is_some() {
PolicyDecision::Blocked
} else {
PolicyDecision::Unset
}
}
})
}
fn context_json(&self, call: &CallContext) -> Value {
json!({
"tool": call.tool,
"risk": call.risk.unwrap_or("none"),
"mutating": call.mutating,
"tainted": call.tainted,
"session_calls": call.session_calls,
"session_cost": call.session_cost,
"args": sanitize_args(call.arguments),
"dec": decimal_args(call.arguments),
})
}
}
fn uid(type_name: &str, id: &str) -> EntityUid {
let ty = EntityTypeName::from_str(type_name).expect("static Cedar type name is valid");
EntityUid::from_type_name_and_id(ty, EntityId::new(id))
}
fn sanitize_args(args: &Value) -> Value {
let mut out = Map::new();
if let Some(obj) = args.as_object() {
for (k, v) in obj {
let cedar = match v {
Value::String(_) | Value::Bool(_) => v.clone(),
Value::Number(n) if n.is_i64() => v.clone(),
Value::Number(n) => Value::String(n.to_string()),
_ => continue,
};
out.insert(k.clone(), cedar);
}
}
Value::Object(out)
}
fn decimal_args(args: &Value) -> Value {
let mut out = Map::new();
if let Some(obj) = args.as_object() {
for (k, v) in obj {
if let Value::Number(n) = v {
if let Some(arg) = to_decimal_arg(n) {
out.insert(
k.clone(),
json!({ "__extn": { "fn": "decimal", "arg": arg } }),
);
}
}
}
}
Value::Object(out)
}
fn to_decimal_arg(n: &serde_json::Number) -> Option<String> {
let f = n.as_f64()?;
if !f.is_finite() || f.abs() > 922_337_203_685_477.0 {
return None;
}
Some(format!("{f:.4}"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn call<'a>(tool: &'a str, mutating: bool, args: &'a Value) -> CallContext<'a> {
CallContext {
agent: "default",
tool,
mutating,
risk: Some(if mutating { "high" } else { "none" }),
tainted: false,
session_calls: 0,
session_cost: 0,
arguments: args,
}
}
#[test]
fn a_forbid_blocks_the_matching_tool() {
let eng = PolicyEngine::parse(
r#"forbid(principal, action, resource) when { context.tool == "delete_file" };"#,
)
.unwrap();
assert_eq!(
eng.evaluate(&call("delete_file", true, &json!({"path": "/etc/passwd"}))),
PolicyDecision::Blocked
);
assert_eq!(
eng.evaluate(&call("write_file", true, &json!({"path": "a"}))),
PolicyDecision::Unset
);
}
#[test]
fn a_permit_preauthorizes_the_matching_call() {
let eng = PolicyEngine::parse(
r#"permit(principal, action, resource) when { context.mutating == false };"#,
)
.unwrap();
assert_eq!(
eng.evaluate(&call("read_file", false, &json!({"path": "x"}))),
PolicyDecision::Authorized
);
assert_eq!(
eng.evaluate(&call("delete_file", true, &json!({}))),
PolicyDecision::Unset
);
}
#[test]
fn forbid_wins_over_permit() {
let eng = PolicyEngine::parse(
r#"
permit(principal, action, resource)
when { context.tool == "issue_refund" && context.args.amount <= 50 };
forbid(principal, action, resource)
when { context.tool == "issue_refund" && context.args.new_account == true };
"#,
)
.unwrap();
assert_eq!(
eng.evaluate(&call(
"issue_refund",
true,
&json!({"amount": 30, "new_account": false})
)),
PolicyDecision::Authorized,
"a small refund to an established account is pre-authorized"
);
assert_eq!(
eng.evaluate(&call(
"issue_refund",
true,
&json!({"amount": 30, "new_account": true})
)),
PolicyDecision::Blocked,
"forbid overrides the permit for a new account"
);
assert_eq!(
eng.evaluate(&call(
"issue_refund",
true,
&json!({"amount": 5000, "new_account": false})
)),
PolicyDecision::Unset,
"a large refund is neither permitted nor forbidden → defer to approval"
);
}
#[test]
fn session_call_count_guards_a_runaway_loop() {
let eng = PolicyEngine::parse(
r#"forbid(principal, action, resource) when { context.session_calls > 50 };"#,
)
.unwrap();
let args = json!({});
let under = CallContext {
session_calls: 50,
..call("anything", true, &args)
};
let over = CallContext {
session_calls: 51,
..call("anything", true, &args)
};
assert_eq!(eng.evaluate(&under), PolicyDecision::Unset);
assert_eq!(eng.evaluate(&over), PolicyDecision::Blocked);
}
#[test]
fn decimal_money_rules_handle_cents() {
let eng = PolicyEngine::parse(
r#"
permit(principal, action, resource)
when { context.tool == "issue_refund"
&& context.dec.amount.lessThanOrEqual(decimal("50.00")) };
"#,
)
.unwrap();
assert_eq!(
eng.evaluate(&call("issue_refund", true, &json!({"amount": 30}))),
PolicyDecision::Authorized
);
assert_eq!(
eng.evaluate(&call("issue_refund", true, &json!({"amount": 49.99}))),
PolicyDecision::Authorized
);
assert_eq!(
eng.evaluate(&call("issue_refund", true, &json!({"amount": 50.01}))),
PolicyDecision::Unset
);
}
#[test]
fn a_decimal_forbid_blocks_over_a_cents_threshold() {
let eng = PolicyEngine::parse(
r#"forbid(principal, action, resource)
when { context.dec.amount.greaterThan(decimal("50.00")) };"#,
)
.unwrap();
assert_eq!(
eng.evaluate(&call("issue_refund", true, &json!({"amount": 50.01}))),
PolicyDecision::Blocked
);
assert_eq!(
eng.evaluate(&call("issue_refund", true, &json!({"amount": 50.00}))),
PolicyDecision::Unset,
"exactly at the limit is not over it"
);
}
#[test]
fn invalid_policy_source_is_a_parse_error() {
assert!(PolicyEngine::parse("this is not cedar").is_err());
}
#[test]
fn an_erroring_policy_never_authorizes_a_mutation() {
let eng = PolicyEngine::parse(
r#"
permit(principal, action, resource) when { context.tool == "issue_refund" };
forbid(principal, action, resource) when { context.args.blocked == true };
"#,
)
.unwrap();
let args = json!({"amount": 30}); assert_eq!(
eng.evaluate(&call("issue_refund", true, &args)),
PolicyDecision::Unset,
"an erroring policy set must never auto-authorize a real mutation"
);
let guarded = PolicyEngine::parse(
r#"
permit(principal, action, resource) when { context.tool == "issue_refund" };
forbid(principal, action, resource)
when { context.args has blocked && context.args.blocked == true };
"#,
)
.unwrap();
assert_eq!(
guarded.evaluate(&call("issue_refund", true, &args)),
PolicyDecision::Authorized
);
}
#[test]
fn float_and_nested_args_do_not_break_evaluation() {
let eng = PolicyEngine::parse(
r#"forbid(principal, action, resource) when { context.args.limit > 10 };"#,
)
.unwrap();
let args = json!({"limit": 49.99, "nested": {"a": 1}, "list": [1, 2]});
assert_eq!(
eng.evaluate(&call("do_thing", true, &args)),
PolicyDecision::Unset
);
}
}