use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, SecondsFormat};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::json;
use crate::core::Harness;
use super::now_ms;
use super::{guard::read_marker, marker_is_armed};
pub const GUARD_MARKER: &str = ".slow-powers-eval-guard.json";
pub const GUARD_MANIFEST: &str = ".slow-powers-eval-guard-manifest.json";
const GUARD_TTL: Duration = Duration::from_secs(6 * 60 * 60);
#[derive(Debug, Serialize, Deserialize)]
struct GuardManifest {
created_at: String,
settings_path: String,
settings_existed: bool,
settings_backup: Option<String>,
marker_path: String,
}
pub(crate) fn iso_millis(ms: i64) -> String {
DateTime::from_timestamp_millis(ms)
.unwrap_or_default()
.to_rfc3339_opts(SecondsFormat::Millis, true)
}
fn absolutize(p: &Path) -> PathBuf {
std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
}
pub(crate) fn write_json(path: &Path, value: &Value) -> io::Result<()> {
let mut text = serde_json::to_string_pretty(value)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
text.push('\n');
fs::write(path, text)
}
fn marker_allowed_roots(stage_root: &Path) -> Vec<String> {
vec![
absolutize(stage_root).display().to_string(),
absolutize(&std::env::temp_dir()).display().to_string(),
]
}
pub(crate) fn write_marker(
marker_path: &Path,
stage_root: &Path,
ttl: Option<Duration>,
) -> io::Result<()> {
let expires_ms = now_ms() + ttl.unwrap_or(GUARD_TTL).as_millis() as i64;
write_json(
marker_path,
&json!({
"active": true,
"allowedRoots": marker_allowed_roots(stage_root),
"expiresAt": iso_millis(expires_ms),
}),
)
}
pub(crate) fn write_manifest(
manifest_path: &Path,
settings_path: &Path,
settings_existed: bool,
settings_backup: Option<String>,
marker_path: &Path,
) -> io::Result<()> {
let manifest = GuardManifest {
created_at: iso_millis(now_ms()),
settings_path: settings_path.display().to_string(),
settings_existed,
settings_backup,
marker_path: marker_path.display().to_string(),
};
write_json(
manifest_path,
&serde_json::to_value(&manifest)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
)
}
pub fn teardown_guard(stage_root: &Path) -> bool {
let mut torn = false;
for harness in Harness::known() {
let adapter = crate::adapters::adapter_for(harness);
if let Some(skills_dir) = adapter.skills_dir(stage_root) {
torn |= teardown_guard_from_skills_dir(&skills_dir);
}
if let Some(hook_dir) = adapter.guard_hook_cleanup_dir(stage_root) {
let _ = prune_if_empty(&hook_dir);
}
}
torn
}
pub(crate) fn guard_is_armed(stage_root: &Path) -> bool {
let now = now_ms();
Harness::known().any(|harness| {
crate::adapters::adapter_for(harness)
.skills_dir(stage_root)
.is_some_and(|skills_dir| {
let marker_path = skills_dir.join(GUARD_MARKER);
marker_is_armed(read_marker(&marker_path).as_ref(), now)
})
})
}
fn teardown_guard_from_skills_dir(skills_dir: &Path) -> bool {
let manifest_path = skills_dir.join(GUARD_MANIFEST);
let marker_path = skills_dir.join(GUARD_MARKER);
let Ok(manifest_text) = fs::read_to_string(&manifest_path) else {
if marker_path.exists() {
let _ = fs::remove_file(&marker_path);
return true;
}
return false;
};
let Ok(manifest) = serde_json::from_str::<GuardManifest>(&manifest_text) else {
let _ = fs::remove_file(&manifest_path);
let _ = fs::remove_file(&marker_path);
return true;
};
match (manifest.settings_existed, &manifest.settings_backup) {
(true, Some(backup)) => {
let _ = fs::write(&manifest.settings_path, backup);
}
_ => {
let _ = fs::remove_file(&manifest.settings_path);
}
}
let _ = fs::remove_file(&manifest.marker_path);
let _ = fs::remove_file(&manifest_path);
true
}
fn prune_if_empty(dir: &Path) -> io::Result<()> {
if dir.exists() && fs::read_dir(dir)?.next().is_none() {
fs::remove_dir_all(dir)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
struct Case {
_tmp: TempDir,
stage_root: PathBuf,
}
fn setup() -> Case {
let tmp = TempDir::new().unwrap();
let stage_root = tmp.path().join("stage");
fs::create_dir_all(&stage_root).unwrap();
Case {
_tmp: tmp,
stage_root,
}
}
#[test]
fn teardown_is_a_safe_no_op_when_nothing_is_installed() {
let c = setup();
assert!(!teardown_guard(&c.stage_root));
}
#[test]
fn teardown_sweeps_stray_marker_without_manifest() {
let c = setup();
for skills_dir in [
c.stage_root.join(".claude").join("skills"),
c.stage_root.join(".opencode").join("skills"),
] {
fs::create_dir_all(&skills_dir).unwrap();
let marker = skills_dir.join(GUARD_MARKER);
fs::write(&marker, "{}").unwrap();
assert!(teardown_guard(&c.stage_root));
assert!(!marker.exists(), "stray marker at {marker:?} was not swept");
}
}
#[test]
fn claude_install_writes_this_exact_hook_file() {
let c = setup();
let adapter = crate::adapters::adapter_for(Harness::resolve("claude-code").unwrap());
let marker = adapter
.install_guard(&c.stage_root, Path::new("/g/eval-magic"), None)
.unwrap();
let settings =
fs::read_to_string(c.stage_root.join(".claude").join("settings.local.json")).unwrap();
let expected = format!(
r#"{{
"hooks": {{
"PreToolUse": [
{{
"matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
"hooks": [
{{
"type": "command",
"command": "\"/g/eval-magic\" guard \"{marker}\""
}}
]
}}
]
}}
}}
"#,
marker = marker.display()
);
assert_eq!(settings, expected);
}
#[test]
fn codex_install_writes_this_exact_hook_file() {
let c = setup();
let adapter = crate::adapters::adapter_for(Harness::resolve("codex").unwrap());
let marker = adapter
.install_guard(&c.stage_root, Path::new("/g/eval-magic"), None)
.unwrap();
let hooks = fs::read_to_string(c.stage_root.join(".codex").join("hooks.json")).unwrap();
let expected = format!(
r#"{{
"hooks": {{
"PreToolUse": [
{{
"matcher": "^Bash$|^apply_patch$|^Edit$|^Write$",
"hooks": [
{{
"type": "command",
"command": "\"/g/eval-magic\" guard-codex \"{marker}\"",
"timeout": 30,
"statusMessage": "Checking eval write boundary"
}}
]
}}
]
}}
}}
"#,
marker = marker.display()
);
assert_eq!(hooks, expected);
}
#[test]
fn guard_is_armed_detects_claude_or_codex_marker() {
let c = setup();
let exe = Path::new("/g/eval-magic");
let claude = crate::adapters::adapter_for(Harness::resolve("claude-code").unwrap());
claude.install_guard(&c.stage_root, exe, None).unwrap();
assert!(guard_is_armed(&c.stage_root));
teardown_guard(&c.stage_root);
assert!(!guard_is_armed(&c.stage_root));
let codex = crate::adapters::adapter_for(Harness::resolve("codex").unwrap());
codex.install_guard(&c.stage_root, exe, None).unwrap();
assert!(guard_is_armed(&c.stage_root));
}
}