1use std::{
9 fs,
10 path::{Path, PathBuf},
11};
12
13use anyhow::{Context, Result};
14use serde_json::{Map, Value, json};
15
16use crate::cli::Agent;
17
18pub const FILE_SURFACE_AGENTS: [Agent; 2] = [Agent::Claude, Agent::Codex];
25
26pub fn agent_slug(agent: Agent) -> &'static str {
27 match agent {
28 Agent::Claude => "claude",
29 Agent::Codex => "codex",
30 Agent::Pi => "pi",
31 }
32}
33
34pub fn surface_relative_path(agent: Agent) -> &'static str {
37 match agent {
38 Agent::Claude => ".claude/settings.json",
39 Agent::Codex => ".codex/hooks.json",
40 Agent::Pi => ".pi/settings.json",
42 }
43}
44
45pub fn reinject_command(agent: Agent) -> String {
47 format!("truth-mirror reinject --agent {}", agent_slug(agent))
48}
49
50pub const PI_EXTENSION_RELATIVE: &str = ".pi/extensions/truth-mirror.js";
52
53pub fn pi_extension_path(repo_root: &Path) -> PathBuf {
59 repo_root.join(PI_EXTENSION_RELATIVE)
60}
61
62pub const PI_EXTENSION_SOURCE: &str = r#"// truth-mirror Pi reinjection extension.
67// Auto-generated by `truth-mirror install-hooks --pi`. Pi auto-loads this file
68// from <repo>/.pi/extensions/ once the project folder is trusted.
69import { execFile } from "node:child_process";
70import { promisify } from "node:util";
71
72const run = promisify(execFile);
73
74export default function truthMirror(pi) {
75 let lastInjected = "";
76 pi.on("context", async (event) => {
77 let text = "";
78 try {
79 const { stdout } = await run("truth-mirror", ["reinject", "--agent", "pi"], {
80 cwd: process.cwd(),
81 });
82 text = (stdout || "").trim();
83 } catch {
84 return; // truth-mirror missing or errored: stay silent.
85 }
86 // `context` fires before every LLM call; dedup so findings inject once per change.
87 if (!text || text === lastInjected) return;
88 lastInjected = text;
89 return {
90 messages: [
91 ...event.messages,
92 { role: "user", content: [{ type: "text", text }] },
93 ],
94 };
95 });
96}
97"#;
98
99pub fn install_pi_extension(repo_root: &Path) -> Result<()> {
101 let path = pi_extension_path(repo_root);
102 if let Some(parent) = path.parent() {
103 fs::create_dir_all(parent)
104 .with_context(|| format!("creating pi extensions dir {}", parent.display()))?;
105 }
106 fs::write(&path, PI_EXTENSION_SOURCE)
107 .with_context(|| format!("writing pi extension {}", path.display()))?;
108 Ok(())
109}
110
111pub fn uninstall_pi_extension(repo_root: &Path) -> Result<()> {
113 let path = pi_extension_path(repo_root);
114 if path.is_file() {
115 fs::remove_file(&path)
116 .with_context(|| format!("removing pi extension {}", path.display()))?;
117 }
118 Ok(())
119}
120
121pub const ENFORCE_COMMAND: &str = "gate --pre-tool-use";
125
126fn enforce_command(global_args: &str) -> String {
127 format!("truth-mirror {global_args}{ENFORCE_COMMAND}")
128}
129
130pub fn install_enforcement(repo_root: &Path, agent: Agent, global_args: &str) -> Result<()> {
135 debug_assert!(is_nested(agent), "enforcement hook is nested-surface only");
136 let command = enforce_command(global_args);
137 let path = repo_root.join(surface_relative_path(agent));
138 let mut root = read_object(&path)?;
139 remove_own_enforcement(&mut root, "PreToolUse");
142 let entries = event_array_mut(&mut root, "PreToolUse");
143 entries.push(json!({ "hooks": [ { "type": "command", "command": command } ] }));
144 write_object(&path, &root)
145}
146
147pub fn uninstall_enforcement(repo_root: &Path, agent: Agent) -> Result<()> {
149 let path = repo_root.join(surface_relative_path(agent));
150 if !path.exists() {
151 return Ok(());
152 }
153 let mut root = read_object(&path)?;
154 remove_own_enforcement(&mut root, "PreToolUse");
155 if root.is_empty() {
156 fs::remove_file(&path)
157 .with_context(|| format!("removing empty surface {}", path.display()))?;
158 } else {
159 write_object(&path, &root)?;
160 }
161 Ok(())
162}
163
164fn is_own_enforcement_command(entry: &Value) -> bool {
168 entry
169 .get("command")
170 .and_then(Value::as_str)
171 .is_some_and(|value| {
172 value.split_whitespace().next() == Some("truth-mirror")
173 && value.contains(ENFORCE_COMMAND)
174 })
175}
176
177fn event_array_mut<'a>(root: &'a mut Map<String, Value>, event: &str) -> &'a mut Vec<Value> {
179 let hooks = root
180 .entry("hooks")
181 .or_insert_with(|| Value::Object(Map::new()));
182 if !hooks.is_object() {
183 *hooks = Value::Object(Map::new());
184 }
185 let hooks = hooks.as_object_mut().expect("hooks is object");
186 let entries = hooks
187 .entry(event.to_owned())
188 .or_insert_with(|| Value::Array(Vec::new()));
189 if !entries.is_array() {
190 *entries = Value::Array(Vec::new());
191 }
192 entries.as_array_mut().expect("event is array")
193}
194
195fn remove_own_enforcement(root: &mut Map<String, Value>, event: &str) {
196 let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
197 return;
198 };
199 if let Some(groups) = hooks.get_mut(event).and_then(Value::as_array_mut) {
200 for group in groups.iter_mut() {
201 if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
202 inner.retain(|entry| !is_own_enforcement_command(entry));
205 }
206 }
207 groups.retain(|group| {
208 group
209 .get("hooks")
210 .and_then(Value::as_array)
211 .is_none_or(|inner| !inner.is_empty())
212 });
213 if groups.is_empty() {
214 hooks.remove(event);
215 }
216 }
217 if hooks.is_empty() {
218 root.remove("hooks");
219 }
220}
221
222fn is_nested(agent: Agent) -> bool {
226 matches!(agent, Agent::Claude | Agent::Codex)
227}
228
229#[derive(Clone, Debug, Eq, PartialEq)]
230pub struct SurfacePlan {
231 pub agent: Agent,
232 pub path: PathBuf,
233}
234
235impl SurfacePlan {
236 pub fn for_agent(repo_root: &Path, agent: Agent) -> Self {
237 Self {
238 agent,
239 path: repo_root.join(surface_relative_path(agent)),
240 }
241 }
242
243 pub fn install(&self) -> Result<()> {
244 let mut root = read_object(&self.path)?;
245 install_command(self.agent, &mut root, &reinject_command(self.agent));
246 write_object(&self.path, &root)
247 }
248
249 pub fn uninstall(&self) -> Result<()> {
250 if !self.path.exists() {
251 return Ok(());
252 }
253 let mut root = read_object(&self.path)?;
254 remove_command(self.agent, &mut root, &reinject_command(self.agent));
255 if root.is_empty() {
256 fs::remove_file(&self.path)
257 .with_context(|| format!("removing empty surface {}", self.path.display()))?;
258 } else {
259 write_object(&self.path, &root)?;
260 }
261 Ok(())
262 }
263
264 pub fn contains_reinject(&self) -> Result<bool> {
265 if !self.path.exists() {
266 return Ok(false);
267 }
268 let root = read_object(&self.path)?;
269 Ok(surface_contains(
270 self.agent,
271 &root,
272 &reinject_command(self.agent),
273 ))
274 }
275}
276
277fn read_object(path: &Path) -> Result<Map<String, Value>> {
278 match fs::read_to_string(path) {
279 Ok(contents) if contents.trim().is_empty() => Ok(Map::new()),
280 Ok(contents) => {
281 let value: Value = serde_json::from_str(&contents)
282 .with_context(|| format!("parsing existing surface {}", path.display()))?;
283 match value {
284 Value::Object(map) => Ok(map),
285 _ => anyhow::bail!(
286 "surface {} is not a JSON object; refusing to clobber",
287 path.display()
288 ),
289 }
290 }
291 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
292 Err(error) => Err(error).with_context(|| format!("reading surface {}", path.display()))?,
293 }
294}
295
296fn write_object(path: &Path, root: &Map<String, Value>) -> Result<()> {
297 if let Some(parent) = path.parent() {
298 fs::create_dir_all(parent)
299 .with_context(|| format!("creating surface dir {}", parent.display()))?;
300 }
301 let mut serialized = serde_json::to_string_pretty(&Value::Object(root.clone()))?;
302 serialized.push('\n');
303 fs::write(path, serialized).with_context(|| format!("writing surface {}", path.display()))?;
304 Ok(())
305}
306
307fn install_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
308 let entries = user_prompt_submit_mut(agent, root);
309 if array_contains_command(agent, entries, command) {
310 return;
311 }
312 entries.push(surface_entry(agent, command));
313}
314
315fn remove_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
316 if is_nested(agent) {
317 let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
318 return;
319 };
320 if let Some(groups) = hooks
321 .get_mut("UserPromptSubmit")
322 .and_then(Value::as_array_mut)
323 {
324 for group in groups.iter_mut() {
325 if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
326 inner.retain(|entry| !entry_matches_command(entry, command));
327 }
328 }
329 groups.retain(|group| {
330 group
331 .get("hooks")
332 .and_then(Value::as_array)
333 .is_none_or(|inner| !inner.is_empty())
334 });
335 if groups.is_empty() {
336 hooks.remove("UserPromptSubmit");
337 }
338 }
339 if hooks.is_empty() {
340 root.remove("hooks");
341 }
342 } else if let Some(entries) = root
343 .get_mut("UserPromptSubmit")
344 .and_then(Value::as_array_mut)
345 {
346 entries.retain(|entry| !entry_matches_command(entry, command));
347 if entries.is_empty() {
348 root.remove("UserPromptSubmit");
349 }
350 }
351}
352
353fn user_prompt_submit_mut(agent: Agent, root: &mut Map<String, Value>) -> &mut Vec<Value> {
356 if is_nested(agent) {
357 let hooks = root
358 .entry("hooks")
359 .or_insert_with(|| Value::Object(Map::new()));
360 if !hooks.is_object() {
361 *hooks = Value::Object(Map::new());
362 }
363 let hooks = hooks.as_object_mut().expect("hooks is object");
364 let entries = hooks
365 .entry("UserPromptSubmit")
366 .or_insert_with(|| Value::Array(Vec::new()));
367 if !entries.is_array() {
368 *entries = Value::Array(Vec::new());
369 }
370 entries.as_array_mut().expect("UserPromptSubmit is array")
371 } else {
372 let entries = root
373 .entry("UserPromptSubmit")
374 .or_insert_with(|| Value::Array(Vec::new()));
375 if !entries.is_array() {
376 *entries = Value::Array(Vec::new());
377 }
378 entries.as_array_mut().expect("UserPromptSubmit is array")
379 }
380}
381
382fn surface_entry(agent: Agent, command: &str) -> Value {
383 if is_nested(agent) {
384 json!({ "hooks": [ { "type": "command", "command": command } ] })
385 } else {
386 json!({ "command": command })
387 }
388}
389
390fn array_contains_command(agent: Agent, entries: &[Value], command: &str) -> bool {
391 if is_nested(agent) {
392 entries.iter().any(|group| {
393 group
394 .get("hooks")
395 .and_then(Value::as_array)
396 .is_some_and(|inner| inner.iter().any(|e| entry_matches_command(e, command)))
397 })
398 } else {
399 entries.iter().any(|e| entry_matches_command(e, command))
400 }
401}
402
403fn entry_matches_command(entry: &Value, command: &str) -> bool {
404 entry
405 .get("command")
406 .and_then(Value::as_str)
407 .is_some_and(|value| value == command)
408}
409
410pub fn surface_contains(agent: Agent, root: &Map<String, Value>, command: &str) -> bool {
412 if is_nested(agent) {
413 root.get("hooks")
414 .and_then(Value::as_object)
415 .and_then(|hooks| hooks.get("UserPromptSubmit"))
416 .and_then(Value::as_array)
417 .is_some_and(|entries| array_contains_command(agent, entries, command))
418 } else {
419 root.get("UserPromptSubmit")
420 .and_then(Value::as_array)
421 .is_some_and(|entries| array_contains_command(agent, entries, command))
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::{
428 Agent, SurfacePlan, install_command, reinject_command, remove_command, surface_contains,
429 };
430 use proptest::prelude::*;
431 use serde_json::{Map, Value, json};
432
433 fn install_into(agent: Agent, mut root: Map<String, Value>) -> Map<String, Value> {
434 install_command(agent, &mut root, &reinject_command(agent));
435 root
436 }
437
438 #[test]
439 fn claude_surface_uses_nested_user_prompt_submit() {
440 let root = install_into(Agent::Claude, Map::new());
441 let value = Value::Object(root.clone());
442
443 let command = value
444 .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
445 .and_then(Value::as_str)
446 .unwrap();
447 assert_eq!(command, "truth-mirror reinject --agent claude");
448 assert!(surface_contains(
449 Agent::Claude,
450 &root,
451 &reinject_command(Agent::Claude)
452 ));
453 }
454
455 #[test]
456 fn codex_uses_nested_user_prompt_submit_like_claude() {
457 let root = install_into(Agent::Codex, Map::new());
459 let value = Value::Object(root.clone());
460
461 let command = value
462 .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
463 .and_then(Value::as_str)
464 .unwrap();
465 assert_eq!(command, "truth-mirror reinject --agent codex");
466 assert!(surface_contains(
467 Agent::Codex,
468 &root,
469 &reinject_command(Agent::Codex)
470 ));
471 }
472
473 #[test]
474 fn install_is_idempotent() {
475 let mut root = install_into(Agent::Claude, Map::new());
476 install_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
477
478 let count = Value::Object(root)
479 .pointer("/hooks/UserPromptSubmit")
480 .and_then(Value::as_array)
481 .map(Vec::len)
482 .unwrap();
483 assert_eq!(count, 1);
484 }
485
486 #[test]
487 fn install_preserves_foreign_config() {
488 let existing: Map<String, Value> = json!({
489 "model": "sonnet",
490 "hooks": { "PreToolUse": [ { "matcher": "Bash" } ] }
491 })
492 .as_object()
493 .cloned()
494 .unwrap();
495
496 let root = install_into(Agent::Claude, existing);
497 let value = Value::Object(root);
498
499 assert_eq!(
500 value.pointer("/model").and_then(Value::as_str),
501 Some("sonnet")
502 );
503 assert!(value.pointer("/hooks/PreToolUse").is_some());
504 assert!(value.pointer("/hooks/UserPromptSubmit/0").is_some());
505 }
506
507 #[test]
508 fn uninstall_removes_only_truth_mirror_entries() {
509 let existing: Map<String, Value> = json!({
510 "model": "sonnet",
511 "hooks": {
512 "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "other-tool" } ] } ]
513 }
514 })
515 .as_object()
516 .cloned()
517 .unwrap();
518
519 let mut root = install_into(Agent::Claude, existing);
520 remove_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
521 let value = Value::Object(root);
522
523 assert_eq!(
524 value.pointer("/model").and_then(Value::as_str),
525 Some("sonnet")
526 );
527 let commands: Vec<&str> = value
528 .pointer("/hooks/UserPromptSubmit")
529 .and_then(Value::as_array)
530 .unwrap()
531 .iter()
532 .filter_map(|group| group.pointer("/hooks/0/command").and_then(Value::as_str))
533 .collect();
534 assert_eq!(commands, ["other-tool"]);
535 }
536
537 #[test]
538 fn enforcement_hook_installs_and_coexists_with_reinject() {
539 let temp = tempfile::tempdir().unwrap();
540 let plan = SurfacePlan::for_agent(temp.path(), Agent::Claude);
541 plan.install().unwrap(); super::install_enforcement(temp.path(), Agent::Claude, "").unwrap();
543
544 let content = std::fs::read_to_string(&plan.path).unwrap();
545 assert!(content.contains("UserPromptSubmit"));
546 assert!(content.contains("PreToolUse"));
547 assert!(content.contains("truth-mirror gate --pre-tool-use"));
548
549 super::uninstall_enforcement(temp.path(), Agent::Claude).unwrap();
551 let after = std::fs::read_to_string(&plan.path).unwrap();
552 assert!(after.contains("UserPromptSubmit"));
553 assert!(!after.contains("PreToolUse"));
554 }
555
556 #[test]
557 fn reinstalling_enforcement_updates_preserved_flags() {
558 let temp = tempfile::tempdir().unwrap();
559 super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
560 super::install_enforcement(temp.path(), Agent::Codex, "--config '/abs/x.toml' ").unwrap();
562
563 let content = std::fs::read_to_string(temp.path().join(".codex/hooks.json")).unwrap();
564 assert_eq!(content.matches("gate --pre-tool-use").count(), 1);
565 assert!(content.contains("--config '/abs/x.toml'"));
566 }
567
568 #[test]
569 fn enforcement_leaves_foreign_pretooluse_hooks_intact() {
570 let temp = tempfile::tempdir().unwrap();
571 let path = temp.path().join(".codex/hooks.json");
572 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
573 let foreign = "external-auditor gate --pre-tool-use --keep";
575 std::fs::write(
576 &path,
577 format!(
578 "{{\"hooks\":{{\"PreToolUse\":[{{\"hooks\":[{{\"type\":\"command\",\"command\":\"{foreign}\"}}]}}]}}}}"
579 ),
580 )
581 .unwrap();
582
583 super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
584 let after_install = std::fs::read_to_string(&path).unwrap();
585 assert!(
586 after_install.contains(foreign),
587 "foreign hook survives install"
588 );
589 assert!(after_install.contains("truth-mirror gate --pre-tool-use"));
590
591 super::uninstall_enforcement(temp.path(), Agent::Codex).unwrap();
592 let after_uninstall = std::fs::read_to_string(&path).unwrap();
593 assert!(
594 after_uninstall.contains(foreign),
595 "foreign hook survives uninstall"
596 );
597 assert!(!after_uninstall.contains("truth-mirror gate --pre-tool-use"));
598 }
599
600 #[test]
601 fn enforcement_round_trips_for_codex() {
602 let temp = tempfile::tempdir().unwrap();
603 super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
604 assert!(
605 std::fs::read_to_string(temp.path().join(".codex/hooks.json"))
606 .unwrap()
607 .contains("truth-mirror gate --pre-tool-use")
608 );
609 super::uninstall_enforcement(temp.path(), Agent::Codex).unwrap();
610 assert!(!temp.path().join(".codex/hooks.json").exists());
611 }
612
613 #[test]
614 fn install_then_uninstall_on_disk_round_trips() {
615 let temp = tempfile::tempdir().unwrap();
616 for agent in super::FILE_SURFACE_AGENTS {
617 let plan = SurfacePlan::for_agent(temp.path(), agent);
618 plan.install().unwrap();
619 assert!(plan.contains_reinject().unwrap());
620 plan.uninstall().unwrap();
621 assert!(!plan.contains_reinject().unwrap());
622 assert!(!plan.path.exists());
623 }
624 }
625
626 proptest! {
627 #[test]
628 fn foreign_keys_survive_install_uninstall(
629 key in "[a-z]{1,8}",
630 val in "[a-z0-9]{1,8}",
631 ) {
632 prop_assume!(key != "hooks" && key != "UserPromptSubmit");
633 let existing: Map<String, Value> = json!({ key.clone(): val.clone() })
634 .as_object()
635 .cloned()
636 .unwrap();
637
638 let mut root = existing.clone();
639 install_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
640 prop_assert!(surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
641
642 remove_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
643 prop_assert!(!surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
644 prop_assert_eq!(root.get(&key).and_then(Value::as_str), Some(val.as_str()));
645 }
646 }
647}