use std::{
collections::HashMap,
path::{Component, Path, PathBuf},
sync::{
Arc, RwLock,
atomic::{AtomicU64, Ordering},
},
};
use mentra::{
error::RuntimeError,
runtime::{HookDecision, PreExecutionContext, PreExecutionHook},
};
use crate::{
hooks::HookRunner,
shell::ShellAccess,
tools::{
SPAWN,
spawn::{SpawnMode, parse_spawn},
},
};
pub(crate) struct WorkspaceGuardEntry {
pub(crate) runner: Arc<HookRunner>,
pub(crate) shell: ShellAccess,
pub(crate) root: PathBuf,
pub(crate) shared: bool,
}
pub(crate) struct HookRegistration {
dispatch: Arc<HookDispatch>,
key: PathBuf,
id: u64,
}
impl Drop for HookRegistration {
fn drop(&mut self) {
self.dispatch.deregister(&self.key, self.id);
}
}
struct Registered {
id: u64,
entry: WorkspaceGuardEntry,
}
pub(crate) struct HookDispatch {
interceptors: Vec<Arc<dyn crate::hooks::Interceptor>>,
workspaces: RwLock<HashMap<PathBuf, Registered>>,
next_id: AtomicU64,
}
impl HookDispatch {
pub(crate) fn new(interceptors: Vec<Arc<dyn crate::hooks::Interceptor>>) -> Self {
Self {
interceptors,
workspaces: RwLock::new(HashMap::new()),
next_id: AtomicU64::new(0),
}
}
pub(crate) fn interceptors(&self) -> &[Arc<dyn crate::hooks::Interceptor>] {
&self.interceptors
}
pub(crate) fn register(self: &Arc<Self>, entry: WorkspaceGuardEntry) -> HookRegistration {
let key = canonical(&entry.root);
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
self.workspaces
.write()
.expect("workspace registry poisoned")
.insert(key.clone(), Registered { id, entry });
HookRegistration {
dispatch: Arc::clone(self),
key,
id,
}
}
fn deregister(&self, key: &Path, id: u64) {
let mut workspaces = self
.workspaces
.write()
.expect("workspace registry poisoned");
if workspaces.get(key).is_some_and(|held| held.id == id) {
workspaces.remove(key);
}
}
fn entry_for(
&self,
working_directory: &Path,
) -> Option<(Arc<HookRunner>, ShellAccess, PathBuf, bool)> {
let key = canonical(working_directory);
self.workspaces
.read()
.expect("workspace registry poisoned")
.get(&key)
.map(|held| {
(
Arc::clone(&held.entry.runner),
held.entry.shell,
held.entry.root.clone(),
held.entry.shared,
)
})
}
}
#[async_trait::async_trait]
impl PreExecutionHook for HookDispatch {
async fn pre_tool_execution(
&self,
context: &PreExecutionContext,
) -> Result<HookDecision, RuntimeError> {
let Some((runner, shell, root, shared)) = self.entry_for(&context.working_directory) else {
if self.interceptors.is_empty() {
return Ok(HookDecision::Allow);
}
let runner = self.interceptors.iter().cloned().fold(
HookRunner::new(&context.working_directory, Vec::new()),
|runner, interceptor| runner.with_interceptor(interceptor),
);
return runner.pre_tool_execution(context).await;
};
if shared && let Some(reason) = guard(context, shell, &root) {
return Ok(HookDecision::Deny(reason));
}
runner.pre_tool_execution(context).await
}
}
pub(crate) struct DispatchHook(pub(crate) Arc<HookDispatch>);
#[async_trait::async_trait]
impl PreExecutionHook for DispatchHook {
async fn pre_tool_execution(
&self,
context: &PreExecutionContext,
) -> Result<HookDecision, RuntimeError> {
self.0.pre_tool_execution(context).await
}
}
fn guard(context: &PreExecutionContext, shell: ShellAccess, root: &Path) -> Option<String> {
if context.tool_name == SPAWN && !shell.is_granted() {
let input: serde_json::Value = serde_json::from_str(&context.input_json).ok()?;
if parse_spawn(&input).is_ok_and(|spawn| spawn.mode() == SpawnMode::Command) {
return Some(
"command execution is denied: this workspace was opened with commands off"
.to_string(),
);
}
return None;
}
if context.tool_name == "files" {
let input: serde_json::Value = serde_json::from_str(&context.input_json).ok()?;
let operations = input.get("operations")?.as_array()?;
let denied =
[root.join(".git/hooks"), root.join(".git/config")].map(|p| resolved(root, &p));
for operation in operations {
for raw in write_targets(operation) {
let candidate = resolved(root, Path::new(raw));
if denied.iter().any(|root| candidate.starts_with(root)) {
return Some(format!(
"path '{}' is under this workspace's protected git paths \
(.git/hooks, .git/config decide what runs)",
candidate.display()
));
}
}
}
}
None
}
fn write_targets(operation: &serde_json::Value) -> Vec<&str> {
let field = |name: &str| operation.get(name).and_then(serde_json::Value::as_str);
match field("op") {
Some("create" | "set" | "replace" | "insert" | "delete") => {
field("path").into_iter().collect()
}
Some("move") => field("from").into_iter().chain(field("to")).collect(),
_ => Vec::new(),
}
}
pub(crate) fn canonical(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn resolved(root: &Path, path: &Path) -> PathBuf {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
let normalized = lexically_normalized(&joined);
let mut existing = normalized.clone();
let mut tail = Vec::new();
while !existing.exists() {
match (existing.parent(), existing.file_name()) {
(Some(parent), Some(name)) => {
tail.push(name.to_os_string());
existing = parent.to_path_buf();
}
_ => return normalized,
}
}
let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
for part in tail.iter().rev() {
resolved.push(part);
}
resolved
}
fn lexically_normalized(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests;