1use std::path::{Path, PathBuf};
4
5use anyhow::Result;
6use clap::{Parser, Subcommand};
7
8use crate::{context, observe, summarize};
9
10pub const HOOK_COMMANDS: &[&str] = &["context", "session-init", "observe", "summarize"];
11
12pub fn hook_subcommand_uses_slim_binary(subcommand: &str) -> bool {
13 HOOK_COMMANDS.contains(&subcommand)
14}
15
16pub fn is_full_remem_binary(path: &Path) -> bool {
17 binary_stem_eq(path, "remem")
18}
19
20pub fn is_hook_binary(path: &Path) -> bool {
21 binary_stem_eq(path, "remem-hook")
22}
23
24fn binary_stem_eq(path: &Path, expected: &str) -> bool {
25 path.file_stem()
26 .and_then(|stem| stem.to_str())
27 .is_some_and(|stem| stem == expected)
28}
29
30fn platform_binary_name(stem: &str) -> String {
31 if cfg!(windows) {
32 format!("{stem}.exe")
33 } else {
34 stem.to_string()
35 }
36}
37
38fn sibling_named_path(bin: &Path, stem: &str) -> Option<PathBuf> {
39 Some(bin.parent()?.join(platform_binary_name(stem)))
40}
41
42fn sibling_named_binary(bin: &Path, stem: &str) -> Option<PathBuf> {
43 let candidate = sibling_named_path(bin, stem)?;
44 executable_file(&candidate).then_some(candidate)
45}
46
47fn executable_file(path: &Path) -> bool {
48 let Ok(metadata) = std::fs::metadata(path) else {
49 return false;
50 };
51 if !metadata.is_file() {
52 return false;
53 }
54 #[cfg(unix)]
55 {
56 use std::os::unix::fs::PermissionsExt;
57 metadata.permissions().mode() & 0o111 != 0
58 }
59 #[cfg(not(unix))]
60 {
61 true
62 }
63}
64
65pub fn sibling_hook_binary(remem_bin: &Path) -> Option<PathBuf> {
66 sibling_named_binary(remem_bin, "remem-hook")
67}
68
69pub fn sibling_full_binary(hook_bin: &Path) -> Option<PathBuf> {
70 sibling_named_binary(hook_bin, "remem")
71}
72
73pub fn sibling_full_binary_path(hook_bin: &Path) -> Option<PathBuf> {
74 sibling_named_path(hook_bin, "remem")
75}
76
77pub fn hook_invocation_binary(remem_bin: &Path, subcommand: &str) -> PathBuf {
78 if hook_subcommand_uses_slim_binary(subcommand) {
79 if let Some(hook) = sibling_hook_binary(remem_bin) {
80 return hook;
81 }
82 }
83 remem_bin.to_path_buf()
84}
85
86pub fn hook_executable_is_allowed(
87 invocation: &Path,
88 expected_remem: &Path,
89 subcommand: &str,
90) -> bool {
91 if invocation == expected_remem {
92 return !is_hook_binary(invocation) || executable_file(invocation);
93 }
94 hook_subcommand_uses_slim_binary(subcommand)
95 && sibling_hook_binary(expected_remem).as_deref() == Some(invocation)
96}
97
98pub fn preferred_expected_hook_executable<S: AsRef<str>>(paths: &[S]) -> Option<String> {
101 if paths.is_empty() {
102 return None;
103 }
104 if let Some(full) = paths
105 .iter()
106 .find(|path| is_full_remem_binary(Path::new(path.as_ref())))
107 {
108 return Some(full.as_ref().to_string());
109 }
110 for path in paths {
111 let hook = Path::new(path.as_ref());
112 if is_hook_binary(hook) {
113 if let Some(full) = sibling_full_binary(hook) {
114 return Some(full.to_string_lossy().into_owned());
115 }
116 }
117 }
118 Some(paths[0].as_ref().to_string())
119}
120
121#[derive(Parser)]
122#[command(
123 name = "remem-hook",
124 about = "Slim remem host-hook entry (context, session-init, observe, summarize)"
125)]
126struct HookCli {
127 #[command(subcommand)]
128 command: HookCommand,
129}
130
131#[derive(Subcommand)]
132enum HookCommand {
133 Context {
134 #[arg(long)]
135 cwd: Option<String>,
136 #[arg(long)]
137 session_id: Option<String>,
138 #[arg(long)]
139 host: Option<String>,
140 #[arg(long)]
141 color: bool,
142 #[arg(long)]
143 debug: bool,
144 #[arg(long)]
145 force: bool,
146 #[arg(long, value_name = "off|auto|strict|delta")]
147 gate: Option<String>,
148 },
149 SessionInit {
150 #[arg(long)]
151 host: Option<String>,
152 },
153 Observe {
154 #[arg(long)]
155 host: Option<String>,
156 },
157 Summarize {
158 #[arg(long)]
159 host: Option<String>,
160 #[arg(long)]
161 profile: Option<String>,
162 },
163}
164
165pub async fn run() -> Result<()> {
166 crate::hook_runtime::enter_hook_runtime_mode();
167 match HookCli::parse().command {
168 HookCommand::Context {
169 cwd,
170 session_id,
171 host,
172 color,
173 debug,
174 force,
175 gate,
176 } => run_context(cwd, session_id, host, color, debug, force, gate).await,
177 HookCommand::SessionInit { host } => run_session_init(host).await,
178 HookCommand::Observe { host } => run_observe(host).await,
179 HookCommand::Summarize { host, profile } => run_summarize(host, profile).await,
180 }
181}
182
183pub(crate) async fn run_context(
184 cwd: Option<String>,
185 session_id: Option<String>,
186 host: Option<String>,
187 color: bool,
188 debug: bool,
189 force: bool,
190 gate: Option<String>,
191) -> Result<()> {
192 if remem_hooks_disabled() {
193 return Ok(());
194 }
195 match parse_explicit_hook_host(host.as_deref())? {
196 Some(crate::identity::InstallHost::Cursor) => context::generate_cursor_context_from_stdin(),
197 _ => context::generate_context_from_cli(cwd, session_id, color, host, debug, force, gate),
198 }
199}
200
201pub(crate) async fn run_session_init(host: Option<String>) -> Result<()> {
202 if remem_hooks_disabled() {
203 return Ok(());
204 }
205 if matches!(
206 parse_explicit_hook_host(host.as_deref())?,
207 Some(crate::identity::InstallHost::Cursor)
208 ) {
209 anyhow::bail!(
210 "session-init is not supported on --host cursor; \
211 Cursor beforeSubmitPrompt is permit/block-only (GH-823 B-006)"
212 );
213 }
214 observe::session_init(host.as_deref()).await
215}
216
217pub(crate) async fn run_observe(host: Option<String>) -> Result<()> {
218 if remem_hooks_disabled() {
219 return Ok(());
220 }
221 match parse_explicit_hook_host(host.as_deref())? {
222 Some(crate::identity::InstallHost::Cursor) => observe::observe_cursor().await,
223 _ => observe::observe(host.as_deref()).await,
224 }
225}
226
227pub(crate) async fn run_summarize(host: Option<String>, profile: Option<String>) -> Result<()> {
228 if remem_hooks_disabled() {
229 return Ok(());
230 }
231 match parse_explicit_hook_host(host.as_deref())? {
232 Some(crate::identity::InstallHost::Cursor) => summarize::summarize_cursor().await,
233 _ => summarize::summarize(host.as_deref(), profile.as_deref()).await,
234 }
235}
236
237pub(crate) fn parse_explicit_hook_host(
238 host: Option<&str>,
239) -> Result<Option<crate::identity::InstallHost>> {
240 host.map(crate::identity::InstallHost::parse).transpose()
241}
242
243pub(crate) fn remem_hooks_disabled() -> bool {
244 std::env::var("REMEM_DISABLE_HOOKS")
245 .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
246 .unwrap_or(false)
247}
248
249#[cfg(test)]
250mod tests {
251 use clap::Parser;
252
253 use super::{HookCli, HookCommand, HOOK_COMMANDS};
254
255 fn make_executable(path: &std::path::Path) {
256 #[cfg(unix)]
257 {
258 use std::os::unix::fs::PermissionsExt;
259 let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
260 permissions.set_mode(0o755);
261 std::fs::set_permissions(path, permissions).expect("set executable mode");
262 }
263 }
264
265 #[test]
266 fn hook_command_names_are_stable() {
267 assert_eq!(
268 HOOK_COMMANDS,
269 ["context", "session-init", "observe", "summarize"]
270 );
271 }
272
273 #[test]
274 fn parses_observe_host() {
275 let cli = HookCli::try_parse_from(["remem-hook", "observe", "--host", "codex-cli"])
276 .expect("observe should parse");
277 match cli.command {
278 HookCommand::Observe { host } => assert_eq!(host.as_deref(), Some("codex-cli")),
279 _ => panic!("expected observe"),
280 }
281 }
282
283 #[test]
284 fn sibling_hook_binary_used_only_for_slim_commands() {
285 let dir = std::env::temp_dir().join(format!(
286 "remem-hook-sibling-{}-{}",
287 std::process::id(),
288 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
289 ));
290 std::fs::create_dir_all(&dir).expect("temp dir");
291 let remem = dir.join("remem");
292 let hook = dir.join("remem-hook");
293 std::fs::write(&remem, []).expect("touch remem");
294 std::fs::write(&hook, []).expect("touch remem-hook");
295 make_executable(&remem);
296 make_executable(&hook);
297
298 assert_eq!(
299 super::sibling_hook_binary(&remem).as_deref(),
300 Some(hook.as_path())
301 );
302 assert_eq!(
303 super::sibling_full_binary(&hook).as_deref(),
304 Some(remem.as_path())
305 );
306 assert_eq!(super::hook_invocation_binary(&remem, "observe"), hook);
307 assert_eq!(super::hook_invocation_binary(&remem, "rules"), remem);
308 assert!(super::hook_executable_is_allowed(&hook, &remem, "context"));
309 assert!(!super::hook_executable_is_allowed(&hook, &remem, "rules"));
310 assert_eq!(
311 super::preferred_expected_hook_executable(&[
312 hook.to_string_lossy().into_owned(),
313 remem.to_string_lossy().into_owned(),
314 ])
315 .as_deref(),
316 remem.to_str()
317 );
318
319 let _ = std::fs::remove_dir_all(&dir);
320 }
321
322 #[cfg(unix)]
323 #[test]
324 fn non_executable_sibling_hook_is_not_selected_or_allowed() {
325 let dir = std::env::temp_dir().join(format!(
326 "remem-hook-non-executable-{}-{}",
327 std::process::id(),
328 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
329 ));
330 std::fs::create_dir_all(&dir).expect("temp dir");
331 let remem = dir.join("remem");
332 let hook = dir.join("remem-hook");
333 std::fs::write(&remem, []).expect("touch remem");
334 std::fs::write(&hook, []).expect("touch remem-hook");
335 make_executable(&remem);
336
337 assert_eq!(super::sibling_hook_binary(&remem), None);
338 assert_eq!(super::hook_invocation_binary(&remem, "observe"), remem);
339 assert!(!super::hook_executable_is_allowed(&hook, &hook, "observe"));
340
341 let _ = std::fs::remove_dir_all(&dir);
342 }
343
344 #[test]
345 fn rejects_full_binary_commands() {
346 for args in [
347 ["remem-hook", "worker"].as_slice(),
348 ["remem-hook", "eval"].as_slice(),
349 ["remem-hook", "mcp"].as_slice(),
350 ] {
351 assert!(
352 HookCli::try_parse_from(args).is_err(),
353 "expected rejection for {args:?}"
354 );
355 }
356 }
357}