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..ebe94045c3fef --- /dev/null +++ b/browser/components/smartwindow/actors/SmartWindowMetaParent.sys.mjs @@ -0,0 +1,163 @@ +/* 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; + } + + // Process all URLs in one place + const processed = this.#processMetadataUrls(metadata); + + if (processed.error) { + result.errors.push(processed.error); + return result; + } + + result.seededUrls = processed.seededUrls; + result.skippedUrls = processed.skippedUrls; + + sessionLedger.forTab(tabId).seed(processed.urlsToSeed, metadata.pageUrl); + result.success = true; + } catch (error) { + result.errors.push({ + message: "Actor communication failed", + error: error.message || String(error), + }); + } + + 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 + * @returns {object} Validation result with success flag and normalizedUrl or reason + * @private + */ + #validateSecondaryUrl(url, normalizedPageUrl, baseUrl) { + 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/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 102f504b5ce7a..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, @@ -31,11 +31,39 @@ 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 { generateId } = ChromeUtils.importESModule( + "chrome://global/content/ml/security/SecurityUtils.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?.toLowerCase().includes("already registered")) { + console.error("Failed to register SmartWindowMeta actor:", e); + } +} + /** * */ @@ -88,6 +116,32 @@ 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 = generateId("smart-window"); + const securityEnabled = Services.prefs.getBoolPref("browser.smartwindow.security.enabled", true); + + this.securityOrchestrator = null; + + if (securityEnabled) { + 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; + setSecurityOrchestrator(null); + console.warn("[Security] AI Window security DISABLED via kill switch - running in pass-through mode"); + } + gBrowser.selectedTab.conversation = new ChatHistoryConversation({ title: "", description: "", @@ -134,6 +188,11 @@ class SmartWindowPage { this.onboardingPrefObserver ); } + + 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 b49bd47304c46..f06d8b8438f42 100644 --- a/browser/components/smartwindow/content/utils.mjs +++ b/browser/components/smartwindow/content/utils.mjs @@ -16,6 +16,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" @@ -25,6 +26,61 @@ const { PageExtractorParent } = ChromeUtils.importESModule( "resource://gre/actors/PageExtractorParent.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); + 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.debug(`[Security] @mentioned URL already present in ledger: ${url} for tab ${tabId}`); + } +} + /** * Detects the type of query based on patterns in the text. * Uses navigate heuristics for URLs/domains, then ML model for chat/search classification. @@ -556,6 +612,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 = 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 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 === 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 +996,90 @@ 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) { + console.warn( + `[Security] Tool execution blocked: ${toolName}`, + securityCheck.reason + ); + result = { + error: securityCheck.reason || "Security policy denied this action", + }; + } else { + // 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 + // (canonical URLs, related links, etc.) even if they're not in open tabs. + let mergedAllowedUrls = new Set(allowedUrls || []); + try { + const sessionLedger = securityOrchestrator?.getSessionLedger(); + + if (sessionLedger) { + // Get current tab ID from tool params or browser + let currentTabId = toolParams.tabId; + if (!currentTabId && toolParams.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 ( + tabUrl === toolParams.url || + tabUrl.replace(/\/$/, "") === + toolParams.url.replace(/\/$/, "") + ); + }); + if (targetTab) { + currentTabId = targetTab.linkedPanel; + } + } + + if (currentTabId) { + const tabLedger = sessionLedger.forTab(currentTabId); + if (tabLedger) { + 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 mergedAllowedUrls for headless extraction:", + error + ); } - 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, mergedAllowedUrls); + 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; + default: + console.error(`[Tools] Tool "${toolName}" is in TOOLS array but not implemented in switch`); + result = { error: `Tool "${toolName}" is not implemented` }; + 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/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/jar.mn b/toolkit/components/ml/jar.mn index 7dbb7c5afc7bf..78b55d15be768 100644 --- a/toolkit/components/ml/jar.mn +++ b/toolkit/components/ml/jar.mn @@ -28,6 +28,13 @@ 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/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 4eab5600b06e3..063b17d9bc59c 100644 --- a/toolkit/components/ml/moz.build +++ b/toolkit/components/ml/moz.build @@ -12,11 +12,16 @@ JAR_MANIFESTS += ["jar.mn"] with Files("**"): BUG_COMPONENT = ("Core", "Machine Learning") -DIRS += ["actors"] +DIRS += [ + "actors", + "security", +] 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/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..216d2fa6b0b85 --- /dev/null +++ b/toolkit/components/ml/security/DecisionTypes.sys.mjs @@ -0,0 +1,184 @@ +/* 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, + }; + } +} + +/** + * @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. + // 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: 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: 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 === 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 === 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..c0c1a7b63229d --- /dev/null +++ b/toolkit/components/ml/security/PolicyEvaluator.sys.mjs @@ -0,0 +1,246 @@ +/* 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 { + EFFECT_ALLOW, + EFFECT_DENY, + 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 {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 + * @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 === 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 === 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 === 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 !== EFFECT_DENY && policy.effect !== EFFECT_ALLOW) { + errors.push("Field 'effect' must be 'deny' or 'allow'"); + } + + // Conditional requirements + 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)) { + 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..f3c605b2d1959 --- /dev/null +++ b/toolkit/components/ml/security/SecurityLogger.sys.mjs @@ -0,0 +1,46 @@ +/* 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 { EFFECT_DENY } from "chrome://global/content/ml/security/DecisionTypes.sys.mjs"; + +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 === 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..b1d477cc73601 --- /dev/null +++ b/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs @@ -0,0 +1,328 @@ +/* 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 { + EFFECT_ALLOW, + 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. + * Each AI Window instance should create its own SecurityOrchestrator. + */ +export class SecurityOrchestrator { + /** + * Registry of security policies by phase. + * + * @type {Map>} + */ + #policies = new Map(); + + /** + * Session ledger for URL tracking across tabs in this window. + * + * @type {SessionLedger | null} + */ + #sessionLedger = null; + + /** + * Session identifier for this window. + * + * @type {string | null} + */ + #sessionId = null; + + /** + * Used by create() to instantiate SecurityOrchestrator instance. + * + * @param {string} sessionId - Unique identifier for this session + */ + constructor(sessionId) { + this.#sessionId = sessionId; + this.#sessionLedger = new SessionLedger(sessionId); + } + + /** + * 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( + instance.#policies.values() + ).reduce((sum, policies) => sum + policies.length, 0)} policies` + ); + + return instance; + } + + /** + * Loads and validates policies from JSON files. + * + * @private + */ + async #loadPolicies() { + 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. + * Call this when the AI Window closes. + */ + reset() { + this.#sessionLedger = null; + this.#sessionId = null; + this.#policies.clear(); + } + + /** + * Gets the session ledger for this orchestrator. + * + * @returns {SessionLedger | null} The session ledger + * @throws {Error} If orchestrator not initialized AND security is enabled + */ + getSessionLedger() { + if (!this.#sessionLedger) { + if (!isSecurityEnabled()) { + return null; + } + + throw new Error("Security orchestrator not initialized."); + } + return this.#sessionLedger; + } + + /** + * Main entry point for all security checks. + * + * @param {object} envelope - Security check request + * @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 + */ + 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: EFFECT_ALLOW, + reason: "Security disabled via kill switch", + }, + durationMs: Date.now() - startTime, + killSwitchBypass: true, + }); + return { effect: 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. + * + * @param {string} phase - Phase identifier to remove + * @returns {boolean} True if policies were removed, false if not found + */ + removePolicy(phase) { + return this.#policies.delete(phase); + } + + /** + * Gets statistics about the orchestrator state. + * + * @returns {object} Stats object with registered policies, session info, etc. + */ + getStats() { + const totalPolicies = Array.from(this.#policies.values()).reduce( + (sum, policies) => sum + policies.length, + 0 + ); + + 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..cbed5871a0def --- /dev/null +++ b/toolkit/components/ml/security/SecurityUtils.sys.mjs @@ -0,0 +1,449 @@ +/* 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 + * - Unique ID generation (given a prefix) + * + * 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; + } +} + +/** + * 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/moz.build b/toolkit/components/ml/security/moz.build new file mode 100644 index 0000000000000..53b00bc213969 --- /dev/null +++ b/toolkit/components/ml/security/moz.build @@ -0,0 +1,6 @@ +# 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") 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/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_condition_evaluator.js b/toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js new file mode 100644 index 0000000000000..8b1ab08e10238 --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js @@ -0,0 +1,270 @@ +/* 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"; + +/** @type {SecurityOrchestrator|null} */ +let orchestrator = null; + +function setup() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); +} + +function teardown() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + orchestrator?.reset(); + orchestrator = null; +} + +// ============================================================================ +// Test: allUrlsIn Condition Behavior (via SecurityOrchestrator) +// ============================================================================ + +add_task(async function test_condition_passes_when_all_urls_in_ledger() { + setup(); + + 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 orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com"); + + const decision = await orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com"); + + const decision = await orchestrator.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(); + + 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 orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com/page"); // No fragment + + const decision = await orchestrator.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..b6e78ac786dcd --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_json_policy_system.js @@ -0,0 +1,455 @@ +/* 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"; + +/** @type {SecurityOrchestrator|null} */ +let orchestrator = null; + +function setup() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); +} + +function teardown() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + orchestrator?.reset(); + orchestrator = null; +} + +// ============================================================================ +// 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 create succeeds, policies loaded correctly + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + + Assert.ok(ledger, "Should initialize successfully"); + Assert.ok(orchestrator.getSessionLedger(), "Should have session ledger"); + + // Verify policies work by testing actual evaluation + ledger.forTab("tab-1"); + const decision = await orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); // Empty ledger + + const decision = await orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + const tabLedger = ledger.forTab("tab-1"); + tabLedger.add("https://example.com"); + + const decision = await orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + const tabLedger = ledger.forTab("tab-1"); + tabLedger.add("https://example.com"); + + const decision = await orchestrator.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(); + + 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 orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.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 orchestrator.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(); + + 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 orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com/page"); // No fragment + + const decision = await orchestrator.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); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); // Empty ledger + + const decision = await orchestrator.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_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/test_policy_evaluator.js b/toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js new file mode 100644 index 0000000000000..f9c1485306f6f --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js @@ -0,0 +1,324 @@ +/* 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"; + +/** @type {SecurityOrchestrator|null} */ +let orchestrator = null; + +function setup() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + Services.prefs.setBoolPref(PREF_SECURITY_ENABLED, true); +} + +function teardown() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + orchestrator?.reset(); + orchestrator = null; +} + +// ============================================================================ +// Test: Policy Matching Behavior +// ============================================================================ + +add_task(async function test_policy_matches_correct_phase() { + setup(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + // tool.execution phase should match our policies + const decision = await orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + // Unknown phase should not match any policies + const decision = await orchestrator.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(); + + 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 orchestrator.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(); + + 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 orchestrator.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(); + + 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 orchestrator.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(); + + 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 orchestrator.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(); + + 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 orchestrator.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(); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await orchestrator.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..206bb3653ea2e --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/test_security_orchestrator.js @@ -0,0 +1,329 @@ +/* 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"; + +/** @type {SecurityOrchestrator|null} */ +let orchestrator = null; + +function setup() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); +} + +function teardown() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + orchestrator?.reset(); + orchestrator = null; +} + +// ============================================================================= +// Initialization Tests +// ============================================================================= + +add_task(async function test_initialization_creates_session() { + setup(); + + 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( + orchestrator.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); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); // Empty ledger + + const decision = await orchestrator.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); + + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await orchestrator.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); + orchestrator = await SecurityOrchestrator.create("test-session"); + const ledger = orchestrator.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 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 orchestrator.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); + orchestrator = await SecurityOrchestrator.create("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 orchestrator.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); + orchestrator = await SecurityOrchestrator.create("test-session"); + + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com"); + + const decision = await orchestrator.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); + orchestrator = await SecurityOrchestrator.create("test-session"); + + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); // Empty ledger + + const decision = await orchestrator.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); + orchestrator = await SecurityOrchestrator.create("test-session"); + + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1").add("https://example.com"); + + const decision = await orchestrator.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); + orchestrator = await SecurityOrchestrator.create("test-session"); + + const ledger = orchestrator.getSessionLedger(); + ledger.forTab("tab-1"); + + const decision = await orchestrator.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/xpcshell.toml b/toolkit/components/ml/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000000..2873fe0d1d193 --- /dev/null +++ b/toolkit/components/ml/tests/xpcshell/xpcshell.toml @@ -0,0 +1,11 @@ +[DEFAULT] +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"]