1use anyhow::{Context, Result};
12use extism::{CurrentPlugin, Function, PTR, UserData, Val};
13use parking_lot::Mutex;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use std::cell::RefCell;
17use std::collections::HashMap;
18use std::io::Read as _;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::time::{Duration, Instant};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ExtensionInfo {
28 pub name: String,
29 pub version: String,
30 #[serde(default)]
31 pub description: String,
32 #[serde(default)]
35 pub permissions: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct WasmToolDef {
41 pub name: String,
42 pub description: String,
43 pub schema: Value,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct WasmCommandDef {
49 pub name: String,
50 pub description: String,
51}
52
53#[derive(Debug)]
55pub struct LoadedWasmExtension {
56 pub info: ExtensionInfo,
57 pub tools: Vec<WasmToolDef>,
58 pub commands: Vec<WasmCommandDef>,
59 pub source_path: PathBuf,
60}
61
62fn host_oxicode_http_request(
69 plugin: &mut CurrentPlugin,
70 inputs: &[Val],
71 outputs: &mut [Val],
72 user_data: UserData<Arc<reqwest::blocking::Client>>,
73) -> Result<(), extism::Error> {
74 let result: anyhow::Result<()> = (|| {
76 let input_json: String = plugin.memory_get_val(&inputs[0])?;
77
78 #[derive(Deserialize)]
79 struct HttpReq {
80 url: String,
81 #[serde(default)]
82 method: String,
83 #[serde(default)]
84 headers: HashMap<String, String>,
85 #[serde(default)]
86 body: Option<String>,
87 }
88
89 let req: HttpReq = serde_json::from_str(&input_json)
90 .context("oxicode_http_request: invalid request JSON")?;
91
92 let method = if req.method.is_empty() {
93 "GET"
94 } else {
95 &req.method
96 };
97
98 if let Err(e) = validate_url(&req.url) {
100 anyhow::bail!("oxicode_http_request: {}", e);
101 }
102
103 let client_arc = user_data.get()?;
105 let client = client_arc.lock().expect("wasm client lock poisoned");
106
107 let method = match method.to_uppercase().as_str() {
108 "GET" => reqwest::Method::GET,
109 "POST" => reqwest::Method::POST,
110 "PUT" => reqwest::Method::PUT,
111 "DELETE" => reqwest::Method::DELETE,
112 "PATCH" => reqwest::Method::PATCH,
113 "HEAD" => reqwest::Method::HEAD,
114 other => anyhow::bail!("oxicode_http_request: unsupported method '{}'", other),
115 };
116
117 let mut rb = client.request(method, &req.url);
118 for (k, v) in &req.headers {
119 rb = rb.header(k, v);
120 }
121 if let Some(body) = &req.body {
122 rb = rb.body(body.clone());
123 }
124
125 let resp = rb
127 .send()
128 .map_err(|e| anyhow::anyhow!("HTTP request failed: {}", e))?;
129 let status = resp.status().as_u16();
130 let resp_headers: HashMap<String, String> = resp
131 .headers()
132 .iter()
133 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
134 .collect();
135 let resp_body = {
137 let max_body = 1024 * 1024; let body_bytes = resp
139 .bytes()
140 .map_err(|e| anyhow::anyhow!("Failed to read response: {}", e))?;
141 if body_bytes.len() > max_body {
142 tracing::warn!(
143 "HTTP response truncated: {} bytes > {} limit",
144 body_bytes.len(),
145 max_body
146 );
147 String::from_utf8_lossy(&body_bytes[..max_body]).to_string()
148 } else {
149 String::from_utf8_lossy(&body_bytes).to_string()
150 }
151 };
152
153 let response = serde_json::json!({
154 "status": status,
155 "headers": resp_headers,
156 "body": resp_body,
157 });
158
159 let output = serde_json::to_string(&response)?;
160 let handle = plugin.memory_new(&output)?;
161 if !outputs.is_empty() {
162 outputs[0] = plugin.memory_to_val(handle);
163 }
164 Ok(())
165 })();
166
167 result
168}
169
170fn host_oxicode_log(
172 plugin: &mut CurrentPlugin,
173 inputs: &[Val],
174 _outputs: &mut [Val],
175 _user_data: UserData<()>,
176) -> Result<(), extism::Error> {
177 let message: String = plugin.memory_get_val(&inputs[0])?;
178 tracing::debug!("[WASM] {}", message);
179 Ok(())
180}
181
182fn host_oxicode_read_file(
190 plugin: &mut CurrentPlugin,
191 inputs: &[Val],
192 outputs: &mut [Val],
193 _user_data: UserData<()>,
194) -> Result<(), extism::Error> {
195 let result: anyhow::Result<()> = (|| {
196 let input_json: String = plugin.memory_get_val(&inputs[0])?;
197
198 #[derive(Deserialize)]
199 struct ReadReq {
200 path: String,
201 #[serde(default)]
202 offset: Option<usize>,
203 #[serde(default = "default_limit")]
204 limit: usize,
205 }
206 fn default_limit() -> usize {
207 2000
208 }
209
210 let req: ReadReq =
211 serde_json::from_str(&input_json).context("oxicode_read_file: invalid request JSON")?;
212
213 validate_path_allowed(&req.path)?;
215
216 let metadata = std::fs::metadata(&req.path);
217 match metadata {
218 Ok(m) => {
219 let max_bytes = 50 * 1024; let file_size = m.len() as usize;
221
222 let content = std::fs::read_to_string(&req.path)
223 .map_err(|e| anyhow::anyhow!("Failed to read file: {}", e))?;
224
225 let lines: Vec<&str> = content.lines().collect();
226 let total_lines = lines.len();
227
228 let offset = req.offset.unwrap_or(0).min(total_lines);
229 let end = (offset + req.limit).min(total_lines);
230 let selected: Vec<&str> = lines[offset..end].to_vec();
231 let mut result = selected.join("\n");
232
233 let truncated = result.len() > max_bytes;
234 if truncated {
235 result = result.chars().take(max_bytes).collect();
236 }
237
238 let response = serde_json::json!({
239 "success": true,
240 "content": result,
241 "truncated": truncated || end < total_lines,
242 "bytes": file_size,
243 "total_lines": total_lines,
244 "shown_lines": end - offset,
245 });
246 let output = serde_json::to_string(&response)?;
247 let handle = plugin.memory_new(&output)?;
248 if !outputs.is_empty() {
249 outputs[0] = plugin.memory_to_val(handle);
250 }
251 }
252 Err(e) => {
253 let response = serde_json::json!({
254 "success": false,
255 "error": format!("File not found: {}", e),
256 });
257 let output = serde_json::to_string(&response)?;
258 let handle = plugin.memory_new(&output)?;
259 if !outputs.is_empty() {
260 outputs[0] = plugin.memory_to_val(handle);
261 }
262 }
263 }
264 Ok(())
265 })();
266 result
267}
268
269fn host_oxicode_write_file(
274 plugin: &mut CurrentPlugin,
275 inputs: &[Val],
276 outputs: &mut [Val],
277 _user_data: UserData<()>,
278) -> Result<(), extism::Error> {
279 let result: anyhow::Result<()> = (|| {
280 let input_json: String = plugin.memory_get_val(&inputs[0])?;
281
282 #[derive(Deserialize)]
283 struct WriteReq {
284 path: String,
285 content: String,
286 #[serde(default = "default_true")]
287 create_dirs: bool,
288 }
289 fn default_true() -> bool {
290 true
291 }
292
293 let req: WriteReq = serde_json::from_str(&input_json)
294 .context("oxicode_write_file: invalid request JSON")?;
295
296 validate_path_allowed(&req.path)?;
297
298 if req.create_dirs
299 && let Some(parent) = std::path::Path::new(&req.path).parent()
300 {
301 std::fs::create_dir_all(parent)
302 .map_err(|e| anyhow::anyhow!("Failed to create directories: {}", e))?;
303 }
304
305 let bytes = req.content.len();
306 std::fs::write(&req.path, &req.content)
307 .map_err(|e| anyhow::anyhow!("Failed to write file: {}", e))?;
308
309 let response = serde_json::json!({
310 "success": true,
311 "bytes_written": bytes,
312 });
313 let output = serde_json::to_string(&response)?;
314 let handle = plugin.memory_new(&output)?;
315 if !outputs.is_empty() {
316 outputs[0] = plugin.memory_to_val(handle);
317 }
318 Ok(())
319 })();
320 result
321}
322
323fn host_oxicode_exec(
328 plugin: &mut CurrentPlugin,
329 inputs: &[Val],
330 outputs: &mut [Val],
331 _user_data: UserData<()>,
332) -> Result<(), extism::Error> {
333 let result: anyhow::Result<()> = (|| {
334 if std::env::var("OXICODE_EXTENSION_EXEC").ok().as_deref() != Some("1") {
339 let response = serde_json::json!({
340 "success": false,
341 "error": "oxicode_exec is disabled; set OXICODE_EXTENSION_EXEC=1 to allow extensions to run commands",
342 "exit_code": -1,
343 });
344 let output = serde_json::to_string(&response)?;
345 let handle = plugin.memory_new(&output)?;
346 if !outputs.is_empty() {
347 outputs[0] = plugin.memory_to_val(handle);
348 }
349 return Ok(());
350 }
351 let input_json: String = plugin.memory_get_val(&inputs[0])?;
352
353 #[derive(Deserialize)]
354 struct ExecReq {
355 command: String,
356 #[serde(default)]
357 args: Vec<String>,
358 #[serde(default)]
359 cwd: Option<String>,
360 #[serde(default = "default_timeout")]
361 timeout: u64,
362 }
363 fn default_timeout() -> u64 {
364 30
365 }
366
367 let req: ExecReq =
368 serde_json::from_str(&input_json).context("oxicode_exec: invalid request JSON")?;
369
370 let cwd = req.cwd.as_deref().unwrap_or(".");
371
372 let full_cmd = if req.args.is_empty() {
374 req.command.clone()
375 } else {
376 format!("{} {}", req.command, req.args.join(" "))
377 };
378
379 let blocked_patterns = [
381 "rm -rf /",
382 "rm -rf /*",
383 "mkfs",
384 "dd if=",
385 "format ",
386 ":(){ :|:& };:",
387 "chmod 777 /",
388 "chown root",
389 "> /etc/",
390 "> /boot/",
391 "> /dev/",
392 "dd of=/dev/",
393 "mv / /",
394 ];
395 for blocked in &blocked_patterns {
396 if full_cmd.contains(blocked) {
397 anyhow::bail!("oxicode_exec: blocked dangerous command pattern");
398 }
399 }
400
401 let cmd_lower = req.command.to_lowercase();
403 if cmd_lower == "sudo"
404 || cmd_lower == "su"
405 || cmd_lower == "doas"
406 || cmd_lower.starts_with("sudo ")
407 || cmd_lower.starts_with("su ")
408 || cmd_lower.starts_with("doas ")
409 {
410 anyhow::bail!("oxicode_exec: privilege escalation commands are blocked");
411 }
412
413 let timeout_ms = req.timeout.clamp(1000, 30000);
415 let timeout_dur = Duration::from_millis(timeout_ms);
416
417 let mut child = match std::process::Command::new(&req.command)
419 .args(&req.args)
420 .current_dir(cwd)
421 .stdout(std::process::Stdio::piped())
422 .stderr(std::process::Stdio::piped())
423 .spawn()
424 {
425 Ok(c) => c,
426 Err(e) => {
427 let response = serde_json::json!({
428 "success": false,
429 "error": format!("Failed to execute: {}", e),
430 "exit_code": -1,
431 });
432 let out = serde_json::to_string(&response)?;
433 let handle = plugin.memory_new(&out)?;
434 if !outputs.is_empty() {
435 outputs[0] = plugin.memory_to_val(handle);
436 }
437 return Ok(());
438 }
439 };
440
441 let start = Instant::now();
443 let mut timed_out = false;
444 let mut exit_status: Option<std::process::ExitStatus> = None;
445
446 loop {
447 match child.try_wait() {
448 Ok(Some(status)) => {
449 exit_status = Some(status);
450 break;
451 }
452 Ok(None) => {
453 if start.elapsed() >= timeout_dur {
454 tracing::warn!(
456 "oxicode_exec: command '{}' timed out after {}ms",
457 req.command,
458 timeout_ms
459 );
460 let _ = child.kill();
461 let _ = child.wait(); timed_out = true;
463 break;
464 }
465 std::thread::sleep(Duration::from_millis(50));
466 }
467 Err(_) => {
468 match child.wait() {
470 Ok(status) => {
471 exit_status = Some(status);
472 }
473 Err(_) => {
474 timed_out = true;
475 }
476 }
477 break;
478 }
479 }
480 }
481
482 let mut stdout_buf = Vec::new();
484 let mut stderr_buf = Vec::new();
485 if let Some(mut out) = child.stdout.take() {
486 let _ = out.read_to_end(&mut stdout_buf);
487 }
488 if let Some(mut err) = child.stderr.take() {
489 let _ = err.read_to_end(&mut stderr_buf);
490 }
491
492 let stdout = String::from_utf8_lossy(&stdout_buf);
493 let stderr = String::from_utf8_lossy(&stderr_buf);
494 let max_output = 50 * 1024; let stdout_truncated = stdout.len() > max_output;
496 let stderr_truncated = stderr.len() > max_output;
497 let stdout_str: String = if stdout_truncated {
498 stdout.chars().take(max_output).collect()
499 } else {
500 stdout.to_string()
501 };
502 let stderr_str: String = if stderr_truncated {
503 stderr.chars().take(max_output).collect()
504 } else {
505 stderr.to_string()
506 };
507
508 let response = serde_json::json!({
509 "success": !timed_out && exit_status.map(|s| s.success()).unwrap_or(false),
510 "stdout": stdout_str,
511 "stderr": stderr_str,
512 "exit_code": if timed_out { -2 } else { exit_status.and_then(|s| s.code()).unwrap_or(-1) },
513 "stdout_truncated": stdout_truncated,
514 "stderr_truncated": stderr_truncated,
515 "timed_out": timed_out,
516 });
517 let out = serde_json::to_string(&response)?;
518 let handle = plugin.memory_new(&out)?;
519 if !outputs.is_empty() {
520 outputs[0] = plugin.memory_to_val(handle);
521 }
522 Ok(())
523 })();
524 result
525}
526
527fn host_oxicode_get_env(
532 plugin: &mut CurrentPlugin,
533 inputs: &[Val],
534 outputs: &mut [Val],
535 _user_data: UserData<()>,
536) -> Result<(), extism::Error> {
537 let result: anyhow::Result<()> = (|| {
538 let input_json: String = plugin.memory_get_val(&inputs[0])?;
539
540 #[derive(Deserialize)]
541 struct EnvReq {
542 key: String,
543 }
544
545 let req: EnvReq =
546 serde_json::from_str(&input_json).context("oxicode_get_env: invalid request JSON")?;
547
548 let blocked_keys = ["AWS_SECRET", "PRIVATE_KEY", "PASSWORD", "TOKEN", "SECRET"];
550 let key_upper = req.key.to_uppercase();
551 for blocked in &blocked_keys {
552 if key_upper.contains(blocked) {
553 anyhow::bail!("oxicode_get_env: access to '{}' is blocked", req.key);
554 }
555 }
556
557 let value = std::env::var(&req.key).ok();
558 let response = serde_json::json!({
559 "success": value.is_some(),
560 "value": value.unwrap_or_default(),
561 });
562 let output = serde_json::to_string(&response)?;
563 let handle = plugin.memory_new(&output)?;
564 if !outputs.is_empty() {
565 outputs[0] = plugin.memory_to_val(handle);
566 }
567 Ok(())
568 })();
569 result
570}
571
572fn host_oxicode_kv_get(
581 plugin: &mut CurrentPlugin,
582 inputs: &[Val],
583 outputs: &mut [Val],
584 _user_data: UserData<()>,
585) -> Result<(), extism::Error> {
586 let result: anyhow::Result<()> = (|| {
587 let input_json: String = plugin.memory_get_val(&inputs[0])?;
588
589 #[derive(Deserialize)]
590 struct KvReq {
591 key: String,
592 }
593
594 let req: KvReq =
595 serde_json::from_str(&input_json).context("oxicode_kv_get: invalid request JSON")?;
596
597 let ext_name = current_extension_name();
599 let value = kv_namespaced_get(&ext_name, &req.key);
600 let response = serde_json::json!({
601 "success": value.is_some(),
602 "value": value.unwrap_or_default(),
603 });
604 let output = serde_json::to_string(&response)?;
605 let handle = plugin.memory_new(&output)?;
606 if !outputs.is_empty() {
607 outputs[0] = plugin.memory_to_val(handle);
608 }
609 Ok(())
610 })();
611 result
612}
613
614fn host_oxicode_kv_set(
618 plugin: &mut CurrentPlugin,
619 inputs: &[Val],
620 _outputs: &mut [Val],
621 _user_data: UserData<()>,
622) -> Result<(), extism::Error> {
623 let result: anyhow::Result<()> = (|| {
624 let input_json: String = plugin.memory_get_val(&inputs[0])?;
625
626 #[derive(Deserialize)]
627 struct KvSetReq {
628 key: String,
629 value: String,
630 }
631
632 let req: KvSetReq =
633 serde_json::from_str(&input_json).context("oxicode_kv_set: invalid request JSON")?;
634
635 let ext_name = current_extension_name();
637 kv_namespaced_set(&ext_name, &req.key, &req.value);
638 Ok(())
639 })();
640 result
641}
642
643use std::sync::LazyLock;
646
647static KV_STORE: LazyLock<parking_lot::RwLock<HashMap<String, String>>> =
648 LazyLock::new(|| parking_lot::RwLock::new(HashMap::new()));
649
650thread_local! {
654 static CURRENT_EXTENSION: RefCell<Option<String>> = const { RefCell::new(None) };
655}
656
657#[allow(dead_code)]
659fn with_extension_context<F, R>(ext_name: &str, f: F) -> R
660where
661 F: FnOnce() -> R,
662{
663 CURRENT_EXTENSION.with(|cell| *cell.borrow_mut() = Some(ext_name.to_string()));
664 let result = f();
665 CURRENT_EXTENSION.with(|cell| *cell.borrow_mut() = None);
666 result
667}
668
669fn current_extension_name() -> String {
672 CURRENT_EXTENSION.with(|cell| {
673 cell.borrow()
674 .clone()
675 .unwrap_or_else(|| "__unknown__".to_string())
676 })
677}
678
679fn kv_store_get(key: &str) -> Option<String> {
680 KV_STORE.read().get(key).cloned()
681}
682
683fn kv_store_set(key: &str, value: &str) {
684 KV_STORE.write().insert(key.to_string(), value.to_string());
685}
686
687fn kv_namespaced_get(extension: &str, key: &str) -> Option<String> {
690 let namespaced = format!("{}:{}", extension, key);
691 kv_store_get(&namespaced)
692}
693
694fn kv_namespaced_set(extension: &str, key: &str, value: &str) {
695 let namespaced = format!("{}:{}", extension, key);
696 kv_store_set(&namespaced, value);
697}
698
699fn validate_path_allowed(path: &str) -> Result<()> {
704 let p = std::path::Path::new(path);
705
706 let abs = if p.is_absolute() {
708 p.to_path_buf()
709 } else {
710 std::env::current_dir().unwrap_or_default().join(p)
711 };
712
713 let resolved = if abs.exists() {
715 abs.canonicalize().unwrap_or(abs)
716 } else {
717 if let Some(parent) = abs.parent() {
719 if parent.exists() {
720 let canon_parent = parent
721 .canonicalize()
722 .unwrap_or_else(|_| parent.to_path_buf());
723 canon_parent.join(abs.file_name().unwrap_or_default())
724 } else {
725 abs
726 }
727 } else {
728 abs
729 }
730 };
731
732 let abs_str = resolved.to_string_lossy();
733
734 let blocked_prefixes = [
736 "/etc",
737 "/sys",
738 "/proc",
739 "/dev",
740 "/boot",
741 "/root",
742 "/System",
743 "/Library/System",
744 "/usr/bin",
745 "/usr/sbin",
746 "/bin",
747 "/sbin",
748 ];
749 for prefix in &blocked_prefixes {
750 if abs_str.starts_with(prefix) {
751 anyhow::bail!("Path '{}' is in a protected system directory", path);
752 }
753 }
754
755 if let Some(home) = dirs::home_dir() {
757 let home_str = home.to_string_lossy();
758 if abs_str.starts_with(&*home_str) {
759 let blocked_home_suffixes = [
760 "/.ssh/",
761 "/.gnupg/",
762 "/.aws/",
763 "/.config/gcloud/",
764 "/.kube/",
765 "/.docker/",
766 "/.npmrc",
767 "/.netrc",
768 ];
769 for suffix in &blocked_home_suffixes {
770 if abs_str.contains(suffix) {
771 anyhow::bail!("Path '{}' is in a protected directory", path);
772 }
773 }
774 }
775 }
776
777 Ok(())
778}
779
780fn validate_url(url: &str) -> Result<(), String> {
784 let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
785 let host = parsed.host_str().unwrap_or("").to_lowercase();
786
787 let blocked = [
789 "localhost",
790 "127.0.0.1",
791 "0.0.0.0",
792 "::1",
793 "[::1]",
794 "169.254.169.254", "metadata.google.internal",
796 ];
797 for &b in &blocked {
798 if host == b || host.starts_with(b) {
799 return Err(format!("Blocked internal address: {}", host));
800 }
801 }
802
803 if host.starts_with("10.") || host.starts_with("192.168.") || is_172_private(&host) {
805 return Err(format!("Blocked private address: {}", host));
806 }
807
808 Ok(())
809}
810
811fn is_172_private(host: &str) -> bool {
813 if !host.starts_with("172.") {
814 return false;
815 }
816 let parts: Vec<&str> = host.split('.').collect();
817 if parts.len() < 2 {
818 return false;
819 }
820 if let Ok(second) = parts[1].parse::<u8>() {
821 (16..=31).contains(&second)
822 } else {
823 false
824 }
825}
826
827pub struct WasmExtensionManager {
836 extensions: HashMap<String, LoadedWasmExtension>,
837 pub(crate) plugins: Arc<parking_lot::Mutex<HashMap<String, extism::Plugin>>>,
842 tool_to_ext: HashMap<String, String>,
844 http_client: Arc<reqwest::blocking::Client>,
846 #[allow(dead_code, unused)]
848 permissions: HashMap<String, std::collections::HashSet<String>>,
849}
850
851impl Default for WasmExtensionManager {
852 fn default() -> Self {
853 Self::new()
854 }
855}
856
857impl WasmExtensionManager {
858 pub fn new() -> Self {
860 Self {
861 extensions: HashMap::new(),
862 plugins: Arc::new(Mutex::new(HashMap::new())),
863 tool_to_ext: HashMap::new(),
864 http_client: Arc::new(
865 reqwest::blocking::Client::builder()
866 .timeout(std::time::Duration::from_secs(30))
867 .connect_timeout(std::time::Duration::from_secs(10))
868 .no_proxy() .build()
870 .expect("Failed to build HTTP client"),
871 ),
872 permissions: HashMap::new(),
873 }
874 }
875
876 pub fn with_http_client(client: reqwest::blocking::Client) -> Self {
878 Self {
879 extensions: HashMap::new(),
880 plugins: Arc::new(Mutex::new(HashMap::new())),
881 tool_to_ext: HashMap::new(),
882 http_client: Arc::new(client),
883 permissions: HashMap::new(),
884 }
885 }
886
887 pub fn discover(cwd: &Path) -> Vec<PathBuf> {
892 let mut paths = Vec::new();
893
894 if let Some(dir) = oxicode_catalog::oxi_home::read_path(Path::new("extensions"))
896 && dir.is_dir()
897 {
898 Self::discover_in_dir(&dir, &mut paths);
899 }
900
901 let local_dir = cwd.join(".oxicode").join("extensions");
903 if local_dir.is_dir() {
904 Self::discover_in_dir(&local_dir, &mut paths);
905 }
906
907 paths.sort();
908 paths.dedup();
909 paths
910 }
911
912 fn discover_in_dir(dir: &Path, out: &mut Vec<PathBuf>) {
913 let Ok(entries) = std::fs::read_dir(dir) else {
914 return;
915 };
916 for entry in entries.flatten() {
917 let path = entry.path();
918 if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("wasm") {
919 out.push(path);
920 }
921 }
922 }
923
924 fn host_functions(http_client: &Arc<reqwest::blocking::Client>) -> Vec<Function> {
928 let http_fn = Function::new(
929 "oxicode_http_request",
930 [PTR],
931 [PTR],
932 UserData::new(http_client.clone()),
933 host_oxicode_http_request,
934 );
935
936 let log_fn = Function::new(
937 "oxicode_log",
938 [PTR],
939 [],
940 UserData::new(()),
941 host_oxicode_log,
942 );
943
944 let read_fn = Function::new(
945 "oxicode_read_file",
946 [PTR],
947 [PTR],
948 UserData::new(()),
949 host_oxicode_read_file,
950 );
951
952 let write_fn = Function::new(
953 "oxicode_write_file",
954 [PTR],
955 [PTR],
956 UserData::new(()),
957 host_oxicode_write_file,
958 );
959
960 let exec_fn = Function::new(
961 "oxicode_exec",
962 [PTR],
963 [PTR],
964 UserData::new(()),
965 host_oxicode_exec,
966 );
967
968 let get_env_fn = Function::new(
969 "oxicode_get_env",
970 [PTR],
971 [PTR],
972 UserData::new(()),
973 host_oxicode_get_env,
974 );
975
976 let kv_get_fn = Function::new(
977 "oxicode_kv_get",
978 [PTR],
979 [PTR],
980 UserData::new(()),
981 host_oxicode_kv_get,
982 );
983
984 let kv_set_fn = Function::new(
985 "oxicode_kv_set",
986 [PTR],
987 [],
988 UserData::new(()),
989 host_oxicode_kv_set,
990 );
991
992 vec![
993 http_fn, log_fn, read_fn, write_fn, exec_fn, get_env_fn, kv_get_fn, kv_set_fn,
994 ]
995 }
996
997 pub fn load(&mut self, path: &Path) -> Result<ExtensionInfo> {
999 let path_display = path.display().to_string();
1000 tracing::info!("Loading WASM extension: {}", path_display);
1001
1002 let wasm_bytes = std::fs::read(path)
1003 .with_context(|| format!("Failed to read extension: {}", path_display))?;
1004
1005 let wasm = extism::Wasm::data(wasm_bytes);
1006 let manifest = extism::Manifest::new([wasm]).with_memory_max(64);
1008 let mut plugin = extism::PluginBuilder::new(manifest)
1009 .with_wasi(true)
1010 .with_functions(Self::host_functions(&self.http_client))
1011 .build()
1012 .with_context(|| format!("Failed to create Extism plugin from {}", path_display))?;
1013
1014 let info: ExtensionInfo = match plugin.call::<&str, &str>("init", "{}") {
1016 Ok(output) => serde_json::from_str(output)
1017 .with_context(|| format!("init() returned invalid JSON: {}", output))?,
1018 Err(_) => {
1019 let name = path
1021 .file_stem()
1022 .and_then(|s| s.to_str())
1023 .unwrap_or("unknown")
1024 .to_string();
1025 ExtensionInfo {
1026 name,
1027 version: "0.0.0".to_string(),
1028 description: String::new(),
1029 permissions: vec![],
1030 }
1031 }
1032 };
1033 if !info.permissions.is_empty() {
1036 tracing::info!(name = %info.name, perms = ?info.permissions, "extension permissions requested");
1037 }
1038 if info.permissions.iter().any(|p| p == "exec") {
1039 tracing::warn!(
1040 name = %info.name,
1041 "extension requested 'exec' permission — oxicode_exec stays disabled unless OXICODE_EXTENSION_EXEC=1 is set"
1042 );
1043 }
1044
1045 let ext_name_for_ctx = info.name.clone();
1048 CURRENT_EXTENSION.with(|cell| *cell.borrow_mut() = Some(ext_name_for_ctx));
1049 let tools: Vec<WasmToolDef> = match plugin.call::<&str, &str>("register_tools", "{}") {
1050 Ok(output) => {
1051 let resp: Value = serde_json::from_str(output)
1052 .with_context(|| format!("register_tools() invalid JSON: {}", output))?;
1053 resp.get("tools")
1054 .cloned()
1055 .unwrap_or(Value::Array(vec![]))
1056 .as_array()
1057 .map(|arr| {
1058 arr.iter()
1059 .filter_map(|v| serde_json::from_value(v.clone()).ok())
1060 .collect()
1061 })
1062 .unwrap_or_default()
1063 }
1064 Err(_) => vec![], };
1066
1067 let commands: Vec<WasmCommandDef> =
1069 match plugin.call::<&str, &str>("register_commands", "{}") {
1070 Ok(output) => {
1071 let resp: Value = serde_json::from_str(output)
1072 .with_context(|| format!("register_commands() invalid JSON: {}", output))?;
1073 resp.get("commands")
1074 .cloned()
1075 .unwrap_or(Value::Array(vec![]))
1076 .as_array()
1077 .map(|arr| {
1078 arr.iter()
1079 .filter_map(|v| serde_json::from_value(v.clone()).ok())
1080 .collect()
1081 })
1082 .unwrap_or_default()
1083 }
1084 Err(_) => vec![], };
1086
1087 CURRENT_EXTENSION.with(|cell| *cell.borrow_mut() = None);
1089
1090 let ext_name = info.name.clone();
1091
1092 if self.extensions.contains_key(&ext_name) {
1094 tracing::warn!(
1095 "Extension '{}' already loaded, replacing with '{}'",
1096 ext_name,
1097 path_display
1098 );
1099 self.tool_to_ext.retain(|_, v| v != &ext_name);
1101 self.plugins.lock().remove(&ext_name);
1103 }
1104
1105 for tool in &tools {
1106 self.tool_to_ext.insert(tool.name.clone(), ext_name.clone());
1107 }
1108
1109 let loaded = LoadedWasmExtension {
1110 info: info.clone(),
1111 tools,
1112 commands,
1113 source_path: path.to_path_buf(),
1114 };
1115
1116 self.extensions.insert(ext_name.clone(), loaded);
1117 self.plugins.lock().insert(ext_name, plugin);
1118
1119 tracing::info!(
1120 name = %info.name,
1121 version = %info.version,
1122 tools = self.tool_to_ext.len(),
1123 "WASM extension loaded"
1124 );
1125
1126 Ok(info)
1127 }
1128
1129 pub fn load_all(&mut self, paths: &[PathBuf]) -> (Vec<ExtensionInfo>, Vec<anyhow::Error>) {
1131 let mut loaded = Vec::new();
1132 let mut errors = Vec::new();
1133
1134 for path in paths {
1135 match self.load(path) {
1136 Ok(info) => loaded.push(info),
1137 Err(e) => {
1138 tracing::warn!("Failed to load extension '{}': {}", path.display(), e);
1139 errors.push(e);
1140 }
1141 }
1142 }
1143
1144 (loaded, errors)
1145 }
1146
1147 pub fn execute_tool(&self, tool_name: &str, params: Value) -> Result<Value> {
1151 let ext_name = self
1152 .tool_to_ext
1153 .get(tool_name)
1154 .with_context(|| format!("No extension registered for tool: {}", tool_name))?
1155 .clone();
1156
1157 let mut plugins = self.plugins.lock();
1158 let plugin = plugins
1159 .get_mut(&ext_name)
1160 .with_context(|| format!("Extension '{}' not loaded", ext_name))?;
1161
1162 let input = serde_json::json!({
1163 "tool": tool_name,
1164 "params": params,
1165 });
1166 let input_str = serde_json::to_string(&input)?;
1167
1168 CURRENT_EXTENSION.with(|cell| *cell.borrow_mut() = Some(ext_name.clone()));
1170 let call_result = plugin.call("execute_tool", &input_str);
1171 CURRENT_EXTENSION.with(|cell| *cell.borrow_mut() = None);
1172
1173 let output: &str = call_result
1174 .with_context(|| format!("execute_tool('{}') failed in '{}'", tool_name, ext_name))?;
1175
1176 let result: Value = serde_json::from_str(output)
1177 .with_context(|| format!("execute_tool() returned invalid JSON: {}", output))?;
1178
1179 Ok(result)
1180 }
1181
1182 pub fn all_tool_defs(&self) -> Vec<&WasmToolDef> {
1186 self.extensions
1187 .values()
1188 .flat_map(|e| e.tools.iter())
1189 .collect()
1190 }
1191
1192 pub fn is_wasm_tool(&self, tool_name: &str) -> bool {
1194 self.tool_to_ext.contains_key(tool_name)
1195 }
1196
1197 pub fn extension_names(&self) -> impl Iterator<Item = &str> {
1199 self.extensions.keys().map(|s| s.as_str())
1200 }
1201
1202 pub fn get_info(&self, name: &str) -> Option<&ExtensionInfo> {
1204 self.extensions.get(name).map(|e| &e.info)
1205 }
1206
1207 pub fn len(&self) -> usize {
1209 self.extensions.len()
1210 }
1211
1212 pub fn is_empty(&self) -> bool {
1214 self.extensions.is_empty()
1215 }
1216
1217 pub fn all_command_defs(&self) -> Vec<(&str, &WasmCommandDef)> {
1221 let mut cmds = Vec::new();
1222 for ext in self.extensions.values() {
1223 for cmd in &ext.commands {
1224 cmds.push((ext.info.name.as_str(), cmd));
1225 }
1226 }
1227 cmds
1228 }
1229
1230 pub fn execute_command(&self, command_name: &str, args: &str) -> Result<String> {
1233 let ext_name = self
1235 .extensions
1236 .iter()
1237 .find(|(_, ext)| ext.commands.iter().any(|c| c.name == command_name))
1238 .map(|(name, _)| name.clone())
1239 .with_context(|| format!("No extension registered for command: /{}", command_name))?;
1240
1241 let mut plugins = self.plugins.lock();
1242 let plugin = plugins
1243 .get_mut(&ext_name)
1244 .with_context(|| format!("Extension '{}' not loaded", ext_name))?;
1245
1246 let input = serde_json::json!({
1247 "command": command_name,
1248 "args": args,
1249 });
1250 let input_str = serde_json::to_string(&input)?;
1251
1252 let output: &str = {
1253 CURRENT_EXTENSION.with(|cell| *cell.borrow_mut() = Some(ext_name.clone()));
1254 let result = plugin.call("execute_command", &input_str);
1255 CURRENT_EXTENSION.with(|cell| *cell.borrow_mut() = None);
1256 result
1257 }
1258 .with_context(|| {
1259 format!(
1260 "execute_command('/{}') failed in '{}'",
1261 command_name, ext_name
1262 )
1263 })?;
1264
1265 let result: Value =
1267 serde_json::from_str(output).unwrap_or_else(|_| serde_json::json!({"output": output}));
1268
1269 Ok(result
1270 .get("output")
1271 .and_then(|v| v.as_str())
1272 .unwrap_or(output)
1273 .to_string())
1274 }
1275}
1276
1277#[cfg(test)]
1280mod tests {
1281 use super::*;
1282
1283 #[test]
1284 fn test_discover_empty_dir() {
1285 let dir = tempfile::tempdir().unwrap();
1286 let paths = WasmExtensionManager::discover(dir.path());
1287 assert!(paths.is_empty());
1288 }
1289
1290 #[test]
1291 fn test_discover_finds_wasm_files() {
1292 let dir = tempfile::tempdir().unwrap();
1293 let wasm_path = dir.path().join("test_ext.wasm");
1294 std::fs::write(&wasm_path, b"\x00asm").unwrap();
1295 std::fs::write(dir.path().join("readme.txt"), b"hello").unwrap();
1297
1298 let mut paths = Vec::new();
1299 WasmExtensionManager::discover_in_dir(dir.path(), &mut paths);
1300 assert_eq!(paths.len(), 1);
1301 assert!(paths[0].ends_with("test_ext.wasm"));
1302 }
1303
1304 #[test]
1305 fn test_extension_info_parse() {
1306 let json = r#"{"name":"my_ext","version":"1.0.0","description":"Test"}"#;
1307 let info: ExtensionInfo = serde_json::from_str(json).unwrap();
1308 assert_eq!(info.name, "my_ext");
1309 assert_eq!(info.version, "1.0.0");
1310 }
1311
1312 #[test]
1313 fn test_tool_def_parse() {
1314 let json = r#"{"name":"search","description":"Search","schema":{"type":"object"}}"#;
1315 let tool: WasmToolDef = serde_json::from_str(json).unwrap();
1316 assert_eq!(tool.name, "search");
1317 }
1318
1319 #[test]
1320 fn test_manager_new_is_empty() {
1321 let mgr = WasmExtensionManager::new();
1322 assert!(mgr.is_empty());
1323 assert_eq!(mgr.len(), 0);
1324 }
1325
1326 #[test]
1327 fn test_is_wasm_tool_false() {
1328 let mgr = WasmExtensionManager::new();
1329 assert!(!mgr.is_wasm_tool("anything"));
1330 }
1331
1332 #[test]
1333 fn test_extension_info_default_description() {
1334 let json = r#"{"name":"test","version":"0.1"}"#;
1335 let info: ExtensionInfo = serde_json::from_str(json).unwrap();
1336 assert_eq!(info.description, "");
1337 }
1338
1339 #[test]
1340 fn test_ssrf_blocks_localhost() {
1341 assert!(validate_url("http://localhost/admin").is_err());
1342 assert!(validate_url("http://127.0.0.1/secret").is_err());
1343 assert!(validate_url("http://10.0.0.1/internal").is_err());
1344 assert!(validate_url("http://192.168.1.1/router").is_err());
1345 assert!(validate_url("http://172.16.0.1/corp").is_err());
1346 assert!(validate_url("http://169.254.169.254/metadata").is_err());
1347 assert!(validate_url("http://[::1]/ipv6").is_err());
1348 assert!(validate_url("http://0.0.0.0/admin").is_err());
1350 }
1351
1352 #[test]
1353 fn test_ssrf_allows_public() {
1354 assert!(validate_url("https://api.github.com/repos/test").is_ok());
1355 assert!(validate_url("https://example.com/api").is_ok());
1356 assert!(validate_url("https://search.brave.com/api/search?q=test").is_ok());
1357 }
1358
1359 #[test]
1360 fn test_ssrf_172_range() {
1361 assert!(validate_url("http://172.16.0.1/test").is_err());
1362 assert!(validate_url("http://172.31.255.255/test").is_err());
1363 assert!(validate_url("http://172.15.0.1/test").is_ok());
1364 assert!(validate_url("http://172.32.0.1/test").is_ok());
1365 }
1366}