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 Agent::Grok => "grok",
32 }
33}
34
35pub fn surface_relative_path(agent: Agent) -> &'static str {
38 match agent {
39 Agent::Claude => ".claude/settings.json",
40 Agent::Codex => ".codex/hooks.json",
41 Agent::Pi => ".pi/settings.json",
43 Agent::Grok => ".grok/hooks/truth-mirror.json",
45 }
46}
47
48pub const GROK_SKILL_RELATIVE: &str = ".grok/skills/truth-mirror/SKILL.md";
50
51pub const GROK_SKILL_MANAGED_MARKER: &str = "<!-- truth-mirror managed grok skill -->";
54
55pub const GROK_SKILL_SOURCE: &str = r#"---
58name: truth-mirror
59description: Use at session start, before completion claims, before push, and whenever truth-mirror ledger rejections or memory-skill candidates may be unresolved. Run `truth-mirror reinject --agent grok` and treat non-empty output as mandatory work.
60---
61
62<!-- truth-mirror managed grok skill -->
63
64# Truth Mirror Unresolved Findings (Grok)
65
66Grok Build does **not** inject `UserPromptSubmit` (or other passive) hook stdout into the model context. Reinjection is active, not hook-stdout:
67
68```bash
69truth-mirror reinject --agent grok
70```
71
72If the command prints anything, address or waive those findings before claiming completion or pushing. Do not ignore empty success as "all clear" without having run the command in this session when ledger state may have changed.
73
74Optional status:
75
76```bash
77truth-mirror ledger list
78truth-mirror status
79```
80"#;
81
82pub fn grok_skill_path(repo_root: &Path) -> PathBuf {
84 repo_root.join(GROK_SKILL_RELATIVE)
85}
86
87pub fn grok_skill_installed(repo_root: &Path) -> bool {
89 let path = grok_skill_path(repo_root);
90 fs::read_to_string(path)
91 .map(|body| body.contains(GROK_SKILL_MANAGED_MARKER))
92 .unwrap_or(false)
93}
94
95pub fn install_grok_skill(repo_root: &Path) -> Result<()> {
97 let path = grok_skill_path(repo_root);
98 reject_symlink_path(repo_root, &path)?;
99 if path.exists() {
100 let meta = fs::symlink_metadata(&path)
101 .with_context(|| format!("statting grok skill {}", path.display()))?;
102 if !meta.is_file() {
103 anyhow::bail!(
104 "refusing to install Grok skill at {} (not a regular file)",
105 path.display()
106 );
107 }
108 let existing = fs::read_to_string(&path)
109 .with_context(|| format!("reading existing grok skill {}", path.display()))?;
110 if !existing.contains(GROK_SKILL_MANAGED_MARKER) {
111 anyhow::bail!(
112 "refusing to overwrite foreign Grok skill at {} (missing managed marker); move it aside or pass force via manual edit",
113 path.display()
114 );
115 }
116 }
117 if let Some(parent) = path.parent() {
118 fs::create_dir_all(parent)
119 .with_context(|| format!("creating grok skills dir {}", parent.display()))?;
120 }
121 reject_symlink_path(repo_root, &path)?;
123 fs::write(&path, GROK_SKILL_SOURCE)
124 .with_context(|| format!("writing grok skill {}", path.display()))?;
125 Ok(())
126}
127
128fn reject_symlink_path(repo_root: &Path, path: &Path) -> Result<()> {
129 let relative = path.strip_prefix(repo_root).unwrap_or(path);
134 let mut prefix = repo_root.to_path_buf();
135 for component in relative.components() {
136 prefix.push(component);
137 match fs::symlink_metadata(&prefix) {
138 Ok(meta) if meta.file_type().is_symlink() => anyhow::bail!(
139 "refusing to use Grok skill path {} (symlink at {}; possible write-outside-repo attack)",
140 path.display(),
141 prefix.display()
142 ),
143 Ok(_) | Err(_) => {}
144 }
145 }
146 Ok(())
147}
148
149pub fn uninstall_grok_skill(repo_root: &Path) -> Result<()> {
151 let path = grok_skill_path(repo_root);
152 if path.is_file() {
153 let existing = fs::read_to_string(&path)
154 .with_context(|| format!("reading grok skill {}", path.display()))?;
155 if !existing.contains(GROK_SKILL_MANAGED_MARKER) {
156 return Ok(());
158 }
159 fs::remove_file(&path)
160 .with_context(|| format!("removing grok skill {}", path.display()))?;
161 }
162 let _ = fs::remove_dir(repo_root.join(".grok/skills/truth-mirror"));
164 let _ = fs::remove_dir(repo_root.join(".grok/skills"));
165 let _ = fs::remove_dir(repo_root.join(".grok"));
166 Ok(())
167}
168
169pub fn cleanup_empty_grok_dirs(repo_root: &Path) {
171 let _ = fs::remove_dir(repo_root.join(".grok/hooks"));
172 let _ = fs::remove_dir(repo_root.join(".grok"));
173}
174
175pub fn reinject_command(agent: Agent) -> String {
177 format!("truth-mirror reinject --agent {}", agent_slug(agent))
178}
179
180pub const PI_EXTENSION_RELATIVE: &str = ".pi/extensions/truth-mirror.js";
182
183pub fn pi_extension_path(repo_root: &Path) -> PathBuf {
189 repo_root.join(PI_EXTENSION_RELATIVE)
190}
191
192pub const PI_EXTENSION_SOURCE: &str = r#"// truth-mirror Pi reinjection extension.
197// Auto-generated by `truth-mirror install-hooks --pi`. Pi auto-loads this file
198// from <repo>/.pi/extensions/ once the project folder is trusted.
199import { execFile } from "node:child_process";
200import { promisify } from "node:util";
201
202const run = promisify(execFile);
203
204export default function truthMirror(pi) {
205 let lastInjected = "";
206 pi.on("context", async (event) => {
207 let text = "";
208 try {
209 const { stdout } = await run("truth-mirror", ["reinject", "--agent", "pi"], {
210 cwd: process.cwd(),
211 });
212 text = (stdout || "").trim();
213 } catch {
214 return; // truth-mirror missing or errored: stay silent.
215 }
216 // `context` fires before every LLM call; dedup so findings inject once per change.
217 if (!text || text === lastInjected) return;
218 lastInjected = text;
219 return {
220 messages: [
221 ...event.messages,
222 { role: "user", content: [{ type: "text", text }] },
223 ],
224 };
225 });
226}
227"#;
228
229pub fn install_pi_extension(repo_root: &Path) -> Result<()> {
231 let path = pi_extension_path(repo_root);
232 if let Some(parent) = path.parent() {
233 fs::create_dir_all(parent)
234 .with_context(|| format!("creating pi extensions dir {}", parent.display()))?;
235 }
236 fs::write(&path, PI_EXTENSION_SOURCE)
237 .with_context(|| format!("writing pi extension {}", path.display()))?;
238 Ok(())
239}
240
241pub fn uninstall_pi_extension(repo_root: &Path) -> Result<()> {
243 let path = pi_extension_path(repo_root);
244 if path.is_file() {
245 fs::remove_file(&path)
246 .with_context(|| format!("removing pi extension {}", path.display()))?;
247 }
248 Ok(())
249}
250
251pub const ENFORCE_COMMAND: &str = "gate --pre-tool-use";
255
256fn enforce_command(global_args: &str) -> String {
257 format!("truth-mirror {global_args}{ENFORCE_COMMAND}")
258}
259
260pub fn install_enforcement(repo_root: &Path, agent: Agent, global_args: &str) -> Result<()> {
265 debug_assert!(is_nested(agent), "enforcement hook is nested-surface only");
266 let command = enforce_command(global_args);
267 let path = repo_root.join(surface_relative_path(agent));
268 let mut root = read_object(&path)?;
269 remove_own_enforcement(&mut root, "PreToolUse");
272 let entries = event_array_mut(&mut root, "PreToolUse");
273 entries.push(json!({ "hooks": [ { "type": "command", "command": command } ] }));
274 write_object(&path, &root)
275}
276
277pub fn uninstall_enforcement(repo_root: &Path, agent: Agent) -> Result<()> {
279 let path = repo_root.join(surface_relative_path(agent));
280 if !path.exists() {
281 return Ok(());
282 }
283 let mut root = read_object(&path)?;
284 remove_own_enforcement(&mut root, "PreToolUse");
285 if root.is_empty() {
286 fs::remove_file(&path)
287 .with_context(|| format!("removing empty surface {}", path.display()))?;
288 } else {
289 write_object(&path, &root)?;
290 }
291 Ok(())
292}
293
294fn is_own_enforcement_command(entry: &Value) -> bool {
298 entry
299 .get("command")
300 .and_then(Value::as_str)
301 .is_some_and(|value| {
302 value.split_whitespace().next() == Some("truth-mirror")
303 && value.contains(ENFORCE_COMMAND)
304 })
305}
306
307fn event_array_mut<'a>(root: &'a mut Map<String, Value>, event: &str) -> &'a mut Vec<Value> {
309 let hooks = root
310 .entry("hooks")
311 .or_insert_with(|| Value::Object(Map::new()));
312 if !hooks.is_object() {
313 *hooks = Value::Object(Map::new());
314 }
315 let hooks = hooks.as_object_mut().expect("hooks is object");
316 let entries = hooks
317 .entry(event.to_owned())
318 .or_insert_with(|| Value::Array(Vec::new()));
319 if !entries.is_array() {
320 *entries = Value::Array(Vec::new());
321 }
322 entries.as_array_mut().expect("event is array")
323}
324
325fn remove_own_enforcement(root: &mut Map<String, Value>, event: &str) {
326 let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
327 return;
328 };
329 if let Some(groups) = hooks.get_mut(event).and_then(Value::as_array_mut) {
330 for group in groups.iter_mut() {
331 if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
332 inner.retain(|entry| !is_own_enforcement_command(entry));
335 }
336 }
337 groups.retain(|group| {
338 group
339 .get("hooks")
340 .and_then(Value::as_array)
341 .is_none_or(|inner| !inner.is_empty())
342 });
343 if groups.is_empty() {
344 hooks.remove(event);
345 }
346 }
347 if hooks.is_empty() {
348 root.remove("hooks");
349 }
350}
351
352fn is_nested(agent: Agent) -> bool {
357 matches!(agent, Agent::Claude | Agent::Codex | Agent::Grok)
358}
359
360#[derive(Clone, Debug, Eq, PartialEq)]
361pub struct SurfacePlan {
362 pub agent: Agent,
363 pub path: PathBuf,
364}
365
366impl SurfacePlan {
367 pub fn for_agent(repo_root: &Path, agent: Agent) -> Self {
368 Self {
369 agent,
370 path: repo_root.join(surface_relative_path(agent)),
371 }
372 }
373
374 pub fn install(&self) -> Result<()> {
375 let mut root = read_object(&self.path)?;
376 install_command(self.agent, &mut root, &reinject_command(self.agent));
377 write_object(&self.path, &root)
378 }
379
380 pub fn uninstall(&self) -> Result<()> {
381 if !self.path.exists() {
382 return Ok(());
383 }
384 let mut root = read_object(&self.path)?;
385 remove_command(self.agent, &mut root, &reinject_command(self.agent));
386 if root.is_empty() {
387 fs::remove_file(&self.path)
388 .with_context(|| format!("removing empty surface {}", self.path.display()))?;
389 } else {
390 write_object(&self.path, &root)?;
391 }
392 Ok(())
393 }
394
395 pub fn contains_reinject(&self) -> Result<bool> {
396 if !self.path.exists() {
397 return Ok(false);
398 }
399 let root = read_object(&self.path)?;
400 Ok(surface_contains(
401 self.agent,
402 &root,
403 &reinject_command(self.agent),
404 ))
405 }
406}
407
408fn read_object(path: &Path) -> Result<Map<String, Value>> {
409 match fs::read_to_string(path) {
410 Ok(contents) if contents.trim().is_empty() => Ok(Map::new()),
411 Ok(contents) => {
412 let value: Value = serde_json::from_str(&contents)
413 .with_context(|| format!("parsing existing surface {}", path.display()))?;
414 match value {
415 Value::Object(map) => Ok(map),
416 _ => anyhow::bail!(
417 "surface {} is not a JSON object; refusing to clobber",
418 path.display()
419 ),
420 }
421 }
422 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
423 Err(error) => Err(error).with_context(|| format!("reading surface {}", path.display()))?,
424 }
425}
426
427fn write_object(path: &Path, root: &Map<String, Value>) -> Result<()> {
428 if let Some(parent) = path.parent() {
429 fs::create_dir_all(parent)
430 .with_context(|| format!("creating surface dir {}", parent.display()))?;
431 }
432 let mut serialized = serde_json::to_string_pretty(&Value::Object(root.clone()))?;
433 serialized.push('\n');
434 fs::write(path, serialized).with_context(|| format!("writing surface {}", path.display()))?;
435 Ok(())
436}
437
438fn install_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
439 let entries = user_prompt_submit_mut(agent, root);
440 if array_contains_command(agent, entries, command) {
441 return;
442 }
443 entries.push(surface_entry(agent, command));
444}
445
446fn remove_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
447 if is_nested(agent) {
448 let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
449 return;
450 };
451 if let Some(groups) = hooks
452 .get_mut("UserPromptSubmit")
453 .and_then(Value::as_array_mut)
454 {
455 for group in groups.iter_mut() {
456 if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
457 inner.retain(|entry| !entry_matches_command(entry, command));
458 }
459 }
460 groups.retain(|group| {
461 group
462 .get("hooks")
463 .and_then(Value::as_array)
464 .is_none_or(|inner| !inner.is_empty())
465 });
466 if groups.is_empty() {
467 hooks.remove("UserPromptSubmit");
468 }
469 }
470 if hooks.is_empty() {
471 root.remove("hooks");
472 }
473 } else if let Some(entries) = root
474 .get_mut("UserPromptSubmit")
475 .and_then(Value::as_array_mut)
476 {
477 entries.retain(|entry| !entry_matches_command(entry, command));
478 if entries.is_empty() {
479 root.remove("UserPromptSubmit");
480 }
481 }
482}
483
484fn user_prompt_submit_mut(agent: Agent, root: &mut Map<String, Value>) -> &mut Vec<Value> {
487 if is_nested(agent) {
488 let hooks = root
489 .entry("hooks")
490 .or_insert_with(|| Value::Object(Map::new()));
491 if !hooks.is_object() {
492 *hooks = Value::Object(Map::new());
493 }
494 let hooks = hooks.as_object_mut().expect("hooks is object");
495 let entries = hooks
496 .entry("UserPromptSubmit")
497 .or_insert_with(|| Value::Array(Vec::new()));
498 if !entries.is_array() {
499 *entries = Value::Array(Vec::new());
500 }
501 entries.as_array_mut().expect("UserPromptSubmit is array")
502 } else {
503 let entries = root
504 .entry("UserPromptSubmit")
505 .or_insert_with(|| Value::Array(Vec::new()));
506 if !entries.is_array() {
507 *entries = Value::Array(Vec::new());
508 }
509 entries.as_array_mut().expect("UserPromptSubmit is array")
510 }
511}
512
513fn surface_entry(agent: Agent, command: &str) -> Value {
514 if is_nested(agent) {
515 json!({ "hooks": [ { "type": "command", "command": command } ] })
516 } else {
517 json!({ "command": command })
518 }
519}
520
521fn array_contains_command(agent: Agent, entries: &[Value], command: &str) -> bool {
522 if is_nested(agent) {
523 entries.iter().any(|group| {
524 group
525 .get("hooks")
526 .and_then(Value::as_array)
527 .is_some_and(|inner| inner.iter().any(|e| entry_matches_command(e, command)))
528 })
529 } else {
530 entries.iter().any(|e| entry_matches_command(e, command))
531 }
532}
533
534fn entry_matches_command(entry: &Value, command: &str) -> bool {
535 entry
536 .get("command")
537 .and_then(Value::as_str)
538 .is_some_and(|value| value == command)
539}
540
541pub fn surface_contains(agent: Agent, root: &Map<String, Value>, command: &str) -> bool {
543 if is_nested(agent) {
544 root.get("hooks")
545 .and_then(Value::as_object)
546 .and_then(|hooks| hooks.get("UserPromptSubmit"))
547 .and_then(Value::as_array)
548 .is_some_and(|entries| array_contains_command(agent, entries, command))
549 } else {
550 root.get("UserPromptSubmit")
551 .and_then(Value::as_array)
552 .is_some_and(|entries| array_contains_command(agent, entries, command))
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::{
559 Agent, SurfacePlan, install_command, reinject_command, remove_command, surface_contains,
560 };
561 use proptest::prelude::*;
562 use serde_json::{Map, Value, json};
563
564 fn install_into(agent: Agent, mut root: Map<String, Value>) -> Map<String, Value> {
565 install_command(agent, &mut root, &reinject_command(agent));
566 root
567 }
568
569 #[test]
570 fn claude_surface_uses_nested_user_prompt_submit() {
571 let root = install_into(Agent::Claude, Map::new());
572 let value = Value::Object(root.clone());
573
574 let command = value
575 .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
576 .and_then(Value::as_str)
577 .unwrap();
578 assert_eq!(command, "truth-mirror reinject --agent claude");
579 assert!(surface_contains(
580 Agent::Claude,
581 &root,
582 &reinject_command(Agent::Claude)
583 ));
584 }
585
586 #[test]
587 fn codex_uses_nested_user_prompt_submit_like_claude() {
588 let root = install_into(Agent::Codex, Map::new());
590 let value = Value::Object(root.clone());
591
592 let command = value
593 .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
594 .and_then(Value::as_str)
595 .unwrap();
596 assert_eq!(command, "truth-mirror reinject --agent codex");
597 assert!(surface_contains(
598 Agent::Codex,
599 &root,
600 &reinject_command(Agent::Codex)
601 ));
602 }
603
604 #[test]
605 fn grok_skill_install_round_trips() {
606 let temp = tempfile::tempdir().unwrap();
607 super::install_grok_skill(temp.path()).unwrap();
608 let path = super::grok_skill_path(temp.path());
609 assert!(path.is_file());
610 let body = std::fs::read_to_string(&path).unwrap();
611 assert!(body.contains("truth-mirror reinject --agent grok"));
612 assert!(body.contains(super::GROK_SKILL_MANAGED_MARKER));
613 assert!(super::grok_skill_installed(temp.path()));
614 super::uninstall_grok_skill(temp.path()).unwrap();
615 assert!(!path.is_file());
616 assert!(!super::grok_skill_installed(temp.path()));
617 }
618
619 #[test]
620 fn grok_skill_refuses_foreign_file() {
621 let temp = tempfile::tempdir().unwrap();
622 let path = super::grok_skill_path(temp.path());
623 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
624 std::fs::write(&path, "hand-written skill without marker").unwrap();
625 let err = super::install_grok_skill(temp.path()).unwrap_err();
626 assert!(err.to_string().contains("foreign"));
627 assert!(!super::grok_skill_installed(temp.path()));
628 super::uninstall_grok_skill(temp.path()).unwrap();
629 assert!(path.is_file(), "foreign skill preserved on uninstall");
630 }
631
632 #[cfg(unix)]
633 #[test]
634 fn grok_skill_refuses_symlink_path() {
635 use std::os::unix::fs::symlink;
636 let temp = tempfile::tempdir().unwrap();
637 let outside = temp.path().join("outside.md");
638 std::fs::write(&outside, super::GROK_SKILL_SOURCE).unwrap();
639 let path = super::grok_skill_path(temp.path());
640 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
641 symlink(&outside, &path).unwrap();
642 let err = super::install_grok_skill(temp.path()).unwrap_err();
643 assert!(err.to_string().contains("symlink"));
644 assert_eq!(
645 std::fs::read_to_string(&outside).unwrap(),
646 super::GROK_SKILL_SOURCE,
647 "outside target must not be rewritten"
648 );
649 }
650
651 #[cfg(unix)]
652 #[test]
653 fn grok_skill_refuses_project_ancestor_symlink() {
654 use std::os::unix::fs::symlink;
655 let temp = tempfile::tempdir().unwrap();
656 let outside = tempfile::tempdir().unwrap();
657 symlink(outside.path(), temp.path().join(".grok")).unwrap();
658
659 let err = super::install_grok_skill(temp.path()).unwrap_err();
660
661 assert!(err.to_string().contains("symlink"));
662 assert!(
663 !outside.path().join("skills/truth-mirror/SKILL.md").exists(),
664 "ancestor symlink target must not receive the managed Grok skill"
665 );
666 }
667
668 #[test]
669 fn grok_enforcement_uses_nested_hooks_path() {
670 assert_eq!(
671 super::surface_relative_path(Agent::Grok),
672 ".grok/hooks/truth-mirror.json"
673 );
674 let temp = tempfile::tempdir().unwrap();
675 super::install_enforcement(temp.path(), Agent::Grok, "").unwrap();
676 let content =
677 std::fs::read_to_string(temp.path().join(".grok/hooks/truth-mirror.json")).unwrap();
678 assert!(content.contains("PreToolUse"));
679 assert!(content.contains("truth-mirror gate --pre-tool-use"));
680 assert!(!content.contains("UserPromptSubmit"));
681 super::uninstall_enforcement(temp.path(), Agent::Grok).unwrap();
682 super::cleanup_empty_grok_dirs(temp.path());
683 assert!(!temp.path().join(".grok/hooks/truth-mirror.json").exists());
684 }
685
686 #[test]
687 fn install_is_idempotent() {
688 let mut root = install_into(Agent::Claude, Map::new());
689 install_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
690
691 let count = Value::Object(root)
692 .pointer("/hooks/UserPromptSubmit")
693 .and_then(Value::as_array)
694 .map(Vec::len)
695 .unwrap();
696 assert_eq!(count, 1);
697 }
698
699 #[test]
700 fn install_preserves_foreign_config() {
701 let existing: Map<String, Value> = json!({
702 "model": "sonnet",
703 "hooks": { "PreToolUse": [ { "matcher": "Bash" } ] }
704 })
705 .as_object()
706 .cloned()
707 .unwrap();
708
709 let root = install_into(Agent::Claude, existing);
710 let value = Value::Object(root);
711
712 assert_eq!(
713 value.pointer("/model").and_then(Value::as_str),
714 Some("sonnet")
715 );
716 assert!(value.pointer("/hooks/PreToolUse").is_some());
717 assert!(value.pointer("/hooks/UserPromptSubmit/0").is_some());
718 }
719
720 #[test]
721 fn uninstall_removes_only_truth_mirror_entries() {
722 let existing: Map<String, Value> = json!({
723 "model": "sonnet",
724 "hooks": {
725 "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "other-tool" } ] } ]
726 }
727 })
728 .as_object()
729 .cloned()
730 .unwrap();
731
732 let mut root = install_into(Agent::Claude, existing);
733 remove_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
734 let value = Value::Object(root);
735
736 assert_eq!(
737 value.pointer("/model").and_then(Value::as_str),
738 Some("sonnet")
739 );
740 let commands: Vec<&str> = value
741 .pointer("/hooks/UserPromptSubmit")
742 .and_then(Value::as_array)
743 .unwrap()
744 .iter()
745 .filter_map(|group| group.pointer("/hooks/0/command").and_then(Value::as_str))
746 .collect();
747 assert_eq!(commands, ["other-tool"]);
748 }
749
750 #[test]
751 fn enforcement_hook_installs_and_coexists_with_reinject() {
752 let temp = tempfile::tempdir().unwrap();
753 let plan = SurfacePlan::for_agent(temp.path(), Agent::Claude);
754 plan.install().unwrap(); super::install_enforcement(temp.path(), Agent::Claude, "").unwrap();
756
757 let content = std::fs::read_to_string(&plan.path).unwrap();
758 assert!(content.contains("UserPromptSubmit"));
759 assert!(content.contains("PreToolUse"));
760 assert!(content.contains("truth-mirror gate --pre-tool-use"));
761
762 super::uninstall_enforcement(temp.path(), Agent::Claude).unwrap();
764 let after = std::fs::read_to_string(&plan.path).unwrap();
765 assert!(after.contains("UserPromptSubmit"));
766 assert!(!after.contains("PreToolUse"));
767 }
768
769 #[test]
770 fn reinstalling_enforcement_updates_preserved_flags() {
771 let temp = tempfile::tempdir().unwrap();
772 super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
773 super::install_enforcement(temp.path(), Agent::Codex, "--config '/abs/x.toml' ").unwrap();
775
776 let content = std::fs::read_to_string(temp.path().join(".codex/hooks.json")).unwrap();
777 assert_eq!(content.matches("gate --pre-tool-use").count(), 1);
778 assert!(content.contains("--config '/abs/x.toml'"));
779 }
780
781 #[test]
782 fn enforcement_leaves_foreign_pretooluse_hooks_intact() {
783 let temp = tempfile::tempdir().unwrap();
784 let path = temp.path().join(".codex/hooks.json");
785 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
786 let foreign = "external-auditor gate --pre-tool-use --keep";
788 std::fs::write(
789 &path,
790 format!(
791 "{{\"hooks\":{{\"PreToolUse\":[{{\"hooks\":[{{\"type\":\"command\",\"command\":\"{foreign}\"}}]}}]}}}}"
792 ),
793 )
794 .unwrap();
795
796 super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
797 let after_install = std::fs::read_to_string(&path).unwrap();
798 assert!(
799 after_install.contains(foreign),
800 "foreign hook survives install"
801 );
802 assert!(after_install.contains("truth-mirror gate --pre-tool-use"));
803
804 super::uninstall_enforcement(temp.path(), Agent::Codex).unwrap();
805 let after_uninstall = std::fs::read_to_string(&path).unwrap();
806 assert!(
807 after_uninstall.contains(foreign),
808 "foreign hook survives uninstall"
809 );
810 assert!(!after_uninstall.contains("truth-mirror gate --pre-tool-use"));
811 }
812
813 #[test]
814 fn enforcement_round_trips_for_codex() {
815 let temp = tempfile::tempdir().unwrap();
816 super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
817 assert!(
818 std::fs::read_to_string(temp.path().join(".codex/hooks.json"))
819 .unwrap()
820 .contains("truth-mirror gate --pre-tool-use")
821 );
822 super::uninstall_enforcement(temp.path(), Agent::Codex).unwrap();
823 assert!(!temp.path().join(".codex/hooks.json").exists());
824 }
825
826 #[test]
827 fn install_then_uninstall_on_disk_round_trips() {
828 let temp = tempfile::tempdir().unwrap();
829 for agent in super::FILE_SURFACE_AGENTS {
830 let plan = SurfacePlan::for_agent(temp.path(), agent);
831 plan.install().unwrap();
832 assert!(plan.contains_reinject().unwrap());
833 plan.uninstall().unwrap();
834 assert!(!plan.contains_reinject().unwrap());
835 assert!(!plan.path.exists());
836 }
837 }
838
839 proptest! {
840 #[test]
841 fn foreign_keys_survive_install_uninstall(
842 key in "[a-z]{1,8}",
843 val in "[a-z0-9]{1,8}",
844 ) {
845 prop_assume!(key != "hooks" && key != "UserPromptSubmit");
846 let existing: Map<String, Value> = json!({ key.clone(): val.clone() })
847 .as_object()
848 .cloned()
849 .unwrap();
850
851 let mut root = existing.clone();
852 install_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
853 prop_assert!(surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
854
855 remove_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
856 prop_assert!(!surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
857 prop_assert_eq!(root.get(&key).and_then(Value::as_str), Some(val.as_str()));
858 }
859 }
860}