Skip to main content

progit_plugin_sdk/lua/
mod.rs

1// SPDX-License-Identifier: LSL-1.0
2// Copyright (c) 2025 Markus Maiwald
3
4//! Sandboxed LuaJIT plugin runtime.
5//!
6//! [SEC] Doctrine 4 ("Plugins are sandboxed") is enforced here, not just
7//! aspirationally. The VM is constructed with a filtered stdlib, dangerous
8//! `os`/`package` entries are neutralised, memory and instruction caps
9//! are wired in. Plugins that try `os.execute`, `io.popen`, `package.loadlib`
10//! or `debug.*` get a hard error rather than a foothold to read your SSH keys.
11//!
12//! [HAZMAT] If you change `setup_safe_stdlib`, audit every callable returned
13//! to Lua. A single dangling `os.exit` or `io.open` defeats the sandbox for
14//! the entire VM.
15
16use crate::event::PluginEvent;
17use crate::manifest::Capabilities;
18use crate::render::{HighlightRequest, HighlightResponse};
19use crate::storage::{JsonFileStorage, PluginStorage};
20use crate::traits::*;
21use anyhow::Result;
22use mlua::{HookTriggers, Lua, LuaOptions, LuaSerdeExt, StdLib, Table, Value};
23use std::path::{Path, PathBuf};
24use std::sync::{Arc, Mutex};
25use std::time::Duration;
26
27/// Request passed from Lua `sober.run(action, opts)` to the host.
28#[derive(Debug, Clone)]
29pub struct SoberInvocation {
30    /// Allowlisted Sober action, e.g. `doctor` or `review-preview`.
31    pub action: String,
32    /// Free-form JSON options interpreted by the host.
33    pub options: serde_json::Value,
34}
35
36/// Result returned by the host-backed Sober capability.
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38pub struct SoberInvocationResult {
39    pub ok: bool,
40    #[serde(default)]
41    pub data: serde_json::Value,
42    #[serde(default)]
43    pub error: Option<String>,
44}
45
46/// Host-provided Sober capability handle.
47#[derive(Clone)]
48pub struct SoberHost {
49    runner: Arc<dyn Fn(SoberInvocation) -> SoberInvocationResult + Send + Sync>,
50}
51
52impl std::fmt::Debug for SoberHost {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("SoberHost").finish_non_exhaustive()
55    }
56}
57
58impl SoberHost {
59    /// Create a Sober capability from a host callback.
60    pub fn new<F>(runner: F) -> Self
61    where
62        F: Fn(SoberInvocation) -> SoberInvocationResult + Send + Sync + 'static,
63    {
64        Self {
65            runner: Arc::new(runner),
66        }
67    }
68
69    /// Run a Sober invocation through the host callback.
70    pub fn run(&self, invocation: SoberInvocation) -> SoberInvocationResult {
71        (self.runner)(invocation)
72    }
73}
74
75/// Tunable knobs for a `LuaPlugin` VM.
76///
77/// Defaults are conservative and align with `Capabilities::legacy_default()`:
78/// 64 MB memory, 10 M instructions per hook call, 5 s HTTP timeout.
79///
80/// [HAZMAT] The instruction cap is *best-effort under LuaJIT*. LuaJIT's
81/// tracing JIT can compile a tight integer loop into native code that
82/// bypasses debug hooks; the cap will not trip on that specific
83/// pathology until the loop calls a function or touches a table /
84/// string. In practice every real plugin does. The memory cap and HTTP
85/// timeout are not affected by JIT and remain hard limits.
86#[derive(Debug, Clone)]
87pub struct LuaPluginOptions {
88    /// Heap memory ceiling in megabytes. Counted by the Lua GC.
89    pub memory_mb: u32,
90    /// Hard cap on Lua instructions per single hook call.
91    /// Set to `0` to disable (not recommended).
92    pub max_instructions: u64,
93    /// Per-request HTTP timeout.
94    pub http_timeout: Duration,
95    /// If set, restrict outbound HTTP to these hosts (suffix match).
96    pub network_allow: Vec<String>,
97    /// Master switch for outbound HTTP.
98    pub network: bool,
99    /// Allow `os.getenv`. Disable in hardened deployments.
100    pub env_access: bool,
101    /// Host-provided Sober bridge. Only set when the manifest grants it.
102    pub sober: Option<SoberHost>,
103    /// Repository root; used to scope the plugin's `storage` table.
104    pub repo_root: Option<PathBuf>,
105}
106
107impl Default for LuaPluginOptions {
108    fn default() -> Self {
109        Self::from_capabilities(&Capabilities::legacy_default())
110    }
111}
112
113impl LuaPluginOptions {
114    /// Derive runtime options from a manifest capability block.
115    pub fn from_capabilities(caps: &Capabilities) -> Self {
116        Self {
117            memory_mb: caps.memory_mb,
118            max_instructions: caps.max_instructions,
119            http_timeout: Duration::from_secs(caps.http_timeout_secs),
120            network_allow: caps.network_allow.clone(),
121            network: caps.network,
122            env_access: caps.env,
123            sober: None,
124            repo_root: None,
125        }
126    }
127}
128
129/// Shared HTTP capability handle — single client + allowlist.
130struct HttpCap {
131    client: reqwest::blocking::Client,
132    network: bool,
133    allow: Vec<String>,
134}
135
136impl HttpCap {
137    fn new(opts: &LuaPluginOptions) -> mlua::Result<Self> {
138        let client = reqwest::blocking::Client::builder()
139            .timeout(opts.http_timeout)
140            .user_agent(concat!("progit-plugin-sdk/", env!("CARGO_PKG_VERSION")))
141            .build()
142            .map_err(|e| mlua::Error::RuntimeError(format!("http client init: {e}")))?;
143        Ok(Self {
144            client,
145            network: opts.network,
146            allow: opts.network_allow.clone(),
147        })
148    }
149
150    fn check(&self, url: &str) -> mlua::Result<()> {
151        if !self.network {
152            return Err(mlua::Error::RuntimeError(
153                "http: capability 'network' not granted in plugin manifest".into(),
154            ));
155        }
156        if self.allow.is_empty() {
157            return Ok(());
158        }
159        let host = match reqwest::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_string)) {
160            Some(h) => h.to_lowercase(),
161            None => {
162                return Err(mlua::Error::RuntimeError(format!("http: invalid URL '{url}'")));
163            }
164        };
165        let ok = self.allow.iter().any(|a| {
166            let al = a.to_lowercase();
167            host == al || host.ends_with(&format!(".{al}"))
168        });
169        if ok {
170            Ok(())
171        } else {
172            Err(mlua::Error::RuntimeError(format!(
173                "http: host '{host}' not in network_allow list"
174            )))
175        }
176    }
177}
178
179/// A sandboxed LuaJIT plugin instance.
180pub struct LuaPlugin {
181    lua: Lua,
182    metadata: PluginMetadata,
183    context: Option<PluginContext>,
184    options: LuaPluginOptions,
185    /// Wired up at `init()` once we know the repo root + plugin name.
186    storage: Arc<Mutex<Option<JsonFileStorage>>>,
187}
188
189impl LuaPlugin {
190    /// Load with default options (legacy-permissive caps, network OFF).
191    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
192        Self::load_with_options(path, LuaPluginOptions::default())
193    }
194
195    /// Load from a script string with default options.
196    pub fn from_string(script: &str, name: &str) -> Result<Self> {
197        Self::from_string_with_options(script, name, LuaPluginOptions::default())
198    }
199
200    /// Load with explicit options. Use when constructing from a manifest's
201    /// declared capabilities (the recommended path for production hosts).
202    pub fn load_with_options<P: AsRef<Path>>(path: P, options: LuaPluginOptions) -> Result<Self> {
203        let script = std::fs::read_to_string(path.as_ref())?;
204        Self::from_string_with_options(&script, "<file>", options)
205    }
206
207    /// Load from a script string with explicit options.
208    pub fn from_string_with_options(
209        script: &str,
210        _name: &str,
211        options: LuaPluginOptions,
212    ) -> Result<Self> {
213        // === Build a sandboxed VM ============================================
214        // Skip IO (file open, popen), DEBUG (introspection), and FFI (raw C
215        // memory access — defeats the entire sandbox in one line). Keep
216        // TABLE/STRING/MATH for ergonomics; OS for time/date (dangerous bits
217        // get neutralised below); PACKAGE for require of injected modules
218        // (loadlib neutralised); BIT and JIT because they are LuaJIT-essential
219        // and benign. Coroutines come with the base library on Lua 5.1/LuaJIT
220        // — there is no separate `StdLib::COROUTINE` flag.
221        let libs = StdLib::TABLE
222            | StdLib::STRING
223            | StdLib::MATH
224            | StdLib::OS
225            | StdLib::PACKAGE
226            | StdLib::BIT
227            | StdLib::JIT;
228        
229        // Create Lua VM with panic isolation (mlua can panic on some errors)
230        let lua_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
231            Lua::new_with(libs, LuaOptions::default())
232        }));
233        
234        let lua = match lua_result {
235            Ok(Ok(lua)) => lua,
236            Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to construct sandboxed Lua VM: {}", e)),
237            Err(panic_info) => {
238                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
239                    s.to_string()
240                } else if let Some(s) = panic_info.downcast_ref::<String>() {
241                    s.clone()
242                } else {
243                    "Unknown panic".to_string()
244                };
245                return Err(anyhow::anyhow!("Lua VM construction panicked: {}", msg));
246            }
247        };
248
249        // Memory cap. Note: `set_memory_limit` is best-effort — large native
250        // userdata can still allocate outside this budget.
251        if options.memory_mb > 0 {
252            let bytes = (options.memory_mb as usize).saturating_mul(1024 * 1024);
253            let _ = lua.set_memory_limit(bytes);
254        }
255
256        let storage = Arc::new(Mutex::new(None));
257
258        // Set up stdlib with panic isolation
259        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
260            setup_safe_stdlib(&lua, &options, storage.clone())
261        })).map_err(|e| {
262            let msg = if let Some(s) = e.downcast_ref::<&str>() {
263                s.to_string()
264            } else if let Some(s) = e.downcast_ref::<String>() {
265                s.clone()
266            } else {
267                "Unknown panic".to_string()
268            };
269            anyhow::anyhow!("Failed to set up sandboxed stdlib (panicked): {}", msg)
270        })?.map_err(|e| anyhow::anyhow!("Failed to set up sandboxed stdlib: {}", e))?;
271
272        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
273            neutralise_dangerous(&lua, &options)
274        })).map_err(|e| {
275            let msg = if let Some(s) = e.downcast_ref::<&str>() {
276                s.to_string()
277            } else if let Some(s) = e.downcast_ref::<String>() {
278                s.clone()
279            } else {
280                "Unknown panic".to_string()
281            };
282            anyhow::anyhow!("Failed to neutralise dangerous globals (panicked): {}", msg)
283        })?.map_err(|e| anyhow::anyhow!("Failed to neutralise dangerous globals: {}", e))?;
284
285        lua.load(script)
286            .exec()
287            .map_err(|e| anyhow::anyhow!("Plugin script error: {e}"))?;
288
289        let metadata = Self::extract_metadata(&lua)?;
290
291        if options.max_instructions > 0 {
292            install_instruction_hook(&lua, options.max_instructions)?;
293        }
294
295        Ok(Self {
296            lua,
297            metadata,
298            context: None,
299            options,
300            storage,
301        })
302    }
303
304    fn extract_metadata(lua: &Lua) -> Result<PluginMetadata> {
305        let globals = lua.globals();
306        let plugin_table: Table = globals
307            .get("plugin")
308            .map_err(|e| anyhow::anyhow!("Plugin must define a 'plugin' table: {e}"))?;
309
310        let name: String = plugin_table
311            .get("name")
312            .map_err(|e| anyhow::anyhow!("Plugin metadata missing 'name': {e}"))?;
313        let version: String = plugin_table
314            .get("version")
315            .map_err(|e| anyhow::anyhow!("Plugin metadata missing 'version': {e}"))?;
316        let author: String = plugin_table
317            .get("author")
318            .map_err(|e| anyhow::anyhow!("Plugin metadata missing 'author': {e}"))?;
319        let description: String = plugin_table.get("description").unwrap_or_default();
320
321        let hooks_table: Table = plugin_table
322            .get("hooks")
323            .map_err(|e| anyhow::anyhow!("Plugin metadata missing 'hooks' table: {e}"))?;
324        let mut hooks = Vec::new();
325        for pair in hooks_table.pairs::<String, bool>() {
326            let (hook_name, enabled) = pair
327                .map_err(|e| anyhow::anyhow!("Failed to iterate hooks: {e}"))?;
328            if !enabled {
329                continue;
330            }
331            if let Some(h) = hook_name_to_enum(&hook_name) {
332                hooks.push(h);
333            }
334        }
335
336        Ok(PluginMetadata {
337            name,
338            version,
339            author,
340            description,
341            hooks,
342        })
343    }
344
345    fn call_lua_hook(
346        &self,
347        hook_name: &str,
348        data: &serde_json::Value,
349    ) -> Result<serde_json::Value> {
350        let globals = self.lua.globals();
351        let lua_data = self.json_to_lua(data)?;
352        let hook_fn: mlua::Function = globals
353            .get(hook_name)
354            .map_err(|e| anyhow::anyhow!("Hook function '{hook_name}' not found: {e}"))?;
355        let result: Value = hook_fn
356            .call(lua_data)
357            .map_err(|e| anyhow::anyhow!("Lua hook '{hook_name}' raised: {e}"))?;
358        self.lua_to_json(&result)
359    }
360
361    fn json_to_lua(&self, value: &serde_json::Value) -> Result<Value> {
362        Ok(self
363            .lua
364            .to_value(value)
365            .map_err(|e| anyhow::anyhow!("Failed to convert JSON to Lua: {e}"))?)
366    }
367
368    fn lua_to_json(&self, value: &Value) -> Result<serde_json::Value> {
369        Ok(self
370            .lua
371            .from_value(value.clone())
372            .map_err(|e| anyhow::anyhow!("Failed to convert Lua to JSON: {e}"))?)
373    }
374
375    /// Call `plugin.on_event(event)` if the plugin defines it.
376    pub fn call_event(&self, event: &serde_json::Value) -> Result<Option<serde_json::Value>> {
377        let globals = self.lua.globals();
378        let plugin_table: Table = match globals.get("plugin") {
379            Ok(t) => t,
380            Err(_) => return Ok(None),
381        };
382        let on_event_fn: mlua::Function = match plugin_table.get("on_event") {
383            Ok(f) => f,
384            Err(_) => return Ok(None),
385        };
386        let lua_event = self.json_to_lua(event)?;
387        let result: Value = on_event_fn
388            .call(lua_event)
389            .map_err(|e| anyhow::anyhow!("plugin.on_event failed: {e}"))?;
390        match result {
391            Value::Nil => Ok(None),
392            _ => Ok(Some(self.lua_to_json(&result)?)),
393        }
394    }
395
396    /// Strongly-typed convenience for the `Custom` event variant.
397    pub fn call_typed_event(&self, event: &PluginEvent) -> Result<Option<serde_json::Value>> {
398        let v = serde_json::to_value(event)?;
399        self.call_event(&v)
400    }
401
402    /// Call `plugin.highlight(req)` if defined. Returns `Ok(None)` when
403    /// the plugin is not a highlight provider, when it explicitly
404    /// returned `nil`, or when its decoded response was empty.
405    fn call_highlight(
406        &self,
407        request: &HighlightRequest,
408    ) -> Result<Option<HighlightResponse>> {
409        let globals = self.lua.globals();
410        let plugin_table: Table = match globals.get("plugin") {
411            Ok(t) => t,
412            Err(_) => return Ok(None),
413        };
414        let highlight_fn: mlua::Function = match plugin_table.get("highlight") {
415            Ok(f) => f,
416            Err(_) => return Ok(None),
417        };
418        let req_json = serde_json::to_value(request)?;
419        let req_lua = self.json_to_lua(&req_json)?;
420        let result: Value = highlight_fn
421            .call(req_lua)
422            .map_err(|e| anyhow::anyhow!("plugin.highlight failed: {e}"))?;
423        if let Value::Nil = result {
424            return Ok(None);
425        }
426        let resp_json = self.lua_to_json(&result)?;
427        let resp: HighlightResponse = serde_json::from_value(resp_json)
428            .map_err(|e| anyhow::anyhow!("highlight response decode: {e}"))?;
429        if resp.spans.is_empty() {
430            Ok(None)
431        } else {
432            Ok(Some(resp))
433        }
434    }
435}
436
437impl Plugin for LuaPlugin {
438    fn metadata(&self) -> &PluginMetadata {
439        &self.metadata
440    }
441
442    fn init(&mut self, context: &PluginContext) -> PluginResult<()> {
443        let globals = self.lua.globals();
444
445        let repo_root = self
446            .options
447            .repo_root
448            .clone()
449            .unwrap_or_else(|| PathBuf::from(&context.repo_path));
450        let scoped = JsonFileStorage::new(&repo_root, &self.metadata.name);
451        if let Ok(mut slot) = self.storage.lock() {
452            *slot = Some(scoped);
453        }
454
455        // Set context with panic isolation
456        let context_clone = context.clone();
457        // Run init in panic-safe wrapper
458        let init_result: Result<Result<(), PluginError>, String> = 
459            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
460                let context_json = serde_json::to_value(&context_clone)
461                    .map_err(|e| PluginError::InitError(e.to_string()))?;
462                let context_lua = self
463                    .json_to_lua(&context_json)
464                    .map_err(|e| PluginError::InitError(e.to_string()))?;
465                globals
466                    .set("context", context_lua)
467                    .map_err(|e| PluginError::InitError(e.to_string()))?;
468
469                self.context = Some(context_clone.clone());
470
471                if let Ok(init_fn) = globals.get::<mlua::Function>("init") {
472                    init_fn
473                        .call::<()>(())
474                        .map_err(|e| PluginError::InitError(e.to_string()))?;
475                }
476                Ok(())
477            })).map_err(|panic_payload| {
478                if let Some(s) = panic_payload.downcast_ref::<&str>() {
479                    s.to_string()
480                } else if let Some(s) = panic_payload.downcast_ref::<String>() {
481                    s.clone()
482                } else {
483                    "Unknown panic in init".to_string()
484                }
485            });
486
487        match init_result {
488            Ok(Ok(())) => Ok(()),
489            Ok(Err(e)) => Err(e),
490            Err(msg) => Err(PluginError::InitError(format!("Plugin init panicked: {}", msg))),
491        }
492    }
493
494    fn execute_hook(
495        &mut self,
496        hook: &PluginHook,
497        data: &serde_json::Value,
498    ) -> PluginResult<serde_json::Value> {
499        if !self.supports_hook(hook) {
500            return Err(PluginError::UnsupportedHook(hook.clone()));
501        }
502        let result = match hook {
503            PluginHook::OnSchedule(name) => {
504                self.call_lua_hook(&format!("on_schedule_{name}"), data)
505            }
506            PluginHook::OnSprintStart(n) => self.call_lua_hook(
507                "on_sprint_start",
508                &serde_json::json!({ "sprint": n, "data": data }),
509            ),
510            PluginHook::OnSprintEnd(n) => self.call_lua_hook(
511                "on_sprint_end",
512                &serde_json::json!({ "sprint": n, "data": data }),
513            ),
514            PluginHook::OnCommand(_cmd) => self.call_lua_hook("on_command", data),
515            PluginHook::OnBulkOperation(op) => {
516                let name = match op {
517                    BulkOp::Import => "on_bulk_import",
518                    BulkOp::Export => "on_bulk_export",
519                    BulkOp::Archive => "on_bulk_archive",
520                    BulkOp::Delete => "on_bulk_delete",
521                };
522                self.call_lua_hook(name, data)
523            }
524            other => {
525                let name = hook_enum_to_name(other).ok_or_else(|| {
526                    PluginError::ExecutionError(format!("no Lua name for hook {other:?}"))
527                })?;
528                self.call_lua_hook(name, data)
529            }
530        };
531        result.map_err(|e| PluginError::ExecutionError(e.to_string()))
532    }
533
534    fn on_event(&mut self, event: &serde_json::Value) -> PluginResult<Option<serde_json::Value>> {
535        self.call_event(event)
536            .map_err(|e| PluginError::ExecutionError(e.to_string()))
537    }
538
539    fn highlight(
540        &mut self,
541        request: &HighlightRequest,
542    ) -> PluginResult<Option<HighlightResponse>> {
543        self.call_highlight(request)
544            .map_err(|e| PluginError::ExecutionError(e.to_string()))
545    }
546}
547
548impl IssuePlugin for LuaPlugin {}
549impl SyncPlugin for LuaPlugin {}
550
551// ─── Hook name <-> enum bridge ────────────────────────────────────────────
552
553fn hook_name_to_enum(name: &str) -> Option<PluginHook> {
554    Some(match name {
555        "on_issue_created" => PluginHook::OnIssueCreated,
556        "on_issue_updated" => PluginHook::OnIssueUpdated,
557        "on_issue_deleted" => PluginHook::OnIssueDeleted,
558        "on_status_changed" => PluginHook::OnStatusChanged,
559        "on_sync_push" => PluginHook::OnSyncPush,
560        "on_sync_pull" => PluginHook::OnSyncPull,
561        "on_merge_request_created" => PluginHook::OnMergeRequestCreated,
562        "on_external_sync" => PluginHook::OnExternalSync,
563        "on_webhook_received" => PluginHook::OnWebhookReceived,
564        "on_due_date_approaching" => PluginHook::OnDueDateApproaching,
565        "on_due_date_passed" => PluginHook::OnDueDatePassed,
566        "on_report_requested" => PluginHook::OnReportRequested,
567        "on_metric_query" => PluginHook::OnMetricQuery,
568        "on_command" => PluginHook::OnCommand(String::new()), // Command name set at runtime
569        _ => return None,
570    })
571}
572
573fn hook_enum_to_name(hook: &PluginHook) -> Option<&'static str> {
574    Some(match hook {
575        PluginHook::OnIssueCreated => "on_issue_created",
576        PluginHook::OnIssueUpdated => "on_issue_updated",
577        PluginHook::OnIssueDeleted => "on_issue_deleted",
578        PluginHook::OnStatusChanged => "on_status_changed",
579        PluginHook::OnSyncPush => "on_sync_push",
580        PluginHook::OnSyncPull => "on_sync_pull",
581        PluginHook::OnMergeRequestCreated => "on_merge_request_created",
582        PluginHook::OnExternalSync => "on_external_sync",
583        PluginHook::OnWebhookReceived => "on_webhook_received",
584        PluginHook::OnDueDateApproaching => "on_due_date_approaching",
585        PluginHook::OnDueDatePassed => "on_due_date_passed",
586        PluginHook::OnReportRequested => "on_report_requested",
587        PluginHook::OnMetricQuery => "on_metric_query",
588        PluginHook::OnCommand(_) => "on_command", // Commands use dynamic names
589        _ => return None,
590    })
591}
592
593// ─── Sandbox plumbing ─────────────────────────────────────────────────────
594
595/// Install an instruction-count hook that aborts a hook call once the
596/// budget is exceeded. Triggers fire every 4 K instructions so the
597/// per-instruction overhead stays negligible.
598fn install_instruction_hook(lua: &Lua, max: u64) -> Result<()> {
599    use std::sync::atomic::{AtomicU64, Ordering};
600    let counter = Arc::new(AtomicU64::new(0));
601    let triggers = HookTriggers::new().every_nth_instruction(4096);
602    let counter_cl = counter.clone();
603    lua.set_hook(triggers, move |_, _debug| {
604        let n = counter_cl.fetch_add(4096, Ordering::Relaxed);
605        if n >= max {
606            return Err(mlua::Error::RuntimeError(format!(
607                "plugin exceeded instruction budget ({max})"
608            )));
609        }
610        Ok(mlua::VmState::Continue)
611    })
612    .map_err(|e| anyhow::anyhow!("Failed to install Lua instruction hook: {e}"))?;
613    Ok(())
614}
615
616/// Build the safe stdlib: `http`, `json`, `log`, `storage`.
617fn setup_safe_stdlib(
618    lua: &Lua,
619    opts: &LuaPluginOptions,
620    storage: Arc<Mutex<Option<JsonFileStorage>>>,
621) -> mlua::Result<()> {
622    let globals = lua.globals();
623
624    // ── http ─────────────────────────────────────────────────────────────
625    let http_cap = Arc::new(HttpCap::new(opts)?);
626    let http = lua.create_table()?;
627
628    let cap = http_cap.clone();
629    let http_get = lua.create_function(move |lua, (url, headers): (String, Option<Table>)| {
630        cap.check(&url)?;
631        let mut req = cap.client.get(&url);
632        if let Some(h) = headers {
633            for pair in h.pairs::<String, String>() {
634                let (k, v) = pair?;
635                req = req.header(k, v);
636            }
637        }
638        send_and_wrap(lua, req)
639    })?;
640
641    let cap = http_cap.clone();
642    let http_post = lua.create_function(
643        move |lua, (url, body, headers): (String, String, Option<Table>)| {
644            cap.check(&url)?;
645            let mut req = cap.client.post(&url).body(body);
646            if let Some(h) = headers {
647                for pair in h.pairs::<String, String>() {
648                    let (k, v) = pair?;
649                    req = req.header(k, v);
650                }
651            }
652            send_and_wrap(lua, req)
653        },
654    )?;
655
656    let cap = http_cap.clone();
657    let http_put = lua.create_function(
658        move |lua, (url, body, headers): (String, String, Option<Table>)| {
659            cap.check(&url)?;
660            let mut req = cap.client.put(&url).body(body);
661            if let Some(h) = headers {
662                for pair in h.pairs::<String, String>() {
663                    let (k, v) = pair?;
664                    req = req.header(k, v);
665                }
666            }
667            send_and_wrap(lua, req)
668        },
669    )?;
670
671    let cap = http_cap.clone();
672    let http_delete =
673        lua.create_function(move |lua, (url, headers): (String, Option<Table>)| {
674            cap.check(&url)?;
675            let mut req = cap.client.delete(&url);
676            if let Some(h) = headers {
677                for pair in h.pairs::<String, String>() {
678                    let (k, v) = pair?;
679                    req = req.header(k, v);
680                }
681            }
682            send_and_wrap(lua, req)
683        })?;
684
685    http.set("get", http_get)?;
686    http.set("post", http_post)?;
687    http.set("put", http_put)?;
688    http.set("delete", http_delete)?;
689    globals.set("http", http)?;
690
691    // ── json ─────────────────────────────────────────────────────────────
692    let json = lua.create_table()?;
693    let json_encode = lua.create_function(|lua, value: Value| {
694        let json_val: serde_json::Value = lua
695            .from_value(value)
696            .map_err(|e| mlua::Error::RuntimeError(format!("json.encode: {e}")))?;
697        serde_json::to_string(&json_val)
698            .map_err(|e| mlua::Error::RuntimeError(format!("json.encode: {e}")))
699    })?;
700    let json_decode = lua.create_function(|lua, s: String| {
701        let json_val: serde_json::Value = serde_json::from_str(&s)
702            .map_err(|e| mlua::Error::RuntimeError(format!("json.decode: {e}")))?;
703        lua.to_value(&json_val)
704            .map_err(|e| mlua::Error::RuntimeError(format!("json.decode: {e}")))
705    })?;
706    json.set("encode", json_encode)?;
707    json.set("decode", json_decode)?;
708    globals.set("json", json)?;
709
710    // ── log ──────────────────────────────────────────────────────────────
711    let log = lua.create_table()?;
712    let log_debug = lua.create_function(|_, msg: String| {
713        log::debug!(target: "progit_plugin", "{msg}");
714        Ok(())
715    })?;
716    let log_info = lua.create_function(|_, msg: String| {
717        log::info!(target: "progit_plugin", "{msg}");
718        Ok(())
719    })?;
720    let log_warn = lua.create_function(|_, msg: String| {
721        log::warn!(target: "progit_plugin", "{msg}");
722        Ok(())
723    })?;
724    let log_error = lua.create_function(|_, msg: String| {
725        log::error!(target: "progit_plugin", "{msg}");
726        Ok(())
727    })?;
728    log.set("debug", log_debug)?;
729    log.set("info", log_info)?;
730    log.set("warn", log_warn)?;
731    log.set("error", log_error)?;
732    globals.set("log", log)?;
733
734    // Back-compat shims: v0.1 plugins called `log_info("...")` etc. as globals.
735    let log_info_global = lua.create_function(|_, msg: String| {
736        log::info!(target: "progit_plugin", "{msg}");
737        Ok(())
738    })?;
739    let log_warn_global = lua.create_function(|_, msg: String| {
740        log::warn!(target: "progit_plugin", "{msg}");
741        Ok(())
742    })?;
743    let log_error_global = lua.create_function(|_, msg: String| {
744        log::error!(target: "progit_plugin", "{msg}");
745        Ok(())
746    })?;
747    globals.set("log_info", log_info_global)?;
748    globals.set("log_warn", log_warn_global)?;
749    globals.set("log_error", log_error_global)?;
750
751    // ── sober (host capability) ─────────────────────────────────────────
752    let sober_tbl = lua.create_table()?;
753    if let Some(host) = opts.sober.clone() {
754        let sober_run = lua.create_function(
755            move |lua, (action, options): (String, Option<Value>)| {
756                let options_json = match options {
757                    Some(value) => lua.from_value(value).map_err(|e| {
758                        mlua::Error::RuntimeError(format!("sober.run: options: {e}"))
759                    })?,
760                    None => serde_json::Value::Object(Default::default()),
761                };
762                let result = host.run(SoberInvocation {
763                    action,
764                    options: options_json,
765                });
766                lua.to_value(&result)
767                    .map_err(|e| mlua::Error::RuntimeError(format!("sober.run: result: {e}")))
768            },
769        )?;
770        sober_tbl.set("run", sober_run)?;
771    } else {
772        let sober_run = lua.create_function(|_, (_action, _options): (String, Option<Value>)| {
773            Err::<Value, _>(mlua::Error::RuntimeError(
774                "sober.run requires the manifest capability `sober = true` and a host bridge"
775                    .into(),
776            ))
777        })?;
778        sober_tbl.set("run", sober_run)?;
779    }
780    globals.set("sober", sober_tbl)?;
781
782    // ── storage ──────────────────────────────────────────────────────────
783    let storage_tbl = lua.create_table()?;
784
785    let s = storage.clone();
786    let storage_get = lua.create_function(move |lua, key: String| {
787        let guard = s
788            .lock()
789            .map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
790        let val = match guard.as_ref() {
791            Some(st) => st
792                .get(&key)
793                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
794            None => return Ok(Value::Nil),
795        };
796        match val {
797            Some(v) => lua
798                .to_value(&v)
799                .map_err(|e| mlua::Error::RuntimeError(e.to_string())),
800            None => Ok(Value::Nil),
801        }
802    })?;
803
804    let s = storage.clone();
805    let storage_set = lua.create_function(move |lua, (key, value): (String, Value)| {
806        let json_val: serde_json::Value = lua
807            .from_value(value)
808            .map_err(|e| mlua::Error::RuntimeError(format!("storage.set: {e}")))?;
809        let mut guard = s
810            .lock()
811            .map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
812        match guard.as_mut() {
813            Some(st) => st
814                .set(&key, &json_val)
815                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
816            None => return Err(mlua::Error::RuntimeError("storage not initialised".into())),
817        }
818        Ok(())
819    })?;
820
821    let s = storage.clone();
822    let storage_delete = lua.create_function(move |_, key: String| {
823        let mut guard = s
824            .lock()
825            .map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
826        let removed = match guard.as_mut() {
827            Some(st) => st
828                .delete(&key)
829                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
830            None => false,
831        };
832        Ok(removed)
833    })?;
834
835    let s = storage.clone();
836    let storage_keys = lua.create_function(move |lua, ()| {
837        let guard = s
838            .lock()
839            .map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
840        let keys: Vec<String> = match guard.as_ref() {
841            Some(st) => st
842                .keys()
843                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
844            None => Vec::new(),
845        };
846        let t = lua.create_table()?;
847        for (i, k) in keys.into_iter().enumerate() {
848            t.set(i + 1, k)?;
849        }
850        Ok(t)
851    })?;
852
853    let storage_clear = {
854        let s = storage.clone();
855        lua.create_function(move |_, ()| {
856            let mut guard = s
857                .lock()
858                .map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
859            if let Some(st) = guard.as_mut() {
860                st.clear()
861                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
862            }
863            Ok(())
864        })?
865    };
866
867    storage_tbl.set("get", storage_get)?;
868    storage_tbl.set("set", storage_set)?;
869    storage_tbl.set("delete", storage_delete)?;
870    storage_tbl.set("keys", storage_keys)?;
871    storage_tbl.set("clear", storage_clear)?;
872    globals.set("storage", storage_tbl)?;
873
874    // Mount under package.loaded so `require()` resolves.
875    let package: Table = globals.get("package")?;
876    let loaded: Table = package.get("loaded")?;
877    for name in ["http", "json", "log", "sober", "storage"] {
878        let v: Value = globals.get(name)?;
879        loaded.set(name, v)?;
880    }
881
882    // ── io (safe shim) ──────────────────────────────────────────────────
883    let repo_root = opts.repo_root.clone();
884    let io = lua.create_table()?;
885    
886    let repo_for_open = repo_root.clone();
887    let io_open = lua.create_function(move |lua, (path, mode): (String, Option<String>)| {
888        let repo_str = repo_for_open.as_ref()
889            .map(|s| s.to_string_lossy().to_string())
890            .unwrap_or_else(|| ".".to_string());
891        
892        let m = mode.unwrap_or_else(|| "r".to_string());
893        // Allow read ('r') and append ('a') modes only
894        // Write mode ('w') is not allowed to prevent accidental overwrites
895        if !m.starts_with('r') && !m.starts_with('a') {
896            return Err(mlua::Error::RuntimeError(
897                "io: only read ('r') and append ('a') modes are allowed".into()
898            ));
899        }
900        
901        let abs = if path.starts_with('/') { path.clone() }
902                  else { format!("{}/{}", repo_str, path) };
903        
904        // Read file
905        let content = match std::fs::read_to_string(&abs) {
906            Ok(c) => c,
907            Err(e) => return Err(mlua::Error::RuntimeError(format!("io: {}", e))),
908        };
909        
910        // Create file table
911        let file = lua.create_table()?;
912        let c = content.clone();
913        let read_fn = lua.create_function(move |_, fmt: String| {
914            if fmt == "*a" { Ok(c.clone()) }
915            else if fmt == "*l" { Ok(c.lines().next().unwrap_or_default().to_string()) }
916            else { Ok(c.clone()) }
917        })?;
918        file.set("read", read_fn)?;
919        let close_fn = lua.create_function(|_, ()| Ok(()))?;
920        file.set("close", close_fn)?;
921        let lines_vec: Vec<String> = content.lines().map(|s| s.to_string()).collect();
922        let lines_fn = lua.create_function(move |lua, ()| {
923            let lines = lines_vec.clone();
924            let idx = std::cell::RefCell::new(0usize);
925            Ok(lua.create_function(move |_, ()| {
926                *idx.borrow_mut() += 1;
927                let i = *idx.borrow();
928                if i <= lines.len() { Ok(Some(lines[i-1].clone())) }
929                else { Ok(None) }
930            }))
931        })?;
932        file.set("lines", lines_fn)?;
933        Ok(file)
934    })?;
935    
936    io.set("open", io_open.clone())?;
937    globals.set("io", io)?;
938    globals.set("io_open", io_open)?;
939
940    Ok(())
941}
942
943fn send_and_wrap(lua: &Lua, req: reqwest::blocking::RequestBuilder) -> mlua::Result<Table> {
944    let resp = req
945        .send()
946        .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
947    let status = resp.status().as_u16() as i64;
948    let ok = resp.status().is_success();
949    let body = resp
950        .text()
951        .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
952    let t = lua.create_table()?;
953    t.set("status", status)?;
954    t.set("body", body)?;
955    t.set("ok", ok)?;
956    Ok(t)
957}
958
959/// Strip the dangerous entries that `StdLib::OS` and `StdLib::PACKAGE`
960/// expose by default.
961fn neutralise_dangerous(lua: &Lua, opts: &LuaPluginOptions) -> mlua::Result<()> {
962    let globals = lua.globals();
963
964    if let Ok(os_tbl) = globals.get::<Table>("os") {
965        for name in ["execute", "exit", "remove", "rename", "tmpname", "setlocale"] {
966            let banned = name.to_string();
967            let f = lua.create_function(move |_, ()| {
968                Err::<(), _>(mlua::Error::RuntimeError(format!(
969                    "os.{banned} is disabled in the ProGit plugin sandbox"
970                )))
971            })?;
972            os_tbl.set(name, f)?;
973        }
974        if !opts.env_access {
975            let f = lua.create_function(|_, _name: String| {
976                Err::<Option<String>, _>(mlua::Error::RuntimeError(
977                    "os.getenv is disabled (capability 'env' not granted)".into(),
978                ))
979            })?;
980            os_tbl.set("getenv", f)?;
981        }
982    }
983
984    if let Ok(pkg_tbl) = globals.get::<Table>("package") {
985        for name in ["loadlib", "searchpath"] {
986            let banned = name.to_string();
987            let f = lua.create_function(move |_, ()| {
988                Err::<(), _>(mlua::Error::RuntimeError(format!(
989                    "package.{banned} is disabled in the sandbox"
990                )))
991            })?;
992            pkg_tbl.set(name, f)?;
993        }
994    }
995
996    // Note: io shim is now set up in setup_safe_stdlib
997
998    Ok(())
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004
1005    fn loose_opts() -> LuaPluginOptions {
1006        let mut opts = LuaPluginOptions::default();
1007        opts.network = false;
1008        opts.max_instructions = 0;
1009        opts
1010    }
1011
1012    #[test]
1013    fn loads_minimal_plugin() {
1014        let script = r#"
1015            plugin = {
1016                name = "test", version = "1.0.0", author = "t",
1017                hooks = { on_issue_created = true }
1018            }
1019            function on_issue_created(issue)
1020                return { ok = true }
1021            end
1022        "#;
1023        let p = LuaPlugin::from_string_with_options(script, "test", loose_opts()).unwrap();
1024        assert_eq!(p.metadata().name, "test");
1025    }
1026
1027    #[test]
1028    fn os_execute_is_blocked() {
1029        let script = r#"
1030            plugin = { name="x", version="1", author="y", hooks = {} }
1031            os.execute("ls")
1032        "#;
1033        let err = LuaPlugin::from_string_with_options(script, "x", loose_opts())
1034            .err()
1035            .expect("os.execute should be blocked");
1036        let msg = format!("{err}");
1037        assert!(msg.contains("disabled"), "got: {msg}");
1038    }
1039
1040    #[test]
1041    fn io_module_shim_works() {
1042        // io is now a safe shim, not nil
1043        let script = r#"
1044            plugin = { name="x", version="1", author="y", hooks = {} }
1045            assert(type(io) == "table", "io should be a table")
1046            assert(type(io.open) == "function", "io.open should be a function")
1047        "#;
1048        LuaPlugin::from_string_with_options(script, "x", loose_opts()).unwrap();
1049    }
1050
1051    #[test]
1052    fn debug_module_is_absent() {
1053        let script = r#"
1054            plugin = { name="x", version="1", author="y", hooks = {} }
1055            assert(debug == nil, "debug should be nil")
1056        "#;
1057        LuaPlugin::from_string_with_options(script, "x", loose_opts()).unwrap();
1058    }
1059
1060    #[test]
1061    fn http_blocked_without_network_capability() {
1062        let script = r#"
1063            plugin = { name="x", version="1", author="y", hooks = { on_issue_created = true } }
1064            function on_issue_created(_)
1065                local r = http.get("https://example.com")
1066                return { ok = r.ok }
1067            end
1068        "#;
1069        let opts = loose_opts();
1070        let mut p = LuaPlugin::from_string_with_options(script, "x", opts).unwrap();
1071        let ctx = PluginContext {
1072            repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1073            user: None,
1074            env: Default::default(),
1075            config: Default::default(),
1076        };
1077        p.init(&ctx).unwrap();
1078        let err = p
1079            .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1080            .err()
1081            .expect("network should be denied");
1082        assert!(format!("{err}").contains("network"));
1083    }
1084
1085    #[test]
1086    fn storage_round_trip() {
1087        let script = r#"
1088            plugin = { name="store-test", version="1", author="t",
1089                hooks = { on_issue_created = true } }
1090            function on_issue_created(_)
1091                storage.set("k", { v = 42 })
1092                local got = storage.get("k")
1093                return { v = got.v }
1094            end
1095        "#;
1096        let temp = tempfile::tempdir().unwrap();
1097        let mut opts = loose_opts();
1098        opts.repo_root = Some(temp.path().to_path_buf());
1099        let mut p = LuaPlugin::from_string_with_options(script, "store-test", opts).unwrap();
1100        let ctx = PluginContext {
1101            repo_path: temp.path().to_string_lossy().to_string(),
1102            user: None,
1103            env: Default::default(),
1104            config: Default::default(),
1105        };
1106        p.init(&ctx).unwrap();
1107        let r = p
1108            .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1109            .unwrap();
1110        assert_eq!(r["v"], 42);
1111    }
1112
1113    #[test]
1114    fn sober_capability_is_blocked_without_host() {
1115        let script = r#"
1116            plugin = { name="sober-denied", version="1", author="t",
1117                hooks = { on_issue_created = true } }
1118            function on_issue_created(_)
1119                return sober.run("doctor", {})
1120            end
1121        "#;
1122        let mut p = LuaPlugin::from_string_with_options(script, "sober-denied", loose_opts()).unwrap();
1123        let ctx = PluginContext {
1124            repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1125            user: None,
1126            env: Default::default(),
1127            config: Default::default(),
1128        };
1129        p.init(&ctx).unwrap();
1130        let err = p
1131            .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1132            .err()
1133            .expect("sober should require host capability");
1134        assert!(format!("{err}").contains("sober.run requires"));
1135    }
1136
1137    #[test]
1138    fn sober_capability_round_trips_through_host() {
1139        let script = r#"
1140            plugin = { name="sober-ok", version="1", author="t",
1141                hooks = { on_issue_created = true } }
1142            function on_issue_created(_)
1143                return sober.run("preflight", { base = "HEAD" })
1144            end
1145        "#;
1146        let mut opts = loose_opts();
1147        opts.sober = Some(SoberHost::new(|invocation| {
1148            assert_eq!(invocation.action, "preflight");
1149            assert_eq!(invocation.options["base"], "HEAD");
1150            SoberInvocationResult {
1151                ok: true,
1152                data: serde_json::json!({ "action": invocation.action, "ok": true }),
1153                error: None,
1154            }
1155        }));
1156        let mut p = LuaPlugin::from_string_with_options(script, "sober-ok", opts).unwrap();
1157        let ctx = PluginContext {
1158            repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1159            user: None,
1160            env: Default::default(),
1161            config: Default::default(),
1162        };
1163        p.init(&ctx).unwrap();
1164        let r = p
1165            .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1166            .unwrap();
1167        assert_eq!(r["ok"], true);
1168        assert_eq!(r["data"]["action"], "preflight");
1169    }
1170
1171    #[test]
1172    fn instruction_cap_trips_runaway_loop() {
1173        // [HAZMAT] LuaJIT's tracing JIT can compile tight integer loops to
1174        // native code that bypasses debug hooks. We turn JIT off in this
1175        // test so we are verifying the *mechanism* (hook → counter → abort)
1176        // rather than fighting the JIT. In production, plugins that touch
1177        // tables / strings / IO trigger hooks frequently enough that the
1178        // cap fires within milliseconds anyway. A plugin that genuinely
1179        // wants to spin a JIT-traceable loop forever still hits the
1180        // memory cap or the host's overall watchdog.
1181        let script = r#"
1182            jit.off()
1183            plugin = { name="loopy", version="1", author="t",
1184                hooks = { on_issue_created = true } }
1185            function on_issue_created(_)
1186                local i = 0
1187                while true do i = i + 1 end
1188                return { i = i }
1189            end
1190        "#;
1191        let mut opts = loose_opts();
1192        opts.max_instructions = 100_000;
1193        let mut p = LuaPlugin::from_string_with_options(script, "loopy", opts).unwrap();
1194        let ctx = PluginContext {
1195            repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1196            user: None,
1197            env: Default::default(),
1198            config: Default::default(),
1199        };
1200        p.init(&ctx).unwrap();
1201        let err = p
1202            .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1203            .err()
1204            .expect("runaway loop should hit the instruction cap");
1205        assert!(format!("{err}").contains("instruction budget"), "{err}");
1206    }
1207
1208    #[test]
1209    fn highlight_round_trips_through_lua() {
1210        let script = r#"
1211            plugin = { name="hl", version="1", author="t", hooks = {} }
1212            function plugin.highlight(req)
1213                return {
1214                    spans = {
1215                        { text = "fn ", fg = { r = 200, g = 0, b = 0 }, bold = true },
1216                        { text = req.content:sub(4) },
1217                    }
1218                }
1219            end
1220        "#;
1221        let mut p = LuaPlugin::from_string_with_options(script, "hl", loose_opts()).unwrap();
1222        let ctx = PluginContext {
1223            repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1224            user: None,
1225            env: Default::default(),
1226            config: Default::default(),
1227        };
1228        p.init(&ctx).unwrap();
1229        let resp = p
1230            .highlight(&HighlightRequest {
1231                language: Some("rust".into()),
1232                content: "fn main() {}".into(),
1233            })
1234            .expect("highlight should not error")
1235            .expect("plugin should return spans");
1236        assert_eq!(resp.spans.len(), 2);
1237        assert_eq!(resp.spans[0].text, "fn ");
1238        assert_eq!(resp.spans[0].fg, Some(crate::render::Rgb::new(200, 0, 0)));
1239        assert!(resp.spans[0].bold);
1240        assert_eq!(resp.spans[1].text, "main() {}");
1241    }
1242
1243    #[test]
1244    fn highlight_returns_none_when_plugin_does_not_implement_it() {
1245        let script = r#"
1246            plugin = { name="nohl", version="1", author="t", hooks = {} }
1247        "#;
1248        let mut p = LuaPlugin::from_string_with_options(script, "nohl", loose_opts()).unwrap();
1249        let ctx = PluginContext {
1250            repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1251            user: None,
1252            env: Default::default(),
1253            config: Default::default(),
1254        };
1255        p.init(&ctx).unwrap();
1256        let resp = p
1257            .highlight(&HighlightRequest {
1258                language: None,
1259                content: "x".into(),
1260            })
1261            .unwrap();
1262        assert!(resp.is_none(), "plugin without highlight() should yield None");
1263    }
1264
1265    #[test]
1266    fn back_compat_global_log_info_works() {
1267        let script = r#"
1268            plugin = { name="bc", version="1", author="t",
1269                hooks = { on_issue_created = true } }
1270            function on_issue_created(_)
1271                log_info("hello from v0.1 style")
1272                return { ok = true }
1273            end
1274        "#;
1275        let mut p = LuaPlugin::from_string_with_options(script, "bc", loose_opts()).unwrap();
1276        let ctx = PluginContext {
1277            repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1278            user: None,
1279            env: Default::default(),
1280            config: Default::default(),
1281        };
1282        p.init(&ctx).unwrap();
1283        let r = p
1284            .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1285            .unwrap();
1286        assert_eq!(r["ok"], true);
1287    }
1288}