/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import {} from '@google/genai';
import { PolicyDecision, getHookSource, ApprovalMode, } from './types.js';
import { stableStringify } from './stable-stringify.js';
import { debugLogger } from '../utils/debugLogger.js';
import { SafetyCheckDecision } from '../safety/protocol.js';
import { SHELL_TOOL_NAMES, initializeShellParsers, splitCommands, hasRedirection, } from '../utils/shell-utils.js';
function ruleMatches(rule, toolCall, stringifiedArgs, serverName, currentApprovalMode) {
    // Check if rule applies to current approval mode
    if (rule.modes && rule.modes.length > 0) {
        if (!rule.modes.includes(currentApprovalMode)) {
            return false;
        }
    }
    // Check tool name if specified
    if (rule.toolName) {
        // Support wildcard patterns: "serverName__*" matches "serverName__anyTool"
        if (rule.toolName.endsWith('__*')) {
            const prefix = rule.toolName.slice(0, -3); // Remove "__*"
            if (serverName !== undefined) {
                // Robust check: if serverName is provided, it MUST match the prefix exactly.
                // This prevents "malicious-server" from spoofing "trusted-server" by naming itself "trusted-server__malicious".
                if (serverName !== prefix) {
                    return false;
                }
            }
            // Always verify the prefix, even if serverName matched
            if (!toolCall.name || !toolCall.name.startsWith(prefix + '__')) {
                return false;
            }
        }
        else if (toolCall.name !== rule.toolName) {
            return false;
        }
    }
    // Check args pattern if specified
    if (rule.argsPattern) {
        // If rule has an args pattern but tool has no args, no match
        if (!toolCall.args) {
            return false;
        }
        // Use stable JSON stringification with sorted keys to ensure consistent matching
        if (stringifiedArgs === undefined ||
            !rule.argsPattern.test(stringifiedArgs)) {
            return false;
        }
    }
    return true;
}
/**
 * Check if a hook checker rule matches a hook execution context.
 */
function hookCheckerMatches(rule, context) {
    // Check event name if specified
    if (rule.eventName && rule.eventName !== context.eventName) {
        return false;
    }
    // Check hook source if specified
    if (rule.hookSource && rule.hookSource !== context.hookSource) {
        return false;
    }
    return true;
}
export class PolicyEngine {
    rules;
    checkers;
    hookCheckers;
    defaultDecision;
    nonInteractive;
    checkerRunner;
    allowHooks;
    approvalMode;
    constructor(config = {}, checkerRunner) {
        this.rules = (config.rules ?? []).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
        this.checkers = (config.checkers ?? []).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
        this.hookCheckers = (config.hookCheckers ?? []).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
        this.defaultDecision = config.defaultDecision ?? PolicyDecision.ASK_USER;
        this.nonInteractive = config.nonInteractive ?? false;
        this.checkerRunner = checkerRunner;
        this.allowHooks = config.allowHooks ?? true;
        this.approvalMode = config.approvalMode ?? ApprovalMode.DEFAULT;
    }
    /**
     * Update the current approval mode.
     */
    setApprovalMode(mode) {
        this.approvalMode = mode;
    }
    /**
     * Get the current approval mode.
     */
    getApprovalMode() {
        return this.approvalMode;
    }
    /**
     * Check if a shell command is allowed.
     */
    async checkShellCommand(toolName, command, ruleDecision, serverName, dir_path, allowRedirection) {
        if (!command) {
            return this.applyNonInteractiveMode(ruleDecision);
        }
        await initializeShellParsers();
        const subCommands = splitCommands(command);
        if (subCommands.length === 0) {
            debugLogger.debug(`[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to ASK_USER.`);
            return this.applyNonInteractiveMode(PolicyDecision.ASK_USER);
        }
        // If there are multiple parts, or if we just want to validate the single part against DENY rules
        if (subCommands.length > 0) {
            debugLogger.debug(`[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`);
            if (ruleDecision === PolicyDecision.DENY) {
                return PolicyDecision.DENY;
            }
            // Start optimistically. If all parts are ALLOW, the whole is ALLOW.
            // We will downgrade if any part is ASK_USER or DENY.
            let aggregateDecision = PolicyDecision.ALLOW;
            for (const subCmd of subCommands) {
                // Prevent infinite recursion for the root command
                if (subCmd === command) {
                    if (!allowRedirection && hasRedirection(subCmd)) {
                        debugLogger.debug(`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`);
                        // Redirection always downgrades ALLOW to ASK_USER
                        if (aggregateDecision === PolicyDecision.ALLOW) {
                            aggregateDecision = PolicyDecision.ASK_USER;
                        }
                    }
                    else {
                        // If the command is atomic (cannot be split further) and didn't
                        // trigger infinite recursion checks, we must respect the decision
                        // of the rule that triggered this check. If the rule was ASK_USER
                        // (e.g. wildcard), we must downgrade.
                        if (ruleDecision === PolicyDecision.ASK_USER &&
                            aggregateDecision === PolicyDecision.ALLOW) {
                            aggregateDecision = PolicyDecision.ASK_USER;
                        }
                    }
                    continue;
                }
                const subResult = await this.check({ name: toolName, args: { command: subCmd, dir_path } }, serverName);
                // subResult.decision is already filtered through applyNonInteractiveMode by this.check()
                const subDecision = subResult.decision;
                // If any part is DENIED, the whole command is DENIED
                if (subDecision === PolicyDecision.DENY) {
                    return PolicyDecision.DENY;
                }
                // If any part requires ASK_USER, the whole command requires ASK_USER
                if (subDecision === PolicyDecision.ASK_USER) {
                    if (aggregateDecision === PolicyDecision.ALLOW) {
                        aggregateDecision = PolicyDecision.ASK_USER;
                    }
                }
                // Check for redirection in allowed sub-commands
                if (subDecision === PolicyDecision.ALLOW &&
                    !allowRedirection &&
                    hasRedirection(subCmd)) {
                    debugLogger.debug(`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`);
                    if (aggregateDecision === PolicyDecision.ALLOW) {
                        aggregateDecision = PolicyDecision.ASK_USER;
                    }
                }
            }
            return this.applyNonInteractiveMode(aggregateDecision);
        }
        return this.applyNonInteractiveMode(ruleDecision);
    }
    /**
     * Check if a tool call is allowed based on the configured policies.
     * Returns the decision and the matching rule (if any).
     */
    async check(toolCall, serverName) {
        let stringifiedArgs;
        // Compute stringified args once before the loop
        if (toolCall.args &&
            (this.rules.some((rule) => rule.argsPattern) ||
                this.checkers.some((checker) => checker.argsPattern))) {
            stringifiedArgs = stableStringify(toolCall.args);
        }
        debugLogger.debug(`[PolicyEngine.check] toolCall.name: ${toolCall.name}, stringifiedArgs: ${stringifiedArgs}`);
        // Find the first matching rule (already sorted by priority)
        let matchedRule;
        let decision;
        for (const rule of this.rules) {
            if (ruleMatches(rule, toolCall, stringifiedArgs, serverName, this.approvalMode)) {
                debugLogger.debug(`[PolicyEngine.check] MATCHED rule: toolName=${rule.toolName}, decision=${rule.decision}, priority=${rule.priority}, argsPattern=${rule.argsPattern?.source || 'none'}`);
                if (toolCall.name && SHELL_TOOL_NAMES.includes(toolCall.name)) {
                    const args = toolCall.args;
                    decision = await this.checkShellCommand(toolCall.name, args?.command, rule.decision, serverName, args?.dir_path, rule.allowRedirection);
                }
                else {
                    decision = this.applyNonInteractiveMode(rule.decision);
                }
                matchedRule = rule;
                break;
            }
        }
        if (!decision) {
            // No matching rule found, use default decision
            debugLogger.debug(`[PolicyEngine.check] NO MATCH - using default decision: ${this.defaultDecision}`);
            decision = this.applyNonInteractiveMode(this.defaultDecision);
        }
        // If decision is not DENY, run safety checkers
        if (decision !== PolicyDecision.DENY && this.checkerRunner) {
            for (const checkerRule of this.checkers) {
                if (ruleMatches(checkerRule, toolCall, stringifiedArgs, serverName, this.approvalMode)) {
                    debugLogger.debug(`[PolicyEngine.check] Running safety checker: ${checkerRule.checker.name}`);
                    try {
                        const result = await this.checkerRunner.runChecker(toolCall, checkerRule.checker);
                        if (result.decision === SafetyCheckDecision.DENY) {
                            debugLogger.debug(`[PolicyEngine.check] Safety checker denied: ${result.reason}`);
                            return {
                                decision: PolicyDecision.DENY,
                                rule: matchedRule,
                            };
                        }
                        else if (result.decision === SafetyCheckDecision.ASK_USER) {
                            debugLogger.debug(`[PolicyEngine.check] Safety checker requested ASK_USER: ${result.reason}`);
                            decision = PolicyDecision.ASK_USER;
                        }
                    }
                    catch (error) {
                        debugLogger.debug(`[PolicyEngine.check] Safety checker failed: ${error}`);
                        return {
                            decision: PolicyDecision.DENY,
                            rule: matchedRule,
                        };
                    }
                }
            }
        }
        return {
            decision: this.applyNonInteractiveMode(decision),
            rule: matchedRule,
        };
    }
    /**
     * Add a new rule to the policy engine.
     */
    addRule(rule) {
        this.rules.push(rule);
        // Re-sort rules by priority
        this.rules.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
    }
    addChecker(checker) {
        this.checkers.push(checker);
        this.checkers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
    }
    /**
     * Remove rules for a specific tool.
     */
    removeRulesForTool(toolName) {
        this.rules = this.rules.filter((rule) => rule.toolName !== toolName);
    }
    /**
     * Get all current rules.
     */
    getRules() {
        return this.rules;
    }
    getCheckers() {
        return this.checkers;
    }
    /**
     * Add a new hook checker to the policy engine.
     */
    addHookChecker(checker) {
        this.hookCheckers.push(checker);
        this.hookCheckers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
    }
    /**
     * Get all current hook checkers.
     */
    getHookCheckers() {
        return this.hookCheckers;
    }
    /**
     * Check if a hook execution is allowed based on the configured policies.
     * Runs hook-specific safety checkers if configured.
     */
    async checkHook(request) {
        // If hooks are globally disabled, deny all hook executions
        if (!this.allowHooks) {
            return PolicyDecision.DENY;
        }
        const context = 'input' in request
            ? {
                eventName: request.eventName,
                hookSource: getHookSource(request.input),
                trustedFolder: typeof request.input['trusted_folder'] === 'boolean'
                    ? request.input['trusted_folder']
                    : undefined,
            }
            : request;
        // In untrusted folders, deny project-level hooks
        if (context.trustedFolder === false && context.hookSource === 'project') {
            return PolicyDecision.DENY;
        }
        // Run hook-specific safety checkers if configured
        if (this.checkerRunner && this.hookCheckers.length > 0) {
            for (const checkerRule of this.hookCheckers) {
                if (hookCheckerMatches(checkerRule, context)) {
                    debugLogger.debug(`[PolicyEngine.checkHook] Running hook checker: ${checkerRule.checker.name} for event: ${context.eventName}`);
                    try {
                        // Create a synthetic function call for the checker runner
                        // This allows reusing the existing checker infrastructure
                        const syntheticCall = {
                            name: `hook:${context.eventName}`,
                            args: {
                                hookSource: context.hookSource,
                                trustedFolder: context.trustedFolder,
                            },
                        };
                        const result = await this.checkerRunner.runChecker(syntheticCall, checkerRule.checker);
                        if (result.decision === SafetyCheckDecision.DENY) {
                            debugLogger.debug(`[PolicyEngine.checkHook] Hook checker denied: ${result.reason}`);
                            return PolicyDecision.DENY;
                        }
                        else if (result.decision === SafetyCheckDecision.ASK_USER) {
                            debugLogger.debug(`[PolicyEngine.checkHook] Hook checker requested ASK_USER: ${result.reason}`);
                            // For hooks, ASK_USER is treated as DENY in non-interactive mode
                            return this.applyNonInteractiveMode(PolicyDecision.ASK_USER);
                        }
                    }
                    catch (error) {
                        debugLogger.debug(`[PolicyEngine.checkHook] Hook checker failed: ${error}`);
                        return PolicyDecision.DENY;
                    }
                }
            }
        }
        // Default: Allow hooks
        return PolicyDecision.ALLOW;
    }
    applyNonInteractiveMode(decision) {
        // In non-interactive mode, ASK_USER becomes DENY
        if (this.nonInteractive && decision === PolicyDecision.ASK_USER) {
            return PolicyDecision.DENY;
        }
        return decision;
    }
}
//# sourceMappingURL=policy-engine.js.map