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