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),
foreign_tools: Default::default(),
shared: true,
}
}
fn permissive_entry(root: &Path, shell: ShellAccess) -> WorkspaceGuardEntry {
WorkspaceGuardEntry {
runner: Arc::new(HookRunner::new(root, Vec::new())),
shell,
root: canonical(root),
foreign_tools: Default::default(),
shared: true,
}
}
async fn decide(dispatch: &HookDispatch, context: &PreExecutionContext) -> HookDecision {
dispatch
.pre_tool_execution(context)
.await
.expect("the dispatcher never errors")
}
struct Rewrites(&'static str);
#[async_trait::async_trait]
impl Interceptor for Rewrites {
fn name(&self) -> &str {
self.0
}
async fn intercept(&self, _call: &HookRequest) -> Result<HookOutcome, InterceptorError> {
Ok(HookOutcome::Allow)
}
async fn review(&self, _result: &HookRequest) -> Result<HookOutcome, InterceptorError> {
Ok(HookOutcome::Replace {
output: json!(self.0),
is_error: false,
reason: None,
})
}
}
fn rewriting_entry(root: &Path, name: &'static str) -> WorkspaceGuardEntry {
WorkspaceGuardEntry {
runner: Arc::new(HookRunner::new(root, Vec::new()).with_interceptor(Rewrites(name))),
shell: ShellAccess::Granted,
root: canonical(root),
foreign_tools: Default::default(),
shared: true,
}
}
fn finished(dir: &Path, output: &str) -> PostExecutionContext {
PostExecutionContext {
agent_id: "agent-1".to_string(),
tool_name: "spawn".to_string(),
tool_call_id: "call-1".to_string(),
input_json: json!({"command": "cat .env"}).to_string(),
working_directory: dir.to_path_buf(),
content: mentra::tool::ToolResultContent::text(output),
is_error: false,
}
}
async fn review(dispatch: &HookDispatch, context: &PostExecutionContext) -> ResultDecision {
dispatch
.post_tool_execution(context)
.await
.expect("the dispatcher never errors")
}
fn replaced(decision: ResultDecision) -> String {
match decision {
ResultDecision::Replace { content, .. } => content.to_display_string(),
other => panic!("expected a replacement, got {other:?}"),
}
}
#[tokio::test]
async fn a_result_is_routed_by_the_same_key_the_call_was() {
let mine = tempfile::tempdir().expect("tempdir");
let theirs = tempfile::tempdir().expect("tempdir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _mine = dispatch.register(rewriting_entry(mine.path(), "mine"));
let _theirs = dispatch.register(rewriting_entry(theirs.path(), "theirs"));
assert_eq!(
replaced(review(&dispatch, &finished(mine.path(), "secret")).await),
"mine"
);
assert_eq!(
replaced(review(&dispatch, &finished(theirs.path(), "secret")).await),
"theirs"
);
}
#[tokio::test]
async fn an_unknown_directorys_result_still_reaches_the_host() {
let elsewhere = tempfile::tempdir().expect("tempdir");
let bare = Arc::new(HookDispatch::new(Vec::new()));
assert_eq!(
review(&bare, &finished(elsewhere.path(), "whatever")).await,
ResultDecision::Keep,
"no workspace and no host guard is nobody to ask"
);
let guarded = Arc::new(HookDispatch::new(vec![Arc::new(Rewrites("host"))]));
assert_eq!(
replaced(review(&guarded, &finished(elsewhere.path(), "whatever")).await),
"host",
"a miss fails open for workspace hooks only, on this seam as on the other"
);
}
#[tokio::test]
async fn a_workspace_with_nobody_to_ask_keeps_its_results() {
let dir = tempfile::tempdir().expect("tempdir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(permissive_entry(dir.path(), ShellAccess::Granted));
assert_eq!(
review(&dispatch, &finished(dir.path(), "untouched")).await,
ResultDecision::Keep
);
}
#[tokio::test]
async fn basis_own_guards_have_nothing_to_say_about_a_result() {
let dir = tempfile::tempdir().expect("tempdir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(permissive_entry(dir.path(), ShellAccess::Denied));
assert_eq!(
review(&dispatch, &finished(dir.path(), "output of a command")).await,
ResultDecision::Keep
);
}
#[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 a_shell_denied_workspace_refuses_a_targeted_command_too() {
let dir = tempfile::tempdir().expect("tempdir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(permissive_entry(dir.path(), ShellAccess::Denied));
for input in [
json!({"input": "!@mac ls"}),
json!({"input": "!@build-box rm -rf /"}),
] {
let decision = decide(&dispatch, &context(dir.path(), SPAWN, input.clone())).await;
assert!(
matches!(&decision, HookDecision::Deny(reason) if reason.contains("commands off")),
"{input}: {decision:?}"
);
}
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": "!@mac 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()),
foreign_tools: Default::default(),
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()),
foreign_tools: Default::default(),
shared: false,
});
for (tool, input) in [
(SPAWN, json!({"input": "!ls"})),
(
"files",
json!({"operations": [{"op": "create", "path": ".git/hooks/pre-commit", "content": "x"}]}),
),
(
"write",
json!({"path": ".git/hooks/pre-commit", "content": "x"}),
),
(
"edit",
json!({"path": ".git/config", "edits": [{"old_string": "a", "new_string": "b"}]}),
),
] {
assert!(
matches!(
decide(&dispatch, &context(dir.path(), tool, input.clone())).await,
HookDecision::Allow
),
"{tool}: policy, not the dispatcher, refuses on the private path: {input}"
);
}
}
#[tokio::test]
async fn the_split_writers_reach_the_same_protected_git_paths() {
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 = [
(
"write",
json!({"path": ".git/hooks/pre-commit", "content": "x"}),
),
(
"write",
json!({"file_path": ".git/hooks/pre-commit", "content": "x"}),
),
(
"write",
json!({"filePath": ".git/hooks/pre-commit", "content": "x"}),
),
(
"write",
json!({"path": ".git/hooks/../hooks/pre-commit", "content": "x"}),
),
("write", json!({"path": ".git/config", "content": "x"})),
(
"edit",
json!({"path": ".git/config", "edits": [{"old_string": "a", "new_string": "b"}]}),
),
(
"edit",
json!({"file_path": ".git/hooks/pre-push", "edits": [{"old_string": "a", "new_string": "b"}]}),
),
];
for (tool, input) in denied {
let decision = decide(&dispatch, &context(dir.path(), tool, input.clone())).await;
assert!(
matches!(&decision, HookDecision::Deny(reason) if reason.contains("protected git paths")),
"{tool} {input} -> {decision:?}"
);
}
let allowed = [
("write", json!({"path": "src/main.rs", "content": "x"})),
(
"write",
json!({"path": ".git/info/exclude", "content": "x"}),
),
("read", json!({"path": ".git/hooks/pre-commit"})),
("ls", json!({"path": ".git/hooks"})),
("grep", json!({"pattern": "curl", "path": ".git/hooks"})),
("glob", json!({"pattern": ".git/hooks/*"})),
];
for (tool, input) in allowed {
assert!(
matches!(
decide(&dispatch, &context(dir.path(), tool, input.clone())).await,
HookDecision::Allow
),
"{tool} {input}"
);
}
}
struct RewritesInput(&'static str, serde_json::Value);
#[async_trait::async_trait]
impl Interceptor for RewritesInput {
fn name(&self) -> &str {
self.0
}
async fn intercept(&self, _call: &HookRequest) -> Result<HookOutcome, InterceptorError> {
Ok(HookOutcome::Modify {
input: self.1.clone(),
reason: Some(format!("rewritten by {}", self.0)),
})
}
}
fn rewriting_input_entry(
root: &Path,
shell: ShellAccess,
input: serde_json::Value,
) -> WorkspaceGuardEntry {
WorkspaceGuardEntry {
runner: Arc::new(
HookRunner::new(root, Vec::new()).with_interceptor(RewritesInput("rewrite", input)),
),
shell,
root: canonical(root),
foreign_tools: Default::default(),
shared: true,
}
}
#[tokio::test]
async fn a_rewrite_into_the_protected_git_paths_is_refused() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join(".git")).expect("git dir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(rewriting_input_entry(
dir.path(),
ShellAccess::Granted,
json!({"operations": [{"op": "set", "path": ".git/config", "content": "x"}]}),
));
let decision = decide(
&dispatch,
&context(
dir.path(),
"files",
json!({"operations": [{"op": "create", "path": "src/main.rs", "content": "x"}]}),
),
)
.await;
let HookDecision::Deny(reason) = &decision else {
panic!("expected the rewrite to be refused, got {decision:?}");
};
assert!(reason.contains("protected git paths"), "{reason}");
assert!(
reason.contains("rewrite") && reason.contains("rewritten by rewrite"),
"{reason}"
);
}
#[tokio::test]
async fn a_rewrite_into_a_command_is_refused_when_commands_are_off() {
let dir = tempfile::tempdir().expect("tempdir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(rewriting_input_entry(
dir.path(),
ShellAccess::Denied,
json!({"input": "!rm -rf /"}),
));
let decision = decide(
&dispatch,
&context(dir.path(), SPAWN, json!({"input": "summarise the TODOs"})),
)
.await;
assert!(
matches!(&decision, HookDecision::Deny(reason) if reason.contains("commands off")),
"{decision:?}"
);
}
#[tokio::test]
async fn an_innocent_rewrite_still_reaches_the_tool() {
let dir = tempfile::tempdir().expect("tempdir");
let dispatch = Arc::new(HookDispatch::new(Vec::new()));
let _registration = dispatch.register(rewriting_input_entry(
dir.path(),
ShellAccess::Denied,
json!({"operations": [{"op": "create", "path": "approved.txt", "content": "x"}]}),
));
let decision = decide(
&dispatch,
&context(
dir.path(),
"files",
json!({"operations": [{"op": "create", "path": "wherever.txt", "content": "x"}]}),
),
)
.await;
let HookDecision::Modify { input_json, .. } = &decision else {
panic!("expected the rewrite to survive, got {decision:?}");
};
assert!(input_json.contains("approved.txt"), "{input_json}");
}