From 8515afde06dffbd5024977c8c0fa33e97b604b69 Mon Sep 17 00:00:00 2001 From: Nicolas Luck Date: Thu, 22 Jan 2026 14:53:42 +0100 Subject: [PATCH 1/9] Refactor notification system to work with surreal trigger code instead of Prolog --- rust-executor/src/db.rs | 102 ++++++++++++++++++ .../src/perspectives/perspective_instance.rs | 91 ++++++++++++---- rust-executor/src/surreal_service/mod.rs | 70 ++++++++++++ tests/js/tests/runtime.ts | 90 +++++++++++++--- 4 files changed, 316 insertions(+), 37 deletions(-) diff --git a/rust-executor/src/db.rs b/rust-executor/src/db.rs index 95c9775d9..ce5c29075 100644 --- a/rust-executor/src/db.rs +++ b/rust-executor/src/db.rs @@ -453,10 +453,105 @@ impl Ad4mDb { Ok(result > 0) } + /// Validates that a notification query is safe and well-formed + fn validate_notification_query(query: &str) -> Result<(), String> { + let query_trimmed = query.trim(); + let query_upper = query_trimmed.to_uppercase(); + + // Check for empty query + if query_trimmed.is_empty() { + return Err("Query cannot be empty".to_string()); + } + + // Check query length (prevent extremely long queries) + if query_trimmed.len() > 10000 { + return Err("Query is too long (max 10000 characters)".to_string()); + } + + // Validate that query starts with SELECT, RETURN, LET, or WITH + let first_word = query_upper.split_whitespace().next().unwrap_or(""); + if !matches!(first_word, "SELECT" | "RETURN" | "LET" | "WITH") { + return Err(format!( + "Query must start with SELECT, RETURN, LET, or WITH. Got: {}", + first_word + )); + } + + // Check for mutating operations + let mutating_operations = [ + "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "REMOVE", + "DEFINE", "ALTER", "RELATE", "BEGIN", "COMMIT", "CANCEL", + ]; + + for operation in &mutating_operations { + let mut search_pos = 0; + while let Some(pos) = query_upper[search_pos..].find(operation) { + let absolute_pos = search_pos + pos; + + // Check what comes before + let before_ok = if absolute_pos == 0 { + true + } else { + let before_char = query_upper.chars().nth(absolute_pos - 1); + matches!( + before_char, + Some(' ') | Some('\t') | Some('\n') | Some('\r') | Some(';') | Some('(') + ) + }; + + // Check what comes after + let after_pos = absolute_pos + operation.len(); + let after_ok = if after_pos >= query_upper.len() { + true + } else { + let after_char = query_upper.chars().nth(after_pos); + matches!( + after_char, + Some(' ') | Some('\t') | Some('\n') | Some('\r') | Some(';') | Some('(') + ) + }; + + if before_ok && after_ok { + return Err(format!( + "Query contains mutating operation '{}' which is not allowed", + operation + )); + } + + search_pos = absolute_pos + 1; + } + } + + // Basic syntax check - ensure balanced parentheses + let mut paren_count = 0; + for c in query_trimmed.chars() { + match c { + '(' => paren_count += 1, + ')' => paren_count -= 1, + _ => {} + } + if paren_count < 0 { + return Err("Unbalanced parentheses in query".to_string()); + } + } + if paren_count != 0 { + return Err("Unbalanced parentheses in query".to_string()); + } + + Ok(()) + } + pub fn add_notification( &self, notification: NotificationInput, ) -> Result { + // Validate the trigger query before storing + if let Err(e) = Self::validate_notification_query(¬ification.trigger) { + return Err(rusqlite::Error::InvalidParameterName( + format!("Invalid notification query: {}", e) + )); + } + let id = uuid::Uuid::new_v4().to_string(); self.conn.execute( "INSERT INTO notifications (id, granted, description, appName, appUrl, appIconPath, trigger, perspective_ids, webhookUrl, webhookAuth) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", @@ -535,6 +630,13 @@ impl Ad4mDb { id: String, updated_notification: &Notification, ) -> Result { + // Validate the trigger query before updating + if let Err(e) = Self::validate_notification_query(&updated_notification.trigger) { + return Err(rusqlite::Error::InvalidParameterName( + format!("Invalid notification query: {}", e) + )); + } + let result = self.conn.execute( "UPDATE notifications SET description = ?2, appName = ?3, appUrl = ?4, appIconPath = ?5, trigger = ?6, perspective_ids = ?7, webhookUrl = ?8, webhookAuth = ?9, granted = ?10 WHERE id = ?1", params![ diff --git a/rust-executor/src/perspectives/perspective_instance.rs b/rust-executor/src/perspectives/perspective_instance.rs index 611d28ded..6049658fa 100644 --- a/rust-executor/src/perspectives/perspective_instance.rs +++ b/rust-executor/src/perspectives/perspective_instance.rs @@ -2296,6 +2296,47 @@ impl PerspectiveInstance { }) } + /// Executes a SurrealQL query for notifications with context injection + /// Auto-injects $agentDid and $perspectiveId variables before execution + pub async fn surreal_query_notification( + &self, + query: String, + ) -> Result, AnyError> { + // Get context data without holding locks + let perspective_id = { + let persisted_guard = self.persisted.lock().await; + persisted_guard.uuid.clone() + }; + + // Get global agent DID (not perspective-specific owner) + let agent_did = crate::agent::did(); + + // Inject context variables using string replacement instead of LET statements + // This ensures the SELECT query result is at index 0 + let query_with_context = query + .replace("$agentDid", &format!("'{}'", agent_did)) + .replace("$perspectiveId", &format!("'{}'", perspective_id)); + + log::debug!("🔔 Notification query original: {}", query); + log::debug!("🔔 Notification query with context (agentDid='{}', perspectiveId='{}'): {}", agent_did, perspective_id, query_with_context); + + let results = self.surreal_service + .query_links(&perspective_id, &query_with_context) + .await + .map_err(|e| { + log::error!( + "Failed to execute notification query for perspective {}: {:?}", + perspective_id, + e + ); + anyhow!("Notification query failed for perspective {}: {}", perspective_id, e) + })?; + + log::debug!("🔔 Notification query results: {:?}", results); + + Ok(results) + } + async fn retry_surreal_op(op: F, uuid: &str, op_name: &str) where F: Fn() -> Fut, @@ -2523,7 +2564,7 @@ impl PerspectiveInstance { async fn calc_notification_trigger_matches( &self, - ) -> Result>, AnyError> { + ) -> Result>, AnyError> { // Get UUID without holding lock during operations let uuid = { let persisted_guard = self.persisted.lock().await; @@ -2538,7 +2579,7 @@ impl PerspectiveInstance { // .collect::>() // .join("\n")); let mut result_map = BTreeMap::new(); - let mut trigger_cache: HashMap> = HashMap::new(); + let mut trigger_cache: HashMap> = HashMap::new(); for n in notifications { //log::info!("🔔 NOTIFICATIONS: Processing notification for perspective {}: {}", uuid, n.trigger); @@ -2548,11 +2589,7 @@ impl PerspectiveInstance { } else { //let query_start = std::time::Instant::now(); //log::info!("🔔 NOTIFICATIONS: not cached - Querying notification for perspective {}", uuid); - let query_result = self.prolog_query_notification(n.trigger.clone()).await?; - let matches = match query_result { - QueryResolution::Matches(matches) => matches, - _ => Vec::new(), // For True/False results, use empty matches - }; + let matches = self.surreal_query_notification(n.trigger.clone()).await?; trigger_cache.insert(n.trigger.clone(), matches.clone()); result_map.insert(n.clone(), matches); //log::info!("🔔 NOTIFICATIONS: Querying notification: {} - took {:?}", n.trigger, query_start.elapsed()); @@ -2562,7 +2599,7 @@ impl PerspectiveInstance { Ok(result_map) } - async fn notification_trigger_snapshot(&self) -> BTreeMap> { + async fn notification_trigger_snapshot(&self) -> BTreeMap> { self.calc_notification_trigger_matches() .await .unwrap_or_else(|e| { @@ -2572,38 +2609,52 @@ impl PerspectiveInstance { } fn subtract_before_notification_matches( - before: &BTreeMap>, - after: &BTreeMap>, - ) -> BTreeMap> { + before: &BTreeMap>, + after: &BTreeMap>, + ) -> BTreeMap> { after .iter() - .map(|(notification, matches)| { - let new_matches: Vec = - if let Some(old_matches) = before.get(notification) { - matches + .filter_map(|(notification, after_matches)| { + let new_matches: Vec = + if let Some(before_matches) = before.get(notification) { + // Find matches that exist in "after" but not in "before" + after_matches .iter() - .filter(|m| !old_matches.contains(m)) + .filter(|after_match| { + !before_matches.iter().any(|before_match| { + before_match == *after_match + }) + }) .cloned() .collect() } else { - matches.clone() + // No previous matches, so all current matches are new + after_matches.clone() }; - (notification.clone(), new_matches) + if new_matches.is_empty() { + None + } else { + Some((notification.clone(), new_matches)) + } }) .collect() } async fn publish_notification_matches( uuid: String, - match_map: BTreeMap>, + match_map: BTreeMap>, ) { for (notification, matches) in match_map { if !matches.is_empty() { + // Convert matches to JSON string + let trigger_match = serde_json::to_string(&matches) + .unwrap_or_else(|_| "[]".to_string()); + let payload = TriggeredNotification { notification: notification.clone(), perspective_id: uuid.clone(), - trigger_match: prolog_resolution_to_string(QueryResolution::Matches(matches)), + trigger_match, }; let message = serde_json::to_string(&payload).unwrap(); diff --git a/rust-executor/src/surreal_service/mod.rs b/rust-executor/src/surreal_service/mod.rs index 54122e24c..3f98d419f 100644 --- a/rust-executor/src/surreal_service/mod.rs +++ b/rust-executor/src/surreal_service/mod.rs @@ -321,6 +321,76 @@ impl SurrealDBService { return url; }; }; + + DEFINE FUNCTION IF NOT EXISTS fn::strip_html($html: option) { + RETURN function($html) { + const [html] = arguments; + + if (!html || typeof html !== 'string') { + return html; + } + + // Remove HTML tags using regex + return html.replace(/<[^>]*>/g, ''); + }; + }; + + DEFINE FUNCTION IF NOT EXISTS fn::json_path($obj: option, $path: option) { + RETURN function($obj, $path) { + const [obj, path] = arguments; + + if (!obj || !path || typeof path !== 'string') { + return null; + } + + // Split path by dots and traverse object + const parts = path.split('.'); + let current = obj; + + for (const part of parts) { + if (current && typeof current === 'object' && part in current) { + current = current[part]; + } else { + return null; + } + } + + return current; + }; + }; + + DEFINE FUNCTION IF NOT EXISTS fn::contains($str: option, $substring: option) { + RETURN function($str, $substring) { + const [str, substring] = arguments; + //console.log('🔍 fn::contains input - str:', str, 'substring:', substring); + + if (!str || !substring || typeof str !== 'string' || typeof substring !== 'string') { + //console.log('🔍 fn::contains: invalid types, returning false'); + return false; + } + + const result = str.includes(substring); + //console.log('🔍 fn::contains result:', result); + return result; + }; + }; + + DEFINE FUNCTION IF NOT EXISTS fn::regex_match($str: option, $pattern: option) { + RETURN function($str, $pattern) { + const [str, pattern] = arguments; + + if (!str || !pattern || typeof str !== 'string' || typeof pattern !== 'string') { + return false; + } + + try { + const regex = new RegExp(pattern); + return regex.test(str); + } catch (e) { + return false; + } + }; + }; ", ) .await?; diff --git a/tests/js/tests/runtime.ts b/tests/js/tests/runtime.ts index adb304f7a..e1f5cd921 100644 --- a/tests/js/tests/runtime.ts +++ b/tests/js/tests/runtime.ts @@ -157,7 +157,7 @@ export default function runtimeTests(testContext: TestContext) { appName: "Test App Name", appUrl: "Test App URL", appIconPath: "Test App Icon Path", - trigger: "Test Trigger", + trigger: "SELECT * FROM link WHERE predicate = 'test://never-matches'", perspectiveIds: ["Test Perspective ID"], webhookUrl: "Test Webhook URL", webhookAuth: "Test Webhook Auth" @@ -174,14 +174,16 @@ export default function runtimeTests(testContext: TestContext) { if (exception.type === ExceptionType.InstallNotificationRequest) { const requestedNotification = JSON.parse(exception.addon); - expect(requestedNotification.description).to.equal(notification.description); - expect(requestedNotification.appName).to.equal(notification.appName); - expect(requestedNotification.appUrl).to.equal(notification.appUrl); - expect(requestedNotification.appIconPath).to.equal(notification.appIconPath); - expect(requestedNotification.trigger).to.equal(notification.trigger); - expect(requestedNotification.perspectiveIds).to.eql(notification.perspectiveIds); - expect(requestedNotification.webhookUrl).to.equal(notification.webhookUrl); - expect(requestedNotification.webhookAuth).to.equal(notification.webhookAuth); + // Only check assertions for THIS test's notification + if (requestedNotification.description === notification.description) { + expect(requestedNotification.appName).to.equal(notification.appName); + expect(requestedNotification.appUrl).to.equal(notification.appUrl); + expect(requestedNotification.appIconPath).to.equal(notification.appIconPath); + expect(requestedNotification.trigger).to.equal(notification.trigger); + expect(requestedNotification.perspectiveIds).to.eql(notification.perspectiveIds); + expect(requestedNotification.webhookUrl).to.equal(notification.webhookUrl); + expect(requestedNotification.webhookAuth).to.equal(notification.webhookAuth); + } // Automatically resolve without needing to manually manage a Promise return null; } @@ -215,7 +217,7 @@ export default function runtimeTests(testContext: TestContext) { appName: "Test App Name", appUrl: "Test App URL", appIconPath: "Test App Icon Path", - trigger: "Test Trigger", + trigger: "SELECT * FROM link WHERE predicate = 'test://updated'", perspectiveIds: ["Test Perspective ID"], webhookUrl: "Test Webhook URL", webhookAuth: "Test Webhook Auth" @@ -235,8 +237,7 @@ export default function runtimeTests(testContext: TestContext) { expect(removed).to.be.true }) - // TODO: make notifications work without prolog - it.skip("can trigger notifications", async () => { + it("can trigger notifications", async () => { const ad4mClient = testContext.ad4mClient! let triggerPredicate = "ad4m://notification" @@ -249,7 +250,7 @@ export default function runtimeTests(testContext: TestContext) { appName: "ADAM tests", appUrl: "Test App URL", appIconPath: "Test App Icon Path", - trigger: `triple(Source, "${triggerPredicate}", Target)`, + trigger: `SELECT source, target, predicate FROM link WHERE predicate = '${triggerPredicate}'`, perspectiveIds: [notificationPerspective.uuid], webhookUrl: "Test Webhook URL", webhookAuth: "Test Webhook Auth" @@ -285,9 +286,9 @@ export default function runtimeTests(testContext: TestContext) { expect(triggerMatch.length).to.equal(1) let match = triggerMatch[0] //@ts-ignore - expect(match.Source).to.equal("test://source") + expect(match.source).to.equal("test://source") //@ts-ignore - expect(match.Target).to.equal("test://target1") + expect(match.target).to.equal("test://target1") // Ensuring we don't get old data on a new trigger await notificationPerspective.add(new Link({source: "test://source", predicate: triggerPredicate, target: "test://target2"})) @@ -298,9 +299,64 @@ export default function runtimeTests(testContext: TestContext) { expect(triggerMatch.length).to.equal(1) match = triggerMatch[0] //@ts-ignore - expect(match.Source).to.equal("test://source") + expect(match.source).to.equal("test://source") //@ts-ignore - expect(match.Target).to.equal("test://target2") + expect(match.target).to.equal("test://target2") + }) + + it("can detect mentions in notifications (Flux example)", async () => { + const ad4mClient = testContext.ad4mClient! + const agentStatus = await ad4mClient.agent.status() + const agentDid = agentStatus.did + + let notificationPerspective = await ad4mClient.perspective.add("flux mention test") + + const notification: NotificationInput = { + description: "You were mentioned in a message", + appName: "Flux Mentions", + appUrl: "https://flux.app", + appIconPath: "/flux-icon.png", + // Use context variable $agentDid, fn::parse_literal and fn::contains functions + trigger: `SELECT source, fn::parse_literal(target) as target, predicate FROM link WHERE predicate = 'rdf://content' AND fn::contains(fn::parse_literal(target), $agentDid)`, + perspectiveIds: [notificationPerspective.uuid], + webhookUrl: "https://test.webhook", + webhookAuth: "test-auth" + } + + const notificationId = await ad4mClient.runtime.requestInstallNotification(notification); + await sleep(1000) + const granted = await ad4mClient.runtime.grantNotification(notificationId) + expect(granted).to.be.true + + const mockFunction = sinon.stub(); + await ad4mClient.runtime.addNotificationTriggeredCallback(mockFunction) + + // Add a message that doesn't mention the agent + await notificationPerspective.add(new Link({ + source: "message://1", + predicate: "rdf://content", + target: "literal://string:Hello%20world" + })) + await sleep(2000) + expect(mockFunction.called).to.be.false + + // Add a message that mentions the agent + await notificationPerspective.add(new Link({ + source: "message://2", + predicate: "rdf://content", + target: `literal://string:Hello%20${encodeURIComponent(agentDid!)}%2C%20how%20are%20you%3F` + })) + await sleep(7000) + expect(mockFunction.called).to.be.true + + let triggeredNotification = mockFunction.getCall(0).args[0] as TriggeredNotification + expect(triggeredNotification.notification.description).to.equal(notification.description) + let triggerMatch = JSON.parse(triggeredNotification.triggerMatch) + expect(triggerMatch.length).to.equal(1) + //@ts-ignore + expect(triggerMatch[0].source).to.equal("message://2") + //@ts-ignore + expect(triggerMatch[0].target).to.include(agentDid) }) it("can export and import database", async () => { From c68b8c8be5055beb530fe9f134e64dd5ab04bdca Mon Sep 17 00:00:00 2001 From: Nicolas Luck Date: Thu, 22 Jan 2026 15:53:28 +0100 Subject: [PATCH 2/9] Update Flux inspired test to return multiple variables --- tests/js/tests/runtime.ts | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/tests/js/tests/runtime.ts b/tests/js/tests/runtime.ts index e1f5cd921..04cbcc61b 100644 --- a/tests/js/tests/runtime.ts +++ b/tests/js/tests/runtime.ts @@ -316,8 +316,16 @@ export default function runtimeTests(testContext: TestContext) { appName: "Flux Mentions", appUrl: "https://flux.app", appIconPath: "/flux-icon.png", - // Use context variable $agentDid, fn::parse_literal and fn::contains functions - trigger: `SELECT source, fn::parse_literal(target) as target, predicate FROM link WHERE predicate = 'rdf://content' AND fn::contains(fn::parse_literal(target), $agentDid)`, + // Extract multiple data points from the match + trigger: `SELECT + source as message_id, + fn::parse_literal(target) as message_content, + fn::strip_html(fn::parse_literal(target)) as plain_text, + $agentDid as mentioned_agent, + $perspectiveId as perspective_id + FROM link + WHERE predicate = 'rdf://content' + AND fn::contains(fn::parse_literal(target), $agentDid)`, perspectiveIds: [notificationPerspective.uuid], webhookUrl: "https://test.webhook", webhookAuth: "test-auth" @@ -340,11 +348,12 @@ export default function runtimeTests(testContext: TestContext) { await sleep(2000) expect(mockFunction.called).to.be.false - // Add a message that mentions the agent + // Add a message that mentions the agent (with HTML formatting) + const messageWithMention = `

Hey ${agentDid!}, how are you?

` await notificationPerspective.add(new Link({ source: "message://2", predicate: "rdf://content", - target: `literal://string:Hello%20${encodeURIComponent(agentDid!)}%2C%20how%20are%20you%3F` + target: `literal://string:${encodeURIComponent(messageWithMention)}` })) await sleep(7000) expect(mockFunction.called).to.be.true @@ -353,10 +362,22 @@ export default function runtimeTests(testContext: TestContext) { expect(triggeredNotification.notification.description).to.equal(notification.description) let triggerMatch = JSON.parse(triggeredNotification.triggerMatch) expect(triggerMatch.length).to.equal(1) + + // Verify all extracted data points + //@ts-ignore + expect(triggerMatch[0].message_id).to.equal("message://2") + //@ts-ignore + expect(triggerMatch[0].message_content).to.include(agentDid) + //@ts-ignore + expect(triggerMatch[0].message_content).to.include("") + //@ts-ignore + expect(triggerMatch[0].plain_text).to.include(agentDid) + //@ts-ignore + expect(triggerMatch[0].plain_text).to.not.include("") //@ts-ignore - expect(triggerMatch[0].source).to.equal("message://2") + expect(triggerMatch[0].mentioned_agent).to.equal(agentDid) //@ts-ignore - expect(triggerMatch[0].target).to.include(agentDid) + expect(triggerMatch[0].perspective_id).to.equal(notificationPerspective.uuid) }) it("can export and import database", async () => { From 4accd032f2a53fa3b9c1b2d35ab10dbda6849768 Mon Sep 17 00:00:00 2001 From: Nicolas Luck Date: Thu, 22 Jan 2026 21:15:26 +0100 Subject: [PATCH 3/9] fix: Parentheses check causes false positives for queries with parentheses in string literals. --- rust-executor/src/db.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/rust-executor/src/db.rs b/rust-executor/src/db.rs index ce5c29075..a81619de2 100644 --- a/rust-executor/src/db.rs +++ b/rust-executor/src/db.rs @@ -523,13 +523,38 @@ impl Ad4mDb { } // Basic syntax check - ensure balanced parentheses + // Skip counting parentheses inside string literals let mut paren_count = 0; + let mut in_string = false; + let mut string_char = ' '; // Track which quote character started the string + let mut escaped = false; + for c in query_trimmed.chars() { + if escaped { + // Skip this character as it's escaped + escaped = false; + continue; + } + match c { - '(' => paren_count += 1, - ')' => paren_count -= 1, + '\\' => escaped = true, + '\'' | '"' => { + if in_string { + // Check if this closes the current string + if c == string_char { + in_string = false; + } + } else { + // Start a new string + in_string = true; + string_char = c; + } + } + '(' if !in_string => paren_count += 1, + ')' if !in_string => paren_count -= 1, _ => {} } + if paren_count < 0 { return Err("Unbalanced parentheses in query".to_string()); } From 5484b4074aea59a2f254d04ff7bfcdc547466f4c Mon Sep 17 00:00:00 2001 From: Nicolas Luck Date: Thu, 22 Jan 2026 21:19:19 +0100 Subject: [PATCH 4/9] fmt --- rust-executor/src/db.rs | 18 ++++++----- .../src/perspectives/perspective_instance.rs | 30 +++++++++++++------ 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/rust-executor/src/db.rs b/rust-executor/src/db.rs index a81619de2..4b8e584eb 100644 --- a/rust-executor/src/db.rs +++ b/rust-executor/src/db.rs @@ -479,8 +479,8 @@ impl Ad4mDb { // Check for mutating operations let mutating_operations = [ - "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "REMOVE", - "DEFINE", "ALTER", "RELATE", "BEGIN", "COMMIT", "CANCEL", + "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "REMOVE", "DEFINE", "ALTER", "RELATE", + "BEGIN", "COMMIT", "CANCEL", ]; for operation in &mutating_operations { @@ -572,9 +572,10 @@ impl Ad4mDb { ) -> Result { // Validate the trigger query before storing if let Err(e) = Self::validate_notification_query(¬ification.trigger) { - return Err(rusqlite::Error::InvalidParameterName( - format!("Invalid notification query: {}", e) - )); + return Err(rusqlite::Error::InvalidParameterName(format!( + "Invalid notification query: {}", + e + ))); } let id = uuid::Uuid::new_v4().to_string(); @@ -657,9 +658,10 @@ impl Ad4mDb { ) -> Result { // Validate the trigger query before updating if let Err(e) = Self::validate_notification_query(&updated_notification.trigger) { - return Err(rusqlite::Error::InvalidParameterName( - format!("Invalid notification query: {}", e) - )); + return Err(rusqlite::Error::InvalidParameterName(format!( + "Invalid notification query: {}", + e + ))); } let result = self.conn.execute( diff --git a/rust-executor/src/perspectives/perspective_instance.rs b/rust-executor/src/perspectives/perspective_instance.rs index 6049658fa..34dbeaf7b 100644 --- a/rust-executor/src/perspectives/perspective_instance.rs +++ b/rust-executor/src/perspectives/perspective_instance.rs @@ -2318,9 +2318,15 @@ impl PerspectiveInstance { .replace("$perspectiveId", &format!("'{}'", perspective_id)); log::debug!("🔔 Notification query original: {}", query); - log::debug!("🔔 Notification query with context (agentDid='{}', perspectiveId='{}'): {}", agent_did, perspective_id, query_with_context); + log::debug!( + "🔔 Notification query with context (agentDid='{}', perspectiveId='{}'): {}", + agent_did, + perspective_id, + query_with_context + ); - let results = self.surreal_service + let results = self + .surreal_service .query_links(&perspective_id, &query_with_context) .await .map_err(|e| { @@ -2329,7 +2335,11 @@ impl PerspectiveInstance { perspective_id, e ); - anyhow!("Notification query failed for perspective {}: {}", perspective_id, e) + anyhow!( + "Notification query failed for perspective {}: {}", + perspective_id, + e + ) })?; log::debug!("🔔 Notification query results: {:?}", results); @@ -2599,7 +2609,9 @@ impl PerspectiveInstance { Ok(result_map) } - async fn notification_trigger_snapshot(&self) -> BTreeMap> { + async fn notification_trigger_snapshot( + &self, + ) -> BTreeMap> { self.calc_notification_trigger_matches() .await .unwrap_or_else(|e| { @@ -2621,9 +2633,9 @@ impl PerspectiveInstance { after_matches .iter() .filter(|after_match| { - !before_matches.iter().any(|before_match| { - before_match == *after_match - }) + !before_matches + .iter() + .any(|before_match| before_match == *after_match) }) .cloned() .collect() @@ -2648,8 +2660,8 @@ impl PerspectiveInstance { for (notification, matches) in match_map { if !matches.is_empty() { // Convert matches to JSON string - let trigger_match = serde_json::to_string(&matches) - .unwrap_or_else(|_| "[]".to_string()); + let trigger_match = + serde_json::to_string(&matches).unwrap_or_else(|_| "[]".to_string()); let payload = TriggeredNotification { notification: notification.clone(), From 89f1feb71aa0fcd441b84936be260065fee2bbe9 Mon Sep 17 00:00:00 2001 From: Nicolas Luck Date: Fri, 23 Jan 2026 12:34:44 +0100 Subject: [PATCH 5/9] Fix new db tests --- rust-executor/src/db.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rust-executor/src/db.rs b/rust-executor/src/db.rs index 4b8e584eb..26b762a6c 100644 --- a/rust-executor/src/db.rs +++ b/rust-executor/src/db.rs @@ -2993,7 +2993,7 @@ mod tests { app_name: "Test App".to_string(), app_url: "http://test.app".to_string(), app_icon_path: "/test/icon.png".to_string(), - trigger: "test-trigger".to_string(), + trigger: "SELECT * FROM link WHERE predicate = 'test://trigger'".to_string(), perspective_ids: vec![perspective1.uuid.clone()], webhook_url: "http://test.webhook".to_string(), webhook_auth: "test-auth".to_string(), @@ -3337,7 +3337,7 @@ mod tests { app_name: "Test App Name".to_string(), app_url: "Test App URL".to_string(), app_icon_path: "Test App Icon Path".to_string(), - trigger: "Test Trigger".to_string(), + trigger: "SELECT * FROM link WHERE predicate = 'test://trigger'".to_string(), perspective_ids: vec!["Test Perspective ID".to_string()], webhook_url: "Test Webhook URL".to_string(), webhook_auth: "Test Webhook Auth".to_string(), @@ -3360,7 +3360,7 @@ mod tests { test_notification.app_icon_path, "Test App Icon Path".to_string() ); - assert_eq!(test_notification.trigger, "Test Trigger"); + assert_eq!(test_notification.trigger, "SELECT * FROM link WHERE predicate = 'test://trigger'"); assert_eq!( test_notification.perspective_ids, vec!["Test Perspective ID".to_string()] @@ -3376,7 +3376,7 @@ mod tests { app_name: "Test App Name".to_string(), app_url: "Test App URL".to_string(), app_icon_path: "Test App Icon Path".to_string(), - trigger: "Test Trigger".to_string(), + trigger: "SELECT * FROM link WHERE predicate = 'test://updated'".to_string(), perspective_ids: vec!["Test Perspective ID".to_string()], webhook_url: "Test Webhook URL".to_string(), webhook_auth: "Test Webhook Auth".to_string(), From a97a5d13753d90571d979e93f01c55e02edd44d1 Mon Sep 17 00:00:00 2001 From: Nicolas Luck Date: Fri, 23 Jan 2026 12:38:58 +0100 Subject: [PATCH 6/9] Improve escape handling --- rust-executor/src/db.rs | 42 +++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/rust-executor/src/db.rs b/rust-executor/src/db.rs index 26b762a6c..69a67d5de 100644 --- a/rust-executor/src/db.rs +++ b/rust-executor/src/db.rs @@ -530,29 +530,39 @@ impl Ad4mDb { let mut escaped = false; for c in query_trimmed.chars() { - if escaped { - // Skip this character as it's escaped - escaped = false; - continue; - } - match c { - '\\' => escaped = true, + '\\' if in_string => { + // Toggle escaped state for backslashes inside strings + // If already escaped, this is a literal backslash (\\) + // If not escaped, next char will be escaped + escaped = !escaped; + } '\'' | '"' => { - if in_string { - // Check if this closes the current string - if c == string_char { - in_string = false; + if !escaped { + if in_string { + // Check if this closes the current string + if c == string_char { + in_string = false; + } + } else { + // Start a new string + in_string = true; + string_char = c; } - } else { - // Start a new string - in_string = true; - string_char = c; + } + // Clear escaped state after processing + if escaped { + escaped = false; } } '(' if !in_string => paren_count += 1, ')' if !in_string => paren_count -= 1, - _ => {} + _ => { + // Any other character clears the escaped state + if escaped { + escaped = false; + } + } } if paren_count < 0 { From 46a11a36c003f5ed3112d3e95ff4347c1457909d Mon Sep 17 00:00:00 2001 From: Nicolas Luck Date: Fri, 23 Jan 2026 12:39:10 +0100 Subject: [PATCH 7/9] fmt --- rust-executor/src/db.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rust-executor/src/db.rs b/rust-executor/src/db.rs index 69a67d5de..2eb88d5a1 100644 --- a/rust-executor/src/db.rs +++ b/rust-executor/src/db.rs @@ -3370,7 +3370,10 @@ mod tests { test_notification.app_icon_path, "Test App Icon Path".to_string() ); - assert_eq!(test_notification.trigger, "SELECT * FROM link WHERE predicate = 'test://trigger'"); + assert_eq!( + test_notification.trigger, + "SELECT * FROM link WHERE predicate = 'test://trigger'" + ); assert_eq!( test_notification.perspective_ids, vec!["Test Perspective ID".to_string()] From 96dd5156c30609d6feaa19c21bba22bd471dc0d5 Mon Sep 17 00:00:00 2001 From: Nicolas Luck Date: Fri, 23 Jan 2026 17:32:19 +0100 Subject: [PATCH 8/9] More fixes on surreal query validation --- rust-executor/src/db.rs | 119 +++++++++++++++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 14 deletions(-) diff --git a/rust-executor/src/db.rs b/rust-executor/src/db.rs index 2eb88d5a1..4f86e0b1d 100644 --- a/rust-executor/src/db.rs +++ b/rust-executor/src/db.rs @@ -478,37 +478,77 @@ impl Ad4mDb { } // Check for mutating operations + // Use a single pass that tracks string literals to avoid false positives let mutating_operations = [ "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "REMOVE", "DEFINE", "ALTER", "RELATE", "BEGIN", "COMMIT", "CANCEL", ]; + let query_bytes = query_upper.as_bytes(); + for operation in &mutating_operations { + let op_bytes = operation.as_bytes(); let mut search_pos = 0; + while let Some(pos) = query_upper[search_pos..].find(operation) { let absolute_pos = search_pos + pos; - // Check what comes before + // Re-track string state up to this position + let mut in_string = false; + let mut escaped = false; + let mut string_char: u8 = 0; + + for i in 0..absolute_pos { + let byte = query_bytes[i]; + + match byte { + b'\\' if in_string => { + escaped = !escaped; + } + b'\'' | b'"' => { + if !escaped { + if in_string { + if byte == string_char { + in_string = false; + } + } else { + in_string = true; + string_char = byte; + } + } + if escaped { + escaped = false; + } + } + _ => { + if escaped { + escaped = false; + } + } + } + } + + // Skip if inside a string literal + if in_string { + search_pos = absolute_pos + 1; + continue; + } + + // Check what comes before (byte-based) let before_ok = if absolute_pos == 0 { true } else { - let before_char = query_upper.chars().nth(absolute_pos - 1); - matches!( - before_char, - Some(' ') | Some('\t') | Some('\n') | Some('\r') | Some(';') | Some('(') - ) + let before_byte = query_bytes[absolute_pos - 1]; + matches!(before_byte, b' ' | b'\t' | b'\n' | b'\r' | b';' | b'(') }; - // Check what comes after - let after_pos = absolute_pos + operation.len(); - let after_ok = if after_pos >= query_upper.len() { + // Check what comes after (byte-based) + let after_pos = absolute_pos + op_bytes.len(); + let after_ok = if after_pos >= query_bytes.len() { true } else { - let after_char = query_upper.chars().nth(after_pos); - matches!( - after_char, - Some(' ') | Some('\t') | Some('\n') | Some('\r') | Some(';') | Some('(') - ) + let after_byte = query_bytes[after_pos]; + matches!(after_byte, b' ' | b'\t' | b'\n' | b'\r' | b';' | b'(') }; if before_ok && after_ok { @@ -3421,6 +3461,57 @@ mod tests { .all(|n| n.id != notification_id)); } + #[test] + fn test_notification_query_validation_with_string_literals() { + let db = Ad4mDb::new(":memory:").unwrap(); + + // Should accept: keyword inside string literal + let notification1 = NotificationInput { + description: "Test".to_string(), + app_name: "Test".to_string(), + app_url: "Test".to_string(), + app_icon_path: "Test".to_string(), + trigger: "SELECT * FROM link WHERE data = 'DELETE this'".to_string(), + perspective_ids: vec!["test".to_string()], + webhook_url: "".to_string(), + webhook_auth: "".to_string(), + }; + + let result1 = db.add_notification(notification1); + assert!(result1.is_ok(), "Should allow DELETE inside string literal"); + + // Should reject: actual DELETE operation + let notification2 = NotificationInput { + description: "Test".to_string(), + app_name: "Test".to_string(), + app_url: "Test".to_string(), + app_icon_path: "Test".to_string(), + trigger: "DELETE FROM link WHERE id = 123".to_string(), + perspective_ids: vec!["test".to_string()], + webhook_url: "".to_string(), + webhook_auth: "".to_string(), + }; + + let result2 = db.add_notification(notification2); + assert!(result2.is_err(), "Should reject actual DELETE operation"); + assert!(result2.unwrap_err().to_string().contains("DELETE")); + + // Should accept: escaped quotes with keyword + let notification3 = NotificationInput { + description: "Test".to_string(), + app_name: "Test".to_string(), + app_url: "Test".to_string(), + app_icon_path: "Test".to_string(), + trigger: r#"SELECT * FROM link WHERE data = "Don\'t DELETE this""#.to_string(), + perspective_ids: vec!["test".to_string()], + webhook_url: "".to_string(), + webhook_auth: "".to_string(), + }; + + let result3 = db.add_notification(notification3); + assert!(result3.is_ok(), "Should allow DELETE inside escaped string"); + } + #[test] fn test_task_operations() { let db = Ad4mDb::new(":memory:").unwrap(); From b0efc8baed455e00f9a8ef2a62e0d7a7ac34b129 Mon Sep 17 00:00:00 2001 From: Nicolas Luck Date: Fri, 23 Jan 2026 17:33:05 +0100 Subject: [PATCH 9/9] clean-up --- rust-executor/src/perspectives/perspective_instance.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-executor/src/perspectives/perspective_instance.rs b/rust-executor/src/perspectives/perspective_instance.rs index 34dbeaf7b..eebbf1445 100644 --- a/rust-executor/src/perspectives/perspective_instance.rs +++ b/rust-executor/src/perspectives/perspective_instance.rs @@ -15,7 +15,7 @@ use crate::languages::language::Language; use crate::languages::LanguageController; use crate::perspectives::utils::{prolog_get_first_binding, prolog_value_to_json_string}; use crate::prolog_service::get_prolog_service; -use crate::prolog_service::types::{QueryMatch, QueryResolution}; +use crate::prolog_service::types::QueryResolution; use crate::prolog_service::PrologService; use crate::prolog_service::{ engine_pool::FILTERING_THRESHOLD, DEFAULT_POOL_SIZE, DEFAULT_POOL_SIZE_WITH_FILTERING,