use chrono::DateTime;
use serde::Deserialize;
use serde_json::Value;
use super::policy::{
apply_patch_paths, classify_bash, is_patch_tool, is_shell_tool, is_under_any, is_write_tool,
path_arg,
};
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GuardMarker {
#[serde(default)]
pub active: Option<bool>,
#[serde(default)]
pub allowed_roots: Option<Vec<String>>,
#[serde(default)]
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuardDecision {
pub allow: bool,
pub reason: Option<String>,
}
impl GuardDecision {
fn allow() -> Self {
Self {
allow: true,
reason: None,
}
}
fn deny(reason: String) -> Self {
Self {
allow: false,
reason: Some(reason),
}
}
}
pub(crate) fn marker_is_armed(marker: Option<&GuardMarker>, now_ms: i64) -> bool {
let Some(marker) = marker else {
return false;
};
if marker.active != Some(true) {
return false;
}
if let Some(expires_at) = &marker.expires_at {
match DateTime::parse_from_rfc3339(expires_at) {
Ok(exp) if exp.timestamp_millis() <= now_ms => return false,
_ => {}
}
}
true
}
pub fn decide(
tool_name: &str,
tool_input: &Value,
marker: Option<&GuardMarker>,
now_ms: i64,
) -> GuardDecision {
if !marker_is_armed(marker, now_ms) {
return GuardDecision::allow();
}
let roots = marker
.and_then(|m| m.allowed_roots.clone())
.unwrap_or_default();
let repo_root = std::env::current_dir().unwrap_or_default();
if is_write_tool(tool_name) {
if let Some(p) = path_arg(tool_input)
&& !is_under_any(p, &roots, &repo_root)
{
return GuardDecision::deny(format!(
"eval guard: {tool_name} to {p} is outside the eval sandbox (allowed: {})",
roots.join(", ")
));
}
return GuardDecision::allow();
}
if is_patch_tool(tool_name) {
let paths = apply_patch_paths(tool_input);
if paths.is_empty() {
return GuardDecision::deny(format!(
"eval guard: blocked {tool_name} because no patch target path could be determined"
));
}
if let Some(path) = paths.iter().find(|p| !is_under_any(p, &roots, &repo_root)) {
return GuardDecision::deny(format!(
"eval guard: {tool_name} target {path} is outside the eval sandbox (allowed: {})",
roots.join(", ")
));
}
return GuardDecision::allow();
}
if is_shell_tool(tool_name) {
let command = tool_input
.get("command")
.and_then(Value::as_str)
.unwrap_or("");
if let Some(reason) = classify_bash(command, &roots) {
return GuardDecision::deny(format!(
"eval guard: blocked {tool_name} ({reason}) — runs outside the eval sandbox"
));
}
}
GuardDecision::allow()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sandbox::now_ms;
use serde_json::json;
const ROOTS: [&str; 2] = ["/work/.eval-magic", "/work/.claude/skills"];
fn rfc3339(offset_ms: i64) -> String {
DateTime::from_timestamp_millis(now_ms() + offset_ms)
.unwrap()
.to_rfc3339()
}
fn future() -> String {
rfc3339(60_000)
}
fn past() -> String {
rfc3339(-60_000)
}
fn marker() -> GuardMarker {
GuardMarker {
active: Some(true),
allowed_roots: Some(ROOTS.iter().map(|s| s.to_string()).collect()),
expires_at: Some(future()),
}
}
fn decide_now(tool: &str, input: Value, m: Option<&GuardMarker>) -> GuardDecision {
decide(tool, &input, m, now_ms())
}
#[test]
fn allows_everything_when_marker_is_null() {
let d = decide_now("Write", json!({ "file_path": "/etc/passwd" }), None);
assert!(d.allow);
}
#[test]
fn allows_everything_when_marker_is_inactive_or_expired() {
let inactive = GuardMarker {
active: Some(false),
..marker()
};
assert!(
decide_now(
"Write",
json!({ "file_path": "/etc/passwd" }),
Some(&inactive)
)
.allow
);
let expired = GuardMarker {
expires_at: Some(past()),
..marker()
};
assert!(
decide_now(
"Write",
json!({ "file_path": "/etc/passwd" }),
Some(&expired)
)
.allow
);
}
#[test]
fn allows_a_write_under_an_allowed_root() {
let d = decide_now(
"Write",
json!({ "file_path": "/work/.eval-magic/x/outputs/a.md" }),
Some(&marker()),
);
assert!(d.allow);
}
#[test]
fn denies_a_write_outside_all_allowed_roots() {
let d = decide_now(
"Edit",
json!({ "file_path": "/work/runner/run.ts" }),
Some(&marker()),
);
assert!(!d.allow);
assert!(d.reason.unwrap().to_lowercase().contains("outside"));
}
#[test]
fn denies_an_install_command() {
let d = decide_now(
"Bash",
json!({ "command": "npm install left-pad" }),
Some(&marker()),
);
assert!(!d.allow);
assert!(d.reason.unwrap().to_lowercase().contains("install"));
}
#[test]
fn allows_a_bash_command_scoped_to_an_allowed_root() {
let d = decide_now(
"Bash",
json!({ "command": "echo hi > /work/.eval-magic/x/outputs/log" }),
Some(&marker()),
);
assert!(d.allow);
}
#[test]
fn allows_non_mutating_bash_and_read_tools() {
assert!(decide_now("Bash", json!({ "command": "ls -la /" }), Some(&marker())).allow);
assert!(
decide_now(
"Read",
json!({ "file_path": "/etc/passwd" }),
Some(&marker())
)
.allow
);
}
#[test]
fn denies_git_worktree_add() {
let d = decide_now(
"Bash",
json!({ "command": "git worktree add ../wt -b scratch" }),
Some(&marker()),
);
assert!(!d.allow);
assert!(d.reason.unwrap().to_lowercase().contains("worktree"));
}
#[test]
fn denies_apply_patch_outside_allowed_roots() {
let d = decide_now(
"apply_patch",
json!({ "files": ["/work/runner/src/lib.rs"] }),
Some(&marker()),
);
assert!(!d.allow);
assert!(d.reason.unwrap().contains("apply_patch"));
}
#[test]
fn allows_apply_patch_inside_allowed_roots() {
let d = decide_now(
"apply_patch",
json!({ "files": ["/work/.eval-magic/eval/outputs/out.md"] }),
Some(&marker()),
);
assert!(d.allow);
}
#[test]
fn denies_apply_patch_without_a_known_target() {
let d = decide_now("apply_patch", json!({}), Some(&marker()));
assert!(!d.allow);
assert!(d.reason.unwrap().contains("no patch target"));
}
#[test]
fn denies_bash_that_creates_a_path_under_dot_claude_via_non_redirect_verb() {
assert!(
!decide_now(
"Bash",
json!({ "command": "mkdir -p .claude/foo" }),
Some(&marker())
)
.allow
);
assert!(
!decide_now(
"Bash",
json!({ "command": "cp out.txt .claude/bar" }),
Some(&marker())
)
.allow
);
}
#[test]
fn denies_bash_that_creates_a_bare_skills_dir() {
assert!(
!decide_now(
"Bash",
json!({ "command": "mkdir skills" }),
Some(&marker())
)
.allow
);
assert!(
!decide_now(
"Bash",
json!({ "command": "cp -r src ./skills" }),
Some(&marker())
)
.allow
);
}
#[test]
fn still_allows_reads_of_dot_claude_with_no_create_verb() {
assert!(
decide_now(
"Bash",
json!({ "command": "cat .claude/settings.json" }),
Some(&marker())
)
.allow
);
assert!(decide_now("Bash", json!({ "command": "ls .claude" }), Some(&marker())).allow);
}
#[test]
fn allows_a_create_scoped_to_the_dot_claude_skills_staging_root() {
let d = decide_now(
"Bash",
json!({ "command": "mkdir -p /work/.claude/skills/staged-x" }),
Some(&marker()),
);
assert!(d.allow);
}
#[test]
fn denies_bash_that_creates_a_path_under_dot_codex_via_non_redirect_verb() {
assert!(
!decide_now(
"Bash",
json!({ "command": "mkdir -p .codex/foo" }),
Some(&marker())
)
.allow
);
assert!(
!decide_now(
"Bash",
json!({ "command": "cp evil.json .codex/hooks.json" }),
Some(&marker())
)
.allow
);
}
#[test]
fn denies_bash_that_creates_a_path_under_dot_agents_via_non_redirect_verb() {
assert!(
!decide_now(
"Bash",
json!({ "command": "mkdir -p .agents/foo" }),
Some(&marker())
)
.allow
);
}
#[test]
fn denies_bash_that_creates_a_path_under_dot_opencode_via_non_redirect_verb() {
assert!(
!decide_now(
"Bash",
json!({ "command": "touch .opencode/opencode.json" }),
Some(&marker())
)
.allow
);
}
#[test]
fn still_allows_reads_of_other_harness_config_dirs_with_no_create_verb() {
for command in [
"cat .codex/hooks.json",
"ls .agents",
"cat .opencode/skills/x/SKILL.md",
] {
assert!(
decide_now("Bash", json!({ "command": command }), Some(&marker())).allow,
"{command} should stay allowed"
);
}
}
#[test]
fn allows_a_create_scoped_to_a_codex_skills_staging_root() {
let codex_marker = GuardMarker {
allowed_roots: Some(vec![
"/work/.eval-magic".to_string(),
"/work/.agents/skills".to_string(),
]),
..marker()
};
let d = decide_now(
"Bash",
json!({ "command": "mkdir -p /work/.agents/skills/staged-x" }),
Some(&codex_marker),
);
assert!(d.allow);
}
#[test]
fn does_not_flag_a_skills_prefixed_dir_as_a_bare_skills_write() {
let d = decide_now(
"Bash",
json!({ "command": "mkdir -p /work/skills-data/x/outputs" }),
Some(&marker()),
);
assert!(d.allow);
}
}