use std::path::Path;
use std::sync::Mutex;
use ort::session::Session;
use ort::value::Tensor;
use tokenizers::Tokenizer;
use crate::scanner::{
Direction, Disposition, HoldBack, ScanCtx, ScanError, ScanResult, Scanner, ScannerId,
Threshold, Verdict,
};
const DEFAULT_THRESHOLD: f32 = 0.5;
const MODEL_FILE: &str = "model.onnx";
const TOKENIZER_FILE: &str = "tokenizer.json";
const INPUT_IDS: &str = "input_ids";
const ATTENTION_MASK: &str = "attention_mask";
const LOGITS: &str = "logits";
#[derive(Debug, thiserror::Error)]
pub enum LoadError {
#[error("failed to load prompt-injection model from {path}: {reason}")]
Model { path: String, reason: String },
#[error("failed to load tokenizer from {path}: {reason}")]
Tokenizer { path: String, reason: String },
}
pub struct PromptInjectionScanner {
session: Mutex<Session>,
tokenizer: Tokenizer,
threshold: Threshold,
}
impl PromptInjectionScanner {
pub const ID: ScannerId = ScannerId("native:prompt-injection");
pub fn from_dir(dir: impl AsRef<Path>) -> Result<Self, LoadError> {
Self::from_dir_with_threshold(dir, Threshold::new(DEFAULT_THRESHOLD))
}
pub fn from_dir_with_threshold(
dir: impl AsRef<Path>,
threshold: Threshold,
) -> Result<Self, LoadError> {
let dir = dir.as_ref();
let model_path = dir.join(MODEL_FILE);
let tokenizer_path = dir.join(TOKENIZER_FILE);
let session = Session::builder()
.and_then(|mut b| b.commit_from_file(&model_path))
.map_err(|e| LoadError::Model {
path: model_path.display().to_string(),
reason: format!("{e}"),
})?;
let tokenizer =
Tokenizer::from_file(&tokenizer_path).map_err(|e| LoadError::Tokenizer {
path: tokenizer_path.display().to_string(),
reason: format!("{e}"),
})?;
Ok(Self {
session: Mutex::new(session),
tokenizer,
threshold,
})
}
#[must_use]
pub fn threshold(&self) -> Threshold {
self.threshold
}
fn injection_probability(&self, text: &str) -> Result<f32, ScanError> {
let encoding = self
.tokenizer
.encode(text, true)
.map_err(|e| detector_error(format!("tokenize: {e}")))?;
let len = encoding.get_ids().len();
let ids: Vec<i64> = encoding.get_ids().iter().map(|&id| i64::from(id)).collect();
let mask: Vec<i64> = encoding
.get_attention_mask()
.iter()
.map(|&m| i64::from(m))
.collect();
let shape = [1_i64, i64::try_from(len).unwrap_or(i64::MAX)];
let ids_tensor = Tensor::from_array((shape, ids))
.map_err(|e| detector_error(format!("input_ids tensor: {e}")))?;
let mask_tensor = Tensor::from_array((shape, mask))
.map_err(|e| detector_error(format!("attention_mask tensor: {e}")))?;
let mut session = self
.session
.lock()
.map_err(|_| detector_error("session mutex poisoned".to_owned()))?;
let outputs = session
.run(ort::inputs![
INPUT_IDS => ids_tensor,
ATTENTION_MASK => mask_tensor,
])
.map_err(|e| detector_error(format!("inference: {e}")))?;
let (_shape, logits) = outputs[LOGITS]
.try_extract_tensor::<f32>()
.map_err(|e| detector_error(format!("extract logits: {e}")))?;
injection_softmax(logits)
}
}
fn injection_softmax(logits: &[f32]) -> Result<f32, ScanError> {
let [safe, injection] = logits else {
return Err(detector_error(format!(
"expected 2 logits (SAFE, INJECTION), got {}",
logits.len()
)));
};
let max = safe.max(*injection);
let exp_safe = (safe - max).exp();
let exp_injection = (injection - max).exp();
Ok(exp_injection / (exp_safe + exp_injection))
}
fn detector_error(reason: String) -> ScanError {
ScanError::Detector {
scanner: PromptInjectionScanner::ID,
reason,
}
}
impl Scanner for PromptInjectionScanner {
fn id(&self) -> ScannerId {
Self::ID
}
fn direction(&self) -> Direction {
Direction::Input
}
fn disposition(&self) -> Disposition {
Disposition::Block
}
fn scan<'a>(&self, text: &'a str, _ctx: &mut ScanCtx) -> ScanResult<'a> {
let risk = self.injection_probability(text)?;
Ok(Verdict::detected(text, risk, self.threshold.get()))
}
fn hold_back(&self) -> HoldBack {
HoldBack::WholeStream
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "tests assert on known-good values"
)]
use super::*;
#[test]
fn softmax_index_one_is_injection_probability() {
assert!((injection_softmax(&[0.0, 0.0]).unwrap() - 0.5).abs() < 1e-6);
assert!(injection_softmax(&[-10.0, 10.0]).unwrap() > 0.999);
assert!(injection_softmax(&[10.0, -10.0]).unwrap() < 0.001);
}
#[test]
fn softmax_does_not_overflow_on_large_logits() {
let p = injection_softmax(&[1.0e30, 1.0e30]).unwrap();
assert!((p - 0.5).abs() < 1e-6);
}
#[test]
fn softmax_rejects_wrong_arity() {
assert!(injection_softmax(&[0.0]).is_err());
assert!(injection_softmax(&[0.0, 0.0, 0.0]).is_err());
}
#[test]
fn missing_model_dir_is_a_load_error() {
match PromptInjectionScanner::from_dir("/nonexistent/model/dir") {
Err(LoadError::Model { .. }) => {}
Err(other) => panic!("expected a Model load error, got {other:?}"),
Ok(_) => panic!("loading a nonexistent model dir must fail"),
}
}
}