use serde_json::json;
use crate::hooks::{HookOutcome, HookRequest, Interceptor, InterceptorError};
use super::*;
struct Answers(&'static str, HookOutcome);
#[async_trait::async_trait]
impl Interceptor for Answers {
fn name(&self) -> &str {
self.0
}
async fn intercept(&self, _call: &HookRequest) -> Result<HookOutcome, InterceptorError> {
Ok(self.1.clone())
}
}
fn context(dir: &Path, tool: &str, input: serde_json::Value) -> PreExecutionContext {
PreExecutionContext {
agent_id: "agent-1".to_string(),
tool_name: tool.to_string(),
tool_call_id: "call-1".to_string(),
input_json: input.to_string(),
working_directory: dir.to_path_buf(),
}
}
fn denying_entry(root: &Path, reason: &'static str) -> WorkspaceGuardEntry {
WorkspaceGuardEntry {
runner: Arc::new(
HookRunner::new(root, Vec::new())
.with_interceptor(Answers("workspace", HookOutcome::Deny(reason.to_string()))),
),
shell: ShellAccess::Granted,
root: canonical(root),
shared: true,
}
}
fn permissive_entry(root: &Path, shell: ShellAccess) -> WorkspaceGuardEntry {
WorkspaceGuardEntry {
runner: Arc::new(HookRunner::new(root, Vec::new())),
shell,
root: canonical(root),
shared: true,
}
}
async fn decide(dispatch: &HookDispatch, context: &PreExecutionContext) -> HookDecision {
dispatch
.pre_tool_execution(context)
.await
.expect("the dispatcher never errors")
}
#[tokio::test]
async fn a_registered_workspace_is_the_one_consulted() {
let dir = tempfile::tempdir().expect("tempdir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(denying_entry(dir.path(), "mine"));
let decision = decide(
&dispatch,
&context(dir.path(), "files", json!({"operations": []})),
)
.await;
assert!(
matches!(&decision, HookDecision::Deny(reason) if reason.contains("mine")),
"{decision:?}"
);
}
#[tokio::test]
async fn an_unknown_directory_runs_host_interceptors_and_nothing_else() {
let known = tempfile::tempdir().expect("tempdir");
let elsewhere = tempfile::tempdir().expect("tempdir");
let bare = Arc::new(HookDispatch::new(Vec::new()));
let _registration = bare.register(denying_entry(known.path(), "mine"));
assert!(matches!(
decide(&bare, &context(elsewhere.path(), "files", json!({}))).await,
HookDecision::Allow
));
let guarded = Arc::new(HookDispatch::new(vec![Arc::new(Answers(
"host",
HookOutcome::Deny("host says no".to_string()),
))]));
let decision = decide(&guarded, &context(elsewhere.path(), "files", json!({}))).await;
assert!(
matches!(&decision, HookDecision::Deny(reason) if reason.contains("host says no")),
"{decision:?}"
);
}
#[tokio::test]
async fn a_dropped_workspace_stops_being_consulted() {
let dir = tempfile::tempdir().expect("tempdir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let registration = dispatch.register(denying_entry(dir.path(), "mine"));
drop(registration);
assert!(matches!(
decide(&dispatch, &context(dir.path(), "files", json!({}))).await,
HookDecision::Allow
));
}
#[tokio::test]
async fn an_earlier_registrations_drop_does_not_evict_a_later_one() {
let dir = tempfile::tempdir().expect("tempdir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let first = dispatch.register(denying_entry(dir.path(), "first"));
let _second = dispatch.register(denying_entry(dir.path(), "second"));
drop(first);
let decision = decide(&dispatch, &context(dir.path(), "files", json!({}))).await;
assert!(
matches!(&decision, HookDecision::Deny(reason) if reason.contains("second")),
"{decision:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn a_symlinked_spelling_reaches_the_same_workspace() {
let dir = tempfile::tempdir().expect("tempdir");
let real = dir.path().join("real");
std::fs::create_dir(&real).expect("dir");
let link = dir.path().join("link");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(denying_entry(&link, "mine"));
let decision = decide(&dispatch, &context(&real, "files", json!({}))).await;
assert!(
matches!(&decision, HookDecision::Deny(reason) if reason.contains("mine")),
"{decision:?}"
);
}
#[tokio::test]
async fn a_shell_denied_workspace_loses_spawns_command_mode_and_keeps_the_rest() {
let dir = tempfile::tempdir().expect("tempdir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(permissive_entry(dir.path(), ShellAccess::Denied));
let command = decide(
&dispatch,
&context(dir.path(), SPAWN, json!({"input": "!rm -rf /"})),
)
.await;
assert!(
matches!(&command, HookDecision::Deny(reason) if reason.contains("commands off")),
"{command:?}"
);
for input in [
json!({"input": "summarise the TODOs"}),
json!({"input": "!!literal"}),
] {
assert!(
matches!(
decide(&dispatch, &context(dir.path(), SPAWN, input.clone())).await,
HookDecision::Allow
),
"{input}"
);
}
let granted = tempfile::tempdir().expect("tempdir");
let _second = dispatch.register(permissive_entry(granted.path(), ShellAccess::Granted));
assert!(matches!(
decide(
&dispatch,
&context(granted.path(), SPAWN, json!({"input": "!ls"}))
)
.await,
HookDecision::Allow
));
}
#[tokio::test]
async fn writes_into_the_protected_git_paths_are_refused() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join(".git/hooks")).expect("hooks dir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(permissive_entry(dir.path(), ShellAccess::Granted));
let denied = [
json!({"operations": [{"op": "create", "path": ".git/hooks/pre-commit", "content": "x"}]}),
json!({"operations": [{"op": "set", "path": ".git/hooks/../hooks/pre-commit", "content": "x"}]}),
json!({"operations": [{"op": "replace", "path": ".git/config", "old": "a", "new": "b"}]}),
json!({"operations": [{"op": "move", "from": "innocent.txt", "to": ".git/hooks/post-merge"}]}),
json!({"operations": [{"op": "delete", "path": ".git/hooks/pre-push"}]}),
];
for input in denied {
let decision = decide(&dispatch, &context(dir.path(), "files", input.clone())).await;
assert!(
matches!(&decision, HookDecision::Deny(reason) if reason.contains("protected git paths")),
"{input} -> {decision:?}"
);
}
let allowed = [
json!({"operations": [{"op": "create", "path": "src/main.rs", "content": "x"}]}),
json!({"operations": [{"op": "create", "path": ".git/info/exclude", "content": "x"}]}),
json!({"operations": [{"op": "read", "path": ".git/hooks/pre-commit"}]}),
];
for input in allowed {
assert!(
matches!(
decide(&dispatch, &context(dir.path(), "files", input.clone())).await,
HookDecision::Allow
),
"{input}"
);
}
}
#[tokio::test]
async fn a_broken_workspace_guard_fails_closed_through_the_dispatcher() {
struct Broken;
#[async_trait::async_trait]
impl Interceptor for Broken {
fn name(&self) -> &str {
"broken"
}
async fn intercept(&self, _call: &HookRequest) -> Result<HookOutcome, InterceptorError> {
Err(std::io::Error::other("the vault is unreachable"))?
}
}
let dir = tempfile::tempdir().expect("tempdir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(WorkspaceGuardEntry {
runner: Arc::new(
HookRunner::new(dir.path(), Vec::new())
.with_reporter(|_| {})
.with_interceptor(Broken),
),
shell: ShellAccess::Granted,
root: canonical(dir.path()),
shared: true,
});
let decision = decide(&dispatch, &context(dir.path(), "files", json!({}))).await;
assert!(
matches!(&decision, HookDecision::Deny(reason) if reason.contains("broken")),
"fail-closed must survive the move onto the dispatcher: {decision:?}"
);
}
#[tokio::test]
async fn a_private_runtimes_workspace_leaves_the_guards_to_its_policy() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join(".git/hooks")).expect("hooks dir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(WorkspaceGuardEntry {
runner: Arc::new(HookRunner::new(dir.path(), Vec::new())),
shell: ShellAccess::Denied,
root: canonical(dir.path()),
shared: false,
});
for (tool, input) in [
(SPAWN, json!({"input": "!ls"})),
(
"files",
json!({"operations": [{"op": "create", "path": ".git/hooks/pre-commit", "content": "x"}]}),
),
] {
assert!(
matches!(
decide(&dispatch, &context(dir.path(), tool, input.clone())).await,
HookDecision::Allow
),
"{tool}: policy, not the dispatcher, refuses on the private path: {input}"
);
}
}