// trigger-scanner.mjs — TRG: trigger/activation-abuse scanner
//
// Inspects command/agent/skill `name` + `description` frontmatter for three
// activation-surface abuses:
//   - TRG-shadow  : name collides with a built-in command/tool (intercepts it)
//   - TRG-baiting : description uses maximally-activating phrases ("anything",
//                   "always", "all files", …) to bait indiscriminate invocation
//   - TRG-broad   : a broad/generic name AND a universal-applicability claim,
//                   so the unit auto-activates far beyond its stated purpose
//
// Skills/agents auto-activate on their description, so this is a real,
// skill-native attack surface not covered by the permission or taint scanners.
//
// Descriptions are run through the decode pipeline (zero-width strip ->
// homoglyph fold -> normalizeForScan) before phrase matching, so obfuscated
// baiting (zero-width / homoglyph / entity / base64) still trips.
//
// OWASP coverage: LLM06 (excessive agency); AST04 (skills framework).
// Zero external dependencies.

import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { readTextFile } from './lib/file-discovery.mjs';
import { parseFrontmatter } from './lib/yaml-frontmatter.mjs';
import { normalizeForScan, foldHomoglyphs } from './lib/string-utils.mjs';
import { getPolicyValue } from './lib/policy-loader.mjs';

// ---------------------------------------------------------------------------
// Defaults — mirrored into DEFAULT_POLICY.trg (policy-loader.mjs) so an
// operator can override any of these via .llm-security/policy.json.
// ---------------------------------------------------------------------------

const DEFAULT_BAITING_PHRASES = [
  'anything', 'everything', 'always', 'whenever', 'no matter what',
  'any request', 'any task', 'any file', 'every time',
  'all files', 'all messages', 'all requests',
];

const DEFAULT_BUILTIN_NAMES = [
  'read', 'write', 'edit', 'bash', 'glob', 'grep', 'task',
  'webfetch', 'websearch', 'notebookedit', 'todowrite',
  'ls', 'cat', 'agent', 'search', 'fetch',
];

const DEFAULT_BROAD_SINGLE_WORDS = [
  'run', 'do', 'go', 'help', 'fix', 'use', 'get', 'set', 'all', 'any', 'it', 'this', 'that',
];

// Bare universal-applicability claim words used only for the TRG-broad combo.
const UNIVERSAL_CLAIM_RE = /\b(any|all|every|everything|anything|universal|whenever|always|no matter what)\b/i;

// Invisible / zero-width characters used to split keywords (ZWSP, ZWNJ, ZWJ,
// word-joiner, BOM/zero-width-no-break-space).
const ZERO_WIDTH_RE = /[\u200B-\u200D\u2060\uFEFF]/g;

/** True if relPath is a command, agent, or skill definition file. */
function isTriggerFile(relPath) {
  const p = String(relPath).replace(/\\/g, '/');
  return /(^|\/)commands\/[^/]+\.md$/i.test(p)
    || /(^|\/)agents\/[^/]+\.md$/i.test(p)
    || /(^|\/)skills\/.+\/SKILL\.md$/i.test(p);
}

/** Coarse type label for messaging. */
function fileTypeOf(relPath) {
  const p = String(relPath).replace(/\\/g, '/');
  if (/(^|\/)commands\//i.test(p)) return 'command';
  if (/(^|\/)agents\//i.test(p)) return 'agent';
  return 'skill';
}

/** Strip zero-width chars, fold homoglyphs, decode obfuscation layers, lowercase. */
function normalizeText(s) {
  if (!s) return '';
  const noZeroWidth = String(s).replace(ZERO_WIDTH_RE, '');
  return normalizeForScan(foldHomoglyphs(noZeroWidth)).toLowerCase();
}

/**
 * Scan command/agent/skill definitions for trigger/activation abuse.
 *
 * @param {string} targetPath        - Absolute root path being scanned
 * @param {{ files: import('./lib/file-discovery.mjs').FileInfo[] }} discovery
 * @returns {Promise<object>}        - scannerResult envelope
 */
export async function scan(targetPath, discovery) {
  const startMs = Date.now();
  const findings = [];
  let filesScanned = 0;

  try {
    const baitingPhrases = (getPolicyValue('trg', 'baiting_phrases', DEFAULT_BAITING_PHRASES, targetPath) || [])
      .map(p => String(p).toLowerCase());
    const builtinNames = (getPolicyValue('trg', 'builtin_names', DEFAULT_BUILTIN_NAMES, targetPath) || [])
      .map(n => String(n).toLowerCase());
    const broadWords = (getPolicyValue('trg', 'broad_single_words', DEFAULT_BROAD_SINGLE_WORDS, targetPath) || [])
      .map(w => String(w).toLowerCase());

    for (const fileInfo of discovery.files) {
      if (!isTriggerFile(fileInfo.relPath)) continue;

      const content = await readTextFile(fileInfo.absPath);
      if (content === null) continue;

      const fm = parseFrontmatter(content);
      if (!fm || !fm.name) continue;

      filesScanned++;

      const name = String(fm.name).trim();
      const nameLower = name.toLowerCase();
      const rawDesc = typeof fm.description === 'string' ? fm.description : '';
      const normDesc = normalizeText(rawDesc);
      const fileType = fileTypeOf(fileInfo.relPath);

      const isBroadName = broadWords.includes(nameLower) || nameLower.length <= 2;
      const hasUniversalClaim = UNIVERSAL_CLAIM_RE.test(normDesc);

      // TRG-shadow — name collides with a built-in command/tool.
      if (builtinNames.includes(nameLower)) {
        findings.push(finding({
          scanner: 'TRG',
          severity: SEVERITY.MEDIUM,
          title: `Built-in shadowing: trigger name "${name}"`,
          description: `The ${fileType} "${name}" uses a name that collides with a built-in command/tool. `
            + `A shadowing trigger can intercept invocations intended for the built-in.`,
          file: fileInfo.relPath,
          evidence: `name: ${name}`,
          owasp: 'LLM06',
          recommendation: 'Rename the command/agent/skill so its name does not collide with a built-in tool.',
        }));
      }

      // TRG-broad — broad/generic name AND a universal-applicability claim (HIGH).
      if (isBroadName && hasUniversalClaim) {
        findings.push(finding({
          scanner: 'TRG',
          severity: SEVERITY.HIGH,
          title: `Overly broad trigger: "${name}"`,
          description: `The ${fileType} "${name}" pairs a broad/generic name with a description claiming `
            + `universal applicability, so it may auto-activate far beyond its stated purpose.`,
          file: fileInfo.relPath,
          evidence: `name: ${name}`,
          owasp: 'LLM06',
          recommendation: 'Give the unit a specific name and scope the description to a single well-bounded task.',
        }));
      }

      // TRG-baiting — maximally-activating phrases in the (decoded) description.
      const matched = baitingPhrases.filter(p => p && normDesc.includes(p));
      if (matched.length > 0) {
        findings.push(finding({
          scanner: 'TRG',
          severity: SEVERITY.MEDIUM,
          title: `Activation baiting phrase in ${fileType} description`,
          description: `The ${fileType} "${name}" description uses maximally-activating phrasing that baits the `
            + `model into invoking it indiscriminately${rawDesc === normDesc ? '' : ' (recovered from obfuscation)'}.`,
          file: fileInfo.relPath,
          evidence: `phrases: ${matched.join(', ')}`,
          owasp: 'LLM06',
          recommendation: 'Remove universal/maximal activation phrasing; describe concrete, scoped trigger conditions.',
        }));
      }
    }

    const durationMs = Date.now() - startMs;
    return scannerResult('trigger-scanner', 'ok', findings, filesScanned, durationMs);

  } catch (err) {
    const durationMs = Date.now() - startMs;
    return scannerResult('trigger-scanner', 'error', findings, filesScanned, durationMs, err.message);
  }
}
