tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit ecc2f60f296d13c9e17b9936257bcab828c5715f
parent 509ee60b49bcfdb7f85454650e407c7a97b18d85
Author: Randy Concepcion <rconcepcion@mozilla.com>
Date:   Thu, 18 Dec 2025 02:46:56 +0000

Bug 2003190 - Add policy engine to security layer r=ai-ondevice-reviewers,gregtatum

Implements a policy-based security layer for AI Window to protect against
prompt injection attacks. The security layer validates tool execution requests
against a URL ledger that tracks user-authorized URLs.

Key components:
- SecurityOrchestrator: Central coordinator with evaluation flow documentation
- PolicyEvaluator: JSON policy evaluation with "first deny wins" strategy
- ConditionEvaluator: Safe condition evaluation for policy rules
- SecurityLogger: Console-based audit logging (stub for now)
- SecurityUtils: URL normalization, eTLD validation, ledger management
- DecisionTypes: Type definitions and helpers for allow/deny decisions

Differential Revision: https://phabricator.services.mozilla.com/D274523

Diffstat:
Mtoolkit/components/ml/jar.mn | 7+++++++
Mtoolkit/components/ml/moz.build | 1+
Atoolkit/components/ml/security/ConditionEvaluator.sys.mjs | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/security/DecisionTypes.sys.mjs | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/security/PolicyEvaluator.sys.mjs | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/security/SecurityLogger.sys.mjs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/security/SecurityOrchestrator.sys.mjs | 359+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/security/SecurityUtils.sys.mjs | 419+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/security/moz.build | 6++++++
Atoolkit/components/ml/security/policies/POLICY_AUTHORING.md | 484+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/security/policies/tool-execution-policies.json | 28++++++++++++++++++++++++++++
Atoolkit/components/ml/tests/xpcshell/test_condition_evaluator.js | 315+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/tests/xpcshell/test_decision_types.js | 366+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/tests/xpcshell/test_json_policy_system.js | 524+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/tests/xpcshell/test_policy_evaluator.js | 366+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/tests/xpcshell/test_security_orchestrator.js | 380+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/ml/tests/xpcshell/test_security_utils.js | 551+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/components/ml/tests/xpcshell/xpcshell.toml | 10++++++++++
18 files changed, 4607 insertions(+), 0 deletions(-)

diff --git 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 @@ -14,6 +14,7 @@ with Files("**"): DIRS += [ "actors", + "security", ] if CONFIG["MOZ_WIDGET_TOOLKIT"] != "android": diff --git a/toolkit/components/ml/security/ConditionEvaluator.sys.mjs b/toolkit/components/ml/security/ConditionEvaluator.sys.mjs @@ -0,0 +1,252 @@ +/* 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. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = XPCOMUtils.declareLazy({ + console: () => + console.createInstance({ + maxLogLevelPref: "browser.ml.logLevel", + prefix: "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 + */ +export function resolveConditionPath(path, action, context) { + if (!path || typeof path !== "string") { + lazy.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 { + lazy.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 + */ +export function evaluateCondition(condition, action, context) { + if (!condition || !condition.type) { + lazy.console.error( + "[ConditionEvaluator] Invalid condition object:", + condition + ); + return false; + } + + try { + switch (condition.type) { + case "allUrlsIn": + return evaluateAllUrlsIn(condition, action, context); + + case "equals": + return evaluateEquals(condition, action, context); + + case "matches": + return evaluateMatches(condition, action, context); + + case "noPatternInParams": + return evaluateNoPatternInParams(condition, action, context); + + default: + lazy.console.error( + `[ConditionEvaluator] Unknown condition type: ${condition.type}` + ); + return false; + } + } catch (error) { + lazy.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 + */ +function evaluateAllUrlsIn(condition, action, context) { + const urls = resolveConditionPath(condition.urls, action, context); + const ledger = resolveConditionPath(condition.ledger, action, context); + + if (!urls || !Array.isArray(urls) || urls.length === 0) { + return true; + } + + if (!ledger || typeof ledger.has !== "function") { + lazy.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)); + lazy.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 + */ +function evaluateEquals(condition, action, context) { + const actualValue = resolveConditionPath(condition.actual, action, context); + const expectedValue = condition.expected; + + const result = actualValue === expectedValue; + + if (!result) { + lazy.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 + */ +function evaluateMatches(condition, action, context) { + const value = resolveConditionPath(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) { + lazy.console.warn( + `[ConditionEvaluator] Pattern match failed: ${value} does not match ${condition.pattern}` + ); + } + + return result; + } catch (error) { + lazy.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 + */ +function evaluateNoPatternInParams(condition, action, context) { + const params = resolveConditionPath(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) { + lazy.console.warn( + `[ConditionEvaluator] Blocked pattern found in params: ${condition.pattern}`, + condition.description || "" + ); + } + + return !found; + } catch (error) { + lazy.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 @@ -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 - Denial code (see DenialCodes) + * @property {string} reason - 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 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 createAllowDecision = () => + /** @type {SecurityDecisionAllow} */ ({ + effect: EFFECT_ALLOW, + }); + +/** + * Creates a "deny" decision with structured error information. + * + * @param {string} code - Denial code from DenialCodes + * @param {string} reason - Reason phrase for denial (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 createDenyDecision = ( + 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 isAllowDecision = 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 isDenyDecision = decision => decision?.effect === EFFECT_DENY; diff --git a/toolkit/components/ml/security/PolicyEvaluator.sys.mjs b/toolkit/components/ml/security/PolicyEvaluator.sys.mjs @@ -0,0 +1,307 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = XPCOMUtils.declareLazy({ + EFFECT_ALLOW: "chrome://global/content/ml/security/DecisionTypes.sys.mjs", + EFFECT_DENY: "chrome://global/content/ml/security/DecisionTypes.sys.mjs", + createAllowDecision: + "chrome://global/content/ml/security/DecisionTypes.sys.mjs", + createDenyDecision: + "chrome://global/content/ml/security/DecisionTypes.sys.mjs", + evaluateCondition: + "chrome://global/content/ml/security/ConditionEvaluator.sys.mjs", + resolveConditionPath: + "chrome://global/content/ml/security/ConditionEvaluator.sys.mjs", + console: () => + console.createInstance({ + maxLogLevelPref: "browser.ml.logLevel", + prefix: "PolicyEvaluator", + }), +}); + +/** + * Evaluates JSON-based security policies using "first deny wins" strategy. + * Delegates condition evaluation to ConditionEvaluator. + */ + +/** + * 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. + * + * @example + * // Exact match - Policy from tool-execution-policies.json: + * // "match": { "action.type": "tool.call", "action.tool": "get_page_content" } + * // + * // Action from AI Window tool dispatch: + * // { type: "tool.call", tool: "get_page_content", urls: [...], tabId: "tab-1" } + * // + * // checkPolicyMatch resolves "action.type" -> "tool.call" and + * // "action.tool" -> "get_page_content", both match, so returns true. + * + * @example + * // OR condition - Policy matching multiple tools: + * // "match": { "action.tool": "get_page_content|search_history" } + * // + * // Action: { type: "tool.call", tool: "search_history", query: "..." } + * // + * // checkPolicyMatch resolves "action.tool" -> "search_history", which + * // matches one of the OR options, so returns true. + * + * @example + * // Wildcard - Policy matching all tools: + * // "match": { "action.type": "tool.call", "action.tool": "*" } + * // + * // Action: { type: "tool.call", tool: "any_tool_name", ... } + * // + * // checkPolicyMatch resolves "action.tool" -> "any_tool_name", which + * // matches the wildcard "*", so returns true. + * + * @param {object} matchCriteria - Match object from policy (e.g., { "action.type": "tool.call" }) + * @param {object} action - Action to check against (e.g., { type: "tool.call", tool: "get_page_content" }) + * @returns {boolean} True if policy applies to this action + */ +export function checkPolicyMatch(matchCriteria, action) { + lazy.console.debug( + "[PolicyEvaluator] checkPolicyMatch 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 = lazy.resolveConditionPath(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 is enabled + * 2. Check if policy matches action (match criteria) + * 3. If not, return null (policy doesn't apply) + * 4. Evaluate conditions once: + * - DENY policies: failing a condition triggers denial; success => null + * - ALLOW policies: all conditions must pass to allow; failure => deny + * + * @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 - lazy.EFFECT_DENY or lazy.EFFECT_ALLOW + * @param {object} policy.onDeny - Denial information for deny policies + * @param {object} action - Action being evaluated + * @param {object} context - Request context + * @returns {object|null} Decision object or null if policy doesn't apply + */ +export function evaluatePolicy(policy, action, context) { + if (!policy.enabled) { + return null; + } + + if (!checkPolicyMatch(policy.match, action)) { + return null; + } + + const conditions = policy.conditions || []; + + // Evaluate conditions once and remember the first failure (if any) + let failedCondition = null; + for (const condition of conditions) { + const result = lazy.evaluateCondition(condition, action, context); + if (!result) { + failedCondition = condition; + break; + } + } + + // No failed condition → all conditions passed + if (!failedCondition) { + if (policy.effect === lazy.EFFECT_DENY) { + // DENY policy with all conditions passing => no denial applies + return null; + } + + // ALLOW policy with all conditions passing => explicit allow + return lazy.createAllowDecision({ + policyId: policy.id, + note: "All policy conditions satisfied", + }); + } + + // At least one condition failed + if (policy.effect === lazy.EFFECT_DENY) { + // DENY policy: failing a condition triggers a deny with policy-specific info + return lazy.createDenyDecision(policy.onDeny.code, policy.onDeny.reason, { + policyId: policy.id, + failedCondition: failedCondition.type, + conditionDescription: failedCondition.description, + }); + } + + // ALLOW policy: failing a condition means we can't allow + return lazy.createDenyDecision( + "POLICY_CONDITION_FAILED", + "Policy condition not met", + { + policyId: policy.id, + failedCondition: failedCondition.type, + } + ); +} + +/** + * 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) + */ +export function evaluatePhasePolicies(policies, action, context) { + if (!policies || policies.length === 0) { + lazy.console.warn("[PolicyEvaluator] No policies provided for evaluation"); + return lazy.createAllowDecision({ note: "No policies to evaluate" }); + } + + let appliedPolicies = 0; + + for (const policy of policies) { + const decision = evaluatePolicy(policy, action, context); + + if (decision === null) { + continue; + } + + appliedPolicies++; + + if (decision.effect === lazy.EFFECT_DENY) { + lazy.console.warn( + `[PolicyEvaluator] Policy ${policy.id} denied action:`, + decision.reason + ); + return decision; + } + } + + if (appliedPolicies === 0) { + lazy.console.warn( + "[PolicyEvaluator] No policies applied to action:", + action.type, + action.tool || "" + ); + } + + return lazy.createAllowDecision({ + 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[] } + */ +export function 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 !== lazy.EFFECT_DENY && + policy.effect !== lazy.EFFECT_ALLOW + ) { + errors.push("Field 'effect' must be 'deny' or 'allow'"); + } + + // Conditional requirements + if (policy.effect === lazy.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 @@ -0,0 +1,48 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = XPCOMUtils.declareLazy({ + EFFECT_DENY: "chrome://global/content/ml/security/DecisionTypes.sys.mjs", + console: () => + console.createInstance({ + maxLogLevelPref: "browser.ml.logLevel", + prefix: "SecurityLogger", + }), +}); + +/** + * Security audit logger (stub - console only). + * + * TODO (Bug 2003214): Phase 2 POC - Part 2: Implement Security Logger + */ + +/** + * 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 + */ +export function logSecurityEvent(event) { + const { phase, decision, durationMs, error } = event; + + if (error) { + lazy.console.error( + `[${phase}] Security evaluation error:`, + error.message || error + ); + } else if (decision.effect === lazy.EFFECT_DENY) { + lazy.console.warn( + `[${phase}] DENY: ${decision.code} - ${decision.reason} (${durationMs}ms)` + ); + } else { + lazy.console.debug(`[${phase}] ALLOW (${durationMs}ms)`); + } +} diff --git a/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs b/toolkit/components/ml/security/SecurityOrchestrator.sys.mjs @@ -0,0 +1,359 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = XPCOMUtils.declareLazy({ + SessionLedger: "chrome://global/content/ml/security/SecurityUtils.sys.mjs", + logSecurityEvent: + "chrome://global/content/ml/security/SecurityLogger.sys.mjs", + EFFECT_ALLOW: "chrome://global/content/ml/security/DecisionTypes.sys.mjs", + createAllowDecision: + "chrome://global/content/ml/security/DecisionTypes.sys.mjs", + createDenyDecision: + "chrome://global/content/ml/security/DecisionTypes.sys.mjs", + validatePolicy: "chrome://global/content/ml/security/PolicyEvaluator.sys.mjs", + evaluatePhasePolicies: + "chrome://global/content/ml/security/PolicyEvaluator.sys.mjs", + console: () => + console.createInstance({ + maxLogLevelPref: "browser.ml.logLevel", + prefix: "SecurityOrchestrator", + }), +}); + +/** + * Dev/emergency kill-switch for security enforcement. + * When false, all security checks are bypassed and allow is returned. + * Should remain true in production. Consider restricting to debug builds in follow-up. + */ +const PREF_SECURITY_ENABLED = "browser.ml.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 creates its own SecurityOrchestrator via create(). + * + * ## Evaluation Flow + * + * 1. Caller invokes evaluate() with an envelope containing: + * - phase: Security checkpoint (e.g., "tool.execution") + * - action: What's being attempted (tool name, URLs, etc.) + * - context: Request metadata (tabId, requestId, etc.) + * + * 2. Orchestrator checks preference flag (browser.ml.security.enabled) + * - If disabled: logs bypass and returns allow + * + * 3. Orchestrator looks up policies registered for the phase + * - If none: returns allow with note + * + * 4. Orchestrator builds context with session ledger (trusted URLs) + * - Merges ledgers from current tab and any @mentioned tabs + * + * 5. PolicyEvaluator evaluates policies using "first deny wins": + * - Each policy's match criteria checked against action + * - If match, conditions evaluated via ConditionEvaluator + * - First denial terminates evaluation + * + * 6. Decision logged via SecurityLogger and returned to caller + * + * ## Key Components + * + * - SessionLedger: Tracks trusted URLs per tab (seeded from page metadata) + * - PolicyEvaluator: Evaluates JSON policies against actions + * - ConditionEvaluator: Evaluates individual policy conditions + * - SecurityLogger: Audit logging for all decisions + */ +export class SecurityOrchestrator { + /** + * Registry of security policies by phase. + * + * @type {Map<string, Array<object>>} + */ + #policies = new Map(); + + /** + * Session ledger for URL tracking across tabs in this window. + * + * @type {lazy.SessionLedger} + */ + #sessionLedger; + + /** + * Session identifier for this window. + * + * @type {string} + */ + #sessionId; + + /** + * Used by create() to instantiate SecurityOrchestrator instance. + * + * @param {string} sessionId - Unique identifier for this session + */ + constructor(sessionId) { + this.#sessionId = sessionId; + this.#sessionLedger = new lazy.SessionLedger(sessionId); + } + + /** + * Creates and initializes a new SecurityOrchestrator instance. + * + * @param {string} sessionId - Unique identifier for this session + * @returns {Promise<SecurityOrchestrator>} Initialized orchestrator instance + */ + static async create(sessionId) { + const instance = new SecurityOrchestrator(sessionId); + await instance.#loadPolicies(); + + lazy.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"]; + + for (const file of policyFiles) { + const response = await fetch( + `chrome://global/content/ml/security/policies/${file}` + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch policy file ${file}: ${response.status}` + ); + } + + const data = await response.json(); + + // Validate policy file structure + if (!data.policies || !Array.isArray(data.policies)) { + throw new Error( + `Invalid policy file structure in ${file}: missing 'policies' array` + ); + } + + // Validate each policy + for (const policy of data.policies) { + const validation = lazy.validatePolicy(policy); + if (!validation.valid) { + throw new Error( + `Invalid policy '${policy.id}' in ${file}: ${validation.errors.join(", ")}` + ); + } + + // Group by phase + if (!this.#policies.has(policy.phase)) { + this.#policies.set(policy.phase, []); + } + this.#policies.get(policy.phase).push(policy); + } + + lazy.console.debug( + `[Security] Loaded ${data.policies.length} policies from ${file}` + ); + } + + lazy.console.debug( + `[Security] Policy loading complete: ${this.#policies.size} phases` + ); + } + + /** + * Gets the session ledger for this orchestrator. + * + * @returns {lazy.SessionLedger} The session ledger + */ + getSessionLedger() { + return this.#sessionLedger; + } + + /** + * Main entry point for all security checks. + * + * The envelope wraps a security check request, containing all information + * needed to evaluate policies: which phase is being checked, what action + * is being attempted, and the context in which it's occurring. + * + * @example + * // AI Window dispatching a tool call: + * 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: ["tab-2"], + * requestId: "req-123" + * } + * }); + * // Returns: { effect: "allow" } or { effect: "deny", code: "UNSEEN_LINK", ... } + * + * @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<object>} Decision object with effect (allow/deny), code, reason + */ + async evaluate(envelope) { + const startTime = ChromeUtils.now(); + + try { + if (!envelope || typeof envelope !== "object") { + return lazy.createDenyDecision( + "INVALID_REQUEST", + "Security envelope is null or invalid" + ); + } + + const { phase, action, context } = envelope; + if (!phase || !action || !context) { + return lazy.createDenyDecision( + "INVALID_REQUEST", + "Security envelope missing required fields (phase, action, or context)" + ); + } + + if (!isSecurityEnabled()) { + lazy.logSecurityEvent({ + phase, + action, + context, + decision: { + effect: lazy.EFFECT_ALLOW, + reason: "Security disabled via preference flag", + }, + durationMs: ChromeUtils.now() - startTime, + prefSwitchBypass: true, + }); + return { effect: lazy.EFFECT_ALLOW }; + } + + const policies = this.#policies.get(phase); + if (!policies || policies.length === 0) { + lazy.console.warn( + `[Security] No policies registered for phase: ${phase}` + ); + return lazy.createAllowDecision({ note: "No policies for phase" }); + } + + const fullContext = { + ...context, + sessionLedger: this.#sessionLedger, + sessionId: this.#sessionId, + timestamp: ChromeUtils.now(), + }; + + const { currentTabId, mentionedTabIds = [] } = context; + const tabsToCheck = [currentTabId, ...mentionedTabIds]; + const linkLedger = this.#sessionLedger.merge(tabsToCheck); + fullContext.linkLedger = linkLedger; + + const decision = lazy.evaluatePhasePolicies( + policies, + action, + fullContext + ); + + lazy.logSecurityEvent({ + phase, + action, + context: fullContext, + decision, + durationMs: ChromeUtils.now() - startTime, + }); + + return decision; + } catch (error) { + const errorDecision = lazy.createDenyDecision( + "EVALUATION_ERROR", + "Security evaluation failed with unexpected error", + { error: error.message || String(error) } + ); + + lazy.logSecurityEvent({ + phase: envelope.phase || "unknown", + action: envelope.action || {}, + context: envelope.context || {}, + decision: errorDecision, + durationMs: ChromeUtils.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 @@ -0,0 +1,419 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Security utilities for Firefox Smart Window security layer. + * + * This module provides: + * - URL normalization for consistent comparison + * - eTLD+1 (effective top-level domain) validation + * - TabLedger: Per-tab trusted URL storage + * - SessionLedger: Container for all tab ledgers in a Smart Window session + * + * Security Model: + * --------------- + * - Each tab maintains its own ledger of trusted URLs + * - Request-scoped context merges current tab + @mentioned tabs + * - URLs are normalized before storage and comparison + * - Same eTLD+1 validation prevents injection via canonical/og:url + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = XPCOMUtils.declareLazy({ + console: () => + console.createInstance({ + maxLogLevelPref: "browser.ml.logLevel", + prefix: "SecurityUtils", + }), +}); + +/** 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 = 1000; + +/** 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. + * + * Ensures the same logical URL always produces the same normalized string, + * regardless of superficial differences like: + * - Default ports (https://example.com:443 → https://example.com) + * - Fragments (https://example.com#section → https://example.com) + * - Query param order (sorted alphabetically) + * - Tracking params (utm_*, fbclid, etc. are stripped) + * - Case differences in hostname + * + * @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 cleanedParams = new URLSearchParams(); + + for (const [key, value] of url.searchParams) { + if (key.startsWith("utm_") || TRACKING_PARAMS.has(key)) { + continue; + } + cleanedParams.append(key, value); + } + + cleanedParams.sort(); + const search = cleanedParams.toString(); + + let normalizedUrl = `${url.protocol}//${url.hostname}`; + + if (url.port) { + normalizedUrl += `:${url.port}`; + } + + normalizedUrl += url.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 areSameSite(url1, url2) { + try { + const parsed1 = new URL(url1); + const parsed2 = new URL(url2); + + const eTLD1 = Services.eTLD.getBaseDomainFromHost(parsed1.hostname); + const eTLD2 = Services.eTLD.getBaseDomainFromHost(parsed2.hostname); + + return eTLD1 === eTLD2; + } catch (error) { + lazy.console.error("areSameSite 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 + */ + constructor(tabId) { + this.tabId = tabId; + this.ttlMs = DEFAULT_TTL_MS; + this.maxUrls = MAX_URLS_PER_TAB; + + /** @type {Map<string, number>} URL --> expiration timestamp */ + this.urls = new Map(); + + /** @type {number} Last cleanup timestamp */ + this.lastCleanup = ChromeUtils.now(); + } + + /** + * Seeds the ledger with initial URLs. + * + * Behavior: + * - Runs cleanup of expired entries before adding + * - Invalid URLs are skipped silently (no error thrown) + * - Stops adding when maxUrls limit is reached + * - Each URL expires after TTL (default 30 minutes) + * + * @param {string[]} urls - URLs to seed + * @param {string} [baseUrl] - Optional base URL for resolving relative URLs + */ + seed(urls, baseUrl = null) { + this.#cleanup(); + + const now = ChromeUtils.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 = ChromeUtils.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 (ChromeUtils.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 = ChromeUtils.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 = ChromeUtils.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 an AI Window session. + * + * A session represents a single AI Window instance. Each AI Window + * creates its own SessionLedger when opened. The session ends when the AI + * Window is closed. + * + * SessionLedger manages the lifecycle of individual TabLedgers and provides + * methods to build request-scoped contexts by merging tab ledgers. + * + * Lifetime: SessionLedger is ephemeral and in-memory only. It is scoped to + * the current browser session and cleared on restart. Ledgers are not + * persisted to disk or restored via session restore. + */ +export class SessionLedger { + /** + * Creates a new session ledger. + * + * @param {string} sessionId - The Smart Window session identifier + */ + constructor(sessionId) { + this.sessionId = sessionId; + + /** @type {Map<string, TabLedger>} 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 = ChromeUtils.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; + }, + }; + } + + /** + * Removes a tab's ledger completely. + * Typically called when tab closes. + * + * @param {string} tabId - The tab identifier + */ + removeTab(tabId) { + this.tabs.delete(tabId); + } + + /** Clears all tab ledgers. */ + clearAll() { + for (const ledger of this.tabs.values()) { + ledger.clear(); + } + this.tabs.clear(); + } + + /** @returns {number} Number of tabs */ + tabCount() { + return this.tabs.size; + } +} diff --git a/toolkit/components/ml/security/moz.build 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 = ("Core", "Machine Learning: General") diff --git a/toolkit/components/ml/security/policies/POLICY_AUTHORING.md b/toolkit/components/ml/security/policies/POLICY_AUTHORING.md @@ -0,0 +1,484 @@ +# 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. + +## Enforcement + +Policies in `tool-execution-policies.json` are evaluated at the **tool dispatch** layer. When AI Window attempts to execute a tool (e.g., `get_page_content`), the security layer evaluates the request against registered policies and returns an allow or deny decision. If denied, the tool call is blocked. + +The evaluation flow: + +1. AI Window prepares a tool execution request +2. Request is sent to `SecurityOrchestrator.evaluate()` with phase, action, and context +3. PolicyEvaluator checks all policies registered for that phase +4. First matching policy that denies blocks the request ("first deny wins") +5. If no policy denies, the action is allowed + +**Session Ledger Lifetime:** The session ledger used by policies is in-memory only. It is scoped to the current browser session and cleared on restart. Ledgers are not persisted to disk or restored via session restore. + +## Policy Trust Model + +Policies are **bundled with Firefox** as part of the source tree. They are reviewed like any other code change and are not loaded from the network or from user-modifiable locations. + +In the current design, policies are treated as trusted configuration, not untrusted input. This means: + +- Policies go through standard code review before landing +- End users cannot modify or inject custom policies +- Runtime validation exists as a development aid, not a security boundary + +## Policy File Structure + +```json +{ + "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`: Code for type of denial (e.g., "UNSEEN_LINK") +- `reason`: Explanation of denial + +### 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 + +> **Note:** The policies in this section are illustrative examples only. They are not the actual policies shipped in Firefox and should not be treated as final security guidance. + +### 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. + +## 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 @@ -0,0 +1,28 @@ +{ + "description": "Security policies for Smart Window tool execution", + "policies": [ + { + "id": "block-unseen-links", + "phase": "tool.execution", + "enabled": true, + "description": "Prevent tools from accessing URLs not in trusted page context. This is the core 'explicit seeding' policy that blocks prompt injection attacks by ensuring tools can only access URLs that the user has explicitly made available (current page, @mentioned tabs, etc.)", + "match": { + "action.type": "tool.call", + "action.tool": "get_page_content" + }, + "conditions": [ + { + "type": "allUrlsIn", + "urls": "action.urls", + "ledger": "context.linkLedger", + "description": "All URLs must be present in the request-scoped ledger (merged from current tab + @mentioned tabs)" + } + ], + "effect": "deny", + "onDeny": { + "code": "UNSEEN_LINK", + "reason": "URL not in selected request context" + } + } + ] +} diff --git a/toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js b/toolkit/components/ml/tests/xpcshell/test_condition_evaluator.js @@ -0,0 +1,315 @@ +/* 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.ml.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 = null; +} + +/** + * Test: condition passes when all URLs are present in the ledger. + * + * Reason: + * The `allUrlsIn` condition should allow a tool call only when + * every URL in `action.urls` exists in the request-scoped ledger. + * This ensures that tool execution is restricted to trusted, + * user-visible URLs and prevents unseen-link tool calls. + */ +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(); +}); + +/** + * Test: condition fails when any URL is missing from the ledger. + * + * Reason: + * If even one URL in `action.urls` is not in the ledger, the condition + * must fail and deny the request. This enforces all-or-nothing security — + * partial trust is not acceptable for URL-based tool access. + */ +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(); +}); + +/** + * Test: condition passes with an empty URLs array. + * + * Reason: + * When no URLs are requested, there's nothing to validate. The condition + * should pass (vacuous truth) since there are no untrusted URLs to block. + * This allows tools that don't require URL access to proceed. + */ +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(); +}); + +/** + * Test: condition fails with a malformed URL. + * + * Reason: + * Malformed URLs cannot be normalized or matched against the ledger. + * The security layer must fail-closed: if a URL can't be validated, + * it's treated as unseen and denied rather than allowed. + */ +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: condition checks current tab's ledger only (no mentions). + * + * Reason: + * When no @mentioned tabs are provided, the security check should only + * consider URLs from the current tab's ledger. This establishes the + * baseline isolation behavior before testing cross-tab merging. + */ +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(); +}); + +/** + * Test: condition merges current tab with @mentioned tabs. + * + * Reason: + * The @mentions feature allows users to explicitly grant access to URLs + * from other tabs. When `mentionedTabIds` is provided, the security layer + * must merge those ledgers with the current tab's ledger for validation. + * This enables cross-tab workflows while maintaining explicit user consent. + */ +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: condition normalizes URLs before comparison. + * + * Reason: + * URLs that differ only in fragments (#section) refer to the same resource. + * The security layer must normalize URLs (stripping fragments, default ports, + * etc.) so that superficial differences don't cause false denials. A user + * who visited `example.com/page` should be allowed to access `example.com/page#section`. + */ +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 @@ -0,0 +1,366 @@ +/* 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, + createAllowDecision, + createDenyDecision, + isAllowDecision, + isDenyDecision, +} = ChromeUtils.importESModule( + "chrome://global/content/ml/security/DecisionTypes.sys.mjs" +); + +/** + * Test: SecurityPolicyError constructor captures all decision properties. + * + * Reason: + * SecurityPolicyError wraps deny decisions for structured error handling. + * The constructor must capture code, policyId, reason, and details so that + * error handlers can log meaningful security violation messages and take + * appropriate action based on the denial reason. + */ +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: SecurityPolicyError.toJSON() serializes correctly. + * + * Reason: + * Security errors need to be logged for debugging and auditing. + * The toJSON() method must produce a JSON-serializable object + * containing all relevant fields (code, policyId, message, decision). + */ +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: SecurityPolicyError can be thrown and caught. + * + * Reason: + * The security layer uses throw/catch for control flow when policies deny + * requests. The error must behave as a standard Error subclass so it can + * be caught, inspected, and handled by callers up the stack. + */ +add_task(async function test_error_throw_catch() { + const decision = createDenyDecision("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: createAllowDecision() returns correct structure. + * + * Reason: + * Allow decisions are the "happy path" result. The helper must return + * a minimal object with only `effect: "allow"` to keep the API simple + * and avoid confusion with deny decision properties. + */ +add_task(async function test_allow_helper() { + const decision = createAllowDecision(); + + Assert.equal(decision.effect, "allow", "Should have effect 'allow'"); + Assert.equal( + Object.keys(decision).length, + 1, + "Should only have 'effect' property" + ); +}); + +/** + * Test: createDenyDecision() with all parameters. + * + * Reason: + * Deny decisions carry diagnostic information (code, reason, details, policyId) + * needed for logging, debugging, and user feedback. The helper must correctly + * assemble all provided parameters into the decision structure. + */ +add_task(async function test_deny_helper_full() { + const decision = createDenyDecision( + "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: createDenyDecision() uses default policyId. + * + * Reason: + * Most denials come from the "block-unseen-links" policy. Providing a sensible + * default reduces boilerplate at call sites while still allowing override + * when needed for other policies. + */ +add_task(async function test_deny_helper_default_policy() { + const decision = createDenyDecision("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: isAllowDecision() returns true for allow decisions. + * + * Reason: + * Type guards enable safe control flow based on decision type. isAllowDecision() + * must correctly identify allow decisions so callers can branch logic without + * risking property access errors on deny decisions. + */ +add_task(async function test_isAllow_with_allow_decision() { + const decision = createAllowDecision(); + Assert.ok(isAllowDecision(decision), "Should return true for allow decision"); +}); + +/** + * Test: isAllowDecision() returns false for deny decisions. + * + * Reason: + * The type guard must distinguish between decision types. Returning true + * for a deny decision would cause callers to skip error handling, potentially + * allowing unauthorized actions. + */ +add_task(async function test_isAllow_with_deny_decision() { + const decision = createDenyDecision("CODE", "reason"); + Assert.ok( + !isAllowDecision(decision), + "Should return false for deny decision" + ); +}); + +/** + * Test: isAllowDecision() handles null/undefined/invalid gracefully. + * + * Reason: + * Defensive programming requires type guards to handle malformed input + * without throwing. Returning false for invalid input ensures callers + * fall through to denial handling rather than crashing. + */ +add_task(async function test_isAllow_with_invalid() { + Assert.ok(!isAllowDecision(null), "Should return false for null"); + Assert.ok(!isAllowDecision(undefined), "Should return false for undefined"); + Assert.ok(!isAllowDecision({}), "Should return false for empty object"); + Assert.ok( + !isAllowDecision({ effect: "maybe" }), + "Should return false for invalid effect" + ); +}); + +/** + * Test: isDenyDecision() returns true for deny decisions. + * + * Reason: + * Type guards enable safe control flow based on decision type. isDenyDecision() + * must correctly identify deny decisions so callers can extract error details + * (code, reason, policyId) without risking undefined property access. + */ +add_task(async function test_isDeny_with_deny_decision() { + const decision = createDenyDecision("CODE", "reason"); + Assert.ok(isDenyDecision(decision), "Should return true for deny decision"); +}); + +/** + * Test: isDenyDecision() returns false for allow decisions. + * + * Reason: + * The type guard must distinguish between decision types. Returning true + * for an allow decision would cause unnecessary error handling for + * legitimate requests. + */ +add_task(async function test_isDeny_with_allow_decision() { + const decision = createAllowDecision(); + Assert.ok( + !isDenyDecision(decision), + "Should return false for allow decision" + ); +}); + +/** + * Test: isDenyDecision() handles null/undefined/invalid gracefully. + * + * Reason: + * Defensive programming requires type guards to handle malformed input + * without throwing. Returning false for invalid input ensures callers + * don't incorrectly treat garbage data as a deny decision. + */ +add_task(async function test_isDeny_with_invalid() { + Assert.ok(!isDenyDecision(null), "Should return false for null"); + Assert.ok(!isDenyDecision(undefined), "Should return false for undefined"); + Assert.ok(!isDenyDecision({}), "Should return false for empty object"); + Assert.ok( + !isDenyDecision({ effect: "maybe" }), + "Should return false for invalid effect" + ); +}); + +/** + * Test: complete flow from createDenyDecision() to SecurityPolicyError to toJSON(). + * + * Reason: + * This integration test validates the full error handling pipeline used in + * production: a policy creates a deny decision, wraps it in SecurityPolicyError, + * and serializes it for logging. All data must flow through correctly. + */ +add_task(async function test_deny_to_error_to_json() { + const decision = createDenyDecision( + 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: allow/deny decisions work correctly in control flow. + * + * Reason: + * Validates the common pattern used throughout the codebase: check decision + * type with guards, then branch accordingly. This ensures the helpers and + * guards compose correctly for real-world usage. + */ +add_task(async function test_decision_control_flow() { + const allowDecision = createAllowDecision(); + const denyDecision = createDenyDecision("CODE", "reason"); + + // Simulate policy evaluation control flow + function processDecision(decision) { + if (isAllowDecision(decision)) { + return "allowed"; + } else if (isDenyDecision(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 @@ -0,0 +1,524 @@ +/* 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.ml.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); +} + +function teardown() { + Services.prefs.clearUserPref(PREF_SECURITY_ENABLED); + orchestrator = null; +} + +/** + * Test: JSON policy file loads and has valid structure. + * + * Reason: + * The policy JSON file is fetched at runtime. This test validates that + * the file exists, parses correctly, and contains the required fields + * (id, phase, effect). Build-time validation catches authoring errors. + */ +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(); +}); + +/** + * Test: SecurityOrchestrator initializes with policies loaded. + * + * Reason: + * The orchestrator must load policies during initialization so they're + * available for evaluation. This test verifies the full initialization + * path works and policies are functional (not just loaded). + */ +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 for unseen link. + * + * Reason: + * Core security behavior: URLs not in the ledger must be denied. + * This validates the real JSON policy produces the expected denial + * with correct code and policyId. + */ +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(); +}); + +/** + * Test: end-to-end deny if any URL is unseen. + * + * Reason: + * All-or-nothing security: if a request includes multiple URLs and + * any one is unseen, the entire request must be denied. Partial + * trust is not acceptable. + */ +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(); +}); + +/** + * Test: end-to-end deny for malformed URL. + * + * Reason: + * Fail-closed behavior: URLs that can't be parsed or normalized + * cannot be validated against the ledger. They must be treated + * as unseen and denied. + */ +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 for seeded URL. + * + * Reason: + * Core functionality: URLs that have been seeded into the ledger + * (from user-visible page context) must be allowed. This is the + * happy path for legitimate tool calls. + */ +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(); +}); + +/** + * Test: end-to-end allow for multiple seeded URLs. + * + * Reason: + * Tool calls may request multiple URLs. When all URLs are in the + * ledger, the request should be allowed. Validates that the + * allUrlsIn condition handles arrays correctly. + */ +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(); +}); + +/** + * Test: end-to-end allow for empty URLs array. + * + * Reason: + * Some tool calls don't require URL access. An empty URLs array + * has nothing to validate, so the request should be allowed. + */ +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: end-to-end allow for URL from @mentioned tab. + * + * Reason: + * The @mentions feature lets users explicitly grant access to URLs + * from other tabs. When a URL exists in a mentioned tab's ledger, + * the request should be allowed. + */ +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(); +}); + +/** + * Test: end-to-end deny for URL not in current or @mentioned tabs. + * + * Reason: + * Even with @mentions, URLs must exist in some trusted ledger. + * A URL not present in the current tab or any mentioned tab + * must still be denied. + */ +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: end-to-end URL normalization strips fragments. + * + * Reason: + * URLs differing only by fragment (#section) refer to the same resource. + * Normalization ensures a user who visited `page` can access `page#section` + * without false denials. + */ +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: end-to-end preference switch bypasses policies. + * + * Reason: + * The preference switch (browser.ml.security.enabled=false) must bypass all + * policy enforcement, allowing everything through. This enables + * debugging and provides an escape hatch if policies cause issues. + */ +add_task(async function test_e2e_pref_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 pref switch is off + tabId: "tab-1", + }, + context: { + currentTabId: "tab-1", + mentionedTabIds: [], + requestId: "test-prefswitch", + }, + }); + + Assert.equal( + decision.effect, + "allow", + "Pref switch OFF: should bypass all policies (allow everything)" + ); + + teardown(); +}); diff --git a/toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js b/toolkit/components/ml/tests/xpcshell/test_policy_evaluator.js @@ -0,0 +1,366 @@ +/* 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.ml.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 = null; +} + +/** + * Test: policy matches the correct phase. + * + * Reason: + * Policies are scoped to specific phases (e.g., "tool.execution"). + * A policy should only evaluate when the envelope's phase matches, + * ensuring policies don't interfere with unrelated operations. + */ +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(); +}); + +/** + * Test: policy ignores unknown phases. + * + * Reason: + * When no policy matches the requested phase, the default behavior + * is to allow. This ensures new phases can be added without requiring + * policy updates, and unknown phases don't cause false denials. + */ +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 policy denies when condition fails. + * + * Reason: + * A deny policy with a failing condition (URL not in ledger) must + * produce a deny decision with code, reason, policyId, and details. + * This is the core security enforcement mechanism. + */ +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(); +}); + +/** + * Test: deny policy passes through when condition passes. + * + * Reason: + * A deny policy only blocks when its condition fails. When the condition + * passes (all URLs in ledger), the policy doesn't apply and the request + * is allowed. This ensures legitimate requests aren't blocked. + */ +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: policy checks all URLs in the request. + * + * Reason: + * All-or-nothing security: if any URL in the request is unseen, + * the entire request must be denied. Checking only the first URL + * would allow attackers to smuggle unseen URLs in multi-URL requests. + */ +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(); +}); + +/** + * Test: policy allows when all URLs are valid. + * + * Reason: + * When every URL in the request exists in the ledger, the condition + * passes and the request is allowed. This validates the happy path + * for multi-URL tool calls. + */ +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: policy applies to get_page_content tool. + * + * Reason: + * The get_page_content tool fetches external URLs and is the primary + * vector for prompt injection attacks. The block-unseen-links policy + * must apply to this tool to prevent malicious URL access. + */ +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: deny decision includes policy information. + * + * Reason: + * Deny decisions must include diagnostic information (code, reason, + * policyId, details) for logging and debugging. This helps identify + * which policy blocked a request and why. + */ +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 @@ -0,0 +1,380 @@ +/* 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 + * - Preference 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.ml.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 = null; +} + +/** + * Test: initialization creates a session with ledger. + * + * Reason: + * SecurityOrchestrator.create() must initialize a functional session + * with an empty ledger ready for URL seeding. This is the entry point + * for all security layer operations. + */ +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(); +}); + +/** + * Test: preference switch disabled allows everything. + * + * Reason: + * When browser.ml.security.enabled=false, all policy enforcement is + * bypassed. This provides a debugging escape hatch and allows the + * feature to be disabled without code changes. + */ +add_task(async function test_pref_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", + "Pref switch OFF: should allow everything (pass-through)" + ); + + teardown(); +}); + +/** + * Test: preference switch enabled enforces policies. + * + * Reason: + * When browser.ml.security.enabled=true (the default), policies must + * be enforced. Unseen URLs should be denied. This is the expected + * production behavior. + */ +add_task(async function test_pref_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", "Pref switch ON: should enforce"); + Assert.equal(decision.code, "UNSEEN_LINK", "Should deny unseen links"); + + teardown(); +}); + +/** + * Test: preference switch responds to runtime changes. + * + * Reason: + * The preference is checked on each evaluate() call, not cached at + * initialization. This allows toggling security on/off without + * restarting the browser or recreating the orchestrator. + */ +add_task(async function test_pref_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(); +}); + +/** + * Test: invalid envelope fails closed. + * + * Reason: + * Malformed envelopes (missing phase, action, or context) must be + * denied rather than allowed. Fail-closed behavior ensures that + * broken or malicious requests don't bypass security checks. + */ +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(); +}); + +/** + * Test: policy allows seeded URL. + * + * Reason: + * URLs added to the ledger represent user-visible, trusted content. + * Tool calls requesting these URLs should be allowed. This is the + * core functionality enabling legitimate AI-assisted browsing. + */ +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(); +}); + +/** + * Test: policy denies unseen URL. + * + * Reason: + * URLs not in the ledger are untrusted and potentially injected by + * malicious page content. They must be denied to prevent prompt + * injection attacks from directing tools to attacker-controlled URLs. + */ +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(); +}); + +/** + * Test: policy denies if any URL is unseen. + * + * Reason: + * All-or-nothing security: a request with multiple URLs must have + * all URLs in the ledger. If any URL is unseen, the entire request + * is denied. Partial trust is not acceptable. + */ +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(); +}); + +/** + * Test: malformed URL fails closed. + * + * Reason: + * URLs that cannot be parsed or normalized cannot be validated + * against the ledger. They must be treated as unseen and denied + * rather than allowed by default. + */ +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 @@ -0,0 +1,551 @@ +/* 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 + * - areSameSite() - 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, areSameSite, TabLedger, SessionLedger } = + ChromeUtils.importESModule( + "chrome://global/content/ml/security/SecurityUtils.sys.mjs" + ); + +/** + * Test: valid HTTP URLs normalize successfully. + * + * Reason: + * HTTP URLs are valid input for the security layer. The normalizer + * must accept them and return a normalized form for consistent + * ledger comparison. + */ +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: valid HTTPS URLs normalize successfully. + * + * Reason: + * HTTPS URLs are the primary input for the security layer. The normalizer + * must accept them and preserve the scheme in the output. + */ +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: URLs with query parameters normalize successfully. + * + * Reason: + * Query parameters are part of resource identity. The normalizer must + * preserve them so that URLs like `page?id=1` and `page?id=2` are + * treated as distinct resources. + */ +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: empty string fails normalization. + * + * Reason: + * Empty strings are invalid URLs. The normalizer must reject them + * with an error rather than returning an empty or malformed result. + */ +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: whitespace-only string fails normalization. + * + * Reason: + * Whitespace-only strings are invalid URLs. The normalizer must + * reject them rather than treating whitespace as a valid resource. + */ +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: invalid URL format fails normalization. + * + * Reason: + * Malformed URLs cannot be validated against the ledger. The normalizer + * must reject them so the security layer can deny the request (fail-closed). + */ +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: non-http/https schemes fail normalization. + * + * Reason: + * Only http/https URLs are valid for web content fetching. Schemes like + * ftp://, file://, and javascript: must be rejected to prevent attacks + * using unexpected protocol handlers. + */ +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: null/undefined fail normalization gracefully. + * + * Reason: + * Defensive programming: the normalizer must handle null/undefined + * without throwing, returning a failure result instead. + */ +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: fragments are removed during normalization. + * + * Reason: + * Fragments (#section) identify positions within a page, not different + * resources. Stripping them ensures `page` and `page#section` are treated + * as the same resource for security purposes. + */ +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: tracking parameters are removed during normalization. + * + * Reason: + * Tracking parameters (utm_source, etc.) don't change the resource. + * Stripping them prevents false denials when the same page is accessed + * with different tracking parameters. + */ +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: relative URLs work with baseUrl. + * + * Reason: + * Page content may contain relative URLs. The normalizer must resolve + * them against a base URL to produce absolute URLs for ledger comparison. + */ +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: same domain returns true for areSameSite. + * + * Reason: + * Identical domains share the same eTLD+1. This is the baseline case + * for same-site validation. + */ +add_task(async function test_areSameSite_same_domain() { + const result = areSameSite("https://example.com", "https://example.com"); + + Assert.ok(result, "Should return true for same domain"); +}); + +/** + * Test: subdomain and apex domain return true. + * + * Reason: + * www.example.com and example.com share the same eTLD+1 (example.com). + * They should be considered same-site for security purposes. + */ +add_task(async function test_areSameSite_subdomain() { + const result = areSameSite("https://www.example.com", "https://example.com"); + + Assert.ok(result, "Should return true for subdomain vs apex"); +}); + +/** + * Test: different subdomains of same eTLD+1 return true. + * + * Reason: + * blog.example.com and shop.example.com share the same eTLD+1. + * They should be considered same-site for security purposes. + */ +add_task(async function test_areSameSite_different_subdomains() { + const result = areSameSite( + "https://blog.example.com", + "https://shop.example.com" + ); + + Assert.ok(result, "Should return true for different subdomains"); +}); + +/** + * Test: different domains return false. + * + * Reason: + * example.com and evil.com have different eTLD+1 values. They must + * be treated as different sites to prevent cross-site attacks. + */ +add_task(async function test_areSameSite_different_domains() { + const result = areSameSite("https://example.com", "https://evil.com"); + + Assert.ok(!result, "Should return false for different domains"); +}); + +/** + * Test: subdomain injection attempt returns false. + * + * Reason: + * example.com.evil.com has eTLD+1 of evil.com, not example.com. + * This attack pattern must be detected and rejected. + */ +add_task(async function test_areSameSite_injection_attempt() { + const result = areSameSite( + "https://example.com", + "https://example.com.evil.com" + ); + + Assert.ok(!result, "Should return false for subdomain injection attempt"); +}); + +/** + * Test: invalid URLs return false (fail-closed). + * + * Reason: + * If either URL is invalid, same-site comparison should return false. + * Fail-closed behavior ensures malformed input doesn't bypass checks. + */ +add_task(async function test_areSameSite_invalid_urls() { + const result = areSameSite("not-a-url", "https://example.com"); + + Assert.ok(!result, "Should return false for invalid URL (fail-closed)"); +}); + +/** + * Test: TabLedger can be created. + * + * Reason: + * TabLedger is the per-tab URL storage. It must initialize correctly + * with a tab ID and start empty. + */ +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: seed() adds multiple URLs to ledger. + * + * Reason: + * When a page loads, multiple URLs (page URL, linked resources) are + * seeded at once. seed() must add all valid URLs to the 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: add() adds individual URLs. + * + * Reason: + * Single URLs may be added incrementally (e.g., dynamic content). + * add() must work for individual URL additions. + */ +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: has() returns false for URLs not in ledger. + * + * Reason: + * The core security check: has() must return false for unseen URLs + * so the policy can deny access to untrusted resources. + */ +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: clear() empties the ledger. + * + * Reason: + * When a tab navigates to a new page, the old URLs are no longer + * valid. clear() must remove all URLs from 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: ledger enforces size limit. + * + * Reason: + * Unbounded ledger growth could cause memory issues. The size limit + * prevents malicious pages from bloating the ledger with many URLs. + */ +add_task(async function test_TabLedger_size_limit() { + const maxUrls = 1000; + const ledger = new TabLedger("tab-123"); + + // 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: invalid URLs are rejected gracefully. + * + * Reason: + * Malformed URLs (empty strings, null, non-URLs) should be silently + * ignored rather than added to the ledger or causing exceptions. + */ +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 can be created. + * + * Reason: + * SessionLedger manages per-tab ledgers for a session. It must + * initialize with a session ID and start with no tabs. + */ +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: forTab() creates and retrieves tab ledgers. + * + * Reason: + * forTab() is the primary interface for accessing tab ledgers. It must + * create a new ledger on first access and return the same instance + * on subsequent calls for the same tab. + */ +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: different tabs get different ledgers. + * + * Reason: + * Tab isolation: each tab must have its own ledger. URLs from one tab + * should not be automatically trusted in another tab. + */ +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: merge() combines URLs from multiple tabs. + * + * Reason: + * The @mentions feature requires merging ledgers from multiple tabs. + * merge() must return a combined set of URLs from all specified 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: removeTab() removes a tab's ledger. + * + * Reason: + * When a tab is closed, its ledger should be removed to free memory. + * Accessing the same tab ID later should create a fresh empty 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: clearAll() clears all tab ledgers. + * + * Reason: + * Session reset or cleanup may require removing all ledgers at once. + * clearAll() must remove all tabs and their associated 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: ledgers normalize URLs consistently. + * + * Reason: + * URLs must be normalized both when added and when checked. A URL + * added with a fragment should match a check without the fragment + * (and vice versa) after normalization. + */ +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 @@ -1,5 +1,15 @@ [DEFAULT] head = "" skip-if = ["os == 'android'"] +prefs = [ + "browser.ml.logLevel=Info", + "browser.ml.security.enabled=true", +] +["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"]