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::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};
18
19const SUMMARY_CAP: usize = 200;
20
21struct GateState {
22    counter: AtomicU64,
23    approved: HashSet<u64>,
24    denied: Option<u64>,
25}
26
27pub fn apply(lua: &Lua, config: &ApprovalConfig) -> mlua::Result<()> {
28    let state = Arc::new(GateState {
29        counter: AtomicU64::new(0),
30        approved: config.approved_indices.iter().copied().collect(),
31        denied: config.denied_index,
32    });
33    for path in BLOCKED_FUNCTIONS {
34        gate_function(lua, path, &state)?;
35    }
36    for name in BLOCKED_TABLES {
37        gate_table(lua, name, &state)?;
38    }
39    gate_http_client_request(lua, &state)?;
40    gate_io_open(lua, &state)?;
41    gate_io_output(lua, &state)?;
42    Ok(())
43}
44
45/// Advance the counter and decide the fate of this call. `Ok(())` admits
46/// the operation; an error either denies it terminally or raises the
47/// approval request that suspends the run.
48fn gate_decision(state: &GateState, op: &str, summary: &str) -> mlua::Result<()> {
49    let index = state.counter.fetch_add(1, Ordering::SeqCst);
50    if state.denied == Some(index) {
51        return Err(mlua::Error::runtime(format!("approval: {op} denied")));
52    }
53    if state.approved.contains(&index) {
54        return Ok(());
55    }
56    Err(approval_request(op, summary, index))
57}
58
59fn approval_request(op: &str, summary: &str, index: u64) -> mlua::Error {
60    let payload = serde_json::json!({
61        "prompt": format!("Approve {op}?"),
62        "op": op,
63        "summary": summary,
64        "index": index,
65    });
66    mlua::Error::runtime(format!("{APPROVAL_REQUEST_PREFIX}{payload}"))
67}
68
69fn truncate(value: &str) -> String {
70    if value.chars().count() > SUMMARY_CAP {
71        let head: String = value.chars().take(SUMMARY_CAP).collect();
72        format!("{head}...")
73    } else {
74        value.to_string()
75    }
76}
77
78/// The salient argument for the descriptor: the first string argument
79/// (url for http, path for fs, command for shell, sql for db.execute).
80fn first_string_arg(args: &MultiValue) -> String {
81    for value in args.iter() {
82        if let Value::String(s) = value
83            && let Ok(text) = s.to_str()
84        {
85            return truncate(&text);
86        }
87    }
88    String::new()
89}
90
91fn gate_function(lua: &Lua, path: &str, state: &Arc<GateState>) -> mlua::Result<()> {
92    let Some((table_name, fn_name)) = path.split_once('.') else {
93        return Ok(());
94    };
95    let Some(table) = lua.globals().get::<Option<Table>>(table_name)? else {
96        return Ok(());
97    };
98    let Value::Function(inner) = table.get::<Value>(fn_name)? else {
99        return Ok(());
100    };
101    let op = path.to_string();
102    let state = Arc::clone(state);
103    let wrapper = lua.create_async_function(move |_, args: MultiValue| {
104        let inner = inner.clone();
105        let state = Arc::clone(&state);
106        let op = op.clone();
107        async move {
108            let summary = first_string_arg(&args);
109            gate_decision(&state, &op, &summary)?;
110            inner.call_async::<MultiValue>(args).await
111        }
112    })?;
113    table.set(fn_name, wrapper)?;
114    Ok(())
115}
116
117fn gate_table(lua: &Lua, name: &str, state: &Arc<GateState>) -> mlua::Result<()> {
118    let Some(table) = lua.globals().get::<Option<Table>>(name)? else {
119        return Ok(());
120    };
121    for pair in table.clone().pairs::<Value, Value>() {
122        let (key, value) = pair?;
123        let (Value::String(key_str), Value::Function(inner)) = (&key, &value) else {
124            continue;
125        };
126        let op = format!("{name}.{}", key_str.to_str()?);
127        let inner = inner.clone();
128        let state = Arc::clone(state);
129        let wrapper = lua.create_async_function(move |_, args: MultiValue| {
130            let inner = inner.clone();
131            let state = Arc::clone(&state);
132            let op = op.clone();
133            async move {
134                let summary = first_string_arg(&args);
135                gate_decision(&state, &op, &summary)?;
136                inner.call_async::<MultiValue>(args).await
137            }
138        })?;
139        table.set(key.clone(), wrapper)?;
140    }
141    Ok(())
142}
143
144fn gate_http_client_request(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
145    let Some(http) = lua.globals().get::<Option<Table>>("http")? else {
146        return Ok(());
147    };
148    let Some(inner) = http.get::<Option<mlua::Function>>("_client_request")? else {
149        return Ok(());
150    };
151    let state = Arc::clone(state);
152    let wrapper = lua.create_async_function(move |_, args: MultiValue| {
153        let inner = inner.clone();
154        let state = Arc::clone(&state);
155        async move {
156            let method = match args.iter().nth(1) {
157                Some(Value::String(s)) => Some(s.to_str()?.to_string()),
158                _ => None,
159            };
160            if let Some(method) = method
161                && is_gated_http_verb(&method)
162            {
163                let op = format!("http.{method}");
164                let summary = match args.iter().nth(2) {
165                    Some(Value::String(s)) => truncate(&s.to_str()?),
166                    _ => String::new(),
167                };
168                gate_decision(&state, &op, &summary)?;
169            }
170            inner.call_async::<MultiValue>(args).await
171        }
172    })?;
173    http.set("_client_request", wrapper)?;
174    Ok(())
175}
176
177fn gate_io_open(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
178    let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
179        return Ok(());
180    };
181    let Some(inner) = io_table.get::<Option<mlua::Function>>("open")? else {
182        return Ok(());
183    };
184    let state = Arc::clone(state);
185    let wrapper = lua.create_function(move |_, args: MultiValue| {
186        let mode = match args.iter().nth(1) {
187            Some(Value::String(s)) => s.to_str()?.to_string(),
188            _ => "r".to_string(),
189        };
190        if mode.contains('w') || mode.contains('a') || mode.contains('+') {
191            let summary = first_string_arg(&args);
192            gate_decision(&state, "io.open", &summary)?;
193        }
194        inner.call::<MultiValue>(args)
195    })?;
196    io_table.set("open", wrapper)?;
197    Ok(())
198}
199
200fn gate_io_output(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
201    let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
202        return Ok(());
203    };
204    let Some(inner) = io_table.get::<Option<mlua::Function>>("output")? else {
205        return Ok(());
206    };
207    let state = Arc::clone(state);
208    let wrapper = lua.create_function(move |_, args: MultiValue| {
209        if !args.is_empty() {
210            let summary = first_string_arg(&args);
211            gate_decision(&state, "io.output", &summary)?;
212        }
213        inner.call::<MultiValue>(args)
214    })?;
215    io_table.set("output", wrapper)?;
216    Ok(())
217}