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(workspace_root: &Path, skills_dir: &Path) -> Vec<String> {
72 vec![
73 absolutize(workspace_root).display().to_string(),
74 absolutize(skills_dir).display().to_string(),
75 absolutize(&std::env::temp_dir()).display().to_string(),
76 ]
77}
78
79fn write_marker(
80 marker_path: &Path,
81 workspace_root: &Path,
82 skills_dir: &Path,
83 ttl: Option<Duration>,
84) -> io::Result<()> {
85 let expires_ms = now_ms() + ttl.unwrap_or(GUARD_TTL).as_millis() as i64;
86 write_json(
87 marker_path,
88 &json!({
89 "active": true,
90 "allowedRoots": marker_allowed_roots(workspace_root, skills_dir),
91 "expiresAt": iso_millis(expires_ms),
92 }),
93 )
94}
95
96fn write_manifest(
97 manifest_path: &Path,
98 settings_path: &Path,
99 settings_existed: bool,
100 settings_backup: Option<String>,
101 marker_path: &Path,
102) -> io::Result<()> {
103 let manifest = GuardManifest {
104 created_at: iso_millis(now_ms()),
105 settings_path: settings_path.display().to_string(),
106 settings_existed,
107 settings_backup,
108 marker_path: marker_path.display().to_string(),
109 };
110 write_json(
111 manifest_path,
112 &serde_json::to_value(&manifest)
113 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
114 )
115}
116
117pub fn install_guard(
122 stage_root: &Path,
123 workspace_root: &Path,
124 guard_exe: &Path,
125 ttl: Option<Duration>,
126) -> io::Result<PathBuf> {
127 install_claude_guard(stage_root, workspace_root, guard_exe, ttl)
128}
129
130pub(crate) fn install_claude_guard(
131 stage_root: &Path,
132 workspace_root: &Path,
133 guard_exe: &Path,
134 ttl: Option<Duration>,
135) -> io::Result<PathBuf> {
136 let skills_dir = stage_root.join(".claude").join("skills");
137 fs::create_dir_all(&skills_dir)?;
138
139 let marker_path = skills_dir.join(GUARD_MARKER);
140 write_marker(&marker_path, workspace_root, &skills_dir, ttl)?;
141
142 let settings_path = stage_root.join(".claude").join("settings.local.json");
143 let settings_existed = settings_path.exists();
144 let backup = if settings_existed {
145 Some(fs::read_to_string(&settings_path)?)
146 } else {
147 None
148 };
149
150 let mut settings: Value = backup
153 .as_deref()
154 .and_then(|s| serde_json::from_str(s).ok())
155 .unwrap_or_else(|| json!({}));
156 let hooks = settings
157 .as_object_mut()
158 .expect("settings is a JSON object")
159 .entry("hooks")
160 .or_insert_with(|| json!({}));
161 let pre = hooks
162 .as_object_mut()
163 .expect("hooks is a JSON object")
164 .entry("PreToolUse")
165 .or_insert_with(|| json!([]));
166 let command = format!(
167 "\"{}\" guard \"{}\"",
168 guard_exe.display(),
169 marker_path.display()
170 );
171 pre.as_array_mut()
172 .expect("PreToolUse is an array")
173 .push(json!({
174 "matcher": CLAUDE_HOOK_MATCHER,
175 "hooks": [ { "type": "command", "command": command } ],
176 }));
177 write_json(&settings_path, &settings)?;
178
179 write_manifest(
180 &skills_dir.join(GUARD_MANIFEST),
181 &settings_path,
182 settings_existed,
183 backup,
184 &marker_path,
185 )?;
186
187 Ok(marker_path)
188}
189
190pub(crate) fn install_codex_guard(
191 stage_root: &Path,
192 workspace_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, workspace_root, &skills_dir, 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 workspace_root: PathBuf,
328 }
329
330 fn setup() -> Case {
331 let tmp = TempDir::new().unwrap();
332 let stage_root = tmp.path().join("stage");
333 fs::create_dir_all(&stage_root).unwrap();
334 let workspace_root = stage_root.join("skills-workspace");
335 Case {
336 _tmp: tmp,
337 stage_root,
338 workspace_root,
339 }
340 }
341
342 fn skills_dir(stage_root: &Path) -> PathBuf {
343 stage_root.join(".claude").join("skills")
344 }
345
346 fn settings_path(stage_root: &Path) -> PathBuf {
347 stage_root.join(".claude").join("settings.local.json")
348 }
349
350 fn codex_hooks_path(stage_root: &Path) -> PathBuf {
351 stage_root.join(".codex").join("hooks.json")
352 }
353
354 fn read_json(path: &Path) -> Value {
355 serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
356 }
357
358 #[test]
359 fn install_writes_an_active_marker_hook_and_manifest() {
360 let c = setup();
361 let exe = Path::new("/g/eval-magic");
362 install_guard(&c.stage_root, &c.workspace_root, exe, None).unwrap();
363
364 let marker = read_json(&skills_dir(&c.stage_root).join(GUARD_MARKER));
365 assert_eq!(marker["active"], json!(true));
366 let expires = marker["expiresAt"].as_str().unwrap();
367 let exp_ms = DateTime::parse_from_rfc3339(expires)
368 .unwrap()
369 .timestamp_millis();
370 assert!(exp_ms > now_ms());
371 assert!(
372 marker["allowedRoots"]
373 .as_array()
374 .unwrap()
375 .iter()
376 .any(|r| r.as_str().unwrap().contains("skills-workspace"))
377 );
378
379 let settings = read_json(&settings_path(&c.stage_root));
380 let hook = &settings["hooks"]["PreToolUse"][0];
381 assert!(hook["matcher"].as_str().unwrap().contains("Write"));
382 assert!(
383 hook["hooks"][0]["command"]
384 .as_str()
385 .unwrap()
386 .contains("guard")
387 );
388
389 assert!(skills_dir(&c.stage_root).join(GUARD_MANIFEST).exists());
390 }
391
392 #[test]
393 fn hook_command_invokes_the_binary_guard_subcommand() {
394 let c = setup();
395 let exe = Path::new("/g/eval-magic");
396 let marker = install_guard(&c.stage_root, &c.workspace_root, exe, None).unwrap();
397 let settings = read_json(&settings_path(&c.stage_root));
398 let command = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
399 .as_str()
400 .unwrap()
401 .to_string();
402 assert_eq!(
403 command,
404 format!("\"/g/eval-magic\" guard \"{}\"", marker.display())
405 );
406 }
407
408 #[test]
409 fn teardown_deletes_settings_it_created() {
410 let c = setup();
411 let exe = Path::new("/g/eval-magic");
412 install_guard(&c.stage_root, &c.workspace_root, exe, None).unwrap();
413 assert!(settings_path(&c.stage_root).exists());
414
415 assert!(teardown_guard(&c.stage_root));
416 assert!(!settings_path(&c.stage_root).exists());
417 assert!(!skills_dir(&c.stage_root).join(GUARD_MARKER).exists());
418 assert!(!skills_dir(&c.stage_root).join(GUARD_MANIFEST).exists());
419 }
420
421 #[test]
422 fn teardown_restores_a_pre_existing_settings_verbatim() {
423 let c = setup();
424 fs::create_dir_all(c.stage_root.join(".claude")).unwrap();
425 let original = format!(
426 "{}\n",
427 serde_json::to_string_pretty(&json!({
428 "permissions": { "allow": ["Bash(ls)"] }
429 }))
430 .unwrap()
431 );
432 fs::write(settings_path(&c.stage_root), &original).unwrap();
433
434 let exe = Path::new("/g/eval-magic");
435 install_guard(&c.stage_root, &c.workspace_root, exe, None).unwrap();
436 assert!(
438 fs::read_to_string(settings_path(&c.stage_root))
439 .unwrap()
440 .contains("PreToolUse")
441 );
442
443 teardown_guard(&c.stage_root);
444 assert_eq!(
445 fs::read_to_string(settings_path(&c.stage_root)).unwrap(),
446 original
447 );
448 }
449
450 #[test]
451 fn teardown_is_a_safe_no_op_when_nothing_is_installed() {
452 let c = setup();
453 assert!(!teardown_guard(&c.stage_root));
454 }
455
456 #[test]
457 fn guard_is_armed_detects_claude_or_codex_marker() {
458 let c = setup();
459 install_guard(
460 &c.stage_root,
461 &c.workspace_root,
462 Path::new("/g/eval-magic"),
463 None,
464 )
465 .unwrap();
466 assert!(guard_is_armed(&c.stage_root));
467 teardown_guard(&c.stage_root);
468 assert!(!guard_is_armed(&c.stage_root));
469
470 install_codex_guard(
471 &c.stage_root,
472 &c.workspace_root,
473 Path::new("/g/eval-magic"),
474 None,
475 )
476 .unwrap();
477 assert!(guard_is_armed(&c.stage_root));
478 }
479
480 #[test]
481 fn guard_is_armed_ignores_missing_inactive_expired_and_malformed_markers() {
482 let c = setup();
483 let marker_path = skills_dir(&c.stage_root).join(GUARD_MARKER);
484 fs::create_dir_all(skills_dir(&c.stage_root)).unwrap();
485
486 assert!(!guard_is_armed(&c.stage_root));
487
488 fs::write(
489 &marker_path,
490 serde_json::to_string(&json!({ "active": false })).unwrap(),
491 )
492 .unwrap();
493 assert!(!guard_is_armed(&c.stage_root));
494
495 fs::write(
496 &marker_path,
497 serde_json::to_string(&json!({
498 "active": true,
499 "expiresAt": iso_millis(now_ms() - 60_000),
500 }))
501 .unwrap(),
502 )
503 .unwrap();
504 assert!(!guard_is_armed(&c.stage_root));
505
506 fs::write(&marker_path, "not json").unwrap();
507 assert!(!guard_is_armed(&c.stage_root));
508 }
509
510 #[test]
511 fn teardown_sweeps_a_stray_marker_even_without_a_manifest() {
512 let c = setup();
513 fs::create_dir_all(skills_dir(&c.stage_root)).unwrap();
514 fs::write(skills_dir(&c.stage_root).join(GUARD_MARKER), "{}").unwrap();
515 assert!(teardown_guard(&c.stage_root));
516 assert!(!skills_dir(&c.stage_root).join(GUARD_MARKER).exists());
517 }
518
519 #[test]
520 fn codex_install_writes_project_hook_marker_and_manifest() {
521 let c = setup();
522 let exe = Path::new("/g/eval-magic");
523 install_codex_guard(&c.stage_root, &c.workspace_root, exe, None).unwrap();
524
525 let marker = read_json(
526 &c.stage_root
527 .join(".agents")
528 .join("skills")
529 .join(GUARD_MARKER),
530 );
531 assert_eq!(marker["active"], json!(true));
532 assert!(
533 marker["allowedRoots"]
534 .as_array()
535 .unwrap()
536 .iter()
537 .any(|r| r.as_str().unwrap().contains(".agents/skills"))
538 );
539
540 let hooks = read_json(&codex_hooks_path(&c.stage_root));
541 let hook = &hooks["hooks"]["PreToolUse"][0];
542 assert!(hook["matcher"].as_str().unwrap().contains("apply_patch"));
543 assert!(
544 hook["hooks"][0]["command"]
545 .as_str()
546 .unwrap()
547 .contains("guard-codex")
548 );
549 assert!(
550 c.stage_root
551 .join(".agents")
552 .join("skills")
553 .join(GUARD_MANIFEST)
554 .exists()
555 );
556 }
557
558 #[test]
559 fn codex_teardown_restores_pre_existing_hooks_json_verbatim() {
560 let c = setup();
561 fs::create_dir_all(c.stage_root.join(".codex")).unwrap();
562 let original = format!(
563 "{}\n",
564 serde_json::to_string_pretty(&json!({
565 "hooks": {
566 "PostToolUse": [
567 {
568 "matcher": "Bash",
569 "hooks": [{ "type": "command", "command": "echo ok" }]
570 }
571 ]
572 }
573 }))
574 .unwrap()
575 );
576 fs::write(codex_hooks_path(&c.stage_root), &original).unwrap();
577
578 install_codex_guard(
579 &c.stage_root,
580 &c.workspace_root,
581 Path::new("/g/eval-magic"),
582 None,
583 )
584 .unwrap();
585 assert!(
586 fs::read_to_string(codex_hooks_path(&c.stage_root))
587 .unwrap()
588 .contains("guard-codex")
589 );
590
591 teardown_guard(&c.stage_root);
592 assert_eq!(
593 fs::read_to_string(codex_hooks_path(&c.stage_root)).unwrap(),
594 original
595 );
596 }
597}