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::{
17    BLOCKED_FUNCTIONS, BLOCKED_TABLES, header_names, http_call_is_read, is_http_verb_path,
18    operation_digest, wrap_http_verbs,
19};
20use crate::lua::{APPROVAL_REQUEST_PREFIX, ApprovalConfig, approved_ops_from_env};
21
22const SUMMARY_CAP: usize = 200;
23
24struct GateState {
25    counter: AtomicU64,
26    approved: HashSet<u64>,
27    denied: Option<u64>,
28    /// What each grant was issued for. A replay must not spend an index's
29    /// grant on a different op, nor on the same op with other arguments.
30    bindings: HashMap<u64, (String, Option<String>)>,
31}
32
33pub fn apply(lua: &Lua, config: &ApprovalConfig) -> mlua::Result<()> {
34    let state = Arc::new(GateState {
35        counter: AtomicU64::new(0),
36        approved: config.approved_indices.iter().copied().collect(),
37        denied: config.denied_index,
38        // Bindings arrive via ASSAY_APPROVED_OPS (crate-internal transport
39        // set by the resume machinery), keeping the public ApprovalConfig
40        // API unchanged.
41        bindings: approved_ops_from_env()
42            .into_iter()
43            .map(|entry| (entry.index, (entry.op, entry.digest)))
44            .collect(),
45    });
46    for path in BLOCKED_FUNCTIONS {
47        if is_http_verb_path(path) {
48            continue;
49        }
50        gate_function(lua, path, &state)?;
51    }
52    for name in BLOCKED_TABLES {
53        gate_table(lua, name, &state)?;
54    }
55    let verb_state = Arc::clone(&state);
56    wrap_http_verbs(lua, move |op, url, digest, headers| {
57        gate_decision(&verb_state, op, &truncate(url), digest, headers)
58    })?;
59    gate_http_client_request(lua, &state)?;
60    gate_io_open(lua, &state)?;
61    gate_io_output(lua, &state)?;
62    Ok(())
63}
64
65/// Advance the counter and decide the fate of this call. `Ok(())` admits
66/// the operation; an error either denies it terminally or raises the
67/// approval request that suspends the run.
68fn gate_decision(
69    state: &GateState,
70    op: &str,
71    summary: &str,
72    digest: &str,
73    headers: &[String],
74) -> mlua::Result<()> {
75    let index = state.counter.fetch_add(1, Ordering::SeqCst);
76    if state.denied == Some(index) {
77        return Err(mlua::Error::runtime(format!("approval: {op} denied")));
78    }
79    if state.approved.contains(&index) {
80        // A grant with no binding is refused outright, and a replay that
81        // reaches a different op — or the same op with different arguments
82        // — fails terminally rather than executing what nobody approved.
83        return match state.bindings.get(&index) {
84            Some((expected, _)) if expected != op => Err(mlua::Error::runtime(format!(
85                "approval: operation at index {index} changed since approval \
86                 (approved '{expected}', got '{op}')"
87            ))),
88            Some((_, Some(expected))) if expected != digest => Err(mlua::Error::runtime(format!(
89                "approval: request at index {index} changed since approval \
90                 ('{op}' arguments differ) — refusing"
91            ))),
92            Some((_, Some(_))) => Ok(()),
93            Some((_, None)) => Err(mlua::Error::runtime(format!(
94                "approval: grant for index {index} ('{op}') predates request \
95                 binding — refusing"
96            ))),
97            None => Err(mlua::Error::runtime(format!(
98                "approval: no operation binding for approved index {index} \
99                 ('{op}') — refusing"
100            ))),
101        };
102    }
103    Err(approval_request(op, summary, index, digest, headers))
104}
105
106fn approval_request(
107    op: &str,
108    summary: &str,
109    index: u64,
110    digest: &str,
111    headers: &[String],
112) -> mlua::Error {
113    let payload = serde_json::json!({
114        "prompt": format!("Approve {op}?"),
115        "op": op,
116        "summary": summary,
117        "index": index,
118        "digest": digest,
119        "headers": headers,
120    });
121    mlua::Error::runtime(format!("{APPROVAL_REQUEST_PREFIX}{payload}"))
122}
123
124fn truncate(value: &str) -> String {
125    if value.chars().count() > SUMMARY_CAP {
126        let head: String = value.chars().take(SUMMARY_CAP).collect();
127        format!("{head}...")
128    } else {
129        value.to_string()
130    }
131}
132
133/// The salient argument for the descriptor: the first string argument
134/// (url for http, path for fs, command for shell, sql for db.execute).
135fn first_string_arg(args: &MultiValue) -> String {
136    for value in args.iter() {
137        if let Value::String(s) = value
138            && let Ok(text) = s.to_str()
139        {
140            return truncate(&text);
141        }
142    }
143    String::new()
144}
145
146fn gate_function(lua: &Lua, path: &str, state: &Arc<GateState>) -> mlua::Result<()> {
147    let Some((table_name, fn_name)) = path.split_once('.') else {
148        return Ok(());
149    };
150    let Some(table) = lua.globals().get::<Option<Table>>(table_name)? else {
151        return Ok(());
152    };
153    let Value::Function(inner) = table.get::<Value>(fn_name)? else {
154        return Ok(());
155    };
156    let wrapper = gated_wrapper(lua, path.to_string(), inner, state)?;
157    table.set(fn_name, wrapper)?;
158    Ok(())
159}
160
161/// One gated call: digest the request, take a decision, and only then
162/// delegate to the original builtin.
163fn gated_wrapper(
164    lua: &Lua,
165    op: String,
166    inner: mlua::Function,
167    state: &Arc<GateState>,
168) -> mlua::Result<mlua::Function> {
169    let state = Arc::clone(state);
170    lua.create_async_function(move |_, args: MultiValue| {
171        let inner = inner.clone();
172        let state = Arc::clone(&state);
173        let op = op.clone();
174        async move {
175            let summary = first_string_arg(&args);
176            gate_decision(
177                &state,
178                &op,
179                &summary,
180                &operation_digest(&op, &args),
181                &header_names(&args),
182            )?;
183            inner.call_async::<MultiValue>(args).await
184        }
185    })
186}
187
188fn gate_table(lua: &Lua, name: &str, state: &Arc<GateState>) -> mlua::Result<()> {
189    let Some(table) = lua.globals().get::<Option<Table>>(name)? else {
190        return Ok(());
191    };
192    for pair in table.clone().pairs::<Value, Value>() {
193        let (key, value) = pair?;
194        let (Value::String(key_str), Value::Function(inner)) = (&key, &value) else {
195            continue;
196        };
197        let op = format!("{name}.{}", key_str.to_str()?);
198        let wrapper = gated_wrapper(lua, op, inner.clone(), state)?;
199        table.set(key.clone(), wrapper)?;
200    }
201    Ok(())
202}
203
204fn gate_http_client_request(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
205    let Some(http) = lua.globals().get::<Option<Table>>("http")? else {
206        return Ok(());
207    };
208    let Some(inner) = http.get::<Option<mlua::Function>>("_client_request")? else {
209        return Ok(());
210    };
211    let state = Arc::clone(state);
212    let wrapper = lua.create_async_function(move |lua, args: MultiValue| {
213        let inner = inner.clone();
214        let state = Arc::clone(&state);
215        async move {
216            let method = match args.iter().nth(1) {
217                Some(Value::String(s)) => Some(s.to_str()?.to_string()),
218                _ => None,
219            };
220            let url = match args.iter().nth(2) {
221                Some(Value::String(s)) => Some(s.to_str()?.to_string()),
222                _ => None,
223            };
224            if let Some(method) = method
225                && !http_call_is_read(&lua, &method, url.as_deref())
226            {
227                let op = format!("http.{method}");
228                gate_decision(
229                    &state,
230                    &op,
231                    &truncate(url.as_deref().unwrap_or("")),
232                    &operation_digest(&op, &args),
233                    &header_names(&args),
234                )?;
235            }
236            inner.call_async::<MultiValue>(args).await
237        }
238    })?;
239    http.set("_client_request", wrapper)?;
240    Ok(())
241}
242
243fn gate_io_open(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
244    let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
245        return Ok(());
246    };
247    let Some(inner) = io_table.get::<Option<mlua::Function>>("open")? else {
248        return Ok(());
249    };
250    let state = Arc::clone(state);
251    let wrapper = lua.create_function(move |_, args: MultiValue| {
252        let mode = match args.iter().nth(1) {
253            Some(Value::String(s)) => s.to_str()?.to_string(),
254            _ => "r".to_string(),
255        };
256        if mode.contains('w') || mode.contains('a') || mode.contains('+') {
257            let summary = first_string_arg(&args);
258            gate_decision(
259                &state,
260                "io.open",
261                &summary,
262                &operation_digest("io.open", &args),
263                &[],
264            )?;
265        }
266        inner.call::<MultiValue>(args)
267    })?;
268    io_table.set("open", wrapper)?;
269    Ok(())
270}
271
272fn gate_io_output(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
273    let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
274        return Ok(());
275    };
276    let Some(inner) = io_table.get::<Option<mlua::Function>>("output")? else {
277        return Ok(());
278    };
279    let state = Arc::clone(state);
280    let wrapper = lua.create_function(move |_, args: MultiValue| {
281        if !args.is_empty() {
282            let summary = first_string_arg(&args);
283            gate_decision(
284                &state,
285                "io.output",
286                &summary,
287                &operation_digest("io.output", &args),
288                &[],
289            )?;
290        }
291        inner.call::<MultiValue>(args)
292    })?;
293    io_table.set("output", wrapper)?;
294    Ok(())
295}