Skip to main content

evidential_protocol/
ergative.rs

1//! Ergative Permission Model for the Evidential Protocol.
2//!
3//! Defines agent roles and tool permissions with a gate that enforces
4//! access control. Supports glob-style patterns for tool matching.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9// ---------------------------------------------------------------------------
10// AgentRole
11// ---------------------------------------------------------------------------
12
13/// Role assigned to an agent, determining its tool access level.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum AgentRole {
17    /// Read-only access to resources.
18    Reader,
19    /// Read and write access to resources.
20    Writer,
21    /// Minimal read-only access (no search tools).
22    Observer,
23    /// Unrestricted access to all tools.
24    Admin,
25}
26
27impl fmt::Display for AgentRole {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::Reader => write!(f, "reader"),
31            Self::Writer => write!(f, "writer"),
32            Self::Observer => write!(f, "observer"),
33            Self::Admin => write!(f, "admin"),
34        }
35    }
36}
37
38// ---------------------------------------------------------------------------
39// ToolPermission
40// ---------------------------------------------------------------------------
41
42/// A rule mapping a tool pattern to the roles that may use it.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ToolPermission {
45    /// Glob-style tool pattern. Trailing `*` means prefix match.
46    pub tool_pattern: String,
47    /// Roles permitted to use tools matching this pattern.
48    pub allowed_roles: Vec<AgentRole>,
49}
50
51// ---------------------------------------------------------------------------
52// PermissionManifest
53// ---------------------------------------------------------------------------
54
55/// A generated manifest of tool permissions for a specific agent.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct PermissionManifest {
58    /// Agent this manifest was generated for.
59    pub agent_id: String,
60    /// The agent's role.
61    pub role: AgentRole,
62    /// Tools the agent is allowed to invoke.
63    pub allowed_tools: Vec<String>,
64    /// Tools the agent is explicitly denied.
65    pub denied_tools: Vec<String>,
66    /// ISO-8601 timestamp of when the manifest was generated.
67    pub generated_at: String,
68}
69
70// ---------------------------------------------------------------------------
71// ErgativeGate
72// ---------------------------------------------------------------------------
73
74/// Permission gate that enforces role-based tool access.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ErgativeGate {
77    permissions: Vec<ToolPermission>,
78}
79
80impl Default for ErgativeGate {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl ErgativeGate {
87    /// Create a gate with the default permission set.
88    ///
89    /// Default rules:
90    /// - **Observer**: `Read`, `Glob`, `Grep`
91    /// - **Reader**: Observer tools + `WebSearch`, `WebFetch`
92    /// - **Writer**: Reader tools + `Edit`, `Write`
93    /// - **Admin**: wildcard `*` (everything)
94    pub fn new() -> Self {
95        let observer_tools = vec!["Read", "Glob", "Grep"];
96        let reader_extras = vec!["WebSearch", "WebFetch"];
97        let writer_extras = vec!["Edit", "Write"];
98
99        let mut permissions = Vec::new();
100
101        // Observer tools.
102        for tool in &observer_tools {
103            permissions.push(ToolPermission {
104                tool_pattern: tool.to_string(),
105                allowed_roles: vec![
106                    AgentRole::Observer,
107                    AgentRole::Reader,
108                    AgentRole::Writer,
109                    AgentRole::Admin,
110                ],
111            });
112        }
113
114        // Reader extras.
115        for tool in &reader_extras {
116            permissions.push(ToolPermission {
117                tool_pattern: tool.to_string(),
118                allowed_roles: vec![AgentRole::Reader, AgentRole::Writer, AgentRole::Admin],
119            });
120        }
121
122        // Writer extras.
123        for tool in &writer_extras {
124            permissions.push(ToolPermission {
125                tool_pattern: tool.to_string(),
126                allowed_roles: vec![AgentRole::Writer, AgentRole::Admin],
127            });
128        }
129
130        // Admin wildcard.
131        permissions.push(ToolPermission {
132            tool_pattern: "*".to_string(),
133            allowed_roles: vec![AgentRole::Admin],
134        });
135
136        Self { permissions }
137    }
138
139    /// Check whether `tool` matches `pattern`.
140    ///
141    /// If the pattern ends with `*`, it is treated as a prefix match.
142    /// Otherwise, an exact (case-sensitive) match is required.
143    fn matches(pattern: &str, tool: &str) -> bool {
144        if pattern == "*" {
145            return true;
146        }
147        if !pattern.contains('*') {
148            return pattern == tool;
149        }
150        let parts: Vec<&str> = pattern.split('*').filter(|p| !p.is_empty()).collect();
151        if parts.is_empty() {
152            return true;
153        }
154        let mut search_from = 0usize;
155        for part in &parts {
156            match tool[search_from..].find(part) {
157                Some(idx) => search_from += idx + part.len(),
158                None => return false,
159            }
160        }
161        if !pattern.starts_with('*') && !tool.starts_with(parts[0]) {
162            return false;
163        }
164        if !pattern.ends_with('*') && !tool.ends_with(parts[parts.len() - 1]) {
165            return false;
166        }
167        true
168    }
169
170    /// Check whether an agent with a given role may invoke a tool.
171    pub fn can_invoke(&self, _agent_id: &str, role: &AgentRole, tool: &str) -> bool {
172        for perm in &self.permissions {
173            if Self::matches(&perm.tool_pattern, tool) && perm.allowed_roles.contains(role) {
174                return true;
175            }
176        }
177        false
178    }
179
180    /// Filter a list of tools down to those accessible by a given role.
181    pub fn filter_tools(&self, role: &AgentRole, tools: &[String]) -> Vec<String> {
182        tools
183            .iter()
184            .filter(|t| self.can_invoke("_filter", role, t))
185            .cloned()
186            .collect()
187    }
188
189    /// Generate a permission manifest for an agent.
190    pub fn generate_manifest(&self, agent_id: &str, role: &AgentRole) -> PermissionManifest {
191        let all_known_tools = vec![
192            "Read", "Glob", "Grep", "WebSearch", "WebFetch", "Edit", "Write", "Bash",
193            "NotebookEdit", "EnterWorktree", "ExitWorktree",
194        ];
195
196        let mut allowed = Vec::new();
197        let mut denied = Vec::new();
198
199        for tool in &all_known_tools {
200            if self.can_invoke(agent_id, role, tool) {
201                allowed.push(tool.to_string());
202            } else {
203                denied.push(tool.to_string());
204            }
205        }
206
207        PermissionManifest {
208            agent_id: agent_id.to_string(),
209            role: role.clone(),
210            allowed_tools: allowed,
211            denied_tools: denied,
212            generated_at: chrono::Utc::now().to_rfc3339(),
213        }
214    }
215
216    /// Audit a tool invocation, returning `(allowed, reason)`.
217    pub fn audit(&self, agent_id: &str, role: &AgentRole, tool: &str) -> (bool, String) {
218        let allowed = self.can_invoke(agent_id, role, tool);
219        let reason = if allowed {
220            format!(
221                "agent '{}' with role '{}' is permitted to invoke '{}'",
222                agent_id, role, tool
223            )
224        } else {
225            format!(
226                "agent '{}' with role '{}' is denied access to '{}'",
227                agent_id, role, tool
228            )
229        };
230        (allowed, reason)
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn default_permissions() {
240        let gate = ErgativeGate::new();
241
242        // Observer can Read but not Edit.
243        assert!(gate.can_invoke("obs-1", &AgentRole::Observer, "Read"));
244        assert!(gate.can_invoke("obs-1", &AgentRole::Observer, "Glob"));
245        assert!(!gate.can_invoke("obs-1", &AgentRole::Observer, "Edit"));
246        assert!(!gate.can_invoke("obs-1", &AgentRole::Observer, "WebSearch"));
247
248        // Reader can Read and WebSearch but not Edit.
249        assert!(gate.can_invoke("read-1", &AgentRole::Reader, "Read"));
250        assert!(gate.can_invoke("read-1", &AgentRole::Reader, "WebSearch"));
251        assert!(!gate.can_invoke("read-1", &AgentRole::Reader, "Edit"));
252
253        // Writer can do Reader things + Edit/Write.
254        assert!(gate.can_invoke("write-1", &AgentRole::Writer, "Read"));
255        assert!(gate.can_invoke("write-1", &AgentRole::Writer, "Edit"));
256        assert!(gate.can_invoke("write-1", &AgentRole::Writer, "Write"));
257
258        // Admin can do anything.
259        assert!(gate.can_invoke("admin-1", &AgentRole::Admin, "Read"));
260        assert!(gate.can_invoke("admin-1", &AgentRole::Admin, "Edit"));
261        assert!(gate.can_invoke("admin-1", &AgentRole::Admin, "Bash"));
262        assert!(gate.can_invoke("admin-1", &AgentRole::Admin, "SomeCustomTool"));
263    }
264
265    #[test]
266    fn filter_and_manifest() {
267        let gate = ErgativeGate::new();
268        let tools: Vec<String> = vec!["Read", "Edit", "Bash", "Glob"]
269            .into_iter()
270            .map(String::from)
271            .collect();
272
273        let reader_tools = gate.filter_tools(&AgentRole::Reader, &tools);
274        assert!(reader_tools.contains(&"Read".to_string()));
275        assert!(reader_tools.contains(&"Glob".to_string()));
276        assert!(!reader_tools.contains(&"Edit".to_string()));
277        assert!(!reader_tools.contains(&"Bash".to_string()));
278
279        let manifest = gate.generate_manifest("agent-x", &AgentRole::Writer);
280        assert!(manifest.allowed_tools.contains(&"Edit".to_string()));
281        assert!(manifest.denied_tools.contains(&"Bash".to_string()));
282    }
283
284    #[test]
285    fn audit_reporting() {
286        let gate = ErgativeGate::new();
287        let (allowed, reason) = gate.audit("agent-1", &AgentRole::Reader, "Edit");
288        assert!(!allowed);
289        assert!(reason.contains("denied"));
290
291        let (allowed, reason) = gate.audit("agent-1", &AgentRole::Writer, "Edit");
292        assert!(allowed);
293        assert!(reason.contains("permitted"));
294    }
295}