eval-magic 0.6.1

One-stop CLI for running skill evals — measure whether an agent skill actually shifts behavior.
Documentation
use super::*;
use crate::adapters::descriptor::{EMBEDDED_DESCRIPTORS, HarnessDescriptor, load_descriptor};
use crate::sandbox::install::teardown_guard;
use chrono::DateTime;
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,
    }
}

fn descriptor(label: &str) -> HarnessDescriptor {
    let (source, toml_src) = EMBEDDED_DESCRIPTORS
        .iter()
        .find(|(path, _)| path.ends_with(&format!("{label}.toml")))
        .unwrap_or_else(|| panic!("no embedded descriptor for {label}"));
    load_descriptor(toml_src, source).unwrap()
}

fn install(label: &str, stage_root: &Path) -> PathBuf {
    let descriptor = descriptor(label);
    let skills = resolve_rel(stage_root, descriptor.skills_dir.as_deref().unwrap());
    install_guard(
        descriptor.guard.as_ref().unwrap(),
        &skills,
        stage_root,
        Path::new("/g/eval-magic"),
        None,
    )
    .unwrap()
}

fn verdict(label: &str, payload: &str, marker: Option<GuardMarker>) -> Option<String> {
    let descriptor = descriptor(label);
    guard_verdict(descriptor.guard.as_ref().unwrap(), label, payload, marker)
}

fn marker() -> GuardMarker {
    GuardMarker {
        active: Some(true),
        allowed_roots: Some(vec!["/work/.eval-magic".to_string()]),
        expires_at: None,
        denial_log_path: None,
    }
}

fn codex_bash_verdict(command: &str) -> Option<String> {
    let payload = json!({
        "hook_event_name": "PreToolUse",
        "cwd": "/work/.eval-magic",
        "tool_name": "Bash",
        "tool_input": { "command": command },
    });
    verdict("codex", &payload.to_string(), Some(marker()))
}

fn claude_skills_dir(stage_root: &Path) -> PathBuf {
    stage_root.join(".claude").join("skills")
}

fn settings_path(stage_root: &Path) -> PathBuf {
    stage_root.join(".claude").join("settings.local.json")
}

fn denial_log_path(stage_root: &Path) -> PathBuf {
    stage_root
        .join(".eval-magic-outputs")
        .join("guard-denials.jsonl")
}

fn read_json(path: &Path) -> Value {
    serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
}

fn absolutize(path: &Path) -> PathBuf {
    std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())
}

#[test]
fn install_writes_an_active_marker_hook_manifest_and_empty_denial_log() {
    let case = setup();
    install("claude-code", &case.stage_root);

    let marker = read_json(&claude_skills_dir(&case.stage_root).join(GUARD_MARKER));
    assert_eq!(marker["active"], json!(true));
    let expires = marker["expiresAt"].as_str().unwrap();
    let exp_ms = DateTime::parse_from_rfc3339(expires)
        .unwrap()
        .timestamp_millis();
    assert!(exp_ms > now_ms());
    let env = absolutize(&case.stage_root).display().to_string();
    assert!(
        marker["allowedRoots"]
            .as_array()
            .unwrap()
            .iter()
            .any(|root| root.as_str().unwrap() == env)
    );

    let settings = read_json(&settings_path(&case.stage_root));
    let hook = &settings["hooks"]["PreToolUse"][0];
    assert!(hook["matcher"].as_str().unwrap().contains("Write"));
    assert!(
        hook["hooks"][0]["command"]
            .as_str()
            .unwrap()
            .contains("guard")
    );
    assert!(
        claude_skills_dir(&case.stage_root)
            .join(GUARD_MANIFEST)
            .exists()
    );
    assert_eq!(
        marker["denialLogPath"],
        json!(absolutize(&denial_log_path(&case.stage_root)))
    );
    assert_eq!(
        fs::read_to_string(denial_log_path(&case.stage_root)).unwrap(),
        ""
    );
}

#[test]
fn rearming_truncates_stale_guard_denials() {
    let case = setup();
    install("codex", &case.stage_root);
    fs::write(denial_log_path(&case.stage_root), "stale denial\n").unwrap();

    install("codex", &case.stage_root);

    assert_eq!(
        fs::read_to_string(denial_log_path(&case.stage_root)).unwrap(),
        ""
    );
}

#[test]
fn teardown_preserves_guard_denial_evidence() {
    let case = setup();
    install("claude-code", &case.stage_root);
    assert!(settings_path(&case.stage_root).exists());
    fs::write(denial_log_path(&case.stage_root), "preserved evidence\n").unwrap();

    assert!(teardown_guard(&case.stage_root));
    assert!(!settings_path(&case.stage_root).exists());
    assert!(
        !claude_skills_dir(&case.stage_root)
            .join(GUARD_MARKER)
            .exists()
    );
    assert!(
        !claude_skills_dir(&case.stage_root)
            .join(GUARD_MANIFEST)
            .exists()
    );
    assert_eq!(
        fs::read_to_string(denial_log_path(&case.stage_root)).unwrap(),
        "preserved evidence\n"
    );
}

#[test]
fn codex_apply_patch_command_resolves_relative_targets_from_hook_cwd() {
    let payload = r#"{
        "hook_event_name": "PreToolUse",
        "cwd": "/work/.eval-magic/eval-root",
        "tool_name": "apply_patch",
        "tool_input": {
            "command": "*** Begin Patch\n*** Update File: fixtures/input.txt\n*** Move to: fixtures/output.txt\n*** End Patch\n"
        }
    }"#;
    assert_eq!(verdict("codex", payload, Some(marker())), None);
}

#[test]
fn codex_relative_redirection_inside_hook_cwd_allows() {
    let payload = r#"{
        "hook_event_name": "PreToolUse",
        "cwd": "/work/.eval-magic/eval-root",
        "tool_name": "Bash",
        "tool_input": { "command": "printf '%s\n' done > final-message.md" }
    }"#;
    assert_eq!(verdict("codex", payload, Some(marker())), None);
}

#[test]
fn codex_allowed_root_mention_does_not_hide_an_outside_redirection() {
    let payload = r#"{
        "hook_event_name": "PreToolUse",
        "cwd": "/work/.eval-magic/eval-root",
        "tool_name": "Bash",
        "tool_input": {
            "command": "printf '%s\n' /work/.eval-magic/eval-root > /etc/eval-magic-out"
        }
    }"#;
    let out = verdict("codex", payload, Some(marker())).expect("should block");
    assert!(
        serde_json::from_str::<Value>(&out).unwrap()["reason"]
            .as_str()
            .unwrap()
            .contains("output redirection")
    );
}

#[test]
fn codex_redirection_scanner_allows_literal_targets_inside_hook_cwd() {
    for command in [
        "printf done > /work/.eval-magic/absolute.txt",
        "printf done > fixtures/output.txt",
        "printf done > ./fixtures/output.txt",
        "printf done > .eval-magic-outputs/final-message.md",
        "printf done > final-message.md",
        "printf done 2>>\"quoted path.log\"",
        "printf done >| overwritten.txt",
        "printf done > stdout.txt 2>> stderr.txt",
        "printf done | tee \"quoted path.log\"",
        "printf done | tee -a one.txt two.txt",
        "printf done | sudo tee final-message.md",
    ] {
        assert_eq!(
            codex_bash_verdict(command),
            None,
            "inside literal target should be allowed: {command}"
        );
    }
}

#[test]
fn codex_redirection_scanner_denies_outside_dynamic_and_malformed_targets() {
    for command in [
        "printf done > ../outside.txt",
        "printf done > /etc/out.txt",
        "printf /work/.eval-magic > /etc/decoy.txt",
        "printf done > good.txt 2>> /etc/bad.txt",
        "printf done | tee /etc/out.txt",
        "printf done | sudo tee /etc/out.txt",
        "printf done > \"$OUT\"",
        "printf done > ${OUT}",
        "printf done > \"unterminated",
    ] {
        let out = codex_bash_verdict(command)
            .unwrap_or_else(|| panic!("unsafe target should be denied: {command}"));
        assert!(
            serde_json::from_str::<Value>(&out).unwrap()["reason"]
                .as_str()
                .unwrap()
                .contains("output redirection"),
            "{command}: {out}"
        );
    }
}

#[test]
fn codex_apply_patch_validates_move_source_and_destination() {
    for command in [
        "*** Begin Patch\n*** Update File: /etc/source.txt\n*** Move to: fixtures/output.txt\n*** End Patch\n",
        "*** Begin Patch\n*** Update File: fixtures/source.txt\n*** Move to: /etc/output.txt\n*** End Patch\n",
    ] {
        let payload = json!({
            "hook_event_name": "PreToolUse",
            "cwd": "/work/.eval-magic",
            "tool_name": "apply_patch",
            "tool_input": { "command": command },
        });
        assert!(
            verdict("codex", &payload.to_string(), Some(marker())).is_some(),
            "every move endpoint must stay inside the allowed root"
        );
    }
}

#[test]
fn guard_denials_append_privacy_safe_sorted_metadata_and_allows_do_not_log() {
    let tmp = TempDir::new().unwrap();
    let eval_root = absolutize(&tmp.path().join("eval-root"));
    fs::create_dir_all(&eval_root).unwrap();
    let log_path = tmp.path().join("guard-denials.jsonl");
    fs::write(&log_path, "").unwrap();
    let marker: GuardMarker = serde_json::from_value(json!({
        "active": true,
        "allowedRoots": [eval_root],
        "denialLogPath": absolutize(&log_path),
    }))
    .unwrap();

    let allowed = json!({
        "hook_event_name": "PreToolUse",
        "cwd": eval_root,
        "tool_name": "Bash",
        "tool_input": { "command": "printf done > final-message.md" },
    });
    assert_eq!(
        verdict("codex", &allowed.to_string(), Some(marker.clone())),
        None
    );
    assert_eq!(fs::read_to_string(&log_path).unwrap(), "");

    let denied = json!({
        "hook_event_name": "PreToolUse",
        "cwd": eval_root,
        "tool_name": "Bash",
        "tool_input": {
            "z": "not persisted",
            "command": "printf secret > /etc/eval-magic-out",
            "a": 1,
        },
    });
    assert!(verdict("codex", &denied.to_string(), Some(marker.clone())).is_some());
    let second_denial = json!({
        "hook_event_name": "PreToolUse",
        "cwd": eval_root,
        "tool_name": "Write",
        "tool_input": { "file_path": "/etc/second-out" },
    });
    assert!(verdict("codex", &second_denial.to_string(), Some(marker)).is_some());

    let raw = fs::read_to_string(&log_path).unwrap();
    let lines = raw.lines().collect::<Vec<_>>();
    assert_eq!(lines.len(), 2);
    let record: Value = serde_json::from_str(lines[0]).unwrap();
    assert!(record["timestamp"].as_str().is_some());
    assert_eq!(record["harness"], "codex");
    assert_eq!(record["tool"], "Bash");
    assert!(
        record["reason"]
            .as_str()
            .unwrap()
            .contains("output redirection")
    );
    assert_eq!(record["resolved_targets"], json!(["/etc/eval-magic-out"]));
    assert_eq!(record["input_keys"], json!(["a", "command", "z"]));
    assert!(record.get("command").is_none(), "{record}");
    let second: Value = serde_json::from_str(lines[1]).unwrap();
    assert_eq!(second["tool"], "Write");
    assert_eq!(second["resolved_targets"], json!(["/etc/second-out"]));
    assert_eq!(second["input_keys"], json!(["file_path"]));
    assert!(!raw.contains("secret"), "{raw}");
    assert!(!raw.contains("not persisted"), "{raw}");
}

#[test]
fn guard_denial_logging_failure_preserves_the_block_verdict() {
    let tmp = TempDir::new().unwrap();
    let marker: GuardMarker = serde_json::from_value(json!({
        "active": true,
        "allowedRoots": [tmp.path()],
        "denialLogPath": tmp.path(),
    }))
    .unwrap();
    let payload = json!({
        "hook_event_name": "PreToolUse",
        "cwd": tmp.path(),
        "tool_name": "Write",
        "tool_input": { "file_path": "/etc/passwd" },
    });

    assert!(
        verdict("codex", &payload.to_string(), Some(marker)).is_some(),
        "logging errors must not turn a denial into an allow"
    );
}