Repository navigation
Expose pure local identification-risk helpers in the API #259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
c78dbbb
feat(safety): add local identification-risk classification and redaction
senamakel b4ee5cb
style(safety): reorder imports and drop trailing blank lines
senamakel 2dbe05f
chore: files changed crates/tinymemory-api/src/safety/detector.rs,cra…
senamakel f64c0c4
docs(safety): fix stale intra-doc links and scoring wording
senamakel cfcccfe
feat(safety): gate regex classification behind local-safety feature
senamakel 9a4553b
chore(safety): gate pii category weight behind local-safety feature
senamakel cc52c68
test(local-safety): gate no-logger test behind local-safety feature
senamakel 233d2be
chore: files changed crates/tinymemory-api/Cargo.toml,crates/tinymemo…
senamakel d1b0633
Merge branch 'main' into shrink-core-utilities-7418
senamakel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
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: ®ex::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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| //! Classification entry-point and risk-threshold regressions. | ||
|
|
||
| use super::*; | ||
|
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); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.