use std::{
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use serde_json::{Map, Value, json};
use crate::cli::Agent;
pub const FILE_SURFACE_AGENTS: [Agent; 2] = [Agent::Claude, Agent::Codex];
pub fn agent_slug(agent: Agent) -> &'static str {
match agent {
Agent::Claude => "claude",
Agent::Codex => "codex",
Agent::Pi => "pi",
}
}
pub fn surface_relative_path(agent: Agent) -> &'static str {
match agent {
Agent::Claude => ".claude/settings.json",
Agent::Codex => ".codex/hooks.json",
Agent::Pi => ".pi/settings.json",
}
}
pub fn reinject_command(agent: Agent) -> String {
format!("truth-mirror reinject --agent {}", agent_slug(agent))
}
pub const PI_EXTENSION_RELATIVE: &str = ".pi/extensions/truth-mirror.js";
pub fn pi_extension_path(repo_root: &Path) -> PathBuf {
repo_root.join(PI_EXTENSION_RELATIVE)
}
pub const PI_EXTENSION_SOURCE: &str = r#"// truth-mirror Pi reinjection extension.
// Auto-generated by `truth-mirror install-hooks --pi`. Pi auto-loads this file
// from <repo>/.pi/extensions/ once the project folder is trusted.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
export default function truthMirror(pi) {
let lastInjected = "";
pi.on("context", async (event) => {
let text = "";
try {
const { stdout } = await run("truth-mirror", ["reinject", "--agent", "pi"], {
cwd: process.cwd(),
});
text = (stdout || "").trim();
} catch {
return; // truth-mirror missing or errored: stay silent.
}
// `context` fires before every LLM call; dedup so findings inject once per change.
if (!text || text === lastInjected) return;
lastInjected = text;
return {
messages: [
...event.messages,
{ role: "user", content: [{ type: "text", text }] },
],
};
});
}
"#;
pub fn install_pi_extension(repo_root: &Path) -> Result<()> {
let path = pi_extension_path(repo_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating pi extensions dir {}", parent.display()))?;
}
fs::write(&path, PI_EXTENSION_SOURCE)
.with_context(|| format!("writing pi extension {}", path.display()))?;
Ok(())
}
pub fn uninstall_pi_extension(repo_root: &Path) -> Result<()> {
let path = pi_extension_path(repo_root);
if path.is_file() {
fs::remove_file(&path)
.with_context(|| format!("removing pi extension {}", path.display()))?;
}
Ok(())
}
pub const ENFORCE_COMMAND: &str = "gate --pre-tool-use";
fn enforce_command(global_args: &str) -> String {
format!("truth-mirror {global_args}{ENFORCE_COMMAND}")
}
pub fn install_enforcement(repo_root: &Path, agent: Agent, global_args: &str) -> Result<()> {
debug_assert!(is_nested(agent), "enforcement hook is nested-surface only");
let command = enforce_command(global_args);
let path = repo_root.join(surface_relative_path(agent));
let mut root = read_object(&path)?;
remove_own_enforcement(&mut root, "PreToolUse");
let entries = event_array_mut(&mut root, "PreToolUse");
entries.push(json!({ "hooks": [ { "type": "command", "command": command } ] }));
write_object(&path, &root)
}
pub fn uninstall_enforcement(repo_root: &Path, agent: Agent) -> Result<()> {
let path = repo_root.join(surface_relative_path(agent));
if !path.exists() {
return Ok(());
}
let mut root = read_object(&path)?;
remove_own_enforcement(&mut root, "PreToolUse");
if root.is_empty() {
fs::remove_file(&path)
.with_context(|| format!("removing empty surface {}", path.display()))?;
} else {
write_object(&path, &root)?;
}
Ok(())
}
fn is_own_enforcement_command(entry: &Value) -> bool {
entry
.get("command")
.and_then(Value::as_str)
.is_some_and(|value| {
value.split_whitespace().next() == Some("truth-mirror")
&& value.contains(ENFORCE_COMMAND)
})
}
fn event_array_mut<'a>(root: &'a mut Map<String, Value>, event: &str) -> &'a mut Vec<Value> {
let hooks = root
.entry("hooks")
.or_insert_with(|| Value::Object(Map::new()));
if !hooks.is_object() {
*hooks = Value::Object(Map::new());
}
let hooks = hooks.as_object_mut().expect("hooks is object");
let entries = hooks
.entry(event.to_owned())
.or_insert_with(|| Value::Array(Vec::new()));
if !entries.is_array() {
*entries = Value::Array(Vec::new());
}
entries.as_array_mut().expect("event is array")
}
fn remove_own_enforcement(root: &mut Map<String, Value>, event: &str) {
let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
return;
};
if let Some(groups) = hooks.get_mut(event).and_then(Value::as_array_mut) {
for group in groups.iter_mut() {
if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
inner.retain(|entry| !is_own_enforcement_command(entry));
}
}
groups.retain(|group| {
group
.get("hooks")
.and_then(Value::as_array)
.is_none_or(|inner| !inner.is_empty())
});
if groups.is_empty() {
hooks.remove(event);
}
}
if hooks.is_empty() {
root.remove("hooks");
}
}
fn is_nested(agent: Agent) -> bool {
matches!(agent, Agent::Claude | Agent::Codex)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SurfacePlan {
pub agent: Agent,
pub path: PathBuf,
}
impl SurfacePlan {
pub fn for_agent(repo_root: &Path, agent: Agent) -> Self {
Self {
agent,
path: repo_root.join(surface_relative_path(agent)),
}
}
pub fn install(&self) -> Result<()> {
let mut root = read_object(&self.path)?;
install_command(self.agent, &mut root, &reinject_command(self.agent));
write_object(&self.path, &root)
}
pub fn uninstall(&self) -> Result<()> {
if !self.path.exists() {
return Ok(());
}
let mut root = read_object(&self.path)?;
remove_command(self.agent, &mut root, &reinject_command(self.agent));
if root.is_empty() {
fs::remove_file(&self.path)
.with_context(|| format!("removing empty surface {}", self.path.display()))?;
} else {
write_object(&self.path, &root)?;
}
Ok(())
}
pub fn contains_reinject(&self) -> Result<bool> {
if !self.path.exists() {
return Ok(false);
}
let root = read_object(&self.path)?;
Ok(surface_contains(
self.agent,
&root,
&reinject_command(self.agent),
))
}
}
fn read_object(path: &Path) -> Result<Map<String, Value>> {
match fs::read_to_string(path) {
Ok(contents) if contents.trim().is_empty() => Ok(Map::new()),
Ok(contents) => {
let value: Value = serde_json::from_str(&contents)
.with_context(|| format!("parsing existing surface {}", path.display()))?;
match value {
Value::Object(map) => Ok(map),
_ => anyhow::bail!(
"surface {} is not a JSON object; refusing to clobber",
path.display()
),
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
Err(error) => Err(error).with_context(|| format!("reading surface {}", path.display()))?,
}
}
fn write_object(path: &Path, root: &Map<String, Value>) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating surface dir {}", parent.display()))?;
}
let mut serialized = serde_json::to_string_pretty(&Value::Object(root.clone()))?;
serialized.push('\n');
fs::write(path, serialized).with_context(|| format!("writing surface {}", path.display()))?;
Ok(())
}
fn install_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
let entries = user_prompt_submit_mut(agent, root);
if array_contains_command(agent, entries, command) {
return;
}
entries.push(surface_entry(agent, command));
}
fn remove_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
if is_nested(agent) {
let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
return;
};
if let Some(groups) = hooks
.get_mut("UserPromptSubmit")
.and_then(Value::as_array_mut)
{
for group in groups.iter_mut() {
if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
inner.retain(|entry| !entry_matches_command(entry, command));
}
}
groups.retain(|group| {
group
.get("hooks")
.and_then(Value::as_array)
.is_none_or(|inner| !inner.is_empty())
});
if groups.is_empty() {
hooks.remove("UserPromptSubmit");
}
}
if hooks.is_empty() {
root.remove("hooks");
}
} else if let Some(entries) = root
.get_mut("UserPromptSubmit")
.and_then(Value::as_array_mut)
{
entries.retain(|entry| !entry_matches_command(entry, command));
if entries.is_empty() {
root.remove("UserPromptSubmit");
}
}
}
fn user_prompt_submit_mut(agent: Agent, root: &mut Map<String, Value>) -> &mut Vec<Value> {
if is_nested(agent) {
let hooks = root
.entry("hooks")
.or_insert_with(|| Value::Object(Map::new()));
if !hooks.is_object() {
*hooks = Value::Object(Map::new());
}
let hooks = hooks.as_object_mut().expect("hooks is object");
let entries = hooks
.entry("UserPromptSubmit")
.or_insert_with(|| Value::Array(Vec::new()));
if !entries.is_array() {
*entries = Value::Array(Vec::new());
}
entries.as_array_mut().expect("UserPromptSubmit is array")
} else {
let entries = root
.entry("UserPromptSubmit")
.or_insert_with(|| Value::Array(Vec::new()));
if !entries.is_array() {
*entries = Value::Array(Vec::new());
}
entries.as_array_mut().expect("UserPromptSubmit is array")
}
}
fn surface_entry(agent: Agent, command: &str) -> Value {
if is_nested(agent) {
json!({ "hooks": [ { "type": "command", "command": command } ] })
} else {
json!({ "command": command })
}
}
fn array_contains_command(agent: Agent, entries: &[Value], command: &str) -> bool {
if is_nested(agent) {
entries.iter().any(|group| {
group
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|inner| inner.iter().any(|e| entry_matches_command(e, command)))
})
} else {
entries.iter().any(|e| entry_matches_command(e, command))
}
}
fn entry_matches_command(entry: &Value, command: &str) -> bool {
entry
.get("command")
.and_then(Value::as_str)
.is_some_and(|value| value == command)
}
pub fn surface_contains(agent: Agent, root: &Map<String, Value>, command: &str) -> bool {
if is_nested(agent) {
root.get("hooks")
.and_then(Value::as_object)
.and_then(|hooks| hooks.get("UserPromptSubmit"))
.and_then(Value::as_array)
.is_some_and(|entries| array_contains_command(agent, entries, command))
} else {
root.get("UserPromptSubmit")
.and_then(Value::as_array)
.is_some_and(|entries| array_contains_command(agent, entries, command))
}
}
#[cfg(test)]
mod tests {
use super::{
Agent, SurfacePlan, install_command, reinject_command, remove_command, surface_contains,
};
use proptest::prelude::*;
use serde_json::{Map, Value, json};
fn install_into(agent: Agent, mut root: Map<String, Value>) -> Map<String, Value> {
install_command(agent, &mut root, &reinject_command(agent));
root
}
#[test]
fn claude_surface_uses_nested_user_prompt_submit() {
let root = install_into(Agent::Claude, Map::new());
let value = Value::Object(root.clone());
let command = value
.pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
.and_then(Value::as_str)
.unwrap();
assert_eq!(command, "truth-mirror reinject --agent claude");
assert!(surface_contains(
Agent::Claude,
&root,
&reinject_command(Agent::Claude)
));
}
#[test]
fn codex_uses_nested_user_prompt_submit_like_claude() {
let root = install_into(Agent::Codex, Map::new());
let value = Value::Object(root.clone());
let command = value
.pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
.and_then(Value::as_str)
.unwrap();
assert_eq!(command, "truth-mirror reinject --agent codex");
assert!(surface_contains(
Agent::Codex,
&root,
&reinject_command(Agent::Codex)
));
}
#[test]
fn install_is_idempotent() {
let mut root = install_into(Agent::Claude, Map::new());
install_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
let count = Value::Object(root)
.pointer("/hooks/UserPromptSubmit")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn install_preserves_foreign_config() {
let existing: Map<String, Value> = json!({
"model": "sonnet",
"hooks": { "PreToolUse": [ { "matcher": "Bash" } ] }
})
.as_object()
.cloned()
.unwrap();
let root = install_into(Agent::Claude, existing);
let value = Value::Object(root);
assert_eq!(
value.pointer("/model").and_then(Value::as_str),
Some("sonnet")
);
assert!(value.pointer("/hooks/PreToolUse").is_some());
assert!(value.pointer("/hooks/UserPromptSubmit/0").is_some());
}
#[test]
fn uninstall_removes_only_truth_mirror_entries() {
let existing: Map<String, Value> = json!({
"model": "sonnet",
"hooks": {
"UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "other-tool" } ] } ]
}
})
.as_object()
.cloned()
.unwrap();
let mut root = install_into(Agent::Claude, existing);
remove_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
let value = Value::Object(root);
assert_eq!(
value.pointer("/model").and_then(Value::as_str),
Some("sonnet")
);
let commands: Vec<&str> = value
.pointer("/hooks/UserPromptSubmit")
.and_then(Value::as_array)
.unwrap()
.iter()
.filter_map(|group| group.pointer("/hooks/0/command").and_then(Value::as_str))
.collect();
assert_eq!(commands, ["other-tool"]);
}
#[test]
fn enforcement_hook_installs_and_coexists_with_reinject() {
let temp = tempfile::tempdir().unwrap();
let plan = SurfacePlan::for_agent(temp.path(), Agent::Claude);
plan.install().unwrap(); super::install_enforcement(temp.path(), Agent::Claude, "").unwrap();
let content = std::fs::read_to_string(&plan.path).unwrap();
assert!(content.contains("UserPromptSubmit"));
assert!(content.contains("PreToolUse"));
assert!(content.contains("truth-mirror gate --pre-tool-use"));
super::uninstall_enforcement(temp.path(), Agent::Claude).unwrap();
let after = std::fs::read_to_string(&plan.path).unwrap();
assert!(after.contains("UserPromptSubmit"));
assert!(!after.contains("PreToolUse"));
}
#[test]
fn reinstalling_enforcement_updates_preserved_flags() {
let temp = tempfile::tempdir().unwrap();
super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
super::install_enforcement(temp.path(), Agent::Codex, "--config '/abs/x.toml' ").unwrap();
let content = std::fs::read_to_string(temp.path().join(".codex/hooks.json")).unwrap();
assert_eq!(content.matches("gate --pre-tool-use").count(), 1);
assert!(content.contains("--config '/abs/x.toml'"));
}
#[test]
fn enforcement_leaves_foreign_pretooluse_hooks_intact() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(".codex/hooks.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let foreign = "external-auditor gate --pre-tool-use --keep";
std::fs::write(
&path,
format!(
"{{\"hooks\":{{\"PreToolUse\":[{{\"hooks\":[{{\"type\":\"command\",\"command\":\"{foreign}\"}}]}}]}}}}"
),
)
.unwrap();
super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
let after_install = std::fs::read_to_string(&path).unwrap();
assert!(
after_install.contains(foreign),
"foreign hook survives install"
);
assert!(after_install.contains("truth-mirror gate --pre-tool-use"));
super::uninstall_enforcement(temp.path(), Agent::Codex).unwrap();
let after_uninstall = std::fs::read_to_string(&path).unwrap();
assert!(
after_uninstall.contains(foreign),
"foreign hook survives uninstall"
);
assert!(!after_uninstall.contains("truth-mirror gate --pre-tool-use"));
}
#[test]
fn enforcement_round_trips_for_codex() {
let temp = tempfile::tempdir().unwrap();
super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
assert!(
std::fs::read_to_string(temp.path().join(".codex/hooks.json"))
.unwrap()
.contains("truth-mirror gate --pre-tool-use")
);
super::uninstall_enforcement(temp.path(), Agent::Codex).unwrap();
assert!(!temp.path().join(".codex/hooks.json").exists());
}
#[test]
fn install_then_uninstall_on_disk_round_trips() {
let temp = tempfile::tempdir().unwrap();
for agent in super::FILE_SURFACE_AGENTS {
let plan = SurfacePlan::for_agent(temp.path(), agent);
plan.install().unwrap();
assert!(plan.contains_reinject().unwrap());
plan.uninstall().unwrap();
assert!(!plan.contains_reinject().unwrap());
assert!(!plan.path.exists());
}
}
proptest! {
#[test]
fn foreign_keys_survive_install_uninstall(
key in "[a-z]{1,8}",
val in "[a-z0-9]{1,8}",
) {
prop_assume!(key != "hooks" && key != "UserPromptSubmit");
let existing: Map<String, Value> = json!({ key.clone(): val.clone() })
.as_object()
.cloned()
.unwrap();
let mut root = existing.clone();
install_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
prop_assert!(surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
remove_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
prop_assert!(!surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
prop_assert_eq!(root.get(&key).and_then(Value::as_str), Some(val.as_str()));
}
}
}