From 3453fab39e4e9b02a421fd1da1b8457d7183e753 Mon Sep 17 00:00:00 2001 From: Randy Concepcion Date: Wed, 19 Nov 2025 16:04:05 +0000 Subject: [PATCH 1/6] Bug 1997986 - Add ML security layer initially as a pass-through component for LLM calls and tool dispatch. r=tarek,ai-ondevice-reviewers - Moved JSActor pattern from security-specific JSActor back to parent MLEngine JSActor - Move validation logic for both request/response from child to parent to avoid IPC round trip (improve performance) Differential Revision: https://phabricator.services.mozilla.com/D271495 --- .../ml/actors/MLEngineParent.sys.mjs | 59 +++++++++++++--- toolkit/components/ml/moz.build | 6 +- .../components/ml/tests/browser/browser.toml | 2 + .../browser/browser_ml_engine_security.js | 67 +++++++++++++++++++ .../tests/xpcshell/test_ml_engine_security.js | 43 ++++++++++++ .../ml/tests/xpcshell/xpcshell.toml | 5 ++ 6 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 toolkit/components/ml/tests/browser/browser_ml_engine_security.js create mode 100644 toolkit/components/ml/tests/xpcshell/test_ml_engine_security.js create mode 100644 toolkit/components/ml/tests/xpcshell/xpcshell.toml diff --git a/toolkit/components/ml/actors/MLEngineParent.sys.mjs b/toolkit/components/ml/actors/MLEngineParent.sys.mjs index d006e9cdfbe21..094d1515a0640 100644 --- a/toolkit/components/ml/actors/MLEngineParent.sys.mjs +++ b/toolkit/components/ml/actors/MLEngineParent.sys.mjs @@ -1039,6 +1039,30 @@ export class MLEngine { this.notificationsCallback = notificationsCallback; } + /** + * Validates an inference request before sending to child process. + * + * @param {object} request - The request to validate + * @returns {object|null} The validated request, or null if blocked + * @private + */ + #validateRequest(request) { + lazy.console.debug("[MLSecurity] Validating request:", request); + return request; + } + + /** + * Validates an inference response after receiving from child process. + * + * @param {object} response - The response to validate + * @returns {object|null} The validated response, or null if blocked + * @private + */ + #validateResponse(response) { + lazy.console.debug("[MLSecurity] Validating response:", response); + return response; + } + /** * Observes shutdown events from the child process. * @@ -1308,12 +1332,19 @@ export class MLEngine { }); } if (response) { - const totalTime = - response.metrics.tokenizingTime + response.metrics.inferenceTime; - Glean.firefoxAiRuntime.runInferenceSuccess[ - this.getGleanLabel() - ].accumulateSingleSample(totalTime); - request.resolve(response); + // Validate response before returning to caller + const validatedResponse = this.#validateResponse(response); + if (!validatedResponse) { + request.reject(new Error("Response failed security validation")); + } else { + const totalTime = + validatedResponse.metrics.tokenizingTime + + validatedResponse.metrics.inferenceTime; + Glean.firefoxAiRuntime.runInferenceSuccess[ + this.getGleanLabel() + ].accumulateSingleSample(totalTime); + request.resolve(validatedResponse); + } } else { request.reject(error); } @@ -1477,6 +1508,12 @@ export class MLEngine { throw new Error("Port does not exist"); } + // Validate request before sending to child process + const validatedRequest = this.#validateRequest(request); + if (!validatedRequest) { + throw new Error("Request failed security validation"); + } + const resourcesPromise = this.getInferenceResources(); const beforeRun = ChromeUtils.now(); @@ -1484,7 +1521,7 @@ export class MLEngine { { type: "EnginePort:Run", requestId, - request, + request: validatedRequest, engineRunOptions: { enableInferenceProgress: false }, }, transferables @@ -1572,12 +1609,18 @@ export class MLEngine { throw new Error("The port is null"); } + // Validate request before sending to child process + const validatedRequest = this.#validateRequest(request); + if (!validatedRequest) { + throw new Error("Request failed security validation"); + } + // Send the request to the engine via postMessage with optional transferables this.#port.postMessage( { type: "EnginePort:Run", requestId, - request, + request: validatedRequest, engineRunOptions: { enableInferenceProgress: true }, }, transferables diff --git a/toolkit/components/ml/moz.build b/toolkit/components/ml/moz.build index 4eab5600b06e3..daf6eb6a16ef8 100644 --- a/toolkit/components/ml/moz.build +++ b/toolkit/components/ml/moz.build @@ -12,11 +12,15 @@ JAR_MANIFESTS += ["jar.mn"] with Files("**"): BUG_COMPONENT = ("Core", "Machine Learning") -DIRS += ["actors"] +DIRS += [ + "actors", +] if CONFIG["MOZ_WIDGET_TOOLKIT"] != "android": DIRS += ["backends/llama"] +XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] + BROWSER_CHROME_MANIFESTS += [ "tests/browser/browser.toml", "tests/browser/perftest.toml", diff --git a/toolkit/components/ml/tests/browser/browser.toml b/toolkit/components/ml/tests/browser/browser.toml index 6e155d363bb74..639eaacf7845f 100644 --- a/toolkit/components/ml/tests/browser/browser.toml +++ b/toolkit/components/ml/tests/browser/browser.toml @@ -22,6 +22,8 @@ support-files = [ ["browser_ml_engine_rs_hub.js"] +["browser_ml_engine_security.js"] + ["browser_ml_native.js"] skip-if = [ "os == 'android'", diff --git a/toolkit/components/ml/tests/browser/browser_ml_engine_security.js b/toolkit/components/ml/tests/browser/browser_ml_engine_security.js new file mode 100644 index 0000000000000..8571139d23f81 --- /dev/null +++ b/toolkit/components/ml/tests/browser/browser_ml_engine_security.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Browser integration tests for ML security layer scaffolding. + * These tests verify that the security layer infrastructure is correctly + * integrated into MLEngine. + */ + +/** + * Test that MLEngine can be instantiated with security layer integrated. + */ +add_task(async function test_mlengine_instantiation() { + info("Testing MLEngine instantiation with security layer"); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.ml.enable", true]], + }); + + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "data:text/html,MLEngine Test" + ); + + try { + const { MLEngine } = ChromeUtils.importESModule( + "resource://gre/actors/MLEngineParent.sys.mjs" + ); + + // Create engine instance + const engine = new MLEngine({ + mlEngineParent: {}, + pipelineOptions: { + engineId: "browser-test-instantiation", + featureId: "test-feature", + taskName: "test-task", + }, + notificationsCallback: null, + }); + + Assert.ok(engine, "MLEngine instantiates successfully"); + Assert.equal( + engine.engineId, + "browser-test-instantiation", + "Engine ID is set correctly" + ); + Assert.equal( + engine.engineStatus, + "uninitialized", + "Initial status is uninitialized" + ); + + // Verify engine is tracked + const retrieved = MLEngine.getInstance("browser-test-instantiation"); + Assert.equal(retrieved, engine, "Engine is tracked in instances map"); + + // Clean up + await MLEngine.removeInstance("browser-test-instantiation", false, false); + + info("MLEngine instantiation works with security layer"); + } finally { + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); + } +}); diff --git a/toolkit/components/ml/tests/xpcshell/test_ml_engine_security.js b/toolkit/components/ml/tests/xpcshell/test_ml_engine_security.js new file mode 100644 index 0000000000000..3c524ae5de766 --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_ml_engine_security.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MLEngine } = ChromeUtils.importESModule( + "resource://gre/actors/MLEngineParent.sys.mjs" +); + +/** + * Test that MLEngine properly manages request lifecycle. + * This ensures security layer integration doesn't break request tracking. + */ +add_task(async function test_request_lifecycle_management() { + info("Testing request lifecycle management"); + + const engine = new MLEngine({ + mlEngineParent: {}, + pipelineOptions: { engineId: "test-request-lifecycle" }, + notificationsCallback: null, + }); + + // Verify engine starts with no pending requests + // The #requests map is private, but we can test behavior + + // Without a port, run() should throw before creating a request + let errorThrown = false; + try { + await engine.run({ args: ["test"] }); + } catch (error) { + errorThrown = true; + Assert.ok( + error.message.includes("Port does not exist"), + "Should fail on port check" + ); + } + + Assert.ok(errorThrown, "run() without port should throw"); + + await MLEngine.removeInstance("test-request-lifecycle", false, false); + + info("Request lifecycle management works correctly"); +}); diff --git a/toolkit/components/ml/tests/xpcshell/xpcshell.toml b/toolkit/components/ml/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000000..2c9f4480462dd --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/xpcshell.toml @@ -0,0 +1,5 @@ +[DEFAULT] +head = "" +skip-if = ["os == 'android'"] + +["test_ml_engine_security.js"] From 1578d0b2ec7bae52f45b507c36b007abd3629de3 Mon Sep 17 00:00:00 2001 From: Randy Concepcion Date: Fri, 21 Nov 2025 23:17:14 -0500 Subject: [PATCH 2/6] [GENAI-2335] Implement Policy Engine for Smart Window tool execution Add security layer policy enforcement for Smart Window tool execution with policy to prevent exfiltration links Components: - SecurityOrchestrator: Central coordination with pref switch - PolicyEvaluator/ConditionEvaluator: JSON-based policy evaluation - SecurityUtils: URL normalization and session-scoped ledgers - SmartWindowMeta actors: Page metadata extraction (canonical/og:url) - DecisionTypes: Structured allow/deny decisions Security model: Explicit seeding with deterministic URL validation, fail-closed behavior, and pref switch (browser.smartwindow.security.enabled). --- .../actors/SmartWindowMetaChild.sys.mjs | 57 ++ .../actors/SmartWindowMetaParent.sys.mjs | 149 +++++ .../smartwindow/content/smartwindow.mjs | 46 ++ .../components/smartwindow/content/utils.mjs | 196 +++++- browser/components/smartwindow/jar.mn | 42 +- .../smartwindow/test/browser/browser.toml | 2 +- toolkit/components/ml/jar.mn | 8 + toolkit/components/ml/moz.build | 1 + .../ml/security/ConditionEvaluator.sys.mjs | 247 ++++++++ .../ml/security/DecisionTypes.sys.mjs | 174 ++++++ .../ml/security/PolicyEvaluator.sys.mjs | 239 ++++++++ .../ml/security/SecurityLogger.sys.mjs | 44 ++ .../ml/security/SecurityOrchestrator.sys.mjs | 339 +++++++++++ .../ml/security/SecurityUtils.sys.mjs | 438 ++++++++++++++ .../security/SmartWindowIntegration.sys.mjs | 217 +++++++ toolkit/components/ml/security/moz.build | 20 + .../ml/security/policies/POLICY_AUTHORING.md | 466 ++++++++++++++ .../policies/tool-execution-policies.json | 29 + .../xpcshell/test_condition_evaluator.js | 267 ++++++++ .../ml/tests/xpcshell/test_decision_types.js | 371 ++++++++++++ .../tests/xpcshell/test_json_policy_system.js | 454 ++++++++++++++ .../tests/xpcshell/test_policy_evaluator.js | 321 ++++++++++ .../xpcshell/test_security_orchestrator.js | 325 ++++++++++ .../ml/tests/xpcshell/test_security_utils.js | 568 ++++++++++++++++++ .../xpcshell/test_smart_window_integration.js | 407 +++++++++++++ .../ml/tests/xpcshell/xpcshell.toml | 7 + 26 files changed, 5397 insertions(+), 37 deletions(-) create mode 100644 browser/components/smartwindow/actors/SmartWindowMetaChild.sys.mjs create mode 100644 browser/components/smartwindow/actors/SmartWindowMetaParent.sys.mjs create mode 100644 toolkit/components/ml/security/ConditionEvaluator.sys.mjs create mode 100644 toolkit/components/ml/security/DecisionTypes.sys.mjs create mode 100644 toolkit/components/ml/security/PolicyEvaluator.sys.mjs create mode 100644 toolkit/components/ml/security/SecurityLogger.sys.mjs create mode 100644 toolkit/components/ml/security/SecurityOrchestrator.sys.mjs create mode 100644 toolkit/components/ml/security/SecurityUtils.sys.mjs create mode 100644 toolkit/components/ml/security/SmartWindowIntegration.sys.mjs create mode 100644 toolkit/components/ml/security/moz.build create mode 100644 toolkit/components/ml/security/policies/POLICY_AUTHORING.md create mode 100644 toolkit/components/ml/security/policies/tool-execution-policies.json create mode 100644 toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js create mode 100644 toolkit/components/ml/tests/xpcshell/test_decision_types.js create mode 100644 toolkit/components/ml/tests/xpcshell/test_json_policy_system.js create mode 100644 toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js create mode 100644 toolkit/components/ml/tests/xpcshell/test_security_orchestrator.js create mode 100644 toolkit/components/ml/tests/xpcshell/test_security_utils.js create mode 100644 toolkit/components/ml/tests/xpcshell/test_smart_window_integration.js diff --git a/browser/components/smartwindow/actors/SmartWindowMetaChild.sys.mjs b/browser/components/smartwindow/actors/SmartWindowMetaChild.sys.mjs new file mode 100644 index 0000000000000..24ad9701cdb1f --- /dev/null +++ b/browser/components/smartwindow/actors/SmartWindowMetaChild.sys.mjs @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Content-process actor for extracting page metadata (page URL, canonical, og:url). + * The parent actor validates and normalizes these URLs before seeding the security ledger. + */ +export class SmartWindowMetaChild extends JSWindowActorChild { + /** + * Receives queries from the parent process. + * + * @param {ReceiveMessageArgument} message - The message from parent + * @returns {Promise} Metadata object with URLs + */ + receiveMessage(message) { + switch (message.name) { + case "SmartWindowMeta:GetMetadata": + return this.getMetadata(); + default: + return Promise.reject(new Error(`Unknown message: ${message.name}`)); + } + } + + /** + * Extracts metadata from the current page. + * + * @returns {object} Metadata with pageUrl, canonical, and ogUrl (raw strings) + */ + getMetadata() { + const doc = this.contentWindow?.document; + + if (!doc) { + return { pageUrl: "", canonical: "", ogUrl: "" }; + } + + const pageUrl = doc.location?.href || ""; + + let canonical = ""; + try { + const canonicalLink = doc.querySelector('link[rel="canonical"]'); + canonical = canonicalLink?.getAttribute("href") || ""; + } catch { + // querySelector may fail on some documents (e.g., XML) + } + + let ogUrl = ""; + try { + const ogUrlMeta = doc.querySelector('meta[property="og:url"]'); + ogUrl = ogUrlMeta?.getAttribute("content") || ""; + } catch { + // querySelector may fail on some documents + } + + return { pageUrl, canonical, ogUrl }; + } +} diff --git a/browser/components/smartwindow/actors/SmartWindowMetaParent.sys.mjs b/browser/components/smartwindow/actors/SmartWindowMetaParent.sys.mjs new file mode 100644 index 0000000000000..6b826ee90bc03 --- /dev/null +++ b/browser/components/smartwindow/actors/SmartWindowMetaParent.sys.mjs @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + normalizeUrl, + isSameETLDPlusOne, +} from "chrome://global/content/ml/security/SecurityUtils.sys.mjs"; + +/** + * Chrome-process actor for validating page metadata and seeding security ledger. + * Validates canonical/og:url have same eTLD+1 as page URL before seeding. + */ +export class SmartWindowMetaParent extends JSWindowActorParent { + /** + * Seeds the security ledger for the given browser/tab. + * + * @param {object} sessionLedger - The SessionLedger instance + * @param {string} tabId - The tab identifier (typically linkedPanel) + * @returns {Promise} Result with seededUrls, skippedUrls, and errors + */ + async seedLedgerForTab(sessionLedger, tabId) { + const result = { + success: false, + seededUrls: [], + skippedUrls: [], + errors: [], + }; + + try { + const metadata = await this.sendQuery("SmartWindowMeta:GetMetadata"); + + if (!metadata || !metadata.pageUrl) { + result.errors.push("No page URL available from content process"); + return result; + } + + const { pageUrl, canonical, ogUrl } = metadata; + + const normalizedPageUrl = normalizeUrl(pageUrl); + if (!normalizedPageUrl.success) { + result.errors.push({ + url: pageUrl, + reason: "Page URL normalization failed", + error: normalizedPageUrl.error, + }); + return result; + } + + const urlsToSeed = [normalizedPageUrl.url]; + result.seededUrls.push({ + original: pageUrl, + normalized: normalizedPageUrl.url, + source: "page", + }); + + if (canonical) { + const validated = this.#validateSecondaryUrl( + canonical, + normalizedPageUrl.url, + pageUrl, + "canonical" + ); + + if (validated.success) { + urlsToSeed.push(validated.normalizedUrl); + result.seededUrls.push({ + original: canonical, + normalized: validated.normalizedUrl, + source: "canonical", + }); + } else { + result.skippedUrls.push({ + original: canonical, + source: "canonical", + reason: validated.reason, + }); + } + } + + if (ogUrl) { + const validated = this.#validateSecondaryUrl( + ogUrl, + normalizedPageUrl.url, + pageUrl, + "og:url" + ); + + if (validated.success) { + urlsToSeed.push(validated.normalizedUrl); + result.seededUrls.push({ + original: ogUrl, + normalized: validated.normalizedUrl, + source: "og:url", + }); + } else { + result.skippedUrls.push({ + original: ogUrl, + source: "og:url", + reason: validated.reason, + }); + } + } + + sessionLedger.forTab(tabId).seed(urlsToSeed, pageUrl); + result.success = true; + } catch (error) { + result.errors.push({ + message: "Actor communication failed", + error: error.message || String(error), + }); + } + + return result; + } + + /** + * Validates a secondary URL (canonical or og:url) against the page's eTLD+1. + * + * @param {string} url - The URL to validate (may be relative) + * @param {string} normalizedPageUrl - The normalized page URL for eTLD+1 comparison + * @param {string} baseUrl - The original page URL for resolving relative URLs + * @param {string} source - Source identifier ("canonical" or "og:url") + * @returns {object} Validation result with success flag and normalizedUrl or reason + * @private + */ + #validateSecondaryUrl(url, normalizedPageUrl, baseUrl, source) { + const normalized = normalizeUrl(url, baseUrl); + + if (!normalized.success) { + return { + success: false, + reason: "Normalization failed", + details: normalized.error, + }; + } + + if (!isSameETLDPlusOne(normalizedPageUrl, normalized.url)) { + return { + success: false, + reason: "Different eTLD+1 from page URL", + pageUrl: normalizedPageUrl, + secondaryUrl: normalized.url, + }; + } + + return { success: true, normalizedUrl: normalized.url }; + } +} diff --git a/browser/components/smartwindow/content/smartwindow.mjs b/browser/components/smartwindow/content/smartwindow.mjs index 102f504b5ce7a..dcbab707154d8 100644 --- a/browser/components/smartwindow/content/smartwindow.mjs +++ b/browser/components/smartwindow/content/smartwindow.mjs @@ -31,11 +31,36 @@ const { SessionStore } = ChromeUtils.importESModule( const { TabStateFlusher } = ChromeUtils.importESModule( "resource:///modules/sessionstore/TabStateFlusher.sys.mjs" ); +const { SecurityOrchestrator } = ChromeUtils.importESModule( + "chrome://global/content/ml/security/SecurityOrchestrator.sys.mjs" +); const { embedderElement, topChromeWindow } = window.browsingContext; const gBrowser = topChromeWindow.gBrowser; const FIRST_RUN_PREF = "browser.smartwindow.firstrun.didSeeWelcome"; +// Register SmartWindowMeta actor for secure page metadata extraction +try { + ChromeUtils.registerWindowActor("SmartWindowMeta", { + parent: { + esModuleURI: "chrome://browser/content/smartwindow/actors/SmartWindowMetaParent.sys.mjs", + }, + child: { + esModuleURI: "chrome://browser/content/smartwindow/actors/SmartWindowMetaChild.sys.mjs", + events: { + DOMContentLoaded: {}, + }, + }, + allFrames: true, + }); +} catch (e) { + // Actor already registered - this is expected if Smart Window + // has been opened before in this browser session + if (!e.message?.includes("already registered")) { + console.error("Failed to register SmartWindowMeta actor:", e); + } +} + /** * */ @@ -88,6 +113,25 @@ class SmartWindowPage { this.#chatHistory = new ChatHistory(); + // Initialize security orchestrator (if enabled) + // Creates the SessionLedger that tracks trusted URLs across tabs + // Only initialize if Smart Window security is enabled + const sessionId = `smart-window-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + const securityEnabled = Services.prefs.getBoolPref("browser.smartwindow.security.enabled", true); + + if (securityEnabled) { + SecurityOrchestrator.init(sessionId).then(sessionLedger => { + this.sessionLedger = sessionLedger; + console.log("[Security] Smart Window security enabled - orchestrator initialized"); + }).catch(error => { + console.error("[Security] Failed to initialize orchestrator:", error); + this.sessionLedger = null; + }); + } else { + this.sessionLedger = null; + console.log("[Security] Smart Window security DISABLED via kill switch - running in pass-through mode"); + } + gBrowser.selectedTab.conversation = new ChatHistoryConversation({ title: "", description: "", @@ -134,6 +178,8 @@ class SmartWindowPage { this.onboardingPrefObserver ); } + + SecurityOrchestrator.reset(); } getQueryTypeIcon(type) { diff --git a/browser/components/smartwindow/content/utils.mjs b/browser/components/smartwindow/content/utils.mjs index b49bd47304c46..fb9cedef28e91 100644 --- a/browser/components/smartwindow/content/utils.mjs +++ b/browser/components/smartwindow/content/utils.mjs @@ -11,6 +11,8 @@ ChromeUtils.defineESModuleGetters(lazy, { PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", PageThumbsStorage: "resource://gre/modules/PageThumbs.sys.mjs", getPlacesSemanticHistoryManager: "resource://gre/modules/PlacesSemanticHistoryManager.sys.mjs", + SecurityOrchestrator: + "chrome://global/content/ml/security/SecurityOrchestrator.sys.mjs", }); import { createEngine } from "chrome://global/content/ml/EngineProcess.sys.mjs"; @@ -556,6 +558,112 @@ const MODE_HANDLERS = { const DEFAULT_MODE = "viewport"; +/** + * Checks if a tool execution is allowed by security policy. + * + * @param {string} toolName - Name of the tool being called + * @param {object} toolParams - Tool parameters + * @param {string} requestId - Unique request identifier + * @returns {Promise<{allowed: boolean, reason?: string}>} Security decision + */ +async function checkToolSecurity(toolName, toolParams, requestId) { + try { + // Extract URLs based on tool type + let urls = []; + let tabId = null; + + if (toolName === GET_PAGE_CONTENT && toolParams.url) { + urls = [toolParams.url]; + + // Find the target tab + let win = lazy.BrowserWindowTracker.getTopWindow(); + let gBrowser = win.gBrowser; + let targetTab = gBrowser.tabs.find(tab => { + const tabUrl = tab.linkedBrowser.currentURI.spec; + return ( + tabUrl === toolParams.url || + tabUrl.replace(/\/$/, "") === toolParams.url.replace(/\/$/, "") + ); + }); + + if (targetTab) { + tabId = targetTab.linkedPanel; + + // Seed this tab's URLs since user is requesting it + try { + const browser = targetTab.linkedBrowser; + if (browser.browsingContext?.currentWindowContext) { + const actor = + await browser.browsingContext.currentWindowContext.getActor( + "SmartWindowMeta" + ); + const sessionLedger = lazy.SecurityOrchestrator.getSessionLedger(); + + if (!sessionLedger) { + console.log( + "[Security] Kill switch disabled - skipping ledger seeding" + ); + } else { + const result = await actor.seedLedgerForTab(sessionLedger, tabId); + + if (result.success) { + console.warn( + `[Security] Explicitly seeded tab ${tabId} with ${result.seededUrls.length} URLs for tool execution` + ); + } + } + } + } catch (seedError) { + console.warn( + "[Security] Failed to seed tab before tool execution:", + seedError + ); + } + } + } + + if (urls.length === 0) { + return { allowed: true }; + } + + if (!tabId) { + let win = lazy.BrowserWindowTracker.getTopWindow(); + let gBrowser = win.gBrowser; + tabId = gBrowser.selectedTab.linkedPanel; + } + + const decision = await lazy.SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: toolName, + urls, + tabId, + }, + context: { + currentTabId: tabId, + mentionedTabIds: [], // TODO: Extract from @mentions + requestId, + }, + }); + + if (decision.effect === "deny") { + return { + allowed: false, + reason: `Security policy blocked this action: ${decision.reason} (${decision.code})`, + }; + } + + return { allowed: true }; + } catch (error) { + console.error("[Security Check] Error:", error); + return { + allowed: false, + reason: `Security check failed: ${error.message}`, + }; + } +} + /** * @param {object} toolParams * @param {string} toolParams.url @@ -834,23 +942,79 @@ export async function* fetchWithHistory(messages, allowedUrls) { result = { error: `There is no tool called : ${String(toolName)}` }; } - switch (toolName) { - case SEARCH_OPEN_TABS: - result = search_open_tabs(toolParams); - break; - case GET_PAGE_CONTENT: - result = await get_page_content(toolParams, allowedUrls); - break; - case SEARCH_HISTORY: - result = await searchBrowserHistory(toolParams); - break; - case ADD_NEW_INSIGHT: { - result = await addNewInsight(toolParams); - break; + // Check security policy before executing tool + const securityCheck = await checkToolSecurity( + toolName, + toolParams, + id // Use tool call ID as request ID + ); + + if (!securityCheck.allowed) { + result = { + error: securityCheck.reason || "Security policy denied this action", + }; + } else { + // Populate allowedUrls from current tab's ledger for headless extraction + // This allows fetching URLs that are part of the current page's metadata + // Such as (canonical URLs, related links, etc.) even if they're not in open tabs + let allowedUrls = new Set(); + try { + const sessionLedger = lazy.SecurityOrchestrator.getSessionLedger(); + + if (sessionLedger) { + // Get current tab ID from tool params or browser + let currentTabId = toolParams.tabId; + if (!currentTabId && toolParams.url) { + // Try to find tab by URL + let win = lazy.BrowserWindowTracker.getTopWindow(); + let gBrowser = win.gBrowser; + let targetTab = gBrowser.tabs.find(tab => { + const tabUrl = tab.linkedBrowser.currentURI.spec; + return ( + tabUrl === toolParams.url || + tabUrl.replace(/\/$/, "") === + toolParams.url.replace(/\/$/, "") + ); + }); + if (targetTab) { + currentTabId = targetTab.linkedPanel; + } + } + + if (currentTabId) { + const tabLedger = sessionLedger.forTab(currentTabId); + if (tabLedger) { + allowedUrls = new Set(tabLedger.getAll()); + console.log( + `[Security] Allowing headless extraction for ${allowedUrls.size} URLs from current tab ${currentTabId}` + ); + } + } + } + } catch (error) { + console.warn( + "[Security] Could not populate allowedUrls for headless extraction:", + error + ); + allowedUrls = new Set(); } - case DELETE_INSIGHT: { - result = await deleteRelevantInsight(toolParams); - break; + + switch (toolName) { + case SEARCH_OPEN_TABS: + result = search_open_tabs(toolParams); + break; + case GET_PAGE_CONTENT: + result = await get_page_content(toolParams, allowedUrls); + break; + case SEARCH_HISTORY: + result = await searchBrowserHistory(toolParams); + break; + case ADD_NEW_INSIGHT: + result = await addNewInsight(toolParams); + break; + case DELETE_INSIGHT: + result = await deleteRelevantInsight(toolParams); + break; } } diff --git a/browser/components/smartwindow/jar.mn b/browser/components/smartwindow/jar.mn index 34e67304f076b..18775ac1b1fd6 100644 --- a/browser/components/smartwindow/jar.mn +++ b/browser/components/smartwindow/jar.mn @@ -3,23 +3,25 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. browser.jar: - content/browser/smartwindow/chat.mjs (content/chat.mjs) - content/browser/smartwindow/chat-history.mjs (content/chat-history.mjs) - content/browser/smartwindow/combined-button-select.mjs (content/combined-button-select.mjs) - content/browser/smartwindow/tool-log.mjs (content/tool-log.mjs) - content/browser/smartwindow/insights.mjs (content/insights.mjs) - content/browser/smartwindow/insights.html (content/insights.html) - content/browser/smartwindow/marked.js (vendor/marked.js) - content/browser/smartwindow/mentions.mjs (content/mentions.mjs) - content/browser/smartwindow/page-history.mjs (content/page-history.mjs) - content/browser/smartwindow/smartbar.mjs (content/smartbar.mjs) - content/browser/smartwindow/suggestions.mjs (content/suggestions.mjs) - content/browser/smartwindow/tiptap-bundle.js (vendor/tiptap-bundle.js) - content/browser/smartwindow/smartwindow.css (content/smartwindow.css) - content/browser/smartwindow/smartwindow.html (content/smartwindow.html) - content/browser/smartwindow/smartwindow.mjs (content/smartwindow.mjs) - content/browser/smartwindow/smart-fx-icon.svg (content/smart-fx-icon.svg) - content/browser/smartwindow/favicon.svg (content/favicon.svg) - content/browser/smartwindow/utils.mjs (content/utils.mjs) - content/browser/smartwindow/welcome.html (content/welcome.html) - content/browser/smartwindow/welcome.js (content/welcome.js) + content/browser/smartwindow/chat.mjs (content/chat.mjs) + content/browser/smartwindow/chat-history.mjs (content/chat-history.mjs) + content/browser/smartwindow/combined-button-select.mjs (content/combined-button-select.mjs) + content/browser/smartwindow/tool-log.mjs (content/tool-log.mjs) + content/browser/smartwindow/insights.mjs (content/insights.mjs) + content/browser/smartwindow/insights.html (content/insights.html) + content/browser/smartwindow/marked.js (vendor/marked.js) + content/browser/smartwindow/mentions.mjs (content/mentions.mjs) + content/browser/smartwindow/page-history.mjs (content/page-history.mjs) + content/browser/smartwindow/smartbar.mjs (content/smartbar.mjs) + content/browser/smartwindow/suggestions.mjs (content/suggestions.mjs) + content/browser/smartwindow/tiptap-bundle.js (vendor/tiptap-bundle.js) + content/browser/smartwindow/smartwindow.css (content/smartwindow.css) + content/browser/smartwindow/smartwindow.html (content/smartwindow.html) + content/browser/smartwindow/smartwindow.mjs (content/smartwindow.mjs) + content/browser/smartwindow/actors/SmartWindowMetaChild.sys.mjs (actors/SmartWindowMetaChild.sys.mjs) + content/browser/smartwindow/actors/SmartWindowMetaParent.sys.mjs (actors/SmartWindowMetaParent.sys.mjs) + content/browser/smartwindow/smart-fx-icon.svg (content/smart-fx-icon.svg) + content/browser/smartwindow/favicon.svg (content/favicon.svg) + content/browser/smartwindow/utils.mjs (content/utils.mjs) + content/browser/smartwindow/welcome.html (content/welcome.html) + content/browser/smartwindow/welcome.js (content/welcome.js) diff --git a/browser/components/smartwindow/test/browser/browser.toml b/browser/components/smartwindow/test/browser/browser.toml index 02d2a2f776580..4fa4f7133533c 100644 --- a/browser/components/smartwindow/test/browser/browser.toml +++ b/browser/components/smartwindow/test/browser/browser.toml @@ -1,3 +1,3 @@ [DEFAULT] -["browser_smartwindow_basic.js"] \ No newline at end of file +["browser_smartwindow_basic.js"] diff --git a/toolkit/components/ml/jar.mn b/toolkit/components/ml/jar.mn index 7dbb7c5afc7bf..0d420a1c0d097 100644 --- a/toolkit/components/ml/jar.mn +++ b/toolkit/components/ml/jar.mn @@ -28,6 +28,14 @@ toolkit.jar: content/global/ml/backends/StaticEmbeddingsPipeline.mjs (content/backends/StaticEmbeddingsPipeline.mjs) content/global/ml/openai.mjs (vendor/openai/dist/openai.mjs) content/global/ml/MLTelemetry.sys.mjs (MLTelemetry.sys.mjs) + content/global/ml/security/ConditionEvaluator.sys.mjs (security/ConditionEvaluator.sys.mjs) + content/global/ml/security/DecisionTypes.sys.mjs (security/DecisionTypes.sys.mjs) + content/global/ml/security/PolicyEvaluator.sys.mjs (security/PolicyEvaluator.sys.mjs) + content/global/ml/security/SecurityLogger.sys.mjs (security/SecurityLogger.sys.mjs) + content/global/ml/security/SecurityOrchestrator.sys.mjs (security/SecurityOrchestrator.sys.mjs) + content/global/ml/security/SecurityUtils.sys.mjs (security/SecurityUtils.sys.mjs) + content/global/ml/security/SmartWindowIntegration.sys.mjs (security/SmartWindowIntegration.sys.mjs) + content/global/ml/security/policies/tool-execution-policies.json (security/policies/tool-execution-policies.json) #ifdef NIGHTLY_BUILD content/global/ml/ort.webgpu-dev.mjs (vendor/ort.webgpu-dev.mjs) content/global/ml/transformers-dev.js (vendor/transformers-dev.js) diff --git a/toolkit/components/ml/moz.build b/toolkit/components/ml/moz.build index daf6eb6a16ef8..063b17d9bc59c 100644 --- a/toolkit/components/ml/moz.build +++ b/toolkit/components/ml/moz.build @@ -14,6 +14,7 @@ with Files("**"): DIRS += [ "actors", + "security", ] if CONFIG["MOZ_WIDGET_TOOLKIT"] != "android": diff --git a/toolkit/components/ml/security/ConditionEvaluator.sys.mjs b/toolkit/components/ml/security/ConditionEvaluator.sys.mjs new file mode 100644 index 0000000000000..02cd7c1a2af78 --- /dev/null +++ b/toolkit/components/ml/security/ConditionEvaluator.sys.mjs @@ -0,0 +1,247 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Safe condition evaluator for JSON-based security policies. + * Evaluates policy conditions against action and context. + */ +export class ConditionEvaluator { + /** + * Resolves a dot-notation path (e.g., "action.urls") in action or context. + * + * @param {string} path - Dot-notation path + * @param {object} action - Action object + * @param {object} context - Context object + * @returns {*} Resolved value or undefined + */ + static resolvePath(path, action, context) { + if (!path || typeof path !== "string") { + console.error("[ConditionEvaluator] Invalid path:", path); + return undefined; + } + + const parts = path.split("."); + + let obj; + if (parts[0] === "action") { + obj = action; + } else if (parts[0] === "context") { + obj = context; + } else { + console.error( + "[ConditionEvaluator] Path must start with 'action' or 'context':", + path + ); + return undefined; + } + + for (let i = 1; i < parts.length; i++) { + if (obj === undefined || obj === null) { + return undefined; + } + obj = obj[parts[i]]; + } + + return obj; + } + + /** + * Evaluates a condition against action and context. Fails closed on unknown types. + * + * @param {object} condition - Condition object with type property + * @param {object} action - Action being evaluated + * @param {object} context - Request context + * @returns {boolean} True if condition passes + */ + static evaluate(condition, action, context) { + if (!condition || !condition.type) { + console.error( + "[ConditionEvaluator] Invalid condition object:", + condition + ); + return false; + } + + try { + switch (condition.type) { + case "allUrlsIn": + return this.#evaluateAllUrlsIn(condition, action, context); + + case "equals": + return this.#evaluateEquals(condition, action, context); + + case "matches": + return this.#evaluateMatches(condition, action, context); + + case "noPatternInParams": + return this.#evaluateNoPatternInParams(condition, action, context); + + default: + console.error( + `[ConditionEvaluator] Unknown condition type: ${condition.type}` + ); + return false; + } + } catch (error) { + console.error( + `[ConditionEvaluator] Error evaluating condition ${condition.type}:`, + error + ); + return false; + } + } + + /** + * Evaluates allUrlsIn condition. + * + * Checks that all URLs in an array are present in a ledger. + * + * @param {object} condition - Condition configuration + * @param {string} condition.urls - Path to URL array + * @param {string} condition.ledger - Path to ledger object + * @param {object} action - Action object + * @param {object} context - Context object + * @returns {boolean} True if all URLs in ledger or no URLs + * @private + */ + static #evaluateAllUrlsIn(condition, action, context) { + const urls = this.resolvePath(condition.urls, action, context); + const ledger = this.resolvePath(condition.ledger, action, context); + + if (!urls || !Array.isArray(urls) || urls.length === 0) { + return true; + } + + if (!ledger || typeof ledger.has !== "function") { + console.error( + "[ConditionEvaluator] Ledger not found or invalid:", + condition.ledger + ); + return false; + } + + const result = urls.every(url => ledger.has(url)); + + if (!result) { + const failedUrl = urls.find(url => !ledger.has(url)); + console.warn( + `[ConditionEvaluator] URL not in ledger: ${failedUrl}`, + condition.description || "" + ); + } + + return result; + } + + /** + * Evaluates equals condition. + * + * Checks exact equality between actual and expected values. + * + * @param {object} condition - Condition configuration + * @param {string} condition.actual - Path to actual value + * @param {*} condition.expected - Expected value (literal) + * @param {object} action - Action object + * @param {object} context - Context object + * @returns {boolean} True if values are equal + * @private + */ + static #evaluateEquals(condition, action, context) { + const actualValue = this.resolvePath(condition.actual, action, context); + const expectedValue = condition.expected; + + const result = actualValue === expectedValue; + + if (!result) { + console.warn( + `[ConditionEvaluator] Equality check failed: expected ${expectedValue}, got ${actualValue}` + ); + } + + return result; + } + + /** + * Evaluates matches condition. + * + * Checks if a value matches a regex pattern. + * + * @param {object} condition - Condition configuration + * @param {string} condition.value - Path to value + * @param {string} condition.pattern - Regex pattern (string) + * @param {object} action - Action object + * @param {object} context - Context object + * @returns {boolean} True if value matches pattern + * @private + */ + static #evaluateMatches(condition, action, context) { + const value = this.resolvePath(condition.value, action, context); + + if (value === undefined || value === null) { + return false; + } + + try { + const pattern = new RegExp(condition.pattern); + const result = pattern.test(String(value)); + + if (!result) { + console.warn( + `[ConditionEvaluator] Pattern match failed: ${value} does not match ${condition.pattern}` + ); + } + + return result; + } catch (error) { + console.error( + `[ConditionEvaluator] Invalid regex pattern: ${condition.pattern}`, + error + ); + return false; + } + } + + /** + * Evaluates noPatternInParams condition. + * + * Checks that a regex pattern does NOT appear in parameters. + * Useful for blocking PII like email addresses. + * + * @param {object} condition - Condition configuration + * @param {string} condition.params - Path to params object + * @param {string} condition.pattern - Regex pattern to block + * @param {object} action - Action object + * @param {object} context - Context object + * @returns {boolean} True if pattern NOT found in params + * @private + */ + static #evaluateNoPatternInParams(condition, action, context) { + const params = this.resolvePath(condition.params, action, context); + + if (!params) { + return true; + } + + try { + const pattern = new RegExp(condition.pattern); + const paramsStr = JSON.stringify(params); + const found = pattern.test(paramsStr); + + if (found) { + console.warn( + `[ConditionEvaluator] Blocked pattern found in params: ${condition.pattern}`, + condition.description || "" + ); + } + + return !found; + } catch (error) { + console.error( + `[ConditionEvaluator] Error checking pattern: ${condition.pattern}`, + error + ); + return false; + } + } +} diff --git a/toolkit/components/ml/security/DecisionTypes.sys.mjs b/toolkit/components/ml/security/DecisionTypes.sys.mjs new file mode 100644 index 0000000000000..8cbadfc5e309c --- /dev/null +++ b/toolkit/components/ml/security/DecisionTypes.sys.mjs @@ -0,0 +1,174 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Type definitions and helpers for the Smart Window security layer. + * Defines SecurityDecision, DenialCodes, and allow/deny helper functions. + */ + +/** + * Security decision allowing an action to proceed. + * + * @typedef {object} SecurityDecisionAllow + * @property {"allow"} effect - The decision effect + */ + +/** + * Security decision denying an action with structured error information. + * + * @typedef {object} SecurityDecisionDeny + * @property {"deny"} effect - The decision effect + * @property {string} policyId - The policy that made this decision (e.g., "block-unseen-links") + * @property {string} code - Machine-readable denial code (see DenialCodes) + * @property {string} reason - Human-readable explanation of the denial + * @property {object} [details] - Optional additional context for logging/debugging + */ + +/** + * Result of policy evaluation. + * Either allows the action or denies it with structured error information. + * + * @typedef {SecurityDecisionAllow | SecurityDecisionDeny} SecurityDecision + */ + +/** + * An action being evaluated by the security layer. + * + * Represents a request to perform an operation (e.g., tool call) that + * requires security validation. + * + * @typedef {object} SecurityAction + * @property {"tool.call"} type - Action type (extensible for future action types) + * @property {string} tool - Tool name (case-sensitive, matches dispatcher constants) + * @property {string[]} [urls] - URLs being accessed by the tool (always array, may be empty) + * @property {string} tabId - The originating tab ID for this action + * @property {object} [args] - Original tool arguments (for logging/debugging) + */ + +/** + * Request-scoped context for policy evaluation. + * + * Security Note: + * Context is rebuilt for each request and discarded afterward to prevent + * cross-request authorization leakage. + * + * @typedef {object} SecurityContext + * @property {TabLedger} linkLedger - Request-scoped link ledger (union of authorized sources) + * @property {string} sessionId - Smart Window session identifier + * @property {string} requestId - Individual request identifier (for logging/correlation) + * @property {string} currentTabId - The active/focused tab + * @property {string[]} [mentionedTabIds] - Tab IDs explicitly referenced via @mentions (future) + */ + +/** + * Structured error thrown when a security policy denies an action. + * + * This error allows the tool dispatcher to catch and handle policy denials + * gracefully, distinguishing them from other errors (e.g., network failures). + */ +export class SecurityPolicyError extends Error { + /** + * Creates a structured error from a denial decision. + * + * @param {SecurityDecisionDeny} decision - The denial decision + */ + constructor(decision) { + super(decision.reason); + this.name = "SecurityPolicyError"; + this.code = decision.code; + this.policyId = decision.policyId; + this.decision = decision; + } + + /** + * Serializes the error for structured logging. + * Avoids circular references and provides stable JSON output. + * + * @returns {object} Structured representation of the error + */ + toJSON() { + return { + name: this.name, + code: this.code, + policyId: this.policyId, + message: this.message, + decision: this.decision, + }; + } +} + +// Standard denial codes for consistent error handling across the security layer. +export const DenialCodes = Object.freeze({ + // URL not present in the request-scoped link ledger. + // e.g., "block-unseen-links" policy + UNSEEN_LINK: "UNSEEN_LINK", + + // URL parsing or normalization failed. + // Fail-closed behavior: treat malformed URLs as untrusted. + MALFORMED_URL: "MALFORMED_URL", + + // Required context (e.g., link ledger, tab ID) not provided. + // Fail-closed behavior: cannot evaluate without proper context. + MISSING_CONTEXT: "MISSING_CONTEXT", + + // Policy enforcement is disabled (from policy configuration file). + POLICY_DISABLED: "POLICY_DISABLED", +}); + +// Standard human-readable reason phrases for denial codes. +export const ReasonPhrases = Object.freeze({ + UNSEEN_LINK: "URL not in selected request context", + MALFORMED_URL: "Failed to parse or normalize URL", + MISSING_CONTEXT: "Missing required evaluation context", + POLICY_DISABLED: "Policy enforcement disabled", +}); + +/** + * Creates an "allow" decision. + * + * @returns {SecurityDecisionAllow} Allow decision + */ +export const allow = () => + /** @type {SecurityDecisionAllow} */ ({ + effect: "allow", + }); + +/** + * Creates a "deny" decision with structured error information. + * + * @param {string} code - Denial code from DenialCodes + * @param {string} reason - Human-readable reason (typically from ReasonPhrases) + * @param {object} [details] - Optional additional context + * @param {string} [policyId="block-unseen-links"] - The policy making this decision + * @returns {SecurityDecisionDeny} Deny decision + */ +export const deny = ( + code, + reason, + details = undefined, + policyId = "block-unseen-links" +) => + /** @type {SecurityDecisionDeny} */ ({ + effect: "deny", + policyId, + code, + reason, + details, + }); + +/** + * Type guard: checks if a decision is an allow. + * + * @param {SecurityDecision | undefined | null} decision - Decision to check + * @returns {boolean} True if decision is allow + */ +export const isAllow = decision => decision?.effect === "allow"; + +/** + * Type guard: checks if a decision is a deny. + * + * @param {SecurityDecision | undefined | null} decision - Decision to check + * @returns {boolean} True if decision is deny + */ +export const isDeny = decision => decision?.effect === "deny"; diff --git a/toolkit/components/ml/security/PolicyEvaluator.sys.mjs b/toolkit/components/ml/security/PolicyEvaluator.sys.mjs new file mode 100644 index 0000000000000..01c13c38fd92a --- /dev/null +++ b/toolkit/components/ml/security/PolicyEvaluator.sys.mjs @@ -0,0 +1,239 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + allow, + deny, +} from "chrome://global/content/ml/security/DecisionTypes.sys.mjs"; +import { ConditionEvaluator } from "chrome://global/content/ml/security/ConditionEvaluator.sys.mjs"; + +/** + * Evaluates JSON-based security policies using "first deny wins" strategy. + * Delegates condition evaluation to ConditionEvaluator. + */ +export class PolicyEvaluator { + /** + * Checks if a policy's match criteria applies to an action. + * Supports exact matches, OR conditions (pipe separator), and wildcards (*). + * + * Match criteria use dot-notation paths and support: + * - Exact matches: "get_page_content" + * - OR conditions: "get_page_content|search_history" + * - Wildcards: "*" (matches anything) + * + * All criteria must match for policy to apply. + * + * @param {object} matchCriteria - Match object from policy + * @param {object} action - Action to check against + * @returns {boolean} True if policy applies to this action + */ + static checkMatch(matchCriteria, action) { + console.warn("[PolicyEvaluator] checkMatch criteria:", JSON.stringify(matchCriteria), "action:", JSON.stringify(action)); + if (!matchCriteria || typeof matchCriteria !== "object") { + return false; + } + + for (const [path, expectedValue] of Object.entries(matchCriteria)) { + const actualValue = ConditionEvaluator.resolvePath(path, action, {}); + + // Handle OR conditions with pipe separator + // e.g., "get_page_content|search_history" or "get_page_content|*" + if (typeof expectedValue === "string" && expectedValue.includes("|")) { + const options = expectedValue.split("|"); + + const matches = options.some( + option => option === "*" || option === actualValue + ); + + if (!matches) { + return false; + } + } else if (expectedValue === "*") { + if (actualValue === undefined || actualValue === null) { + return false; + } + } else if (actualValue !== expectedValue) { + // Exact match required + return false; + } + } + + return true; + } + + /** + * Evaluates a single policy against an action. + * Returns null if policy doesn't apply, otherwise allow/deny decision. + * + * Process: + * 1. Check if policy matches action (match criteria) + * 2. If not, return null (policy doesn't apply) + * 3. If matches, evaluate all conditions + * 4. If any condition fails, return deny decision + * 5. If all conditions pass, return allow decision + * + * @param {object} policy - Policy object from JSON + * @param {string} policy.id - Unique policy identifier + * @param {boolean} policy.enabled - Whether policy is active + * @param {object} policy.match - Match criteria + * @param {Array} policy.conditions - Conditions to evaluate + * @param {string} policy.effect - "deny" or "allow" + * @param {object} policy.onDeny - Denial information + * @param {object} action - Action being evaluated + * @param {object} context - Request context + * @returns {object|null} Decision object or null if policy doesn't apply + */ + static evaluatePolicy(policy, action, context) { + if (policy.enabled === false) { + return null; + } + + if (!this.checkMatch(policy.match, action)) { + return null; + } + + for (const condition of policy.conditions) { + const result = ConditionEvaluator.evaluate(condition, action, context); + + if (!result) { + if (policy.effect === "deny") { + return deny(policy.onDeny.code, policy.onDeny.reason, { + policyId: policy.id, + failedCondition: condition.type, + conditionDescription: condition.description, + }); + } + + return deny("POLICY_CONDITION_FAILED", "Policy condition not met", { + policyId: policy.id, + failedCondition: condition.type, + }); + } + } + + if (policy.effect === "deny") { + return null; + } + + return allow({ + policyId: policy.id, + note: "All policy conditions satisfied", + }); + } + + /** + * Evaluates all policies for a phase against an action. + * + * Strategy: First deny wins (short-circuit evaluation) + * - Iterate through policies in order + * - First policy that denies terminates evaluation + * - If no policies deny, allow + * + * @param {Array} policies - Array of policy objects for this phase + * @param {object} action - Action being evaluated + * @param {object} context - Request context + * @returns {object} Decision object (allow or deny) + */ + static evaluatePhase(policies, action, context) { + if (!policies || policies.length === 0) { + console.warn("[PolicyEvaluator] No policies provided for evaluation"); + return allow({ note: "No policies to evaluate" }); + } + + let appliedPolicies = 0; + + for (const policy of policies) { + const decision = this.evaluatePolicy(policy, action, context); + + if (decision === null) { + continue; + } + + appliedPolicies++; + + if (decision.effect === "deny") { + console.warn( + `[PolicyEvaluator] Policy ${policy.id} denied action:`, + decision.reason + ); + return decision; + } + } + + if (appliedPolicies === 0) { + console.warn( + "[PolicyEvaluator] No policies applied to action:", + action.type, + action.tool || "" + ); + } + + return allow({ + note: `Evaluated ${appliedPolicies} policies, none denied`, + }); + } + + /** + * Validates a policy object structure. + * + * Checks for required fields and valid values. + * Used during policy loading to catch configuration errors. + * + * @param {object} policy - Policy object to validate + * @returns {object} { valid: boolean, errors: string[] } + */ + static validatePolicy(policy) { + const errors = []; + + // Required fields + if (!policy.id) { + errors.push("Missing required field: id"); + } + if (!policy.phase) { + errors.push("Missing required field: phase"); + } + if (!policy.match) { + errors.push("Missing required field: match"); + } + if (!policy.conditions) { + errors.push("Missing required field: conditions"); + } + if (!policy.effect) { + errors.push("Missing required field: effect"); + } + + // Type validation + if (policy.enabled !== undefined && typeof policy.enabled !== "boolean") { + errors.push("Field 'enabled' must be boolean"); + } + if (!Array.isArray(policy.conditions)) { + errors.push("Field 'conditions' must be an array"); + } + if (policy.effect !== "deny" && policy.effect !== "allow") { + errors.push("Field 'effect' must be 'deny' or 'allow'"); + } + + // Conditional requirements + if (policy.effect === "deny" && !policy.onDeny) { + errors.push("Field 'onDeny' required when effect is 'deny'"); + } + if (policy.onDeny && (!policy.onDeny.code || !policy.onDeny.reason)) { + errors.push("Field 'onDeny' must have 'code' and 'reason'"); + } + + // Condition validation + if (Array.isArray(policy.conditions)) { + policy.conditions.forEach((condition, index) => { + if (!condition.type) { + errors.push(`Condition ${index}: missing 'type' field`); + } + }); + } + + return { + valid: errors.length === 0, + errors, + }; + } +} diff --git a/toolkit/components/ml/security/SecurityLogger.sys.mjs b/toolkit/components/ml/security/SecurityLogger.sys.mjs new file mode 100644 index 0000000000000..a8c2a94015041 --- /dev/null +++ b/toolkit/components/ml/security/SecurityLogger.sys.mjs @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const logConsole = console.createInstance({ + maxLogLevelPref: "browser.ml.logLevel", + prefix: "SecurityLogger", +}); + +/** + * Security audit logger (stub implementation). + * + * Security audit logger (stub - console only). + * TODO: Full implementation in separate ticket (NDJSON, Glean, field hashing). + */ +export class SecurityLogger { + /** + * Logs a security decision event. + * + * @param {object} event - The security event to log + * @param {string} event.phase - Security phase (tool.execution, etc.) + * @param {object} event.action - Action that was checked + * @param {object} event.context - Request context + * @param {object} event.decision - Policy decision (allow/deny) + * @param {number} event.durationMs - Evaluation duration in milliseconds + * @param {Error} [event.error] - Optional error if evaluation failed + */ + static log(event) { + const { phase, decision, durationMs, error } = event; + + if (error) { + logConsole.error( + `[${phase}] Security evaluation error:`, + error.message || error + ); + } else if (decision.effect === "deny") { + logConsole.warn( + `[${phase}] DENY: ${decision.code} - ${decision.reason} (${durationMs}ms)` + ); + } else { + logConsole.debug(`[${phase}] ALLOW (${durationMs}ms)`); + } + } +} diff --git a/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs b/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs new file mode 100644 index 0000000000000..73aefdf50c22f --- /dev/null +++ b/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs @@ -0,0 +1,339 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { SessionLedger } from "chrome://global/content/ml/security/SecurityUtils.sys.mjs"; +import { SecurityLogger } from "chrome://global/content/ml/security/SecurityLogger.sys.mjs"; +import { + allow, + deny, +} from "chrome://global/content/ml/security/DecisionTypes.sys.mjs"; +import { PolicyEvaluator } from "chrome://global/content/ml/security/PolicyEvaluator.sys.mjs"; + +/** Kill switch pref. When false, all security checks bypassed. Default: true */ +const PREF_SECURITY_ENABLED = "browser.smartwindow.security.enabled"; + +/** + * Checks if Smart Window security enforcement is enabled. + * + * @returns {boolean} True if security is enabled, false otherwise + */ +function isSecurityEnabled() { + return Services.prefs.getBoolPref(PREF_SECURITY_ENABLED, true); +} + +/** + * Central security orchestrator for Firefox AI features. + * Single entry point: evaluate() routes to policy modules with centralized logging. + * + */ +export class SecurityOrchestrator { + /** + * Registry of security policies by phase. + * + * @type {Map>} + */ + static #policies = new Map(); + + /** + * Shared session ledger for URL tracking across tabs. + * + * @type {SessionLedger | null} + */ + static #sessionLedger = null; + + /** + * Session identifier. + * + * @type {string | null} + */ + static #sessionId = null; + + /** + * Initializes the security orchestrator for a new session. + * + * This should be called once when Smart Window (or other AI feature) starts. + * Creates the SessionLedger that will track trusted URLs across all tabs. + * Loads security policies from JSON files. + * + * @param {string} sessionId - Unique identifier for this session + * @returns {Promise} The initialized session ledger + */ + static async init(sessionId) { + this.#sessionId = sessionId; + this.#sessionLedger = new SessionLedger(sessionId); + + await this.#loadPolicies(); + + console.warn( + `[Security] Orchestrator initialized for session ${sessionId} with ${Array.from( + this.#policies.values() + ).reduce((sum, policies) => sum + policies.length, 0)} policies` + ); + + return this.#sessionLedger; + } + + /** + * Loads and validates policies from JSON files. + * + * @private + */ + static async #loadPolicies() { + // Add more policy files here as they're created: + const policyFiles = ["tool-execution-policies.json"]; + + const allPolicies = []; + let totalLoaded = 0; + let totalFailed = 0; + + for (const file of policyFiles) { + try { + const response = await fetch( + `chrome://global/content/ml/security/policies/${file}` + ); + + if (!response.ok) { + console.error( + `[Security] Failed to fetch policy file ${file}: ${response.status}` + ); + totalFailed++; + continue; + } + + const data = await response.json(); + + // Validate policy file structure + if (!data.policies || !Array.isArray(data.policies)) { + console.error( + `[Security] Invalid policy file structure in ${file}: missing 'policies' array` + ); + totalFailed++; + continue; + } + + // Validate each policy + for (const policy of data.policies) { + const validation = PolicyEvaluator.validatePolicy(policy); + if (!validation.valid) { + console.error( + `[Security] Invalid policy '${policy.id}' in ${file}:`, + validation.errors + ); + totalFailed++; + continue; + } + + allPolicies.push(policy); + totalLoaded++; + } + + console.warn( + `[Security] Loaded ${data.policies.length} policies from ${file}` + ); + } catch (error) { + console.error(`[Security] Error loading policy file ${file}:`, error); + totalFailed++; + } + } + + // Group policies by phase + const policyMap = new Map(); + for (const policy of allPolicies) { + if (!policyMap.has(policy.phase)) { + policyMap.set(policy.phase, []); + } + policyMap.get(policy.phase).push(policy); + } + + this.#policies = policyMap; + + console.warn( + `[Security] Policy loading complete: ${totalLoaded} loaded, ${totalFailed} failed, ${policyMap.size} phases` + ); + } + + /** + * Resets the security orchestrator state. + * Used for cleanup or testing. + */ + static reset() { + this.#sessionLedger = null; + this.#sessionId = null; + this.#policies.clear(); + } + + /** + * Gets the current session ledger. + * + * @returns {SessionLedger} The session ledger + * @throws {Error} If orchestrator not initialized AND security is enabled + */ + static getSessionLedger() { + if (!this.#sessionLedger) { + if (!isSecurityEnabled()) { + return null; + } + + throw new Error( + "Security orchestrator not initialized. Call SecurityOrchestrator.init() first." + ); + } + return this.#sessionLedger; + } + + /** + * Main entry point for all security checks. + * + * This method: + * 1. Validates the request envelope + * 2. Builds shared context (ledger, metadata) + * 3. Routes to appropriate policy module + * 4. Evaluates the action against policy + * 5. Logs the decision + * 6. Returns allow/deny decision + * + * @param {object} envelope - Security check request + * @param {string} envelope.phase - Security phase ("tool.execution", "inference-pipeline", etc.) + * @param {object} envelope.action - Action being checked (type, tool, urls, etc.) + * @param {object} envelope.context - Request context (tabId, requestId, etc.) + * @returns {Promise} Decision object with effect (allow/deny), code, reason + */ + static async evaluate(envelope) { + const startTime = Date.now(); + + try { + if (!envelope || typeof envelope !== "object") { + return deny("INVALID_REQUEST", "Security envelope is null or invalid"); + } + + const { phase, action, context } = envelope; + if (!phase || !action || !context) { + return deny( + "INVALID_REQUEST", + "Security envelope missing required fields (phase, action, or context)" + ); + } + + if (!isSecurityEnabled()) { + SecurityLogger.log({ + phase, + action, + context, + decision: { + effect: "allow", + reason: "Security disabled via kill switch", + }, + durationMs: Date.now() - startTime, + killSwitchBypass: true, + }); + return { effect: "allow" }; + } + + const policies = this.#policies.get(phase); + if (!policies || policies.length === 0) { + console.warn(`[Security] No policies registered for phase: ${phase}`); + return allow({ note: "No policies for phase" }); + } + + const fullContext = { + ...context, + sessionLedger: this.#sessionLedger, + sessionId: this.#sessionId, + timestamp: Date.now(), + }; + + const { currentTabId, mentionedTabIds = [] } = context; + const tabsToCheck = [currentTabId, ...mentionedTabIds].filter(Boolean); + const linkLedger = this.#sessionLedger.merge(tabsToCheck); + fullContext.linkLedger = linkLedger; + + const decision = PolicyEvaluator.evaluatePhase( + policies, + action, + fullContext + ); + + SecurityLogger.log({ + phase, + action, + context: fullContext, + decision, + durationMs: Date.now() - startTime, + }); + + return decision; + } catch (error) { + const errorDecision = deny( + "EVALUATION_ERROR", + "Security evaluation failed with unexpected error", + { error: error.message || String(error) } + ); + + SecurityLogger.log({ + phase: envelope.phase || "unknown", + action: envelope.action || {}, + context: envelope.context || {}, + decision: errorDecision, + durationMs: Date.now() - startTime, + error, + }); + + return errorDecision; + } + } + + /** + * Removes all policies for a phase. + * + * Note: With JSON-based policies, this only affects runtime state. + * Policies will be reloaded from JSON on next init(). + * + * @param {string} phase - Phase identifier to remove + * @returns {boolean} True if policies were removed, false if not found + */ + static removePolicy(phase) { + return this.#policies.delete(phase); + } + + /** + * Gets statistics about the orchestrator state. + * + * @returns {object} Stats object with registered policies, session info, etc. + */ + static getStats() { + const totalPolicies = Array.from(this.#policies.values()).reduce( + (sum, policies) => sum + policies.length, + 0 + ); + + // Get policy breakdown by phase + const policyBreakdown = {}; + for (const [phase, policies] of this.#policies.entries()) { + policyBreakdown[phase] = { + count: policies.length, + policies: policies.map(p => ({ + id: p.id, + enabled: p.enabled !== false, + })), + }; + } + + return { + sessionId: this.#sessionId, + initialized: this.#sessionLedger !== null, + registeredPhases: Array.from(this.#policies.keys()), + totalPolicies, + policyBreakdown, + sessionLedgerStats: this.#sessionLedger + ? { + tabCount: this.#sessionLedger.tabCount(), + totalUrls: Array.from(this.#sessionLedger.tabs.values()).reduce( + (sum, ledger) => sum + ledger.size(), + 0 + ), + } + : null, + }; + } +} diff --git a/toolkit/components/ml/security/SecurityUtils.sys.mjs b/toolkit/components/ml/security/SecurityUtils.sys.mjs new file mode 100644 index 0000000000000..dfcf47e39b3e6 --- /dev/null +++ b/toolkit/components/ml/security/SecurityUtils.sys.mjs @@ -0,0 +1,438 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Security utilities for Firefox Smart Window security layer. + * + * This module provides: + * - URL normalization for consistent comparison + * - eTLD+1 (effective top-level domain) validation + * - TabLedger: Per-tab trusted URL storage + * - SessionLedger: Container for all tab ledgers in a Smart Window session + * + * Security Model: + * --------------- + * - Each tab maintains its own ledger of trusted URLs + * - Request-scoped context merges current tab + @mentioned tabs + * - URLs are normalized before storage and comparison + * - Same eTLD+1 validation prevents injection via canonical/og:url + */ + +/** TTL for ledger entries (30 minutes) */ +const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes + +/** Max URLs per tab (prevents memory exhaustion) */ +const MAX_URLS_PER_TAB = 500; + +/** Tracking params to strip during normalization */ +const TRACKING_PARAMS = new Set([ + "fbclid", + "gclid", + "msclkid", + "mc_eid", + "_ga", + // Note: utm_* params are handled via startsWith() pattern below +]); + +/** + * Normalizes a URL for consistent comparison. + * Lowercases host, strips default ports/fragments/tracking params, sorts query. + * + * @param {string} urlString - URL to normalize + * @param {string} [baseUrl] - Base URL for relative resolution + * @returns {object} { success, url?, error? } + */ +export function normalizeUrl(urlString, baseUrl = null) { + if (!urlString || !String(urlString).trim()) { + return { + success: false, + error: "Empty URL", + }; + } + + try { + let url; + try { + url = baseUrl ? new URL(urlString, baseUrl) : new URL(urlString); + } catch (parseError) { + return { + success: false, + error: "Invalid URL format", + }; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { + success: false, + error: `Unsupported scheme: ${url.protocol}`, + }; + } + + const protocol = url.protocol.toLowerCase(); + const hostname = url.hostname.toLowerCase(); + + let port = url.port; + if ( + (protocol === "http:" && port === "80") || + (protocol === "https:" && port === "443") + ) { + port = ""; + } + + const params = new URLSearchParams(url.searchParams); + const cleanedParams = new URLSearchParams(); + + for (const [key, value] of params) { + const keyLower = key.toLowerCase(); + if (keyLower.startsWith("utm_") || TRACKING_PARAMS.has(keyLower)) { + continue; + } + cleanedParams.append(key, value); + } + + cleanedParams.sort(); + const search = cleanedParams.toString(); + + let pathname = url.pathname; + if (!pathname.startsWith("/")) { + pathname = "/" + pathname; + } + + let normalizedUrl = `${protocol}//${hostname}`; + + if (port) { + normalizedUrl += `:${port}`; + } + + normalizedUrl += pathname; + + if (search) { + normalizedUrl += `?${search}`; + } + + return { + success: true, + url: normalizedUrl, + }; + } catch (error) { + return { + success: false, + error: error.message || String(error), + }; + } +} + +/** + * Validates that two URLs share the same eTLD+1 (effective top-level domain). + * + * @param {string} url1 - First URL (typically page URL) + * @param {string} url2 - Second URL (typically canonical/og:url) + * @returns {boolean} True if both URLs share the same eTLD+1 + */ +export function isSameETLDPlusOne(url1, url2) { + try { + const parsed1 = new URL(url1); + const parsed2 = new URL(url2); + + if (!Services || !Services.eTLD) { + console.error("Services.eTLD not available"); + return false; + } + + const eTLD1 = Services.eTLD.getBaseDomainFromHost(parsed1.hostname); + const eTLD2 = Services.eTLD.getBaseDomainFromHost(parsed2.hostname); + + return eTLD1 === eTLD2; + } catch (error) { + console.error("isSameETLDPlusOne error:", error.message); + return false; + } +} + +/** + * Per-tab storage for trusted URLs. + * + * Each tab maintains its own ledger of URLs that are authorized for + * security-sensitive operations. URLs are stored with expiration timestamps + * and the ledger enforces size limits to prevent memory exhaustion. + */ +export class TabLedger { + /** + * Creates a new tab ledger. + * + * @param {string} tabId - The tab identifier + * @param {number} [ttlMs=DEFAULT_TTL_MS] - Time-to-live for entries in milliseconds + * @param {number} [maxUrls=MAX_URLS_PER_TAB] - Maximum URLs to store + */ + constructor(tabId, ttlMs = DEFAULT_TTL_MS, maxUrls = MAX_URLS_PER_TAB) { + this.tabId = tabId; + this.ttlMs = ttlMs; + this.maxUrls = maxUrls; + + /** @type {Map} URL --> expiration timestamp */ + this.urls = new Map(); + + /** @type {number} Last cleanup timestamp */ + this.lastCleanup = Date.now(); + } + + /** + * Seeds the ledger with initial URLs. Invalid URLs skipped silently. + * + * @param {string[]} urls - URLs to seed + * @param {string} [baseUrl] - Optional base URL for resolving relatives + */ + seed(urls, baseUrl = null) { + this.#cleanup(); + + const now = Date.now(); + const expiresAt = now + this.ttlMs; + + for (const url of urls) { + if (this.urls.size >= this.maxUrls) { + break; + } + + const normalized = normalizeUrl(url, baseUrl); + if (normalized.success) { + this.urls.set(normalized.url, expiresAt); + } + } + + this.lastCleanup = now; + } + + /** + * Adds a single URL to the ledger. + * + * @param {string} url - URL to add + * @param {string} [baseUrl] - Optional base URL for resolving relatives + * @returns {boolean} True if added successfully, false if invalid or at capacity + */ + add(url, baseUrl = null) { + this.#cleanup(); + + if (this.urls.size >= this.maxUrls) { + return false; + } + + const normalized = normalizeUrl(url, baseUrl); + if (!normalized.success) { + return false; + } + + const expiresAt = Date.now() + this.ttlMs; + this.urls.set(normalized.url, expiresAt); + + return true; + } + + /** + * Checks if a URL is in the ledger and not expired. + * + * @param {string} url - URL to check (will be normalized) + * @param {string} [baseUrl] - Optional base URL for resolving relatives + * @returns {boolean} True if URL is in ledger and not expired + */ + has(url, baseUrl = null) { + const normalized = normalizeUrl(url, baseUrl); + if (!normalized.success) { + return false; + } + + const expiresAt = this.urls.get(normalized.url); + if (expiresAt === undefined) { + return false; + } + + // Check expiration + if (Date.now() > expiresAt) { + this.urls.delete(normalized.url); + return false; + } + + return true; + } + + /** + * Clears all URLs from the ledger. + * Typically called on tab navigation or tab close. + */ + clear() { + this.urls.clear(); + this.lastCleanup = Date.now(); + } + + /** + * Returns the number of URLs currently in the ledger (including expired). + * + * @returns {number} Number of URLs + */ + size() { + return this.urls.size; + } + + /** + * Removes expired entries from the ledger. + * Called automatically during add() and can be called manually. + * + * @private + */ + #cleanup() { + const now = Date.now(); + for (const [url, expiresAt] of this.urls) { + if (now > expiresAt) { + this.urls.delete(url); + } + } + this.lastCleanup = now; + } + + /** + * Returns all URLs currently in the ledger (expired entries removed). + * + * @returns {string[]} Array of URLs + */ + getAll() { + this.#cleanup(); + + return Array.from(this.urls.keys()); + } +} + +/** + * Container for all tab ledgers in a Smart Window session. + * + * SessionLedger manages the lifecycle of individual TabLedgers and provides + * methods to build request-scoped contexts by merging tab ledgers. + */ +export class SessionLedger { + /** + * Creates a new session ledger. + * + * @param {string} sessionId - The Smart Window session identifier + */ + constructor(sessionId) { + this.sessionId = sessionId; + + /** @type {Map} Map of tab ID --> TabLedger */ + this.tabs = new Map(); + } + + /** + * Gets or creates a TabLedger for the specified tab. + * + * @param {string} tabId - The tab identifier + * @returns {TabLedger} The tab's ledger + */ + forTab(tabId) { + if (!this.tabs.has(tabId)) { + this.tabs.set(tabId, new TabLedger(tabId)); + } + return this.tabs.get(tabId); + } + + /** + * Merges ledgers from multiple tabs into a temporary request-scoped ledger. + * + * This is used to build context for requests with @mentions, where the user + * explicitly authorizes access to multiple tabs. + * + * IMPORTANT: The returned merged ledger is a temporary view. It should be + * used for a single request and then discarded. It does NOT support add() + * operations (read-only for policy evaluation). + * + * @param {string[]} tabIds - Tab IDs to merge (typically current + @mentioned) + * @returns {object} Merged ledger with has() and size() methods + */ + merge(tabIds) { + const mergedUrls = new Set(); + + for (const tabId of tabIds) { + const ledger = this.forTab(tabId); + const now = Date.now(); + + for (const [url, expiresAt] of ledger.urls) { + if (now <= expiresAt) { + mergedUrls.add(url); + } + } + } + + // Return a temporary read-only ledger + return { + /** + * Checks if URL is in any of the merged ledgers. + * + * @param {string} url - URL to check + * @param {string} [baseUrl] - Optional base URL + * @returns {boolean} True if URL is in any merged ledger + */ + has(url, baseUrl = null) { + const normalized = normalizeUrl(url, baseUrl); + if (!normalized.success) { + return false; + } + return mergedUrls.has(normalized.url); + }, + + /** + * Returns number of unique URLs in merged ledger. + * + * @returns {number} Number of URLs + */ + size() { + return mergedUrls.size; + }, + }; + } + + /** + * Convenience method for building request-scoped context. + * + * @param {object} options - Request scope options + * @param {string} options.currentTabId - The active/focused tab (always included) + * @param {string[]} [options.mentionedTabIds=[]] - Tabs from @mentions (optional) + * @returns {object} Request-scoped ledger with has() and size() methods + */ + buildRequestScope({ currentTabId, mentionedTabIds = [] }) { + return this.merge([currentTabId, ...mentionedTabIds]); + } + + /** + * Clears the ledger for a specific tab. + * Typically called on tab navigation or tab close. + * + * @param {string} tabId - The tab identifier + */ + clearTab(tabId) { + const ledger = this.tabs.get(tabId); + if (ledger) { + ledger.clear(); + } + } + + /** + * Removes a tab's ledger completely. + * Typically called when tab closes. + * + * @param {string} tabId - The tab identifier + */ + removeTab(tabId) { + this.tabs.delete(tabId); + } + + /** Clears all tab ledgers. */ + clearAll() { + for (const ledger of this.tabs.values()) { + ledger.clear(); + } + this.tabs.clear(); + } + + /** @returns {number} Number of tabs */ + tabCount() { + return this.tabs.size; + } +} diff --git a/toolkit/components/ml/security/SmartWindowIntegration.sys.mjs b/toolkit/components/ml/security/SmartWindowIntegration.sys.mjs new file mode 100644 index 0000000000000..316611696d122 --- /dev/null +++ b/toolkit/components/ml/security/SmartWindowIntegration.sys.mjs @@ -0,0 +1,217 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { SessionLedger } from "chrome://global/content/ml/security/SecurityUtils.sys.mjs"; + +const logConsole = console.createInstance({ + maxLogLevelPref: "browser.ml.logLevel", + prefix: "SmartWindowSecurity", +}); + +/** + * Smart Window security layer integration. + * + * Bridge between Smart Window UI and security layer. + * Manages SessionLedger lifecycle and request context building. + */ + +let gSessionLedger = null; +let gSessionId = null; + +/** + * Initializes the security layer. Call once when Smart Window opens. + * + * @param {string} [sessionId] - Optional session identifier (auto-generated if not provided) + * @returns {SessionLedger} The session ledger instance + */ +export function initSecurityLayer(sessionId = null) { + if (!sessionId) { + sessionId = `smart-window-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + gSessionId = sessionId; + gSessionLedger = new SessionLedger(sessionId); + + logConsole.debug(`Security layer initialized for session: ${sessionId}`); + + return gSessionLedger; +} + +/** + * Gets the current SessionLedger instance. + * + * @returns {SessionLedger|null} The session ledger, or null if not initialized + * @throws {Error} If security layer hasn't been initialized + */ +export function getSessionLedger() { + if (!gSessionLedger) { + throw new Error( + "Security layer not initialized. Call initSecurityLayer() first." + ); + } + return gSessionLedger; +} + +/** Resets the security layer (clears all ledgers). */ +export function resetSecurityLayer() { + if (gSessionLedger) { + gSessionLedger.clearAll(); + } + gSessionLedger = null; + gSessionId = null; + logConsole.debug("Security layer reset"); +} + +/** + * Seeds the ledger for a tab via SmartWindowMeta actor. + * + * @param {Browser} browser - The browser element for the tab + * @param {string} tabId - The tab identifier (typically linkedPanel) + * @returns {Promise} Result object with seeded URLs and any errors + */ +export async function seedCurrentTab(browser, tabId) { + const sessionLedger = getSessionLedger(); + + try { + const actor = + browser.browsingContext?.currentWindowGlobal?.getActor("SmartWindowMeta"); + + if (!actor) { + logConsole.error( + "SmartWindowMeta actor not available for browser", + tabId + ); + return { + success: false, + errors: [ + "SmartWindowMeta actor not available (page may not be loaded yet)", + ], + }; + } + + const result = await actor.seedLedgerForTab(sessionLedger, tabId); + + if (result.success) { + logConsole.debug( + `Seeded tab ${tabId} with ${result.seededUrls.length} URLs`, + result.seededUrls.map(u => u.normalized) + ); + + if (result.skippedUrls.length) { + logConsole.debug( + `Skipped ${result.skippedUrls.length} URLs for tab ${tabId}`, + result.skippedUrls + ); + } + } else { + logConsole.error(`Failed to seed tab ${tabId}:`, result.errors); + } + + return result; + } catch (error) { + logConsole.error("Error seeding tab:", error); + return { + success: false, + errors: [{ message: "Exception during seeding", error: String(error) }], + }; + } +} + +/** + * Handles tab navigation by clearing and re-seeding the ledger. + * + * This should be called when the user navigates to a new page in a tab. + * It clears the old trusted URLs and seeds with the new page context. + * + * @param {Browser} browser - The browser element for the tab + * @param {string} tabId - The tab identifier + * @param {nsIURI} uri - The new URI (optional, for logging) + * @returns {Promise} + */ +export async function handleNavigation(browser, tabId, uri = null) { + const sessionLedger = getSessionLedger(); + + sessionLedger.clearTab(tabId); + + logConsole.debug( + `Cleared ledger for tab ${tabId} after navigation${uri ? ` to ${uri.spec}` : ""}` + ); + + // Re-seed with new page context + return seedCurrentTab(browser, tabId); +} + +/** + * Handles tab close by removing the tab's ledger. + * + * This should be called when a tab closes to clean up memory. + * + * @param {string} tabId - The tab identifier + */ +export function handleTabClose(tabId) { + const sessionLedger = getSessionLedger(); + sessionLedger.removeTab(tabId); + logConsole.debug(`Removed ledger for closed tab ${tabId}`); +} + +/** + * Builds request-scoped security context for policy evaluation. + * + * @param {string} currentTabId - The active/focused tab + * @param {string[]} [mentionedTabIds=[]] - Tab IDs from @mentions + * @param {string} [requestId] - Optional request identifier for logging + * @returns {object} Security context object for policy evaluation + */ +export function getRequestContext( + currentTabId, + mentionedTabIds = [], + requestId = null +) { + const sessionLedger = getSessionLedger(); + + const linkLedger = sessionLedger.buildRequestScope({ + currentTabId, + mentionedTabIds, + }); + + const actualRequestId = + requestId || `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + return { + linkLedger, + sessionId: gSessionId, + requestId: actualRequestId, + currentTabId, + mentionedTabIds, + }; +} + +/** + * Gets statistics about the current security layer state. + * Useful for debugging and monitoring. + * + * @returns {object} Statistics object with tab counts and URL counts + */ +export function getSecurityLayerStats() { + const sessionLedger = getSessionLedger(); + + const tabIds = []; + const tabStats = []; + let totalUrls = 0; + + for (const [tabId, ledger] of sessionLedger.tabs.entries()) { + const urlCount = ledger.size(); + tabIds.push(tabId); + tabStats.push({ tabId, urlCount }); + totalUrls += urlCount; + } + + return { + sessionId: gSessionId, + tabCount: sessionLedger.tabCount(), + totalUrls, + tabIds, + tabStats, + }; +} diff --git a/toolkit/components/ml/security/moz.build b/toolkit/components/ml/security/moz.build new file mode 100644 index 0000000000000..4eee224331e26 --- /dev/null +++ b/toolkit/components/ml/security/moz.build @@ -0,0 +1,20 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Machine Learning") + +EXTRA_JS_MODULES += [ + 'ConditionEvaluator.sys.mjs', + 'DecisionTypes.sys.mjs', + 'PolicyEvaluator.sys.mjs', + 'SecurityLogger.sys.mjs', + 'SecurityOrchestrator.sys.mjs', + 'SecurityUtils.sys.mjs', + 'SmartWindowIntegration.sys.mjs', +] + +FINAL_TARGET_FILES.content.ml.security.policies += [ + 'policies/tool-execution-policies.json', +] diff --git a/toolkit/components/ml/security/policies/POLICY_AUTHORING.md b/toolkit/components/ml/security/policies/POLICY_AUTHORING.md new file mode 100644 index 0000000000000..969a15bad8bc5 --- /dev/null +++ b/toolkit/components/ml/security/policies/POLICY_AUTHORING.md @@ -0,0 +1,466 @@ +# Security Policy Authorship Guide + +## Overview + +Security policies are defined in JSON files and evaluated by the `PolicyEvaluator` at runtime. This allows security policies to be added or modified without creating new JavaScript modules. + +## Policy File Structure + +```json +{ + "version": "1.0", + "description": "Brief description of this policy file", + "policies": [ + { /* policy object */ }, + { /* policy object */ } + ] +} +``` + +## Policy Object Schema + +### Required Fields + +**`id`** (string): Unique identifier for the policy +- Must be unique across all policy files +- Use kebab-case: "block-unseen-links" +- Appears in logs and decision metadata + +**`phase`** (string): Security phase where policy applies +- Current phases: "tool.execution", "inference.request", "inference.response" +- Must match the phase used in SecurityOrchestrator.evaluate() + +**`enabled`** (boolean): Whether this policy is active +- `true`: Policy will be evaluated +- `false`: Policy will be skipped (useful for testing) + +**`match`** (object): Criteria to determine if policy applies +- Key: Dot-notation path (e.g., "action.type") +- Value: Expected value or wildcard pattern +- Policy only evaluates if ALL match criteria are met + +**`conditions`** (array): Conditions that must be satisfied +- Each condition is an object with a `type` field +- All conditions must pass for action to be allowed +- If any condition fails, policy effect is applied + +**`effect`** (string): What happens when conditions fail +- "deny": Block the action +- "allow": Permit the action (rarely used - policies typically deny) + +**`onDeny`** (object): Information returned when denying +- `code`: Machine-readable denial code (e.g., "UNSEEN_LINK") +- `reason`: Human-readable explanation + +### Optional Fields + +**`description`** (string): Explanation of policy purpose +- Appears in logs +- Helps future maintainers understand intent + +## Match Criteria + +Match criteria determine whether a policy applies to an action. + +### Syntax + +```json +"match": { + "path.to.field": "expected-value", + "another.field": "value" +} +``` + +### Dot-Notation Paths + +Access nested fields using dots: +- `"action.type"` → `action.type` +- `"action.params.url"` → `action.params.url` +- `"context.sessionId"` → `context.sessionId` + +### Wildcard Matching + +Use pipe (`|`) for OR conditions: + +```json +"match": { + "action.tool": "get_page_content|search_history" +} +``` + +Matches if tool is either get_page_content OR search_history. + +Use asterisk (`*`) to match anything: + +```json +"match": { + "action.tool": "*" +} +``` + +Matches any tool (policy applies to all tools). + +### Examples + +**Match specific tool**: +```json +"match": { + "action.type": "tool.call", + "action.tool": "get_page_content" +} +``` + +**Match multiple tools**: +```json +"match": { + "action.type": "tool.call", + "action.tool": "get_page_content|search_history" +} +``` + +**Match all tool calls**: +```json +"match": { + "action.type": "tool.call", + "action.tool": "*" +} +``` + +## Condition Types + +Conditions are evaluated after match criteria. Each condition has a `type` field that determines how it's evaluated. + +### `allUrlsIn` + +**Purpose**: Check that all URLs are present in a ledger + +**Fields**: +- `urls`: Dot-notation path to URL array +- `ledger`: Dot-notation path to ledger object +- `description`: Optional explanation + +**Example**: +```json +{ + "type": "allUrlsIn", + "urls": "action.urls", + "ledger": "context.linkLedger", + "description": "URLs must be in trusted ledger" +} +``` + +**Behavior**: +- Returns `true` if all URLs in the array are found in the ledger +- Returns `true` if URL array is empty (no URLs to check) +- Returns `false` if ledger is missing or any URL is not in ledger +- Uses ledger's `has()` method for checking + +### `equals` + +**Purpose**: Check exact equality + +**Fields**: +- `actual`: Dot-notation path to actual value +- `expected`: Expected value (literal) + +**Example**: +```json +{ + "type": "equals", + "actual": "action.type", + "expected": "tool.call" +} +``` + +### `matches` + +**Purpose**: Check if value matches a regex pattern + +**Fields**: +- `value`: Dot-notation path to value +- `pattern`: Regular expression pattern (string) + +**Example**: +```json +{ + "type": "matches", + "value": "action.params.query", + "pattern": "^[a-zA-Z0-9\\s]+$", + "description": "Query must be alphanumeric" +} +``` + +**Note**: Backslashes in regex must be escaped in JSON (`\\b` not `\b`) + +### `noPatternInParams` + +**Purpose**: Ensure pattern doesn't appear in parameters (useful for blocking PII) + +**Fields**: +- `params`: Dot-notation path to params object +- `pattern`: Regular expression pattern to block + +**Example**: +```json +{ + "type": "noPatternInParams", + "params": "action.params", + "pattern": "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b", + "description": "Block email addresses in parameters" +} +``` + +## Complete Policy Examples + +### Example 1: Block Unseen Links + +```json +{ + "id": "block-unseen-links", + "phase": "tool.execution", + "enabled": true, + "description": "Prevent prompt injection by blocking access to unseen URLs", + "match": { + "action.type": "tool.call", + "action.tool": "get_page_content" + }, + "conditions": [ + { + "type": "allUrlsIn", + "urls": "action.urls", + "ledger": "context.linkLedger" + } + ], + "effect": "deny", + "onDeny": { + "code": "UNSEEN_LINK", + "reason": "URL not in selected request context" + } +} +``` + +### Example 2: Block Email Exfiltration + +```json +{ + "id": "block-email-exfiltration", + "phase": "tool.execution", + "enabled": true, + "description": "Prevent email addresses from being used in search queries", + "match": { + "action.type": "tool.call", + "action.tool": "search_history" + }, + "conditions": [ + { + "type": "noPatternInParams", + "params": "action.params", + "pattern": "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b", + "description": "Block email address patterns" + } + ], + "effect": "deny", + "onDeny": { + "code": "EMAIL_PATTERN_DETECTED", + "reason": "Search parameters contain email address pattern" + } +} +``` + +### Example 3: Multiple Conditions + +```json +{ + "id": "strict-page-content-access", + "phase": "tool.execution", + "enabled": true, + "description": "Multiple validation checks for page content access", + "match": { + "action.type": "tool.call", + "action.tool": "get_page_content" + }, + "conditions": [ + { + "type": "allUrlsIn", + "urls": "action.urls", + "ledger": "context.linkLedger", + "description": "URLs must be in ledger" + }, + { + "type": "equals", + "actual": "action.params.mode", + "expected": "viewport", + "description": "Only viewport mode allowed" + } + ], + "effect": "deny", + "onDeny": { + "code": "INVALID_ACCESS", + "reason": "Page content access validation failed" + } +} +``` + +## Testing Policies + +### Validation Checklist + +Before adding a new policy: + +1. ✅ **Unique ID**: No other policy uses this ID +2. ✅ **Valid phase**: Phase exists in SecurityOrchestrator +3. ✅ **Match criteria**: Correctly identifies target actions +4. ✅ **Conditions**: All condition types are implemented +5. ✅ **Paths exist**: All dot-notation paths resolve at runtime +6. ✅ **JSON valid**: File parses correctly + +### Manual Testing + +```javascript +// In Browser Console +const { SecurityOrchestrator } = ChromeUtils.importESModule( + "chrome://global/content/ml/security/SecurityOrchestrator.sys.mjs" +); + +// Test a policy +const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"] + }, + context: { + currentTabId: "panel-1", + mentionedTabIds: [], + requestId: "test-123" + } +}); + +console.log(decision); +// Should show: { effect: "deny", code: "UNSEEN_LINK", ... } +``` + +## Best Practices + +### Policy Design + +✅ **DO**: +- Use descriptive IDs: "block-email-exfiltration" not "policy-1" +- Add descriptions explaining the security concern +- Test policies before deploying +- Keep conditions simple and focused +- Use fail-closed logic (deny by default) + +❌ **DON'T**: +- Create overlapping policies (unclear precedence) +- Use complex regex that's hard to understand +- Make policies too broad (match everything) +- Forget to validate paths exist at runtime + +### Condition Design + +✅ **DO**: +- Check one thing per condition (single responsibility) +- Add description fields for complex conditions +- Use existing condition types when possible +- Handle edge cases (empty arrays, null values) + +❌ **DON'T**: +- Try to implement business logic in conditions +- Create tightly coupled conditions +- Use conditions for logging or side effects + +### Match Criteria + +✅ **DO**: +- Be specific (match exact tool names) +- Use wildcards sparingly +- Test that policy applies when expected + +❌ **DON'T**: +- Use "*" unless truly needed for all actions +- Match on fields that might not exist + +## Adding New Condition Types + +To add a new condition type, modify `ConditionEvaluator.sys.mjs`: + +```javascript +// In ConditionEvaluator.sys.mjs +export class ConditionEvaluator { + static evaluate(condition, action, context) { + switch (condition.type) { + // ... existing types ... + + case 'yourNewType': { + const value = this.resolvePath(condition.value, action, context); + // Your validation logic + return /* true or false */; + } + } + } +} +``` + +Then document it in this guide and add tests. + +## Policy File Organization + +### Multiple Policy Files + +Organize policies by phase or concern: + +``` +policies/ +├── tool-execution-policies.json # Smart Window tools +├── inference-pipeline-policies.json # MLEngine inference +├── content-filtering-policies.json # Content safety +└── README.md # This file +``` + +All policy files are loaded automatically at startup. + +### Version Field + +The `version` field tracks schema changes: + +- `"1.0"`: Initial schema +- Future versions for breaking changes + +## Troubleshooting + +### Policy Not Applying + +Check: +1. `enabled: true`? +2. Match criteria correct? +3. Phase name matches SecurityOrchestrator.evaluate() call? +4. Policy file loaded? (Check console logs at startup) + +### Condition Always Failing + +Check: +1. Dot-notation paths resolve? (Use console.log in ConditionEvaluator) +2. Ledger/data exists at runtime? +3. Condition type implemented? + +### Unexpected Denials + +Check: +1. Multiple policies applying? (First deny wins) +2. Condition logic correct? +3. Edge cases handled? (empty arrays, null values) + +## Support + +For questions about policy authorship: +1. Review examples in this guide +2. Check existing policies in policy files +3. Consult security team +4. Review PolicyEvaluator and ConditionEvaluator code + +--- + +**Last Updated**: Phase 2 Implementation (JSON Policy Migration) +**Schema Version**: 1.0 diff --git a/toolkit/components/ml/security/policies/tool-execution-policies.json b/toolkit/components/ml/security/policies/tool-execution-policies.json new file mode 100644 index 0000000000000..4bc70af16b4ed --- /dev/null +++ b/toolkit/components/ml/security/policies/tool-execution-policies.json @@ -0,0 +1,29 @@ +{ + "version": "1.0", + "description": "Security policies for Smart Window tool execution", + "policies": [ + { + "id": "block-unseen-links", + "phase": "tool.execution", + "enabled": true, + "description": "Prevent tools from accessing URLs not in trusted page context. This is the core 'explicit seeding' policy that blocks prompt injection attacks by ensuring tools can only access URLs that the user has explicitly made available (current page, @mentioned tabs, etc.)", + "match": { + "action.type": "tool.call", + "action.tool": "get_page_content" + }, + "conditions": [ + { + "type": "allUrlsIn", + "urls": "action.urls", + "ledger": "context.linkLedger", + "description": "All URLs must be present in the request-scoped ledger (merged from current tab + @mentioned tabs)" + } + ], + "effect": "deny", + "onDeny": { + "code": "UNSEEN_LINK", + "reason": "URL not in selected request context" + } + } + ] +} diff --git a/toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js b/toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js new file mode 100644 index 0000000000000..bcee6d4b88be9 --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js @@ -0,0 +1,267 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for ConditionEvaluator.sys.mjs + * + * Note: ConditionEvaluator is an internal module used by PolicyEvaluator. + * These tests verify it through SecurityOrchestrator (the public API) rather + * than testing internal implementation details. + * + * Focus: Testing condition evaluation behavior through policy execution + */ + +const { SecurityOrchestrator } = ChromeUtils.importESModule( + "chrome://global/content/ml/security/SecurityOrchestrator.sys.mjs" +); + +const PREF_SECURITY_ENABLED = "browser.smartwindow.security.enabled"; + +function setup() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); + SecurityOrchestrator.reset(); +} + +function teardown() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + SecurityOrchestrator.reset(); +} + +// ============================================================================ +// Test: allUrlsIn Condition Behavior (via SecurityOrchestrator) +// ============================================================================ + +add_task(async function test_condition_passes_when_all_urls_in_ledger() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + const tabLedger = ledger.forTab("tab-1"); + tabLedger.add("https://example.com"); + tabLedger.add("https://mozilla.org"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://example.com", "https://mozilla.org"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Should allow when all URLs in ledger (condition passes)" + ); + + teardown(); +}); + +add_task(async function test_condition_fails_when_url_missing_from_ledger() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://example.com", "https://evil.com"], // evil.com not in ledger + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "Should deny when URL not in ledger (condition fails)" + ); + Assert.equal(decision.code, "UNSEEN_LINK"); + + teardown(); +}); + +add_task(async function test_condition_passes_with_empty_urls_array() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: [], // Empty array + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Should allow with empty URLs (nothing to check)" + ); + + teardown(); +}); + +add_task(async function test_condition_fails_with_malformed_url() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["not-a-valid-url"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "Should deny malformed URL (condition/validation fails)" + ); + // Malformed URLs are treated as unseen (not in ledger) rather than + // caught as specifically malformed at this layer + Assert.equal(decision.code, "UNSEEN_LINK"); + + teardown(); +}); + +// ============================================================================ +// Test: Ledger Merging (@Mentions) +// ============================================================================ + +add_task(async function test_condition_checks_current_tab_only() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://example.com"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Should check current tab ledger only" + ); + + teardown(); +}); + +add_task(async function test_condition_merges_mentioned_tabs() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + + ledger.forTab("tab-1").add("https://example.com"); + ledger.forTab("tab-2").add("https://mozilla.org"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://mozilla.org"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: ["tab-2"], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Should merge current tab + @mentioned tabs" + ); + + teardown(); +}); + +// ============================================================================ +// Test: URL Normalization +// ============================================================================ + +add_task(async function test_condition_normalizes_urls() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com/page"); // No fragment + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://example.com/page#section"], // Has fragment + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Should allow after normalizing URLs (fragments stripped)" + ); + + teardown(); +}); diff --git a/toolkit/components/ml/tests/xpcshell/test_decision_types.js b/toolkit/components/ml/tests/xpcshell/test_decision_types.js new file mode 100644 index 0000000000000..cd2a3ed7aeca3 --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_decision_types.js @@ -0,0 +1,371 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for DecisionTypes.sys.mjs + * + * Tests the core type definitions and helpers for the security layer: + * - SecurityPolicyError class (constructor, toJSON, throw/catch) + * - Decision helper functions (allow, deny) - correct structure + * - Type guards (isAllow, isDeny) - control flow correctness + * - Constants (DenialCodes, ReasonPhrases) - expected values + */ + +const { + SecurityPolicyError, + DenialCodes, + ReasonPhrases, + allow, + deny, + isAllow, + isDeny, +} = ChromeUtils.importESModule( + "chrome://global/content/ml/security/DecisionTypes.sys.mjs" +); + +// ============================================================================ +// Test: SecurityPolicyError Class +// ============================================================================ + +/** + * Test that SecurityPolicyError constructor creates error with correct properties + */ +add_task(async function test_security_policy_error_constructor() { + const decision = { + effect: "deny", + policyId: "test-policy", + code: "TEST_CODE", + reason: "Test reason message", + details: { foo: "bar" }, + }; + + const error = new SecurityPolicyError(decision); + + // Check properties that matter for error handling + Assert.equal(error.name, "SecurityPolicyError", "Should have correct name"); + Assert.equal( + error.message, + "Test reason message", + "Should have correct message" + ); + Assert.equal(error.code, "TEST_CODE", "Should have correct code"); + Assert.equal(error.policyId, "test-policy", "Should have correct policyId"); + Assert.deepEqual( + error.decision, + decision, + "Should store the full decision object" + ); +}); + +/** + * Test that SecurityPolicyError.toJSON() serializes correctly + */ +add_task(async function test_security_policy_error_toJSON() { + const decision = { + effect: "deny", + policyId: "test-policy", + code: "TEST_CODE", + reason: "Test reason", + details: { url: "https://example.com" }, + }; + + const error = new SecurityPolicyError(decision); + const json = error.toJSON(); + + // Check serialized structure has all required fields + Assert.equal(json.name, "SecurityPolicyError", "JSON should include name"); + Assert.equal(json.code, "TEST_CODE", "JSON should include code"); + Assert.equal(json.policyId, "test-policy", "JSON should include policyId"); + Assert.equal(json.message, "Test reason", "JSON should include message"); + Assert.deepEqual( + json.decision, + decision, + "JSON should include full decision" + ); + + // Verify it's JSON-serializable (critical for logging/telemetry) + const serialized = JSON.stringify(json); + const parsed = JSON.parse(serialized); + Assert.equal(parsed.code, "TEST_CODE", "Should round-trip through JSON"); +}); + +/** + * Test that error can be thrown and caught (critical for control flow) + */ +add_task(async function test_error_throw_catch() { + const decision = deny("TEST_CODE", "Test reason"); + + try { + throw new SecurityPolicyError(decision); + } catch (error) { + Assert.equal( + error.name, + "SecurityPolicyError", + "Should catch as SecurityPolicyError" + ); + Assert.equal(error.code, "TEST_CODE", "Should have correct code"); + Assert.equal(error.message, "Test reason", "Should have correct message"); + } +}); + +// ============================================================================ +// Test: allow() Helper Function +// ============================================================================ + +/** + * Test that allow() returns correct structure + */ +add_task(async function test_allow_helper() { + const decision = allow(); + + Assert.equal(decision.effect, "allow", "Should have effect 'allow'"); + Assert.equal( + Object.keys(decision).length, + 1, + "Should only have 'effect' property" + ); +}); + +// ============================================================================ +// Test: deny() Helper Function +// ============================================================================ + +/** + * Test deny() with all parameters + */ +add_task(async function test_deny_helper_full() { + const decision = deny( + "TEST_CODE", + "Test reason", + { url: "https://example.com" }, + "custom-policy" + ); + + Assert.equal(decision.effect, "deny", "Should have effect 'deny'"); + Assert.equal(decision.code, "TEST_CODE", "Should have correct code"); + Assert.equal(decision.reason, "Test reason", "Should have correct reason"); + Assert.equal( + decision.policyId, + "custom-policy", + "Should have custom policyId" + ); + Assert.deepEqual( + decision.details, + { url: "https://example.com" }, + "Should have correct details" + ); +}); + +/** + * Test deny() with default policyId + */ +add_task(async function test_deny_helper_default_policy() { + const decision = deny("TEST_CODE", "Test reason"); + + Assert.equal( + decision.policyId, + "block-unseen-links", + "Should use default policyId" + ); + Assert.equal( + decision.details, + undefined, + "Details should be undefined when not provided" + ); +}); + +// ============================================================================ +// Test: isAllow() Type Guard +// ============================================================================ + +/** + * Test isAllow() returns true for allow decisions + */ +add_task(async function test_isAllow_with_allow_decision() { + const decision = allow(); + Assert.ok(isAllow(decision), "Should return true for allow decision"); +}); + +/** + * Test isAllow() returns false for deny decisions + */ +add_task(async function test_isAllow_with_deny_decision() { + const decision = deny("CODE", "reason"); + Assert.ok(!isAllow(decision), "Should return false for deny decision"); +}); + +/** + * Test isAllow() handles null/undefined/invalid gracefully + */ +add_task(async function test_isAllow_with_invalid() { + Assert.ok(!isAllow(null), "Should return false for null"); + Assert.ok(!isAllow(undefined), "Should return false for undefined"); + Assert.ok(!isAllow({}), "Should return false for empty object"); + Assert.ok( + !isAllow({ effect: "maybe" }), + "Should return false for invalid effect" + ); +}); + +// ============================================================================ +// Test: isDeny() Type Guard +// ============================================================================ + +/** + * Test isDeny() returns true for deny decisions + */ +add_task(async function test_isDeny_with_deny_decision() { + const decision = deny("CODE", "reason"); + Assert.ok(isDeny(decision), "Should return true for deny decision"); +}); + +/** + * Test isDeny() returns false for allow decisions + */ +add_task(async function test_isDeny_with_allow_decision() { + const decision = allow(); + Assert.ok(!isDeny(decision), "Should return false for allow decision"); +}); + +/** + * Test isDeny() handles null/undefined/invalid gracefully + */ +add_task(async function test_isDeny_with_invalid() { + Assert.ok(!isDeny(null), "Should return false for null"); + Assert.ok(!isDeny(undefined), "Should return false for undefined"); + Assert.ok(!isDeny({}), "Should return false for empty object"); + Assert.ok( + !isDeny({ effect: "maybe" }), + "Should return false for invalid effect" + ); +}); + +// ============================================================================ +// Test: DenialCodes Constants +// ============================================================================ + +/** + * Test that DenialCodes has expected values + */ +add_task(async function test_denial_codes_values() { + Assert.equal( + DenialCodes.UNSEEN_LINK, + "UNSEEN_LINK", + "Should have UNSEEN_LINK code" + ); + Assert.equal( + DenialCodes.MALFORMED_URL, + "MALFORMED_URL", + "Should have MALFORMED_URL code" + ); + Assert.equal( + DenialCodes.MISSING_CONTEXT, + "MISSING_CONTEXT", + "Should have MISSING_CONTEXT code" + ); + Assert.equal( + DenialCodes.POLICY_DISABLED, + "POLICY_DISABLED", + "Should have POLICY_DISABLED code" + ); +}); + +// ============================================================================ +// Test: ReasonPhrases Constants +// ============================================================================ + +/** + * Test that ReasonPhrases has expected values + */ +add_task(async function test_reason_phrases_values() { + Assert.equal( + ReasonPhrases.UNSEEN_LINK, + "URL not in selected request context", + "Should have UNSEEN_LINK phrase" + ); + Assert.equal( + ReasonPhrases.MALFORMED_URL, + "Failed to parse or normalize URL", + "Should have MALFORMED_URL phrase" + ); + Assert.equal( + ReasonPhrases.MISSING_CONTEXT, + "Missing required evaluation context", + "Should have MISSING_CONTEXT phrase" + ); + Assert.equal( + ReasonPhrases.POLICY_DISABLED, + "Policy enforcement disabled", + "Should have POLICY_DISABLED phrase" + ); +}); + +// ============================================================================ +// Test: Integration Scenarios +// ============================================================================ + +/** + * Test complete flow: deny() -> SecurityPolicyError -> toJSON() + * This validates the full error handling pipeline used in production + */ +add_task(async function test_deny_to_error_to_json() { + const decision = deny(DenialCodes.UNSEEN_LINK, ReasonPhrases.UNSEEN_LINK, { + url: "https://evil.com", + }); + + const error = new SecurityPolicyError(decision); + const json = error.toJSON(); + + Assert.equal(json.code, "UNSEEN_LINK", "Should preserve code through chain"); + Assert.equal( + json.message, + "URL not in selected request context", + "Should preserve reason through chain" + ); + Assert.equal( + json.policyId, + "block-unseen-links", + "Should have default policyId" + ); + Assert.deepEqual( + json.decision.details, + { url: "https://evil.com" }, + "Should preserve details through chain" + ); +}); + +/** + * Test that allow/deny decisions work in control flow + * This validates the pattern used throughout the codebase + */ +add_task(async function test_decision_control_flow() { + const allowDecision = allow(); + const denyDecision = deny("CODE", "reason"); + + // Simulate policy evaluation control flow + function processDecision(decision) { + if (isAllow(decision)) { + return "allowed"; + } else if (isDeny(decision)) { + return "denied"; + } + return "unknown"; + } + + Assert.equal( + processDecision(allowDecision), + "allowed", + "Should handle allow decision" + ); + Assert.equal( + processDecision(denyDecision), + "denied", + "Should handle deny decision" + ); + Assert.equal( + processDecision(null), + "unknown", + "Should handle invalid decision gracefully" + ); +}); diff --git a/toolkit/components/ml/tests/xpcshell/test_json_policy_system.js b/toolkit/components/ml/tests/xpcshell/test_json_policy_system.js new file mode 100644 index 0000000000000..93ec35e1fdb81 --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_json_policy_system.js @@ -0,0 +1,454 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Integration tests for JSON Policy System + * + * Focus: End-to-end flows with real JSON policies + * - Real policy loading from tool-execution-policies.json + * - Critical allow/deny flows + * - Integration with SecurityOrchestrator + * - @Mentions support + */ + +const { SecurityOrchestrator } = ChromeUtils.importESModule( + "chrome://global/content/ml/security/SecurityOrchestrator.sys.mjs" +); + +const PREF_SECURITY_ENABLED = "browser.smartwindow.security.enabled"; +const POLICY_JSON_URL = + "chrome://global/content/ml/security/policies/tool-execution-policies.json"; + +function setup() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); + SecurityOrchestrator.reset(); +} + +function teardown() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + SecurityOrchestrator.reset(); +} + +// ============================================================================ +// Test: JSON Policy File (Build-Time Validation) +// ============================================================================ + +add_task(async function test_json_policy_file_loads_and_validates() { + const response = await fetch(POLICY_JSON_URL); + const policyData = await response.json(); + + // File exists and parses + Assert.ok(response.ok, "Policy JSON should be accessible"); + Assert.ok(policyData.policies, "Should have policies array"); + Assert.greater( + policyData.policies.length, + 0, + "Should have at least one policy" + ); + + // First policy has required structure + const policy = policyData.policies[0]; + Assert.ok(policy.id, "Policy should have id"); + Assert.ok(policy.phase, "Policy should have phase"); + Assert.ok(policy.effect, "Policy should have effect"); + + teardown(); +}); + +add_task(async function test_orchestrator_initializes_with_policies() { + setup(); + + // If init succeeds, policies loaded correctly + const ledger = await SecurityOrchestrator.init("test-session"); + + Assert.ok(ledger, "Should initialize successfully"); + Assert.ok( + SecurityOrchestrator.getSessionLedger(), + "Should have session ledger" + ); + + // Verify policies work by testing actual evaluation + ledger.forTab("tab-1"); + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "Policies should be loaded and working (denies unseen URL)" + ); + Assert.equal( + decision.policyId, + "block-unseen-links", + "Should use JSON policy" + ); + + teardown(); +}); + +// ============================================================================ +// Test: End-to-End DENY Flow (CRITICAL SECURITY) +// ============================================================================ + +add_task(async function test_e2e_deny_unseen_link() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); // Empty ledger + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], // Not in ledger + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-deny", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "CRITICAL: Should deny unseen URL (real policy from JSON)" + ); + Assert.equal( + decision.code, + "UNSEEN_LINK", + "Should have UNSEEN_LINK code from JSON policy" + ); + Assert.equal( + decision.policyId, + "block-unseen-links", + "Should be from block-unseen-links policy" + ); + + teardown(); +}); + +add_task(async function test_e2e_deny_if_any_url_unseen() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + const tabLedger = ledger.forTab("tab-1"); + tabLedger.add("https://example.com"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: [ + "https://example.com", // OK + "https://evil.com", // NOT OK + ], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-deny-multiple", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "Should deny if ANY URL unseen (all-or-nothing security)" + ); + Assert.equal(decision.code, "UNSEEN_LINK"); + + teardown(); +}); + +add_task(async function test_e2e_deny_malformed_url() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["not-a-valid-url"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-malformed", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "Should deny malformed URL (fail-closed)" + ); + // Malformed URLs are treated as unseen (not in ledger) rather than + // caught as specifically malformed + Assert.equal(decision.code, "UNSEEN_LINK"); + + teardown(); +}); + +// ============================================================================ +// Test: End-to-End ALLOW Flow (CRITICAL FUNCTIONALITY) +// ============================================================================ + +add_task(async function test_e2e_allow_seeded_url() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + const tabLedger = ledger.forTab("tab-1"); + tabLedger.add("https://example.com"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://example.com"], // In ledger + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-allow", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "CRITICAL: Should allow seeded URL (real policy from JSON)" + ); + + teardown(); +}); + +add_task(async function test_e2e_allow_multiple_seeded_urls() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + const tabLedger = ledger.forTab("tab-1"); + tabLedger.add("https://example.com"); + tabLedger.add("https://mozilla.org"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://example.com", "https://mozilla.org"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-allow-multiple", + }, + }); + + Assert.equal(decision.effect, "allow", "Should allow when all URLs seeded"); + + teardown(); +}); + +add_task(async function test_e2e_allow_empty_urls() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: [], // No URLs to check + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-empty", + }, + }); + + Assert.equal(decision.effect, "allow", "Should allow when no URLs to check"); + + teardown(); +}); + +// ============================================================================ +// Test: @Mentions Integration (CRITICAL FEATURE) +// ============================================================================ + +add_task(async function test_e2e_allow_url_from_mentioned_tab() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + + // Current tab + ledger.forTab("tab-1").add("https://example.com"); + + // Mentioned tab (different URL) + ledger.forTab("tab-2").add("https://mozilla.org"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://mozilla.org"], // From @mentioned tab + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: ["tab-2"], // @mention tab-2 + requestId: "test-mention-allow", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Should allow URL from @mentioned tab (merged ledger)" + ); + + teardown(); +}); + +add_task(async function test_e2e_deny_url_not_in_mentioned_tabs() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + + ledger.forTab("tab-1").add("https://example.com"); + ledger.forTab("tab-2").add("https://mozilla.org"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], // Not in tab-1 or tab-2 + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: ["tab-2"], + requestId: "test-mention-deny", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "Should deny URL not in current or @mentioned tabs" + ); + + teardown(); +}); + +// ============================================================================ +// Test: URL Normalization +// ============================================================================ + +add_task(async function test_e2e_url_normalization_strips_fragments() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com/page"); // No fragment + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://example.com/page#section"], // Has fragment + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-normalize", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Should allow after normalizing (fragments stripped)" + ); + + teardown(); +}); + +// ============================================================================ +// Test: Kill Switch Integration +// ============================================================================ + +add_task(async function test_e2e_kill_switch_bypasses_policies() { + setup(); + + // Disable security + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, false); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); // Empty ledger + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], // Unseen, but kill switch is off + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-killswitch", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Kill switch OFF: should bypass all policies (allow everything)" + ); + + teardown(); +}); diff --git a/toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js b/toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js new file mode 100644 index 0000000000000..45197f38ae26f --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js @@ -0,0 +1,321 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for PolicyEvaluator.sys.mjs + * + * Note: PolicyEvaluator is used internally by SecurityOrchestrator. + * These tests verify policy evaluation behavior through the public API + * rather than testing internal implementation details. + * + * Focus: Policy matching, deny/allow effects, multiple conditions + */ + +const { SecurityOrchestrator } = ChromeUtils.importESModule( + "chrome://global/content/ml/security/SecurityOrchestrator.sys.mjs" +); + +const PREF_SECURITY_ENABLED = "browser.smartwindow.security.enabled"; + +function setup() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); + SecurityOrchestrator.reset(); +} + +function teardown() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + SecurityOrchestrator.reset(); +} + +// ============================================================================ +// Test: Policy Matching Behavior +// ============================================================================ + +add_task(async function test_policy_matches_correct_phase() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + // tool.execution phase should match our policies + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "Policy should match tool.execution phase" + ); + Assert.equal(decision.policyId, "block-unseen-links"); + + teardown(); +}); + +add_task(async function test_policy_ignores_unknown_phase() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + // Unknown phase should not match any policies + const decision = await SecurityOrchestrator.evaluate({ + phase: "unknown.phase", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Unknown phase should not match policies (allow by default)" + ); + + teardown(); +}); + +// ============================================================================ +// Test: Deny Effect (Core Security Logic) +// ============================================================================ + +add_task(async function test_deny_policy_denies_when_condition_fails() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com"); + + // URL not in ledger = condition fails = deny + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], // Not in ledger + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal(decision.effect, "deny", "Should deny when condition fails"); + Assert.equal(decision.code, "UNSEEN_LINK"); + Assert.ok(decision.reason, "Should have reason"); + Assert.equal(decision.policyId, "block-unseen-links"); + Assert.ok(decision.details, "Should include failure details"); + + teardown(); +}); + +add_task( + async function test_deny_policy_passes_through_when_condition_passes() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com"); + + // URL in ledger = condition passes = policy doesn't apply (allow) + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://example.com"], // In ledger + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Should allow when deny policy condition passes (policy doesn't apply)" + ); + + teardown(); + } +); + +// ============================================================================ +// Test: Multiple URLs (All-or-Nothing) +// ============================================================================ + +add_task(async function test_policy_checks_all_urls() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com"); + // Not adding evil.com + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: [ + "https://example.com", // OK + "https://evil.com", // NOT OK + ], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "Should deny if ANY URL fails condition (all-or-nothing)" + ); + + teardown(); +}); + +add_task(async function test_policy_allows_when_all_urls_valid() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + const tabLedger = ledger.forTab("tab-1"); + tabLedger.add("https://example.com"); + tabLedger.add("https://mozilla.org"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://example.com", "https://mozilla.org"], // Both OK + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Should allow when all URLs pass condition" + ); + + teardown(); +}); + +// ============================================================================ +// Test: Tool Matching (Wildcard Support) +// ============================================================================ + +add_task(async function test_policy_applies_to_get_page_content() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + // Verify policy applies to get_page_content (the main URL-fetching tool) + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "Policy should apply to get_page_content" + ); + + teardown(); +}); + +// ============================================================================ +// Test: Policy Information in Response +// ============================================================================ + +add_task(async function test_deny_decision_includes_policy_info() { + setup(); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test", + }, + }); + + // Verify decision structure + Assert.equal(decision.effect, "deny", "Should have effect"); + Assert.equal(decision.code, "UNSEEN_LINK", "Should have code"); + Assert.ok(decision.reason, "Should have reason"); + Assert.equal( + decision.policyId, + "block-unseen-links", + "Should identify policy" + ); + Assert.ok(decision.details, "Should have details"); + Assert.ok( + decision.details.failedCondition, + "Should identify failed condition" + ); + + teardown(); +}); diff --git a/toolkit/components/ml/tests/xpcshell/test_security_orchestrator.js b/toolkit/components/ml/tests/xpcshell/test_security_orchestrator.js new file mode 100644 index 0000000000000..d5fc07e7ff3c1 --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_security_orchestrator.js @@ -0,0 +1,325 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Unit tests for SecurityOrchestrator (JSON Policy System) + * + * Focus: Critical security boundaries and core functionality + * - Kill switch behavior (security on/off) + * - Policy execution (allow/deny with real policies) + * - Envelope validation (security boundary) + * - Error handling (fail-closed) + */ + +const { SecurityOrchestrator } = ChromeUtils.importESModule( + "chrome://global/content/ml/security/SecurityOrchestrator.sys.mjs" +); + +const PREF_SECURITY_ENABLED = "browser.smartwindow.security.enabled"; + +function setup() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + SecurityOrchestrator.reset(); +} + +function teardown() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + SecurityOrchestrator.reset(); +} + +// ============================================================================= +// Initialization Tests +// ============================================================================= + +add_task(async function test_initialization_creates_session() { + setup(); + + const ledger = await SecurityOrchestrator.init("test-session"); + + Assert.ok(ledger, "Should return session ledger"); + Assert.equal(ledger.tabCount(), 0, "Should start with no tabs"); + Assert.ok( + SecurityOrchestrator.getSessionLedger(), + "Should be able to get session ledger" + ); + + teardown(); +}); + +// ============================================================================= +// Kill Switch Tests (CRITICAL SECURITY) +// ============================================================================= + +add_task(async function test_kill_switch_disabled_allows_everything() { + setup(); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, false); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); // Empty ledger + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], // Unseen URL + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-123", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Kill switch OFF: should allow everything (pass-through)" + ); + + teardown(); +}); + +add_task(async function test_kill_switch_enabled_enforces_policies() { + setup(); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); + + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-123", + }, + }); + + Assert.equal(decision.effect, "deny", "Kill switch ON: should enforce"); + Assert.equal(decision.code, "UNSEEN_LINK", "Should deny unseen links"); + + teardown(); +}); + +add_task(async function test_kill_switch_runtime_change() { + setup(); + + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); + await SecurityOrchestrator.init("test-session"); + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const envelope = { + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "req-1", + }, + }; + + // Should deny when enabled + let decision = await SecurityOrchestrator.evaluate(envelope); + Assert.equal(decision.effect, "deny", "Should deny when enabled"); + + // Disable at runtime + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, false); + + // Should allow immediately + decision = await SecurityOrchestrator.evaluate(envelope); + Assert.equal( + decision.effect, + "allow", + "Should allow immediately after runtime disable" + ); + + teardown(); +}); + +// ============================================================================= +// Envelope Validation Tests (SECURITY BOUNDARY) +// ============================================================================= + +add_task(async function test_invalid_envelope_fails_closed() { + setup(); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); + await SecurityOrchestrator.init("test-session"); + + const invalidEnvelopes = [ + null, + { action: { type: "test" }, context: {} }, // missing phase + { phase: "test", context: {} }, // missing action + { phase: "test", action: { type: "test" } }, // missing context + ]; + + for (const envelope of invalidEnvelopes) { + const decision = await SecurityOrchestrator.evaluate(envelope); + Assert.equal( + decision.effect, + "deny", + "Invalid envelope should fail closed (deny)" + ); + Assert.equal(decision.code, "INVALID_REQUEST", "Should have correct code"); + } + + teardown(); +}); + +// ============================================================================= +// Policy Execution Tests +// ============================================================================= + +add_task(async function test_policy_allows_seeded_url() { + setup(); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); + await SecurityOrchestrator.init("test-session"); + + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://example.com"], // In ledger + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-123", + }, + }); + + Assert.equal(decision.effect, "allow", "Should allow seeded URL"); + + teardown(); +}); + +add_task(async function test_policy_denies_unseen_url() { + setup(); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); + await SecurityOrchestrator.init("test-session"); + + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); // Empty ledger + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["https://evil.com"], // Not in ledger + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-123", + }, + }); + + Assert.equal(decision.effect, "deny", "Should deny unseen URL"); + Assert.equal(decision.code, "UNSEEN_LINK", "Should have UNSEEN_LINK code"); + Assert.ok(decision.reason, "Should have reason"); + Assert.equal( + decision.policyId, + "block-unseen-links", + "Should identify policy" + ); + + teardown(); +}); + +add_task(async function test_policy_denies_if_any_url_unseen() { + setup(); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); + await SecurityOrchestrator.init("test-session"); + + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: [ + "https://example.com", // OK + "https://evil.com", // NOT OK + ], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-123", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "Should deny if ANY URL unseen (all-or-nothing)" + ); + + teardown(); +}); + +// ============================================================================= +// Error Handling (FAIL-CLOSED) +// ============================================================================= + +add_task(async function test_malformed_url_fails_closed() { + setup(); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); + await SecurityOrchestrator.init("test-session"); + + const ledger = SecurityOrchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await SecurityOrchestrator.evaluate({ + phase: "tool.execution", + action: { + type: "tool.call", + tool: "get_page_content", + urls: ["not-a-valid-url"], + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-123", + }, + }); + + Assert.equal( + decision.effect, + "deny", + "Malformed URL should fail closed (deny)" + ); + // Malformed URLs are treated as unseen (not in ledger) rather than + // caught as specifically malformed + Assert.equal(decision.code, "UNSEEN_LINK", "Should have UNSEEN_LINK code"); + + teardown(); +}); diff --git a/toolkit/components/ml/tests/xpcshell/test_security_utils.js b/toolkit/components/ml/tests/xpcshell/test_security_utils.js new file mode 100644 index 0000000000000..9fa87c4c215cf --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_security_utils.js @@ -0,0 +1,568 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for SecurityUtils.sys.mjs + * + * Tests URL normalization, eTLD validation, and ledger management: + * - normalizeUrl() - URL validation and normalization + * - isSameETLDPlusOne() - eTLD+1 validation + * - TabLedger - per-tab URL storage with TTL + * - SessionLedger - multi-tab ledger management + * + * Focus: Critical paths and edge cases that affect security + */ + +const { normalizeUrl, isSameETLDPlusOne, TabLedger, SessionLedger } = + ChromeUtils.importESModule( + "chrome://global/content/ml/security/SecurityUtils.sys.mjs" + ); + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +// ============================================================================ +// Test: normalizeUrl() - Success Cases +// ============================================================================ + +/** + * Test that valid HTTP URLs normalize successfully + */ +add_task(async function test_normalizeUrl_valid_http() { + const result = normalizeUrl("http://example.com/page"); + + Assert.ok(result.success, "Should succeed for valid HTTP URL"); + Assert.ok(result.url, "Should return normalized URL"); + Assert.ok(result.url.startsWith("http://"), "Should preserve http scheme"); +}); + +/** + * Test that valid HTTPS URLs normalize successfully + */ +add_task(async function test_normalizeUrl_valid_https() { + const result = normalizeUrl("https://example.com/page"); + + Assert.ok(result.success, "Should succeed for valid HTTPS URL"); + Assert.ok(result.url, "Should return normalized URL"); + Assert.ok(result.url.startsWith("https://"), "Should preserve https scheme"); +}); + +/** + * Test that URLs with query parameters normalize successfully + */ +add_task(async function test_normalizeUrl_with_query_params() { + const result = normalizeUrl("https://example.com/page?foo=bar&baz=qux"); + + Assert.ok(result.success, "Should succeed for URL with query params"); + Assert.ok(result.url.includes("?"), "Should preserve query parameters"); +}); + +// ============================================================================ +// Test: normalizeUrl() - Failure Cases (Fail-Closed) +// ============================================================================ + +/** + * Test that empty string fails + */ +add_task(async function test_normalizeUrl_empty_string() { + const result = normalizeUrl(""); + + Assert.ok(!result.success, "Should fail for empty string"); + Assert.ok(result.error, "Should return error"); +}); + +/** + * Test that whitespace-only string fails + */ +add_task(async function test_normalizeUrl_whitespace() { + const result = normalizeUrl(" "); + + Assert.ok(!result.success, "Should fail for whitespace-only string"); + Assert.ok(result.error, "Should return error"); +}); + +/** + * Test that invalid URL format fails + */ +add_task(async function test_normalizeUrl_invalid_format() { + const result = normalizeUrl("not-a-valid-url"); + + Assert.ok(!result.success, "Should fail for invalid URL format"); + Assert.ok(result.error, "Should return error"); +}); + +/** + * Test that non-http/https schemes fail + */ +add_task(async function test_normalizeUrl_non_http_scheme() { + const schemes = ["ftp://example.com", "file:///path", "javascript:alert(1)"]; + + for (const url of schemes) { + const result = normalizeUrl(url); + Assert.ok(!result.success, `Should fail for scheme: ${url}`); + Assert.ok(result.error, "Should return error"); + } +}); + +/** + * Test that null/undefined fail gracefully + */ +add_task(async function test_normalizeUrl_null_undefined() { + const resultNull = normalizeUrl(null); + const resultUndefined = normalizeUrl(undefined); + + Assert.ok(!resultNull.success, "Should fail for null"); + Assert.ok(!resultUndefined.success, "Should fail for undefined"); +}); + +// ============================================================================ +// Test: normalizeUrl() - Normalization Behavior +// ============================================================================ + +/** + * Test that fragments are removed + */ +add_task(async function test_normalizeUrl_strips_fragments() { + const result = normalizeUrl("https://example.com/page#section"); + + Assert.ok(result.success, "Should succeed"); + Assert.ok(!result.url.includes("#"), "Should strip fragment"); +}); + +/** + * Test that tracking parameters are removed + */ +add_task(async function test_normalizeUrl_strips_tracking() { + const result = normalizeUrl( + "https://example.com/page?utm_source=test&foo=bar" + ); + + Assert.ok(result.success, "Should succeed"); + Assert.ok(!result.url.includes("utm_"), "Should strip utm parameters"); + Assert.ok( + result.url.includes("foo=bar"), + "Should preserve non-tracking params" + ); +}); + +/** + * Test that relative URLs work with baseUrl + */ +add_task(async function test_normalizeUrl_relative_with_base() { + const result = normalizeUrl("/page", "https://example.com"); + + Assert.ok(result.success, "Should succeed with baseUrl"); + Assert.ok( + result.url.includes("example.com/page"), + "Should resolve relative URL" + ); +}); + +// ============================================================================ +// Test: isSameETLDPlusOne() - Same eTLD+1 +// ============================================================================ + +/** + * Test that same domain returns true + */ +add_task(async function test_isSameETLDPlusOne_same_domain() { + const result = isSameETLDPlusOne( + "https://example.com", + "https://example.com" + ); + + Assert.ok(result, "Should return true for same domain"); +}); + +/** + * Test that subdomain and apex domain return true + */ +add_task(async function test_isSameETLDPlusOne_subdomain() { + const result = isSameETLDPlusOne( + "https://www.example.com", + "https://example.com" + ); + + Assert.ok(result, "Should return true for subdomain vs apex"); +}); + +/** + * Test that different subdomains of same eTLD+1 return true + */ +add_task(async function test_isSameETLDPlusOne_different_subdomains() { + const result = isSameETLDPlusOne( + "https://blog.example.com", + "https://shop.example.com" + ); + + Assert.ok(result, "Should return true for different subdomains"); +}); + +// ============================================================================ +// Test: isSameETLDPlusOne() - Different eTLD+1 +// ============================================================================ + +/** + * Test that different domains return false + */ +add_task(async function test_isSameETLDPlusOne_different_domains() { + const result = isSameETLDPlusOne("https://example.com", "https://evil.com"); + + Assert.ok(!result, "Should return false for different domains"); +}); + +/** + * Test that subdomain injection attempt returns false + */ +add_task(async function test_isSameETLDPlusOne_injection_attempt() { + const result = isSameETLDPlusOne( + "https://example.com", + "https://example.com.evil.com" + ); + + Assert.ok(!result, "Should return false for subdomain injection attempt"); +}); + +// ============================================================================ +// Test: isSameETLDPlusOne() - Error Handling +// ============================================================================ + +/** + * Test that invalid URLs return false (fail-closed) + */ +add_task(async function test_isSameETLDPlusOne_invalid_urls() { + const result = isSameETLDPlusOne("not-a-url", "https://example.com"); + + Assert.ok(!result, "Should return false for invalid URL (fail-closed)"); +}); + +// ============================================================================ +// Test: TabLedger - Basic Operations +// ============================================================================ + +/** + * Test that TabLedger can be created + */ +add_task(async function test_TabLedger_creation() { + const ledger = new TabLedger("tab-123"); + + Assert.ok(ledger, "Should create ledger"); + Assert.equal(ledger.tabId, "tab-123", "Should store tab ID"); + Assert.equal(ledger.size(), 0, "Should start empty"); +}); + +/** + * Test that seed() adds URLs to ledger + */ +add_task(async function test_TabLedger_seed() { + const ledger = new TabLedger("tab-123"); + const urls = ["https://example.com", "https://example.com/page"]; + + ledger.seed(urls); + + Assert.ok(ledger.has("https://example.com"), "Should contain first URL"); + Assert.ok( + ledger.has("https://example.com/page"), + "Should contain second URL" + ); + Assert.equal(ledger.size(), 2, "Should have correct size"); +}); + +/** + * Test that add() adds individual URLs + */ +add_task(async function test_TabLedger_add() { + const ledger = new TabLedger("tab-123"); + + ledger.add("https://example.com"); + + Assert.ok(ledger.has("https://example.com"), "Should contain added URL"); + Assert.equal(ledger.size(), 1, "Should have size 1"); +}); + +/** + * Test that has() returns false for URLs not in ledger + */ +add_task(async function test_TabLedger_has_missing() { + const ledger = new TabLedger("tab-123"); + ledger.add("https://example.com"); + + Assert.ok( + !ledger.has("https://evil.com"), + "Should return false for missing URL" + ); +}); + +/** + * Test that clear() empties the ledger + */ +add_task(async function test_TabLedger_clear() { + const ledger = new TabLedger("tab-123"); + ledger.seed(["https://example.com", "https://example.com/page"]); + + ledger.clear(); + + Assert.equal(ledger.size(), 0, "Should be empty after clear"); + Assert.ok( + !ledger.has("https://example.com"), + "Should not contain URLs after clear" + ); +}); + +// ============================================================================ +// Test: TabLedger - TTL Expiration +// ============================================================================ + +/** + * Test that URLs expire after TTL + */ +add_task(async function test_TabLedger_expiration() { + const shortTTL = 100; // 100ms + const ledger = new TabLedger("tab-123", shortTTL); + + ledger.add("https://example.com"); + Assert.ok(ledger.has("https://example.com"), "Should have URL initially"); + + // Wait for expiration + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, shortTTL + 50)); + + Assert.ok( + !ledger.has("https://example.com"), + "Should not have URL after TTL" + ); +}); + +// ============================================================================ +// Test: TabLedger - Size Limits +// ============================================================================ + +/** + * Test that ledger enforces size limit + */ +add_task(async function test_TabLedger_size_limit() { + const maxUrls = 5; + const ledger = new TabLedger("tab-123", 60000, maxUrls); + + // Try to add more than max + for (let i = 0; i < maxUrls + 2; i++) { + ledger.add(`https://example.com/page${i}`); + } + + Assert.lessOrEqual(ledger.size(), maxUrls, "Should not exceed max size"); +}); + +// ============================================================================ +// Test: TabLedger - Invalid URLs +// ============================================================================ + +/** + * Test that invalid URLs are rejected gracefully + */ +add_task(async function test_TabLedger_invalid_urls() { + const ledger = new TabLedger("tab-123"); + + ledger.add("not-a-url"); + ledger.add(""); + ledger.add(null); + + Assert.equal(ledger.size(), 0, "Should not add invalid URLs"); +}); + +// ============================================================================ +// Test: SessionLedger - Basic Operations +// ============================================================================ + +/** + * Test that SessionLedger can be created + */ +add_task(async function test_SessionLedger_creation() { + const session = new SessionLedger("session-123"); + + Assert.ok(session, "Should create session ledger"); + Assert.equal(session.sessionId, "session-123", "Should store session ID"); + Assert.equal(session.tabCount(), 0, "Should start with no tabs"); +}); + +/** + * Test that forTab() creates and retrieves tab ledgers + */ +add_task(async function test_SessionLedger_forTab() { + const session = new SessionLedger("session-123"); + + const ledger1 = session.forTab("tab-1"); + const ledger2 = session.forTab("tab-1"); // Same tab + + Assert.ok(ledger1, "Should create ledger for tab-1"); + Assert.equal(ledger1, ledger2, "Should return same ledger for same tab"); + Assert.equal(session.tabCount(), 1, "Should have 1 tab"); +}); + +/** + * Test that different tabs get different ledgers + */ +add_task(async function test_SessionLedger_multiple_tabs() { + const session = new SessionLedger("session-123"); + + const ledger1 = session.forTab("tab-1"); + const ledger2 = session.forTab("tab-2"); + + Assert.notEqual( + ledger1, + ledger2, + "Different tabs should have different ledgers" + ); + Assert.equal(session.tabCount(), 2, "Should have 2 tabs"); +}); + +// ============================================================================ +// Test: SessionLedger - Merge Operations +// ============================================================================ + +/** + * Test that merge() combines URLs from multiple tabs + */ +add_task(async function test_SessionLedger_merge() { + const session = new SessionLedger("session-123"); + + const ledger1 = session.forTab("tab-1"); + const ledger2 = session.forTab("tab-2"); + + ledger1.add("https://example.com/page1"); + ledger2.add("https://example.com/page2"); + + const merged = session.merge(["tab-1", "tab-2"]); + + Assert.ok( + merged.has("https://example.com/page1"), + "Should have URL from tab-1" + ); + Assert.ok( + merged.has("https://example.com/page2"), + "Should have URL from tab-2" + ); + Assert.equal(merged.size(), 2, "Should have 2 URLs"); +}); + +/** + * Test that buildRequestScope() works correctly + */ +add_task(async function test_SessionLedger_buildRequestScope() { + const session = new SessionLedger("session-123"); + + const ledger1 = session.forTab("tab-1"); + ledger1.add("https://example.com/current"); + + const ledger2 = session.forTab("tab-2"); + ledger2.add("https://example.com/mentioned"); + + // Current tab only + const scopeCurrentOnly = session.buildRequestScope({ + currentTabId: "tab-1", + }); + + Assert.ok( + scopeCurrentOnly.has("https://example.com/current"), + "Should have current tab URL" + ); + Assert.ok( + !scopeCurrentOnly.has("https://example.com/mentioned"), + "Should not have mentioned tab URL when not specified" + ); + + // Current + mentioned tabs + const scopeWithMentions = session.buildRequestScope({ + currentTabId: "tab-1", + mentionedTabIds: ["tab-2"], + }); + + Assert.ok( + scopeWithMentions.has("https://example.com/current"), + "Should have current tab URL" + ); + Assert.ok( + scopeWithMentions.has("https://example.com/mentioned"), + "Should have mentioned tab URL" + ); +}); + +// ============================================================================ +// Test: SessionLedger - Tab Lifecycle +// ============================================================================ + +/** + * Test that clearTab() clears a specific tab's ledger + */ +add_task(async function test_SessionLedger_clearTab() { + const session = new SessionLedger("session-123"); + + const ledger1 = session.forTab("tab-1"); + const ledger2 = session.forTab("tab-2"); + + ledger1.add("https://example.com/page1"); + ledger2.add("https://example.com/page2"); + + session.clearTab("tab-1"); + + Assert.equal(ledger1.size(), 0, "tab-1 should be empty"); + Assert.equal(ledger2.size(), 1, "tab-2 should still have URL"); + Assert.equal(session.tabCount(), 2, "Should still track both tabs"); +}); + +/** + * Test that removeTab() removes a tab's ledger + */ +add_task(async function test_SessionLedger_removeTab() { + const session = new SessionLedger("session-123"); + + session.forTab("tab-1").add("https://example.com"); + session.forTab("tab-2").add("https://example.com"); + + session.removeTab("tab-1"); + + Assert.equal(session.tabCount(), 1, "Should have 1 tab after removal"); + + // Getting the tab again should create a new empty ledger + const newLedger = session.forTab("tab-1"); + Assert.equal( + newLedger.size(), + 0, + "New ledger for removed tab should be empty" + ); +}); + +/** + * Test that clearAll() clears all tab ledgers + */ +add_task(async function test_SessionLedger_clearAll() { + const session = new SessionLedger("session-123"); + + session.forTab("tab-1").add("https://example.com"); + session.forTab("tab-2").add("https://example.com"); + + session.clearAll(); + + Assert.equal(session.tabCount(), 0, "Should have no tabs after clearAll"); +}); + +// ============================================================================ +// Test: Edge Cases - URL Normalization in Ledgers +// ============================================================================ + +/** + * Test that ledgers normalize URLs consistently + */ +add_task(async function test_ledger_normalizes_urls() { + const ledger = new TabLedger("tab-123"); + + // Add URL with fragment + ledger.add("https://example.com/page#section"); + + // Check without fragment (should still match after normalization) + Assert.ok( + ledger.has("https://example.com/page"), + "Should match normalized URL without fragment" + ); +}); diff --git a/toolkit/components/ml/tests/xpcshell/test_smart_window_integration.js b/toolkit/components/ml/tests/xpcshell/test_smart_window_integration.js new file mode 100644 index 0000000000000..c87dbcdbf89b3 --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_smart_window_integration.js @@ -0,0 +1,407 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for SmartWindowIntegration.sys.mjs + * + * Tests the Smart Window security layer integration: + * - initSecurityLayer() - initialization + * - getSessionLedger() - retrieval + * - resetSecurityLayer() - cleanup + * - handleTabClose() - tab lifecycle + * - getRequestContext() - context building + * - getSecurityLayerStats() - stats retrieval + * + * Note: seedCurrentTab() and handleNavigation() require browser/actor context + * and are tested in integration tests (Step 8) + * + * Focus: Lifecycle management and context building + */ + +const { + initSecurityLayer, + getSessionLedger, + resetSecurityLayer, + handleTabClose, + getRequestContext, + getSecurityLayerStats, +} = ChromeUtils.importESModule( + "chrome://global/content/ml/security/SmartWindowIntegration.sys.mjs" +); + +// ============================================================================ +// Test Setup/Teardown +// ============================================================================ + +/** + * Reset security layer before each test to ensure clean state + */ +add_setup(function () { + resetSecurityLayer(); +}); + +// ============================================================================ +// Test: initSecurityLayer() +// ============================================================================ + +/** + * Test that initSecurityLayer creates a new session + */ +add_task(async function test_initSecurityLayer_creates_session() { + const session = initSecurityLayer(); + + Assert.ok(session, "Should return SessionLedger"); + Assert.ok(session.sessionId, "Should have session ID"); + Assert.equal(session.tabCount(), 0, "Should start with no tabs"); +}); + +/** + * Test that initSecurityLayer accepts custom session ID + */ +add_task(async function test_initSecurityLayer_custom_id() { + const customId = "test-session-123"; + const session = initSecurityLayer(customId); + + Assert.equal(session.sessionId, customId, "Should use custom session ID"); +}); + +/** + * Test that initSecurityLayer auto-generates ID when not provided + */ +add_task(async function test_initSecurityLayer_auto_id() { + const session = initSecurityLayer(); + + Assert.ok(session.sessionId, "Should have auto-generated session ID"); + Assert.ok( + session.sessionId.startsWith("smart-window-"), + "Should use smart-window prefix" + ); +}); + +// ============================================================================ +// Test: getSessionLedger() +// ============================================================================ + +/** + * Test that getSessionLedger returns initialized session + */ +add_task(async function test_getSessionLedger_returns_session() { + const session1 = initSecurityLayer("test-session"); + const session2 = getSessionLedger(); + + Assert.equal(session1, session2, "Should return same session instance"); +}); + +/** + * Test that getSessionLedger throws when not initialized + */ +add_task(async function test_getSessionLedger_throws_when_not_initialized() { + resetSecurityLayer(); // Ensure not initialized + + Assert.throws( + () => getSessionLedger(), + /not initialized/, + "Should throw when not initialized" + ); +}); + +// ============================================================================ +// Test: resetSecurityLayer() +// ============================================================================ + +/** + * Test that resetSecurityLayer clears the session + */ +add_task(async function test_resetSecurityLayer_clears_session() { + initSecurityLayer("test-session"); + const session = getSessionLedger(); + + // Add some data + session.forTab("tab-1").add("https://example.com"); + Assert.equal(session.tabCount(), 1, "Should have tab before reset"); + + resetSecurityLayer(); + + Assert.throws( + () => getSessionLedger(), + /not initialized/, + "Should throw after reset" + ); +}); + +/** + * Test that resetSecurityLayer can be called multiple times safely + */ +add_task(async function test_resetSecurityLayer_idempotent() { + initSecurityLayer("test-session"); + + resetSecurityLayer(); + resetSecurityLayer(); // Should not throw + + Assert.ok(true, "Multiple resets should not throw"); +}); + +/** + * Test that resetSecurityLayer works when not initialized + */ +add_task(async function test_resetSecurityLayer_when_not_initialized() { + // Don't initialize, just reset + resetSecurityLayer(); + + Assert.ok(true, "Reset when not initialized should not throw"); +}); + +// ============================================================================ +// Test: handleTabClose() +// ============================================================================ + +/** + * Test that handleTabClose removes tab ledger + */ +add_task(async function test_handleTabClose_removes_tab() { + initSecurityLayer("test-session"); + const session = getSessionLedger(); + + // Add two tabs + session.forTab("tab-1").add("https://example.com"); + session.forTab("tab-2").add("https://example.com"); + Assert.equal(session.tabCount(), 2, "Should have 2 tabs"); + + handleTabClose("tab-1"); + + Assert.equal(session.tabCount(), 1, "Should have 1 tab after close"); +}); + +/** + * Test that handleTabClose works for non-existent tab + */ +add_task(async function test_handleTabClose_nonexistent_tab() { + initSecurityLayer("test-session"); + + // Should not throw for non-existent tab + handleTabClose("non-existent-tab"); + + Assert.ok(true, "Closing non-existent tab should not throw"); +}); + +// ============================================================================ +// Test: getRequestContext() +// ============================================================================ + +/** + * Test that getRequestContext builds context with current tab only + */ +add_task(async function test_getRequestContext_current_tab_only() { + initSecurityLayer("test-session"); + const session = getSessionLedger(); + + const ledger = session.forTab("tab-1"); + ledger.add("https://example.com/current"); + + const context = getRequestContext("tab-1"); + + Assert.ok(context.linkLedger, "Should have linkLedger"); + Assert.ok( + context.linkLedger.has("https://example.com/current"), + "Should have current tab URL" + ); + Assert.ok(context.sessionId, "Should have sessionId"); + Assert.ok(context.requestId, "Should have requestId"); + Assert.equal(context.currentTabId, "tab-1", "Should have currentTabId"); +}); + +/** + * Test that getRequestContext includes mentioned tabs + */ +add_task(async function test_getRequestContext_with_mentions() { + initSecurityLayer("test-session"); + const session = getSessionLedger(); + + session.forTab("tab-1").add("https://example.com/current"); + session.forTab("tab-2").add("https://example.com/mentioned1"); + session.forTab("tab-3").add("https://example.com/mentioned2"); + + const context = getRequestContext("tab-1", ["tab-2", "tab-3"]); + + Assert.ok( + context.linkLedger.has("https://example.com/current"), + "Should have current tab URL" + ); + Assert.ok( + context.linkLedger.has("https://example.com/mentioned1"), + "Should have first mentioned tab URL" + ); + Assert.ok( + context.linkLedger.has("https://example.com/mentioned2"), + "Should have second mentioned tab URL" + ); + Assert.deepEqual( + context.mentionedTabIds, + ["tab-2", "tab-3"], + "Should include mentionedTabIds" + ); +}); + +/** + * Test that getRequestContext auto-generates request ID + */ +add_task(async function test_getRequestContext_auto_request_id() { + initSecurityLayer("test-session"); + getSessionLedger().forTab("tab-1"); + + const context = getRequestContext("tab-1"); + + Assert.ok(context.requestId, "Should have auto-generated requestId"); + Assert.ok(context.requestId.startsWith("req-"), "Should use req- prefix"); +}); + +/** + * Test that getRequestContext accepts custom request ID + */ +add_task(async function test_getRequestContext_custom_request_id() { + initSecurityLayer("test-session"); + getSessionLedger().forTab("tab-1"); + + const customId = "custom-request-123"; + const context = getRequestContext("tab-1", [], customId); + + Assert.equal(context.requestId, customId, "Should use custom requestId"); +}); + +/** + * Test that getRequestContext handles empty mentioned tabs + */ +add_task(async function test_getRequestContext_empty_mentions() { + initSecurityLayer("test-session"); + const session = getSessionLedger(); + + session.forTab("tab-1").add("https://example.com"); + + const context = getRequestContext("tab-1", []); + + Assert.ok(context.linkLedger, "Should have linkLedger"); + Assert.deepEqual( + context.mentionedTabIds, + [], + "Should have empty mentionedTabIds" + ); +}); + +// ============================================================================ +// Test: getSecurityLayerStats() +// ============================================================================ + +/** + * Test that getSecurityLayerStats returns correct structure + */ +add_task(async function test_getSecurityLayerStats_structure() { + initSecurityLayer("test-session-stats"); + const session = getSessionLedger(); + + session.forTab("tab-1").add("https://example.com/page1"); + session.forTab("tab-2").add("https://example.com/page2"); + session.forTab("tab-2").add("https://example.com/page3"); + + const stats = getSecurityLayerStats(); + + Assert.equal( + stats.sessionId, + "test-session-stats", + "Should have correct sessionId" + ); + Assert.equal(stats.tabCount, 2, "Should have correct tabCount"); + Assert.equal(stats.totalUrls, 3, "Should have correct totalUrls"); + Assert.ok(Array.isArray(stats.tabIds), "Should have tabIds array"); + Assert.ok(Array.isArray(stats.tabStats), "Should have tabStats array"); + Assert.equal(stats.tabIds.length, 2, "Should have 2 tab IDs"); + Assert.equal(stats.tabStats.length, 2, "Should have 2 tab stats"); +}); + +/** + * Test that getSecurityLayerStats includes per-tab breakdown + */ +add_task(async function test_getSecurityLayerStats_per_tab() { + initSecurityLayer("test-session"); + const session = getSessionLedger(); + + session.forTab("tab-1").add("https://example.com/page1"); + session.forTab("tab-2").add("https://example.com/page2"); + session.forTab("tab-2").add("https://example.com/page3"); + + const stats = getSecurityLayerStats(); + + // Find stats for each tab + const tab1Stats = stats.tabStats.find(s => s.tabId === "tab-1"); + const tab2Stats = stats.tabStats.find(s => s.tabId === "tab-2"); + + Assert.ok(tab1Stats, "Should have stats for tab-1"); + Assert.ok(tab2Stats, "Should have stats for tab-2"); + Assert.equal(tab1Stats.urlCount, 1, "tab-1 should have 1 URL"); + Assert.equal(tab2Stats.urlCount, 2, "tab-2 should have 2 URLs"); +}); + +/** + * Test that getSecurityLayerStats works with empty session + */ +add_task(async function test_getSecurityLayerStats_empty_session() { + initSecurityLayer("test-session"); + + const stats = getSecurityLayerStats(); + + Assert.equal(stats.tabCount, 0, "Should have 0 tabs"); + Assert.equal(stats.totalUrls, 0, "Should have 0 URLs"); + Assert.equal(stats.tabIds.length, 0, "Should have empty tabIds array"); + Assert.equal(stats.tabStats.length, 0, "Should have empty tabStats array"); +}); + +// ============================================================================ +// Test: Integration - Full Lifecycle +// ============================================================================ + +/** + * Test complete lifecycle: init -> use -> reset + */ +add_task(async function test_full_lifecycle() { + // Initialize + const session = initSecurityLayer("lifecycle-test"); + Assert.ok(session, "Should initialize"); + + // Add some data + session.forTab("tab-1").add("https://example.com"); + const context = getRequestContext("tab-1"); + Assert.ok(context.linkLedger.has("https://example.com"), "Should have URL"); + + // Get stats + const stats = getSecurityLayerStats(); + Assert.equal(stats.tabCount, 1, "Should have 1 tab"); + + // Close tab + handleTabClose("tab-1"); + const statsAfterClose = getSecurityLayerStats(); + Assert.equal(statsAfterClose.tabCount, 0, "Should have 0 tabs after close"); + + // Reset + resetSecurityLayer(); + Assert.throws( + () => getSessionLedger(), + /not initialized/, + "Should be uninitialized after reset" + ); +}); + +/** + * Test that multiple init calls replace the session + */ +add_task(async function test_multiple_init_calls() { + const session1 = initSecurityLayer("session-1"); + session1.forTab("tab-1").add("https://example.com"); + + const session2 = initSecurityLayer("session-2"); + + Assert.equal(session2.sessionId, "session-2", "Should have new session ID"); + Assert.equal(session2.tabCount(), 0, "New session should be empty"); + + const currentSession = getSessionLedger(); + Assert.equal(currentSession, session2, "Should return newest session"); +}); diff --git a/toolkit/components/ml/tests/xpcshell/xpcshell.toml b/toolkit/components/ml/tests/xpcshell/xpcshell.toml index 2c9f4480462dd..f7afc894ae377 100644 --- a/toolkit/components/ml/tests/xpcshell/xpcshell.toml +++ b/toolkit/components/ml/tests/xpcshell/xpcshell.toml @@ -2,4 +2,11 @@ head = "" skip-if = ["os == 'android'"] +["test_condition_evaluator.js"] +["test_decision_types.js"] +["test_json_policy_system.js"] ["test_ml_engine_security.js"] +["test_policy_evaluator.js"] +["test_security_orchestrator.js"] +["test_security_utils.js"] +["test_smart_window_integration.js"] From 9d5dd1ac356d51f78496c71617911a499856ecef Mon Sep 17 00:00:00 2001 From: Randy Concepcion Date: Tue, 25 Nov 2025 13:39:59 -0500 Subject: [PATCH 3/6] Add decision effect constants and generateId utility Addressed comments in PR: - Add EFFECT_ALLOW/EFFECT_DENY constants to DecisionTypes.sys.mjs - Add PolicyEffect typedef for type hints - Replace hardcoded effect strings across security modules - Add generateId(prefix) utility to SecurityUtils.sys.mjs - Update SmartWindowIntegration and smartwindow.mjs to use generateId --- .../smartwindow/content/smartwindow.mjs | 5 +++-- .../components/smartwindow/content/utils.mjs | 3 ++- .../ml/security/DecisionTypes.sys.mjs | 18 ++++++++++++++---- .../ml/security/PolicyEvaluator.sys.mjs | 14 ++++++++------ .../ml/security/SecurityLogger.sys.mjs | 4 +++- .../ml/security/SecurityOrchestrator.sys.mjs | 5 +++-- .../ml/security/SecurityUtils.sys.mjs | 11 +++++++++++ .../ml/security/SmartWindowIntegration.sys.mjs | 6 +++--- 8 files changed, 47 insertions(+), 19 deletions(-) diff --git a/browser/components/smartwindow/content/smartwindow.mjs b/browser/components/smartwindow/content/smartwindow.mjs index dcbab707154d8..9249bbd6adda0 100644 --- a/browser/components/smartwindow/content/smartwindow.mjs +++ b/browser/components/smartwindow/content/smartwindow.mjs @@ -14,6 +14,7 @@ import { CHAT_HISTORY_CONVERSATION_SELECTED_EVENT, } from "chrome://browser/content/smartwindow/chat-history.mjs"; import { deleteInsight, getInsightSummariesForPrompt } from "./insights.mjs"; +import { generateId } from "chrome://global/content/ml/security/SecurityUtils.sys.mjs"; const { ChatHistory, ChatHistoryConversation } = ChromeUtils.importESModule( "resource:///modules/smartwindow/ChatHistory.sys.mjs" @@ -56,7 +57,7 @@ try { } catch (e) { // Actor already registered - this is expected if Smart Window // has been opened before in this browser session - if (!e.message?.includes("already registered")) { + if (!e.message?.toLowerCase().includes("already registered")) { console.error("Failed to register SmartWindowMeta actor:", e); } } @@ -116,7 +117,7 @@ class SmartWindowPage { // Initialize security orchestrator (if enabled) // Creates the SessionLedger that tracks trusted URLs across tabs // Only initialize if Smart Window security is enabled - const sessionId = `smart-window-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + const sessionId = generateId("smart-window"); const securityEnabled = Services.prefs.getBoolPref("browser.smartwindow.security.enabled", true); if (securityEnabled) { diff --git a/browser/components/smartwindow/content/utils.mjs b/browser/components/smartwindow/content/utils.mjs index fb9cedef28e91..19aed3f57d34e 100644 --- a/browser/components/smartwindow/content/utils.mjs +++ b/browser/components/smartwindow/content/utils.mjs @@ -18,6 +18,7 @@ ChromeUtils.defineESModuleGetters(lazy, { import { createEngine } from "chrome://global/content/ml/EngineProcess.sys.mjs"; import { SmartAssistEngine } from "moz-src:///browser/components/genai/SmartAssistEngine.sys.mjs"; import { deleteInsight, findRelatedInsight, generateInsightsFromDirectChat } from "chrome://browser/content/smartwindow/insights.mjs"; +import { EFFECT_DENY } from "chrome://global/content/ml/security/DecisionTypes.sys.mjs"; const { ChatHistoryMessage } = ChromeUtils.importESModule( "resource:///modules/smartwindow/ChatHistory.sys.mjs" @@ -647,7 +648,7 @@ async function checkToolSecurity(toolName, toolParams, requestId) { }, }); - if (decision.effect === "deny") { + if (decision.effect === EFFECT_DENY) { return { allowed: false, reason: `Security policy blocked this action: ${decision.reason} (${decision.code})`, diff --git a/toolkit/components/ml/security/DecisionTypes.sys.mjs b/toolkit/components/ml/security/DecisionTypes.sys.mjs index 8cbadfc5e309c..216d2fa6b0b85 100644 --- a/toolkit/components/ml/security/DecisionTypes.sys.mjs +++ b/toolkit/components/ml/security/DecisionTypes.sys.mjs @@ -98,6 +98,16 @@ export class SecurityPolicyError extends Error { } } +/** + * @typedef {'allow' | 'deny'} PolicyEffect + */ + +/** @type {PolicyEffect} */ +export const EFFECT_ALLOW = "allow"; + +/** @type {PolicyEffect} */ +export const EFFECT_DENY = "deny"; + // Standard denial codes for consistent error handling across the security layer. export const DenialCodes = Object.freeze({ // URL not present in the request-scoped link ledger. @@ -131,7 +141,7 @@ export const ReasonPhrases = Object.freeze({ */ export const allow = () => /** @type {SecurityDecisionAllow} */ ({ - effect: "allow", + effect: EFFECT_ALLOW, }); /** @@ -150,7 +160,7 @@ export const deny = ( policyId = "block-unseen-links" ) => /** @type {SecurityDecisionDeny} */ ({ - effect: "deny", + effect: EFFECT_DENY, policyId, code, reason, @@ -163,7 +173,7 @@ export const deny = ( * @param {SecurityDecision | undefined | null} decision - Decision to check * @returns {boolean} True if decision is allow */ -export const isAllow = decision => decision?.effect === "allow"; +export const isAllow = decision => decision?.effect === EFFECT_ALLOW; /** * Type guard: checks if a decision is a deny. @@ -171,4 +181,4 @@ export const isAllow = decision => decision?.effect === "allow"; * @param {SecurityDecision | undefined | null} decision - Decision to check * @returns {boolean} True if decision is deny */ -export const isDeny = decision => decision?.effect === "deny"; +export const isDeny = decision => decision?.effect === EFFECT_DENY; diff --git a/toolkit/components/ml/security/PolicyEvaluator.sys.mjs b/toolkit/components/ml/security/PolicyEvaluator.sys.mjs index 01c13c38fd92a..1b19fe3f77526 100644 --- a/toolkit/components/ml/security/PolicyEvaluator.sys.mjs +++ b/toolkit/components/ml/security/PolicyEvaluator.sys.mjs @@ -3,6 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { + EFFECT_ALLOW, + EFFECT_DENY, allow, deny, } from "chrome://global/content/ml/security/DecisionTypes.sys.mjs"; @@ -78,7 +80,7 @@ export class PolicyEvaluator { * @param {boolean} policy.enabled - Whether policy is active * @param {object} policy.match - Match criteria * @param {Array} policy.conditions - Conditions to evaluate - * @param {string} policy.effect - "deny" or "allow" + * @param {PolicyEffect} policy.effect - EFFECT_DENY or EFFECT_ALLOW * @param {object} policy.onDeny - Denial information * @param {object} action - Action being evaluated * @param {object} context - Request context @@ -97,7 +99,7 @@ export class PolicyEvaluator { const result = ConditionEvaluator.evaluate(condition, action, context); if (!result) { - if (policy.effect === "deny") { + if (policy.effect === EFFECT_DENY) { return deny(policy.onDeny.code, policy.onDeny.reason, { policyId: policy.id, failedCondition: condition.type, @@ -112,7 +114,7 @@ export class PolicyEvaluator { } } - if (policy.effect === "deny") { + if (policy.effect === EFFECT_DENY) { return null; } @@ -152,7 +154,7 @@ export class PolicyEvaluator { appliedPolicies++; - if (decision.effect === "deny") { + if (decision.effect === EFFECT_DENY) { console.warn( `[PolicyEvaluator] Policy ${policy.id} denied action:`, decision.reason @@ -210,12 +212,12 @@ export class PolicyEvaluator { if (!Array.isArray(policy.conditions)) { errors.push("Field 'conditions' must be an array"); } - if (policy.effect !== "deny" && policy.effect !== "allow") { + if (policy.effect !== EFFECT_DENY && policy.effect !== EFFECT_ALLOW) { errors.push("Field 'effect' must be 'deny' or 'allow'"); } // Conditional requirements - if (policy.effect === "deny" && !policy.onDeny) { + if (policy.effect === EFFECT_DENY && !policy.onDeny) { errors.push("Field 'onDeny' required when effect is 'deny'"); } if (policy.onDeny && (!policy.onDeny.code || !policy.onDeny.reason)) { diff --git a/toolkit/components/ml/security/SecurityLogger.sys.mjs b/toolkit/components/ml/security/SecurityLogger.sys.mjs index a8c2a94015041..f3c605b2d1959 100644 --- a/toolkit/components/ml/security/SecurityLogger.sys.mjs +++ b/toolkit/components/ml/security/SecurityLogger.sys.mjs @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { EFFECT_DENY } from "chrome://global/content/ml/security/DecisionTypes.sys.mjs"; + const logConsole = console.createInstance({ maxLogLevelPref: "browser.ml.logLevel", prefix: "SecurityLogger", @@ -33,7 +35,7 @@ export class SecurityLogger { `[${phase}] Security evaluation error:`, error.message || error ); - } else if (decision.effect === "deny") { + } else if (decision.effect === EFFECT_DENY) { logConsole.warn( `[${phase}] DENY: ${decision.code} - ${decision.reason} (${durationMs}ms)` ); diff --git a/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs b/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs index 73aefdf50c22f..ffd1e703ad5af 100644 --- a/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs +++ b/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs @@ -5,6 +5,7 @@ import { SessionLedger } from "chrome://global/content/ml/security/SecurityUtils.sys.mjs"; import { SecurityLogger } from "chrome://global/content/ml/security/SecurityLogger.sys.mjs"; import { + EFFECT_ALLOW, allow, deny, } from "chrome://global/content/ml/security/DecisionTypes.sys.mjs"; @@ -221,13 +222,13 @@ export class SecurityOrchestrator { action, context, decision: { - effect: "allow", + effect: EFFECT_ALLOW, reason: "Security disabled via kill switch", }, durationMs: Date.now() - startTime, killSwitchBypass: true, }); - return { effect: "allow" }; + return { effect: EFFECT_ALLOW }; } const policies = this.#policies.get(phase); diff --git a/toolkit/components/ml/security/SecurityUtils.sys.mjs b/toolkit/components/ml/security/SecurityUtils.sys.mjs index dfcf47e39b3e6..cbed5871a0def 100644 --- a/toolkit/components/ml/security/SecurityUtils.sys.mjs +++ b/toolkit/components/ml/security/SecurityUtils.sys.mjs @@ -10,6 +10,7 @@ * - eTLD+1 (effective top-level domain) validation * - TabLedger: Per-tab trusted URL storage * - SessionLedger: Container for all tab ledgers in a Smart Window session + * - Unique ID generation (given a prefix) * * Security Model: * --------------- @@ -436,3 +437,13 @@ export class SessionLedger { return this.tabs.size; } } + +/** + * Generates a unique ID with the given prefix. + * + * @param {string} prefix - Prefix for the ID (e.g., "req", "session") + * @returns {string} Unique ID like "req-1234567890-abc123def" + */ +export function generateId(prefix) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} diff --git a/toolkit/components/ml/security/SmartWindowIntegration.sys.mjs b/toolkit/components/ml/security/SmartWindowIntegration.sys.mjs index 316611696d122..4acb61cfa7070 100644 --- a/toolkit/components/ml/security/SmartWindowIntegration.sys.mjs +++ b/toolkit/components/ml/security/SmartWindowIntegration.sys.mjs @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { SessionLedger } from "chrome://global/content/ml/security/SecurityUtils.sys.mjs"; +import { SessionLedger, generateId } from "chrome://global/content/ml/security/SecurityUtils.sys.mjs"; const logConsole = console.createInstance({ maxLogLevelPref: "browser.ml.logLevel", @@ -27,7 +27,7 @@ let gSessionId = null; */ export function initSecurityLayer(sessionId = null) { if (!sessionId) { - sessionId = `smart-window-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + sessionId = generateId("smart-window"); } gSessionId = sessionId; @@ -176,7 +176,7 @@ export function getRequestContext( }); const actualRequestId = - requestId || `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + requestId || generateId("req"); return { linkLedger, From 3f76c5421b723de62720be7ed00a6dcf43d354f1 Mon Sep 17 00:00:00 2001 From: Randy Concepcion Date: Sat, 29 Nov 2025 21:04:49 -0500 Subject: [PATCH 4/6] Refactor SecurityOrchestrator from singleton to instance-based pattern - Fixes multi-window bug where closing one AI Window would reset security state for all windows. - Fixes minor bug for allowedUrls and other lint errors - Update SecurityOrchestrator instantiation in xpcshell tests - Remove deprecated files and tests (SmartWindowIntegration.sys.mjs) --- .../smartwindow/content/smartbar.mjs | 8 + .../smartwindow/content/smartwindow.mjs | 26 +- .../components/smartwindow/content/utils.mjs | 78 +++- toolkit/components/ml/jar.mn | 1 - .../ml/security/PolicyEvaluator.sys.mjs | 7 +- .../ml/security/SecurityOrchestrator.sys.mjs | 74 ++-- .../security/SmartWindowIntegration.sys.mjs | 217 ---------- toolkit/components/ml/security/moz.build | 14 - .../xpcshell/test_condition_evaluator.js | 49 ++- .../tests/xpcshell/test_json_policy_system.js | 79 ++-- .../tests/xpcshell/test_policy_evaluator.js | 55 +-- .../xpcshell/test_security_orchestrator.js | 60 +-- .../xpcshell/test_smart_window_integration.js | 407 ------------------ .../ml/tests/xpcshell/xpcshell.toml | 1 - 14 files changed, 255 insertions(+), 821 deletions(-) delete mode 100644 toolkit/components/ml/security/SmartWindowIntegration.sys.mjs delete mode 100644 toolkit/components/ml/tests/xpcshell/test_smart_window_integration.js diff --git a/browser/components/smartwindow/content/smartbar.mjs b/browser/components/smartwindow/content/smartbar.mjs index 251e32187444c..e57a8dbe70c89 100644 --- a/browser/components/smartwindow/content/smartbar.mjs +++ b/browser/components/smartwindow/content/smartbar.mjs @@ -15,6 +15,8 @@ import { getMentionSuggestions, } from "chrome://browser/content/smartwindow/mentions.mjs"; +import { seedMentionedUrl } from "./utils.mjs"; + // Track autofill state let autofillState = null; let deletedQuery = ""; @@ -252,6 +254,12 @@ export function attachToElement(element, options = {}) { dropdown = new MentionDropdown(); currentCommand = props.command; dropdown.create(props.items, item => { + // Seed the @mentioned URL into the security ledger + // This grants the AI permission to access this URL + if (item.id) { + seedMentionedUrl(item.id); + } + currentCommand({ id: item.id, label: item.label, diff --git a/browser/components/smartwindow/content/smartwindow.mjs b/browser/components/smartwindow/content/smartwindow.mjs index 9249bbd6adda0..82bf79d338ea4 100644 --- a/browser/components/smartwindow/content/smartwindow.mjs +++ b/browser/components/smartwindow/content/smartwindow.mjs @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { detectQueryType, searchBrowserHistory } from "./utils.mjs"; +import { detectQueryType, searchBrowserHistory, setSecurityOrchestrator } from "./utils.mjs"; import { attachToElement } from "chrome://browser/content/smartwindow/smartbar.mjs"; import { generateLiveSuggestions, @@ -14,7 +14,6 @@ import { CHAT_HISTORY_CONVERSATION_SELECTED_EVENT, } from "chrome://browser/content/smartwindow/chat-history.mjs"; import { deleteInsight, getInsightSummariesForPrompt } from "./insights.mjs"; -import { generateId } from "chrome://global/content/ml/security/SecurityUtils.sys.mjs"; const { ChatHistory, ChatHistoryConversation } = ChromeUtils.importESModule( "resource:///modules/smartwindow/ChatHistory.sys.mjs" @@ -35,6 +34,9 @@ const { TabStateFlusher } = ChromeUtils.importESModule( const { SecurityOrchestrator } = ChromeUtils.importESModule( "chrome://global/content/ml/security/SecurityOrchestrator.sys.mjs" ); +const { generateId } = ChromeUtils.importESModule( + "chrome://global/content/ml/security/SecurityUtils.sys.mjs" +); const { embedderElement, topChromeWindow } = window.browsingContext; const gBrowser = topChromeWindow.gBrowser; @@ -119,18 +121,25 @@ class SmartWindowPage { // Only initialize if Smart Window security is enabled const sessionId = generateId("smart-window"); const securityEnabled = Services.prefs.getBoolPref("browser.smartwindow.security.enabled", true); + + this.securityOrchestrator = null; if (securityEnabled) { - SecurityOrchestrator.init(sessionId).then(sessionLedger => { - this.sessionLedger = sessionLedger; - console.log("[Security] Smart Window security enabled - orchestrator initialized"); + SecurityOrchestrator.create(sessionId).then(orchestrator => { + this.securityOrchestrator = orchestrator; + this.sessionLedger = orchestrator.getSessionLedger(); + setSecurityOrchestrator(orchestrator); + console.warn("[Security] AI Window security enabled - orchestrator initialized"); }).catch(error => { console.error("[Security] Failed to initialize orchestrator:", error); + this.securityOrchestrator = null; this.sessionLedger = null; + setSecurityOrchestrator(null); }); } else { this.sessionLedger = null; - console.log("[Security] Smart Window security DISABLED via kill switch - running in pass-through mode"); + setSecurityOrchestrator(null); + console.warn("[Security] AI Window security DISABLED via kill switch - running in pass-through mode"); } gBrowser.selectedTab.conversation = new ChatHistoryConversation({ @@ -180,7 +189,10 @@ class SmartWindowPage { ); } - SecurityOrchestrator.reset(); + if (this.securityOrchestrator) { + this.securityOrchestrator.reset(); + setSecurityOrchestrator(null); + } } getQueryTypeIcon(type) { diff --git a/browser/components/smartwindow/content/utils.mjs b/browser/components/smartwindow/content/utils.mjs index 19aed3f57d34e..c39f7327f7b78 100644 --- a/browser/components/smartwindow/content/utils.mjs +++ b/browser/components/smartwindow/content/utils.mjs @@ -11,10 +11,59 @@ ChromeUtils.defineESModuleGetters(lazy, { PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", PageThumbsStorage: "resource://gre/modules/PageThumbs.sys.mjs", getPlacesSemanticHistoryManager: "resource://gre/modules/PlacesSemanticHistoryManager.sys.mjs", - SecurityOrchestrator: - "chrome://global/content/ml/security/SecurityOrchestrator.sys.mjs", }); +/** + * Module-level security orchestrator instance. + * Set by SmartWindowPage after initialization. + * + * @type {import("chrome://global/content/ml/security/SecurityOrchestrator.sys.mjs").SecurityOrchestrator|null} + */ +let securityOrchestrator = null; + +/** + * Sets the security orchestrator instance for this window's utils module. + * Called by SmartWindowPage after creating the orchestrator. + * + * @param {import("chrome://global/content/ml/security/SecurityOrchestrator.sys.mjs").SecurityOrchestrator|null} orchestrator + */ +export function setSecurityOrchestrator(orchestrator) { + securityOrchestrator = orchestrator; +} + +/** + * Seeds a URL into the session ledger for the current tab. + * Call this when a user explicitly @mentions a tab/URL. + * This grants the AI permission to access the URL's content. + * + * @param {string} url - The URL to seed + */ +export function seedMentionedUrl(url) { + if (!securityOrchestrator) { + console.log("[Security] No orchestrator - skipping mention seeding"); + return; + } + + const sessionLedger = securityOrchestrator.getSessionLedger(); + if (!sessionLedger) { + console.log("[Security] No session ledger - skipping mention seeding"); + return; + } + + const win = lazy.BrowserWindowTracker.getTopWindow(); + const gBrowser = win.gBrowser; + const tabId = gBrowser.selectedTab.linkedPanel; + + const tabLedger = sessionLedger.forTab(tabId); + const added = tabLedger.add(url); + + if (added) { + console.log(`[Security] Seeded @mentioned URL: ${url} for tab ${tabId}`); + } else { + console.warn(`[Security] Failed to seed @mentioned URL: ${url} for tab ${tabId}`); + } +} + import { createEngine } from "chrome://global/content/ml/EngineProcess.sys.mjs"; import { SmartAssistEngine } from "moz-src:///browser/components/genai/SmartAssistEngine.sys.mjs"; import { deleteInsight, findRelatedInsight, generateInsightsFromDirectChat } from "chrome://browser/content/smartwindow/insights.mjs"; @@ -598,7 +647,7 @@ async function checkToolSecurity(toolName, toolParams, requestId) { await browser.browsingContext.currentWindowContext.getActor( "SmartWindowMeta" ); - const sessionLedger = lazy.SecurityOrchestrator.getSessionLedger(); + const sessionLedger = securityOrchestrator?.getSessionLedger(); if (!sessionLedger) { console.log( @@ -633,7 +682,7 @@ async function checkToolSecurity(toolName, toolParams, requestId) { tabId = gBrowser.selectedTab.linkedPanel; } - const decision = await lazy.SecurityOrchestrator.evaluate({ + const decision = await securityOrchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -955,12 +1004,12 @@ export async function* fetchWithHistory(messages, allowedUrls) { error: securityCheck.reason || "Security policy denied this action", }; } else { - // Populate allowedUrls from current tab's ledger for headless extraction + // Merge session ledger URLs with any pre-existing allowedUrls for headless extraction. // This allows fetching URLs that are part of the current page's metadata - // Such as (canonical URLs, related links, etc.) even if they're not in open tabs - let allowedUrls = new Set(); + // (canonical URLs, related links, etc.) even if they're not in open tabs. + let mergedAllowedUrls = new Set(allowedUrls || []); try { - const sessionLedger = lazy.SecurityOrchestrator.getSessionLedger(); + const sessionLedger = securityOrchestrator?.getSessionLedger(); if (sessionLedger) { // Get current tab ID from tool params or browser @@ -985,19 +1034,20 @@ export async function* fetchWithHistory(messages, allowedUrls) { if (currentTabId) { const tabLedger = sessionLedger.forTab(currentTabId); if (tabLedger) { - allowedUrls = new Set(tabLedger.getAll()); - console.log( - `[Security] Allowing headless extraction for ${allowedUrls.size} URLs from current tab ${currentTabId}` + for (const url of tabLedger.getAll()) { + mergedAllowedUrls.add(url); + } + console.warn( + `[Security] Allowing headless extraction for ${mergedAllowedUrls.size} URLs from current tab ${currentTabId}` ); } } } } catch (error) { console.warn( - "[Security] Could not populate allowedUrls for headless extraction:", + "[Security] Could not populate mergedAllowedUrls for headless extraction:", error ); - allowedUrls = new Set(); } switch (toolName) { @@ -1005,7 +1055,7 @@ export async function* fetchWithHistory(messages, allowedUrls) { result = search_open_tabs(toolParams); break; case GET_PAGE_CONTENT: - result = await get_page_content(toolParams, allowedUrls); + result = await get_page_content(toolParams, mergedAllowedUrls); break; case SEARCH_HISTORY: result = await searchBrowserHistory(toolParams); diff --git a/toolkit/components/ml/jar.mn b/toolkit/components/ml/jar.mn index 0d420a1c0d097..78b55d15be768 100644 --- a/toolkit/components/ml/jar.mn +++ b/toolkit/components/ml/jar.mn @@ -34,7 +34,6 @@ toolkit.jar: content/global/ml/security/SecurityLogger.sys.mjs (security/SecurityLogger.sys.mjs) content/global/ml/security/SecurityOrchestrator.sys.mjs (security/SecurityOrchestrator.sys.mjs) content/global/ml/security/SecurityUtils.sys.mjs (security/SecurityUtils.sys.mjs) - content/global/ml/security/SmartWindowIntegration.sys.mjs (security/SmartWindowIntegration.sys.mjs) content/global/ml/security/policies/tool-execution-policies.json (security/policies/tool-execution-policies.json) #ifdef NIGHTLY_BUILD content/global/ml/ort.webgpu-dev.mjs (vendor/ort.webgpu-dev.mjs) diff --git a/toolkit/components/ml/security/PolicyEvaluator.sys.mjs b/toolkit/components/ml/security/PolicyEvaluator.sys.mjs index 1b19fe3f77526..c0c1a7b63229d 100644 --- a/toolkit/components/ml/security/PolicyEvaluator.sys.mjs +++ b/toolkit/components/ml/security/PolicyEvaluator.sys.mjs @@ -31,7 +31,12 @@ export class PolicyEvaluator { * @returns {boolean} True if policy applies to this action */ static checkMatch(matchCriteria, action) { - console.warn("[PolicyEvaluator] checkMatch criteria:", JSON.stringify(matchCriteria), "action:", JSON.stringify(action)); + console.warn( + "[PolicyEvaluator] checkMatch criteria:", + JSON.stringify(matchCriteria), + "action:", + JSON.stringify(action) + ); if (!matchCriteria || typeof matchCriteria !== "object") { return false; } diff --git a/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs b/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs index ffd1e703ad5af..b1d477cc73601 100644 --- a/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs +++ b/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs @@ -25,8 +25,7 @@ function isSecurityEnabled() { /** * Central security orchestrator for Firefox AI features. - * Single entry point: evaluate() routes to policy modules with centralized logging. - * + * Each AI Window instance should create its own SecurityOrchestrator. */ export class SecurityOrchestrator { /** @@ -34,45 +33,49 @@ export class SecurityOrchestrator { * * @type {Map>} */ - static #policies = new Map(); + #policies = new Map(); /** - * Shared session ledger for URL tracking across tabs. + * Session ledger for URL tracking across tabs in this window. * * @type {SessionLedger | null} */ - static #sessionLedger = null; + #sessionLedger = null; /** - * Session identifier. + * Session identifier for this window. * * @type {string | null} */ - static #sessionId = null; + #sessionId = null; /** - * Initializes the security orchestrator for a new session. - * - * This should be called once when Smart Window (or other AI feature) starts. - * Creates the SessionLedger that will track trusted URLs across all tabs. - * Loads security policies from JSON files. + * Used by create() to instantiate SecurityOrchestrator instance. * * @param {string} sessionId - Unique identifier for this session - * @returns {Promise} The initialized session ledger */ - static async init(sessionId) { + constructor(sessionId) { this.#sessionId = sessionId; this.#sessionLedger = new SessionLedger(sessionId); + } - await this.#loadPolicies(); + /** + * Creates and initializes a new SecurityOrchestrator instance. + * + * @param {string} sessionId - Unique identifier for this session + * @returns {Promise} Initialized orchestrator instance + */ + static async create(sessionId) { + const instance = new SecurityOrchestrator(sessionId); + await instance.#loadPolicies(); console.warn( `[Security] Orchestrator initialized for session ${sessionId} with ${Array.from( - this.#policies.values() + instance.#policies.values() ).reduce((sum, policies) => sum + policies.length, 0)} policies` ); - return this.#sessionLedger; + return instance; } /** @@ -80,8 +83,7 @@ export class SecurityOrchestrator { * * @private */ - static async #loadPolicies() { - // Add more policy files here as they're created: + async #loadPolicies() { const policyFiles = ["tool-execution-policies.json"]; const allPolicies = []; @@ -156,29 +158,27 @@ export class SecurityOrchestrator { /** * Resets the security orchestrator state. - * Used for cleanup or testing. + * Call this when the AI Window closes. */ - static reset() { + reset() { this.#sessionLedger = null; this.#sessionId = null; this.#policies.clear(); } /** - * Gets the current session ledger. + * Gets the session ledger for this orchestrator. * - * @returns {SessionLedger} The session ledger + * @returns {SessionLedger | null} The session ledger * @throws {Error} If orchestrator not initialized AND security is enabled */ - static getSessionLedger() { + getSessionLedger() { if (!this.#sessionLedger) { if (!isSecurityEnabled()) { return null; } - throw new Error( - "Security orchestrator not initialized. Call SecurityOrchestrator.init() first." - ); + throw new Error("Security orchestrator not initialized."); } return this.#sessionLedger; } @@ -186,21 +186,13 @@ export class SecurityOrchestrator { /** * Main entry point for all security checks. * - * This method: - * 1. Validates the request envelope - * 2. Builds shared context (ledger, metadata) - * 3. Routes to appropriate policy module - * 4. Evaluates the action against policy - * 5. Logs the decision - * 6. Returns allow/deny decision - * * @param {object} envelope - Security check request - * @param {string} envelope.phase - Security phase ("tool.execution", "inference-pipeline", etc.) + * @param {string} envelope.phase - Security phase ("tool.execution", etc.) * @param {object} envelope.action - Action being checked (type, tool, urls, etc.) * @param {object} envelope.context - Request context (tabId, requestId, etc.) * @returns {Promise} Decision object with effect (allow/deny), code, reason */ - static async evaluate(envelope) { + async evaluate(envelope) { const startTime = Date.now(); try { @@ -287,13 +279,10 @@ export class SecurityOrchestrator { /** * Removes all policies for a phase. * - * Note: With JSON-based policies, this only affects runtime state. - * Policies will be reloaded from JSON on next init(). - * * @param {string} phase - Phase identifier to remove * @returns {boolean} True if policies were removed, false if not found */ - static removePolicy(phase) { + removePolicy(phase) { return this.#policies.delete(phase); } @@ -302,13 +291,12 @@ export class SecurityOrchestrator { * * @returns {object} Stats object with registered policies, session info, etc. */ - static getStats() { + getStats() { const totalPolicies = Array.from(this.#policies.values()).reduce( (sum, policies) => sum + policies.length, 0 ); - // Get policy breakdown by phase const policyBreakdown = {}; for (const [phase, policies] of this.#policies.entries()) { policyBreakdown[phase] = { diff --git a/toolkit/components/ml/security/SmartWindowIntegration.sys.mjs b/toolkit/components/ml/security/SmartWindowIntegration.sys.mjs deleted file mode 100644 index 4acb61cfa7070..0000000000000 --- a/toolkit/components/ml/security/SmartWindowIntegration.sys.mjs +++ /dev/null @@ -1,217 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import { SessionLedger, generateId } from "chrome://global/content/ml/security/SecurityUtils.sys.mjs"; - -const logConsole = console.createInstance({ - maxLogLevelPref: "browser.ml.logLevel", - prefix: "SmartWindowSecurity", -}); - -/** - * Smart Window security layer integration. - * - * Bridge between Smart Window UI and security layer. - * Manages SessionLedger lifecycle and request context building. - */ - -let gSessionLedger = null; -let gSessionId = null; - -/** - * Initializes the security layer. Call once when Smart Window opens. - * - * @param {string} [sessionId] - Optional session identifier (auto-generated if not provided) - * @returns {SessionLedger} The session ledger instance - */ -export function initSecurityLayer(sessionId = null) { - if (!sessionId) { - sessionId = generateId("smart-window"); - } - - gSessionId = sessionId; - gSessionLedger = new SessionLedger(sessionId); - - logConsole.debug(`Security layer initialized for session: ${sessionId}`); - - return gSessionLedger; -} - -/** - * Gets the current SessionLedger instance. - * - * @returns {SessionLedger|null} The session ledger, or null if not initialized - * @throws {Error} If security layer hasn't been initialized - */ -export function getSessionLedger() { - if (!gSessionLedger) { - throw new Error( - "Security layer not initialized. Call initSecurityLayer() first." - ); - } - return gSessionLedger; -} - -/** Resets the security layer (clears all ledgers). */ -export function resetSecurityLayer() { - if (gSessionLedger) { - gSessionLedger.clearAll(); - } - gSessionLedger = null; - gSessionId = null; - logConsole.debug("Security layer reset"); -} - -/** - * Seeds the ledger for a tab via SmartWindowMeta actor. - * - * @param {Browser} browser - The browser element for the tab - * @param {string} tabId - The tab identifier (typically linkedPanel) - * @returns {Promise} Result object with seeded URLs and any errors - */ -export async function seedCurrentTab(browser, tabId) { - const sessionLedger = getSessionLedger(); - - try { - const actor = - browser.browsingContext?.currentWindowGlobal?.getActor("SmartWindowMeta"); - - if (!actor) { - logConsole.error( - "SmartWindowMeta actor not available for browser", - tabId - ); - return { - success: false, - errors: [ - "SmartWindowMeta actor not available (page may not be loaded yet)", - ], - }; - } - - const result = await actor.seedLedgerForTab(sessionLedger, tabId); - - if (result.success) { - logConsole.debug( - `Seeded tab ${tabId} with ${result.seededUrls.length} URLs`, - result.seededUrls.map(u => u.normalized) - ); - - if (result.skippedUrls.length) { - logConsole.debug( - `Skipped ${result.skippedUrls.length} URLs for tab ${tabId}`, - result.skippedUrls - ); - } - } else { - logConsole.error(`Failed to seed tab ${tabId}:`, result.errors); - } - - return result; - } catch (error) { - logConsole.error("Error seeding tab:", error); - return { - success: false, - errors: [{ message: "Exception during seeding", error: String(error) }], - }; - } -} - -/** - * Handles tab navigation by clearing and re-seeding the ledger. - * - * This should be called when the user navigates to a new page in a tab. - * It clears the old trusted URLs and seeds with the new page context. - * - * @param {Browser} browser - The browser element for the tab - * @param {string} tabId - The tab identifier - * @param {nsIURI} uri - The new URI (optional, for logging) - * @returns {Promise} - */ -export async function handleNavigation(browser, tabId, uri = null) { - const sessionLedger = getSessionLedger(); - - sessionLedger.clearTab(tabId); - - logConsole.debug( - `Cleared ledger for tab ${tabId} after navigation${uri ? ` to ${uri.spec}` : ""}` - ); - - // Re-seed with new page context - return seedCurrentTab(browser, tabId); -} - -/** - * Handles tab close by removing the tab's ledger. - * - * This should be called when a tab closes to clean up memory. - * - * @param {string} tabId - The tab identifier - */ -export function handleTabClose(tabId) { - const sessionLedger = getSessionLedger(); - sessionLedger.removeTab(tabId); - logConsole.debug(`Removed ledger for closed tab ${tabId}`); -} - -/** - * Builds request-scoped security context for policy evaluation. - * - * @param {string} currentTabId - The active/focused tab - * @param {string[]} [mentionedTabIds=[]] - Tab IDs from @mentions - * @param {string} [requestId] - Optional request identifier for logging - * @returns {object} Security context object for policy evaluation - */ -export function getRequestContext( - currentTabId, - mentionedTabIds = [], - requestId = null -) { - const sessionLedger = getSessionLedger(); - - const linkLedger = sessionLedger.buildRequestScope({ - currentTabId, - mentionedTabIds, - }); - - const actualRequestId = - requestId || generateId("req"); - - return { - linkLedger, - sessionId: gSessionId, - requestId: actualRequestId, - currentTabId, - mentionedTabIds, - }; -} - -/** - * Gets statistics about the current security layer state. - * Useful for debugging and monitoring. - * - * @returns {object} Statistics object with tab counts and URL counts - */ -export function getSecurityLayerStats() { - const sessionLedger = getSessionLedger(); - - const tabIds = []; - const tabStats = []; - let totalUrls = 0; - - for (const [tabId, ledger] of sessionLedger.tabs.entries()) { - const urlCount = ledger.size(); - tabIds.push(tabId); - tabStats.push({ tabId, urlCount }); - totalUrls += urlCount; - } - - return { - sessionId: gSessionId, - tabCount: sessionLedger.tabCount(), - totalUrls, - tabIds, - tabStats, - }; -} diff --git a/toolkit/components/ml/security/moz.build b/toolkit/components/ml/security/moz.build index 4eee224331e26..53b00bc213969 100644 --- a/toolkit/components/ml/security/moz.build +++ b/toolkit/components/ml/security/moz.build @@ -4,17 +4,3 @@ with Files("**"): BUG_COMPONENT = ("Firefox", "Machine Learning") - -EXTRA_JS_MODULES += [ - 'ConditionEvaluator.sys.mjs', - 'DecisionTypes.sys.mjs', - 'PolicyEvaluator.sys.mjs', - 'SecurityLogger.sys.mjs', - 'SecurityOrchestrator.sys.mjs', - 'SecurityUtils.sys.mjs', - 'SmartWindowIntegration.sys.mjs', -] - -FINAL_TARGET_FILES.content.ml.security.policies += [ - 'policies/tool-execution-policies.json', -] diff --git a/toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js b/toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js index bcee6d4b88be9..8b1ab08e10238 100644 --- a/toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js +++ b/toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js @@ -18,15 +18,18 @@ const { SecurityOrchestrator } = ChromeUtils.importESModule( const PREF_SECURITY_ENABLED = "browser.smartwindow.security.enabled"; +/** @type {SecurityOrchestrator|null} */ +let orchestrator = null; + function setup() { Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); - SecurityOrchestrator.reset(); } function teardown() { Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); - SecurityOrchestrator.reset(); + orchestrator?.reset(); + orchestrator = null; } // ============================================================================ @@ -36,13 +39,13 @@ function teardown() { add_task(async function test_condition_passes_when_all_urls_in_ledger() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); const tabLedger = ledger.forTab("tab-1"); tabLedger.add("https://example.com"); tabLedger.add("https://mozilla.org"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -69,11 +72,11 @@ add_task(async function test_condition_passes_when_all_urls_in_ledger() { add_task(async function test_condition_fails_when_url_missing_from_ledger() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1").add("https://example.com"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -101,11 +104,11 @@ add_task(async function test_condition_fails_when_url_missing_from_ledger() { add_task(async function test_condition_passes_with_empty_urls_array() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -132,11 +135,11 @@ add_task(async function test_condition_passes_with_empty_urls_array() { add_task(async function test_condition_fails_with_malformed_url() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -170,11 +173,11 @@ add_task(async function test_condition_fails_with_malformed_url() { add_task(async function test_condition_checks_current_tab_only() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1").add("https://example.com"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -201,13 +204,13 @@ add_task(async function test_condition_checks_current_tab_only() { add_task(async function test_condition_merges_mentioned_tabs() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1").add("https://example.com"); ledger.forTab("tab-2").add("https://mozilla.org"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -238,11 +241,11 @@ add_task(async function test_condition_merges_mentioned_tabs() { add_task(async function test_condition_normalizes_urls() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1").add("https://example.com/page"); // No fragment - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", diff --git a/toolkit/components/ml/tests/xpcshell/test_json_policy_system.js b/toolkit/components/ml/tests/xpcshell/test_json_policy_system.js index 93ec35e1fdb81..b6e78ac786dcd 100644 --- a/toolkit/components/ml/tests/xpcshell/test_json_policy_system.js +++ b/toolkit/components/ml/tests/xpcshell/test_json_policy_system.js @@ -20,15 +20,18 @@ const PREF_SECURITY_ENABLED = "browser.smartwindow.security.enabled"; const POLICY_JSON_URL = "chrome://global/content/ml/security/policies/tool-execution-policies.json"; +/** @type {SecurityOrchestrator|null} */ +let orchestrator = null; + function setup() { Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); - SecurityOrchestrator.reset(); } function teardown() { Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); - SecurityOrchestrator.reset(); + orchestrator?.reset(); + orchestrator = null; } // ============================================================================ @@ -60,18 +63,16 @@ add_task(async function test_json_policy_file_loads_and_validates() { add_task(async function test_orchestrator_initializes_with_policies() { setup(); - // If init succeeds, policies loaded correctly - const ledger = await SecurityOrchestrator.init("test-session"); + // If create succeeds, policies loaded correctly + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); Assert.ok(ledger, "Should initialize successfully"); - Assert.ok( - SecurityOrchestrator.getSessionLedger(), - "Should have session ledger" - ); + Assert.ok(orchestrator.getSessionLedger(), "Should have session ledger"); // Verify policies work by testing actual evaluation ledger.forTab("tab-1"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -107,11 +108,11 @@ add_task(async function test_orchestrator_initializes_with_policies() { add_task(async function test_e2e_deny_unseen_link() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); // Empty ledger - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -148,12 +149,12 @@ add_task(async function test_e2e_deny_unseen_link() { add_task(async function test_e2e_deny_if_any_url_unseen() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); const tabLedger = ledger.forTab("tab-1"); tabLedger.add("https://example.com"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -184,11 +185,11 @@ add_task(async function test_e2e_deny_if_any_url_unseen() { add_task(async function test_e2e_deny_malformed_url() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -222,12 +223,12 @@ add_task(async function test_e2e_deny_malformed_url() { add_task(async function test_e2e_allow_seeded_url() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); const tabLedger = ledger.forTab("tab-1"); tabLedger.add("https://example.com"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -254,13 +255,13 @@ add_task(async function test_e2e_allow_seeded_url() { add_task(async function test_e2e_allow_multiple_seeded_urls() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); const tabLedger = ledger.forTab("tab-1"); tabLedger.add("https://example.com"); tabLedger.add("https://mozilla.org"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -283,11 +284,11 @@ add_task(async function test_e2e_allow_multiple_seeded_urls() { add_task(async function test_e2e_allow_empty_urls() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -314,8 +315,8 @@ add_task(async function test_e2e_allow_empty_urls() { add_task(async function test_e2e_allow_url_from_mentioned_tab() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); // Current tab ledger.forTab("tab-1").add("https://example.com"); @@ -323,7 +324,7 @@ add_task(async function test_e2e_allow_url_from_mentioned_tab() { // Mentioned tab (different URL) ledger.forTab("tab-2").add("https://mozilla.org"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -350,13 +351,13 @@ add_task(async function test_e2e_allow_url_from_mentioned_tab() { add_task(async function test_e2e_deny_url_not_in_mentioned_tabs() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1").add("https://example.com"); ledger.forTab("tab-2").add("https://mozilla.org"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -387,11 +388,11 @@ add_task(async function test_e2e_deny_url_not_in_mentioned_tabs() { add_task(async function test_e2e_url_normalization_strips_fragments() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1").add("https://example.com/page"); // No fragment - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -425,11 +426,11 @@ add_task(async function test_e2e_kill_switch_bypasses_policies() { // Disable security Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, false); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); // Empty ledger - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", diff --git a/toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js b/toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js index 45197f38ae26f..f9c1485306f6f 100644 --- a/toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js +++ b/toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js @@ -18,15 +18,18 @@ const { SecurityOrchestrator } = ChromeUtils.importESModule( const PREF_SECURITY_ENABLED = "browser.smartwindow.security.enabled"; +/** @type {SecurityOrchestrator|null} */ +let orchestrator = null; + function setup() { Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); - SecurityOrchestrator.reset(); } function teardown() { Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); - SecurityOrchestrator.reset(); + orchestrator?.reset(); + orchestrator = null; } // ============================================================================ @@ -36,12 +39,12 @@ function teardown() { add_task(async function test_policy_matches_correct_phase() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); // tool.execution phase should match our policies - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -69,12 +72,12 @@ add_task(async function test_policy_matches_correct_phase() { add_task(async function test_policy_ignores_unknown_phase() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); // Unknown phase should not match any policies - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "unknown.phase", action: { type: "tool.call", @@ -105,12 +108,12 @@ add_task(async function test_policy_ignores_unknown_phase() { add_task(async function test_deny_policy_denies_when_condition_fails() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1").add("https://example.com"); // URL not in ledger = condition fails = deny - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -138,12 +141,12 @@ add_task( async function test_deny_policy_passes_through_when_condition_passes() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1").add("https://example.com"); // URL in ledger = condition passes = policy doesn't apply (allow) - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -175,12 +178,12 @@ add_task( add_task(async function test_policy_checks_all_urls() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1").add("https://example.com"); // Not adding evil.com - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -210,13 +213,13 @@ add_task(async function test_policy_checks_all_urls() { add_task(async function test_policy_allows_when_all_urls_valid() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); const tabLedger = ledger.forTab("tab-1"); tabLedger.add("https://example.com"); tabLedger.add("https://mozilla.org"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -247,12 +250,12 @@ add_task(async function test_policy_allows_when_all_urls_valid() { add_task(async function test_policy_applies_to_get_page_content() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); // Verify policy applies to get_page_content (the main URL-fetching tool) - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -283,11 +286,11 @@ add_task(async function test_policy_applies_to_get_page_content() { add_task(async function test_deny_decision_includes_policy_info() { setup(); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", diff --git a/toolkit/components/ml/tests/xpcshell/test_security_orchestrator.js b/toolkit/components/ml/tests/xpcshell/test_security_orchestrator.js index d5fc07e7ff3c1..206bb3653ea2e 100644 --- a/toolkit/components/ml/tests/xpcshell/test_security_orchestrator.js +++ b/toolkit/components/ml/tests/xpcshell/test_security_orchestrator.js @@ -19,14 +19,17 @@ const { SecurityOrchestrator } = ChromeUtils.importESModule( const PREF_SECURITY_ENABLED = "browser.smartwindow.security.enabled"; +/** @type {SecurityOrchestrator|null} */ +let orchestrator = null; + function setup() { Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); - SecurityOrchestrator.reset(); } function teardown() { Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); - SecurityOrchestrator.reset(); + orchestrator?.reset(); + orchestrator = null; } // ============================================================================= @@ -36,12 +39,13 @@ function teardown() { add_task(async function test_initialization_creates_session() { setup(); - const ledger = await SecurityOrchestrator.init("test-session"); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); Assert.ok(ledger, "Should return session ledger"); Assert.equal(ledger.tabCount(), 0, "Should start with no tabs"); Assert.ok( - SecurityOrchestrator.getSessionLedger(), + orchestrator.getSessionLedger(), "Should be able to get session ledger" ); @@ -56,11 +60,11 @@ add_task(async function test_kill_switch_disabled_allows_everything() { setup(); Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, false); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); // Empty ledger - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -88,11 +92,11 @@ add_task(async function test_kill_switch_enabled_enforces_policies() { setup(); Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -117,8 +121,8 @@ add_task(async function test_kill_switch_runtime_change() { setup(); Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); - await SecurityOrchestrator.init("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); const envelope = { @@ -137,14 +141,14 @@ add_task(async function test_kill_switch_runtime_change() { }; // Should deny when enabled - let decision = await SecurityOrchestrator.evaluate(envelope); + let decision = await orchestrator.evaluate(envelope); Assert.equal(decision.effect, "deny", "Should deny when enabled"); // Disable at runtime Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, false); // Should allow immediately - decision = await SecurityOrchestrator.evaluate(envelope); + decision = await orchestrator.evaluate(envelope); Assert.equal( decision.effect, "allow", @@ -161,7 +165,7 @@ add_task(async function test_kill_switch_runtime_change() { add_task(async function test_invalid_envelope_fails_closed() { setup(); Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); - await SecurityOrchestrator.init("test-session"); + orchestrator = await SecurityOrchestrator.create("test-session"); const invalidEnvelopes = [ null, @@ -171,7 +175,7 @@ add_task(async function test_invalid_envelope_fails_closed() { ]; for (const envelope of invalidEnvelopes) { - const decision = await SecurityOrchestrator.evaluate(envelope); + const decision = await orchestrator.evaluate(envelope); Assert.equal( decision.effect, "deny", @@ -190,12 +194,12 @@ add_task(async function test_invalid_envelope_fails_closed() { add_task(async function test_policy_allows_seeded_url() { setup(); Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); - await SecurityOrchestrator.init("test-session"); + orchestrator = await SecurityOrchestrator.create("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1").add("https://example.com"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -218,12 +222,12 @@ add_task(async function test_policy_allows_seeded_url() { add_task(async function test_policy_denies_unseen_url() { setup(); Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); - await SecurityOrchestrator.init("test-session"); + orchestrator = await SecurityOrchestrator.create("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); // Empty ledger - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -253,12 +257,12 @@ add_task(async function test_policy_denies_unseen_url() { add_task(async function test_policy_denies_if_any_url_unseen() { setup(); Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); - await SecurityOrchestrator.init("test-session"); + orchestrator = await SecurityOrchestrator.create("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1").add("https://example.com"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", @@ -292,12 +296,12 @@ add_task(async function test_policy_denies_if_any_url_unseen() { add_task(async function test_malformed_url_fails_closed() { setup(); Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); - await SecurityOrchestrator.init("test-session"); + orchestrator = await SecurityOrchestrator.create("test-session"); - const ledger = SecurityOrchestrator.getSessionLedger(); + const ledger = orchestrator.getSessionLedger(); ledger.forTab("tab-1"); - const decision = await SecurityOrchestrator.evaluate({ + const decision = await orchestrator.evaluate({ phase: "tool.execution", action: { type: "tool.call", diff --git a/toolkit/components/ml/tests/xpcshell/test_smart_window_integration.js b/toolkit/components/ml/tests/xpcshell/test_smart_window_integration.js deleted file mode 100644 index c87dbcdbf89b3..0000000000000 --- a/toolkit/components/ml/tests/xpcshell/test_smart_window_integration.js +++ /dev/null @@ -1,407 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -/** - * Unit tests for SmartWindowIntegration.sys.mjs - * - * Tests the Smart Window security layer integration: - * - initSecurityLayer() - initialization - * - getSessionLedger() - retrieval - * - resetSecurityLayer() - cleanup - * - handleTabClose() - tab lifecycle - * - getRequestContext() - context building - * - getSecurityLayerStats() - stats retrieval - * - * Note: seedCurrentTab() and handleNavigation() require browser/actor context - * and are tested in integration tests (Step 8) - * - * Focus: Lifecycle management and context building - */ - -const { - initSecurityLayer, - getSessionLedger, - resetSecurityLayer, - handleTabClose, - getRequestContext, - getSecurityLayerStats, -} = ChromeUtils.importESModule( - "chrome://global/content/ml/security/SmartWindowIntegration.sys.mjs" -); - -// ============================================================================ -// Test Setup/Teardown -// ============================================================================ - -/** - * Reset security layer before each test to ensure clean state - */ -add_setup(function () { - resetSecurityLayer(); -}); - -// ============================================================================ -// Test: initSecurityLayer() -// ============================================================================ - -/** - * Test that initSecurityLayer creates a new session - */ -add_task(async function test_initSecurityLayer_creates_session() { - const session = initSecurityLayer(); - - Assert.ok(session, "Should return SessionLedger"); - Assert.ok(session.sessionId, "Should have session ID"); - Assert.equal(session.tabCount(), 0, "Should start with no tabs"); -}); - -/** - * Test that initSecurityLayer accepts custom session ID - */ -add_task(async function test_initSecurityLayer_custom_id() { - const customId = "test-session-123"; - const session = initSecurityLayer(customId); - - Assert.equal(session.sessionId, customId, "Should use custom session ID"); -}); - -/** - * Test that initSecurityLayer auto-generates ID when not provided - */ -add_task(async function test_initSecurityLayer_auto_id() { - const session = initSecurityLayer(); - - Assert.ok(session.sessionId, "Should have auto-generated session ID"); - Assert.ok( - session.sessionId.startsWith("smart-window-"), - "Should use smart-window prefix" - ); -}); - -// ============================================================================ -// Test: getSessionLedger() -// ============================================================================ - -/** - * Test that getSessionLedger returns initialized session - */ -add_task(async function test_getSessionLedger_returns_session() { - const session1 = initSecurityLayer("test-session"); - const session2 = getSessionLedger(); - - Assert.equal(session1, session2, "Should return same session instance"); -}); - -/** - * Test that getSessionLedger throws when not initialized - */ -add_task(async function test_getSessionLedger_throws_when_not_initialized() { - resetSecurityLayer(); // Ensure not initialized - - Assert.throws( - () => getSessionLedger(), - /not initialized/, - "Should throw when not initialized" - ); -}); - -// ============================================================================ -// Test: resetSecurityLayer() -// ============================================================================ - -/** - * Test that resetSecurityLayer clears the session - */ -add_task(async function test_resetSecurityLayer_clears_session() { - initSecurityLayer("test-session"); - const session = getSessionLedger(); - - // Add some data - session.forTab("tab-1").add("https://example.com"); - Assert.equal(session.tabCount(), 1, "Should have tab before reset"); - - resetSecurityLayer(); - - Assert.throws( - () => getSessionLedger(), - /not initialized/, - "Should throw after reset" - ); -}); - -/** - * Test that resetSecurityLayer can be called multiple times safely - */ -add_task(async function test_resetSecurityLayer_idempotent() { - initSecurityLayer("test-session"); - - resetSecurityLayer(); - resetSecurityLayer(); // Should not throw - - Assert.ok(true, "Multiple resets should not throw"); -}); - -/** - * Test that resetSecurityLayer works when not initialized - */ -add_task(async function test_resetSecurityLayer_when_not_initialized() { - // Don't initialize, just reset - resetSecurityLayer(); - - Assert.ok(true, "Reset when not initialized should not throw"); -}); - -// ============================================================================ -// Test: handleTabClose() -// ============================================================================ - -/** - * Test that handleTabClose removes tab ledger - */ -add_task(async function test_handleTabClose_removes_tab() { - initSecurityLayer("test-session"); - const session = getSessionLedger(); - - // Add two tabs - session.forTab("tab-1").add("https://example.com"); - session.forTab("tab-2").add("https://example.com"); - Assert.equal(session.tabCount(), 2, "Should have 2 tabs"); - - handleTabClose("tab-1"); - - Assert.equal(session.tabCount(), 1, "Should have 1 tab after close"); -}); - -/** - * Test that handleTabClose works for non-existent tab - */ -add_task(async function test_handleTabClose_nonexistent_tab() { - initSecurityLayer("test-session"); - - // Should not throw for non-existent tab - handleTabClose("non-existent-tab"); - - Assert.ok(true, "Closing non-existent tab should not throw"); -}); - -// ============================================================================ -// Test: getRequestContext() -// ============================================================================ - -/** - * Test that getRequestContext builds context with current tab only - */ -add_task(async function test_getRequestContext_current_tab_only() { - initSecurityLayer("test-session"); - const session = getSessionLedger(); - - const ledger = session.forTab("tab-1"); - ledger.add("https://example.com/current"); - - const context = getRequestContext("tab-1"); - - Assert.ok(context.linkLedger, "Should have linkLedger"); - Assert.ok( - context.linkLedger.has("https://example.com/current"), - "Should have current tab URL" - ); - Assert.ok(context.sessionId, "Should have sessionId"); - Assert.ok(context.requestId, "Should have requestId"); - Assert.equal(context.currentTabId, "tab-1", "Should have currentTabId"); -}); - -/** - * Test that getRequestContext includes mentioned tabs - */ -add_task(async function test_getRequestContext_with_mentions() { - initSecurityLayer("test-session"); - const session = getSessionLedger(); - - session.forTab("tab-1").add("https://example.com/current"); - session.forTab("tab-2").add("https://example.com/mentioned1"); - session.forTab("tab-3").add("https://example.com/mentioned2"); - - const context = getRequestContext("tab-1", ["tab-2", "tab-3"]); - - Assert.ok( - context.linkLedger.has("https://example.com/current"), - "Should have current tab URL" - ); - Assert.ok( - context.linkLedger.has("https://example.com/mentioned1"), - "Should have first mentioned tab URL" - ); - Assert.ok( - context.linkLedger.has("https://example.com/mentioned2"), - "Should have second mentioned tab URL" - ); - Assert.deepEqual( - context.mentionedTabIds, - ["tab-2", "tab-3"], - "Should include mentionedTabIds" - ); -}); - -/** - * Test that getRequestContext auto-generates request ID - */ -add_task(async function test_getRequestContext_auto_request_id() { - initSecurityLayer("test-session"); - getSessionLedger().forTab("tab-1"); - - const context = getRequestContext("tab-1"); - - Assert.ok(context.requestId, "Should have auto-generated requestId"); - Assert.ok(context.requestId.startsWith("req-"), "Should use req- prefix"); -}); - -/** - * Test that getRequestContext accepts custom request ID - */ -add_task(async function test_getRequestContext_custom_request_id() { - initSecurityLayer("test-session"); - getSessionLedger().forTab("tab-1"); - - const customId = "custom-request-123"; - const context = getRequestContext("tab-1", [], customId); - - Assert.equal(context.requestId, customId, "Should use custom requestId"); -}); - -/** - * Test that getRequestContext handles empty mentioned tabs - */ -add_task(async function test_getRequestContext_empty_mentions() { - initSecurityLayer("test-session"); - const session = getSessionLedger(); - - session.forTab("tab-1").add("https://example.com"); - - const context = getRequestContext("tab-1", []); - - Assert.ok(context.linkLedger, "Should have linkLedger"); - Assert.deepEqual( - context.mentionedTabIds, - [], - "Should have empty mentionedTabIds" - ); -}); - -// ============================================================================ -// Test: getSecurityLayerStats() -// ============================================================================ - -/** - * Test that getSecurityLayerStats returns correct structure - */ -add_task(async function test_getSecurityLayerStats_structure() { - initSecurityLayer("test-session-stats"); - const session = getSessionLedger(); - - session.forTab("tab-1").add("https://example.com/page1"); - session.forTab("tab-2").add("https://example.com/page2"); - session.forTab("tab-2").add("https://example.com/page3"); - - const stats = getSecurityLayerStats(); - - Assert.equal( - stats.sessionId, - "test-session-stats", - "Should have correct sessionId" - ); - Assert.equal(stats.tabCount, 2, "Should have correct tabCount"); - Assert.equal(stats.totalUrls, 3, "Should have correct totalUrls"); - Assert.ok(Array.isArray(stats.tabIds), "Should have tabIds array"); - Assert.ok(Array.isArray(stats.tabStats), "Should have tabStats array"); - Assert.equal(stats.tabIds.length, 2, "Should have 2 tab IDs"); - Assert.equal(stats.tabStats.length, 2, "Should have 2 tab stats"); -}); - -/** - * Test that getSecurityLayerStats includes per-tab breakdown - */ -add_task(async function test_getSecurityLayerStats_per_tab() { - initSecurityLayer("test-session"); - const session = getSessionLedger(); - - session.forTab("tab-1").add("https://example.com/page1"); - session.forTab("tab-2").add("https://example.com/page2"); - session.forTab("tab-2").add("https://example.com/page3"); - - const stats = getSecurityLayerStats(); - - // Find stats for each tab - const tab1Stats = stats.tabStats.find(s => s.tabId === "tab-1"); - const tab2Stats = stats.tabStats.find(s => s.tabId === "tab-2"); - - Assert.ok(tab1Stats, "Should have stats for tab-1"); - Assert.ok(tab2Stats, "Should have stats for tab-2"); - Assert.equal(tab1Stats.urlCount, 1, "tab-1 should have 1 URL"); - Assert.equal(tab2Stats.urlCount, 2, "tab-2 should have 2 URLs"); -}); - -/** - * Test that getSecurityLayerStats works with empty session - */ -add_task(async function test_getSecurityLayerStats_empty_session() { - initSecurityLayer("test-session"); - - const stats = getSecurityLayerStats(); - - Assert.equal(stats.tabCount, 0, "Should have 0 tabs"); - Assert.equal(stats.totalUrls, 0, "Should have 0 URLs"); - Assert.equal(stats.tabIds.length, 0, "Should have empty tabIds array"); - Assert.equal(stats.tabStats.length, 0, "Should have empty tabStats array"); -}); - -// ============================================================================ -// Test: Integration - Full Lifecycle -// ============================================================================ - -/** - * Test complete lifecycle: init -> use -> reset - */ -add_task(async function test_full_lifecycle() { - // Initialize - const session = initSecurityLayer("lifecycle-test"); - Assert.ok(session, "Should initialize"); - - // Add some data - session.forTab("tab-1").add("https://example.com"); - const context = getRequestContext("tab-1"); - Assert.ok(context.linkLedger.has("https://example.com"), "Should have URL"); - - // Get stats - const stats = getSecurityLayerStats(); - Assert.equal(stats.tabCount, 1, "Should have 1 tab"); - - // Close tab - handleTabClose("tab-1"); - const statsAfterClose = getSecurityLayerStats(); - Assert.equal(statsAfterClose.tabCount, 0, "Should have 0 tabs after close"); - - // Reset - resetSecurityLayer(); - Assert.throws( - () => getSessionLedger(), - /not initialized/, - "Should be uninitialized after reset" - ); -}); - -/** - * Test that multiple init calls replace the session - */ -add_task(async function test_multiple_init_calls() { - const session1 = initSecurityLayer("session-1"); - session1.forTab("tab-1").add("https://example.com"); - - const session2 = initSecurityLayer("session-2"); - - Assert.equal(session2.sessionId, "session-2", "Should have new session ID"); - Assert.equal(session2.tabCount(), 0, "New session should be empty"); - - const currentSession = getSessionLedger(); - Assert.equal(currentSession, session2, "Should return newest session"); -}); diff --git a/toolkit/components/ml/tests/xpcshell/xpcshell.toml b/toolkit/components/ml/tests/xpcshell/xpcshell.toml index f7afc894ae377..2873fe0d1d193 100644 --- a/toolkit/components/ml/tests/xpcshell/xpcshell.toml +++ b/toolkit/components/ml/tests/xpcshell/xpcshell.toml @@ -9,4 +9,3 @@ skip-if = ["os == 'android'"] ["test_policy_evaluator.js"] ["test_security_orchestrator.js"] ["test_security_utils.js"] -["test_smart_window_integration.js"] From bf8d7952f260923d4ea58ba240de134aa6d3b9d9 Mon Sep 17 00:00:00 2001 From: Randy Concepcion Date: Wed, 3 Dec 2025 10:52:50 -0500 Subject: [PATCH 5/6] Improve security logger message in utils + reorg code - Security logs errors on no ledger available + debug if already in ledger - Reorganized imports and security functions --- .../components/smartwindow/content/utils.mjs | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/browser/components/smartwindow/content/utils.mjs b/browser/components/smartwindow/content/utils.mjs index c39f7327f7b78..8cda12e75671c 100644 --- a/browser/components/smartwindow/content/utils.mjs +++ b/browser/components/smartwindow/content/utils.mjs @@ -13,6 +13,19 @@ ChromeUtils.defineESModuleGetters(lazy, { getPlacesSemanticHistoryManager: "resource://gre/modules/PlacesSemanticHistoryManager.sys.mjs", }); +import { createEngine } from "chrome://global/content/ml/EngineProcess.sys.mjs"; +import { SmartAssistEngine } from "moz-src:///browser/components/genai/SmartAssistEngine.sys.mjs"; +import { deleteInsight, findRelatedInsight, generateInsightsFromDirectChat } from "chrome://browser/content/smartwindow/insights.mjs"; +import { EFFECT_DENY } from "chrome://global/content/ml/security/DecisionTypes.sys.mjs"; + +const { ChatHistoryMessage } = ChromeUtils.importESModule( + "resource:///modules/smartwindow/ChatHistory.sys.mjs" +); + +const { PageExtractorParent } = ChromeUtils.importESModule( + "resource://gre/actors/PageExtractorParent.sys.mjs" +); + /** * Module-level security orchestrator instance. * Set by SmartWindowPage after initialization. @@ -55,28 +68,19 @@ export function seedMentionedUrl(url) { const tabId = gBrowser.selectedTab.linkedPanel; const tabLedger = sessionLedger.forTab(tabId); - const added = tabLedger.add(url); + if (!tabLedger) { + console.error(`[Security] Unable to seed @mentioned URL: ${url} – no ledger for tab ${tabId}`); + return; + } + const added = tabLedger.add(url); if (added) { console.log(`[Security] Seeded @mentioned URL: ${url} for tab ${tabId}`); } else { - console.warn(`[Security] Failed to seed @mentioned URL: ${url} for tab ${tabId}`); + console.debug(`[Security] @mentioned URL already present in ledger: ${url} for tab ${tabId}`); } } -import { createEngine } from "chrome://global/content/ml/EngineProcess.sys.mjs"; -import { SmartAssistEngine } from "moz-src:///browser/components/genai/SmartAssistEngine.sys.mjs"; -import { deleteInsight, findRelatedInsight, generateInsightsFromDirectChat } from "chrome://browser/content/smartwindow/insights.mjs"; -import { EFFECT_DENY } from "chrome://global/content/ml/security/DecisionTypes.sys.mjs"; - -const { ChatHistoryMessage } = ChromeUtils.importESModule( - "resource:///modules/smartwindow/ChatHistory.sys.mjs" -); - -const { PageExtractorParent } = ChromeUtils.importESModule( - "resource://gre/actors/PageExtractorParent.sys.mjs" -); - /** * Detects the type of query based on patterns in the text. * Uses navigate heuristics for URLs/domains, then ML model for chat/search classification. From 647e1b5f6bb93bc68f954f85c4c9e2516b4d55e2 Mon Sep 17 00:00:00 2001 From: Randy Concepcion Date: Wed, 3 Dec 2025 23:53:10 -0500 Subject: [PATCH 6/6] Address review feedback for AI Window security integration - Consolidate URL processing into #processMetadataUrls() in SmartWindowMetaParent - Add console.warn when security policy blocks tool execution - Add comment explaining targetTab URL matching logic - Add default case to tool switch --- .../actors/SmartWindowMetaParent.sys.mjs | 144 ++++++++++-------- .../components/smartwindow/content/utils.mjs | 12 +- 2 files changed, 90 insertions(+), 66 deletions(-) diff --git a/browser/components/smartwindow/actors/SmartWindowMetaParent.sys.mjs b/browser/components/smartwindow/actors/SmartWindowMetaParent.sys.mjs index 6b826ee90bc03..ebe94045c3fef 100644 --- a/browser/components/smartwindow/actors/SmartWindowMetaParent.sys.mjs +++ b/browser/components/smartwindow/actors/SmartWindowMetaParent.sys.mjs @@ -35,74 +35,18 @@ export class SmartWindowMetaParent extends JSWindowActorParent { return result; } - const { pageUrl, canonical, ogUrl } = metadata; + // Process all URLs in one place + const processed = this.#processMetadataUrls(metadata); - const normalizedPageUrl = normalizeUrl(pageUrl); - if (!normalizedPageUrl.success) { - result.errors.push({ - url: pageUrl, - reason: "Page URL normalization failed", - error: normalizedPageUrl.error, - }); + if (processed.error) { + result.errors.push(processed.error); return result; } - const urlsToSeed = [normalizedPageUrl.url]; - result.seededUrls.push({ - original: pageUrl, - normalized: normalizedPageUrl.url, - source: "page", - }); - - if (canonical) { - const validated = this.#validateSecondaryUrl( - canonical, - normalizedPageUrl.url, - pageUrl, - "canonical" - ); - - if (validated.success) { - urlsToSeed.push(validated.normalizedUrl); - result.seededUrls.push({ - original: canonical, - normalized: validated.normalizedUrl, - source: "canonical", - }); - } else { - result.skippedUrls.push({ - original: canonical, - source: "canonical", - reason: validated.reason, - }); - } - } + result.seededUrls = processed.seededUrls; + result.skippedUrls = processed.skippedUrls; - if (ogUrl) { - const validated = this.#validateSecondaryUrl( - ogUrl, - normalizedPageUrl.url, - pageUrl, - "og:url" - ); - - if (validated.success) { - urlsToSeed.push(validated.normalizedUrl); - result.seededUrls.push({ - original: ogUrl, - normalized: validated.normalizedUrl, - source: "og:url", - }); - } else { - result.skippedUrls.push({ - original: ogUrl, - source: "og:url", - reason: validated.reason, - }); - } - } - - sessionLedger.forTab(tabId).seed(urlsToSeed, pageUrl); + sessionLedger.forTab(tabId).seed(processed.urlsToSeed, metadata.pageUrl); result.success = true; } catch (error) { result.errors.push({ @@ -114,17 +58,87 @@ export class SmartWindowMetaParent extends JSWindowActorParent { return result; } + /** + * Processes page metadata URLs: normalizes page URL and validates secondary URLs. + * + * @param {object} metadata - Raw metadata from content process + * @param {string} metadata.pageUrl - The page's URL + * @param {string} [metadata.canonical] - The canonical URL from + * @param {string} [metadata.ogUrl] - The og:url from + * @returns {object} Processed result with urlsToSeed, seededUrls, skippedUrls, error + * @private + */ + #processMetadataUrls(metadata) { + const { pageUrl, canonical, ogUrl } = metadata; + const urlsToSeed = []; + const seededUrls = []; + const skippedUrls = []; + + // Normalize page URL first + const normalizedPageUrl = normalizeUrl(pageUrl); + if (!normalizedPageUrl.success) { + return { + error: { + url: pageUrl, + reason: "Page URL normalization failed", + error: normalizedPageUrl.error, + }, + }; + } + + urlsToSeed.push(normalizedPageUrl.url); + seededUrls.push({ + original: pageUrl, + normalized: normalizedPageUrl.url, + source: "page", + }); + + // Process secondary URLs (canonical, og:url) + const secondaryUrls = [ + { url: canonical, source: "canonical" }, + { url: ogUrl, source: "og:url" }, + ]; + + for (const { url, source } of secondaryUrls) { + if (!url) { + continue; + } + + const validated = this.#validateSecondaryUrl( + url, + normalizedPageUrl.url, + pageUrl, + ); + + if (validated.success) { + urlsToSeed.push(validated.normalizedUrl); + seededUrls.push({ + original: url, + normalized: validated.normalizedUrl, + source, + }); + } else { + skippedUrls.push({ + original: url, + source, + reason: validated.reason, + }); + } + } + + return { urlsToSeed, seededUrls, skippedUrls }; + } + /** * Validates a secondary URL (canonical or og:url) against the page's eTLD+1. * * @param {string} url - The URL to validate (may be relative) * @param {string} normalizedPageUrl - The normalized page URL for eTLD+1 comparison * @param {string} baseUrl - The original page URL for resolving relative URLs - * @param {string} source - Source identifier ("canonical" or "og:url") * @returns {object} Validation result with success flag and normalizedUrl or reason * @private */ - #validateSecondaryUrl(url, normalizedPageUrl, baseUrl, source) { + #validateSecondaryUrl(url, normalizedPageUrl, baseUrl) { const normalized = normalizeUrl(url, baseUrl); if (!normalized.success) { diff --git a/browser/components/smartwindow/content/utils.mjs b/browser/components/smartwindow/content/utils.mjs index 8cda12e75671c..f06d8b8438f42 100644 --- a/browser/components/smartwindow/content/utils.mjs +++ b/browser/components/smartwindow/content/utils.mjs @@ -1004,6 +1004,10 @@ export async function* fetchWithHistory(messages, allowedUrls) { ); if (!securityCheck.allowed) { + console.warn( + `[Security] Tool execution blocked: ${toolName}`, + securityCheck.reason + ); result = { error: securityCheck.reason || "Security policy denied this action", }; @@ -1019,9 +1023,11 @@ export async function* fetchWithHistory(messages, allowedUrls) { // Get current tab ID from tool params or browser let currentTabId = toolParams.tabId; if (!currentTabId && toolParams.url) { - // Try to find tab by URL let win = lazy.BrowserWindowTracker.getTopWindow(); let gBrowser = win.gBrowser; + // Find the tab whose URL matches the tool's target URL. + // Compare with trailing slashes stripped to handle inconsistencies + // between how URLs are stored vs. provided by the LLM. let targetTab = gBrowser.tabs.find(tab => { const tabUrl = tab.linkedBrowser.currentURI.spec; return ( @@ -1070,6 +1076,10 @@ export async function* fetchWithHistory(messages, allowedUrls) { case DELETE_INSIGHT: result = await deleteRelevantInsight(toolParams); break; + default: + console.error(`[Tools] Tool "${toolName}" is in TOOLS array but not implemented in switch`); + result = { error: `Tool "${toolName}" is not implemented` }; + break; } }