assay/lua/builtins/
approval.rs1use 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 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: 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
55fn 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 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
102fn 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}