assay/lua/builtins/
approval.rs1use 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
45fn 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
78fn 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}