Skip to main content

re_ghidra_qwen_hook/
hook.rs

1//! `re-ghidra-qwen-hook` — the extension's `SessionStart` preflight.
2//!
3//! The four ways this extension fails to start are all environmental: no
4//! `GHIDRA_INSTALL_DIR`, a directory that is not actually a Ghidra root, a JDK
5//! older than 21, and an unconfigured project. Without this hook each of them
6//! surfaces only on the first tool call, as an error from inside a JVM that
7//! never booted.
8//!
9//! Two rules govern what it may do:
10//!
11//! 1. **Silent when healthy.** It runs on every session start, so a clean
12//!    environment must produce no output at all. Anything else is noise the
13//!    user reads once and then learns to ignore.
14//! 2. **Never blocks.** It always exits 0. A preflight that can stop a session
15//!    is worse than the problem it reports — the user may not even intend to
16//!    touch Ghidra this session.
17//!
18//! Every check is filesystem-or-`java -version` cheap: no JVM boot, no Ghidra
19//! project open.
20
21use re_ghidra_mcp_qwen::{load_settings, parse_java_major, preflight, Probe};
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25fn main() {
26    // Draining stdin is not optional. Qwen Code writes the event JSON to the
27    // hook's stdin, and exiting without reading it can hand the writer a broken
28    // pipe. We take `cwd` from it when present, since it is authoritative for
29    // the session's project root.
30    let mut raw = String::new();
31    let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut raw);
32
33    let project_dir = event_cwd(&raw)
34        .or_else(|| std::env::var("QWEN_PROJECT_DIR").ok().map(PathBuf::from))
35        .or_else(|| std::env::current_dir().ok())
36        .unwrap_or_default();
37
38    // Same three layers the server itself resolves, minus CLI flags: there are
39    // none here. Reporting on a different config than the server will use would
40    // make this hook worse than useless.
41    let mut cfg = load_settings(&project_dir);
42    overlay_env(&mut cfg);
43
44    let probe = Probe {
45        launcher_present: cfg
46            .ghidra_install_dir
47            .as_deref()
48            .is_some_and(|d| launcher_exists(Path::new(d))),
49        java_major: detect_java_major(),
50    };
51
52    let findings = preflight(&cfg, &probe);
53    if findings.is_empty() {
54        return; // The silent path.
55    }
56
57    let body = format!(
58        "re-ghidra-mcp-qwen is not ready to attach to a Ghidra project:\n{}",
59        findings
60            .iter()
61            .map(|f| format!("  - {f}"))
62            .collect::<Vec<_>>()
63            .join("\n")
64    );
65    // `additionalContext` is how SessionStart feeds text to the model;
66    // `systemMessage` is the generic channel. Emitting both means the report
67    // lands whichever one the running Qwen Code honours.
68    let payload = serde_json::json!({
69        "systemMessage": body,
70        "hookSpecificOutput": {
71            "hookEventName": "SessionStart",
72            "additionalContext": body,
73        }
74    });
75    println!("{payload}");
76}
77
78/// Pull `cwd` out of the hook event without a full deserialization struct —
79/// the field is the only one we want and the schema may grow.
80fn event_cwd(raw: &str) -> Option<PathBuf> {
81    let v: serde_json::Value = serde_json::from_str(raw).ok()?;
82    let cwd = v.get("cwd")?.as_str()?;
83    (!cwd.is_empty()).then(|| PathBuf::from(cwd))
84}
85
86/// Layer the environment over the settings file, matching the server's own
87/// precedence. Empty is treated as unset, as it is there.
88fn overlay_env(cfg: &mut ghidra_mcp::config::RawConfig) {
89    let get = |k: &str| std::env::var(k).ok().filter(|s| !s.is_empty());
90    for (slot, key) in [
91        (&mut cfg.ghidra_install_dir, "GHIDRA_INSTALL_DIR"),
92        (&mut cfg.project_dir, "GHIDRA_MCP_PROJECT_DIR"),
93        (&mut cfg.project_name, "GHIDRA_MCP_PROJECT_NAME"),
94        (&mut cfg.bootstrap_program, "GHIDRA_MCP_BOOTSTRAP_PROGRAM"),
95        (
96            &mut cfg.bootstrap_program_path,
97            "GHIDRA_MCP_BOOTSTRAP_PROGRAM_PATH",
98        ),
99        (&mut cfg.max_heap, "GHIDRA_MCP_MAX_HEAP"),
100    ] {
101        if let Some(v) = get(key) {
102            *slot = Some(v);
103        }
104    }
105}
106
107/// A Ghidra install root is identified by its headless launcher, the same file
108/// the server's own config validation looks for.
109fn launcher_exists(install_dir: &Path) -> bool {
110    let support = install_dir.join("support");
111    support.join("analyzeHeadless.bat").exists() || support.join("analyzeHeadless").exists()
112}
113
114/// `java -version` writes to **stderr**, not stdout. Any spawn failure means
115/// "no usable java", which is exactly what the preflight wants to hear.
116fn detect_java_major() -> Option<u32> {
117    let out = Command::new("java").arg("-version").output().ok()?;
118    parse_java_major(&String::from_utf8_lossy(&out.stderr))
119        .or_else(|| parse_java_major(&String::from_utf8_lossy(&out.stdout)))
120}