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, http_call_is_read, is_http_verb_path, wrap_http_verbs,
18};
19use crate::lua::{APPROVAL_REQUEST_PREFIX, ApprovalConfig, approved_ops_from_env};
20
21const SUMMARY_CAP: usize = 200;
22
23struct GateState {
24 counter: AtomicU64,
25 approved: HashSet<u64>,
26 denied: Option<u64>,
27 bindings: HashMap<u64, String>,
30}
31
32pub fn apply(lua: &Lua, config: &ApprovalConfig) -> mlua::Result<()> {
33 let state = Arc::new(GateState {
34 counter: AtomicU64::new(0),
35 approved: config.approved_indices.iter().copied().collect(),
36 denied: config.denied_index,
37 bindings: approved_ops_from_env()
41 .into_iter()
42 .map(|entry| (entry.index, entry.op))
43 .collect(),
44 });
45 for path in BLOCKED_FUNCTIONS {
46 if is_http_verb_path(path) {
47 continue;
48 }
49 gate_function(lua, path, &state)?;
50 }
51 for name in BLOCKED_TABLES {
52 gate_table(lua, name, &state)?;
53 }
54 let verb_state = Arc::clone(&state);
55 wrap_http_verbs(lua, move |op, url| {
56 gate_decision(&verb_state, op, &truncate(url))
57 })?;
58 gate_http_client_request(lua, &state)?;
59 gate_io_open(lua, &state)?;
60 gate_io_output(lua, &state)?;
61 Ok(())
62}
63
64fn gate_decision(state: &GateState, op: &str, summary: &str) -> mlua::Result<()> {
68 let index = state.counter.fetch_add(1, Ordering::SeqCst);
69 if state.denied == Some(index) {
70 return Err(mlua::Error::runtime(format!("approval: {op} denied")));
71 }
72 if state.approved.contains(&index) {
73 return match state.bindings.get(&index) {
78 Some(expected) if expected == op => Ok(()),
79 Some(expected) => Err(mlua::Error::runtime(format!(
80 "approval: operation at index {index} changed since approval \
81 (approved '{expected}', got '{op}')"
82 ))),
83 None => Err(mlua::Error::runtime(format!(
84 "approval: no operation binding for approved index {index} \
85 ('{op}') — refusing"
86 ))),
87 };
88 }
89 Err(approval_request(op, summary, index))
90}
91
92fn approval_request(op: &str, summary: &str, index: u64) -> mlua::Error {
93 let payload = serde_json::json!({
94 "prompt": format!("Approve {op}?"),
95 "op": op,
96 "summary": summary,
97 "index": index,
98 });
99 mlua::Error::runtime(format!("{APPROVAL_REQUEST_PREFIX}{payload}"))
100}
101
102fn truncate(value: &str) -> String {
103 if value.chars().count() > SUMMARY_CAP {
104 let head: String = value.chars().take(SUMMARY_CAP).collect();
105 format!("{head}...")
106 } else {
107 value.to_string()
108 }
109}
110
111fn first_string_arg(args: &MultiValue) -> String {
114 for value in args.iter() {
115 if let Value::String(s) = value
116 && let Ok(text) = s.to_str()
117 {
118 return truncate(&text);
119 }
120 }
121 String::new()
122}
123
124fn gate_function(lua: &Lua, path: &str, state: &Arc<GateState>) -> mlua::Result<()> {
125 let Some((table_name, fn_name)) = path.split_once('.') else {
126 return Ok(());
127 };
128 let Some(table) = lua.globals().get::<Option<Table>>(table_name)? else {
129 return Ok(());
130 };
131 let Value::Function(inner) = table.get::<Value>(fn_name)? else {
132 return Ok(());
133 };
134 let op = path.to_string();
135 let state = Arc::clone(state);
136 let wrapper = lua.create_async_function(move |_, args: MultiValue| {
137 let inner = inner.clone();
138 let state = Arc::clone(&state);
139 let op = op.clone();
140 async move {
141 let summary = first_string_arg(&args);
142 gate_decision(&state, &op, &summary)?;
143 inner.call_async::<MultiValue>(args).await
144 }
145 })?;
146 table.set(fn_name, wrapper)?;
147 Ok(())
148}
149
150fn gate_table(lua: &Lua, name: &str, state: &Arc<GateState>) -> mlua::Result<()> {
151 let Some(table) = lua.globals().get::<Option<Table>>(name)? else {
152 return Ok(());
153 };
154 for pair in table.clone().pairs::<Value, Value>() {
155 let (key, value) = pair?;
156 let (Value::String(key_str), Value::Function(inner)) = (&key, &value) else {
157 continue;
158 };
159 let op = format!("{name}.{}", key_str.to_str()?);
160 let inner = inner.clone();
161 let state = Arc::clone(state);
162 let wrapper = lua.create_async_function(move |_, args: MultiValue| {
163 let inner = inner.clone();
164 let state = Arc::clone(&state);
165 let op = op.clone();
166 async move {
167 let summary = first_string_arg(&args);
168 gate_decision(&state, &op, &summary)?;
169 inner.call_async::<MultiValue>(args).await
170 }
171 })?;
172 table.set(key.clone(), wrapper)?;
173 }
174 Ok(())
175}
176
177fn gate_http_client_request(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
178 let Some(http) = lua.globals().get::<Option<Table>>("http")? else {
179 return Ok(());
180 };
181 let Some(inner) = http.get::<Option<mlua::Function>>("_client_request")? else {
182 return Ok(());
183 };
184 let state = Arc::clone(state);
185 let wrapper = lua.create_async_function(move |lua, args: MultiValue| {
186 let inner = inner.clone();
187 let state = Arc::clone(&state);
188 async move {
189 let method = match args.iter().nth(1) {
190 Some(Value::String(s)) => Some(s.to_str()?.to_string()),
191 _ => None,
192 };
193 let url = match args.iter().nth(2) {
194 Some(Value::String(s)) => Some(s.to_str()?.to_string()),
195 _ => None,
196 };
197 if let Some(method) = method
198 && !http_call_is_read(&lua, &method, url.as_deref())
199 {
200 let op = format!("http.{method}");
201 gate_decision(&state, &op, &truncate(url.as_deref().unwrap_or("")))?;
202 }
203 inner.call_async::<MultiValue>(args).await
204 }
205 })?;
206 http.set("_client_request", wrapper)?;
207 Ok(())
208}
209
210fn gate_io_open(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
211 let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
212 return Ok(());
213 };
214 let Some(inner) = io_table.get::<Option<mlua::Function>>("open")? else {
215 return Ok(());
216 };
217 let state = Arc::clone(state);
218 let wrapper = lua.create_function(move |_, args: MultiValue| {
219 let mode = match args.iter().nth(1) {
220 Some(Value::String(s)) => s.to_str()?.to_string(),
221 _ => "r".to_string(),
222 };
223 if mode.contains('w') || mode.contains('a') || mode.contains('+') {
224 let summary = first_string_arg(&args);
225 gate_decision(&state, "io.open", &summary)?;
226 }
227 inner.call::<MultiValue>(args)
228 })?;
229 io_table.set("open", wrapper)?;
230 Ok(())
231}
232
233fn gate_io_output(lua: &Lua, state: &Arc<GateState>) -> mlua::Result<()> {
234 let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
235 return Ok(());
236 };
237 let Some(inner) = io_table.get::<Option<mlua::Function>>("output")? else {
238 return Ok(());
239 };
240 let state = Arc::clone(state);
241 let wrapper = lua.create_function(move |_, args: MultiValue| {
242 if !args.is_empty() {
243 let summary = first_string_arg(&args);
244 gate_decision(&state, "io.output", &summary)?;
245 }
246 inner.call::<MultiValue>(args)
247 })?;
248 io_table.set("output", wrapper)?;
249 Ok(())
250}