Skip to main content

rskit_tool/
hitl.rs

1//! Human-in-the-loop (HITL) evaluation for tool dispatch.
2//!
3//! Per the locked AI/ML cross-kit decision D10, every tool invocation flows
4//! through stages: authz → sensitivity → (if `RequireApproval`) human approval
5//! → invoke. This module defines the `sensitivity` and `approval` stages.
6//! `authz` is owned by `rskit_authz::Decider` and wired at the boundary
7//! (e.g. `rskit-mcp::Server`), not here, to preserve module layering.
8
9use 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/// One tool invocation as seen by the HITL stages.
18#[derive(Debug, Clone)]
19pub struct ToolCall {
20    /// Registered tool name.
21    pub name: String,
22    /// Validated tool input.
23    pub input: ToolInput,
24}
25
26/// Sensitivity decision returned by a [`SensitivityEvaluator`].
27#[derive(Debug, Clone)]
28pub enum Decision {
29    /// Proceed to invocation.
30    Allow,
31    /// Reject with the given reason.
32    Deny(String),
33    /// Defer to a [`HumanApproval`] before invocation; the reason explains why.
34    RequireApproval(String),
35}
36
37/// Evaluator for the *sensitivity* stage of HITL.
38///
39/// Implementations decide whether a tool call is sensitive given the call's
40/// input and the tool's declared `Envelope.sensitive_invocations` predicates.
41#[async_trait]
42pub trait SensitivityEvaluator: Send + Sync {
43    /// Evaluate a tool call against the given envelope.
44    async fn evaluate(
45        &self,
46        ctx: &Context,
47        call: &ToolCall,
48        envelope: &Envelope,
49    ) -> AppResult<Decision>;
50}
51
52/// Default evaluator that denies any tool call whose input matches one of the
53/// envelope's `sensitive_invocations` predicates.
54///
55/// "Deny on sensitive" is the safe default per D10. To allow such calls,
56/// install a custom evaluator that returns `RequireApproval` and pair it with
57/// a non-default [`HumanApproval`].
58#[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/// Human approval gate consulted when [`SensitivityEvaluator`] returns
82/// [`Decision::RequireApproval`].
83#[async_trait]
84pub trait HumanApproval: Send + Sync {
85    /// Return `true` to proceed with invocation, `false` to deny.
86    async fn approve(&self, ctx: &Context, call: &ToolCall, reason: &str) -> AppResult<bool>;
87}
88
89/// Default approval gate that always denies.
90///
91/// Per D10, `DenyHumanApproval` is the canonical default — there is no
92/// auto-approval. Replace with a real gate (CLI prompt, web UI hand-off,
93/// async ticket queue) at composition time.
94#[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/// Translate a [`Decision::Deny`] (or post-approval rejection) into a typed
105/// `AppError` with the `Forbidden` code.
106#[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    // Compile-and-match without pulling in a regex crate dep — this is a small
150    // glob-style helper that supports `.` (any char) and `.*` (any run).
151    // Implementations that need full PCRE should provide a custom evaluator.
152    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}