selfware 0.6.4

Your personal AI workshop — software you own, software that lasts
Documentation
//! Context safety: provenance, trust, and pollution detection.
//!
//! Everything the tiers/map/selection assemble eventually reaches a model. That
//! makes context a trust boundary: a tool output, a fetched file, or a tampered
//! data file can carry prompt-injection ("ignore previous instructions"),
//! role-switching, hidden-unicode payloads, or exfiltration hints. This module
//! tags each source with a provenance and trust level and scans its content for
//! those patterns, so a human (and the assistant flow) can see *what* is in
//! context, *where it came from*, and *whether it looks poisoned* before it is
//! ever sent.

use std::sync::OnceLock;

use regex::Regex;
use serde::{Deserialize, Serialize};

/// Where a piece of context came from — the root of traceability.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
    /// Authored source in the workspace (git-tracked).
    Workspace,
    /// Output captured from a tool run.
    ToolOutput,
    /// Text generated by a model.
    ModelOutput,
    /// Fetched from outside the workspace (web, remote).
    External,
    /// Recalled from persistent memory.
    Memory,
    /// Direct user input.
    User,
}

/// How much the content may be trusted to be inert data rather than instructions.
#[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",
        }
    }
}

/// A suspicious pattern found in context content.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InjectionFinding {
    /// Category: instruction_override, role_switch, hidden_unicode,
    /// instruction_in_data, exfiltration_hint, encoded_blob, tool_directive.
    pub kind: String,
    pub severity: String, // high | medium | low
    pub line: usize,
    pub excerpt: String,
    pub explanation: String,
}

/// A full trust assessment of one context source.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustReport {
    pub source_id: String,
    pub source_kind: SourceKind,
    pub classification: String,
    pub trust_level: String,
    /// 0 (clean) .. 100 (highly suspicious).
    pub risk_score: u32,
    pub findings: Vec<InjectionFinding>,
    /// Short human verdict.
    pub verdict: String,
}

/// Derive a trust level from provenance and content classification. Workspace
/// Rust source is trusted; data/vendored files are only semi-trusted (they can be
/// tampered and should never carry instructions); anything external or
/// model-generated is untrusted.
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,
    /// Only fire when the content is *not* executable code — instruction-shaped
    /// text is expected in prose/comments but suspicious inside data files.
    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,
            },
        ]
    })
}

/// Characters that don't render but can smuggle instructions past a human reader.
fn hidden_char_name(c: char) -> Option<&'static str> {
    match c {
        // U+200D (ZWJ) is deliberately excluded: it is a legitimate part of emoji
        // sequences (e.g. 🧑‍🎄) and flagging it produces constant false positives.
        '\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 look like ordinary line breaks to a human reader but
        // are single "characters" that can split a payload across what looks
        // like separate lines.
        '\u{2028}' | '\u{2029}' => Some("unicode line/paragraph separator"),
        // TAG characters (plane 14): invisible ASCII-encoded glyphs used to
        // smuggle instructions past both humans and naive string filters.
        '\u{E0000}'..='\u{E007F}' => Some("TAG character"),
        _ => None,
    }
}

/// Scan content for injection / pollution patterns. `is_code` relaxes the
/// instruction-shaped rules (expected in prose) but keeps the data-file rules.
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;

        // Hidden / bidi unicode anywhere is suspicious.
        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;
            }
        }

        // Long base64/hex blobs can smuggle encoded payloads.
        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."),
                });
            }
        }

        // First-party code legitimately discusses these patterns (safety modules,
        // parsers, tests, doc comments). There the same match is informational,
        // not an alarm — downgrade to low. On data/untrusted content it stays hot.
        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
}

/// Assemble a full trust report for one source.
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();
    // Untrusted provenance amplifies the same findings.
    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()
    }
}

/// Length of the longest run of base64/hex-ish characters in a line.
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;