Skip to main content

assay/lua/builtins/
approval.rs

1//! Approval-mode guards, applied after `register_all` when the VM is
2//! created with `ExecMode::Approval`. Mutating builtins stay registered
3//! but each call is gated by its sequence index: an operation whose index
4//! is in the approved set runs against the original inner function;
5//! otherwise the call raises an approval request carrying the operation
6//! descriptor so the tool-mode resume machinery can suspend and collect a
7//! decision. A per-VM counter assigns the index and advances on every
8//! gated call, so successive resumes admit one more operation each.
9
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU64, Ordering};
13
14use mlua::{Lua, MultiValue, Table, Value};
15
16use super::gated::{BLOCKED_FUNCTIONS, BLOCKED_TABLES, is_gated_http_verb};
17use crate::lua::{APPROVAL_REQUEST_PREFIX, ApprovalConfig, approved_ops_from_env};
18
19const SUMMARY_CAP: usize = 200;
20
21struct GateState {
22    counter: AtomicU64,
23    approved: HashSet<u64>,
24    denied: Option<u64>,
25    /// Which operation each grant was issued for. A replay whose control
26    /// flow shifted must not spend an index's grant on a different op.
27    bindings: HashMap<u64, String>,
28}
29
30pub fn apply(lua: &Lua, config: &ApprovalConfig) -> mlua::Result<()> {
31    let state = Arc::new(GateState {
32        counter: AtomicU64::new(0),
33        approved: config.approved_indices.iter().copied().collect(),
34        denied: config.denied_index,
35        // Bindings arrive via ASSAY_APPROVED_OPS (crate-internal transport
36        // set by the resume machinery), keeping the public ApprovalConfig
37        // API unchanged.
38        bindings: approved_ops_from_env()
39            .into_iter()
40            .map(|entry| (entry.index, entry.op))
41            .collect(),
42    });
43    for path in BLOCKED_FUNCTIONS {
44        gate_function(lua, path, &state)?;
45    }
46    for name in BLOCKED_TABLES {
47        gate_table(lua, name, &state)?;
48    }
49    gate_http_client_request(lua, &state)?;
50    gate_io_open(lua, &state)?;
51    gate_io_output(lua, &state)?;
52    Ok(())
53}
54
55/// Advance the counter and decide the fate of this call. `Ok(())` admits
56/// the operation; an error either denies it terminally or raises the
57/// approval request that suspends the run.
58fn gate_decision(state: &GateState, op: &str, summary: &str) -> mlua::Result<()> {
59    let index = state.counter.fetch_add(1, Ordering::SeqCst);
60    if state.denied == Some(index) {
61        return Err(mlua::Error::runtime(format!("approval: {op} denied")));
62    }
63    if state.approved.contains(&index) {
64        // Every grant is bound to the op it was approved for — a grant
65        // with no binding is refused outright, and a replay that reaches
66        // a different op at this index fails terminally rather than
67        // executing an operation nobody approved.
68        return match state.bindings.get(&index) {
69            Some(expected) if expected == op => Ok(()),
70            Some(expected) => Err(mlua::Error::runtime(format!(
71                "approval: operation at index {index} changed since approval \
72                 (approved '{expected}', got '{op}')"
73            ))),
74            None => Err(mlua::Error::runtime(format!(
75                "approval: no operation binding for approved index {index} \
76                 ('{op}') — refusing"
77            ))),
78        };
79    }
80    Err(approval_request(op, summary, index))
81}
82
83fn approval_request(op: &str, summary: &str, index: u64) -> mlua::Error {
84    let payload = serde_json::json!({
85        "prompt": format!("Approve {op}?"),
86        "op": op,
87        "summary": summary,
88        "index": index,
89    });
90    mlua::Error::runtime(format!("{APPROVAL_REQUEST_PREFIX}{payload}"))
91}
92
93fn truncate(value: &str) -> String {
94    if value.chars().count() > SUMMARY_CAP {
95        let head: String = value.chars().take(SUMMARY_CAP).collect();
96        format!("{head}...")
97    } else {
98        value.to_string()
99    }
100}
101
102/// The salient argument for the descriptor: the first string argument
103/// (url for http, path for fs, command for shell, sql for db.execute).
104fn first_string_arg(args: &MultiValue) -> String {
105    for value in args.iter() {
106        if let Value::String(s) = value
107            && let Ok(text) = s.to_str()
108        {
109            return truncate(&text);
110        }
111    }
112    String::new()
113}
114
115fn gate_function(lua: &Lua, path: &str, state: &Arc<GateState>) -> mlua::Result<()> {
116    let Some((table_name, fn_name)) = path.split_once('.') else {
117        return Ok(());
118    };
119    let Some(table) = lua.globals().get::<Option<Table>>(table_name)? else {
120        return Ok(());
121    };
122    let Value::Function(inner) = table.get::<Value>(fn_name)? else {
123        return Ok(());
124    };
125    let op = path.to_string();
126    let state = Arc::clone(state);
127    let wrapper = lua.create_async_function(move |_, args: MultiValue| {
128        let inner = inner.clone();
129        let state = Arc::clone(&state);
130        let op = op.clone();
131        async move {
132            let summary = first_string_arg(&args);
133            gate_decision(&state, &op, &summary)?;
134            inner.call_async::<MultiValue>(args).await
135        }
136    })?;
137    table.set(fn_name, wrapper)?;
138    Ok(())
139}
140
141fn gate_table(lua: &Lua, name: &str, state: &Arc<GateState>) -> mlua::Result<()> {
142    let Some(table) = lua.globals().get::<Option<Table>>(name)? else {
143        return Ok(());
144    };
145    for pair in table.clone().pairs::<Value, Value>() {
146        let (key, value) = pair?;
147        let (Value::String(key_str), Value::Function(inner)) = (&key, &value) else {
148            continue;
149        };
150        let op = format!("{name}.{}", key_str.to_str()?);
151        let inner = inner.clone();
152        let state = Arc::clone(state);
153        let wrapper = lua.create_async_function(move |_, args: MultiValue| {
154            let inner = inner.clone();
155            let state = Arc::clone(&state);
156            let op = op.clone();
157            async move {
158                let summary = first_string_arg(&args);
159                gate_decision(&state, &op, &summary)?;
160                inner.call_async::<MultiValue>(args).await
161            }
162        })?;
163        table.set(key.clone(), wrapper)?;
164    }
165    Ok(())
166}
167
168fn gate_http_client_request(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
169    let Some(http) = lua.globals().get::<Option<Table>>("http")? else {
170        return Ok(());
171    };
172    let Some(inner) = http.get::<Option<mlua::Function>>("_client_request")? else {
173        return Ok(());
174    };
175    let state = Arc::clone(state);
176    let wrapper = lua.create_async_function(move |_, args: MultiValue| {
177        let inner = inner.clone();
178        let state = Arc::clone(&state);
179        async move {
180            let method = match args.iter().nth(1) {
181                Some(Value::String(s)) => Some(s.to_str()?.to_string()),
182                _ => None,
183            };
184            if let Some(method) = method
185                && is_gated_http_verb(&method)
186            {
187                let op = format!("http.{method}");
188                let summary = match args.iter().nth(2) {
189                    Some(Value::String(s)) => truncate(&s.to_str()?),
190                    _ => String::new(),
191                };
192                gate_decision(&state, &op, &summary)?;
193            }
194            inner.call_async::<MultiValue>(args).await
195        }
196    })?;
197    http.set("_client_request", wrapper)?;
198    Ok(())
199}
200
201fn gate_io_open(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
202    let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
203        return Ok(());
204    };
205    let Some(inner) = io_table.get::<Option<mlua::Function>>("open")? else {
206        return Ok(());
207    };
208    let state = Arc::clone(state);
209    let wrapper = lua.create_function(move |_, args: MultiValue| {
210        let mode = match args.iter().nth(1) {
211            Some(Value::String(s)) => s.to_str()?.to_string(),
212            _ => "r".to_string(),
213        };
214        if mode.contains('w') || mode.contains('a') || mode.contains('+') {
215            let summary = first_string_arg(&args);
216            gate_decision(&state, "io.open", &summary)?;
217        }
218        inner.call::<MultiValue>(args)
219    })?;
220    io_table.set("open", wrapper)?;
221    Ok(())
222}
223
224fn gate_io_output(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
225    let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
226        return Ok(());
227    };
228    let Some(inner) = io_table.get::<Option<mlua::Function>>("output")? else {
229        return Ok(());
230    };
231    let state = Arc::clone(state);
232    let wrapper = lua.create_function(move |_, args: MultiValue| {
233        if !args.is_empty() {
234            let summary = first_string_arg(&args);
235            gate_decision(&state, "io.output", &summary)?;
236        }
237        inner.call::<MultiValue>(args)
238    })?;
239    io_table.set("output", wrapper)?;
240    Ok(())
241}