-
-
Notifications
You must be signed in to change notification settings - Fork 84
feat(privacy-filter): add heartbeat-level privacy filtering engine #600
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
ErikBjare
merged 8 commits into
ActivityWatch:master
from
TimeToBuildBob:feat/privacy-filter-heartbeat
May 11, 2026
Merged
Changes from 2 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
25b1c55
feat(privacy-filter): add heartbeat-level privacy filtering engine
TimeToBuildBob a90ffa0
fix(privacy-filter): address Greptile review — backward compat, regex…
TimeToBuildBob 8eceb52
fix(privacy-filter): fix set_field panic and stale rules on key deletion
TimeToBuildBob 5b75ed1
style: rustfmt
TimeToBuildBob fdf8a21
fix(privacy-filter): reject Redact rules missing replacement in from_…
TimeToBuildBob 755ecd4
fix(privacy-filter): validate Redact field presence and regex syntax …
TimeToBuildBob 441f94d
fix(privacy-filter): reject Redact rules with empty-string replacemen…
TimeToBuildBob de2b5ec
fix(privacy-filter): require field on Drop rules in from_json to prev…
TimeToBuildBob 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
Some comments aren't visible on the classic Files Changed page.
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,3 +19,4 @@ log = "0.4" | |
|
|
||
| aw-models = { path = "../aw-models" } | ||
| aw-transform = { path = "../aw-transform" } | ||
| regex = "1" | ||
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,318 @@ | ||
| use std::sync::OnceLock; | ||
|
|
||
| use serde::{Deserialize, Serialize}; | ||
| use serde_json::map::Map; | ||
| use serde_json::Value; | ||
|
|
||
| use aw_models::Event; | ||
|
|
||
| /// Action to take when a rule matches an event. | ||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||
| #[serde(rename_all = "snake_case")] | ||
| pub enum PrivacyFilterAction { | ||
| /// Drop the entire event from storage | ||
| Drop, | ||
| /// Redact a specific field's value with a replacement | ||
| Redact, | ||
| } | ||
|
|
||
| /// A single privacy filter rule. | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct PrivacyFilterRule { | ||
| /// If true, this rule is active | ||
| pub enabled: bool, | ||
| /// Only apply to buckets whose ID starts with this prefix (e.g. "aw-watcher-window") | ||
| pub bucket_prefix: Option<String>, | ||
| /// Dotted path to the event data field to check (e.g. "title") | ||
| pub field: Option<String>, | ||
| /// Regex pattern to match against the field value | ||
| pub pattern: String, | ||
| /// What to do when matched | ||
| pub action: PrivacyFilterAction, | ||
| /// Replacement text for the redact action | ||
| pub replacement: Option<String>, | ||
| /// Pre-compiled regex, populated lazily on first match. Not serialized. | ||
| #[serde(skip)] | ||
| regex_cache: OnceLock<Option<regex::Regex>>, | ||
| } | ||
|
|
||
| impl PartialEq for PrivacyFilterRule { | ||
| fn eq(&self, other: &Self) -> bool { | ||
| self.enabled == other.enabled | ||
| && self.bucket_prefix == other.bucket_prefix | ||
| && self.field == other.field | ||
| && self.pattern == other.pattern | ||
| && self.action == other.action | ||
| && self.replacement == other.replacement | ||
| } | ||
| } | ||
|
|
||
| impl PrivacyFilterRule { | ||
| /// Check if this rule matches a given event in a given bucket. | ||
| pub fn matches(&self, bucket_id: &str, event: &Event) -> bool { | ||
| if !self.enabled { | ||
| return false; | ||
| } | ||
|
|
||
| // Check bucket prefix if specified | ||
| if let Some(ref prefix) = self.bucket_prefix { | ||
| if !bucket_id.starts_with(prefix) { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| // Check field pattern if specified | ||
| if let Some(ref field_path) = self.field { | ||
| let field_value = resolve_field(&event.data, field_path); | ||
| match field_value { | ||
| Some(Value::String(s)) => { | ||
| // Compile the regex once and cache it for subsequent calls. | ||
| let re = self | ||
| .regex_cache | ||
| .get_or_init(|| regex::Regex::new(&self.pattern).ok()); | ||
| re.as_ref().map(|re| re.is_match(s)).unwrap_or(false) | ||
| } | ||
| Some(_) | None => false, | ||
| } | ||
| } else { | ||
| true | ||
| } | ||
| } | ||
|
TimeToBuildBob marked this conversation as resolved.
|
||
|
|
||
| /// Apply this rule's action to an event. | ||
| /// Returns None if dropped, Some(event) if kept (possibly redacted). | ||
| pub fn apply<'a>(&self, event: &'a mut Event) -> Option<&'a mut Event> { | ||
| match self.action { | ||
| PrivacyFilterAction::Drop => None, | ||
| PrivacyFilterAction::Redact => { | ||
| if let Some(ref replacement) = self.replacement { | ||
| if let Some(ref field_path) = self.field { | ||
| set_field( | ||
| &mut event.data, | ||
| field_path, | ||
| Value::String(replacement.clone()), | ||
| ); | ||
| } | ||
| } | ||
| Some(event) | ||
| } | ||
| } | ||
|
TimeToBuildBob marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| /// Engine that holds and applies privacy filter rules. | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct PrivacyFilterEngine { | ||
| rules: Vec<PrivacyFilterRule>, | ||
| } | ||
|
|
||
| impl PrivacyFilterEngine { | ||
| pub fn new(rules: Vec<PrivacyFilterRule>) -> Self { | ||
| PrivacyFilterEngine { rules } | ||
| } | ||
|
|
||
| /// Example rules for common sensitive data patterns. | ||
| /// Not applied automatically — use `new()` with these rules to opt in. | ||
| pub fn with_defaults() -> Self { | ||
| let rules = vec![ | ||
| PrivacyFilterRule { | ||
| enabled: true, | ||
| bucket_prefix: Some("aw-watcher-window".to_string()), | ||
| field: Some("title".to_string()), | ||
| pattern: r"(?i)(private browsing|incognito)".to_string(), | ||
| action: PrivacyFilterAction::Drop, | ||
| replacement: None, | ||
| regex_cache: OnceLock::new(), | ||
| }, | ||
| PrivacyFilterRule { | ||
| enabled: true, | ||
| bucket_prefix: Some("aw-watcher-window".to_string()), | ||
| field: Some("title".to_string()), | ||
| pattern: r"(?i).*banking.*".to_string(), | ||
| action: PrivacyFilterAction::Redact, | ||
| replacement: Some("REDACTED".to_string()), | ||
| regex_cache: OnceLock::new(), | ||
| }, | ||
| ]; | ||
| PrivacyFilterEngine { rules } | ||
| } | ||
|
|
||
| /// Parse rules from a JSON string (as stored in settings). | ||
| pub fn from_json(json_str: &str) -> Result<Self, String> { | ||
| let rules: Vec<PrivacyFilterRule> = serde_json::from_str(json_str) | ||
| .map_err(|e| format!("Failed to parse privacy filter rules: {e}"))?; | ||
| Ok(PrivacyFilterEngine { rules }) | ||
| } | ||
|
|
||
| /// Serialize rules to JSON string. | ||
| pub fn to_json(&self) -> Result<String, String> { | ||
| serde_json::to_string(&self.rules) | ||
| .map_err(|e| format!("Failed to serialize privacy filter rules: {e}")) | ||
| } | ||
|
|
||
| /// Filter a single event for a given bucket. | ||
| /// Applies all matching rules. Returns None if dropped, Some (possibly redacted) event if kept. | ||
| pub fn filter_event(&self, bucket_id: &str, event: Event) -> Option<Event> { | ||
| let mut event = event; | ||
| for rule in &self.rules { | ||
| if rule.matches(bucket_id, &event) { | ||
| match rule.apply(&mut event) { | ||
| Some(_) => {} // Redacted — continue applying other rules | ||
| None => return None, // Dropped | ||
| } | ||
| } | ||
| } | ||
| Some(event) | ||
| } | ||
|
|
||
| /// Filter a batch of events for a given bucket. | ||
| pub fn filter_events(&self, bucket_id: &str, events: Vec<Event>) -> Vec<Event> { | ||
| events | ||
| .into_iter() | ||
| .filter_map(|e| self.filter_event(bucket_id, e)) | ||
| .collect() | ||
| } | ||
| } | ||
|
|
||
| /// Resolve a dotted field path (e.g. "title", "data.url") from a serde_json Map. | ||
| fn resolve_field<'a>(data: &'a Map<String, Value>, path: &str) -> Option<&'a Value> { | ||
| let parts: Vec<&str> = path.split('.').collect(); | ||
| let mut current: &Map<String, Value> = data; | ||
| for (i, part) in parts.iter().enumerate() { | ||
| let val = current.get(*part)?; | ||
| if i == parts.len() - 1 { | ||
| return Some(val); | ||
| } | ||
| match val { | ||
| Value::Object(map) => current = map, | ||
| _ => return None, | ||
| } | ||
| } | ||
| None | ||
| } | ||
|
|
||
| /// Set a field value at a dotted path in a serde_json Map. | ||
| fn set_field(data: &mut Map<String, Value>, path: &str, value: Value) { | ||
| let parts: Vec<&str> = path.split('.').collect(); | ||
| let mut current: &mut Map<String, Value> = data; | ||
| for (i, part) in parts.iter().enumerate() { | ||
| if i == parts.len() - 1 { | ||
| current.insert(part.to_string(), value); | ||
| return; | ||
| } | ||
| current = current | ||
| .entry(part.to_string()) | ||
| .or_insert_with(|| Value::Object(Map::new())) | ||
| .as_object_mut() | ||
| .expect("Field path conflicts with non-object value"); | ||
|
TimeToBuildBob marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use chrono::Utc; | ||
| use serde_json::json; | ||
|
|
||
| fn test_event(title: &str) -> Event { | ||
| Event { | ||
| id: None, | ||
| timestamp: Utc::now(), | ||
| duration: chrono::Duration::seconds(1), | ||
| data: json_map! {"title": json!(title), "app": json!("Firefox")}, | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_drop_incognito_window() { | ||
| let engine = PrivacyFilterEngine::with_defaults(); | ||
| let event = test_event("Private Browsing - Mozilla Firefox"); | ||
| let rule = &engine.rules[0]; | ||
| assert!(rule.matches("aw-watcher-window", &event)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_allow_normal_window() { | ||
| let engine = PrivacyFilterEngine::with_defaults(); | ||
| let event = test_event("GitHub - Mozilla Firefox"); | ||
| let rule = &engine.rules[0]; | ||
| assert!(!rule.matches("aw-watcher-window", &event)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_redact_banking() { | ||
| let engine = PrivacyFilterEngine::with_defaults(); | ||
| let mut event = test_event("Online Banking - My Account Balance"); | ||
| let rule = &engine.rules[1]; | ||
| assert!(rule.matches("aw-watcher-window", &event)); | ||
| let result = rule.apply(&mut event); | ||
| assert!(result.is_some()); | ||
| assert_eq!( | ||
| result.unwrap().data.get("title").unwrap().as_str().unwrap(), | ||
| "REDACTED" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_bucket_scoping() { | ||
| let rule = PrivacyFilterRule { | ||
| enabled: true, | ||
| bucket_prefix: Some("aw-watcher-window".to_string()), | ||
| field: Some("title".to_string()), | ||
| pattern: r"(?i)(private browsing|incognito)".to_string(), | ||
| action: PrivacyFilterAction::Drop, | ||
| replacement: None, | ||
| regex_cache: OnceLock::new(), | ||
| }; | ||
| let event = test_event("Private Browsing - Mozilla Firefox"); | ||
| assert!(rule.matches("aw-watcher-window", &event)); | ||
| assert!(!rule.matches("aw-watcher-afk", &event)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_disabled_rule() { | ||
| let rule = PrivacyFilterRule { | ||
| enabled: false, | ||
| bucket_prefix: Some("aw-watcher-window".to_string()), | ||
| field: Some("title".to_string()), | ||
| pattern: ".*".to_string(), | ||
| action: PrivacyFilterAction::Drop, | ||
| replacement: None, | ||
| regex_cache: OnceLock::new(), | ||
| }; | ||
| let event = test_event("Anything at all"); | ||
| assert!(!rule.matches("aw-watcher-window", &event)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_invalid_regex_no_panic() { | ||
| let rule = PrivacyFilterRule { | ||
| enabled: true, | ||
| bucket_prefix: Some("aw-watcher-window".to_string()), | ||
| field: Some("title".to_string()), | ||
| pattern: r"[invalid".to_string(), | ||
| action: PrivacyFilterAction::Drop, | ||
| replacement: None, | ||
| regex_cache: OnceLock::new(), | ||
| }; | ||
| let event = test_event("test"); | ||
| assert!(!rule.matches("aw-watcher-window", &event)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_drop_action() { | ||
| let rule = PrivacyFilterRule { | ||
| enabled: true, | ||
| bucket_prefix: None, | ||
| field: Some("title".to_string()), | ||
| pattern: ".*".to_string(), | ||
| action: PrivacyFilterAction::Drop, | ||
| replacement: None, | ||
| regex_cache: OnceLock::new(), | ||
| }; | ||
| let mut event = test_event("anything"); | ||
| assert!(rule.matches("any-bucket", &event)); | ||
| let result = rule.apply(&mut event); | ||
| assert!(result.is_none(), "Drop action should return None"); | ||
| } | ||
| } | ||
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.