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