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;
#[derive(Debug, Clone)]
pub struct ToolCall {
pub name: String,
pub input: ToolInput,
}
#[derive(Debug, Clone)]
pub enum Decision {
Allow,
Deny(String),
RequireApproval(String),
}
#[async_trait]
pub trait SensitivityEvaluator: Send + Sync {
async fn evaluate(
&self,
ctx: &Context,
call: &ToolCall,
envelope: &Envelope,
) -> AppResult<Decision>;
}
#[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)
}
}
#[async_trait]
pub trait HumanApproval: Send + Sync {
async fn approve(&self, ctx: &Context, call: &ToolCall, reason: &str) -> AppResult<bool>;
}
#[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)
}
}
#[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 {
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);
}
}