1use async_trait::async_trait;
10use rskit_errors::{AppError, AppResult};
11use rskit_schema::Json;
12
13use crate::context::Context;
14use crate::envelope::{Envelope, SensitiveMatcher, SensitivePredicate};
15use crate::io::ToolInput;
16
17#[derive(Debug, Clone)]
19pub struct ToolCall {
20 pub name: String,
22 pub input: ToolInput,
24}
25
26#[derive(Debug, Clone)]
28pub enum Decision {
29 Allow,
31 Deny(String),
33 RequireApproval(String),
35}
36
37#[async_trait]
42pub trait SensitivityEvaluator: Send + Sync {
43 async fn evaluate(
45 &self,
46 ctx: &Context,
47 call: &ToolCall,
48 envelope: &Envelope,
49 ) -> AppResult<Decision>;
50}
51
52#[derive(Debug, Default, Clone)]
59pub struct DenyOnSensitive;
60
61#[async_trait]
62impl SensitivityEvaluator for DenyOnSensitive {
63 async fn evaluate(
64 &self,
65 _ctx: &Context,
66 call: &ToolCall,
67 envelope: &Envelope,
68 ) -> AppResult<Decision> {
69 for predicate in &envelope.sensitive_invocations {
70 if predicate_matches(&call.input, predicate) {
71 return Ok(Decision::Deny(format!(
72 "tool {:?} matches sensitive predicate at {:?}",
73 call.name, predicate.jsonpath
74 )));
75 }
76 }
77 Ok(Decision::Allow)
78 }
79}
80
81#[async_trait]
84pub trait HumanApproval: Send + Sync {
85 async fn approve(&self, ctx: &Context, call: &ToolCall, reason: &str) -> AppResult<bool>;
87}
88
89#[derive(Debug, Default, Clone)]
95pub struct DenyHumanApproval;
96
97#[async_trait]
98impl HumanApproval for DenyHumanApproval {
99 async fn approve(&self, _ctx: &Context, _call: &ToolCall, _reason: &str) -> AppResult<bool> {
100 Ok(false)
101 }
102}
103
104#[must_use]
107pub fn denied_error(reason: impl Into<String>) -> AppError {
108 AppError::forbidden(reason.into())
109}
110
111fn predicate_matches(input: &ToolInput, predicate: &SensitivePredicate) -> bool {
112 let Some(value) = select_jsonpath(input.as_json(), &predicate.jsonpath) else {
113 return false;
114 };
115 match &predicate.matcher {
116 SensitiveMatcher::Exists => true,
117 SensitiveMatcher::Equals(expected) => value == expected,
118 SensitiveMatcher::Regex(pattern) => value
119 .as_str()
120 .is_some_and(|text| regex_matches(pattern, text)),
121 SensitiveMatcher::Gt(threshold) => value.as_f64().is_some_and(|n| n > *threshold),
122 SensitiveMatcher::Lt(threshold) => value.as_f64().is_some_and(|n| n < *threshold),
123 }
124}
125
126fn select_jsonpath<'a>(value: &'a Json, path: &str) -> Option<&'a Json> {
127 let trimmed = path.trim();
128 let after_root = trimmed.strip_prefix('$').unwrap_or(trimmed);
129 let after_root = after_root.strip_prefix('.').unwrap_or(after_root);
130 if after_root.is_empty() {
131 return Some(value);
132 }
133 let mut cursor = value;
134 for segment in after_root.split('.') {
135 if segment.is_empty() {
136 return None;
137 }
138 match cursor {
139 Json::Object(map) => {
140 cursor = map.get(segment)?;
141 }
142 _ => return None,
143 }
144 }
145 Some(cursor)
146}
147
148fn regex_matches(pattern: &str, text: &str) -> bool {
149 glob_like_match(pattern, text)
153}
154
155fn glob_match_rec(pattern: &[char], text: &[char]) -> bool {
156 match (pattern.first(), text.first()) {
157 (None, None) => true,
158 (Some('.'), Some(_)) if pattern.get(1) == Some(&'*') => {
159 for split in 0..=text.len() {
160 if glob_match_rec(&pattern[2..], &text[split..]) {
161 return true;
162 }
163 }
164 false
165 }
166 (Some('.'), Some(_)) => glob_match_rec(&pattern[1..], &text[1..]),
167 (Some(pattern_char), Some(text_char)) if pattern_char == text_char => {
168 glob_match_rec(&pattern[1..], &text[1..])
169 }
170 _ => false,
171 }
172}
173
174fn glob_like_match(pattern: &str, text: &str) -> bool {
175 let pattern_chars: Vec<char> = pattern.chars().collect();
176 let text_chars: Vec<char> = text.chars().collect();
177 glob_match_rec(&pattern_chars, &text_chars)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::envelope::{Envelope, SensitiveMatcher, SensitivePredicate};
184 use serde_json::json;
185
186 fn call(input: Json) -> ToolCall {
187 ToolCall {
188 name: "demo".to_owned(),
189 input: ToolInput::new(input).unwrap(),
190 }
191 }
192
193 fn envelope(predicates: Vec<SensitivePredicate>) -> Envelope {
194 Envelope {
195 sensitive_invocations: predicates,
196 ..Envelope::default()
197 }
198 }
199
200 #[tokio::test]
201 async fn deny_on_sensitive_allows_when_no_predicates() {
202 let evaluator = DenyOnSensitive;
203 let ctx = Context::new();
204 let decision = evaluator
205 .evaluate(&ctx, &call(json!({"a": 1})), &Envelope::default())
206 .await
207 .unwrap();
208 assert!(matches!(decision, Decision::Allow));
209 }
210
211 #[tokio::test]
212 async fn deny_on_sensitive_denies_when_exists_predicate_matches() {
213 let evaluator = DenyOnSensitive;
214 let ctx = Context::new();
215 let env = envelope(vec![SensitivePredicate {
216 jsonpath: "$.password".to_owned(),
217 matcher: SensitiveMatcher::Exists,
218 }]);
219 let decision = evaluator
220 .evaluate(&ctx, &call(json!({"password": "x"})), &env)
221 .await
222 .unwrap();
223 assert!(matches!(decision, Decision::Deny(_)));
224 }
225
226 #[tokio::test]
227 async fn deny_on_sensitive_allows_when_predicate_misses() {
228 let evaluator = DenyOnSensitive;
229 let ctx = Context::new();
230 let env = envelope(vec![SensitivePredicate {
231 jsonpath: "$.password".to_owned(),
232 matcher: SensitiveMatcher::Exists,
233 }]);
234 let decision = evaluator
235 .evaluate(&ctx, &call(json!({"name": "alice"})), &env)
236 .await
237 .unwrap();
238 assert!(matches!(decision, Decision::Allow));
239 }
240
241 #[tokio::test]
242 async fn deny_on_sensitive_uses_equals_matcher() {
243 let evaluator = DenyOnSensitive;
244 let ctx = Context::new();
245 let env = envelope(vec![SensitivePredicate {
246 jsonpath: "$.action".to_owned(),
247 matcher: SensitiveMatcher::Equals(json!("delete")),
248 }]);
249 let allow = evaluator
250 .evaluate(&ctx, &call(json!({"action": "read"})), &env)
251 .await
252 .unwrap();
253 assert!(matches!(allow, Decision::Allow));
254 let deny = evaluator
255 .evaluate(&ctx, &call(json!({"action": "delete"})), &env)
256 .await
257 .unwrap();
258 assert!(matches!(deny, Decision::Deny(_)));
259 }
260
261 #[tokio::test]
262 async fn deny_on_sensitive_uses_gt_matcher() {
263 let evaluator = DenyOnSensitive;
264 let ctx = Context::new();
265 let env = envelope(vec![SensitivePredicate {
266 jsonpath: "$.amount".to_owned(),
267 matcher: SensitiveMatcher::Gt(100.0),
268 }]);
269 let deny = evaluator
270 .evaluate(&ctx, &call(json!({"amount": 200})), &env)
271 .await
272 .unwrap();
273 assert!(matches!(deny, Decision::Deny(_)));
274 let allow = evaluator
275 .evaluate(&ctx, &call(json!({"amount": 50})), &env)
276 .await
277 .unwrap();
278 assert!(matches!(allow, Decision::Allow));
279 }
280
281 #[tokio::test]
282 async fn deny_on_sensitive_uses_lt_and_regex_matchers() {
283 let evaluator = DenyOnSensitive;
284 let ctx = Context::new();
285 let env = envelope(vec![
286 SensitivePredicate {
287 jsonpath: "$.risk".to_owned(),
288 matcher: SensitiveMatcher::Lt(0.25),
289 },
290 SensitivePredicate {
291 jsonpath: "$.email".to_owned(),
292 matcher: SensitiveMatcher::Regex(".*@example.com".to_owned()),
293 },
294 ]);
295
296 let low_risk = evaluator
297 .evaluate(&ctx, &call(json!({"risk": 0.1})), &env)
298 .await
299 .unwrap();
300 assert!(matches!(low_risk, Decision::Deny(_)));
301
302 let matching_email = evaluator
303 .evaluate(&ctx, &call(json!({"email": "dev@example.com"})), &env)
304 .await
305 .unwrap();
306 assert!(matches!(matching_email, Decision::Deny(_)));
307
308 let allowed = evaluator
309 .evaluate(&ctx, &call(json!({"risk": 0.8, "email": "dev.test"})), &env)
310 .await
311 .unwrap();
312 assert!(matches!(allowed, Decision::Allow));
313 }
314
315 #[tokio::test]
316 async fn deny_on_sensitive_ignores_invalid_or_non_scalar_paths() {
317 let evaluator = DenyOnSensitive;
318 let ctx = Context::new();
319 let env = envelope(vec![
320 SensitivePredicate {
321 jsonpath: "$.nested.".to_owned(),
322 matcher: SensitiveMatcher::Exists,
323 },
324 SensitivePredicate {
325 jsonpath: "$.nested.count".to_owned(),
326 matcher: SensitiveMatcher::Gt(1.0),
327 },
328 SensitivePredicate {
329 jsonpath: "$.nested.label".to_owned(),
330 matcher: SensitiveMatcher::Regex("secret.*".to_owned()),
331 },
332 ]);
333
334 let decision = evaluator
335 .evaluate(
336 &ctx,
337 &call(json!({"nested": {"count": "many", "label": 7}})),
338 &env,
339 )
340 .await
341 .unwrap();
342
343 assert!(matches!(decision, Decision::Allow));
344 }
345
346 #[tokio::test]
347 async fn deny_on_sensitive_supports_root_path_and_non_object_miss() {
348 let evaluator = DenyOnSensitive;
349 let ctx = Context::new();
350
351 let root_env = envelope(vec![SensitivePredicate {
352 jsonpath: "$".to_owned(),
353 matcher: SensitiveMatcher::Exists,
354 }]);
355 let denied = evaluator
356 .evaluate(&ctx, &call(json!({"present": true})), &root_env)
357 .await
358 .expect("root path evaluates");
359 assert!(matches!(denied, Decision::Deny(_)));
360
361 let miss_env = envelope(vec![SensitivePredicate {
362 jsonpath: "$.nested.value".to_owned(),
363 matcher: SensitiveMatcher::Exists,
364 }]);
365 let allowed = evaluator
366 .evaluate(&ctx, &call(json!({"nested": 1})), &miss_env)
367 .await
368 .expect("non-object traversal misses");
369 assert!(matches!(allowed, Decision::Allow));
370 }
371
372 #[test]
373 fn glob_like_match_handles_empty_and_wildcard_cases() {
374 assert!(glob_like_match("", ""));
375 assert!(glob_like_match("a.c", "abc"));
376 assert!(glob_like_match("a.*c", "abbbbbc"));
377 assert!(!glob_like_match("a.*z", "abbbbbc"));
378 assert!(!glob_like_match("abc", ""));
379 }
380
381 #[tokio::test]
382 async fn deny_human_approval_and_denied_error_are_safe_defaults() {
383 let approver = DenyHumanApproval;
384 let decision = approver
385 .approve(&Context::new(), &call(json!({})), "reason")
386 .await
387 .expect("default approval should be infallible");
388 assert!(!decision);
389 assert_eq!(
390 denied_error("no").code(),
391 rskit_errors::ErrorCode::Forbidden
392 );
393 }
394
395 #[tokio::test]
396 async fn deny_human_approval_returns_false() {
397 let approver = DenyHumanApproval;
398 let ctx = Context::new();
399 let result = approver
400 .approve(&ctx, &call(json!({})), "needs review")
401 .await
402 .unwrap();
403 assert!(!result);
404 }
405}