rskit-tool 0.2.0-alpha.2

Tool definition, auto-wiring, registry and middleware for agentic systems
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! Human-in-the-loop (HITL) evaluation for tool dispatch.
//!
//! Per the locked AI/ML cross-kit decision D10, every tool invocation flows
//! through stages: authz → sensitivity → (if `RequireApproval`) human approval
//! → invoke. This module defines the `sensitivity` and `approval` stages.
//! `authz` is owned by `rskit_authz::Decider` and wired at the boundary
//! (e.g. `rskit-mcp::Server`), not here, to preserve module layering.

use async_trait::async_trait;
use rskit_errors::{AppError, AppResult};
use rskit_schema::Json;

use crate::context::Context;
use crate::envelope::{Envelope, SensitiveMatcher, SensitivePredicate};
use crate::io::ToolInput;

/// One tool invocation as seen by the HITL stages.
#[derive(Debug, Clone)]
pub struct ToolCall {
    /// Registered tool name.
    pub name: String,
    /// Validated tool input.
    pub input: ToolInput,
}

/// Sensitivity decision returned by a [`SensitivityEvaluator`].
#[derive(Debug, Clone)]
pub enum Decision {
    /// Proceed to invocation.
    Allow,
    /// Reject with the given reason.
    Deny(String),
    /// Defer to a [`HumanApproval`] before invocation; the reason explains why.
    RequireApproval(String),
}

/// Evaluator for the *sensitivity* stage of HITL.
///
/// Implementations decide whether a tool call is sensitive given the call's
/// input and the tool's declared `Envelope.sensitive_invocations` predicates.
#[async_trait]
pub trait SensitivityEvaluator: Send + Sync {
    /// Evaluate a tool call against the given envelope.
    async fn evaluate(
        &self,
        ctx: &Context,
        call: &ToolCall,
        envelope: &Envelope,
    ) -> AppResult<Decision>;
}

/// Default evaluator that denies any tool call whose input matches one of the
/// envelope's `sensitive_invocations` predicates.
///
/// "Deny on sensitive" is the safe default per D10. To allow such calls,
/// install a custom evaluator that returns `RequireApproval` and pair it with
/// a non-default [`HumanApproval`].
#[derive(Debug, Default, Clone)]
pub struct DenyOnSensitive;

#[async_trait]
impl SensitivityEvaluator for DenyOnSensitive {
    async fn evaluate(
        &self,
        _ctx: &Context,
        call: &ToolCall,
        envelope: &Envelope,
    ) -> AppResult<Decision> {
        for predicate in &envelope.sensitive_invocations {
            if predicate_matches(&call.input, predicate) {
                return Ok(Decision::Deny(format!(
                    "tool {:?} matches sensitive predicate at {:?}",
                    call.name, predicate.jsonpath
                )));
            }
        }
        Ok(Decision::Allow)
    }
}

/// Human approval gate consulted when [`SensitivityEvaluator`] returns
/// [`Decision::RequireApproval`].
#[async_trait]
pub trait HumanApproval: Send + Sync {
    /// Return `true` to proceed with invocation, `false` to deny.
    async fn approve(&self, ctx: &Context, call: &ToolCall, reason: &str) -> AppResult<bool>;
}

/// Default approval gate that always denies.
///
/// Per D10, `DenyHumanApproval` is the canonical default — there is no
/// auto-approval. Replace with a real gate (CLI prompt, web UI hand-off,
/// async ticket queue) at composition time.
#[derive(Debug, Default, Clone)]
pub struct DenyHumanApproval;

#[async_trait]
impl HumanApproval for DenyHumanApproval {
    async fn approve(&self, _ctx: &Context, _call: &ToolCall, _reason: &str) -> AppResult<bool> {
        Ok(false)
    }
}

/// Translate a [`Decision::Deny`] (or post-approval rejection) into a typed
/// `AppError` with the `Forbidden` code.
#[must_use]
pub fn denied_error(reason: impl Into<String>) -> AppError {
    AppError::forbidden(reason.into())
}

fn predicate_matches(input: &ToolInput, predicate: &SensitivePredicate) -> bool {
    let Some(value) = select_jsonpath(input.as_json(), &predicate.jsonpath) else {
        return false;
    };
    match &predicate.matcher {
        SensitiveMatcher::Exists => true,
        SensitiveMatcher::Equals(expected) => value == expected,
        SensitiveMatcher::Regex(pattern) => value
            .as_str()
            .is_some_and(|text| regex_matches(pattern, text)),
        SensitiveMatcher::Gt(threshold) => value.as_f64().is_some_and(|n| n > *threshold),
        SensitiveMatcher::Lt(threshold) => value.as_f64().is_some_and(|n| n < *threshold),
    }
}

fn select_jsonpath<'a>(value: &'a Json, path: &str) -> Option<&'a Json> {
    let trimmed = path.trim();
    let after_root = trimmed.strip_prefix('$').unwrap_or(trimmed);
    let after_root = after_root.strip_prefix('.').unwrap_or(after_root);
    if after_root.is_empty() {
        return Some(value);
    }
    let mut cursor = value;
    for segment in after_root.split('.') {
        if segment.is_empty() {
            return None;
        }
        match cursor {
            Json::Object(map) => {
                cursor = map.get(segment)?;
            }
            _ => return None,
        }
    }
    Some(cursor)
}

fn regex_matches(pattern: &str, text: &str) -> bool {
    // Compile-and-match without pulling in a regex crate dep — this is a small
    // glob-style helper that supports `.` (any char) and `.*` (any run).
    // Implementations that need full PCRE should provide a custom evaluator.
    glob_like_match(pattern, text)
}

fn glob_match_rec(pattern: &[char], text: &[char]) -> bool {
    match (pattern.first(), text.first()) {
        (None, None) => true,
        (Some('.'), Some(_)) if pattern.get(1) == Some(&'*') => {
            for split in 0..=text.len() {
                if glob_match_rec(&pattern[2..], &text[split..]) {
                    return true;
                }
            }
            false
        }
        (Some('.'), Some(_)) => glob_match_rec(&pattern[1..], &text[1..]),
        (Some(pattern_char), Some(text_char)) if pattern_char == text_char => {
            glob_match_rec(&pattern[1..], &text[1..])
        }
        _ => false,
    }
}

fn glob_like_match(pattern: &str, text: &str) -> bool {
    let pattern_chars: Vec<char> = pattern.chars().collect();
    let text_chars: Vec<char> = text.chars().collect();
    glob_match_rec(&pattern_chars, &text_chars)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::envelope::{Envelope, SensitiveMatcher, SensitivePredicate};
    use serde_json::json;

    fn call(input: Json) -> ToolCall {
        ToolCall {
            name: "demo".to_owned(),
            input: ToolInput::new(input).unwrap(),
        }
    }

    fn envelope(predicates: Vec<SensitivePredicate>) -> Envelope {
        Envelope {
            sensitive_invocations: predicates,
            ..Envelope::default()
        }
    }

    #[tokio::test]
    async fn deny_on_sensitive_allows_when_no_predicates() {
        let evaluator = DenyOnSensitive;
        let ctx = Context::new();
        let decision = evaluator
            .evaluate(&ctx, &call(json!({"a": 1})), &Envelope::default())
            .await
            .unwrap();
        assert!(matches!(decision, Decision::Allow));
    }

    #[tokio::test]
    async fn deny_on_sensitive_denies_when_exists_predicate_matches() {
        let evaluator = DenyOnSensitive;
        let ctx = Context::new();
        let env = envelope(vec![SensitivePredicate {
            jsonpath: "$.password".to_owned(),
            matcher: SensitiveMatcher::Exists,
        }]);
        let decision = evaluator
            .evaluate(&ctx, &call(json!({"password": "x"})), &env)
            .await
            .unwrap();
        assert!(matches!(decision, Decision::Deny(_)));
    }

    #[tokio::test]
    async fn deny_on_sensitive_allows_when_predicate_misses() {
        let evaluator = DenyOnSensitive;
        let ctx = Context::new();
        let env = envelope(vec![SensitivePredicate {
            jsonpath: "$.password".to_owned(),
            matcher: SensitiveMatcher::Exists,
        }]);
        let decision = evaluator
            .evaluate(&ctx, &call(json!({"name": "alice"})), &env)
            .await
            .unwrap();
        assert!(matches!(decision, Decision::Allow));
    }

    #[tokio::test]
    async fn deny_on_sensitive_uses_equals_matcher() {
        let evaluator = DenyOnSensitive;
        let ctx = Context::new();
        let env = envelope(vec![SensitivePredicate {
            jsonpath: "$.action".to_owned(),
            matcher: SensitiveMatcher::Equals(json!("delete")),
        }]);
        let allow = evaluator
            .evaluate(&ctx, &call(json!({"action": "read"})), &env)
            .await
            .unwrap();
        assert!(matches!(allow, Decision::Allow));
        let deny = evaluator
            .evaluate(&ctx, &call(json!({"action": "delete"})), &env)
            .await
            .unwrap();
        assert!(matches!(deny, Decision::Deny(_)));
    }

    #[tokio::test]
    async fn deny_on_sensitive_uses_gt_matcher() {
        let evaluator = DenyOnSensitive;
        let ctx = Context::new();
        let env = envelope(vec![SensitivePredicate {
            jsonpath: "$.amount".to_owned(),
            matcher: SensitiveMatcher::Gt(100.0),
        }]);
        let deny = evaluator
            .evaluate(&ctx, &call(json!({"amount": 200})), &env)
            .await
            .unwrap();
        assert!(matches!(deny, Decision::Deny(_)));
        let allow = evaluator
            .evaluate(&ctx, &call(json!({"amount": 50})), &env)
            .await
            .unwrap();
        assert!(matches!(allow, Decision::Allow));
    }

    #[tokio::test]
    async fn deny_on_sensitive_uses_lt_and_regex_matchers() {
        let evaluator = DenyOnSensitive;
        let ctx = Context::new();
        let env = envelope(vec![
            SensitivePredicate {
                jsonpath: "$.risk".to_owned(),
                matcher: SensitiveMatcher::Lt(0.25),
            },
            SensitivePredicate {
                jsonpath: "$.email".to_owned(),
                matcher: SensitiveMatcher::Regex(".*@example.com".to_owned()),
            },
        ]);

        let low_risk = evaluator
            .evaluate(&ctx, &call(json!({"risk": 0.1})), &env)
            .await
            .unwrap();
        assert!(matches!(low_risk, Decision::Deny(_)));

        let matching_email = evaluator
            .evaluate(&ctx, &call(json!({"email": "dev@example.com"})), &env)
            .await
            .unwrap();
        assert!(matches!(matching_email, Decision::Deny(_)));

        let allowed = evaluator
            .evaluate(&ctx, &call(json!({"risk": 0.8, "email": "dev.test"})), &env)
            .await
            .unwrap();
        assert!(matches!(allowed, Decision::Allow));
    }

    #[tokio::test]
    async fn deny_on_sensitive_ignores_invalid_or_non_scalar_paths() {
        let evaluator = DenyOnSensitive;
        let ctx = Context::new();
        let env = envelope(vec![
            SensitivePredicate {
                jsonpath: "$.nested.".to_owned(),
                matcher: SensitiveMatcher::Exists,
            },
            SensitivePredicate {
                jsonpath: "$.nested.count".to_owned(),
                matcher: SensitiveMatcher::Gt(1.0),
            },
            SensitivePredicate {
                jsonpath: "$.nested.label".to_owned(),
                matcher: SensitiveMatcher::Regex("secret.*".to_owned()),
            },
        ]);

        let decision = evaluator
            .evaluate(
                &ctx,
                &call(json!({"nested": {"count": "many", "label": 7}})),
                &env,
            )
            .await
            .unwrap();

        assert!(matches!(decision, Decision::Allow));
    }

    #[tokio::test]
    async fn deny_on_sensitive_supports_root_path_and_non_object_miss() {
        let evaluator = DenyOnSensitive;
        let ctx = Context::new();

        let root_env = envelope(vec![SensitivePredicate {
            jsonpath: "$".to_owned(),
            matcher: SensitiveMatcher::Exists,
        }]);
        let denied = evaluator
            .evaluate(&ctx, &call(json!({"present": true})), &root_env)
            .await
            .expect("root path evaluates");
        assert!(matches!(denied, Decision::Deny(_)));

        let miss_env = envelope(vec![SensitivePredicate {
            jsonpath: "$.nested.value".to_owned(),
            matcher: SensitiveMatcher::Exists,
        }]);
        let allowed = evaluator
            .evaluate(&ctx, &call(json!({"nested": 1})), &miss_env)
            .await
            .expect("non-object traversal misses");
        assert!(matches!(allowed, Decision::Allow));
    }

    #[test]
    fn glob_like_match_handles_empty_and_wildcard_cases() {
        assert!(glob_like_match("", ""));
        assert!(glob_like_match("a.c", "abc"));
        assert!(glob_like_match("a.*c", "abbbbbc"));
        assert!(!glob_like_match("a.*z", "abbbbbc"));
        assert!(!glob_like_match("abc", ""));
    }

    #[tokio::test]
    async fn deny_human_approval_and_denied_error_are_safe_defaults() {
        let approver = DenyHumanApproval;
        let decision = approver
            .approve(&Context::new(), &call(json!({})), "reason")
            .await
            .expect("default approval should be infallible");
        assert!(!decision);
        assert_eq!(
            denied_error("no").code(),
            rskit_errors::ErrorCode::Forbidden
        );
    }

    #[tokio::test]
    async fn deny_human_approval_returns_false() {
        let approver = DenyHumanApproval;
        let ctx = Context::new();
        let result = approver
            .approve(&ctx, &call(json!({})), "needs review")
            .await
            .unwrap();
        assert!(!result);
    }
}