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}