1use anyhow::{Context, Result};
9use async_trait::async_trait;
10use rquickjs::{Context as JsContext, Function, Object, Runtime, Value};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::sync::{Arc, RwLock};
15use tracing::{debug, error, info, warn};
16
17use sentinel_agent_protocol::{
18 AgentHandler, AgentResponse, AuditMetadata, ConfigureEvent, HeaderOp, RequestHeadersEvent,
19 ResponseHeadersEvent,
20};
21
22#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24#[serde(rename_all = "kebab-case")]
25pub struct JsConfigJson {
26 pub script: Option<String>,
28 #[serde(default)]
30 pub fail_open: bool,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct ScriptResult {
36 pub decision: String,
38 pub status: Option<u16>,
40 pub body: Option<String>,
42 pub add_request_headers: Option<HashMap<String, String>>,
44 pub remove_request_headers: Option<Vec<String>>,
46 pub add_response_headers: Option<HashMap<String, String>>,
48 pub remove_response_headers: Option<Vec<String>>,
50 pub tags: Option<Vec<String>>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct JsRequest {
57 pub method: String,
58 pub uri: String,
59 pub client_ip: String,
60 pub correlation_id: String,
61 pub headers: HashMap<String, String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct JsResponse {
67 pub status: u16,
68 pub correlation_id: String,
69 pub headers: HashMap<String, String>,
70}
71
72pub struct JsAgent {
74 runtime: Arc<RwLock<Runtime>>,
76 script_content: RwLock<String>,
78 fail_open: RwLock<bool>,
80}
81
82unsafe impl Send for JsAgent {}
84unsafe impl Sync for JsAgent {}
85
86impl JsAgent {
87 pub fn new(script_path: PathBuf, fail_open: bool) -> Result<Self> {
89 let script_content = std::fs::read_to_string(&script_path)
90 .with_context(|| format!("Failed to read script file: {:?}", script_path))?;
91
92 Self::from_source(script_content, fail_open)
93 }
94
95 pub fn from_source(script_content: String, fail_open: bool) -> Result<Self> {
97 let runtime = Runtime::new().context("Failed to create JavaScript runtime")?;
98
99 info!("JavaScript agent initialized");
100
101 Ok(Self {
102 runtime: Arc::new(RwLock::new(runtime)),
103 script_content: RwLock::new(script_content),
104 fail_open: RwLock::new(fail_open),
105 })
106 }
107
108 pub fn reconfigure(&self, config: JsConfigJson) -> Result<()> {
112 if let Some(script) = config.script {
113 let mut script_content = self
114 .script_content
115 .write()
116 .map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
117 *script_content = script;
118 info!("JavaScript agent script reconfigured");
119 }
120
121 {
122 let mut fail_open = self
123 .fail_open
124 .write()
125 .map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
126 *fail_open = config.fail_open;
127 }
128
129 Ok(())
130 }
131
132 fn json_to_js<'js>(
134 ctx: &rquickjs::Ctx<'js>,
135 value: &serde_json::Value,
136 ) -> rquickjs::Result<Value<'js>> {
137 match value {
138 serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
139 serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
140 serde_json::Value::Number(n) => {
141 if let Some(i) = n.as_i64() {
142 Ok(Value::new_int(ctx.clone(), i as i32))
143 } else if let Some(f) = n.as_f64() {
144 Ok(Value::new_float(ctx.clone(), f))
145 } else {
146 Ok(Value::new_int(ctx.clone(), 0))
147 }
148 }
149 serde_json::Value::String(s) => {
150 rquickjs::String::from_str(ctx.clone(), s).map(|s| s.into())
151 }
152 serde_json::Value::Array(arr) => {
153 let js_array = rquickjs::Array::new(ctx.clone())?;
154 for (i, item) in arr.iter().enumerate() {
155 let js_item = Self::json_to_js(ctx, item)?;
156 js_array.set(i, js_item)?;
157 }
158 Ok(js_array.into())
159 }
160 serde_json::Value::Object(obj) => {
161 let js_obj = Object::new(ctx.clone())?;
162 for (key, val) in obj {
163 let js_val = Self::json_to_js(ctx, val)?;
164 js_obj.set(key.as_str(), js_val)?;
165 }
166 Ok(js_obj.into())
167 }
168 }
169 }
170
171 fn js_to_json(value: &Value) -> serde_json::Value {
173 if value.is_null() || value.is_undefined() {
174 serde_json::Value::Null
175 } else if let Some(b) = value.as_bool() {
176 serde_json::Value::Bool(b)
177 } else if let Some(i) = value.as_int() {
178 serde_json::json!(i)
179 } else if let Some(f) = value.as_float() {
180 serde_json::json!(f)
181 } else if let Some(s) = value.clone().into_string() {
182 if let Ok(rust_str) = s.to_string() {
183 serde_json::Value::String(rust_str)
184 } else {
185 serde_json::Value::Null
186 }
187 } else if let Some(arr) = value.clone().into_array() {
188 let mut vec = Vec::new();
189 for i in 0..arr.len() {
190 if let Ok(item) = arr.get::<Value>(i) {
191 vec.push(Self::js_to_json(&item));
192 }
193 }
194 serde_json::Value::Array(vec)
195 } else if let Some(obj) = value.clone().into_object() {
196 let mut map = serde_json::Map::new();
197 for key in obj.keys::<String>().flatten() {
198 if let Ok(val) = obj.get::<_, Value>(&key) {
199 map.insert(key, Self::js_to_json(&val));
200 }
201 }
202 serde_json::Value::Object(map)
203 } else {
204 serde_json::Value::Null
205 }
206 }
207
208 pub fn call_function(
210 &self,
211 fn_name: &str,
212 arg: serde_json::Value,
213 ) -> Result<Option<ScriptResult>> {
214 let runtime = self
215 .runtime
216 .read()
217 .map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
218
219 let script_content = self
220 .script_content
221 .read()
222 .map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
223
224 let ctx = JsContext::full(&runtime).context("Failed to create JS context")?;
225
226 ctx.with(|ctx| {
227 let console = Object::new(ctx.clone())?;
229
230 let log_fn = Function::new(ctx.clone(), |args: rquickjs::function::Rest<Value>| {
231 let msg: Vec<String> = args.iter().map(|v| format!("{:?}", v)).collect();
232 info!(target: "js_console", "{}", msg.join(" "));
233 })?;
234 console.set("log", log_fn)?;
235
236 let warn_fn = Function::new(ctx.clone(), |args: rquickjs::function::Rest<Value>| {
237 let msg: Vec<String> = args.iter().map(|v| format!("{:?}", v)).collect();
238 warn!(target: "js_console", "{}", msg.join(" "));
239 })?;
240 console.set("warn", warn_fn)?;
241
242 let error_fn = Function::new(ctx.clone(), |args: rquickjs::function::Rest<Value>| {
243 let msg: Vec<String> = args.iter().map(|v| format!("{:?}", v)).collect();
244 error!(target: "js_console", "{}", msg.join(" "));
245 })?;
246 console.set("error", error_fn)?;
247
248 let globals = ctx.globals();
249 globals.set("console", console)?;
250
251 ctx.eval::<(), _>(script_content.as_str())?;
253
254 let func: Option<Function> = globals.get(fn_name).ok();
256
257 let Some(func) = func else {
258 debug!(function = fn_name, "Function not defined in script");
259 return Ok(None);
260 };
261
262 let js_arg = Self::json_to_js(&ctx, &arg)?;
264
265 let result: Value = func.call((js_arg,))?;
267
268 let json_result = Self::js_to_json(&result);
270
271 if json_result.is_null() {
272 return Ok(Some(ScriptResult {
273 decision: "allow".to_string(),
274 ..Default::default()
275 }));
276 }
277
278 let script_result: ScriptResult =
279 serde_json::from_value(json_result).map_err(|e| rquickjs::Error::FromJs {
280 from: "object",
281 to: "ScriptResult",
282 message: Some(format!("Failed to parse result: {}", e)),
283 })?;
284
285 Ok(Some(script_result))
286 })
287 .map_err(|e: rquickjs::Error| anyhow::anyhow!("JavaScript error: {}", e))
288 }
289
290 pub fn build_response(result: ScriptResult) -> AgentResponse {
292 let decision = result.decision.to_lowercase();
293
294 let mut response = match decision.as_str() {
295 "block" | "deny" => {
296 let status = result.status.unwrap_or(403);
297 AgentResponse::block(status, result.body)
298 }
299 "redirect" => {
300 let status = result.status.unwrap_or(302);
301 let mut resp = AgentResponse::block(status, None);
302 if let Some(url) = result.body {
303 resp = resp.add_response_header(HeaderOp::Set {
304 name: "Location".to_string(),
305 value: url,
306 });
307 }
308 resp
309 }
310 _ => AgentResponse::default_allow(),
311 };
312
313 if let Some(headers) = result.add_request_headers {
315 for (name, value) in headers {
316 response = response.add_request_header(HeaderOp::Set { name, value });
317 }
318 }
319
320 if let Some(headers) = result.remove_request_headers {
322 for name in headers {
323 response = response.add_request_header(HeaderOp::Remove { name });
324 }
325 }
326
327 if let Some(headers) = result.add_response_headers {
329 for (name, value) in headers {
330 response = response.add_response_header(HeaderOp::Set { name, value });
331 }
332 }
333
334 if let Some(headers) = result.remove_response_headers {
336 for name in headers {
337 response = response.add_response_header(HeaderOp::Remove { name });
338 }
339 }
340
341 if let Some(tags) = result.tags {
343 response = response.with_audit(AuditMetadata {
344 tags,
345 ..Default::default()
346 });
347 }
348
349 response
350 }
351
352 fn handle_error(&self, error: anyhow::Error, correlation_id: &str) -> AgentResponse {
354 error!(
355 correlation_id = correlation_id,
356 error = %error,
357 "Script execution failed"
358 );
359
360 let fail_open = self.fail_open.read().map(|f| *f).unwrap_or(false);
361
362 if fail_open {
363 AgentResponse::default_allow().with_audit(AuditMetadata {
364 tags: vec!["js-error".to_string(), "fail-open".to_string()],
365 reason_codes: vec![error.to_string()],
366 ..Default::default()
367 })
368 } else {
369 AgentResponse::block(500, Some("Script Error".to_string())).with_audit(AuditMetadata {
370 tags: vec!["js-error".to_string()],
371 reason_codes: vec![error.to_string()],
372 ..Default::default()
373 })
374 }
375 }
376}
377
378#[async_trait]
379impl AgentHandler for JsAgent {
380 async fn on_configure(&self, event: ConfigureEvent) -> AgentResponse {
381 info!(agent_id = %event.agent_id, "Received configuration event");
382
383 let config: JsConfigJson = match serde_json::from_value(event.config) {
385 Ok(c) => c,
386 Err(e) => {
387 error!(error = %e, "Failed to parse agent configuration");
388 return AgentResponse::block(
389 500,
390 Some(format!("Invalid configuration: {}", e)),
391 );
392 }
393 };
394
395 if let Err(e) = self.reconfigure(config) {
397 error!(error = %e, "Failed to apply configuration");
398 return AgentResponse::block(
399 500,
400 Some(format!("Configuration error: {}", e)),
401 );
402 }
403
404 info!("JavaScript agent configured successfully");
405 AgentResponse::default_allow()
406 }
407
408 async fn on_request_headers(&self, event: RequestHeadersEvent) -> AgentResponse {
409 let correlation_id = event.metadata.correlation_id.clone();
410
411 let mut headers: HashMap<String, String> = HashMap::new();
413 for (name, values) in &event.headers {
414 headers.insert(name.clone(), values.join(", "));
415 }
416
417 let request = JsRequest {
418 method: event.method.clone(),
419 uri: event.uri.clone(),
420 client_ip: event.metadata.client_ip.clone(),
421 correlation_id: correlation_id.clone(),
422 headers,
423 };
424
425 let request_json = match serde_json::to_value(&request) {
426 Ok(v) => v,
427 Err(e) => return self.handle_error(e.into(), &correlation_id),
428 };
429
430 let result = self.call_function("on_request_headers", request_json);
432
433 match result {
434 Ok(Some(script_result)) => {
435 debug!(
436 correlation_id = correlation_id,
437 decision = script_result.decision,
438 "Script returned result"
439 );
440 Self::build_response(script_result)
441 }
442 Ok(None) => {
443 AgentResponse::default_allow()
445 }
446 Err(e) => self.handle_error(e, &correlation_id),
447 }
448 }
449
450 async fn on_response_headers(&self, event: ResponseHeadersEvent) -> AgentResponse {
451 let correlation_id = event.correlation_id.clone();
452
453 let mut headers: HashMap<String, String> = HashMap::new();
455 for (name, values) in &event.headers {
456 headers.insert(name.clone(), values.join(", "));
457 }
458
459 let response = JsResponse {
460 status: event.status,
461 correlation_id: correlation_id.clone(),
462 headers,
463 };
464
465 let response_json = match serde_json::to_value(&response) {
466 Ok(v) => v,
467 Err(e) => return self.handle_error(e.into(), &correlation_id),
468 };
469
470 let result = self.call_function("on_response_headers", response_json);
472
473 match result {
474 Ok(Some(script_result)) => {
475 debug!(
476 correlation_id = correlation_id,
477 decision = script_result.decision,
478 "Script returned result"
479 );
480 Self::build_response(script_result)
481 }
482 Ok(None) => AgentResponse::default_allow(),
483 Err(e) => self.handle_error(e, &correlation_id),
484 }
485 }
486}