Skip to main content

agentstategraph_policy_rego/
lib.rs

1//! OPA/Rego runner for AgentStateGraph policies (0.7.5 ยง4b).
2//!
3//! [`RegoEvaluator`] shells out to the `opa` binary on `$PATH` (or an
4//! explicit override via [`RegoEvaluator::with_opa_path`]). The Rego
5//! source is written to a temp file, the evaluation input is streamed
6//! in on stdin, and the decision is pulled from
7//! `data.policy.decision`.
8//!
9//! # Policy contract
10//!
11//! The Rego module MUST declare `package policy` and define a
12//! `decision` rule that evaluates to a JSON object matching
13//! [`Decision`]'s serde representation, e.g.:
14//!
15//! ```rego
16//! package policy
17//!
18//! decision := {
19//!   "kind": "allow",
20//!   "matched_policy": "infra/my-policy@1",
21//!   "preconditions": []
22//! } {
23//!   input.action == "deploy"
24//! }
25//! ```
26//!
27//! # Input shape
28//!
29//! ```json
30//! {
31//!   "situation": { "<key>": "<value>", ... },
32//!   "action": "<action>",
33//!   "agent_id": "<id>"
34//! }
35//! ```
36//!
37//! # Source variants
38//!
39//! - [`EvaluatorSource::Inline`] โ€” body is Rego source text.
40//! - [`EvaluatorSource::FilePath`] โ€” Rego source is read from disk.
41//! - [`EvaluatorSource::CommitRef`] โ€” unsupported; returns
42//!   [`ExternalError::SourceResolution`].
43
44use std::process::{Command, Stdio};
45use std::time::Duration;
46
47use agentstategraph_policy::external::{
48    DEFAULT_TIMEOUT, ExternalError, ExternalEvaluator, wait_with_timeout,
49};
50use agentstategraph_policy::selector::Situation;
51use agentstategraph_policy::types::{Decision, EvaluatorSource};
52
53/// Shells out to `opa eval`.
54pub struct RegoEvaluator {
55    /// Path (or bare name) of the OPA binary. Defaults to `"opa"`
56    /// (resolved against `$PATH`).
57    opa_path: String,
58    /// How long `opa eval` may run before it's killed.
59    timeout: Duration,
60}
61
62impl RegoEvaluator {
63    /// Construct with the default `"opa"` binary (resolved on `$PATH`).
64    pub fn new() -> Self {
65        Self::with_opa_path("opa")
66    }
67
68    /// Override the OPA binary path. Useful for tests and for servers
69    /// that ship a vendored OPA next to their own binary.
70    pub fn with_opa_path(opa_path: impl Into<String>) -> Self {
71        Self {
72            opa_path: opa_path.into(),
73            timeout: DEFAULT_TIMEOUT,
74        }
75    }
76
77    /// How long `opa eval` may run before it's killed and the evaluation
78    /// fails with [`agentstategraph_policy::external::TimedOut`].
79    /// [`DEFAULT_TIMEOUT`] unless set.
80    pub fn with_timeout(mut self, timeout: Duration) -> Self {
81        self.timeout = timeout;
82        self
83    }
84}
85
86impl Default for RegoEvaluator {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl ExternalEvaluator for RegoEvaluator {
93    fn kind(&self) -> &'static str {
94        "rego"
95    }
96
97    fn evaluate(
98        &self,
99        source: &EvaluatorSource,
100        situation: &Situation,
101        action: &str,
102        agent_id: &str,
103    ) -> Result<Decision, ExternalError> {
104        // 1. Resolve source -> Rego text.
105        let rego = match source {
106            EvaluatorSource::Inline { body } => body.clone(),
107            EvaluatorSource::FilePath { path } => std::fs::read_to_string(path)
108                .map_err(|e| ExternalError::SourceResolution(e.to_string()))?,
109            EvaluatorSource::CommitRef { .. } => {
110                return Err(ExternalError::SourceResolution(
111                    "commit_ref not supported by RegoEvaluator".into(),
112                ));
113            }
114        };
115
116        // 2. Write to a temp file; OPA expects `--data <file>`.
117        let tmp = tempfile::Builder::new()
118            .suffix(".rego")
119            .tempfile()
120            .map_err(|e| ExternalError::Execution(format!("tempfile: {e}")))?;
121        std::fs::write(tmp.path(), rego.as_bytes())
122            .map_err(|e| ExternalError::Execution(format!("write rego: {e}")))?;
123
124        // 3. Build the input envelope.
125        let input = serde_json::json!({
126            "situation": &situation.0,
127            "action": action,
128            "agent_id": agent_id,
129        });
130        let input_bytes = serde_json::to_vec(&input)
131            .map_err(|e| ExternalError::Execution(format!("input serialize: {e}")))?;
132
133        // 4. Spawn `opa eval`, give it the input, and wait.
134        let child = Command::new(&self.opa_path)
135            .arg("eval")
136            .arg("--data")
137            .arg(tmp.path())
138            .arg("--stdin-input")
139            .arg("--format=json")
140            .arg("data.policy.decision")
141            .stdin(Stdio::piped())
142            .stdout(Stdio::piped())
143            .stderr(Stdio::piped())
144            .spawn()
145            .map_err(|e| {
146                ExternalError::Execution(format!(
147                    "failed to invoke {}: {e} (is OPA on $PATH?)",
148                    self.opa_path
149                ))
150            })?;
151        let output = wait_with_timeout(child, Some(input_bytes), self.timeout)?;
152
153        if !output.status.success() {
154            return Err(ExternalError::Execution(format!(
155                "opa eval exited {}: {}",
156                output.status,
157                String::from_utf8_lossy(&output.stderr)
158            )));
159        }
160
161        // 6. Parse the OPA envelope.
162        // {"result": [{"expressions": [{"value": <Decision>, ...}]}]}
163        let envelope: serde_json::Value = serde_json::from_slice(&output.stdout)
164            .map_err(|e| ExternalError::Execution(format!("parse opa output: {e}")))?;
165        let decision_value = envelope
166            .pointer("/result/0/expressions/0/value")
167            .cloned()
168            .ok_or_else(|| {
169                ExternalError::Execution("opa output missing /result/0/expressions/0/value".into())
170            })?;
171        let decision: Decision = serde_json::from_value(decision_value)
172            .map_err(|e| ExternalError::Execution(format!("parse decision: {e}")))?;
173        Ok(decision)
174    }
175}