1use 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 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: 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
65fn 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 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
133fn 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
161fn 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}