Skip to main content

aspect_std/
toolscope.rs

1//! Tool-scope sandboxing aspect for agentic systems.
2//!
3//! Restricts an agent invocation to a declared tool allowlist. Wraps a
4//! tool-dispatch join point and rejects calls whose tool name is not in
5//! the per-session allowlist before the dispatch happens.
6//!
7//! The aspect is novel relative to classical AOP catalogues (logging,
8//! caching, rate-limiting): it encodes a *capability boundary* that is
9//! specific to agentic AI architectures, where an LLM may emit tool
10//! calls that escape the intended scope of a session.
11
12use aspect_core::{Aspect, AspectError, JoinPoint};
13use parking_lot::RwLock;
14use std::any::Any;
15use std::collections::{HashMap, HashSet};
16use std::sync::Arc;
17
18/// Identifier for a session whose tool calls are being scoped.
19pub type SessionId = String;
20
21/// Per-call context that the host system must populate before the
22/// guarded function runs.
23#[derive(Clone, Debug)]
24pub struct ToolCall {
25    /// The session whose allowlist applies.
26    pub session: SessionId,
27    /// Name of the tool being invoked.
28    pub tool: String,
29}
30
31/// Sandbox aspect that enforces a per-session tool allowlist.
32#[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    /// Counter for assigning thread-local call keys; tests rely on it
42    /// being monotonic but values are opaque to callers.
43    next_key: RwLock<u64>,
44}
45
46impl ToolScopeSandboxAspect {
47    /// Create an empty sandbox. Sessions must call `allow` before any
48    /// guarded join points fire for them.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Declare the full allowlist for a session, replacing any prior set.
54    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    /// Bind a tool call to the next guarded invocation. The host wraps
64    /// each dispatch with `bind` ... `proceed` ... `release`. The
65    /// returned key is opaque; pass it to `release`.
66    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    /// Clear a previously bound tool call. Idempotent.
75    pub fn release(&self, key: u64) {
76        self.inner.pending.write().remove(&key);
77    }
78
79    /// Inspect whether a `(session, tool)` pair would be admitted.
80    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        // Highest key wins; HashMap iteration order is non-deterministic.
92        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                // The around path is where we can actually deny; here we
104                // only get a chance to log. Real enforcement lives in
105                // `around` below.
106                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}