Skip to main content

truth_mirror/
surface.rs

1//! Per-agent reinjection surface installation.
2//!
3//! `install-hooks --claude|--codex|--pi|--grok` installs `truth-mirror reinject --agent <agent>`
4//! into each selected agent's repo-local configuration surface. Installs are
5//! non-clobbering (existing config is merged, not overwritten), idempotent, and
6//! reversible: uninstall removes only truth-mirror's own entries.
7
8use 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
18/// Agents whose reinjection surface is a JSON hook file this module manages.
19///
20/// Pi is excluded (JS extension). Grok is excluded from UserPromptSubmit file
21/// surfaces: Grok treats non-`PreToolUse` hooks as passive and ignores stdout
22/// (xAI hooks docs), so Claude-style reinject-via-stdout does not work. Grok
23/// reinjection uses a project skill instead (see [`GROK_SKILL_RELATIVE`]).
24pub 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
35/// Repo-relative path of a file-surface agent's reinjection hook file.
36/// Only meaningful for [`FILE_SURFACE_AGENTS`] (and Grok enforcement hooks).
37pub fn surface_relative_path(agent: Agent) -> &'static str {
38    match agent {
39        Agent::Claude => ".claude/settings.json",
40        Agent::Codex => ".codex/hooks.json",
41        // Pi has no hook file; its real project config is .pi/settings.json packages[].
42        Agent::Pi => ".pi/settings.json",
43        // Grok enforcement-only hook file (PreToolUse). Not used for stdout reinjection.
44        Agent::Grok => ".grok/hooks/truth-mirror.json",
45    }
46}
47
48/// Repo-relative path of the Grok project skill that loads reinjection.
49pub const GROK_SKILL_RELATIVE: &str = ".grok/skills/truth-mirror/SKILL.md";
50
51/// Marker embedded in managed Grok skill content. Required before uninstall or
52/// Claude-under-Grok skip treats the skill as truth-mirror-owned.
53pub const GROK_SKILL_MANAGED_MARKER: &str = "<!-- truth-mirror managed grok skill -->";
54
55/// Grok skill body: Grok does not inject passive-hook stdout into context, so the
56/// agent must actively run `truth-mirror reinject --agent grok`.
57pub 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
82/// Absolute path of the project-local Grok reinjection skill.
83pub fn grok_skill_path(repo_root: &Path) -> PathBuf {
84    repo_root.join(GROK_SKILL_RELATIVE)
85}
86
87/// Whether a truth-mirror-owned Grok reinjection skill is present under `repo_root`.
88pub 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
95/// Write the project-local Grok reinjection skill without clobbering foreign content.
96pub 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    // Re-check after create_dir_all in case a symlink was planted concurrently.
122    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    // Reject a symlink at the leaf *or* any existing ancestor so a malicious
130    // `.grok` / `.grok/skills` symlink cannot redirect writes outside the repo.
131    // Start at repo_root so normal platform symlinks outside the repo, such as
132    // macOS /var -> /private/var tempdirs, do not make safe installs fail.
133    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
149/// Remove the project-local Grok reinjection skill (owned only) and empty parents.
150pub 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            // Leave foreign skills in place.
157            return Ok(());
158        }
159        fs::remove_file(&path)
160            .with_context(|| format!("removing grok skill {}", path.display()))?;
161    }
162    // Best-effort empty dir cleanup (same pattern as Pi extension uninstall).
163    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
169/// Remove empty Grok hook dirs after enforcement/hook file uninstall.
170pub 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
175/// The exact command truth-mirror installs into each surface.
176pub fn reinject_command(agent: Agent) -> String {
177    format!("truth-mirror reinject --agent {}", agent_slug(agent))
178}
179
180/// Repo-relative path of the project-local Pi extension file.
181pub const PI_EXTENSION_RELATIVE: &str = ".pi/extensions/truth-mirror.js";
182
183/// Absolute path of the project-local Pi extension file.
184///
185/// Pi auto-loads every `*.js`/`*.ts` file in `<cwd>/.pi/extensions/` (verified
186/// against Pi 0.80.3 `core/extensions/loader.js:466-490,511-512`), subject to the
187/// one-time project-folder trust prompt (`core/project-trust.js`).
188pub fn pi_extension_path(repo_root: &Path) -> PathBuf {
189    repo_root.join(PI_EXTENSION_RELATIVE)
190}
191
192/// The project-local Pi extension. Default-exports a factory `(pi) => {}` that
193/// registers a `context` handler (fires before every LLM call) and appends the
194/// output of `truth-mirror reinject --agent pi` as a user message, with a dedup
195/// guard so findings inject once per change, not once per tool round-trip.
196pub 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
229/// Write the project-local Pi reinjection extension into `<repo>/.pi/extensions/`.
230pub 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
241/// Remove the project-local Pi reinjection extension.
242pub 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
251/// The enforcement subcommand marker. Matched only when the command's program
252/// token is also `truth-mirror` (see `is_own_enforcement_command`), so preserved
253/// `--config`/`--state-dir` variants match but foreign hooks never do.
254pub 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
260/// Install a `PreToolUse` enforcement hook into a nested (Claude/Codex/Grok) surface.
261/// The hook exits non-zero to block a mutating tool while the ledger has
262/// unresolved rejections beyond the configured threshold. `global_args` preserves
263/// the install-time `--config`/`--state-dir` so the hook uses the same config.
264pub 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 any prior truth-mirror enforcement entry first so a reinstall UPDATES
270    // the preserved flags (foreign hooks are left untouched).
271    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
277/// Remove the `PreToolUse` enforcement hook from a nested surface.
278pub 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
294/// Whether an entry is truth-mirror's OWN enforcement command — the program token
295/// must be `truth-mirror`, so a foreign hook like `external-auditor gate
296/// --pre-tool-use` is never matched, removed, or clobbered.
297fn 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
307/// Mutable handle to `hooks.<event>` array, creating nested containers as needed.
308fn 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                // Only truth-mirror's own enforcement command is removed; foreign
333                // hooks (even ones mentioning the subcommand) are left intact.
334                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
352/// Claude Code (`.claude/settings.json`), Codex (`.codex/hooks.json`), and Grok
353/// (`.grok/hooks/*.json`) use the same nested shape for hook entries.
354/// Codex verified against 0.142.4 (`config/src/hook_config.rs`); Grok against xAI
355/// hooks docs. Grok uses this shape for optional PreToolUse enforcement only.
356fn 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
484/// Return a mutable handle to the array we append entries to, creating the
485/// nested containers if they do not exist.
486fn 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
541/// Whether the surface JSON already carries the reinject command for an agent.
542pub 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        // Verified against Codex 0.142.4: hooks.json is nested, not flat.
589        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(); // UserPromptSubmit reinject
755        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        // Removing enforcement leaves the reinject hook intact.
763        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        // Reinstall with a preserved --config: must UPDATE, not leave a stale entry.
774        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        // A FOREIGN hook that merely mentions the subcommand must not be clobbered.
787        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}