Source code

Revision control

Copy as Markdown

Other Tools

/* 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,
};
}