1use aspect_core::{Aspect, AspectError, JoinPoint};
13use parking_lot::RwLock;
14use std::any::Any;
15use std::collections::{HashMap, HashSet};
16use std::sync::Arc;
17
18pub type SessionId = String;
20
21#[derive(Clone, Debug)]
24pub struct ToolCall {
25 pub session: SessionId,
27 pub tool: String,
29}
30
31#[derive(Clone, Default)]
33pub struct ToolScopeSandboxAspect {
34 inner: Arc<Inner>,
35}
36
37#[derive(Default)]
38struct Inner {
39 allowlists: RwLock<HashMap<SessionId, HashSet<String>>>,
40 pending: RwLock<HashMap<u64, ToolCall>>,
41 next_key: RwLock<u64>,
44}
45
46impl ToolScopeSandboxAspect {
47 pub fn new() -> Self {
50 Self::default()
51 }
52
53 pub fn allow<I, S>(&self, session: impl Into<SessionId>, tools: I)
55 where
56 I: IntoIterator<Item = S>,
57 S: Into<String>,
58 {
59 let set: HashSet<String> = tools.into_iter().map(Into::into).collect();
60 self.inner.allowlists.write().insert(session.into(), set);
61 }
62
63 pub fn bind(&self, call: ToolCall) -> u64 {
67 let mut next = self.inner.next_key.write();
68 *next += 1;
69 let key = *next;
70 self.inner.pending.write().insert(key, call);
71 key
72 }
73
74 pub fn release(&self, key: u64) {
76 self.inner.pending.write().remove(&key);
77 }
78
79 pub fn is_allowed(&self, session: &str, tool: &str) -> bool {
81 self.inner
82 .allowlists
83 .read()
84 .get(session)
85 .map(|s| s.contains(tool))
86 .unwrap_or(false)
87 }
88
89 fn most_recent_call(&self) -> Option<ToolCall> {
90 let pending = self.inner.pending.read();
91 pending
93 .iter()
94 .max_by_key(|(k, _)| *k)
95 .map(|(_, v)| v.clone())
96 }
97}
98
99impl Aspect for ToolScopeSandboxAspect {
100 fn before(&self, ctx: &JoinPoint) {
101 if let Some(call) = self.most_recent_call() {
102 if !self.is_allowed(&call.session, &call.tool) {
103 let _ = ctx;
107 }
108 }
109 }
110
111 fn around(
112 &self,
113 pjp: aspect_core::ProceedingJoinPoint,
114 ) -> Result<Box<dyn Any>, AspectError> {
115 if let Some(call) = self.most_recent_call() {
116 if !self.is_allowed(&call.session, &call.tool) {
117 return Err(AspectError::execution(format!(
118 "tool '{}' is out of scope for session '{}'",
119 call.tool, call.session
120 )));
121 }
122 }
123 pjp.proceed()
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use aspect_core::{JoinPoint, Location, ProceedingJoinPoint};
131
132 fn jp() -> JoinPoint {
133 JoinPoint {
134 function_name: "execute_tool_call",
135 module_path: "agent::dispatch",
136 location: Location {
137 file: "agent.rs",
138 line: 1,
139 },
140 }
141 }
142
143 #[test]
144 fn allowed_tool_proceeds() {
145 let sandbox = ToolScopeSandboxAspect::new();
146 sandbox.allow("s1", ["read_file", "list_dir"]);
147 let key = sandbox.bind(ToolCall {
148 session: "s1".into(),
149 tool: "read_file".into(),
150 });
151
152 let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(42_i32) as Box<dyn Any>), jp());
153 let result = sandbox.around(pjp);
154 sandbox.release(key);
155 assert!(result.is_ok());
156 }
157
158 #[test]
159 fn out_of_scope_tool_is_denied() {
160 let sandbox = ToolScopeSandboxAspect::new();
161 sandbox.allow("s1", ["read_file"]);
162 let key = sandbox.bind(ToolCall {
163 session: "s1".into(),
164 tool: "delete_file".into(),
165 });
166
167 let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
168 let result = sandbox.around(pjp);
169 sandbox.release(key);
170 assert!(result.is_err());
171 }
172
173 #[test]
174 fn unknown_session_is_denied() {
175 let sandbox = ToolScopeSandboxAspect::new();
176 let key = sandbox.bind(ToolCall {
177 session: "anon".into(),
178 tool: "read_file".into(),
179 });
180
181 let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
182 let result = sandbox.around(pjp);
183 sandbox.release(key);
184 assert!(result.is_err());
185 }
186}