Skip to main content

lex_runtime/handler/
approval.rs

1//! `approval` effect: the `ApprovalSink` trait, its stdin / null implementations, and the `approval.*` dispatch gated by `--allow-approval`.
2
3use super::*;
4
5/// Host boundary for the `[approval]` effect. `request` blocks until an
6/// operator answers — `Ok(answer)` on approve, `Err(reason)` on deny or
7/// timeout. Implementations decide what "blocks" means (a stdin prompt,
8/// an HTTP long-poll against a dashboard, ...); the effect handler only
9/// needs the synchronous result.
10pub trait ApprovalSink: Send {
11    fn request(&self, scope: &str, reason: &str) -> Result<String, String>;
12}
13
14/// Default sink: `approval.request` is granted by the type/effect
15/// system but there's no operator to ask, so every call is refused.
16/// Embedders that want the effect to actually work must call
17/// `with_approval_sink` — an unconfigured sink silently no-op'ing as
18/// "approved" would defeat the point of the effect.
19pub struct NullApprovalSink;
20impl ApprovalSink for NullApprovalSink {
21    fn request(&self, _scope: &str, _reason: &str) -> Result<String, String> {
22        Err("no ApprovalSink configured — call DefaultHandler::with_approval_sink".into())
23    }
24}
25
26/// Interactive sink for `lex run`: prints the reason to stdout, blocks
27/// on a stdin line. Empty input or a leading `n`/`N` denies; anything
28/// else is the approved answer text.
29pub struct StdinApprovalSink;
30impl ApprovalSink for StdinApprovalSink {
31    fn request(&self, scope: &str, reason: &str) -> Result<String, String> {
32        use std::io::Write;
33        print!("[approval:{scope}] {reason}  (blank/n to deny) > ");
34        let _ = std::io::stdout().flush();
35        let mut line = String::new();
36        std::io::stdin().read_line(&mut line).map_err(|e| e.to_string())?;
37        let answer = line.trim();
38        if answer.is_empty() || answer.eq_ignore_ascii_case("n") || answer.eq_ignore_ascii_case("no") {
39            Err("denied by operator".into())
40        } else {
41            Ok(answer.to_string())
42        }
43    }
44}
45
46impl DefaultHandler {
47    /// `approval.request(scope, reason)` — scope allow-list mirrors
48    /// `process.spawn`'s `--allow-proc` basename check above: empty
49    /// `allow_approval` is a wildcard, non-empty requires an exact
50    /// match. On a match, blocks on `self.approval_sink`.
51    pub(super) fn dispatch_approval(&mut self, op: &str, args: Vec<Value>) -> Result<Value, String> {
52        match op {
53            "request" => {
54                let scope = expect_str(args.first())?.to_string();
55                let reason = expect_str(args.get(1))?.to_string();
56                if !self.policy.allow_approval.is_empty()
57                    && !self.policy.allow_approval.iter().any(|a| a == &scope)
58                {
59                    return Ok(err(Value::Str(format!(
60                        "approval.request: scope `{scope}` not in --allow-approval {:?}",
61                        self.policy.allow_approval
62                    ).into())));
63                }
64                match self.approval_sink.request(&scope, &reason) {
65                    Ok(answer) => Ok(ok(Value::Str(answer.into()))),
66                    Err(reason) => Ok(err(Value::Str(reason.into()))),
67                }
68            }
69            other => Err(format!("unsupported approval.{other}")),
70        }
71    }
72}