use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::AppError;
const CODEX_STATUS_MESSAGE: &str = "omamori: checking command safety";
pub const SHIM_COMMANDS: &[&str] = &["rm", "git", "chmod", "find", "rsync"];
#[derive(Debug, Clone)]
pub struct InstallOptions {
pub base_dir: PathBuf,
pub source_exe: PathBuf,
pub generate_hooks: bool,
}
#[derive(Debug, Clone)]
pub struct InstallResult {
pub shim_dir: PathBuf,
pub linked_commands: Vec<String>,
pub hook_script: Option<PathBuf>,
pub settings_snippet: Option<PathBuf>,
pub cursor_hook_snippet: Option<PathBuf>,
pub codex_wrapper: Option<PathBuf>,
pub codex_hooks_outcome: Option<CodexHooksOutcome>,
pub codex_config_outcome: Option<CodexConfigOutcome>,
pub claude_settings_outcome: Option<ClaudeSettingsOutcome>,
}
#[derive(Debug, Clone)]
pub enum CodexHooksOutcome {
Merged,
Created,
AlreadyPresent,
Skipped(String),
}
#[derive(Debug, Clone)]
pub enum CodexConfigOutcome {
Added,
AlreadyEnabled,
ExplicitlyDisabled,
Skipped(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClaudeSettingsOutcome {
Created,
Merged,
AlreadyPresent,
MatcherMigrated,
Skipped(String),
}
#[derive(Debug, Clone)]
pub struct UninstallResult {
pub shim_dir: PathBuf,
pub removed_entries: Vec<PathBuf>,
}
pub fn install(options: &InstallOptions) -> Result<InstallResult, AppError> {
let shim_dir = options.base_dir.join("shim");
fs::create_dir_all(&shim_dir)?;
let source_exe = options.source_exe.clone();
let mut linked_commands = Vec::new();
for command in SHIM_COMMANDS {
let link_path = shim_dir.join(command);
recreate_symlink(&source_exe, &link_path)?;
linked_commands.push((*command).to_string());
}
let (hook_script, settings_snippet) = if options.generate_hooks {
let hooks_dir = options.base_dir.join("hooks");
fs::create_dir_all(&hooks_dir)?;
let script_path = hooks_dir.join("claude-pretooluse.sh");
atomic_write(&script_path, &render_hook_script())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&script_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_path, perms)?;
}
let snippet_path = hooks_dir.join("claude-settings.snippet.json");
atomic_write(&snippet_path, &render_settings_snippet(&script_path))?;
(Some(script_path), Some(snippet_path))
} else {
(None, None)
};
let cursor_hook_snippet = if options.generate_hooks {
let hooks_dir = options.base_dir.join("hooks");
let cursor_snippet_path = hooks_dir.join("cursor-hooks.snippet.json");
let omamori_exe = options.source_exe.clone();
atomic_write(
&cursor_snippet_path,
&render_cursor_hooks_snippet(&omamori_exe),
)?;
Some(cursor_snippet_path)
} else {
None
};
let claude_settings_outcome = match (options.generate_hooks, hook_script.as_ref()) {
(true, Some(script_path)) => {
let claude_dir = claude_home_dir();
if is_real_directory(&claude_dir) {
Some(
merge_claude_settings(&claude_dir, script_path)
.unwrap_or_else(|e| ClaudeSettingsOutcome::Skipped(format!("I/O: {e}"))),
)
} else {
Some(ClaudeSettingsOutcome::Skipped(
"Claude Code not detected (~/.claude not a directory)".into(),
))
}
}
_ => None,
};
let (codex_wrapper, codex_hooks_outcome, codex_config_outcome) = if options.generate_hooks {
setup_codex_hooks(&options.base_dir, &options.source_exe)
} else {
(None, None, None)
};
if let Err(e) = generate_install_baseline(&options.base_dir) {
eprintln!("omamori: warning — failed to generate integrity baseline: {e}");
}
Ok(InstallResult {
shim_dir,
linked_commands,
hook_script,
settings_snippet,
cursor_hook_snippet,
codex_wrapper,
codex_hooks_outcome,
codex_config_outcome,
claude_settings_outcome,
})
}
pub fn uninstall(base_dir: &Path) -> Result<UninstallResult, AppError> {
let shim_dir = base_dir.join("shim");
let hooks_dir = base_dir.join("hooks");
let mut removed_entries = Vec::new();
for command in SHIM_COMMANDS {
let link_path = shim_dir.join(command);
if link_path.exists() || link_path.is_symlink() {
fs::remove_file(&link_path)?;
removed_entries.push(link_path);
}
}
for path in [
hooks_dir.join("claude-pretooluse.sh"),
hooks_dir.join("claude-settings.snippet.json"),
hooks_dir.join("cursor-hooks.snippet.json"),
hooks_dir.join("codex-pretooluse.sh"),
hooks_dir.join("codex-hooks.snippet.json"),
] {
if path.exists() {
fs::remove_file(&path)?;
removed_entries.push(path);
}
}
if let Err(e) = remove_codex_hooks_entry() {
eprintln!("omamori: warning — failed to clean Codex hooks.json: {e}");
}
if let Err(e) = remove_claude_settings_entry(base_dir) {
eprintln!("omamori: warning — failed to clean Claude settings: {e}");
}
let integrity_path = base_dir.join(".integrity.json");
if integrity_path.exists() {
fs::remove_file(&integrity_path)?;
removed_entries.push(integrity_path);
}
remove_dir_if_empty(&hooks_dir)?;
remove_dir_if_empty(&shim_dir)?;
remove_dir_if_empty(base_dir)?;
Ok(UninstallResult {
shim_dir,
removed_entries,
})
}
pub fn default_base_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".omamori")
}
fn recreate_symlink(source: &Path, link_path: &Path) -> Result<(), AppError> {
if link_path.exists() || link_path.is_symlink() {
fs::remove_file(link_path)?;
}
#[cfg(unix)]
std::os::unix::fs::symlink(source, link_path)?;
#[cfg(not(unix))]
std::os::windows::fs::symlink_file(source, link_path)?;
Ok(())
}
fn remove_dir_if_empty(path: &Path) -> Result<(), AppError> {
if path.is_dir() && fs::read_dir(path)?.next().is_none() {
fs::remove_dir(path)?;
}
Ok(())
}
fn atomic_write(target: &Path, content: &str) -> Result<(), std::io::Error> {
let dir = target.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile_in(dir)?;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
let tmp_path = tmp.into_path();
fs::rename(&tmp_path, target)?;
Ok(())
}
fn atomic_write_with_mode(target: &Path, content: &str, mode: u32) -> Result<(), std::io::Error> {
let dir = target.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile_in_with_mode(dir, mode)?;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
let tmp_path = tmp.into_path();
fs::rename(&tmp_path, target)?;
Ok(())
}
fn tempfile_in(dir: &Path) -> Result<AtomicTempFile, std::io::Error> {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!(".omamori-tmp-{}-{}", std::process::id(), seq));
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&path)?
};
#[cfg(not(unix))]
let file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)?;
Ok(AtomicTempFile { file, path })
}
fn tempfile_in_with_mode(dir: &Path, mode: u32) -> Result<AtomicTempFile, std::io::Error> {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!(".omamori-tmp-{}-{}", std::process::id(), seq));
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.custom_flags(libc::O_NOFOLLOW)
.mode(mode)
.open(&path)?
};
#[cfg(not(unix))]
let _ = mode;
#[cfg(not(unix))]
let file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)?;
Ok(AtomicTempFile { file, path })
}
struct AtomicTempFile {
file: fs::File,
path: PathBuf,
}
impl AtomicTempFile {
fn into_path(self) -> PathBuf {
self.path
}
}
impl Write for AtomicTempFile {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.file.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.file.flush()
}
}
pub fn hook_content_hash(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn parse_hook_version(content: &str) -> Option<&str> {
content
.lines()
.find(|line| line.starts_with("# omamori hook v"))
.and_then(|line| line.strip_prefix("# omamori hook v"))
}
pub fn regenerate_hooks(base_dir: &Path) -> Result<(), std::io::Error> {
let hooks_dir = base_dir.join("hooks");
fs::create_dir_all(&hooks_dir)?;
let script_path = hooks_dir.join("claude-pretooluse.sh");
atomic_write(&script_path, &render_hook_script())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&script_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_path, perms)?;
}
let snippet_path = hooks_dir.join("claude-settings.snippet.json");
atomic_write(&snippet_path, &render_settings_snippet(&script_path))?;
if let Ok(exe) = std::env::current_exe() {
let stable_exe = resolve_stable_exe_path(&exe);
let cursor_path = hooks_dir.join("cursor-hooks.snippet.json");
atomic_write(&cursor_path, &render_cursor_hooks_snippet(&stable_exe))?;
let codex_wrapper = hooks_dir.join("codex-pretooluse.sh");
if codex_wrapper.exists() {
atomic_write(&codex_wrapper, &render_codex_pretooluse_script(&stable_exe))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&codex_wrapper)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&codex_wrapper, perms)?;
}
let codex_dir = codex_home_dir();
if is_real_directory(&codex_dir) {
let _ = merge_codex_hooks(&codex_dir, &codex_wrapper);
}
}
} else {
eprintln!(
"omamori warning: failed to resolve current exe; cursor/codex hooks not regenerated"
);
}
Ok(())
}
pub fn render_hook_script() -> String {
format!(
r#"#!/bin/sh
# omamori hook v{version}
# Thin wrapper: delegates all detection to `omamori hook-check`
set -eu
cat | omamori hook-check --provider claude-code
exit $?
"#,
version = env!("CARGO_PKG_VERSION")
)
}
pub(crate) const PROTECTED_ENV_VARS: &[&str] = &[
"CLAUDECODE",
"CODEX_CI",
"CURSOR_AGENT",
"GEMINI_CLI",
"CLINE_ACTIVE",
"AI_GUARD",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetaPatternKind {
VerbAtCommandPosition,
ProtectedPathSubstring,
}
pub const META_PATTERNS_VERB: &[(&str, &str)] = &[
("config disable", "blocked attempt to modify omamori rules"),
("config enable", "blocked attempt to modify omamori rules"),
("omamori uninstall", "blocked attempt to uninstall omamori"),
(
"omamori init --force",
"blocked attempt to overwrite omamori config",
),
(
"omamori override",
"blocked attempt to override omamori core rules",
),
(
"omamori doctor --fix",
"blocked attempt to run doctor --fix via AI",
),
(
"omamori explain",
"blocked attempt to run explain via AI (oracle attack prevention)",
),
];
pub const META_PATTERNS_PATH: &[(&str, &str)] = &[
("/bin/rm ", "blocked direct rm path that bypasses PATH shim"),
(
"/bin/rm\"",
"blocked direct rm path that bypasses PATH shim",
),
(
"/bin/rm\t",
"blocked direct rm path that bypasses PATH shim",
),
("/bin/rm'", "blocked direct rm path that bypasses PATH shim"),
(
"/usr/bin/rm ",
"blocked direct rm path that bypasses PATH shim",
),
(
"/usr/bin/rm\"",
"blocked direct rm path that bypasses PATH shim",
),
(
"/usr/bin/rm\t",
"blocked direct rm path that bypasses PATH shim",
),
(
"/usr/bin/rm'",
"blocked direct rm path that bypasses PATH shim",
),
(
".claude/settings.json",
"blocked attempt to edit Claude Code settings (contains hook config)",
),
(
".integrity.json",
"blocked attempt to edit integrity baseline",
),
(
".codex/hooks.json",
"blocked attempt to edit Codex hooks config",
),
(".codex/config.toml", "blocked attempt to edit Codex config"),
(
"config.toml.bak",
"blocked attempt to use Codex config backup",
),
(
"codex_hooks",
"blocked attempt to modify Codex hooks feature flag",
),
("audit.jsonl", "blocked attempt to modify audit log"),
("audit-secret", "blocked attempt to access audit secret"),
(
"omamori/config.toml",
"blocked attempt to edit omamori config",
),
(
".local/share/omamori",
"blocked attempt to modify omamori data directory",
),
];
#[allow(dead_code)]
pub const WRITE_VERBS: &[&str] = &[
"tee", "vim", "vi", "nano", "emacs", "cp", "mv", "dd", "install",
];
pub fn blocked_string_patterns() -> Vec<(&'static str, &'static str)> {
META_PATTERNS_PATH
.iter()
.chain(META_PATTERNS_VERB.iter())
.copied()
.collect()
}
fn cellar_to_stable_path(exe: &Path) -> Option<PathBuf> {
let s = exe.to_string_lossy();
let cellar_idx = s.find("/Cellar/")?;
let prefix = &s[..cellar_idx];
let bin_idx = s.rfind("/bin/")?;
let binary = &s[bin_idx + 5..];
(!binary.is_empty()).then(|| PathBuf::from(format!("{prefix}/bin/{binary}")))
}
pub(crate) fn resolve_stable_exe_path(exe: &Path) -> PathBuf {
if let Some(stable) = cellar_to_stable_path(exe) {
if stable.exists() {
return stable;
}
eprintln!(
"omamori warning: Cellar path detected but stable path {} does not exist; \
using versioned path (may break after brew upgrade)",
stable.display()
);
}
exe.to_path_buf()
}
pub(crate) fn render_cursor_hooks_snippet(omamori_exe: &Path) -> String {
let exe_str = omamori_exe.display().to_string();
let command = format!("{} cursor-hook", shell_words::quote(&exe_str));
let snippet = serde_json::json!({
"_comment": format!("Generated by omamori v{}. Merge into .cursor/hooks.json", env!("CARGO_PKG_VERSION")),
"version": 1,
"hooks": {
"beforeShellExecution": [
{ "command": command }
]
}
});
serde_json::to_string_pretty(&snippet).unwrap() + "\n"
}
fn generate_install_baseline(base_dir: &Path) -> Result<(), crate::AppError> {
let baseline = crate::integrity::generate_baseline(base_dir)?;
crate::integrity::write_baseline(base_dir, &baseline)?;
Ok(())
}
pub(crate) fn claude_settings_entry(script_path: &Path) -> serde_json::Value {
let command = shell_words::quote(&script_path.display().to_string()).into_owned();
serde_json::json!({
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": command,
}],
"x-omamori-version": env!("CARGO_PKG_VERSION"),
})
}
fn render_settings_snippet(script_path: &Path) -> String {
let entry = claude_settings_entry(script_path);
let snippet = serde_json::json!({
"_comment": format!(
"Generated by omamori v{}. Auto-merged into ~/.claude/settings.json by `omamori install --hooks`.",
env!("CARGO_PKG_VERSION")
),
"hooks": {
"PreToolUse": [entry]
}
});
serde_json::to_string_pretty(&snippet).unwrap() + "\n"
}
pub(crate) fn claude_home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".claude")
}
pub(crate) fn merge_claude_settings(
claude_dir: &Path,
script_path: &Path,
) -> Result<ClaudeSettingsOutcome, std::io::Error> {
let settings_path = claude_dir.join("settings.json");
let entry = claude_settings_entry(script_path);
if !settings_path.exists() && !settings_path.is_symlink() {
let doc = serde_json::json!({
"hooks": { "PreToolUse": [entry] }
});
atomic_write_with_mode(
&settings_path,
&serde_json::to_string_pretty(&doc).unwrap(),
0o600,
)?;
return Ok(ClaudeSettingsOutcome::Created);
}
if settings_path.is_symlink() || !is_real_file(&settings_path) {
return Ok(ClaudeSettingsOutcome::Skipped(
"settings.json is a symlink or not a regular file".into(),
));
}
let raw = fs::read_to_string(&settings_path)?;
let mut doc: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
return Ok(ClaudeSettingsOutcome::Skipped(format!(
"JSON parse error: {e}"
)));
}
};
let arr = doc
.as_object_mut()
.and_then(|o| {
o.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
})
.and_then(|h| {
let pre = h
.entry("PreToolUse")
.or_insert_with(|| serde_json::json!([]));
pre.as_array_mut()
});
let arr = match arr {
Some(a) => a,
None => {
return Ok(ClaudeSettingsOutcome::Skipped(
"hooks.PreToolUse is not an array".into(),
));
}
};
let install_root = script_path
.parent()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("/"));
let existing_idx = arr
.iter()
.position(|e| is_omamori_owned_entry(e, &install_root));
if let Some(idx) = existing_idx {
if arr[idx] == entry {
return Ok(ClaudeSettingsOutcome::AlreadyPresent);
}
let was_legacy_matcher = arr[idx]
.get("matcher")
.and_then(|m| m.as_str())
.map(is_legacy_matcher)
.unwrap_or(false);
arr[idx] = entry;
let outcome = if was_legacy_matcher {
ClaudeSettingsOutcome::MatcherMigrated
} else {
ClaudeSettingsOutcome::Merged
};
atomic_write_with_mode(
&settings_path,
&serde_json::to_string_pretty(&doc).unwrap(),
0o600,
)?;
return Ok(outcome);
}
arr.push(entry);
atomic_write_with_mode(
&settings_path,
&serde_json::to_string_pretty(&doc).unwrap(),
0o600,
)?;
Ok(ClaudeSettingsOutcome::Merged)
}
pub(crate) fn entry_is_omamori_managed(entry: &serde_json::Value, base_dir: &Path) -> bool {
let mut commands: Vec<&str> = Vec::new();
if let Some(arr) = entry.get("hooks").and_then(|v| v.as_array()) {
for h in arr {
if let Some(c) = h.get("command").and_then(|v| v.as_str()) {
commands.push(c);
}
}
}
if let Some(c) = entry.get("command").and_then(|v| v.as_str()) {
commands.push(c);
}
commands.iter().any(|c| {
let unquoted = c.trim_matches('\'').trim_matches('"');
Path::new(unquoted).starts_with(base_dir)
})
}
pub(crate) fn is_omamori_owned_entry(entry: &serde_json::Value, base_dir: &Path) -> bool {
if !entry_is_omamori_managed(entry, base_dir) {
return false;
}
let hooks_size = entry
.get("hooks")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let has_flat = entry.get("command").is_some();
if hooks_size > 1 {
return false; }
if has_flat && hooks_size > 0 {
return false; }
true
}
fn is_legacy_matcher(matcher: &str) -> bool {
matcher == "*" || matcher.contains("==") || matcher.contains("&&") || matcher.contains("||")
}
fn codex_home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".codex")
}
pub(crate) fn is_real_directory(path: &Path) -> bool {
path.symlink_metadata().map(|m| m.is_dir()).unwrap_or(false)
}
fn is_real_file(path: &Path) -> bool {
path.symlink_metadata()
.map(|m| m.is_file())
.unwrap_or(false)
}
pub fn render_codex_pretooluse_script(omamori_exe: &Path) -> String {
let exe_str = omamori_exe.display().to_string();
let quoted = shell_words::quote(&exe_str);
format!(
r#"#!/bin/sh
# omamori hook v{version} — Codex CLI fail-close wrapper
# Codex: exit 0 = allow, exit 2 = block, exit 1 = allow (fail-open!)
# This wrapper maps all non-zero exits to exit 2 for fail-close safety.
set -u
cat | {exe} hook-check --provider codex
STATUS=$?
if [ "$STATUS" -eq 0 ]; then exit 0; else exit 2; fi
"#,
version = env!("CARGO_PKG_VERSION"),
exe = quoted,
)
}
fn codex_hooks_entry(wrapper_path: &Path) -> serde_json::Value {
let command = shell_words::quote(&wrapper_path.display().to_string()).into_owned();
serde_json::json!({
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": command,
"timeout": 30,
"statusMessage": CODEX_STATUS_MESSAGE
}]
})
}
pub(crate) fn merge_codex_hooks(
codex_dir: &Path,
wrapper_path: &Path,
) -> Result<CodexHooksOutcome, std::io::Error> {
let hooks_path = codex_dir.join("hooks.json");
let entry = codex_hooks_entry(wrapper_path);
if !hooks_path.exists() && !hooks_path.is_symlink() {
let doc = serde_json::json!({
"hooks": { "PreToolUse": [entry] }
});
atomic_write(&hooks_path, &serde_json::to_string_pretty(&doc).unwrap())?;
return Ok(CodexHooksOutcome::Created);
}
if hooks_path.is_symlink() || !is_real_file(&hooks_path) {
return Ok(CodexHooksOutcome::Skipped(
"hooks.json is a symlink or not a regular file".into(),
));
}
let raw = fs::read_to_string(&hooks_path)?;
let mut doc: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => return Ok(CodexHooksOutcome::Skipped(format!("JSON parse error: {e}"))),
};
let arr = doc
.as_object_mut()
.and_then(|o| {
o.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
})
.and_then(|h| {
let pre = h
.entry("PreToolUse")
.or_insert_with(|| serde_json::json!([]));
pre.as_array_mut()
});
let arr = match arr {
Some(a) => a,
None => {
return Ok(CodexHooksOutcome::Skipped(
"hooks.PreToolUse is not an array".into(),
));
}
};
let existing_idx = arr.iter().position(|e| {
e.pointer("/hooks/0/statusMessage").and_then(|v| v.as_str()) == Some(CODEX_STATUS_MESSAGE)
});
if let Some(idx) = existing_idx {
if arr[idx] == entry {
return Ok(CodexHooksOutcome::AlreadyPresent);
}
arr[idx] = entry;
} else {
arr.push(entry);
}
atomic_write(&hooks_path, &serde_json::to_string_pretty(&doc).unwrap())?;
Ok(CodexHooksOutcome::Merged)
}
fn remove_claude_settings_entry(base_dir: &Path) -> Result<(), std::io::Error> {
let settings_path = claude_home_dir().join("settings.json");
if !settings_path.exists() {
return Ok(());
}
if settings_path.is_symlink() || !is_real_file(&settings_path) {
return Ok(());
}
let raw = fs::read_to_string(&settings_path)?;
let mut doc: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => return Ok(()),
};
let mut modified = false;
if let Some(arr) = doc
.pointer_mut("/hooks/PreToolUse")
.and_then(|v| v.as_array_mut())
{
let before = arr.len();
arr.retain(|e| !is_omamori_owned_entry(e, base_dir));
if arr.len() != before {
modified = true;
}
let omamori_script_path = base_dir.join("hooks").join("claude-pretooluse.sh");
for entry in arr.iter_mut() {
if !entry_is_omamori_managed(entry, base_dir) {
continue;
}
if let Some(hooks_arr) = entry.get_mut("hooks").and_then(|v| v.as_array_mut()) {
let h_before = hooks_arr.len();
hooks_arr.retain(|h| {
let cmd = h.get("command").and_then(|v| v.as_str());
let is_canonical_omamori = cmd
.map(|c| {
let unquoted = c.trim_matches('\'').trim_matches('"');
Path::new(unquoted) == omamori_script_path
})
.unwrap_or(false);
!is_canonical_omamori
});
if hooks_arr.len() != h_before {
modified = true;
}
}
}
}
if modified {
atomic_write_with_mode(
&settings_path,
&serde_json::to_string_pretty(&doc).unwrap(),
0o600,
)?;
}
Ok(())
}
fn remove_codex_hooks_entry() -> Result<(), std::io::Error> {
let hooks_path = codex_home_dir().join("hooks.json");
if !hooks_path.exists() {
return Ok(());
}
let raw = fs::read_to_string(&hooks_path)?;
let mut doc: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => return Ok(()), };
let modified = doc
.pointer_mut("/hooks/PreToolUse")
.and_then(|v| v.as_array_mut())
.map(|arr| {
let before = arr.len();
arr.retain(|e| {
e.pointer("/hooks/0/statusMessage").and_then(|v| v.as_str())
!= Some(CODEX_STATUS_MESSAGE)
});
arr.len() != before
})
.unwrap_or(false);
if modified {
atomic_write(&hooks_path, &serde_json::to_string_pretty(&doc).unwrap())?;
}
Ok(())
}
pub(crate) fn update_codex_config(codex_dir: &Path) -> Result<CodexConfigOutcome, std::io::Error> {
let config_path = codex_dir.join("config.toml");
if !config_path.exists() {
return Ok(CodexConfigOutcome::Skipped("config.toml not found".into()));
}
if config_path.is_symlink() || !is_real_file(&config_path) {
return Ok(CodexConfigOutcome::Skipped(
"config.toml is a symlink or not a regular file".into(),
));
}
let raw = fs::read_to_string(&config_path)?;
let mut doc: toml_edit::DocumentMut = raw.parse().map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("TOML parse: {e}"))
})?;
if let Some(features) = doc.get("features") {
if !features.is_table() && !features.is_table_like() {
return Ok(CodexConfigOutcome::Skipped(
"features is not a table".into(),
));
}
if let Some(item) = features.get("codex_hooks") {
if item.as_bool() == Some(true) {
return Ok(CodexConfigOutcome::AlreadyEnabled);
}
if item.as_bool() == Some(false) {
return Ok(CodexConfigOutcome::ExplicitlyDisabled);
}
}
}
let backup_path = codex_dir.join("config.toml.bak");
fs::copy(&config_path, &backup_path)?;
doc["features"]["codex_hooks"] = toml_edit::value(true);
atomic_write(&config_path, &doc.to_string())?;
Ok(CodexConfigOutcome::Added)
}
fn setup_codex_hooks(
base_dir: &Path,
source_exe: &Path,
) -> (
Option<PathBuf>,
Option<CodexHooksOutcome>,
Option<CodexConfigOutcome>,
) {
let codex_dir = codex_home_dir();
if !is_real_directory(&codex_dir) {
return (None, None, None); }
let hooks_dir = base_dir.join("hooks");
let wrapper_path = hooks_dir.join("codex-pretooluse.sh");
if let Err(e) = atomic_write(&wrapper_path, &render_codex_pretooluse_script(source_exe)) {
eprintln!("omamori: warning — Codex wrapper: {e}");
return (None, None, None);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(mut perms) = fs::metadata(&wrapper_path).map(|m| m.permissions()) {
perms.set_mode(0o755);
let _ = fs::set_permissions(&wrapper_path, perms);
}
}
let hooks_outcome = merge_codex_hooks(&codex_dir, &wrapper_path)
.unwrap_or_else(|e| CodexHooksOutcome::Skipped(format!("I/O: {e}")));
if matches!(hooks_outcome, CodexHooksOutcome::Skipped(_)) {
let snippet_path = hooks_dir.join("codex-hooks.snippet.json");
let _ = atomic_write(&snippet_path, &render_codex_hooks_snippet(&wrapper_path));
}
let config_outcome = match update_codex_config(&codex_dir) {
Ok(outcome) => outcome,
Err(e) => CodexConfigOutcome::Skipped(format!("I/O: {e}")),
};
(
Some(wrapper_path),
Some(hooks_outcome),
Some(config_outcome),
)
}
pub fn auto_setup_codex_if_needed(base_dir: &Path) -> bool {
if std::env::var_os("CODEX_CI").is_none() {
return false;
}
let wrapper_path = base_dir.join("hooks/codex-pretooluse.sh");
if wrapper_path.exists() {
return false; }
let source_exe = match std::env::current_exe() {
Ok(exe) => resolve_stable_exe_path(&exe),
Err(_) => return false,
};
let codex_dir = codex_home_dir();
if !is_real_directory(&codex_dir) {
return false;
}
eprintln!("omamori: Codex CLI detected — auto-configuring hooks");
let hooks_dir = base_dir.join("hooks");
if fs::create_dir_all(&hooks_dir).is_err() {
eprintln!("omamori: warning — could not create hooks directory");
return false;
}
let (wrapper, hooks_out, config_out) = setup_codex_hooks(base_dir, &source_exe);
if let Some(ref path) = wrapper {
eprintln!("omamori: [done] {} (created)", path.display());
}
match hooks_out {
Some(CodexHooksOutcome::Created | CodexHooksOutcome::Merged) => {
eprintln!("omamori: [done] ~/.codex/hooks.json (merged)");
}
Some(CodexHooksOutcome::Skipped(ref reason)) => {
eprintln!("omamori: [warn] ~/.codex/hooks.json — {reason}");
}
_ => {}
}
match config_out {
Some(CodexConfigOutcome::Added) => {
eprintln!("omamori: [done] ~/.codex/config.toml (codex_hooks = true)");
}
Some(CodexConfigOutcome::ExplicitlyDisabled) => {
eprintln!(
"omamori: [warn] ~/.codex/config.toml: codex_hooks = false (set by user, not changed)"
);
eprintln!("omamori: hooks will NOT activate until you set codex_hooks = true");
}
Some(CodexConfigOutcome::Skipped(ref reason)) => {
eprintln!("omamori: [warn] ~/.codex/config.toml — {reason}");
}
_ => {}
}
wrapper.is_some()
}
pub(crate) fn render_codex_hooks_snippet(wrapper_path: &Path) -> String {
let doc = serde_json::json!({
"_comment": format!(
"Generated by omamori v{}. Merge PreToolUse entry into ~/.codex/hooks.json",
env!("CARGO_PKG_VERSION")
),
"hooks": { "PreToolUse": [codex_hooks_entry(wrapper_path)] }
});
serde_json::to_string_pretty(&doc).unwrap() + "\n"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_creates_shims_and_hook_templates() {
let root = std::env::temp_dir().join(format!("omamori-install-{}", std::process::id()));
let source = root.join("omamori");
fs::create_dir_all(&root).unwrap();
fs::write(&source, "binary").unwrap();
let result = install(&InstallOptions {
base_dir: root.clone(),
source_exe: source.clone(),
generate_hooks: true,
})
.unwrap();
assert!(result.shim_dir.join("rm").exists());
assert!(result.hook_script.unwrap().exists());
assert!(result.settings_snippet.unwrap().exists());
let _ = fs::remove_dir_all(root);
}
#[test]
fn hook_script_is_thin_wrapper() {
let script = render_hook_script();
assert!(
script.contains("omamori hook-check"),
"hook script should delegate to omamori hook-check"
);
assert!(
!script.contains("case \"$INPUT\""),
"hook script should not contain case statements (now a thin wrapper)"
);
}
#[test]
fn cursor_snippet_quotes_path_with_spaces() {
let snippet = render_cursor_hooks_snippet(Path::new("/Users/my user/bin/omamori"));
let v: serde_json::Value = serde_json::from_str(snippet.trim()).unwrap();
let cmd = v["hooks"]["beforeShellExecution"][0]["command"]
.as_str()
.unwrap();
let words = shell_words::split(cmd).unwrap();
assert_eq!(words[0], "/Users/my user/bin/omamori");
assert_eq!(words[1], "cursor-hook");
}
#[test]
fn codex_pretooluse_script_quotes_path_with_spaces() {
let script = render_codex_pretooluse_script(Path::new("/Users/my user/bin/omamori"));
assert!(script.contains("hook-check --provider codex"));
assert!(!script.contains("\"\""));
}
#[test]
fn settings_snippet_escapes_path() {
let path = std::path::Path::new(r#"/tmp/test "path"/hook.sh"#);
let snippet = render_settings_snippet(path);
assert!(snippet.contains(r#"\"path\""#));
assert!(!snippet.contains(r#"" "path""#));
}
#[test]
fn protected_env_vars_constant_covers_all_detectors() {
for var in &[
"CLAUDECODE",
"CODEX_CI",
"CURSOR_AGENT",
"GEMINI_CLI",
"CLINE_ACTIVE",
"AI_GUARD",
] {
assert!(
PROTECTED_ENV_VARS.contains(var),
"PROTECTED_ENV_VARS should include {var}"
);
}
}
#[test]
fn hook_script_contains_version_comment() {
let script = render_hook_script();
let version = env!("CARGO_PKG_VERSION");
assert!(
script.contains(&format!("# omamori hook v{version}")),
"hook script should contain version comment"
);
}
#[test]
fn parse_hook_version_extracts_version() {
let script = render_hook_script();
let version = parse_hook_version(&script);
assert_eq!(version, Some(env!("CARGO_PKG_VERSION")));
}
#[test]
fn parse_hook_version_returns_none_for_old_hooks() {
let old_script = "#!/bin/sh\nset -eu\nINPUT=\"$(cat)\"\n";
assert_eq!(parse_hook_version(old_script), None);
}
#[test]
fn parse_hook_version_returns_none_for_empty() {
assert_eq!(parse_hook_version(""), None);
}
#[test]
fn regenerate_hooks_creates_files() {
let root = std::env::temp_dir().join(format!("omamori-regen-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
regenerate_hooks(&root).unwrap();
let hook_path = root.join("hooks/claude-pretooluse.sh");
assert!(hook_path.exists(), "hook script should be created");
let content = fs::read_to_string(&hook_path).unwrap();
assert_eq!(
parse_hook_version(&content),
Some(env!("CARGO_PKG_VERSION"))
);
let snippet_path = root.join("hooks/claude-settings.snippet.json");
assert!(snippet_path.exists(), "settings snippet should be created");
let _ = fs::remove_dir_all(root);
}
#[test]
fn atomic_write_creates_file() {
let dir = std::env::temp_dir().join(format!("omamori-atomic-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let target = dir.join("test.txt");
atomic_write(&target, "hello world").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "hello world");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn tempfile_in_generates_unique_paths() {
let dir = std::env::temp_dir().join(format!("omamori-tmpuniq-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let tmp1 = tempfile_in(&dir).unwrap();
let tmp2 = tempfile_in(&dir).unwrap();
assert_ne!(
tmp1.path, tmp2.path,
"sequential tempfile_in must produce different paths"
);
let _ = fs::remove_file(&tmp1.path);
let _ = fs::remove_file(&tmp2.path);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn tempfile_in_uses_exclusive_creation() {
let dir = std::env::temp_dir().join(format!("omamori-tmpexcl-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let tmp = tempfile_in(&dir).unwrap();
let path = tmp.path.clone();
drop(tmp);
let tmp2 = tempfile_in(&dir).unwrap();
assert_ne!(path, tmp2.path);
let _ = fs::remove_file(&path);
let _ = fs::remove_file(&tmp2.path);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn hook_content_hash_is_deterministic() {
let content = "#!/bin/sh\necho hello\n";
let hash1 = hook_content_hash(content);
let hash2 = hook_content_hash(content);
assert_eq!(hash1, hash2, "same content should produce same hash");
}
#[test]
fn hook_content_hash_differs_for_different_content() {
let hash1 = hook_content_hash("exit 2");
let hash2 = hook_content_hash("exit 0");
assert_ne!(
hash1, hash2,
"different content should produce different hash"
);
}
#[test]
fn render_hook_script_produces_stable_hash() {
let script1 = render_hook_script();
let script2 = render_hook_script();
let hash1 = hook_content_hash(&script1);
let hash2 = hook_content_hash(&script2);
assert_eq!(hash1, hash2, "render_hook_script() should be deterministic");
}
#[test]
fn t2_attack_version_preserved_content_changed_hash_differs() {
let original = render_hook_script();
let original_hash = hook_content_hash(&original);
let tampered = original.replace("omamori hook-check", "true");
let tampered_hash = hook_content_hash(&tampered);
assert_ne!(
original_hash, tampered_hash,
"T2 attack (hook-check → true) should be detected by hash mismatch"
);
assert_eq!(
parse_hook_version(&tampered),
parse_hook_version(&original),
"T2 attack preserves version comment"
);
}
#[test]
fn hook_content_hash_returns_hex_string() {
let hash = hook_content_hash("test");
assert_eq!(hash.len(), 64, "SHA-256 hex string should be 64 chars");
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"hash should contain only hex characters"
);
}
#[test]
fn cellar_to_stable_apple_silicon() {
let p = Path::new("/opt/homebrew/Cellar/omamori/0.6.0/bin/omamori");
assert_eq!(
cellar_to_stable_path(p).unwrap(),
PathBuf::from("/opt/homebrew/bin/omamori")
);
}
#[test]
fn cellar_to_stable_intel() {
let p = Path::new("/usr/local/Cellar/omamori/0.6.0/bin/omamori");
assert_eq!(
cellar_to_stable_path(p).unwrap(),
PathBuf::from("/usr/local/bin/omamori")
);
}
#[test]
fn cellar_to_stable_linuxbrew() {
let p = Path::new("/home/linuxbrew/.linuxbrew/Cellar/omamori/0.6.0/bin/omamori");
assert_eq!(
cellar_to_stable_path(p).unwrap(),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/omamori")
);
}
#[test]
fn cellar_to_stable_cargo_install_returns_none() {
assert!(cellar_to_stable_path(Path::new("/Users/dev/.cargo/bin/omamori")).is_none());
}
#[test]
fn cellar_to_stable_manual_copy_returns_none() {
assert!(cellar_to_stable_path(Path::new("/usr/local/bin/omamori")).is_none());
}
#[test]
fn cellar_to_stable_formula_name_irrelevant() {
let p = Path::new("/opt/homebrew/Cellar/some-other-formula/1.0/bin/omamori");
assert_eq!(
cellar_to_stable_path(p).unwrap(),
PathBuf::from("/opt/homebrew/bin/omamori")
);
}
#[test]
fn cellar_to_stable_relative_path_returns_none() {
assert!(cellar_to_stable_path(Path::new("Cellar/omamori/0.6.0/bin/omamori")).is_none());
}
#[test]
fn cellar_to_stable_empty_path_returns_none() {
assert!(cellar_to_stable_path(Path::new("")).is_none());
}
#[test]
fn cellar_to_stable_incomplete_cellar_returns_none() {
assert!(cellar_to_stable_path(Path::new("/opt/homebrew/Cellar/omamori/0.6.0/")).is_none());
}
#[test]
fn resolve_stable_uses_cellar_path_when_stable_missing() {
let cellar = PathBuf::from("/nonexistent/Cellar/omamori/0.6.0/bin/omamori");
assert_eq!(resolve_stable_exe_path(&cellar), cellar);
}
#[test]
fn resolve_stable_passes_through_non_cellar() {
let cargo = PathBuf::from("/Users/dev/.cargo/bin/omamori");
assert_eq!(resolve_stable_exe_path(&cargo), cargo);
}
#[test]
fn resolve_stable_returns_stable_when_exists() {
let dir = std::env::temp_dir().join(format!("omamori-stable-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let bin_dir = dir.join("bin");
fs::create_dir_all(&bin_dir).unwrap();
fs::write(bin_dir.join("omamori"), "binary").unwrap();
let cellar = dir.join("Cellar/omamori/0.6.0/bin/omamori");
let expected = bin_dir.join("omamori");
assert_eq!(resolve_stable_exe_path(&cellar), expected);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn meta_patterns_block_omamori_override() {
let patterns = blocked_string_patterns();
assert!(
patterns.iter().any(|(p, _)| p.contains("omamori override")),
"meta-patterns should block 'omamori override'"
);
}
#[test]
fn codex_pretooluse_script_contains_fail_close_logic() {
let script = render_codex_pretooluse_script(Path::new("/usr/local/bin/omamori"));
assert!(script.contains("exit 2"), "must map non-zero to exit 2");
assert!(script.contains("hook-check --provider codex"));
assert!(script.contains("set -u"));
assert!(script.contains(&format!("v{}", env!("CARGO_PKG_VERSION"))));
}
#[test]
fn codex_hooks_entry_has_status_message() {
let entry = codex_hooks_entry(Path::new("/path/to/wrapper.sh"));
let msg = entry
.pointer("/hooks/0/statusMessage")
.and_then(|v| v.as_str());
assert_eq!(msg, Some(CODEX_STATUS_MESSAGE));
}
#[test]
fn merge_codex_hooks_creates_new_file() {
let dir = std::env::temp_dir().join(format!("omamori-codex-new-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let wrapper = dir.join("wrapper.sh");
fs::write(&wrapper, "#!/bin/sh").unwrap();
let result = merge_codex_hooks(&dir, &wrapper).unwrap();
assert!(matches!(result, CodexHooksOutcome::Created));
let content = fs::read_to_string(dir.join("hooks.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(
doc.pointer("/hooks/PreToolUse/0/hooks/0/statusMessage")
.is_some()
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn merge_codex_hooks_preserves_existing_entries() {
let dir = std::env::temp_dir().join(format!("omamori-codex-merge-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let existing = serde_json::json!({
"hooks": {
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "/tmp/test.sh"}]}],
"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "/other/tool"}]}]
}
});
fs::write(
dir.join("hooks.json"),
serde_json::to_string_pretty(&existing).unwrap(),
)
.unwrap();
let wrapper = dir.join("wrapper.sh");
fs::write(&wrapper, "#!/bin/sh").unwrap();
let result = merge_codex_hooks(&dir, &wrapper).unwrap();
assert!(matches!(result, CodexHooksOutcome::Merged));
let content = fs::read_to_string(dir.join("hooks.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(doc.pointer("/hooks/UserPromptSubmit/0").is_some());
let arr = doc
.pointer("/hooks/PreToolUse")
.unwrap()
.as_array()
.unwrap();
assert_eq!(arr.len(), 2, "should have original + omamori entry");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn merge_codex_hooks_is_idempotent() {
let dir = std::env::temp_dir().join(format!("omamori-codex-idem-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let wrapper = dir.join("wrapper.sh");
fs::write(&wrapper, "#!/bin/sh").unwrap();
let r1 = merge_codex_hooks(&dir, &wrapper).unwrap();
assert!(matches!(r1, CodexHooksOutcome::Created));
let r2 = merge_codex_hooks(&dir, &wrapper).unwrap();
assert!(matches!(r2, CodexHooksOutcome::AlreadyPresent));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn merge_codex_hooks_skips_invalid_json() {
let dir = std::env::temp_dir().join(format!("omamori-codex-bad-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("hooks.json"), "{ not valid json }}}").unwrap();
let wrapper = dir.join("wrapper.sh");
fs::write(&wrapper, "#!/bin/sh").unwrap();
let result = merge_codex_hooks(&dir, &wrapper).unwrap();
assert!(matches!(result, CodexHooksOutcome::Skipped(_)));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn remove_codex_hooks_entry_cleans_up() {
let dir = std::env::temp_dir().join(format!("omamori-codex-rm-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let wrapper = dir.join("wrapper.sh");
fs::write(&wrapper, "#!/bin/sh").unwrap();
merge_codex_hooks(&dir, &wrapper).unwrap();
let content = fs::read_to_string(dir.join("hooks.json")).unwrap();
assert!(content.contains(CODEX_STATUS_MESSAGE));
let raw = fs::read_to_string(dir.join("hooks.json")).unwrap();
let mut doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
if let Some(arr) = doc
.pointer_mut("/hooks/PreToolUse")
.and_then(|v| v.as_array_mut())
{
arr.retain(|e| {
e.pointer("/hooks/0/statusMessage").and_then(|v| v.as_str())
!= Some(CODEX_STATUS_MESSAGE)
});
}
fs::write(
dir.join("hooks.json"),
serde_json::to_string_pretty(&doc).unwrap(),
)
.unwrap();
let cleaned = fs::read_to_string(dir.join("hooks.json")).unwrap();
assert!(!cleaned.contains(CODEX_STATUS_MESSAGE));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn is_real_directory_rejects_symlinks() {
let dir = std::env::temp_dir().join(format!("omamori-symdir-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let real = dir.join("real");
let link = dir.join("link");
fs::create_dir_all(&real).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(is_real_directory(&real));
#[cfg(unix)]
assert!(!is_real_directory(&link));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn update_codex_config_skips_non_table_features() {
let dir = std::env::temp_dir().join(format!("omamori-toml-bad-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("config.toml"), "features = \"oops\"\n").unwrap();
let result = update_codex_config(&dir).unwrap();
assert!(
matches!(result, CodexConfigOutcome::Skipped(ref s) if s.contains("not a table")),
"should skip when features is not a table, got: {result:?}"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn update_codex_config_adds_feature_flag() {
let dir = std::env::temp_dir().join(format!("omamori-toml-add-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("config.toml"), "model = \"gpt-5.3-codex\"\n").unwrap();
let result = update_codex_config(&dir).unwrap();
assert!(matches!(result, CodexConfigOutcome::Added));
let content = fs::read_to_string(dir.join("config.toml")).unwrap();
assert!(content.contains("codex_hooks = true"));
assert!(dir.join("config.toml.bak").exists());
let _ = fs::remove_dir_all(dir);
}
#[test]
fn update_codex_config_idempotent() {
let dir = std::env::temp_dir().join(format!("omamori-toml-idem-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("config.toml"), "[features]\ncodex_hooks = true\n").unwrap();
let result = update_codex_config(&dir).unwrap();
assert!(matches!(result, CodexConfigOutcome::AlreadyEnabled));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn update_codex_config_respects_explicit_false() {
let dir = std::env::temp_dir().join(format!("omamori-toml-false-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("config.toml"), "[features]\ncodex_hooks = false\n").unwrap();
let result = update_codex_config(&dir).unwrap();
assert!(matches!(result, CodexConfigOutcome::ExplicitlyDisabled));
let content = fs::read_to_string(dir.join("config.toml")).unwrap();
assert!(content.contains("codex_hooks = false"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn blocked_string_patterns_include_omamori_override() {
let patterns = blocked_string_patterns();
assert!(
patterns.iter().any(|(p, _)| *p == "omamori override"),
"blocked_string_patterns should include 'omamori override'"
);
}
#[test]
#[serial_test::serial]
fn auto_setup_codex_skips_without_env() {
let saved = std::env::var_os("CODEX_CI");
unsafe { std::env::remove_var("CODEX_CI") };
let dir = std::env::temp_dir().join(format!("omamori-codex-g12-1-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let result = auto_setup_codex_if_needed(&dir);
assert!(!result, "should skip when CODEX_CI is not set");
let _ = fs::remove_dir_all(&dir);
if let Some(v) = saved {
unsafe { std::env::set_var("CODEX_CI", v) };
}
}
#[test]
#[serial_test::serial]
fn auto_setup_codex_skips_when_wrapper_exists() {
let saved = std::env::var_os("CODEX_CI");
unsafe { std::env::remove_var("CODEX_CI") };
let dir = std::env::temp_dir().join(format!("omamori-codex-g12-2-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let hooks_dir = dir.join("hooks");
fs::create_dir_all(&hooks_dir).unwrap();
fs::write(hooks_dir.join("codex-pretooluse.sh"), "#!/bin/sh\n").unwrap();
let result = auto_setup_codex_if_needed(&dir);
assert!(!result);
let _ = fs::remove_dir_all(&dir);
if let Some(v) = saved {
unsafe { std::env::set_var("CODEX_CI", v) };
}
}
fn fresh_test_dir(tag: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("omamori-claude-{}-{}", tag, std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn fake_script(dir: &Path) -> std::path::PathBuf {
let omamori_root = dir.join(".omamori");
let hooks_dir = omamori_root.join("hooks");
fs::create_dir_all(&hooks_dir).unwrap();
let script = hooks_dir.join("claude-pretooluse.sh");
fs::write(&script, "#!/bin/sh\n# omamori hook v\nexit 0\n").unwrap();
script
}
fn with_test_home<R>(home: &Path, f: impl FnOnce() -> R) -> R {
let saved = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", home) };
let result = f();
match saved {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
result
}
#[test]
#[serial_test::serial]
fn merge_claude_creates_when_missing() {
let dir = fresh_test_dir("v001");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Created));
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
doc.pointer("/hooks/PreToolUse/0/matcher")
.and_then(|v| v.as_str()),
Some("Bash")
);
assert_eq!(
doc.pointer("/hooks/PreToolUse/0/x-omamori-version")
.and_then(|v| v.as_str()),
Some(env!("CARGO_PKG_VERSION"))
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_preserves_user_hooks() {
let dir = fresh_test_dir("v002");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let user_doc = serde_json::json!({
"hooks": {
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "/usr/local/bin/userhook"}]}],
"PreToolUse": [{
"matcher": "Edit",
"hooks": [{"type": "command", "command": "/usr/local/bin/another"}]
}]
},
"theme": "dark"
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&user_doc).unwrap(),
)
.unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Merged));
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
doc.pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
.and_then(|v| v.as_str()),
Some("/usr/local/bin/userhook")
);
let pre = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(pre.len(), 2, "user entry + omamori entry");
assert_eq!(doc.get("theme").and_then(|v| v.as_str()), Some("dark"));
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_is_idempotent() {
let dir = fresh_test_dir("v003");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let r1 = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(r1, ClaudeSettingsOutcome::Created));
let r2 = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(r2, ClaudeSettingsOutcome::AlreadyPresent));
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
doc.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(1)
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_migrates_legacy_boolean_matcher() {
let dir = fresh_test_dir("v004");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let omamori_cmd = shell_words::quote(&script.display().to_string()).into_owned();
let stale = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "tool == \"Bash\"",
"hooks": [{"type": "command", "command": omamori_cmd.clone()}]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&stale).unwrap(),
)
.unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::MatcherMigrated));
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
doc.pointer("/hooks/PreToolUse/0/matcher")
.and_then(|v| v.as_str()),
Some("Bash"),
"legacy boolean matcher must migrate to simple Bash"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_migrates_wildcard_matcher() {
let dir = fresh_test_dir("v005");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let stale = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "*",
"command": script.display().to_string()
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&stale).unwrap(),
)
.unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::MatcherMigrated));
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
#[cfg(unix)]
fn merge_claude_writes_with_mode_0o600() {
use std::os::unix::fs::PermissionsExt;
let dir = fresh_test_dir("v006");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let _ = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
let mode = fs::metadata(claude_dir.join("settings.json"))
.unwrap()
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"SEC-3: settings.json must be written with mode 0o600"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_skips_corrupted_json() {
let dir = fresh_test_dir("v007");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("settings.json"), "{ not valid }}}").unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Skipped(_)));
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
assert_eq!(raw, "{ not valid }}}", "must not overwrite on parse error");
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_handles_large_settings_file() {
let dir = fresh_test_dir("v008");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let mut user_entries = Vec::new();
for i in 0..500 {
user_entries.push(serde_json::json!({
"matcher": format!("Tool{i}"),
"hooks": [{"type": "command", "command": format!("/path/{i}")}]
}));
}
let user_doc = serde_json::json!({
"hooks": { "PreToolUse": user_entries }
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&user_doc).unwrap(),
)
.unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Merged));
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
doc.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(501)
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_handles_empty_file() {
let dir = fresh_test_dir("v009");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("settings.json"), "").unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Skipped(_)));
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
#[cfg(unix)]
fn merge_claude_skips_symlink() {
let dir = fresh_test_dir("v011");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let real = dir.join("real-settings.json");
fs::write(&real, "{}").unwrap();
std::os::unix::fs::symlink(&real, claude_dir.join("settings.json")).unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Skipped(_)));
assert_eq!(fs::read_to_string(&real).unwrap(), "{}");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn is_legacy_matcher_classifies_correctly() {
assert!(is_legacy_matcher("*"));
assert!(is_legacy_matcher("tool == \"Bash\""));
assert!(is_legacy_matcher("tool == \"Bash\" && tool == \"Edit\""));
assert!(is_legacy_matcher("tool == \"Bash\" || tool == \"Edit\""));
assert!(!is_legacy_matcher("Bash"));
assert!(!is_legacy_matcher("Edit"));
assert!(!is_legacy_matcher("Read"));
}
#[test]
fn claude_settings_entry_uses_current_spec() {
let entry = claude_settings_entry(Path::new("/usr/local/.omamori/hooks/x.sh"));
assert_eq!(
entry.get("matcher").and_then(|v| v.as_str()),
Some("Bash"),
"matcher must be simple string"
);
assert!(
entry.pointer("/hooks/0/type").is_some(),
"must use nested hooks array with type field"
);
assert_eq!(
entry.get("x-omamori-version").and_then(|v| v.as_str()),
Some(env!("CARGO_PKG_VERSION")),
"must embed omamori version"
);
}
#[test]
fn entry_is_omamori_managed_rejects_lookalike_dirs() {
let base = Path::new("/home/u/.omamori");
let entry_bak = serde_json::json!({
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/home/u/.omamori-bak/hooks/x.sh" }]
});
let entry_real = serde_json::json!({
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/home/u/.omamori/hooks/x.sh" }]
});
assert!(!entry_is_omamori_managed(&entry_bak, base));
assert!(entry_is_omamori_managed(&entry_real, base));
}
#[test]
fn entry_is_omamori_managed_walks_full_hooks_array() {
let base = Path::new("/opt/omamori");
let entry_at_idx1 = serde_json::json!({
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/usr/local/bin/userhook" },
{ "type": "command", "command": "/opt/omamori/hooks/script.sh" }
]
});
assert!(entry_is_omamori_managed(&entry_at_idx1, base));
}
#[test]
#[serial_test::serial]
fn merge_claude_handles_custom_base_dir() {
let dir = fresh_test_dir("p2-base");
let custom_base = dir.join("custom-omamori");
let hooks_dir = custom_base.join("hooks");
fs::create_dir_all(&hooks_dir).unwrap();
let script = hooks_dir.join("claude-pretooluse.sh");
fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let r1 = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(r1, ClaudeSettingsOutcome::Created));
let r2 = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(
matches!(r2, ClaudeSettingsOutcome::AlreadyPresent),
"custom-base-dir managed entry must be recognised"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn remove_claude_does_not_touch_user_hook_inside_omamori_dir() {
let dir = fresh_test_dir("r4-user-in-omamori");
let script = fake_script(&dir);
let user_inside = dir.join(".omamori").join("hooks").join("user-hook.sh");
fs::write(&user_inside, "#!/bin/sh\nexit 0\n").unwrap();
let saved = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &dir) };
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let omamori_cmd = shell_words::quote(&script.display().to_string()).into_owned();
let user_cmd = shell_words::quote(&user_inside.display().to_string()).into_owned();
let hybrid = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": user_cmd.clone()},
{"type": "command", "command": omamori_cmd}
]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&hybrid).unwrap(),
)
.unwrap();
let omamori_root = dir.join(".omamori");
remove_claude_settings_entry(&omamori_root).unwrap();
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let arr = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(arr.len(), 1);
let inner = arr[0].pointer("/hooks").and_then(|v| v.as_array()).unwrap();
assert_eq!(
inner.len(),
1,
"user hook stored inside omamori base dir must survive"
);
let surviving = inner[0].get("command").and_then(|c| c.as_str()).unwrap();
assert!(
surviving.contains("user-hook.sh"),
"the surviving hook must be the user's, not the omamori canonical: {surviving}"
);
match saved {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn remove_claude_surgically_removes_omamori_from_hybrid() {
let dir = fresh_test_dir("r3-hybrid-surgical");
let script = fake_script(&dir);
let saved = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &dir) };
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let omamori_cmd = shell_words::quote(&script.display().to_string()).into_owned();
let hybrid = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": "/usr/local/bin/userhook"},
{"type": "command", "command": omamori_cmd}
]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&hybrid).unwrap(),
)
.unwrap();
let omamori_root = dir.join(".omamori");
remove_claude_settings_entry(&omamori_root).unwrap();
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let arr = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(arr.len(), 1, "hybrid entry must survive uninstall");
let inner = arr[0].pointer("/hooks").and_then(|v| v.as_array()).unwrap();
assert_eq!(inner.len(), 1, "only user hook should remain");
assert_eq!(
inner[0].get("command").and_then(|c| c.as_str()),
Some("/usr/local/bin/userhook"),
"user hook preserved"
);
match saved {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_does_not_replace_hybrid_entry() {
let dir = fresh_test_dir("r2-hybrid-merge");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let omamori_cmd = shell_words::quote(&script.display().to_string()).into_owned();
let hybrid = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": "/usr/local/bin/userhook"},
{"type": "command", "command": omamori_cmd}
]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&hybrid).unwrap(),
)
.unwrap();
let _ = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let arr = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(
arr.len(),
2,
"hybrid entry must be preserved + canonical pushed"
);
let hybrid_entry = arr
.iter()
.find(|e| {
e.pointer("/hooks")
.and_then(|v| v.as_array())
.map(|a| a.len() == 2)
.unwrap_or(false)
})
.expect("hybrid entry must still have 2 inner hooks");
let inner = hybrid_entry
.pointer("/hooks")
.and_then(|v| v.as_array())
.unwrap();
assert!(
inner
.iter()
.any(|h| h.get("command").and_then(|c| c.as_str())
== Some("/usr/local/bin/userhook")),
"user-managed sibling hook must survive"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn remove_claude_settings_entry_preserves_hybrid_entry() {
let dir = fresh_test_dir("r2-hybrid-uninstall");
let script = fake_script(&dir);
let saved = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &dir) };
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let omamori_cmd = shell_words::quote(&script.display().to_string()).into_owned();
let hybrid_only = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": "/usr/local/bin/userhook"},
{"type": "command", "command": omamori_cmd}
]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&hybrid_only).unwrap(),
)
.unwrap();
let omamori_root = dir.join(".omamori");
remove_claude_settings_entry(&omamori_root).unwrap();
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let arr = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(arr.len(), 1, "hybrid entry must not be deleted");
match saved {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn remove_claude_settings_entry_preserves_user_hooks() {
let dir = fresh_test_dir("p1-uninstall");
let script = fake_script(&dir);
let saved = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &dir) };
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let user_doc = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Edit",
"hooks": [{ "type": "command", "command": "/usr/local/bin/userhook" }]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&user_doc).unwrap(),
)
.unwrap();
merge_claude_settings(&claude_dir, &script).unwrap();
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
doc.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(2)
);
let omamori_root = dir.join(".omamori");
remove_claude_settings_entry(&omamori_root).unwrap();
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let arr = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(arr.len(), 1, "user entry preserved, omamori removed");
assert_eq!(
arr[0].pointer("/hooks/0/command").and_then(|v| v.as_str()),
Some("/usr/local/bin/userhook")
);
match saved {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
let _ = fs::remove_dir_all(dir);
}
}