Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions crates/tinymemory-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,31 @@ serde_json = "1"
sha2 = "0.11"
# The crate-wide `Error`.
thiserror = "2"
# Pure local identification-risk matching; no runtime or engine dependency.
regex = { version = "1", optional = true }

[dev-dependencies]
# Regression proving pure safety helpers do not invoke a host-installed logger.
log = "0.4"
tokio = { version = "1", features = ["macros", "rt"] }

[features]
default = []
# Opt-in local classification/redaction; the default engine contract stays regex-free.
local-safety = ["dep:regex"]
# `conformance::run`, the behavioural suite every engine must pass, and
# `conformance::ReferenceEngine`, an in-memory engine that passes it. Adds no
# dependency: both are written against the contract alone.
conformance = []

[[test]]
name = "local_safety_no_logger"
required-features = ["local-safety"]

[[test]]
name = "safety_contract"
required-features = ["local-safety"]

[[test]]
name = "conformance_reference"
required-features = ["conformance"]
Expand Down
6 changes: 6 additions & 0 deletions crates/tinymemory-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ Architecture documents live in
[namespaces](../../docs/architecture/namespaces.md). The accepted behaviour is
[`docs/specs/memory-v2.md`](../../docs/specs/memory-v2.md).

The [`safety`](src/safety/README.md) module supplies pure local identification-risk
classification and structured redaction without linking an engine or runtime.
Hosts retain privacy policy and all I/O.

Risk vocabulary is available by default. Enable `local-safety` for regex-based
classification and redaction; the default engine contract stays regex-free.
The [provenance module](src/provenance/README.md) describes safe prompt markers
and host-supplied connector prefixes; its accepted contract is
[Provenance markers](../../docs/specs/provenance-markers.md).
2 changes: 2 additions & 0 deletions crates/tinymemory-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ pub mod meta;
pub mod namespace;
pub mod provenance;
pub mod query;
/// Pure, local identification-risk vocabulary, classification and redaction.
pub mod safety;
Comment thread
senamakel marked this conversation as resolved.
pub mod write;

pub use consolidate::{
Expand Down
19 changes: 19 additions & 0 deletions crates/tinymemory-api/src/safety/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Local identification risk

The public `safety` contract exposes stable risk levels, categories and counts,
plus pure `scan` and structured `redact_identifiers` helpers. Results never
include matched substrings. Classification uses local regexes, keywords and
structural validators; it performs no I/O and links no engine, storage or
runtime. Consumers own whether a result blocks, warns or allows an operation.

Strong identifiers force high risk. Category co-occurrence adds the existing
score bonus; benign text and invalid cards/IPs remain unflagged. Redaction
replaces validated structured identifiers while preserving topical keywords.

This helper preserves the OpenHuman detector behavior and is independent of
`tinymemory-integrations::safety`, which owns storage-item scrubbing. It does
not install a write pipeline or enforce a privacy policy. Regex supports local matching. The helper never invokes a global logger;
consumers own diagnostics and must consider the sensitivity of derived categories.

Risk vocabulary is available by default. Enable `local-safety` for regex-based
classification and redaction; the default engine contract stays regex-free.
122 changes: 122 additions & 0 deletions crates/tinymemory-api/src/safety/detector.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//! Classifier entry point for the local PII detector.
//!
//! [`scan`] is a pure, synchronous function: it walks the compiled rule set
//! (see [`super::rules`]) over the input, tallies matches per category, and
//! folds the tally into a [`PiiScanResult`]. No async, no I/O, no network.

use std::collections::BTreeMap;

use super::rules::rules;
use super::types::{CategoryHit, PiiCategory, PiiScanResult, RiskLevel};

/// Extra points added per additional co-occurring category. Multiple distinct
/// identifier/context categories in one blob look more like a real record than
/// any single one alone, so co-occurrence nudges the score upward.
const CO_OCCURRENCE_BONUS: u32 = 5;

/// Map a raw score to a [`RiskLevel`]. Thresholds lean toward escalation
/// (recall over precision).
fn level_from_score(score: u32) -> RiskLevel {
match score {
0 => RiskLevel::None,
1..=19 => RiskLevel::Low,
20..=44 => RiskLevel::Medium,
_ => RiskLevel::High,
}
}

/// Scan `content` for identification risk.
///
/// Returns a [`PiiScanResult`] carrying the overall [`RiskLevel`], a numeric
/// score, and the distinct categories detected (with per-category counts).
///
/// Detection runs **fully locally** — pattern + keyword matching only. The
/// function is deterministic and side-effect-free, so it is safe to call from
/// any context (sync or async, on or off a Tokio runtime).
///
/// ## Scoring
/// * Each distinct category contributes its fixed category weight once.
/// * Each additional co-occurring category adds the fixed co-occurrence bonus.
/// * A "strong identifier" (SSN, card, passport, bank account) forces
/// [`RiskLevel::High`] regardless of the numeric score.
pub fn scan(content: &str) -> PiiScanResult {
// BTreeMap keeps the output deterministic (categories emitted in enum
// order) without a separate sort pass.
let mut counts: BTreeMap<PiiCategory, usize> = BTreeMap::new();

for rule in rules() {
for m in rule.regex.find_iter(content) {
if let Some(validator) = rule.validator
&& !validator(m.as_str())
{
continue;
}
*counts.entry(rule.category).or_default() += 1;
Comment thread
senamakel marked this conversation as resolved.
}
}

if counts.is_empty() {
return PiiScanResult::default();
}

let distinct = counts.len() as u32;
let mut score: u32 = counts.keys().map(|c| c.weight()).sum();
if distinct > 1 {
score += CO_OCCURRENCE_BONUS * (distinct - 1);
}

let has_strong = counts.keys().any(|c| c.is_strong_identifier());
let mut level = level_from_score(score);
if has_strong && level < RiskLevel::High {
level = RiskLevel::High;
}

let categories: Vec<PiiCategory> = counts.keys().copied().collect();
let hits: Vec<CategoryHit> = counts
.iter()
.map(|(&category, &count)| CategoryHit { category, count })
.collect();

PiiScanResult {
level,
score,
categories,
hits,
}
}

/// Replacement for every value [`redact_identifiers`] removes.
pub const REDACTED_IDENTIFIER: &str = "[redacted]";

/// Replace every **structured identifier** in `content` (email, phone number,
/// national id, payment card, IBAN, IPv4 address, postal address) with
/// [`REDACTED_IDENTIFIER`].
///
/// Uses the same rules (and validators) as [`scan`], so what is redacted is
/// exactly what would be counted. Topical keyword categories (medical, legal,
/// ...) describe context rather than identify anyone and are left in place.
/// For text shown back to a user from a source that may quote someone's
/// details, such as a tool's error line.
pub fn redact_identifiers(content: &str) -> String {
let mut out = content.to_string();
for rule in rules() {
if !rule.category.is_structured_identifier() {
continue;
}
let redacted = rule.regex.replace_all(&out, |caps: &regex::Captures<'_>| {
let matched = &caps[0];
match rule.validator {
Some(validator) if !validator(matched) => matched.to_string(),
_ => REDACTED_IDENTIFIER.to_string(),
}
});
if let std::borrow::Cow::Owned(changed) = redacted {
out = changed;
}
}
out
}

#[cfg(test)]
#[path = "detector_tests.rs"]
mod tests;
23 changes: 23 additions & 0 deletions crates/tinymemory-api/src/safety/detector_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//! Classification entry-point and risk-threshold regressions.

use super::*;
Comment thread
senamakel marked this conversation as resolved.

#[test]
fn empty_input_is_none() {
let r = scan("");
assert_eq!(r.level, RiskLevel::None);
assert_eq!(r.score, 0);
assert!(r.categories.is_empty());
assert!(!r.is_sensitive());
}

#[test]
fn level_thresholds_are_monotonic() {
assert_eq!(level_from_score(0), RiskLevel::None);
assert_eq!(level_from_score(1), RiskLevel::Low);
assert_eq!(level_from_score(19), RiskLevel::Low);
assert_eq!(level_from_score(20), RiskLevel::Medium);
assert_eq!(level_from_score(44), RiskLevel::Medium);
assert_eq!(level_from_score(45), RiskLevel::High);
assert_eq!(level_from_score(1000), RiskLevel::High);
}
43 changes: 43 additions & 0 deletions crates/tinymemory-api/src/safety/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//! Local PII / identification-risk detector (pure public contract helper).
//!
//! Detects when a prompt or document may contain patient / legal / financial /
//! family or other identifiable information, and reports a risk **level**, a
//! numeric **score**, and the matched **categories** — so upstream gates can
//! decide whether content is safe to send off-device.
//!
//! ## Hard guarantee: detection is fully local
//! Classification is pure pattern + keyword matching over the input string.
//! There is **no** network call, no async, no external model — importing this
//! module pulls in nothing beyond `regex`. Sending content to a remote service
//! to classify it would defeat the entire purpose of the privacy epic, so the
//! helper performs no I/O and requires no runtime or engine.
//!
//! ## Recall over precision
//! A missed flag means sensitive data leaves the machine unnoticed, so the
//! detector is tuned to over-flag rather than under-flag. See `scan` (feature `local-safety`)
//! for the scoring model.
//!
//! ```
//! # #[cfg(feature = "local-safety")]
//! # {
//! use tinymemory_api::safety::{scan, PiiCategory, RiskLevel};
//!
//! let result = scan("patient John was diagnosed; SSN 123-45-6789");
//! assert_eq!(result.level, RiskLevel::High);
//! assert!(result.has_category(PiiCategory::NationalId));
//! # }
//! ```

#[cfg(feature = "local-safety")]
mod detector;
#[cfg(feature = "local-safety")]
mod rules;
mod types;

#[cfg(feature = "local-safety")]
pub use detector::{REDACTED_IDENTIFIER, redact_identifiers, scan};
pub use types::{CategoryHit, PiiCategory, PiiScanResult, RiskLevel};

#[cfg(all(test, feature = "local-safety"))]
#[path = "mod_tests.rs"]
mod tests;
Loading
Loading