use aspect_core::{Aspect, AspectError, JoinPoint};
use parking_lot::RwLock;
use std::any::Any;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
pub type SessionId = String;
#[derive(Clone, Debug)]
pub struct ToolCall {
pub session: SessionId,
pub tool: String,
}
#[derive(Clone, Default)]
pub struct ToolScopeSandboxAspect {
inner: Arc<Inner>,
}
#[derive(Default)]
struct Inner {
allowlists: RwLock<HashMap<SessionId, HashSet<String>>>,
pending: RwLock<HashMap<u64, ToolCall>>,
next_key: RwLock<u64>,
}
impl ToolScopeSandboxAspect {
pub fn new() -> Self {
Self::default()
}
pub fn allow<I, S>(&self, session: impl Into<SessionId>, tools: I)
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let set: HashSet<String> = tools.into_iter().map(Into::into).collect();
self.inner.allowlists.write().insert(session.into(), set);
}
pub fn bind(&self, call: ToolCall) -> u64 {
let mut next = self.inner.next_key.write();
*next += 1;
let key = *next;
self.inner.pending.write().insert(key, call);
key
}
pub fn release(&self, key: u64) {
self.inner.pending.write().remove(&key);
}
pub fn is_allowed(&self, session: &str, tool: &str) -> bool {
self.inner
.allowlists
.read()
.get(session)
.map(|s| s.contains(tool))
.unwrap_or(false)
}
fn most_recent_call(&self) -> Option<ToolCall> {
let pending = self.inner.pending.read();
pending
.iter()
.max_by_key(|(k, _)| *k)
.map(|(_, v)| v.clone())
}
}
impl Aspect for ToolScopeSandboxAspect {
fn before(&self, ctx: &JoinPoint) {
if let Some(call) = self.most_recent_call() {
if !self.is_allowed(&call.session, &call.tool) {
let _ = ctx;
}
}
}
fn around(
&self,
pjp: aspect_core::ProceedingJoinPoint,
) -> Result<Box<dyn Any>, AspectError> {
if let Some(call) = self.most_recent_call() {
if !self.is_allowed(&call.session, &call.tool) {
return Err(AspectError::execution(format!(
"tool '{}' is out of scope for session '{}'",
call.tool, call.session
)));
}
}
pjp.proceed()
}
}
#[cfg(test)]
mod tests {
use super::*;
use aspect_core::{JoinPoint, Location, ProceedingJoinPoint};
fn jp() -> JoinPoint {
JoinPoint {
function_name: "execute_tool_call",
module_path: "agent::dispatch",
location: Location {
file: "agent.rs",
line: 1,
},
}
}
#[test]
fn allowed_tool_proceeds() {
let sandbox = ToolScopeSandboxAspect::new();
sandbox.allow("s1", ["read_file", "list_dir"]);
let key = sandbox.bind(ToolCall {
session: "s1".into(),
tool: "read_file".into(),
});
let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(42_i32) as Box<dyn Any>), jp());
let result = sandbox.around(pjp);
sandbox.release(key);
assert!(result.is_ok());
}
#[test]
fn out_of_scope_tool_is_denied() {
let sandbox = ToolScopeSandboxAspect::new();
sandbox.allow("s1", ["read_file"]);
let key = sandbox.bind(ToolCall {
session: "s1".into(),
tool: "delete_file".into(),
});
let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
let result = sandbox.around(pjp);
sandbox.release(key);
assert!(result.is_err());
}
#[test]
fn unknown_session_is_denied() {
let sandbox = ToolScopeSandboxAspect::new();
let key = sandbox.bind(ToolCall {
session: "anon".into(),
tool: "read_file".into(),
});
let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
let result = sandbox.around(pjp);
sandbox.release(key);
assert!(result.is_err());
}
}