Skip to main content

agentstategraph_policy/
external.rs

1//! External-evaluator dispatcher (0.7.5 §4a).
2//!
3//! This module provides the trait + registry that let a `PolicyStore`
4//! route evaluation to an external rule engine (Rego, Cedar, WASM,
5//! ...) when a policy carries `external_evaluator: Some(_)`. The
6//! concrete runner crates live as optional siblings
7//! (`agentstategraph-policy-wasm` / `-rego` / `-cedar` — §4b); the
8//! main policy crate ships only the trait, the registry, and the
9//! dispatch wiring so callers that never use external evaluators pay
10//! no extra dep cost.
11//!
12//! Dispatch semantics (see `PolicyStore::evaluate_scoped`):
13//!
14//! - Policies without an `external_evaluator` go through the local
15//!   evaluator as before.
16//! - Policies whose `external_evaluator.kind` matches a registered
17//!   runner are evaluated by that runner; the runner's `Decision`
18//!   participates in the normal `deny > require_approval > allow`
19//!   precedence alongside local decisions.
20//! - Policies whose `external_evaluator.kind` is *not* registered are
21//!   skipped — treated as not-matching. This keeps a missing runner
22//!   from crashing evaluation and matches the POLICY_V1.md §11
23//!   soft-model principle.
24
25use std::collections::HashMap;
26use std::io::{Read, Write};
27use std::process::{Child, Output};
28use std::sync::Arc;
29use std::thread::{self, JoinHandle};
30use std::time::{Duration, Instant};
31
32use crate::selector::Situation;
33use crate::types::{Decision, EvaluatorSource};
34
35/// Failure modes for an external evaluator invocation.
36#[derive(Debug, thiserror::Error)]
37pub enum ExternalError {
38    /// The policy's `external_evaluator.kind` has no runner in the
39    /// registry. Dispatch treats this as a skip (policy not-matching);
40    /// surfaced as an error only when the registry is asked directly.
41    #[error("evaluator kind '{0}' not registered")]
42    NotRegistered(String),
43
44    /// The runner could not resolve the `EvaluatorSource` into the
45    /// rule-engine-specific input (missing file, state-path lookup
46    /// failed, bad Inline body, ...).
47    #[error("source resolution failed: {0}")]
48    SourceResolution(String),
49
50    /// The rule engine ran but returned an error (syntax error in the
51    /// policy body, runtime trap, subprocess non-zero exit, ...).
52    #[error("evaluator failed: {0}")]
53    Execution(String),
54
55    /// Escape hatch for runner-specific errors.
56    #[error(transparent)]
57    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
58}
59
60impl ExternalError {
61    /// A stable, low-cardinality code for this error, which telemetry
62    /// records as `error.type`. A runner that timed out returns
63    /// [`TimedOut`] in [`ExternalError::Other`], and its code is
64    /// `evaluator_timeout`.
65    pub fn code(&self) -> &'static str {
66        match self {
67            ExternalError::NotRegistered(_) => "evaluator_not_registered",
68            ExternalError::SourceResolution(_) => "evaluator_source_unresolved",
69            ExternalError::Execution(_) => "evaluator_failed",
70            ExternalError::Other(e) if e.is::<TimedOut>() => "evaluator_timeout",
71            ExternalError::Other(_) => "evaluator_error",
72        }
73    }
74}
75
76/// How long a runner's process may run before [`wait_with_timeout`] kills
77/// it, unless the runner sets its own limit.
78pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
79
80/// A runner's process ran past its time limit and was killed. Runners return
81/// it as [`ExternalError::Other`].
82#[derive(Debug, thiserror::Error)]
83#[error("evaluator timed out after {0:?}")]
84pub struct TimedOut(pub Duration);
85
86/// Wait for a runner's process, writing `input` to its stdin, and collect its
87/// output, as [`Child::wait_with_output`] does, but kill it if it runs longer
88/// than `timeout`: a hung evaluator would otherwise hold the write waiting on
89/// its decision. Spawn it with stdout and stderr piped, and stdin too when
90/// there's input.
91pub fn wait_with_timeout(
92    mut child: Child,
93    input: Option<Vec<u8>>,
94    timeout: Duration,
95) -> Result<Output, ExternalError> {
96    let started = Instant::now();
97    // Write and read on threads, so a process that fills one pipe while
98    // we're blocked on another can't deadlock against us.
99    let stdin = child.stdin.take();
100    let writer = input.and_then(|input| {
101        let mut stdin = stdin?;
102        Some(thread::spawn(move || stdin.write_all(&input)))
103    });
104    let stdout = child.stdout.take().map(read_on_thread);
105    let stderr = child.stderr.take().map(read_on_thread);
106
107    let mut pause = Duration::from_millis(1);
108    let status = loop {
109        let exited = child
110            .try_wait()
111            .map_err(|e| ExternalError::Execution(format!("wait: {e}")))?;
112        if let Some(status) = exited {
113            break status;
114        }
115        let elapsed = started.elapsed();
116        if elapsed >= timeout {
117            let _ = child.kill();
118            let _ = child.wait();
119            return Err(ExternalError::Other(Box::new(TimedOut(timeout))));
120        }
121        thread::sleep(pause.min(timeout - elapsed));
122        pause = (pause * 2).min(Duration::from_millis(50));
123    };
124
125    if let Some(Ok(Err(e))) = writer.map(JoinHandle::join) {
126        // A process that exits without reading all its input closes the
127        // pipe; its status and output say why.
128        if e.kind() != std::io::ErrorKind::BrokenPipe {
129            return Err(ExternalError::Execution(format!("write stdin: {e}")));
130        }
131    }
132    let collect = |pipe: Option<JoinHandle<Vec<u8>>>| {
133        pipe.and_then(|reader| reader.join().ok())
134            .unwrap_or_default()
135    };
136    Ok(Output {
137        status,
138        stdout: collect(stdout),
139        stderr: collect(stderr),
140    })
141}
142
143fn read_on_thread(mut pipe: impl Read + Send + 'static) -> JoinHandle<Vec<u8>> {
144    thread::spawn(move || {
145        let mut out = Vec::new();
146        let _ = pipe.read_to_end(&mut out);
147        out
148    })
149}
150
151/// Implementors evaluate a policy against a situation using some
152/// external rule language.
153///
154/// Implementations MUST be `Send + Sync` because the registry is
155/// shared across threads inside `PolicyStore` via `Arc`.
156pub trait ExternalEvaluator: Send + Sync {
157    /// Canonical kind tag — matches the `kind` field on
158    /// `ExternalEvaluatorRef` variants (`"rego"`, `"cedar"`, `"wasm"`,
159    /// or any custom value a third-party runner picks).
160    fn kind(&self) -> &'static str;
161
162    /// Evaluate against the policy's `external_evaluator.source`. The
163    /// runner is responsible for resolving the source (`Inline` /
164    /// `FilePath` / `CommitRef`) into whatever form its rule engine
165    /// consumes.
166    fn evaluate(
167        &self,
168        source: &EvaluatorSource,
169        situation: &Situation,
170        action: &str,
171        agent_id: &str,
172    ) -> Result<Decision, ExternalError>;
173}
174
175/// Registry of installed external evaluators.
176///
177/// A [`PolicyStore`](crate::PolicyStore) holds one (optional) via
178/// `with_external_evaluators`. When a policy's `external_evaluator`
179/// field is `Some(_)` and the registry contains a runner whose
180/// `kind()` matches, dispatch routes to that runner. Otherwise the
181/// store falls back to local evaluation for non-external policies,
182/// and skips policies whose external kind is unregistered.
183#[derive(Default)]
184pub struct ExternalEvaluatorRegistry {
185    impls: HashMap<&'static str, Arc<dyn ExternalEvaluator>>,
186}
187
188impl ExternalEvaluatorRegistry {
189    /// Create an empty registry.
190    pub fn new() -> Self {
191        Self {
192            impls: HashMap::new(),
193        }
194    }
195
196    /// Register a runner. If a runner with the same `kind()` is
197    /// already registered, the new one replaces it.
198    pub fn register(&mut self, eval: Arc<dyn ExternalEvaluator>) {
199        self.impls.insert(eval.kind(), eval);
200    }
201
202    /// Look up a runner by kind tag.
203    pub fn get(&self, kind: &str) -> Option<&Arc<dyn ExternalEvaluator>> {
204        self.impls.get(kind)
205    }
206
207    /// List every registered kind tag. Order is unspecified.
208    pub fn kinds(&self) -> Vec<&'static str> {
209        self.impls.keys().copied().collect()
210    }
211
212    /// Number of registered runners.
213    pub fn len(&self) -> usize {
214        self.impls.len()
215    }
216
217    /// `true` iff no runners are registered.
218    pub fn is_empty(&self) -> bool {
219        self.impls.is_empty()
220    }
221}
222
223impl std::fmt::Debug for ExternalEvaluatorRegistry {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.debug_struct("ExternalEvaluatorRegistry")
226            .field("kinds", &self.kinds())
227            .finish()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    struct MockRunner;
236    impl ExternalEvaluator for MockRunner {
237        fn kind(&self) -> &'static str {
238            "mock"
239        }
240        fn evaluate(
241            &self,
242            _source: &EvaluatorSource,
243            _situation: &Situation,
244            _action: &str,
245            _agent_id: &str,
246        ) -> Result<Decision, ExternalError> {
247            Ok(Decision::NoPolicyMatch)
248        }
249    }
250
251    #[cfg(unix)]
252    #[test]
253    fn a_process_past_its_timeout_is_killed() {
254        use std::process::{Command, Stdio};
255        let child = Command::new("sleep")
256            .arg("5")
257            .stdout(Stdio::piped())
258            .stderr(Stdio::piped())
259            .spawn()
260            .unwrap();
261        let started = Instant::now();
262        let error = wait_with_timeout(child, None, Duration::from_millis(100)).unwrap_err();
263        assert!(started.elapsed() < Duration::from_secs(4));
264        assert_eq!(error.code(), "evaluator_timeout");
265    }
266
267    #[cfg(unix)]
268    #[test]
269    fn a_process_gets_its_input_and_returns_its_output() {
270        use std::process::{Command, Stdio};
271        let child = Command::new("cat")
272            .stdin(Stdio::piped())
273            .stdout(Stdio::piped())
274            .stderr(Stdio::piped())
275            .spawn()
276            .unwrap();
277        // More than a pipe holds, so writing and reading must overlap.
278        let input = vec![b'x'; 1 << 20];
279        let output = wait_with_timeout(child, Some(input.clone()), DEFAULT_TIMEOUT).unwrap();
280        assert!(output.status.success());
281        assert_eq!(output.stdout, input);
282    }
283
284    #[test]
285    fn registry_register_and_get() {
286        let mut reg = ExternalEvaluatorRegistry::new();
287        assert!(reg.is_empty());
288        reg.register(Arc::new(MockRunner));
289        assert_eq!(reg.len(), 1);
290        assert!(reg.get("mock").is_some());
291        assert!(reg.get("missing").is_none());
292        assert_eq!(reg.kinds(), vec!["mock"]);
293    }
294}