use std::sync::OnceLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
Workspace,
ToolOutput,
ModelOutput,
External,
Memory,
User,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrustLevel {
Trusted,
SemiTrusted,
Untrusted,
}
impl TrustLevel {
pub fn as_str(&self) -> &'static str {
match self {
TrustLevel::Trusted => "trusted",
TrustLevel::SemiTrusted => "semi_trusted",
TrustLevel::Untrusted => "untrusted",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InjectionFinding {
pub kind: String,
pub severity: String, pub line: usize,
pub excerpt: String,
pub explanation: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustReport {
pub source_id: String,
pub source_kind: SourceKind,
pub classification: String,
pub trust_level: String,
pub risk_score: u32,
pub findings: Vec<InjectionFinding>,
pub verdict: String,
}
pub fn trust_level(source: SourceKind, classification: &str) -> TrustLevel {
match source {
SourceKind::External | SourceKind::ModelOutput => TrustLevel::Untrusted,
SourceKind::ToolOutput | SourceKind::Memory => TrustLevel::SemiTrusted,
SourceKind::User => TrustLevel::Trusted,
SourceKind::Workspace => match classification {
"rust_source" | "test" | "config" => TrustLevel::Trusted,
"data" | "vendored" | "markup" | "script" => TrustLevel::SemiTrusted,
_ => TrustLevel::SemiTrusted,
},
}
}
struct Rule {
kind: &'static str,
severity: &'static str,
re: Regex,
explanation: &'static str,
data_only: bool,
}
fn rules() -> &'static [Rule] {
static RULES: OnceLock<Vec<Rule>> = OnceLock::new();
RULES.get_or_init(|| {
let r = |p: &str| Regex::new(p).expect("valid rule regex");
vec![
Rule {
kind: "instruction_override",
severity: "high",
re: r("(?i)(ignore|disregard|forget)\\s+(all\\s+|any\\s+)?(previous|prior|above|preceding|earlier|the\\s+system)?\\s*(instructions|prompt|rules|context|messages)"),
explanation: "Attempts to override or discard prior instructions — a classic prompt-injection lead-in.",
data_only: false,
},
Rule {
kind: "role_switch",
severity: "high",
re: r("(?im)^\\s*(system|assistant|developer)\\s*:|(?i)you\\s+are\\s+now\\s+|(?i)new\\s+(instructions|role|task|persona)\\s*:|(?i)pretend\\s+(you\\s+are|to\\s+be)"),
explanation: "Injects a fake role or redefines the assistant's identity/task.",
data_only: false,
},
Rule {
kind: "exfiltration_hint",
severity: "high",
re: r("(?i)(send|post|upload|exfiltrate|leak|transmit|email)\\b.{0,48}(api[\\s_-]?key|secret|token|password|credential|\\.env|private\\s+key)"),
explanation: "Couples an outbound action with secrets — a data-exfiltration instruction.",
data_only: false,
},
Rule {
kind: "tool_directive",
severity: "medium",
re: r("(?i)<\\s*tool_call|(?i)call\\s+the\\s+(function|tool)\\b|(?i)run\\s+the\\s+following\\s+command|(?i)execute\\s*:\\s*"),
explanation: "Tries to make the model invoke a tool or run a command from within content.",
data_only: false,
},
Rule {
kind: "instruction_in_data",
severity: "high",
re: r("(?i)(you\\s+must|your\\s+task\\s+is|always\\s+respond|do\\s+not\\s+tell|from\\s+now\\s+on|the\\s+assistant\\s+should)"),
explanation: "Imperative, assistant-directed language inside a data/config file — data should never instruct.",
data_only: true,
},
]
})
}
fn hidden_char_name(c: char) -> Option<&'static str> {
match c {
'\u{200B}' | '\u{200C}' | '\u{2060}' | '\u{FEFF}' => Some("zero-width character"),
'\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' => Some("bidirectional override"),
'\u{00AD}' => Some("soft hyphen"),
'\u{2028}' | '\u{2029}' => Some("unicode line/paragraph separator"),
'\u{E0000}'..='\u{E007F}' => Some("TAG character"),
_ => None,
}
}
pub fn scan_injection(content: &str, classification: &str) -> Vec<InjectionFinding> {
let is_data_like = matches!(classification, "data" | "config" | "vendored");
let mut findings = Vec::new();
for (idx, line) in content.lines().enumerate() {
let line_no = idx + 1;
for c in line.chars() {
if let Some(name) = hidden_char_name(c) {
findings.push(InjectionFinding {
kind: "hidden_unicode".to_string(),
severity: "high".to_string(),
line: line_no,
excerpt: excerpt(line),
explanation: format!(
"Contains a {name} (U+{:04X}) that can hide text from a human reviewer.",
c as u32
),
});
break;
}
}
if let Some(m) = longest_encoded_run(line) {
if m >= 200 {
findings.push(InjectionFinding {
kind: "encoded_blob".to_string(),
severity: "low".to_string(),
line: line_no,
excerpt: excerpt(line),
explanation: format!("A {m}-char encoded run may conceal a payload; verify it is legitimate data."),
});
}
}
let is_code = matches!(classification, "rust_source" | "test");
for rule in rules() {
if rule.data_only && !is_data_like {
continue;
}
if rule.re.is_match(line) {
let severity = if is_code { "low" } else { rule.severity };
findings.push(InjectionFinding {
kind: rule.kind.to_string(),
severity: severity.to_string(),
line: line_no,
excerpt: excerpt(line),
explanation: rule.explanation.to_string(),
});
}
}
}
findings
}
pub fn analyze_source(
source_id: &str,
source: SourceKind,
classification: &str,
content: &str,
) -> TrustReport {
let trust = trust_level(source, classification);
let findings = scan_injection(content, classification);
let risk = risk_score(&findings, trust);
let verdict = verdict_for(risk, &findings);
TrustReport {
source_id: source_id.to_string(),
source_kind: source,
classification: classification.to_string(),
trust_level: trust.as_str().to_string(),
risk_score: risk,
findings,
verdict,
}
}
fn risk_score(findings: &[InjectionFinding], trust: TrustLevel) -> u32 {
let base: u32 = findings
.iter()
.map(|f| match f.severity.as_str() {
"high" => 34,
"medium" => 18,
_ => 7,
})
.sum();
let scaled = match trust {
TrustLevel::Untrusted => base + base / 2,
TrustLevel::SemiTrusted => base + base / 4,
TrustLevel::Trusted => base,
};
scaled.min(100)
}
fn verdict_for(risk: u32, findings: &[InjectionFinding]) -> String {
if findings.is_empty() {
return "clean — no injection or pollution patterns found".to_string();
}
let has_high = findings.iter().any(|f| f.severity == "high");
match (risk, has_high) {
(r, true) if r >= 50 => {
"quarantine — high-severity injection patterns; do not send unreviewed".to_string()
}
(_, true) => "review — high-severity pattern present; confirm before including".to_string(),
_ => "caution — low-severity signals; likely benign but traceable".to_string(),
}
}
fn excerpt(line: &str) -> String {
let t = line.trim();
if t.chars().count() > 120 {
format!("{}…", t.chars().take(120).collect::<String>())
} else {
t.to_string()
}
}
fn longest_encoded_run(line: &str) -> Option<usize> {
let mut best = 0usize;
let mut cur = 0usize;
for c in line.chars() {
if c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' {
cur += 1;
best = best.max(cur);
} else {
cur = 0;
}
}
(best > 0).then_some(best)
}
#[cfg(test)]
#[path = "../../tests/unit/evolve/context_trust_test.rs"]
mod context_trust_test;