1use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16use chrono::{DateTime, SecondsFormat};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20use super::now_ms;
21use super::{guard::read_marker, marker_is_armed};
22
23pub const GUARD_MARKER: &str = ".slow-powers-eval-guard.json";
25pub const GUARD_MANIFEST: &str = ".slow-powers-eval-guard-manifest.json";
27
28const GUARD_TTL: Duration = Duration::from_secs(6 * 60 * 60); const CLAUDE_HOOK_MATCHER: &str = "Write|Edit|MultiEdit|NotebookEdit|Bash";
34const CODEX_HOOK_MATCHER: &str = "^Bash$|^apply_patch$|^Edit$|^Write$";
36
37#[derive(Debug, Serialize, Deserialize)]
40struct GuardManifest {
41 created_at: String,
42 settings_path: String,
43 settings_existed: bool,
44 settings_backup: Option<String>,
45 marker_path: String,
46}
47
48fn iso_millis(ms: i64) -> String {
51 DateTime::from_timestamp_millis(ms)
52 .unwrap_or_default()
53 .to_rfc3339_opts(SecondsFormat::Millis, true)
54}
55
56fn absolutize(p: &Path) -> PathBuf {
59 std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
60}
61
62fn write_json(path: &Path, value: &Value) -> io::Result<()> {
65 let mut text = serde_json::to_string_pretty(value)
66 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
67 text.push('\n');
68 fs::write(path, text)
69}
70
71fn marker_allowed_roots(stage_root: &Path) -> Vec<String> {
81 vec![
82 absolutize(stage_root).display().to_string(),
83 absolutize(&std::env::temp_dir()).display().to_string(),
84 ]
85}
86
87fn write_marker(marker_path: &Path, stage_root: &Path, ttl: Option<Duration>) -> io::Result<()> {
88 let expires_ms = now_ms() + ttl.unwrap_or(GUARD_TTL).as_millis() as i64;
89 write_json(
90 marker_path,
91 &json!({
92 "active": true,
93 "allowedRoots": marker_allowed_roots(stage_root),
94 "expiresAt": iso_millis(expires_ms),
95 }),
96 )
97}
98
99fn write_manifest(
100 manifest_path: &Path,
101 settings_path: &Path,
102 settings_existed: bool,
103 settings_backup: Option<String>,
104 marker_path: &Path,
105) -> io::Result<()> {
106 let manifest = GuardManifest {
107 created_at: iso_millis(now_ms()),
108 settings_path: settings_path.display().to_string(),
109 settings_existed,
110 settings_backup,
111 marker_path: marker_path.display().to_string(),
112 };
113 write_json(
114 manifest_path,
115 &serde_json::to_value(&manifest)
116 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
117 )
118}
119
120pub fn install_guard(
125 stage_root: &Path,
126 guard_exe: &Path,
127 ttl: Option<Duration>,
128) -> io::Result<PathBuf> {
129 install_claude_guard(stage_root, guard_exe, ttl)
130}
131
132pub(crate) fn install_claude_guard(
133 stage_root: &Path,
134 guard_exe: &Path,
135 ttl: Option<Duration>,
136) -> io::Result<PathBuf> {
137 let skills_dir = stage_root.join(".claude").join("skills");
138 fs::create_dir_all(&skills_dir)?;
139
140 let marker_path = skills_dir.join(GUARD_MARKER);
141 write_marker(&marker_path, stage_root, ttl)?;
142
143 let settings_path = stage_root.join(".claude").join("settings.local.json");
144 let settings_existed = settings_path.exists();
145 let backup = if settings_existed {
146 Some(fs::read_to_string(&settings_path)?)
147 } else {
148 None
149 };
150
151 let mut settings: Value = backup
154 .as_deref()
155 .and_then(|s| serde_json::from_str(s).ok())
156 .unwrap_or_else(|| json!({}));
157 let hooks = settings
158 .as_object_mut()
159 .expect("settings is a JSON object")
160 .entry("hooks")
161 .or_insert_with(|| json!({}));
162 let pre = hooks
163 .as_object_mut()
164 .expect("hooks is a JSON object")
165 .entry("PreToolUse")
166 .or_insert_with(|| json!([]));
167 let command = format!(
168 "\"{}\" guard \"{}\"",
169 guard_exe.display(),
170 marker_path.display()
171 );
172 pre.as_array_mut()
173 .expect("PreToolUse is an array")
174 .push(json!({
175 "matcher": CLAUDE_HOOK_MATCHER,
176 "hooks": [ { "type": "command", "command": command } ],
177 }));
178 write_json(&settings_path, &settings)?;
179
180 write_manifest(
181 &skills_dir.join(GUARD_MANIFEST),
182 &settings_path,
183 settings_existed,
184 backup,
185 &marker_path,
186 )?;
187
188 Ok(marker_path)
189}
190
191pub(crate) fn install_codex_guard(
192 stage_root: &Path,
193 guard_exe: &Path,
194 ttl: Option<Duration>,
195) -> io::Result<PathBuf> {
196 let skills_dir = stage_root.join(".agents").join("skills");
197 fs::create_dir_all(&skills_dir)?;
198
199 let marker_path = skills_dir.join(GUARD_MARKER);
200 write_marker(&marker_path, stage_root, ttl)?;
201
202 let hooks_path = stage_root.join(".codex").join("hooks.json");
203 if let Some(parent) = hooks_path.parent() {
204 fs::create_dir_all(parent)?;
205 }
206 let hooks_existed = hooks_path.exists();
207 let backup = if hooks_existed {
208 Some(fs::read_to_string(&hooks_path)?)
209 } else {
210 None
211 };
212
213 let mut hooks: Value = backup
214 .as_deref()
215 .and_then(|s| serde_json::from_str(s).ok())
216 .unwrap_or_else(|| json!({}));
217 let hooks_obj = hooks
218 .as_object_mut()
219 .expect("hooks.json root is a JSON object")
220 .entry("hooks")
221 .or_insert_with(|| json!({}));
222 let pre = hooks_obj
223 .as_object_mut()
224 .expect("hooks is a JSON object")
225 .entry("PreToolUse")
226 .or_insert_with(|| json!([]));
227 let command = format!(
228 "\"{}\" guard-codex \"{}\"",
229 guard_exe.display(),
230 marker_path.display()
231 );
232 pre.as_array_mut()
233 .expect("PreToolUse is an array")
234 .push(json!({
235 "matcher": CODEX_HOOK_MATCHER,
236 "hooks": [
237 {
238 "type": "command",
239 "command": command,
240 "timeout": 30,
241 "statusMessage": "Checking eval write boundary",
242 }
243 ],
244 }));
245 write_json(&hooks_path, &hooks)?;
246
247 write_manifest(
248 &skills_dir.join(GUARD_MANIFEST),
249 &hooks_path,
250 hooks_existed,
251 backup,
252 &marker_path,
253 )?;
254
255 Ok(marker_path)
256}
257
258pub fn teardown_guard(stage_root: &Path) -> bool {
262 let torn_claude = teardown_guard_from_skills_dir(&stage_root.join(".claude").join("skills"));
263 let torn_codex = teardown_guard_from_skills_dir(&stage_root.join(".agents").join("skills"));
264 let _ = prune_if_empty(&stage_root.join(".codex"));
265 torn_claude || torn_codex
266}
267
268pub(crate) fn guard_is_armed(stage_root: &Path) -> bool {
270 let now = now_ms();
271 [
272 stage_root.join(".claude").join("skills").join(GUARD_MARKER),
273 stage_root.join(".agents").join("skills").join(GUARD_MARKER),
274 ]
275 .iter()
276 .any(|path| marker_is_armed(read_marker(path).as_ref(), now))
277}
278
279fn teardown_guard_from_skills_dir(skills_dir: &Path) -> bool {
280 let manifest_path = skills_dir.join(GUARD_MANIFEST);
281 let marker_path = skills_dir.join(GUARD_MARKER);
282
283 let Ok(manifest_text) = fs::read_to_string(&manifest_path) else {
284 if marker_path.exists() {
286 let _ = fs::remove_file(&marker_path);
287 return true;
288 }
289 return false;
290 };
291
292 let Ok(manifest) = serde_json::from_str::<GuardManifest>(&manifest_text) else {
293 let _ = fs::remove_file(&manifest_path);
295 let _ = fs::remove_file(&marker_path);
296 return true;
297 };
298
299 match (manifest.settings_existed, &manifest.settings_backup) {
300 (true, Some(backup)) => {
301 let _ = fs::write(&manifest.settings_path, backup);
302 }
303 _ => {
304 let _ = fs::remove_file(&manifest.settings_path);
305 }
306 }
307 let _ = fs::remove_file(&manifest.marker_path);
308 let _ = fs::remove_file(&manifest_path);
309 true
310}
311
312fn prune_if_empty(dir: &Path) -> io::Result<()> {
313 if dir.exists() && fs::read_dir(dir)?.next().is_none() {
314 fs::remove_dir_all(dir)?;
315 }
316 Ok(())
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use tempfile::TempDir;
323
324 struct Case {
325 _tmp: TempDir,
326 stage_root: PathBuf,
327 }
328
329 fn setup() -> Case {
330 let tmp = TempDir::new().unwrap();
331 let stage_root = tmp.path().join("stage");
332 fs::create_dir_all(&stage_root).unwrap();
333 Case {
334 _tmp: tmp,
335 stage_root,
336 }
337 }
338
339 fn skills_dir(stage_root: &Path) -> PathBuf {
340 stage_root.join(".claude").join("skills")
341 }
342
343 fn settings_path(stage_root: &Path) -> PathBuf {
344 stage_root.join(".claude").join("settings.local.json")
345 }
346
347 fn codex_hooks_path(stage_root: &Path) -> PathBuf {
348 stage_root.join(".codex").join("hooks.json")
349 }
350
351 fn read_json(path: &Path) -> Value {
352 serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
353 }
354
355 #[test]
356 fn install_writes_an_active_marker_hook_and_manifest() {
357 let c = setup();
358 let exe = Path::new("/g/eval-magic");
359 install_guard(&c.stage_root, exe, None).unwrap();
360
361 let marker = read_json(&skills_dir(&c.stage_root).join(GUARD_MARKER));
362 assert_eq!(marker["active"], json!(true));
363 let expires = marker["expiresAt"].as_str().unwrap();
364 let exp_ms = DateTime::parse_from_rfc3339(expires)
365 .unwrap()
366 .timestamp_millis();
367 assert!(exp_ms > now_ms());
368 let env = absolutize(&c.stage_root).display().to_string();
369 assert!(
370 marker["allowedRoots"]
371 .as_array()
372 .unwrap()
373 .iter()
374 .any(|r| r.as_str().unwrap() == env)
375 );
376
377 let settings = read_json(&settings_path(&c.stage_root));
378 let hook = &settings["hooks"]["PreToolUse"][0];
379 assert!(hook["matcher"].as_str().unwrap().contains("Write"));
380 assert!(
381 hook["hooks"][0]["command"]
382 .as_str()
383 .unwrap()
384 .contains("guard")
385 );
386
387 assert!(skills_dir(&c.stage_root).join(GUARD_MANIFEST).exists());
388 }
389
390 #[test]
391 fn marker_scopes_allowed_roots_to_the_env_and_temp_only() {
392 let c = setup();
393 let exe = Path::new("/g/eval-magic");
394 install_guard(&c.stage_root, exe, None).unwrap();
395
396 let marker = read_json(&skills_dir(&c.stage_root).join(GUARD_MARKER));
397 let roots: Vec<String> = marker["allowedRoots"]
398 .as_array()
399 .unwrap()
400 .iter()
401 .map(|r| r.as_str().unwrap().to_string())
402 .collect();
403
404 let env = absolutize(&c.stage_root).display().to_string();
408 let temp = absolutize(&std::env::temp_dir()).display().to_string();
409 assert_eq!(roots, vec![env, temp]);
410 assert!(
411 !roots.iter().any(|r| r.ends_with(".eval-magic")),
412 "workspace_root must not be an allowed root: {roots:?}"
413 );
414 }
415
416 #[test]
417 fn hook_command_invokes_the_binary_guard_subcommand() {
418 let c = setup();
419 let exe = Path::new("/g/eval-magic");
420 let marker = install_guard(&c.stage_root, exe, None).unwrap();
421 let settings = read_json(&settings_path(&c.stage_root));
422 let command = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
423 .as_str()
424 .unwrap()
425 .to_string();
426 assert_eq!(
427 command,
428 format!("\"/g/eval-magic\" guard \"{}\"", marker.display())
429 );
430 }
431
432 #[test]
433 fn teardown_deletes_settings_it_created() {
434 let c = setup();
435 let exe = Path::new("/g/eval-magic");
436 install_guard(&c.stage_root, exe, None).unwrap();
437 assert!(settings_path(&c.stage_root).exists());
438
439 assert!(teardown_guard(&c.stage_root));
440 assert!(!settings_path(&c.stage_root).exists());
441 assert!(!skills_dir(&c.stage_root).join(GUARD_MARKER).exists());
442 assert!(!skills_dir(&c.stage_root).join(GUARD_MANIFEST).exists());
443 }
444
445 #[test]
446 fn teardown_restores_a_pre_existing_settings_verbatim() {
447 let c = setup();
448 fs::create_dir_all(c.stage_root.join(".claude")).unwrap();
449 let original = format!(
450 "{}\n",
451 serde_json::to_string_pretty(&json!({
452 "permissions": { "allow": ["Bash(ls)"] }
453 }))
454 .unwrap()
455 );
456 fs::write(settings_path(&c.stage_root), &original).unwrap();
457
458 let exe = Path::new("/g/eval-magic");
459 install_guard(&c.stage_root, exe, None).unwrap();
460 assert!(
462 fs::read_to_string(settings_path(&c.stage_root))
463 .unwrap()
464 .contains("PreToolUse")
465 );
466
467 teardown_guard(&c.stage_root);
468 assert_eq!(
469 fs::read_to_string(settings_path(&c.stage_root)).unwrap(),
470 original
471 );
472 }
473
474 #[test]
475 fn teardown_is_a_safe_no_op_when_nothing_is_installed() {
476 let c = setup();
477 assert!(!teardown_guard(&c.stage_root));
478 }
479
480 #[test]
481 fn guard_is_armed_detects_claude_or_codex_marker() {
482 let c = setup();
483 install_guard(&c.stage_root, Path::new("/g/eval-magic"), None).unwrap();
484 assert!(guard_is_armed(&c.stage_root));
485 teardown_guard(&c.stage_root);
486 assert!(!guard_is_armed(&c.stage_root));
487
488 install_codex_guard(&c.stage_root, Path::new("/g/eval-magic"), None).unwrap();
489 assert!(guard_is_armed(&c.stage_root));
490 }
491
492 #[test]
493 fn guard_is_armed_ignores_missing_inactive_expired_and_malformed_markers() {
494 let c = setup();
495 let marker_path = skills_dir(&c.stage_root).join(GUARD_MARKER);
496 fs::create_dir_all(skills_dir(&c.stage_root)).unwrap();
497
498 assert!(!guard_is_armed(&c.stage_root));
499
500 fs::write(
501 &marker_path,
502 serde_json::to_string(&json!({ "active": false })).unwrap(),
503 )
504 .unwrap();
505 assert!(!guard_is_armed(&c.stage_root));
506
507 fs::write(
508 &marker_path,
509 serde_json::to_string(&json!({
510 "active": true,
511 "expiresAt": iso_millis(now_ms() - 60_000),
512 }))
513 .unwrap(),
514 )
515 .unwrap();
516 assert!(!guard_is_armed(&c.stage_root));
517
518 fs::write(&marker_path, "not json").unwrap();
519 assert!(!guard_is_armed(&c.stage_root));
520 }
521
522 #[test]
523 fn teardown_sweeps_a_stray_marker_even_without_a_manifest() {
524 let c = setup();
525 fs::create_dir_all(skills_dir(&c.stage_root)).unwrap();
526 fs::write(skills_dir(&c.stage_root).join(GUARD_MARKER), "{}").unwrap();
527 assert!(teardown_guard(&c.stage_root));
528 assert!(!skills_dir(&c.stage_root).join(GUARD_MARKER).exists());
529 }
530
531 #[test]
532 fn codex_install_writes_project_hook_marker_and_manifest() {
533 let c = setup();
534 let exe = Path::new("/g/eval-magic");
535 install_codex_guard(&c.stage_root, exe, None).unwrap();
536
537 let marker = read_json(
538 &c.stage_root
539 .join(".agents")
540 .join("skills")
541 .join(GUARD_MARKER),
542 );
543 assert_eq!(marker["active"], json!(true));
544 let env = absolutize(&c.stage_root).display().to_string();
547 assert!(
548 marker["allowedRoots"]
549 .as_array()
550 .unwrap()
551 .iter()
552 .any(|r| r.as_str().unwrap() == env)
553 );
554
555 let hooks = read_json(&codex_hooks_path(&c.stage_root));
556 let hook = &hooks["hooks"]["PreToolUse"][0];
557 assert!(hook["matcher"].as_str().unwrap().contains("apply_patch"));
558 assert!(
559 hook["hooks"][0]["command"]
560 .as_str()
561 .unwrap()
562 .contains("guard-codex")
563 );
564 assert!(
565 c.stage_root
566 .join(".agents")
567 .join("skills")
568 .join(GUARD_MANIFEST)
569 .exists()
570 );
571 }
572
573 #[test]
574 fn codex_teardown_restores_pre_existing_hooks_json_verbatim() {
575 let c = setup();
576 fs::create_dir_all(c.stage_root.join(".codex")).unwrap();
577 let original = format!(
578 "{}\n",
579 serde_json::to_string_pretty(&json!({
580 "hooks": {
581 "PostToolUse": [
582 {
583 "matcher": "Bash",
584 "hooks": [{ "type": "command", "command": "echo ok" }]
585 }
586 ]
587 }
588 }))
589 .unwrap()
590 );
591 fs::write(codex_hooks_path(&c.stage_root), &original).unwrap();
592
593 install_codex_guard(&c.stage_root, Path::new("/g/eval-magic"), None).unwrap();
594 assert!(
595 fs::read_to_string(codex_hooks_path(&c.stage_root))
596 .unwrap()
597 .contains("guard-codex")
598 );
599
600 teardown_guard(&c.stage_root);
601 assert_eq!(
602 fs::read_to_string(codex_hooks_path(&c.stage_root)).unwrap(),
603 original
604 );
605 }
606}