Skip to main content

libverify_core/controls/
mcp_scope_check.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3
4/// Verifies that MCP tool calls stayed within the allowed scope defined in the
5/// agent spec. Checks both positive allow-lists (with wildcard support) and
6/// forbidden server deny-lists.
7pub struct McpScopeCheckControl;
8
9/// Returns true if `tool_ref` (format "mcp:{server}/{tool}") matches `pattern`.
10///
11/// Supported patterns:
12///   - Exact match: "mcp:github/create_pull_request"
13///   - Wildcard: "mcp:github/*" matches any tool on the github server
14fn matches_allowed(tool_ref: &str, pattern: &str) -> bool {
15    if pattern == tool_ref {
16        return true;
17    }
18    if let Some(prefix) = pattern.strip_suffix('*') {
19        tool_ref.starts_with(prefix)
20    } else {
21        false
22    }
23}
24
25impl Control for McpScopeCheckControl {
26    fn id(&self) -> ControlId {
27        builtin::id(builtin::MCP_SCOPE_CHECK)
28    }
29
30    fn description(&self) -> &'static str {
31        "MCP tool calls must stay within allowed scope (requires agent execution log)"
32    }
33
34    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
35        let id = self.id();
36
37        // Extract MCP tool calls
38        let calls = match &evidence.mcp_tool_calls {
39            EvidenceState::NotApplicable => {
40                return vec![ControlFinding::not_applicable(
41                    id,
42                    "No MCP tool call evidence applicable",
43                )];
44            }
45            EvidenceState::Missing { gaps } => {
46                return vec![ControlFinding::indeterminate(
47                    id,
48                    "MCP tool call evidence is missing",
49                    vec![],
50                    gaps.clone(),
51                )];
52            }
53            EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
54        };
55
56        if calls.is_empty() {
57            return vec![ControlFinding::satisfied(
58                id,
59                "No MCP tool calls recorded",
60                vec![],
61            )];
62        }
63
64        // Extract agent spec for allowed_tools and forbidden_mcp_servers
65        let (allowed_tools, forbidden_servers) = match &evidence.agent_spec {
66            EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
67                (&value.allowed_tools, &value.forbidden_mcp_servers)
68            }
69            _ => (&vec![] as &Vec<String>, &vec![] as &Vec<String>),
70        };
71
72        let mut violations: Vec<String> = Vec::new();
73
74        for call in calls {
75            let tool_ref = format!("mcp:{}/{}", call.server, call.tool);
76
77            // Check forbidden servers
78            if forbidden_servers
79                .iter()
80                .any(|s| s.eq_ignore_ascii_case(&call.server))
81            {
82                violations.push(format!(
83                    "{tool_ref} — server '{}' is forbidden",
84                    call.server
85                ));
86                continue;
87            }
88
89            // Check allowed tools (if non-empty, acts as an allow-list)
90            if !allowed_tools.is_empty()
91                && !allowed_tools.iter().any(|p| matches_allowed(&tool_ref, p))
92            {
93                violations.push(format!("{tool_ref} — not in allowed_tools"));
94            }
95        }
96
97        if violations.is_empty() {
98            vec![ControlFinding::satisfied(
99                id,
100                format!(
101                    "All {} MCP tool call(s) are within allowed scope",
102                    calls.len()
103                ),
104                vec![],
105            )]
106        } else {
107            let count = violations.len();
108            vec![ControlFinding::violated(
109                id,
110                format!("{count} MCP tool call(s) outside allowed scope"),
111                violations,
112            )]
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::control::ControlStatus;
121    use crate::evidence::*;
122
123    fn mcp_call(server: &str, tool: &str) -> McpToolCall {
124        McpToolCall {
125            server: server.to_string(),
126            tool: tool.to_string(),
127            success: true,
128            timestamp: None,
129            duration_ms: None,
130        }
131    }
132
133    #[test]
134    fn not_applicable_when_no_evidence() {
135        let b = EvidenceBundle::default();
136        let findings = McpScopeCheckControl.evaluate(&b);
137        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
138    }
139
140    #[test]
141    fn missing_evidence_indeterminate() {
142        let b = EvidenceBundle {
143            mcp_tool_calls: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
144                source: "mcp".into(),
145                subject: "calls".into(),
146                detail: "unavailable".into(),
147            }]),
148            ..Default::default()
149        };
150        let findings = McpScopeCheckControl.evaluate(&b);
151        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
152    }
153
154    #[test]
155    fn empty_calls_satisfied() {
156        let b = EvidenceBundle {
157            mcp_tool_calls: EvidenceState::complete(vec![]),
158            ..Default::default()
159        };
160        let findings = McpScopeCheckControl.evaluate(&b);
161        assert_eq!(findings[0].status, ControlStatus::Satisfied);
162    }
163
164    #[test]
165    fn no_restrictions_satisfied() {
166        let b = EvidenceBundle {
167            mcp_tool_calls: EvidenceState::complete(vec![
168                mcp_call("github", "create_pull_request"),
169                mcp_call("filesystem", "write_file"),
170            ]),
171            agent_spec: EvidenceState::complete(AgentSpec::default()),
172            ..Default::default()
173        };
174        let findings = McpScopeCheckControl.evaluate(&b);
175        assert_eq!(findings[0].status, ControlStatus::Satisfied);
176    }
177
178    #[test]
179    fn allowed_tools_exact_match() {
180        let b = EvidenceBundle {
181            mcp_tool_calls: EvidenceState::complete(vec![mcp_call(
182                "github",
183                "create_pull_request",
184            )]),
185            agent_spec: EvidenceState::complete(AgentSpec {
186                allowed_tools: vec!["mcp:github/create_pull_request".to_string()],
187                ..Default::default()
188            }),
189            ..Default::default()
190        };
191        let findings = McpScopeCheckControl.evaluate(&b);
192        assert_eq!(findings[0].status, ControlStatus::Satisfied);
193    }
194
195    #[test]
196    fn allowed_tools_wildcard_match() {
197        let b = EvidenceBundle {
198            mcp_tool_calls: EvidenceState::complete(vec![
199                mcp_call("github", "create_pull_request"),
200                mcp_call("github", "list_issues"),
201            ]),
202            agent_spec: EvidenceState::complete(AgentSpec {
203                allowed_tools: vec!["mcp:github/*".to_string()],
204                ..Default::default()
205            }),
206            ..Default::default()
207        };
208        let findings = McpScopeCheckControl.evaluate(&b);
209        assert_eq!(findings[0].status, ControlStatus::Satisfied);
210    }
211
212    #[test]
213    fn tool_not_in_allowed_list_violated() {
214        let b = EvidenceBundle {
215            mcp_tool_calls: EvidenceState::complete(vec![mcp_call("database", "execute_query")]),
216            agent_spec: EvidenceState::complete(AgentSpec {
217                allowed_tools: vec!["mcp:github/*".to_string()],
218                ..Default::default()
219            }),
220            ..Default::default()
221        };
222        let findings = McpScopeCheckControl.evaluate(&b);
223        assert_eq!(findings[0].status, ControlStatus::Violated);
224        assert!(findings[0].subjects[0].contains("not in allowed_tools"));
225    }
226
227    #[test]
228    fn forbidden_server_violated() {
229        let b = EvidenceBundle {
230            mcp_tool_calls: EvidenceState::complete(vec![mcp_call("database", "execute_query")]),
231            agent_spec: EvidenceState::complete(AgentSpec {
232                forbidden_mcp_servers: vec!["database".to_string()],
233                ..Default::default()
234            }),
235            ..Default::default()
236        };
237        let findings = McpScopeCheckControl.evaluate(&b);
238        assert_eq!(findings[0].status, ControlStatus::Violated);
239        assert!(findings[0].subjects[0].contains("forbidden"));
240    }
241
242    #[test]
243    fn forbidden_takes_precedence_over_allowed() {
244        let b = EvidenceBundle {
245            mcp_tool_calls: EvidenceState::complete(vec![mcp_call("database", "read_only")]),
246            agent_spec: EvidenceState::complete(AgentSpec {
247                allowed_tools: vec!["mcp:database/*".to_string()],
248                forbidden_mcp_servers: vec!["database".to_string()],
249                ..Default::default()
250            }),
251            ..Default::default()
252        };
253        let findings = McpScopeCheckControl.evaluate(&b);
254        assert_eq!(findings[0].status, ControlStatus::Violated);
255        assert!(findings[0].subjects[0].contains("forbidden"));
256    }
257
258    #[test]
259    fn mixed_allowed_and_forbidden() {
260        let b = EvidenceBundle {
261            mcp_tool_calls: EvidenceState::complete(vec![
262                mcp_call("github", "create_pr"),
263                mcp_call("admin", "delete_user"),
264            ]),
265            agent_spec: EvidenceState::complete(AgentSpec {
266                allowed_tools: vec!["mcp:github/*".to_string()],
267                forbidden_mcp_servers: vec!["admin".to_string()],
268                ..Default::default()
269            }),
270            ..Default::default()
271        };
272        let findings = McpScopeCheckControl.evaluate(&b);
273        assert_eq!(findings[0].status, ControlStatus::Violated);
274        // Only the admin call should be in violations
275        assert_eq!(findings[0].subjects.len(), 1);
276        assert!(findings[0].subjects[0].contains("admin"));
277    }
278}