eval_magic/sandbox/
install.rs1use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17use chrono::{DateTime, SecondsFormat};
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use serde_json::json;
21
22use crate::core::Harness;
23
24use super::now_ms;
25use super::{guard::read_marker, marker_is_armed};
26
27pub const GUARD_MARKER: &str = ".slow-powers-eval-guard.json";
29pub const GUARD_MANIFEST: &str = ".slow-powers-eval-guard-manifest.json";
31
32const GUARD_TTL: Duration = Duration::from_secs(6 * 60 * 60); #[derive(Debug, Serialize, Deserialize)]
39struct GuardManifest {
40 created_at: String,
41 settings_path: String,
42 settings_existed: bool,
43 settings_backup: Option<String>,
44 marker_path: String,
45}
46
47pub(crate) fn iso_millis(ms: i64) -> String {
50 DateTime::from_timestamp_millis(ms)
51 .unwrap_or_default()
52 .to_rfc3339_opts(SecondsFormat::Millis, true)
53}
54
55fn absolutize(p: &Path) -> PathBuf {
58 std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
59}
60
61pub(crate) fn write_json(path: &Path, value: &Value) -> io::Result<()> {
64 let mut text = serde_json::to_string_pretty(value)
65 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
66 text.push('\n');
67 fs::write(path, text)
68}
69
70fn marker_allowed_roots(stage_root: &Path) -> Vec<String> {
80 vec![
81 absolutize(stage_root).display().to_string(),
82 absolutize(&std::env::temp_dir()).display().to_string(),
83 ]
84}
85
86pub(crate) fn write_marker(
90 marker_path: &Path,
91 stage_root: &Path,
92 ttl: Option<Duration>,
93) -> io::Result<()> {
94 let expires_ms = now_ms() + ttl.unwrap_or(GUARD_TTL).as_millis() as i64;
95 write_json(
96 marker_path,
97 &json!({
98 "active": true,
99 "allowedRoots": marker_allowed_roots(stage_root),
100 "expiresAt": iso_millis(expires_ms),
101 }),
102 )
103}
104
105pub(crate) fn write_manifest(
107 manifest_path: &Path,
108 settings_path: &Path,
109 settings_existed: bool,
110 settings_backup: Option<String>,
111 marker_path: &Path,
112) -> io::Result<()> {
113 let manifest = GuardManifest {
114 created_at: iso_millis(now_ms()),
115 settings_path: settings_path.display().to_string(),
116 settings_existed,
117 settings_backup,
118 marker_path: marker_path.display().to_string(),
119 };
120 write_json(
121 manifest_path,
122 &serde_json::to_value(&manifest)
123 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
124 )
125}
126
127pub fn teardown_guard(stage_root: &Path) -> bool {
132 let mut torn = false;
133 for harness in Harness::known() {
134 let adapter = crate::adapters::adapter_for(harness);
135 if let Some(skills_dir) = adapter.skills_dir(stage_root) {
138 torn |= teardown_guard_from_skills_dir(&skills_dir);
139 }
140 if let Some(hook_dir) = adapter.guard_hook_cleanup_dir(stage_root) {
141 let _ = prune_if_empty(&hook_dir);
142 }
143 }
144 torn
145}
146
147pub(crate) fn guard_is_armed(stage_root: &Path) -> bool {
149 let now = now_ms();
150 Harness::known().any(|harness| {
151 crate::adapters::adapter_for(harness)
152 .skills_dir(stage_root)
153 .is_some_and(|skills_dir| {
154 let marker_path = skills_dir.join(GUARD_MARKER);
155 marker_is_armed(read_marker(&marker_path).as_ref(), now)
156 })
157 })
158}
159
160fn teardown_guard_from_skills_dir(skills_dir: &Path) -> bool {
161 let manifest_path = skills_dir.join(GUARD_MANIFEST);
162 let marker_path = skills_dir.join(GUARD_MARKER);
163
164 let Ok(manifest_text) = fs::read_to_string(&manifest_path) else {
165 if marker_path.exists() {
167 let _ = fs::remove_file(&marker_path);
168 return true;
169 }
170 return false;
171 };
172
173 let Ok(manifest) = serde_json::from_str::<GuardManifest>(&manifest_text) else {
174 let _ = fs::remove_file(&manifest_path);
176 let _ = fs::remove_file(&marker_path);
177 return true;
178 };
179
180 match (manifest.settings_existed, &manifest.settings_backup) {
181 (true, Some(backup)) => {
182 let _ = fs::write(&manifest.settings_path, backup);
183 }
184 _ => {
185 let _ = fs::remove_file(&manifest.settings_path);
186 }
187 }
188 let _ = fs::remove_file(&manifest.marker_path);
189 let _ = fs::remove_file(&manifest_path);
190 true
191}
192
193fn prune_if_empty(dir: &Path) -> io::Result<()> {
194 if dir.exists() && fs::read_dir(dir)?.next().is_none() {
195 fs::remove_dir_all(dir)?;
196 }
197 Ok(())
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use tempfile::TempDir;
204
205 struct Case {
206 _tmp: TempDir,
207 stage_root: PathBuf,
208 }
209
210 fn setup() -> Case {
211 let tmp = TempDir::new().unwrap();
212 let stage_root = tmp.path().join("stage");
213 fs::create_dir_all(&stage_root).unwrap();
214 Case {
215 _tmp: tmp,
216 stage_root,
217 }
218 }
219
220 #[test]
221 fn teardown_is_a_safe_no_op_when_nothing_is_installed() {
222 let c = setup();
223 assert!(!teardown_guard(&c.stage_root));
224 }
225
226 #[test]
227 fn teardown_sweeps_stray_marker_without_manifest() {
228 let c = setup();
229 for skills_dir in [
232 c.stage_root.join(".claude").join("skills"),
233 c.stage_root.join(".opencode").join("skills"),
234 ] {
235 fs::create_dir_all(&skills_dir).unwrap();
236 let marker = skills_dir.join(GUARD_MARKER);
237 fs::write(&marker, "{}").unwrap();
238 assert!(teardown_guard(&c.stage_root));
239 assert!(!marker.exists(), "stray marker at {marker:?} was not swept");
240 }
241 }
242
243 #[test]
247 fn claude_install_writes_this_exact_hook_file() {
248 let c = setup();
249 let adapter = crate::adapters::adapter_for(Harness::resolve("claude-code").unwrap());
250 let marker = adapter
251 .install_guard(&c.stage_root, Path::new("/g/eval-magic"), None)
252 .unwrap();
253
254 let settings =
255 fs::read_to_string(c.stage_root.join(".claude").join("settings.local.json")).unwrap();
256 let expected = format!(
257 r#"{{
258 "hooks": {{
259 "PreToolUse": [
260 {{
261 "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
262 "hooks": [
263 {{
264 "type": "command",
265 "command": "\"/g/eval-magic\" guard \"{marker}\""
266 }}
267 ]
268 }}
269 ]
270 }}
271}}
272"#,
273 marker = marker.display()
274 );
275 assert_eq!(settings, expected);
276 }
277
278 #[test]
281 fn codex_install_writes_this_exact_hook_file() {
282 let c = setup();
283 let adapter = crate::adapters::adapter_for(Harness::resolve("codex").unwrap());
284 let marker = adapter
285 .install_guard(&c.stage_root, Path::new("/g/eval-magic"), None)
286 .unwrap();
287
288 let hooks = fs::read_to_string(c.stage_root.join(".codex").join("hooks.json")).unwrap();
289 let expected = format!(
290 r#"{{
291 "hooks": {{
292 "PreToolUse": [
293 {{
294 "matcher": "^Bash$|^apply_patch$|^Edit$|^Write$",
295 "hooks": [
296 {{
297 "type": "command",
298 "command": "\"/g/eval-magic\" guard-codex \"{marker}\"",
299 "timeout": 30,
300 "statusMessage": "Checking eval write boundary"
301 }}
302 ]
303 }}
304 ]
305 }}
306}}
307"#,
308 marker = marker.display()
309 );
310 assert_eq!(hooks, expected);
311 }
312
313 #[test]
314 fn guard_is_armed_detects_claude_or_codex_marker() {
315 let c = setup();
316 let exe = Path::new("/g/eval-magic");
317 let claude = crate::adapters::adapter_for(Harness::resolve("claude-code").unwrap());
318 claude.install_guard(&c.stage_root, exe, None).unwrap();
319 assert!(guard_is_armed(&c.stage_root));
320 teardown_guard(&c.stage_root);
321 assert!(!guard_is_armed(&c.stage_root));
322
323 let codex = crate::adapters::adapter_for(Harness::resolve("codex").unwrap());
324 codex.install_guard(&c.stage_root, exe, None).unwrap();
325 assert!(guard_is_armed(&c.stage_root));
326 }
327}