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 through stages:
4//! authz → sensitivity → (if `RequireApproval`) human approval → invoke.
5//! This module defines the `sensitivity` and `approval` stages.
6//! `authz` is owned by `rskit_authz::Decider` and wired at the boundary (e.g. `rskit-mcp::Server`),
7//! 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 input
40/// 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 envelope's `sensitive_invocations` predicates.
53///
54/// "Deny on sensitive" is the safe default per D10. To allow such calls,
55/// install a custom evaluator that returns `RequireApproval`
56/// and pair it with a non-default [`HumanApproval`].
57#[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/// Human approval gate consulted when [`SensitivityEvaluator`] returns [`Decision::RequireApproval`].
81#[async_trait]
82pub trait HumanApproval: Send + Sync {
83    /// Return `true` to proceed with invocation, `false` to deny.
84    async fn approve(&self, ctx: &Context, call: &ToolCall, reason: &str) -> AppResult<bool>;
85}
86
87/// Default approval gate that always denies.
88///
89/// Per D10, `DenyHumanApproval` is the canonical default — there is no auto-approval.
90/// Replace with a real gate (CLI prompt, web UI hand-off, async ticket queue) at composition time.
91#[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/// Translate a [`Decision::Deny`] (or post-approval rejection) into a typed `AppError` with the `Forbidden` code.
102#[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    // Compile-and-match without pulling in a regex crate dep —
146    // this is a small glob-style helper that supports `.` (any char) and `.*` (any run).
147    // Implementations that need full PCRE should provide a custom evaluator.
148    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}