// --- Shadow Approver Agent ---
// Shadows user approvals in AI coding CLI terminal sessions.
// Monitors PTY output for approval prompts across multiple CLIs
// (Claude Code, Codex, Gemini, Amazon Q, Aider), checks learned
// rules first, then uses AI to decide whether to auto-approve or
// escalate to the user.

const dbModule = require('./db');
const crypto = require('crypto');
const { getProvider, detectProvider, providers } = require('./providers');
const { checkBlocklist } = require('./workers/approval-blocklist');
const { commandHead } = require('./lib/escalation-review');
const { callBackgroundLlm } = require('./lib/background-llm');
const { verifyIfEnabled } = require('./lib/auto-approval-verifier');
const { matchPermission } = require('./lib/permission-match');
const approvalAiRefinement = require('./lib/approval-ai-refinement');

// Dangerous-command blocklist: when enabled, any command matching
// ./workers/approval-blocklist is force-escalated even if learned rules /
// heuristics / AI would approve. Default ON (defense-in-depth); turn off via the
// Permission Manager UI (Shadow Approver panel). Cached read on each check.
function isBlocklistEnabled() {
  try {
    return !!dbModule.getSetting('approval_blocklist_enabled', true);
  } catch {
    return true;
  }
}

// The user's Permission-tab edits to the blocklist: disabled default ids + custom
// patterns. Stored as one JSON setting; checkBlocklist() layers it over the
// shipped defaults. Null/absent → defaults only. Read fresh each check (cheap —
// a single settings row) so edits take effect without a restart.
function getBlocklistConfig() {
  try {
    const cfg = dbModule.getSetting('approval_blocklist_config', null);
    return cfg && typeof cfg === 'object' ? cfg : null;
  } catch {
    return null;
  }
}

// Legacy patterns kept for backward compatibility with tests
const PROCEED_PATTERN = /Do you want to (proceed|make this edit to .+|create .+|overwrite .+)\??/;
const BROAD_PROCEED_PATTERN = /Do you want to .+\?/;
const CLAUDE_DURABLE_YES_OPTION_RE = /^\s*(?:[❯›▶▸>]\s*)?2\.\s*Yes,\s*(allow all|allow\b.*\b(?:for|from) this project\b|(?:and\s+)?allow\s+access\s+to\b.*\b(?:and\s+similar\s+commands|for\s+this\s+session)\b|and\s+allow\s+Claude\b.*\bthis\s+session\b|and don't ask again|and always allow)/i;

// Delay (ms) before sending the auto-approve keystroke. Lower = faster response.
const APPROVE_DELAY_MS = 100;
const ENTER_DELAY_MS = 30;

// Pre-send validation threshold. If the PTY produced more than this many bytes
// of output between "decision made" and "keystroke fires", the prompt likely
// transitioned (user manually approved, Claude Code redrew, prompt auto-dismissed).
// Writing our '1' now would land in a stale input context, creating a spurious
// keystroke in whatever is now active (next prompt, text input field, etc.).
// 256 bytes triggers a live-screen revalidation: if the same approval widget is
// still visible (common for Codex repaint frames), we proceed; otherwise we skip.
const STALE_PROMPT_BYTE_THRESHOLD = 256;
const STALE_PROMPT_RECHECK_TIMEOUT_MS = 150;

function _contextFingerprint(context) {
  return `${context.toolName}:${(context.command || '').slice(0, 200)}`;
}

function _removeWorkerListener(worker, handler) {
  if (typeof worker.off === 'function') worker.off('message', handler);
  else if (typeof worker.removeListener === 'function') worker.removeListener('message', handler);
}

function _requestHeadlessText(headlessWorker, sessionId, timeoutMs = STALE_PROMPT_RECHECK_TIMEOUT_MS) {
  if (!headlessWorker
      || typeof headlessWorker.postMessage !== 'function'
      || typeof headlessWorker.on !== 'function') {
    return Promise.resolve(null);
  }

  const requestId = `stale-approval-${sessionId}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
  return new Promise(resolve => {
    let done = false;
    let timer;

    const finish = (text) => {
      if (done) return;
      done = true;
      clearTimeout(timer);
      _removeWorkerListener(headlessWorker, onMessage);
      resolve(text);
    };

    const onMessage = (msg) => {
      if (!msg || msg.type !== 'text' || msg.requestId !== requestId) return;
      finish(msg.text || '');
    };

    timer = setTimeout(() => finish(null), timeoutMs);
    headlessWorker.on('message', onMessage);
    try {
      headlessWorker.postMessage({ type: 'getText', sessionId, requestId });
    } catch {
      finish(null);
    }
  });
}

function _samePrompt(original, current) {
  if (!original || !current) return false;
  if (_contextFingerprint(original) === _contextFingerprint(current)) return true;

  const originalProvider = original.providerId || '';
  const currentProvider = current.providerId || '';
  if (originalProvider === 'codex' && currentProvider === 'codex'
      && original.toolName === current.toolName) {
    const originalCommand = (original.command || '').replace(/\s+/g, ' ').trim();
    const currentCommand = (current.command || '').replace(/\s+/g, ' ').trim();
    if (!originalCommand || !currentCommand) return true;
    return originalCommand.startsWith(currentCommand) || currentCommand.startsWith(originalCommand);
  }

  return false;
}

async function _currentPromptVisibility(sessionId, context, headlessWorker) {
  const text = await _requestHeadlessText(headlessWorker, sessionId);
  if (text == null) return null;
  if (!text) return false;
  try {
    const current = parseApprovalContext(text, context.providerId);
    return _samePrompt(context, current);
  } catch {
    return false;
  }
}

function _uniqueProviderIds(...ids) {
  const seen = new Set();
  const out = [];
  for (const id of ids) {
    const value = String(id || '').trim();
    if (!value || seen.has(value)) continue;
    seen.add(value);
    out.push(value);
  }
  return out;
}

function _parseKnownProviderContext(rawText, suppliedProviderId) {
  const detectedProviderId = (() => {
    try { return detectProvider(rawText)?.id || ''; } catch { return ''; }
  })();
  const looksClaudeCode = /Esc to cancel|Tab to amend|ctrl\+e to explain|❯/.test(String(rawText || ''));
  const fallbackProviderIds = providers
    .map(p => p.id)
    .filter(id => id !== 'claude-code' || looksClaudeCode);
  const candidateIds = _uniqueProviderIds(
    suppliedProviderId,
    detectedProviderId,
    // The legacy parser is Claude-shaped; try the real provider parser for
    // Claude-anchored screens before falling back to generic rescue so
    // Claude-specific Enter semantics survive structural-gate misses and
    // recheck-only observations without relabeling unrelated numbered prompts.
    ...fallbackProviderIds
  );

  for (const providerId of candidateIds) {
    const provider = getProvider(providerId);
    if (!provider || typeof provider.parse !== 'function') continue;
    try {
      const context = provider.parse(rawText);
      if (context) return { providerId, context: { ...context, providerId } };
    } catch {}
  }
  return null;
}

// Guarded setTimeout: schedules sendApprovalKeystroke after APPROVE_DELAY_MS,
// but first revalidates the currently rendered headless-terminal prompt when
// the worker is available. This prevents low-byte stale transitions from
// leaking approval shortcuts into the next Codex/Claude input box. If the
// worker cannot answer and output advanced significantly, skip as before.
function _scheduleGuardedApproval(session, context, headlessWorker, broadcastFn, sessionId, decisionPayload, options = {}) {
  const outputBytesAtDecision = session._outputBytesCounter || 0;
  // Stash decision provenance on the context so sendApprovalKeystroke can log it.
  context._decidedBy = decisionPayload.decidedBy;
  context._ruleLabel = decisionPayload.label;
  setTimeout(async () => {
    const outputBytesNow = session._outputBytesCounter || 0;
    const outputAdvanced = outputBytesNow - outputBytesAtDecision;
    let promptVisibility = null;
    if (headlessWorker) {
      promptVisibility = await _currentPromptVisibility(sessionId, context, headlessWorker);
      if (promptVisibility === false) {
        console.log(`[approval-agent] Skipping keystroke for session ${sessionId.slice(0, 8)} — live preflight no longer validates the approval prompt after ${outputAdvanced} bytes. decidedBy=${decisionPayload.decidedBy} label="${decisionPayload.label}"`);
        // Still notify clients about the decision for telemetry/UI purposes.
        broadcastFn(sessionId, session, { ...decisionPayload, decision: 'skipped-stale' });
        try { options.onResult?.({ status: 'skipped-stale', sent: false, outputAdvanced, promptVisibility }); } catch {}
        return;
      }
    }
    if (outputAdvanced >= STALE_PROMPT_BYTE_THRESHOLD) {
      if (promptVisibility !== true) {
        console.log(`[approval-agent] Skipping keystroke for session ${sessionId.slice(0, 8)} — ${outputAdvanced} bytes of PTY output since decision and prompt could not be revalidated. decidedBy=${decisionPayload.decidedBy} label="${decisionPayload.label}"`);
        // Still notify clients about the decision for telemetry/UI purposes.
        broadcastFn(sessionId, session, { ...decisionPayload, decision: 'skipped-stale' });
        try { options.onResult?.({ status: 'skipped-stale', sent: false, outputAdvanced, promptVisibility }); } catch {}
        return;
      }
      console.log(`[approval-agent] Proceeding with approval for session ${sessionId.slice(0, 8)} despite ${outputAdvanced} bytes of PTY output — same prompt is still visible. decidedBy=${decisionPayload.decidedBy} label="${decisionPayload.label}"`);
    }
    sendApprovalKeystroke(session, context, headlessWorker, options.keystrokeOptions || {});
    broadcastFn(sessionId, session, decisionPayload);
    try { options.onResult?.({ status: 'sent', sent: true, outputAdvanced, promptVisibility }); } catch {}
  }, APPROVE_DELAY_MS);
}

// Dedup: track last approval context per session to prevent duplicate approvals
// when the VTerm worker detects the same prompt multiple times before the screen resets.
const _lastApproval = new Map(); // sessionId -> { fingerprint, ts }
const DEDUP_WINDOW_MS = 3000;

// Determine which option to send — delegates to provider if available,
// falls back to Claude Code behavior ("2" for allow-all, "1" for plain Yes).
function getApproveKeystroke(context, options = {}) {
  const provider = context.providerId ? getProvider(context.providerId) : null;
  if (options.preferAllowAll === false) {
    if (provider) {
      return provider.approveKeystroke({
        ...context,
        hasAllowAll: false,
        approveAllShortcut: null,
        alwaysAllowShortcut: null,
      });
    }
    if (context.approveShortcut) return context.approveShortcut;
    return '1';
  }
  if (provider) return provider.approveKeystroke(context);
  return context.hasAllowAll ? '2' : '1';
}

function _parseGenericApprovalContext(cleanText, providerId) {
  const lines = String(cleanText || '').split('\n').map(l => l.trim()).filter(Boolean);
  if (!lines.length) return null;

  let proceedIdx = -1;
  for (let i = lines.length - 1; i >= 0; i--) {
    if (/(do you want|would you like|approve|allow|permission|proceed|run this command).*\?/i.test(lines[i])) {
      proceedIdx = i;
      break;
    }
  }
  if (proceedIdx < 0) return null;

  let approveShortcut = '';
  let hasYesNo = false;
  let hasAllowAll = false;
  for (let i = proceedIdx + 1; i < Math.min(proceedIdx + 12, lines.length); i++) {
    const line = lines[i];
    const numbered = line.match(/^\D*([1-9])\.\s*(Yes|Allow|Approve|Proceed)\b/i);
    const single = line.match(/^\D*([yY])\s*[-.)]?\s*(Yes|Allow|Approve|Proceed)\b/i);
    if (!hasYesNo && (numbered || single)) {
      approveShortcut = numbered ? numbered[1] : single[1].toLowerCase();
      hasYesNo = true;
    }
    if (/always|allow all|don't ask again|for this project|for this session|similar commands/i.test(line)) {
      hasAllowAll = true;
    }
  }
  if (!hasYesNo) return null;

  let toolName = 'Generic approval';
  const contextLines = [];
  for (let i = proceedIdx - 1; i >= Math.max(0, proceedIdx - 30); i--) {
    const line = lines[i];
    if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|NotebookEdit|TodoWrite|Agent|MCP)\b/i.test(line)) {
      toolName = line.trim();
      break;
    }
    contextLines.unshift(line);
  }

  const fullContext = lines.slice(Math.max(0, proceedIdx - 20), Math.min(lines.length, proceedIdx + 12)).join('\n');
  return {
    providerId: providerId || 'generic',
    toolName,
    command: contextLines.join('\n').slice(0, 2000),
    warning: '',
    fullContext: fullContext.slice(0, 2000),
    hasAllowAll,
    approveShortcut,
  };
}

// Parse the terminal buffer to extract the approval context.
// If providerId is given and a matching provider exists, delegates to it.
// Defaults to Claude Code parsing for backward compatibility.
function parseApprovalContext(cleanText, providerId) {
  const provider = providerId ? getProvider(providerId) : null;
  if (provider) {
    let ctx = null;
    try { ctx = provider.parse(cleanText); } catch { ctx = null; }
    if (ctx && !ctx.providerId) ctx.providerId = providerId;
    if (ctx) return ctx;
    return approvalAiRefinement.parseWithActiveRules(cleanText, providerId, { dbModule }) || null;
  }
  // Fall through to legacy Claude Code parsing below
  const lines = cleanText.split('\n').map(l => l.trim()).filter(Boolean);

  // Find the "Do you want to ...?" line (narrow first, broad fallback)
  let proceedIdx = -1;
  for (let i = lines.length - 1; i >= 0; i--) {
    if (PROCEED_PATTERN.test(lines[i])) { proceedIdx = i; break; }
  }
  if (proceedIdx < 0) {
    // Broad fallback: "Do you want to allow Claude to fetch...?", "Do you want to run this agent?", etc.
    for (let i = lines.length - 1; i >= 0; i--) {
      if (BROAD_PROCEED_PATTERN.test(lines[i])) { proceedIdx = i; break; }
    }
  }
  if (proceedIdx < 0) {
    return _parseGenericApprovalContext(cleanText, providerId)
      || approvalAiRefinement.parseWithActiveRules(cleanText, providerId, { dbModule });
  }

  // Find "1. Yes" after it (Edit prompts may have more options so search further)
  let hasYesNo = false;
  for (let i = proceedIdx + 1; i < Math.min(proceedIdx + 6, lines.length); i++) {
    if (/^\D*1\.\s*Yes\b/.test(lines[i])) { hasYesNo = true; break; }
  }
  if (!hasYesNo) {
    return _parseGenericApprovalContext(cleanText, providerId)
      || approvalAiRefinement.parseWithActiveRules(cleanText, providerId, { dbModule });
  }

  // Extract warning (line before "Do you want to proceed?")
  let warning = '';
  for (let i = proceedIdx - 1; i >= Math.max(proceedIdx - 3, 0); i--) {
    const line = lines[i];
    if (!line) continue;
    // Warning lines typically describe the risk
    if (/command contains|could write|could modify|could delete|could overwrite|which can|permission|dangerous|destructive|overwrite|will modify|will delete|will overwrite|execute arbitrary|executes commands|modifies files|cannot be auto-allowed|shell command substitution/i.test(line)) {
      warning = line;
      break;
    }
  }

  // Find the warning line index (if any)
  let warningIdx = -1;
  if (warning) {
    for (let i = proceedIdx - 1; i >= Math.max(proceedIdx - 3, 0); i--) {
      if (lines[i] === warning) { warningIdx = i; break; }
    }
  }

  // Extract the tool/command block above the warning
  // Look for tool header like "Bash command", "Edit", "Write", etc.
  let toolName = '';
  const contextLines = [];
  const endIdx = warningIdx > 0 ? warningIdx : proceedIdx;

  for (let i = endIdx - 1; i >= Math.max(0, endIdx - 30); i--) {
    const line = lines[i];
    // Detect tool headers (Claude Code shows "Bash command", "⏺ Bash(...)", "Edit", etc.)
    if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|NotebookEdit|TodoWrite|Agent)\b/.test(line)) {
      toolName = line.trim();
      break;
    }
    // MCP tool-use prompt: capture the real tool name (line ending in "(MCP)" or
    // a raw mcp__/plugin: tool id) so it doesn't collapse to "Unknown".
    if (/\(MCP\)\s*$/i.test(line) || /^(?:[⏺●]\s*)?(?:plugin:|mcp__)/i.test(line)) {
      let name = line.trim().replace(/^[⏺●]\s*/, '').replace(/\s*\(MCP\)\s*$/i, '');
      const argIdx = name.indexOf('(');
      if (argIdx > 0) name = name.slice(0, argIdx).trim();
      toolName = name || 'MCP tool';
      contextLines.unshift(line.trim());
      break;
    }
    contextLines.unshift(line);
  }

  // No tool header above the diff → Claude's file-operation prompt
  // ("overwrite/create/make this edit to <file>?"). Derive Edit/Write + target
  // file so it auto-approves as a normal edit instead of an Unknown command that
  // risk-scores the file's code. (Mirror of the claude-code provider parse path,
  // for the no-detected-provider fallback.)
  let fileOpCommand = '';
  if (!toolName) {
    const ccProvider = getProvider('claude-code');
    const fileOp = ccProvider && typeof ccProvider.deriveFileOpTool === 'function'
      ? ccProvider.deriveFileOpTool(lines[proceedIdx]) : null;
    if (fileOp) { toolName = fileOp.toolName; fileOpCommand = fileOp.command; }
  }

  const command = (fileOpCommand || contextLines.join('\n')).trim();

  // Build focused context: tool header + command + warning + prompt (not the whole screen)
  const ctxStart = Math.max(0, endIdx - (contextLines.length + 1));
  const ctxEnd = Math.min(lines.length, proceedIdx + 5); // include Yes/No options
  const fullContext = lines.slice(ctxStart, ctxEnd).join('\n');

  // Detect if option 2 is a "don't ask again" variant (auto-allow for future matches)
  // - Edit/Write: "2. Yes, allow all edits in foo/ during this session"
  // - Bash: "2. Yes, and don't ask again for: sqlite3 $BRAIN_DB:*"
  // - Claude Code 2.x: "2. Yes, allow reading from foo/ from this project"
  // - Claude Code path groups: "2. Yes, and allow access to tmp/ and similar commands"
  let hasAllowAll = false;
  for (let i = proceedIdx + 1; i < Math.min(proceedIdx + 8, lines.length); i++) {
    if (CLAUDE_DURABLE_YES_OPTION_RE.test(lines[i])) { hasAllowAll = true; break; }
  }

  return {
    // Preserve the legacy no-provider path. Provider-specific keystroke
    // behavior should only activate when detection supplied a provider id.
    providerId: providerId || '',
    toolName: toolName || 'Unknown',
    command: command.slice(0, 2000),
    warning: warning || '',
    fullContext: fullContext.slice(0, 2000),
    hasAllowAll,
  };
}

// Distinguish a GENUINELY LIVE approval prompt (the agent is blocked, waiting at an
// interactive selection at the bottom of the screen) from mere approval-shaped
// PROSE that happens to contain "Do you want to proceed?" / "1. Yes" — e.g. a
// coding agent, docs, or this very tool discussing approval prompts. parseApproval-
// Context matches the structure; this adds the "it's actually live" gate the auto-
// approver needs so it never fires on prompt-shaped output and injects stray
// keystrokes (the spurious "y" bug). A real TUI prompt renders a selection cursor on
// the active option and/or an interactive footer (Esc to cancel, ctrl+e to explain,
// ↑/↓ to select); prose has neither. We scan only the TAIL because the live prompt
// always renders at the bottom of the screen. Erring toward "not live" is the safe
// direction: a missed real prompt just falls back to manual approval, whereas a
// false positive auto-types into the session.
const LIVE_PROMPT_TAIL_LINES = 18;
// Fancy selection cursors only (NOT a bare ">", which appears in quoted prose like
// "> 2. Yes") immediately before an approval option. "→" is Cursor Agent's cursor
// (e.g. "→ Run (once) (y)").
const LIVE_SELECTION_CURSOR_RE = /^\s*[▸❯›▶➤◆→]\s*(?:\d+[.)]\s*)?(?:yes|allow|approve|proceed|accept|run\b|make edits|don'?t ask|always)/i;
// Interactive footers a real TUI renders while waiting at a prompt; very unlikely in prose.
// Cursor Agent's hotkey footer: "Run (once) (y)" / "Skip (esc or n)" / "Auto-run
// everything (shift+tab)" / "… to allowlist? (tab)".
// NOTE: "esc to interrupt" and the generic "(shift+)tab to cycle/toggle/switch"
// (plus "← for agents" / "↓ to manage") were intentionally REMOVED — those are the
// agent's WORKING / mode-cycle composer footers, NOT approval footers, and matching
// them made every idle/working session look like a live approval. They are now the
// decisive NEGATIVE gate below (COMPOSER_STATUS_FOOTER_RE).
const LIVE_PROMPT_FOOTER_RE = /\besc to (?:cancel|reject|go back)\b|\bctrl\+e to explain\b|\bpress enter to (?:confirm|continue|select|submit)\b|\benter to confirm\b|[↑↓]\s*(?:\/\s*[↑↓]\s*)?(?:to\s+)?(?:select|navigate|choose)\b|\bup\/down (?:arrows? )?to (?:select|navigate)\b|\(esc or n\)|Run \(once\) \(y\)|Auto-run everything[^\n]*\(shift\+tab\)|to allowlist\?\s*\(tab\)/i;
// Footers/hints that belong to the agent's READY COMPOSER or WORKING status. A
// live approval widget REPLACES the composer, so when one of these is on screen the
// agent is idle or generating, NOT blocked at an approval. Decisive negative gate:
// a session merely DISPLAYING approval-shaped text (this tool discussing prompts, a
// coding agent's output) still renders its composer at the bottom, so it can never
// be mistaken for a live prompt. None of these appear in a real Claude/Codex/Cursor
// approval widget.
const COMPOSER_STATUS_FOOTER_RE = /\besc to interrupt\b|\b(?:shift\+tab|tab) to (?:cycle|toggle|switch)\b|\bauto[- ]?mode on\b|\baccept edits on\b|\bplan mode on\b|\bbypass(?:ing)? permissions\b|\?\s*for shortcuts\b|(?:⏵⏵?|\*)\s*for agents\b|\bctrl\+t to (?:show|hide|toggle)\b/i;
// True when the agent's READY-COMPOSER or WORKING status footer is visible in the
// last few lines. A live approval/choice widget REPLACES the composer, so a
// composer footer at the bottom is decisive proof the agent is idle/generating at
// its prompt, NOT blocked at a live approval or selection menu. Shared by the
// auto-approver (isLiveApprovalPrompt) and the idle waiting-input detector
// (server.js _idlePromptDetections) so both surfaces agree on "composer present ⇒
// not a live prompt" instead of duplicating the heuristic.
function hasComposerStatusFooter(cleanText) {
  const lines = String(cleanText || '').split('\n');
  const tail = lines.slice(-LIVE_PROMPT_TAIL_LINES);
  for (const raw of tail) {
    if (COMPOSER_STATUS_FOOTER_RE.test(String(raw || ''))) return true;
  }
  return false;
}

function isLiveApprovalPrompt(cleanText) {
  const lines = String(cleanText || '').split('\n');
  const tail = lines.slice(-LIVE_PROMPT_TAIL_LINES);
  // Negative gate first: the agent's composer/working footer at the bottom means
  // it is NOT waiting at an approval, regardless of approval-shaped text above.
  if (hasComposerStatusFooter(cleanText)) return false;
  for (const raw of tail) {
    const line = String(raw || '');
    if (LIVE_SELECTION_CURSOR_RE.test(line)) return true;
    if (LIVE_PROMPT_FOOTER_RE.test(line)) return true;
  }
  return false;
}

// Normalize a command into a stable "signature" by extracting the command structure
// and replacing variable parts (paths, strings, numbers) with placeholders.
// Examples:
//   "node -e 'console.log(1+2)'"         → "node -e <arg>"
//   "git commit -m 'fix typo'"           → "git commit -m <arg>"
//   "cat /Users/bob/ws/foo/bar.js"       → "cat <path>"
//   "python3 -c 'import json; ...'"      → "python3 -c <arg>"
//   "ls -la /some/dir"                   → "ls -la <path>"
//   "kill -9 12345"                      → "kill -9 <num>"
//   "curl http://localhost:3000/api"     → "curl <url>"
function normalizeCommandSignature(toolName, command) {
  const tool = (toolName || '').replace(/^[⏺●\s]+/, '').trim();
  const cmd = (command || '').trim();
  if (!cmd) return tool.toLowerCase();

  // For non-Bash tools (Edit, Write, Read, etc.), signature is just the tool name
  if (!/bash/i.test(tool)) return tool.toLowerCase();

  // Extract the shell command — skip variable assignments (VAR="...") to find the
  // actual command. e.g. BRAIN_DB="/path/to/db" \n sqlite3 "$BRAIN_DB" "SELECT ..."
  // should produce a signature based on "sqlite3", not "BRAIN_DB=<arg>".
  const cmdLines = cmd.split('\n').map(l => l.trim()).filter(Boolean);
  // Find the first non-assignment line, then join all remaining lines so multiline
  // quoted strings (e.g. sqlite3 "..." "SELECT ...\n...\n...") are handled as one unit.
  let startIdx = 0;
  for (let i = 0; i < cmdLines.length; i++) {
    if (/^(export\s+)?[A-Za-z_]\w*=/.test(cmdLines[i]) && !/;\s*\S/.test(cmdLines[i])) { startIdx = i + 1; continue; }
    startIdx = i;
    break;
  }
  const fullCmd = cmdLines.slice(startIdx).join(' ');

  let sig = fullCmd
    // Replace quoted strings (single/double/backtick) with <arg>
    .replace(/(["'`])(?:(?!\1).)*\1/g, '<arg>')
    // Replace URLs with <url>
    .replace(/https?:\/\/\S+/g, '<url>')
    // Replace absolute paths with <path>
    .replace(/(?:\/[\w._-]+){2,}/g, '<path>')
    // Replace standalone numbers (PIDs, ports, line numbers) with <num>
    .replace(/\b\d{2,}\b/g, '<num>')
    // Collapse multiple spaces
    .replace(/\s+/g, ' ')
    .trim();

  // Limit length to prevent bloated signatures
  if (sig.length > 150) sig = sig.slice(0, 150);

  return sig.toLowerCase();
}

// Reject regex patterns that could cause catastrophic backtracking (ReDoS).
// Looks for nested quantifiers like (a+)+, (a*)*,  (a+|b+)+ etc.
function isSafeRegex(pattern) {
  // Reject patterns with nested quantifiers — the main ReDoS vector
  if (/(\+|\*|\{)\)?(\+|\*|\?)/.test(pattern)) return false;
  // Reject patterns with excessive alternation groups
  if ((pattern.match(/\|/g) || []).length > 20) return false;
  // Reject empty alternation (|| or leading/trailing |) — matches everything
  if (/\|\||^\||\|$/.test(pattern)) return false;
  // Quick compile test — reject if it takes too long conceptually
  try { new RegExp(pattern); } catch { return false; }
  return true;
}

// Check if a learned rule already covers this situation.
// Two-tier matching: (1) exact signature lookup in DB (fast, O(1)),
// then (2) regex scan over all rules (slower, backwards-compatible).
function findMatchingRule(context) {
  // Tier 1: Signature-based lookup (indexed, instant)
  const signature = normalizeCommandSignature(context.toolName, context.command);
  if (signature) {
    try {
      const sigRule = dbModule.findApprovalRuleBySignature(signature);
      if (sigRule) return sigRule;
    } catch {}
  }

  // Tier 2: Regex-based scan (backwards-compatible with existing rules)
  let rules;
  try {
    rules = dbModule.listApprovalRules();
  } catch { return null; }

  if (!rules || rules.length === 0) return null;

  const searchText = `${context.toolName} ${context.command} ${context.warning}`.slice(0, 500).toLowerCase();
  const cmdText = (context.command || '').slice(0, 500);
  const warnText = (context.warning || '').slice(0, 500);

  for (const rule of rules) {
    if (!rule.enabled) continue;
    try {
      if (!rule.pattern || rule.pattern.length > 200) continue;
      if (!isSafeRegex(rule.pattern)) continue;
      const re = new RegExp(rule.pattern, 'i');
      if (re.test(searchText) || re.test(cmdText) || re.test(warnText)) {
        return rule;
      }
    } catch {}
  }
  return null;
}

function _shellTokens(segment) {
  const matches = String(segment || '').match(/"[^"]*"|'[^']*'|[^\s]+/g) || [];
  return matches.map(token => token.replace(/^(['"])([\s\S]*)\1$/, '$2'));
}

function _extractFindExecSegments(command) {
  const text = String(command || '');
  const segments = [];
  let idx = 0;
  while ((idx = text.indexOf('-exec', idx)) >= 0) {
    const after = text.slice(idx + 5).trim();
    const endMatch = after.match(/(?:\\;|\s;\s|\s\+(?:\s|$))/);
    const end = endMatch ? endMatch.index : Math.min(after.length, 240);
    const segment = after.slice(0, end).trim();
    if (segment) segments.push(segment);
    idx += 5;
  }
  return segments;
}

function _isReadOnlyFindExecSegment(segment) {
  const tokens = _shellTokens(segment).filter(Boolean);
  if (!tokens.length) return false;
  const tool = tokens[0].split('/').pop().toLowerCase();
  const unsafeTokens = /(^|[\s;&|])(?:sh|bash|zsh|fish|python|python3|node|ruby|perl|osascript|rm|mv|cp|chmod|chown|touch|mkdir|rmdir|truncate|tee|dd|curl|wget)\b/i;
  if (unsafeTokens.test(segment)) return false;
  if (/[<>]/.test(segment)) return false;

  const readOnlyTools = new Set(['cat', 'head', 'tail', 'grep', 'rg', 'wc', 'stat', 'file', 'ls']);
  if (readOnlyTools.has(tool)) return true;

  if (tool === 'sed') {
    if (tokens.some(t => /^-.*i/.test(t) || t === '--in-place')) return false;
    if (!tokens.some(t => t === '-n' || /^-n[a-zA-Z]*$/.test(t))) return false;
    const expressions = tokens.slice(1).filter(t =>
      t !== '-n'
      && !/^-n[a-zA-Z]*$/.test(t)
      && t !== '{}'
      && !/^\{\}\+?$/.test(t)
      && !/^--/.test(t)
    );
    return expressions.length > 0
      && expressions.every(expr => /^(\d+|\$)?(,(\d+|\$))?[pP]$/.test(expr));
  }

  return false;
}

function classifyFindExecPolicy(command) {
  const cmd = String(command || '');
  if (!/\bfind\b[\s\S]*\s-exec\s/.test(cmd)) return null;
  const segments = _extractFindExecSegments(cmd);
  if (!segments.length) {
    return {
      decision: 'escalate',
      reasoning: 'find -exec could not be parsed safely (heuristic)',
      riskLevel: 'high',
      policyFinal: true,
      ruleLabel: 'find -exec needs review',
      ruleDescription: 'find -exec must expose a read-only nested command before one-shot approval',
    };
  }
  if (segments.every(_isReadOnlyFindExecSegment)) {
    return {
      decision: 'approve',
      reasoning: 'find -exec only runs read-only file inspection commands (heuristic)',
      riskLevel: 'low',
      policyFinal: true,
      ruleLabel: 'find -exec read-only inspection',
      rulePattern: String.raw`\bfind\b[\s\S]*\s-exec\s+(sed\s+-n|cat|head|tail|grep|rg|wc|stat|file|ls)\b`,
      ruleDescription: 'One-shot approve find -exec only when every nested command is read-only file inspection',
    };
  }
  return {
    decision: 'escalate',
    reasoning: 'find -exec can run arbitrary commands; nested command is not proven read-only (heuristic)',
    riskLevel: 'high',
    policyFinal: true,
    ruleLabel: 'find -exec needs review',
    ruleDescription: 'Do not auto-approve find -exec unless every nested command is read-only',
  };
}

// Build a readable TITLE + grouping SIGNATURE for an escalation from its command
// context. Escalations were being stored as "Bash command" with an empty signature,
// so the Permission UI couldn't tell them apart or group them. We strip a leading
// run of `export VAR=… &&` / `VAR=…` env assignments (they carry credentials and
// aren't the operation being judged) so the title shows the ACTUAL command
// (`aws eks update-kubeconfig …`), and we normalize args to placeholders for a
// stable group key.
function _escalationCommandText(context) {
  let cmd = String((context && context.command) || '').trim();
  if (!cmd) {
    const lines = String((context && context.fullContext) || '')
      .split('\n').map((l) => l.trim()).filter(Boolean);
    cmd = lines.find((l) => !/^(do you want|would you like|❯|›|>|\d+\.\s|esc to|ctrl\+e|bash command|warning|this command|⏺|●)/i.test(l)) || '';
  }
  return cmd;
}
// Leading FRAMING segments are not the operation being judged — strip a leading run
// of them so the title/signature show the ACTUAL command. Three kinds, separated by
// `&&` / `;` / newline:
//   - env assignments (`export VAR=…` / `VAR=…`) — carry credentials, fragment groups.
//   - `cd <path>` — navigation; the long worktree path otherwise eats the 120-char
//     title budget AND collapses every `cd …` escalation into one group keyed "cd"
//     with a dangerously broad `Bash(cd:*)` suggested rule.
//   - pure `echo "…"` banners — section labels the agent prints, not actions.
// Guarded against stripping to empty (a bare `cd x` / `echo x` keeps itself).
const _FRAMING_PREFIX_RES = [
  /^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s&;|]+)\s*(?:&&|;|\n)+\s*/,
  /^cd\s+(?:"[^"]*"|'[^']*'|[^\s&;|]+)\s*(?:&&|;|\n)+\s*/,
  /^echo\s+(?:"[^"]*"|'[^']*'|[^&;|\n]+?)\s*(?:&&|;|\n)+\s*/,
];
function _stripFramingPrefix(cmd) {
  let out = String(cmd || '').trim();
  for (let i = 0; i < 16; i++) {
    let matched = false;
    for (const re of _FRAMING_PREFIX_RES) {
      const m = out.match(re);
      if (m) { out = out.slice(m[0].length).trim(); matched = true; break; }
    }
    if (!matched) break;
  }
  return out || String(cmd || '').trim();
}
function escalationCommandParts(context) {
  const raw = _escalationCommandText(context);
  const op = _stripFramingPrefix(raw);
  const title = op.replace(/\s+/g, ' ').slice(0, 120).trim();
  const signature = normalizeCommandSignature(context && context.toolName, op)
    || normalizeCommandSignature(context && context.toolName, raw);
  return { title, signature };
}

// A rescue candidate is "actionable" only when we have a concrete command to show
// the operator AND the parser classified the tool. An empty command or an
// "Unknown" tool means the parse degraded (almost always approval-shaped PROSE,
// not a live prompt) — escalating it just yields a confusing, meaningless banner.
function _rescueCandidateActionable(context) {
  if (!context) return false;
  if (!escalationCommandParts(context).title) return false;
  const tool = String(context.toolName || '').replace(/^[⏺●\s]+/, '').trim().toLowerCase();
  if (!tool || tool === 'unknown') return false;
  return true;
}

// The crisp, OBJECTIVE reason a command was sent to review instead of auto-approved
// — a category + sentence, NOT the AI verifier's vague free-text. First match wins
// (priority order). Shown in the session banner and the Pending group's "Why
// escalated" so the user can tell at a glance WHY (e.g. "Runs arbitrary code
// (node -e)") rather than reading a misleading `cd …` label.
function classifyBlockReason(context) {
  const cmd = String((context && context.command) || '').trim();
  const lc = cmd.toLowerCase();
  if (!cmd) return { category: 'unrecognized', reason: 'Could not parse the command — review before allowing.' };

  // 1) High-risk shell patterns (mirrors reviewWithHeuristics' highRisk list).
  const highRisk = [
    [/rm\s+-rf?\s+(?!\/tmp\/)[\/~]/, 'recursive delete outside /tmp (rm -rf)'],
    [/--force\b|force.?push/, 'a force operation (--force / force-push)'],
    [/\bsudo\s/, 'running as root (sudo)'],
    [/\bchmod\s+777\b/, 'world-writable permissions (chmod 777)'],
    [/(?:curl|wget)[\s\S]*\|\s*sh\b/, 'pipe-to-shell (curl … | sh)'],
    [/\bdrop\s+table\b/, 'dropping a database table'],
    [/>\s*\/(?:etc|usr|var)\//, 'writing to a system directory'],
    [/\bmkfs\b|\bdd\s+if=/, 'a low-level disk write (dd / mkfs)'],
  ];
  for (const [re, why] of highRisk) {
    if (re.test(lc)) return { category: 'high-risk-pattern', reason: `Matches a high-risk pattern: ${why}.` };
  }

  // 2) Arbitrary code execution — can't be statically certified safe.
  const arbitrary = [
    [/\bnode\s+-e\b/, 'node -e'], [/\bnode\s+--eval\b/, 'node --eval'],
    [/\bpython3?\s+-c\b/, 'python -c'], [/\b(?:ba)?sh\s+-c\b/, 'sh -c'],
    [/\bperl\s+-e\b/, 'perl -e'], [/\bruby\s+-e\b/, 'ruby -e'], [/\beval\s/, 'eval'],
  ];
  for (const [re, name] of arbitrary) {
    if (re.test(lc)) return { category: 'arbitrary-code', reason: `Runs arbitrary code (\`${name}\`) — can't be statically certified safe; review to allow.` };
  }

  // 3) find -exec runs an arbitrary command per match.
  if (/\bfind\b[\s\S]*-exec\b/.test(lc)) {
    return { category: 'find-exec', reason: 'Uses `find -exec`, which runs an arbitrary command for each match.' };
  }

  // 4) Nothing matched a known-safe allowlist entry.
  return { category: 'unrecognized', reason: 'Not on the safe-command allowlist — needs review.' };
}

// The lightweight context packet the goal-alignment judge reasons over. Built
// from WARM data only — no transcript blob read in the approval hot path (that
// would JSON.parse multi-MB on the main loop and freeze every session). The
// pieces:
//   - goal: CTM's session title (its running summary of the task) — the cheapest
//           stand-in for "what is this session trying to do?".
//   - cwd:  the session's working directory (scopes "local/dev" vs "elsewhere").
//   - the agent's recent reasoning + the command itself are ALREADY on screen in
//     context.fullContext (the viewport the verifier prompt includes) — that IS
//     the recent transcript, for free.
// The judge uses goal + cwd + on-screen context to auto-approve goal-aligned
// actions (even irreversible local ones) and pause off-goal / real-world-
// destructive ones. The dangerous-command blocklist stays the hard floor above.
function _buildSessionContext(session) {
  const goal = String((session && (session.title || session.label)) || '').replace(/\s+/g, ' ').trim().slice(0, 300);
  const cwd = String((session && (session.cwd || (session.meta && session.meta.cwd))) || '').trim();
  return { goal, cwd };
}

async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcastFn, label, source, riskLevel, callModel) {
  // Verifier scope: medium+ risk only. Clearly low-risk/read-only approvals skip
  // the LLM second opinion — keeps the fast path fast and usable offline.
  if (riskLevel && riskLevel !== 'medium' && riskLevel !== 'high') return null;
  // Attach the warm session-context packet (goal + cwd) so the built-in verifier
  // can judge GOAL-ALIGNMENT, not just the command in isolation. Single point —
  // covers both call sites (allow-by-default + approval-rescue). Never overwrite a
  // packet the caller already supplied.
  if (context && !context.sessionContext) context.sessionContext = _buildSessionContext(session);
  const verifier = await verifyIfEnabled({ context, dbModule, callModel });
  // Approve-by-default: ONLY a confident "unsafe" verdict blocks an auto-approval.
  // A disabled verifier, a 'safe' verdict, or an 'unknown'/errored verdict (AI
  // unavailable, timeout, bad response) all fall through to APPROVE. The approver
  // escalates only when it is sure the command is high risk — never merely because
  // the AI gate could not produce an answer. (The dangerous-command blocklist
  // remains the deterministic hard gate above this.)
  if (!verifier.enabled || verifier.verdict !== 'unsafe') return null;

  // Prefer the OBJECTIVE block reason (the category — e.g. "Runs arbitrary code
  // (node -e)") over the verifier's vague free-text, so the banner + Pending group
  // tell the user WHY it was held. Fall back to the verifier's reason, then a default.
  const blocked = classifyBlockReason(context);
  const reason = blocked.reason || verifier.reason || 'Auto-approval verifier flagged this command as high risk.';
  const parts = escalationCommandParts(context);
  // The group key the Permission "Pending" tab will bucket this under — same fn the
  // endpoint uses (lib/escalation-review.commandHead over the signature), so the
  // banner's Review → can deep-link to the exact card.
  const groupKey = (() => { try { return commandHead(parts.signature) || ''; } catch { return ''; } })();
  const decision = {
    sessionId,
    toolName: context.toolName,
    // Record the ACTUAL command (not "Bash command") + a stable grouping signature
    // so the Permission "Needs Review" surface can group escalations by type.
    commandSummary: parts.title || label || context.toolName,
    commandSignature: parts.signature || '',
    fullContext: String(context.fullContext || '').slice(0, 2000),
    warning: context.warning || '',
    decision: 'escalated',
    reasoning: reason,
    decidedBy: 'verifier',
    riskLevel: 'high',
  };
  let decisionId;
  try { decisionId = dbModule.addApprovalDecision?.(decision); } catch (e) { console.error('[approval-agent] verifier DB error:', e.message); }
  try {
    broadcastFn(sessionId, session, {
      type: 'approval-decision',
      sessionId,
      decision: 'escalated',
      decidedBy: 'verifier',
      decisionId,
      // Banner shows the actual command (matches the recorded commandSummary),
      // not the heuristic rule label.
      label: parts.title || label || context.toolName || 'Approval needs review',
      reasoning: reason,
      // The objective category + the Pending group key, so the client can show the
      // right reason and the Review → button can deep-link to the matching group.
      blockCategory: blocked.category || '',
      groupKey,
      riskLevel: decision.riskLevel,
      verifierSource: source || '',
      verifierVerdict: verifier.verdict,
      command: String(context.command || '').slice(0, 500),
      warning: context.warning || '',
    });
  } catch {}
  return { blocked: true, verifier, reason };
}

// Split a shell command into its top-level clauses on the same separators Claude
// Code's own permission model recognizes (&& || ; | |& & and newlines), while
// respecting single/double quotes, backtick and $( )/$(( )) substitution nesting so
// an operator INSIDE a quote or substitution does not split. A compound command is
// only as safe as its riskiest clause, so risk is evaluated per-clause and MAX'd —
// otherwise `cat x; kill -9 PID` inherits `cat`'s low risk (a real auto-approver
// hole, and the same class as Cursor's `&&`-allowlist-bypass CVE).
function _splitShellClauses(cmd) {
  const s = String(cmd || '');
  const clauses = [];
  let buf = '';
  let sq = false, dq = false, bt = false, depth = 0;
  for (let i = 0; i < s.length; i += 1) {
    const ch = s[i], next = s[i + 1];
    if (sq) { buf += ch; if (ch === "'") sq = false; continue; }
    if (dq) { buf += ch; if (ch === '"' && s[i - 1] !== '\\') dq = false; continue; }
    if (bt) { buf += ch; if (ch === '`' && s[i - 1] !== '\\') bt = false; continue; }
    if (ch === "'") { sq = true; buf += ch; continue; }
    if (ch === '"') { dq = true; buf += ch; continue; }
    if (ch === '`') { bt = true; buf += ch; continue; }
    if (ch === '$' && next === '(') { depth += 1; buf += '$('; i += 1; continue; } // $( and $((
    if (ch === '(') { if (depth > 0) depth += 1; buf += ch; continue; }
    if (ch === ')') { if (depth > 0) depth -= 1; buf += ch; continue; }
    if (depth > 0) { buf += ch; continue; }
    if (ch === '\n' || ch === ';') { clauses.push(buf); buf = ''; continue; }
    if (ch === '&' || ch === '|') {
      // && || |& all consume two chars; single & or | consume one.
      if (next === ch || (ch === '|' && next === '&')) { i += 1; }
      clauses.push(buf); buf = ''; continue;
    }
    buf += ch;
  }
  if (buf) clauses.push(buf);
  return clauses.map((c) => c.trim()).filter(Boolean);
}

// #2: process-control clause — terminating processes (kill/pkill/killall, or a
// pipeline ending in `xargs kill`). Never blanket auto-approved as "low": it's a
// legitimate dev action (kill a dev server) but the user/AI must vouch for the
// target, so it routes to review (and, in the context-aware judge, goal-alignment).
function _isProcessControlClause(clause) {
  return /\b(?:kill|pkill|killall)\b/.test(clause) || /\bxargs\b[\s\S]*\bkill\b/.test(clause);
}

// Simple heuristic review when no API key is available
function reviewWithHeuristics(context) {
  const cmd = (context.command || '').toLowerCase();
  // Strip transparent command wrappers (time, env, nice, nohup) so the actual
  // command is matched against patterns. "time curl ..." should match curl rules.
  const cmdUnwrapped = cmd.replace(/^(#[^\n]*\n\s*)*/, '').replace(/^(time|env|nice|nohup|command)\s+/g, '');
  // Strip Unicode bullets/icons (⏺●) from tool name before matching
  const tool = (context.toolName || '').replace(/^[⏺●\s]+/, '').toLowerCase();
  const warning = (context.warning || '').toLowerCase();

  if (/^mcp\b/.test(tool)
      && /\bwall-e\.walle_memory_status\b|\bwall-e\b[\s\S]*\bwalle_memory_status\b/.test(cmd)) {
    return { decision: 'approve', reasoning: 'Read-only Wall-E memory status MCP call (heuristic)', riskLevel: 'low',
      ruleLabel: 'Wall-E memory status', rulePattern: 'wall-e.*walle_memory_status',
      ruleDescription: 'Read Wall-E MCP memory status' };
  }

  // MCP tool calls: auto-approve clearly READ-ONLY operations (navigate,
  // snapshot, read, list, query, etc.). Mutating MCP ops (click, type, fill,
  // run_code, evaluate, upload, write, delete, …) deliberately fall through to
  // the AI reviewer/verifier (medium). Matches both the cleaned tool name
  // (e.g. "plugin:playwright:playwright – navigate to a url") and the raw
  // "mcp__…" / "(mcp)" command text.
  const isMcp = /^(?:plugin:|mcp__|mcp\b)/i.test(tool) || /\(mcp\)/i.test(cmd);
  if (isMcp) {
    const readOnlyMcp = /\b(navigate(?:_back)?|snapshot|take_screenshot|screenshot|read|list|get|query|search|console_messages|network_requests?|wait_for|hover|tabs|resolve[-_ ]library[-_ ]id|get[-_ ]library[-_ ]docs|browser_snapshot|browser_navigate)\b/i;
    const mutatingMcp = /\b(click|type|fill|drag|drop|file_upload|upload|run_code|evaluate|press_key|select_option|handle_dialog|write|create|delete|remove|update|send|post|install|deploy|exec)\b/i;
    if (readOnlyMcp.test(tool) && !mutatingMcp.test(tool)) {
      return { decision: 'approve', reasoning: 'Read-only MCP operation (heuristic)', riskLevel: 'low',
        ruleLabel: context.toolName || 'MCP read-only operation',
        rulePattern: '',
        ruleDescription: 'Auto-approve read-only MCP operations (navigate, snapshot, read, list, query)' };
    }
    // Any other MCP op: do not blanket-approve — return medium so it routes to
    // the AI reviewer/verifier (which can still approve or escalate).
    return { decision: 'approve', reasoning: 'MCP operation — needs review', riskLevel: 'medium', fallback: true,
      ruleLabel: context.toolName || 'MCP operation', rulePattern: '',
      ruleDescription: 'Routed to AI reviewer/verifier for a decision' };
  }

  // Low-risk tools — auto-approve immediately (before high-risk content check,
  // because Edit/Write diffs may contain code with "drop table" or "rm -rf" as
  // string literals — those are code content, not dangerous operations).
  const lowRisk = [
    /^(read|glob|grep|webfetch|notebookedit)/, // Read-only tools
    /^(edit|write)\b/, // Edit/Write to project files — normal dev workflow
  ];
  for (const re of lowRisk) {
    if (re.test(tool)) {
      return { decision: 'approve', reasoning: 'Standard dev tool (heuristic)', riskLevel: 'low',
        ruleLabel: `${context.toolName} operations`, rulePattern: context.toolName.replace(/\s+/g, '\\\\s+'),
        ruleDescription: `Auto-approve ${context.toolName} operations` };
    }
  }

  const findExecPolicy = classifyFindExecPolicy(cmdUnwrapped || cmd);
  if (findExecPolicy) return findExecPolicy;

  // High-risk patterns — escalate. Checked against the WHOLE command (and the
  // provider warning) because a danger substring (rm -rf /, curl|sh) is dangerous
  // regardless of where clause boundaries fall.
  const highRisk = [
    /rm\s+-rf?\s+(?!\/tmp\/)[\/~]/, /force.?push/, /--force/, /drop\s+table/i,
    /delete.*production/i, /sudo\s/, /chmod\s+777/, /curl.*\|\s*sh/,
    />\s*\/etc\//, />\s*\/usr\//, />\s*\/var\//, /mkfs/,
    /dd\s+if=(?!\/dev\/(u?random|zero)\b.*>\s*\/tmp\/)/,
  ];
  for (const re of highRisk) {
    if (re.test(cmd) || re.test(warning)) {
      return { decision: 'escalate', reasoning: 'High-risk operation detected (heuristic)', riskLevel: 'high' };
    }
  }

  // Local dev operations that are safe to auto-approve — matched PER CLAUSE.
  const devSafe = [
    { re: /echo\s+.*>\s*\/tmp\//, label: 'Write to /tmp', desc: 'Echo output to temp files' },
    { re: /^\s*cd\s/, label: 'Change directory', desc: 'Change the working directory' },
    { re: /^\s*sleep\s+[\d.]+\s*$/, label: 'Sleep', desc: 'Pause for a fixed duration' },
    { re: /\bcat\s/, label: 'Read file contents', desc: 'View file contents with cat' },
    { re: /\bls\b/, label: 'List directory', desc: 'List files and directories' },
    { re: /\bpwd\b/, label: 'Print working directory', desc: 'Show current directory path' },
    { re: /git\s+(status|log|diff|branch|show|stash\s+list|tag|remote)/, label: 'Git read operations', desc: 'Read-only git commands (status, log, diff, branch, show, tag, remote)' },
    // NOTE: `node -e`, `python -c`, `cp`, `mv`, and `sqlite3` are intentionally
    // NOT here — they can run arbitrary code or mutate/overwrite arbitrary files
    // (incl. databases) and must go through the AI reviewer/verifier (medium),
    // not blanket low-risk auto-approve.
    { re: /npm\s+(run|test|start)/, label: 'npm script', desc: 'Run npm scripts (run, test, start)' },
    { re: /mkdir\s+-?p?\s/, label: 'Create directory', desc: 'Create directories with mkdir' },
    { re: />\s*\/tmp\//, label: 'Write to /tmp', desc: 'Redirect output to temp files' },
    { re: /touch\s/, label: 'Create empty file', desc: 'Create or update file timestamps' },
    { re: /\bcurl\s[\s\S]*?(https?:\/\/localhost|http:\/\/127\.0\.0\.1)/, label: 'Curl localhost', desc: 'HTTP requests to local dev servers' },
    { re: /grep\s+-?[crn]/, label: 'Grep search', desc: 'Search file contents with grep' },
    { re: /wc\s/, label: 'Word count', desc: 'Count lines/words/bytes' },
    { re: /head\s|tail\s/, label: 'Read file head/tail', desc: 'View beginning or end of files' },
    { re: /which\s|type\s/, label: 'Find command', desc: 'Locate commands in PATH' },
    { re: /echo\s[^|>]+$/, label: 'Echo output', desc: 'Print text to stdout (no redirect/pipe)' },
    { re: /find\s.*-name/, label: 'Find files', desc: 'Search for files by name' },
    { re: /sort\s|uniq\s/, label: 'Sort/unique', desc: 'Sort or deduplicate output' },
    { re: /\bjq\s/, label: 'JSON processing', desc: 'Process JSON with jq' },
    { re: /\bsed\s+-?[ne]/, label: 'Sed filter', desc: 'Stream editing with sed (non-destructive)' },
    { re: /\bawk\s/, label: 'Awk processing', desc: 'Text processing with awk' },
    { re: /\bdiff\s/, label: 'Diff files', desc: 'Compare files or directories' },
    { re: /\bstat\s/, label: 'File stat', desc: 'Show file metadata' },
    { re: /\bdu\s/, label: 'Disk usage', desc: 'Show disk usage' },
    { re: /\bdf\s/, label: 'Disk free', desc: 'Show disk space' },
    { re: /\benv\b|\bprintenv\b/, label: 'Environment', desc: 'Print environment variables' },
    { re: /\blsof\s/, label: 'List open files', desc: 'List open files and ports' },
    { re: /\bps\s/, label: 'Process list', desc: 'List running processes' },
  ];

  // Per-clause MAX-risk: the command auto-approves as low ONLY if EVERY clause is
  // a known-safe dev operation. Any clause that terminates processes (#2) or is
  // unrecognized makes the whole command 'medium' → AI reviewer/verifier (which,
  // with session context, can still auto-approve a goal-aligned action).
  const clauses = _splitShellClauses(cmdUnwrapped || cmd);
  let firstSafe = null;
  let review = null;
  for (const clause of clauses) {
    // A bare assignment with no command substitution just sets a variable
    // (literal or arithmetic) — harmless. `VAR=$(cmd)` keeps the inner command,
    // so it falls through to be classified by that command below.
    if (/^\w+=/.test(clause) && !/\$\((?!\()/.test(clause) && !/`/.test(clause)) continue;
    if (_isProcessControlClause(clause)) {
      review = review || { label: 'Process control', desc: 'Terminates processes (kill/pkill) — review the target' };
      continue;
    }
    const safe = devSafe.find(({ re }) => re.test(clause));
    if (safe) { firstSafe = firstSafe || safe; continue; }
    review = review || { label: context.toolName || 'Bash command', desc: 'Routed to AI reviewer/verifier for a decision' };
  }
  if (review) {
    return { decision: 'approve', reasoning: 'Compound/unrecognized command — needs review', riskLevel: 'medium', fallback: true,
      ruleLabel: review.label, rulePattern: '', ruleDescription: review.desc };
  }
  if (firstSafe) {
    return { decision: 'approve', reasoning: 'Common dev operation (heuristic, all clauses safe)', riskLevel: 'low',
      ruleLabel: firstSafe.label, rulePattern: firstSafe.re.source, ruleDescription: firstSafe.desc };
  }

  // Default: medium risk — NOT auto-approved here. Routed to the AI reviewer +
  // verifier; if the AI gate is unavailable it escalates to the user (fail-safe).
  return { decision: 'approve', reasoning: 'Unrecognized command — needs review', riskLevel: 'medium', fallback: true,
    ruleLabel: context.toolName || 'Unknown', rulePattern: '',
    ruleDescription: 'Routed to AI reviewer/verifier for a decision' };
}

// Fail-safe verdict when the AI reviewer cannot produce an answer. We escalate
// (ask the user) rather than fall back to heuristic approval — a missing/erroring
// AI gate must never silently widen auto-approval (see docs/approval-ai-refinement.md).
function _aiUnavailableEscalation(detail) {
  return {
    decision: 'escalate',
    riskLevel: 'medium',
    reasoning: `AI reviewer unavailable — escalating for safety${detail ? ` (${detail})` : ''}`,
    ruleLabel: '',
    rulePattern: '',
    ruleDescription: '',
    aiUnavailable: true,
  };
}

// Review the command as a TL/Code Reviewer using the user's configured default
// AI provider (via callBackgroundLlm — Anthropic/OpenAI/Gemini/Ollama/etc.).
// No hardcoded provider or model. `options.callModel` is injectable for tests.
async function reviewWithAI(context, learnedRules, options = {}) {
  const callModel = options.callModel || callBackgroundLlm;
  const rules = Array.isArray(learnedRules) ? learnedRules : [];
  const rulesContext = rules.length > 0
    ? `\nPreviously approved patterns (the user has approved these before):\n${rules.map(r => `- ${r.label}: ${r.description || r.pattern}`).join('\n')}\n`
    : '';

  const system = `You are a senior TL/Code Reviewer acting as a gatekeeper for a developer's AI coding CLI sessions. Decide whether a requested command/tool call is safe to AUTO-APPROVE or should ESCALATE to the developer.

The developer's general approach:
- Approve read-only operations, file reads, searches, and code edits within their project
- Approve git read/commit operations and running their own dev scripts
- Approve npm/pip install for known dependencies and local dev-server restarts
- Be cautious about: force push, deleting production/shared data, modifying CI/CD, running unknown binaries, writing to system directories, destructive DB ops, exfiltrating data

Be pragmatic for a local dev environment, but ESCALATE anything that could cause irreversible damage or affect production/shared systems. Return ONLY valid JSON (no markdown fences).`;

  const prompt = `${rulesContext}
Current request being reviewed:
Tool: ${context.toolName}
Command/Content:
${(context.command || '').slice(0, 1500)}

Safety Warning: ${context.warning || 'None'}

Return ONLY this JSON shape:
{
  "decision": "approve" | "escalate",
  "riskLevel": "low" | "medium" | "high",
  "reasoning": "brief explanation (1-2 sentences)",
  "ruleLabel": "short label for this type of operation",
  "rulePattern": "regex pattern that would match similar future requests",
  "ruleDescription": "human-readable description of what this rule covers"
}`;

  let response;
  try {
    response = await callModel(prompt, { system, maxTokens: 512, temperature: 0.1 });
  } catch (e) {
    console.error('[approval-agent] AI reviewer call failed:', e.message);
    return _aiUnavailableEscalation(e.reason || e.message);
  }

  const text = (response && (response.text ?? response)) || '';
  const result = _extractJsonObject(typeof text === 'string' ? text : '');
  if (!result) {
    console.error('[approval-agent] Could not parse AI reviewer response — escalating for safety');
    return _aiUnavailableEscalation('unparseable response');
  }

  return {
    decision: result.decision === 'approve' ? 'approve' : 'escalate',
    riskLevel: result.riskLevel || 'medium',
    reasoning: result.reasoning || '',
    ruleLabel: result.ruleLabel || '',
    rulePattern: result.rulePattern || '',
    ruleDescription: result.ruleDescription || '',
  };
}

// Duration (ms) to suppress WS output after sending an approval keystroke.
// During this window the TUI redraws (erases options, shows spinner) — streaming
// the intermediate ANSI frames to the client causes brief visual garble.
// The headless terminal still receives all data; after the window expires,
// server.js sends a clean snapshot to the client.
const APPROVAL_TRANSITION_MS = 300;
const APPROVAL_TRANSITION_SETTLE_MS = 150;

// Phase 3 verification window. After writing the approval keystroke, we expect
// Claude Code to transition state (cursor moves, alt-screen exits, or
// "Running..." appears). If no transition is detected within this window, the
// match was likely a false positive — recover (see below) and enter cooldown.
const VERIFY_WINDOW_MS = 500;

// Bytes of PTY output after a keystroke that we treat as evidence of a real
// TUI transition. A genuine approval click causes Claude Code to redraw the
// widget area (clear options, render spinner, etc.) — typically hundreds of
// bytes. An echo of "1" or "2" into the input box is just a few bytes.
const VERIFY_TRANSITION_BYTES = 8;

// Approval rescue is intentionally bounded. It is not a replacement for the
// provider parser/gate; it tries one missed prompt, verifies the outcome, and
// suppresses repeated failures by exact fingerprint.
const RESCUE_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
const RESCUE_FAILURE_COOLDOWN_MS = 60 * 60 * 1000;
const RESCUE_WARN_COOLDOWN_MS = 30 * 60 * 1000;
const RESCUE_MAX_CONSECUTIVE_FAILURES = 2;
const RESCUE_DEFAULT_VERIFY_DELAY_MS = APPROVE_DELAY_MS + VERIFY_WINDOW_MS + APPROVAL_TRANSITION_SETTLE_MS + 300;

// Backspace character used to erase a stray keystroke that landed in the
// input box (false-positive detection). Modern terminals interpret \x7f (DEL)
// as backspace; \b (BS = 0x08) is unreliable on macOS PTYs.
const BACKSPACE = '\x7f';

// Send approval keystroke to the PTY, respecting provider's requiresEnter setting.
//
// Timing protocol (when headlessWorker is wired up — production path):
//   T=0      — write keystroke ("1" or "2"), tell worker verify window started
//   T=VERIFY — read PTY output bytes since T=0:
//                ≥ VERIFY_TRANSITION_BYTES → real approval → write "\r" to confirm
//                <  VERIFY_TRANSITION_BYTES → false positive → write BACKSPACE
//                  to erase the orphan char (Enter NEVER fires)
//
// Without this two-stage protocol, an Enter at T=ENTER_DELAY_MS submits the
// keystroke before we can tell whether it landed on an approval widget or in
// the input box — the latter case produces a phantom "1" or "2" user message.
//
// Claude Code is the exception: its approval menu normally commits only after
// Enter and may not redraw after the bare numeric key. For providers that set
// commitApprovalBeforeVerify, the stale-prompt preflight remains the safety
// gate; this function sends Enter on the old timer and treats the post-commit
// byte check as diagnostics/cooldown only.
//
// Legacy path (no headlessWorker, e.g. unit tests): keep original
// keystroke + ENTER_DELAY_MS Enter behavior so existing tests still pass.
function sendApprovalKeystroke(session, context, headlessWorker, options = {}) {
  const provider = context.providerId ? getProvider(context.providerId) : null;
  const keystroke = options.keystroke || getApproveKeystroke(context, options);
  const sid = session.id ? session.id.slice(0, 8) : '?';
  const decidedBy = context._decidedBy || 'unknown';
  const ruleLabel = context._ruleLabel || context.toolName || 'Unknown';
  // Set transition flag BEFORE writing keystroke — flushOutput checks this.
  // When headless verification is available, keep the suppression window open
  // through the verification lifecycle so the browser does not receive half of
  // the approval-dismiss redraw before server.js repairs it with a snapshot.
  const transitionMs = headlessWorker && session.id
    ? Math.max(APPROVAL_TRANSITION_MS, VERIFY_WINDOW_MS + APPROVAL_TRANSITION_SETTLE_MS)
    : APPROVAL_TRANSITION_MS;
  session._approvalTransitionUntil = Date.now() + transitionMs;

  // Phase 3: tell the worker to start a verification window
  if (headlessWorker && session.id) {
    try {
      headlessWorker.postMessage({
        type: 'verify-start',
        sessionId: session.id,
        windowMs: VERIFY_WINDOW_MS,
      });
    } catch {}
  }

  const needsEnter = provider ? provider.requiresEnter : true;
  const commitBeforeVerify = !!(provider && provider.commitApprovalBeforeVerify && needsEnter);
  const outputBytesAtStart = session._outputBytesCounter || 0;
  const verifyMode = headlessWorker
    ? (commitBeforeVerify ? ` (Enter commits before diagnostic verify @ ${VERIFY_WINDOW_MS}ms)` : ` (Enter deferred until verify @ ${VERIFY_WINDOW_MS}ms)`)
    : '';
  console.log(`[approval-agent] Sending keystroke "${keystroke}" for session ${sid} decidedBy=${decidedBy} label="${ruleLabel}"${verifyMode}`);
  session.ptyProcess.write(keystroke);

  if (!headlessWorker || !session.id) {
    // Legacy path: no verify available — fire Enter on the old timer.
    if (needsEnter) {
      setTimeout(() => session.ptyProcess.write('\r'), ENTER_DELAY_MS);
    }
    return;
  }

  if (commitBeforeVerify) {
    setTimeout(() => session.ptyProcess.write('\r'), ENTER_DELAY_MS);
    setTimeout(() => {
      try {
        const outputBytesNow = session._outputBytesCounter || 0;
        const advanced = outputBytesNow - outputBytesAtStart;
        const transitionOccurred = advanced >= VERIFY_TRANSITION_BYTES;
        headlessWorker.postMessage({
          type: 'verify-end',
          sessionId: session.id,
          failed: !transitionOccurred,
        });
        if (!transitionOccurred) {
          console.warn(`[approval] post-commit verify did not observe a transition for session ${sid} — only ${advanced} bytes of PTY output after approval. Enter was already submitted; cooling down duplicate detection.`);
        }
      } catch (e) {
        console.error(`[approval] post-commit verify error for session ${sid}:`, e.message);
      }
    }, VERIFY_WINDOW_MS);
    return;
  }

  // Phase 3: after verify window, check if the terminal produced output in response.
  // A real approval triggers a redraw (TUI exits/changes). A false positive goes
  // into Claude Code's input buffer and produces no immediate terminal output.
  setTimeout(() => {
    try {
      const outputBytesNow = session._outputBytesCounter || 0;
      const advanced = outputBytesNow - outputBytesAtStart;
      const transitionOccurred = advanced >= VERIFY_TRANSITION_BYTES;
      headlessWorker.postMessage({
        type: 'verify-end',
        sessionId: session.id,
        failed: !transitionOccurred,
      });
      if (transitionOccurred) {
        // Real approval — confirm with Enter (if provider requires it)
        if (needsEnter) {
          session.ptyProcess.write('\r');
        }
      } else {
        // False positive — keystroke landed in Claude Code's input box.
        // Erase it BEFORE Enter is sent so it doesn't submit as a phantom
        // user message. Send one backspace per character of the keystroke.
        for (let i = 0; i < keystroke.length; i++) {
          session.ptyProcess.write(BACKSPACE);
        }
        console.warn(`[approval] verify-FAIL for session ${sid} — only ${advanced} bytes of PTY output (need ≥${VERIFY_TRANSITION_BYTES}). Sent ${keystroke.length} backspace(s) to erase stray keystroke; NOT submitting Enter. Cooling down.`);
      }
    } catch (e) {
      console.error(`[approval] verify error for session ${sid}:`, e.message);
    }
  }, VERIFY_WINDOW_MS);
}

function _hashRescue(value) {
  return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 32);
}

function approvalRescueFingerprint(context, providerId, rawText, gateReason) {
  const normalizedProvider = providerId || context?.providerId || 'unknown';
  const signature = context
    ? normalizeCommandSignature(context.toolName, context.command)
    : '';
  const body = signature || String(rawText || '').replace(/\s+/g, ' ').trim().slice(-1000);
  return _hashRescue(`${normalizedProvider}\n${gateReason || ''}\n${context?.toolName || ''}\n${body}`);
}

function _getRescuePattern(fingerprint) {
  try { return dbModule.getApprovalRescuePattern?.(fingerprint) || null; } catch { return null; }
}

function _saveRescuePattern(row) {
  try { return dbModule.saveApprovalRescuePattern?.(row) || row; } catch (e) {
    console.error('[approval-rescue] DB error:', e.message);
    return row;
  }
}

function _baseRescueRow(fingerprint, existing, meta = {}) {
  const now = Date.now();
  return {
    fingerprint,
    providerId: meta.providerId || existing?.provider_id || '',
    detectionSource: meta.source || existing?.detection_source || '',
    gateReason: meta.gateReason || existing?.gate_reason || '',
    status: existing?.status || 'candidate',
    attempts: Number(existing?.attempts || 0),
    successes: Number(existing?.successes || 0),
    failures: Number(existing?.failures || 0),
    consecutiveFailures: Number(existing?.consecutive_failures || 0),
    lastDecision: existing?.last_decision || '',
    lastOutcome: existing?.last_outcome || '',
    lastDiagnosis: existing?.last_diagnosis || '',
    ruleLabel: existing?.rule_label || '',
    ruleDescription: existing?.rule_description || '',
    approvalKey: existing?.approval_key || '',
    requiresEnter: existing?.requires_enter ?? 1,
    promotedRuleId: existing?.promoted_rule_id || null,
    cooldownUntilMs: Number(existing?.cooldown_until_ms || 0),
    lastWarningAtMs: Number(existing?.last_warning_at_ms || 0),
    lastSeenAtMs: now,
    lastAttemptAtMs: Number(existing?.last_attempt_at_ms || 0),
  };
}

function _extractJsonObject(text) {
  const raw = String(text || '').trim();
  const match = raw.match(/\{[\s\S]*\}/);
  if (!match) return null;
  try { return JSON.parse(match[0]); } catch { return null; }
}

async function reviewApprovalRescueCandidate(context, meta = {}, options = {}) {
  const heuristic = reviewWithHeuristics(context);
  if (heuristic.policyFinal && heuristic.decision === 'approve' && heuristic.riskLevel === 'low') {
    return {
      safeToTry: true,
      decidedBy: 'heuristic-rescue',
      missType: 'structural_gate_miss',
      reasoning: heuristic.reasoning,
      ruleLabel: heuristic.ruleLabel || context.toolName || 'Approval',
      ruleDescription: heuristic.ruleDescription || '',
      fallbackHeuristic: true,
    };
  }
  if (heuristic.riskLevel === 'high' || heuristic.decision === 'escalate') {
    return {
      safeToTry: false,
      decidedBy: 'heuristic-rescue',
      missType: 'blocked_by_policy',
      shouldWarnUser: true,
      reasoning: heuristic.reasoning || 'High-risk operation detected.',
      ruleLabel: heuristic.ruleLabel || context.toolName || 'Approval',
      ruleDescription: heuristic.ruleDescription || '',
      fallbackHeuristic: true,
    };
  }

  const callModel = options.callModel || callBackgroundLlm;
  if (options.disableAi !== true && typeof callModel === 'function') {
    const prompt = `You are CTM's approval-rescue monitor.

The deterministic approval pipeline saw approval-shaped terminal text but rejected it before policy could act.

Decide whether this is an ACTIVE approval prompt that should be auto-approved exactly once.

Rules:
- Only approve if the prompt is active, current, and the operation is safe for a local coding session.
- Never choose a durable allow-all option during rescue.
- If the provider parser already detected the prompt but a structural gate rejected it, this is not a new provider pattern.
- If the deterministic path missed this because an unknown/new provider has a new prompt shape, set missType to "new_provider_pattern".
- If it looks like an existing provider parser/gate/race bug, set missType to "structural_gate_miss", "parser_bug", or "race".
- If stale or uncertain, safeToTry must be false.

Provider: ${meta.providerId || context.providerId || 'unknown'}
Gate reason: ${meta.gateReason || 'unknown'}
Source: ${meta.source || 'unknown'}
Provider parser detected prompt: ${meta.rawDetected ? 'yes' : 'no'}
Generic hint detected prompt: ${meta.hintDetected ? 'yes' : 'no'}
Tool: ${context.toolName}
Command:
${String(context.command || '').slice(0, 1500)}

Warning: ${context.warning || 'none'}
Detected context:
${String(context.fullContext || '').slice(0, 2000)}

Heuristic policy says: ${heuristic.decision || 'unknown'} / ${heuristic.riskLevel || 'unknown'} / ${heuristic.reasoning || ''}

Return only JSON:
{
  "safeToTry": true or false,
  "missType": "new_provider_pattern" or "structural_gate_miss" or "parser_bug" or "race" or "stale_screen" or "blocked_by_policy" or "unknown",
  "reasoning": "one sentence",
  "ruleLabel": "short label",
  "ruleDescription": "short description",
  "approvalKey": "optional one-time key such as 1 or y",
  "shouldWarnUser": true or false
}`;

    try {
      const response = await callModel(prompt, {
        task: 'approval-rescue',
        modelTier: 'fast',
        maxTokens: 512,
        temperature: 0,
        thinking: 'disabled',
        reasoningEffort: 'low',
        timeoutMs: Number(options.modelTimeoutMs || 45000),
      });
      const parsed = _extractJsonObject(response?.text || response);
      if (parsed) {
        return {
          safeToTry: !!parsed.safeToTry,
          decidedBy: 'ai-rescue',
          missType: String(parsed.missType || 'unknown'),
          reasoning: String(parsed.reasoning || ''),
          ruleLabel: String(parsed.ruleLabel || context.toolName || 'Approval'),
          ruleDescription: String(parsed.ruleDescription || ''),
          approvalKey: String(parsed.approvalKey || ''),
          shouldWarnUser: !!parsed.shouldWarnUser,
          model: response?.model || '',
        };
      }
    } catch (e) {
      if (options.logModelErrors !== false) {
        console.warn('[approval-rescue] AI review unavailable, falling back to deterministic low-risk policy:', e.message);
      }
    }
  }

  const explicitLowRisk = heuristic.decision === 'approve'
    && heuristic.riskLevel === 'low'
    && !heuristic.fallback;
  return {
    safeToTry: explicitLowRisk,
    decidedBy: 'heuristic-rescue',
    missType: explicitLowRisk ? 'structural_gate_miss' : 'unknown',
    reasoning: explicitLowRisk
      ? heuristic.reasoning
      : 'No confident AI or deterministic low-risk approval decision was available.',
    ruleLabel: heuristic.ruleLabel || context.toolName || 'Approval',
    ruleDescription: heuristic.ruleDescription || '',
    shouldWarnUser: !explicitLowRisk,
    fallbackHeuristic: true,
  };
}

function _diagnoseApprovalRescueMissType(review, context, meta = {}) {
  const pid = meta.providerId || context?.providerId || '';
  const knownProvider = !!(pid && getProvider(pid));
  const missType = String(review?.missType || 'unknown');
  const gateReason = String(meta.gateReason || meta.reason || '');
  const source = String(meta.source || '');
  const providerAlreadySawPrompt = !!meta.rawDetected || !!gateReason || /gate-miss/i.test(source);

  // AI decides whether a one-shot rescue is safe. Promotion is architecture,
  // not vibes: if a known provider already detected the approval and only the
  // structural gate rejected it, the root cause is our parser/gate path.
  if (missType === 'new_provider_pattern' && knownProvider && providerAlreadySawPrompt) {
    return 'structural_gate_miss';
  }
  return missType;
}

function _shouldPromoteApprovalRescuePattern(review, context, meta = {}) {
  const diagnosis = _diagnoseApprovalRescueMissType(review, context, meta);
  if (diagnosis !== 'new_provider_pattern') return false;
  const pid = meta.providerId || context?.providerId || '';
  const knownProvider = !!(pid && getProvider(pid));
  return !knownProvider;
}

function _broadcastRescueWarning(sessionId, session, broadcastFn, context, review, row) {
  const now = Date.now();
  if (row.lastWarningAtMs && now - row.lastWarningAtMs < RESCUE_WARN_COOLDOWN_MS) return row;
  const updated = _saveRescuePattern({
    ...row,
    lastWarningAtMs: now,
    status: row.status === 'promoted' ? 'promoted' : 'suppressed',
  });
  try {
    // The banner title must be the actual command needing review — NOT the AI
    // rescue-monitor's free-text ruleLabel (which describes its own verdict, e.g.
    // "approve-once-new-pattern", and is meaningless to the operator). The model's
    // label/reasoning is kept as the secondary "why" (verdict/reasoning).
    const title = escalationCommandParts(context).title;
    broadcastFn(sessionId, session, {
      type: 'approval-decision',
      sessionId,
      decision: 'escalated',
      decidedBy: 'rescue-monitor',
      label: title || context.toolName || 'Approval needs attention',
      verdict: review.ruleLabel || '',
      reasoning: review.reasoning || 'CTM detected a possible missed approval, but the rescue attempt was not safe or did not work.',
      riskLevel: 'medium',
      approvalRescue: true,
      command: String(context.command || '').slice(0, 500),
      warning: context.warning || '',
    });
  } catch {}
  return updated || row;
}

async function _scheduleRescueAttempt(sessionId, session, context, headlessWorker, broadcastFn, decisionPayload, options = {}) {
  return new Promise(resolve => {
    let settled = false;
    const timeout = setTimeout(() => {
      if (settled) return;
      settled = true;
      resolve({ status: 'timeout', sent: false });
    }, Number(options.scheduleTimeoutMs || 2000));

    _scheduleGuardedApproval(session, context, headlessWorker, broadcastFn, sessionId, decisionPayload, {
      keystrokeOptions: {
        preferAllowAll: false,
        ...(options.keystroke ? { keystroke: options.keystroke } : {}),
      },
      onResult(result) {
        if (settled) return;
        settled = true;
        clearTimeout(timeout);
        resolve(result);
      },
    });
  });
}

async function handleApprovalRescueCandidate(sessionId, session, cleanText, broadcastFn, providerId, headlessWorker, meta = {}, options = {}) {
  let enabled = true;
  try { enabled = dbModule.getSetting ? !!dbModule.getSetting('approval_rescue_enabled', true) : true; } catch {}
  if (!enabled) return { handled: false, reason: 'disabled' };

  const rawText = String(cleanText || meta.rawText || '');
  if (!rawText) return { handled: false, reason: 'empty' };

  const providerContext = _parseKnownProviderContext(rawText, providerId);
  let context = providerContext?.context || parseApprovalContext(rawText, providerId);
  if (!context && providerId) context = parseApprovalContext(rawText, null);
  if (!context) return { handled: false, reason: 'unparsed' };
  const effectiveProviderId = providerContext?.providerId
    || (providerId && getProvider(providerId) ? providerId : '')
    || (context.providerId && getProvider(context.providerId) ? context.providerId : '')
    || context.providerId
    || providerId
    || 'generic';
  context.providerId = effectiveProviderId;

  const fingerprint = approvalRescueFingerprint(context, effectiveProviderId, rawText, meta.gateReason);
  const existing = _getRescuePattern(fingerprint);
  let row = _baseRescueRow(fingerprint, existing, {
    providerId: effectiveProviderId,
    source: meta.source || 'gate-miss',
    gateReason: meta.gateReason || meta.reason || '',
  });
  row = _saveRescuePattern(row) || row;
  row = _baseRescueRow(fingerprint, row, {
    providerId: effectiveProviderId,
    source: meta.source || 'gate-miss',
    gateReason: meta.gateReason || meta.reason || '',
  });

  const now = Date.now();
  if (row.cooldownUntilMs && row.cooldownUntilMs > now) {
    return { handled: false, reason: 'cooldown', fingerprint };
  }
  if (row.consecutiveFailures >= RESCUE_MAX_CONSECUTIVE_FAILURES && row.status !== 'promoted') {
    row.status = 'blocked';
    row.cooldownUntilMs = now + RESCUE_FAILURE_COOLDOWN_MS;
    row.lastOutcome = 'blocked-repeat-failures';
    _saveRescuePattern(row);
    return { handled: false, reason: 'blocked-repeat-failures', fingerprint };
  }

  if (isBlocklistEnabled()) {
    const blockCheck = checkBlocklist(context.command || '', getBlocklistConfig());
    if (blockCheck.blocked) {
      row.status = 'suppressed';
      row.lastDecision = 'blocked';
      row.lastOutcome = 'blocklist';
      row.lastDiagnosis = blockCheck.category || 'blocked_by_policy';
      _saveRescuePattern(row);
      _broadcastRescueWarning(sessionId, session, broadcastFn, context, {
        ruleLabel: `Blocklist: ${blockCheck.reason}`,
        reasoning: `Dangerous-command blocklist matched (${blockCheck.category}): ${blockCheck.reason}`,
      }, row);
      return { handled: true, reason: 'blocklist', fingerprint, outcome: 'blocked' };
    }
  }

  let review;
  if (row.status === 'promoted' && row.lastDecision === 'approve') {
    review = {
      safeToTry: true,
      decidedBy: 'rescue-rule',
      missType: row.lastDiagnosis || 'new_provider_pattern',
      reasoning: row.ruleDescription || 'Matched verified approval rescue pattern.',
      ruleLabel: row.ruleLabel || context.toolName || 'Approval',
      ruleDescription: row.ruleDescription || '',
      approvalKey: row.approvalKey || '',
      shouldWarnUser: false,
    };
  } else {
    review = await reviewApprovalRescueCandidate(context, {
      providerId: effectiveProviderId,
      source: meta.source || 'gate-miss',
      gateReason: meta.gateReason || meta.reason || '',
      rawDetected: !!meta.rawDetected,
      hintDetected: !!meta.hintDetected,
    }, options);
  }
  const diagnosis = _diagnoseApprovalRescueMissType(review, context, {
    providerId: effectiveProviderId,
    source: meta.source || 'gate-miss',
    gateReason: meta.gateReason || meta.reason || '',
    rawDetected: !!meta.rawDetected,
    hintDetected: !!meta.hintDetected,
  });
  review = { ...review, missType: diagnosis };

  if (!review.safeToTry) {
    row.status = row.status === 'promoted' ? 'promoted' : 'suppressed';
    row.lastDecision = 'suppress';
    row.lastOutcome = 'not-safe';
    row.lastDiagnosis = diagnosis || 'unknown';
    row.cooldownUntilMs = now + RESCUE_RETRY_COOLDOWN_MS;
    row.ruleLabel = review.ruleLabel || row.ruleLabel;
    row.ruleDescription = review.ruleDescription || row.ruleDescription;
    row = _saveRescuePattern(row) || row;
    // Only pin a "review needed" banner when there is a concrete, classified
    // command to show the operator. A non-actionable candidate (no parsed command
    // or an unclassified "Unknown" tool) is almost always approval-shaped PROSE,
    // not a live prompt — escalating it produces a confusing, meaningless banner.
    // The refinement loop (handleMiss) still runs separately and learns the shape.
    if (review.shouldWarnUser && _rescueCandidateActionable(context)) {
      _broadcastRescueWarning(sessionId, session, broadcastFn, context, review, row);
    }
    return { handled: false, reason: 'not-safe', fingerprint, decidedBy: review.decidedBy, diagnosis };
  }

  const verifierBlock = await _verifyAutoApprovalOrBlock(
    sessionId,
    session,
    context,
    broadcastFn,
    review.ruleLabel || context.toolName || 'Approval',
    'approval-rescue'
  );
  if (verifierBlock) {
    row.status = row.status === 'promoted' ? 'promoted' : 'suppressed';
    row.lastDecision = 'verifier-blocked';
    row.lastOutcome = verifierBlock.verifier?.verdict || 'verifier-blocked';
    row.lastDiagnosis = 'blocked_by_verifier';
    row.cooldownUntilMs = now + RESCUE_RETRY_COOLDOWN_MS;
    row.ruleLabel = review.ruleLabel || row.ruleLabel;
    row.ruleDescription = verifierBlock.reason || row.ruleDescription;
    _saveRescuePattern(row);
    return { handled: true, reason: 'verifier-blocked', fingerprint, decidedBy: 'verifier', diagnosis: 'blocked_by_verifier' };
  }

  const outputBytesAtAttempt = session._outputBytesCounter || 0;
  row.attempts += 1;
  row.lastAttemptAtMs = now;
  row.lastDecision = 'approve';
  row.lastOutcome = 'attempting';
  row.lastDiagnosis = diagnosis || 'unknown';
  row.ruleLabel = review.ruleLabel || row.ruleLabel || context.toolName || 'Approval';
  row.ruleDescription = review.ruleDescription || row.ruleDescription || '';
  row.approvalKey = review.approvalKey || row.approvalKey || '';
  row.cooldownUntilMs = now + RESCUE_RETRY_COOLDOWN_MS;
  row = _saveRescuePattern(row) || row;

  // Record/surface the ACTUAL command (not the AI rescue-monitor's free-text
  // ruleLabel) so the decisions log + any banner read as the operation.
  const cmdTitle = escalationCommandParts(context).title;
  const decision = {
    sessionId,
    toolName: context.toolName,
    commandSummary: cmdTitle || context.toolName,
    fullContext: String(context.fullContext || '').slice(0, 2000),
    warning: context.warning || '',
    decision: 'approved',
    reasoning: review.reasoning || 'Approval rescue approved one missed active prompt.',
    decidedBy: review.decidedBy || 'ai-rescue',
    riskLevel: 'low',
  };
  try { dbModule.addApprovalDecision?.(decision); } catch (e) { console.error('[approval-rescue] decision DB error:', e.message); }

  const sent = await _scheduleRescueAttempt(sessionId, session, context, headlessWorker, broadcastFn, {
    type: 'approval-decision',
    sessionId,
    decision: 'approved',
    decidedBy: review.decidedBy || 'ai-rescue',
    label: cmdTitle || context.toolName || 'Approval',
    reasoning: decision.reasoning,
    riskLevel: 'low',
    approvalRescue: true,
  }, {
    keystroke: review.approvalKey || row.approvalKey || '',
    scheduleTimeoutMs: options.scheduleTimeoutMs,
  });

  if (!sent.sent) {
    row.lastOutcome = sent.status || 'skipped';
    row.consecutiveFailures = Math.max(row.consecutiveFailures, 0);
    _saveRescuePattern(row);
    return { handled: false, reason: row.lastOutcome, fingerprint, decidedBy: review.decidedBy, outcome: row.lastOutcome };
  }

  const verifyDelayMs = Number(options.verifyDelayMs || RESCUE_DEFAULT_VERIFY_DELAY_MS);
  await new Promise(resolve => setTimeout(resolve, verifyDelayMs));
  const outputBytesNow = session._outputBytesCounter || 0;
  const outputAdvanced = outputBytesNow - outputBytesAtAttempt;
  let promptVisibility = null;
  try { promptVisibility = await _currentPromptVisibility(sessionId, context, headlessWorker); } catch {}
  const success = promptVisibility === false || outputAdvanced >= VERIFY_TRANSITION_BYTES;

  if (success) {
    row.successes += 1;
    row.consecutiveFailures = 0;
    row.lastOutcome = promptVisibility === false ? 'prompt-cleared' : 'output-advanced';
    if (_shouldPromoteApprovalRescuePattern(review, context, {
      providerId: effectiveProviderId,
      source: meta.source || 'gate-miss',
      gateReason: meta.gateReason || meta.reason || '',
      rawDetected: !!meta.rawDetected,
      hintDetected: !!meta.hintDetected,
    })) {
      row.status = 'promoted';
    } else if (row.status !== 'promoted') {
      row.status = 'candidate';
    }
    _saveRescuePattern(row);
    return {
      handled: true,
      reason: 'approved',
      fingerprint,
      decidedBy: review.decidedBy,
      diagnosis,
      outcome: row.lastOutcome,
      promoted: row.status === 'promoted',
    };
  }

  row.failures += 1;
  row.consecutiveFailures += 1;
  row.lastOutcome = 'verify-failed';
  row.cooldownUntilMs = now + (row.consecutiveFailures >= RESCUE_MAX_CONSECUTIVE_FAILURES
    ? RESCUE_FAILURE_COOLDOWN_MS
    : RESCUE_RETRY_COOLDOWN_MS);
  if (row.consecutiveFailures >= RESCUE_MAX_CONSECUTIVE_FAILURES) row.status = 'blocked';
  row = _saveRescuePattern(row) || row;
  _broadcastRescueWarning(sessionId, session, broadcastFn, context, {
    ...review,
    reasoning: `CTM tried to auto-approve a missed prompt, but the terminal did not advance (${outputAdvanced} bytes).`,
  }, row);
  return {
    handled: true,
    reason: 'verify-failed',
    fingerprint,
    decidedBy: review.decidedBy,
    diagnosis,
    outcome: 'verify-failed',
  };
}

// Main entry point: check terminal buffer for approval prompts and handle them.
// providerId is optional — when present, delegates parsing to the matching provider.
// headlessWorker is optional — when present, enables Phase 3 post-keystroke verification.
// Shared, side-effect-free auto-approval decision. This is the single source of
// truth for "should this command auto-approve?", reused by BOTH the Claude/Codex
// PTY path (handleApprovalCheck, below) and the Wall-E coding bridge
// (/api/permissions/walle-check). Every provider runs the SAME cascade:
//   1. dangerous-command blocklist (editable; hard floor)
//   2. Permission Manager rules (explicit user allow/deny)
//   3. learned approval rules / per-clause heuristic risk
//   4. goal-aligned LLM verifier (medium+ risk; user-allowed commands skip it)
// Returns { decision: 'allow'|'ask', decidedBy, riskLevel, reason, label, ... }
// with NO broadcasts, keystrokes, or DB writes — callers own their side effects.
// 'ask' means "escalate to a human" (the PTY path surfaces a card; Wall-E coding
// raises a permission request). It never hard-denies, matching the PTY model
// where the blocklist/verifier escalate rather than silently refuse.
async function decideApproval(context, session, options = {}) {
  const callModel = options.callModel || null;
  const command = context.command || '';

  // 1) Dangerous-command blocklist — runs first, never overridden by other signals.
  if (isBlocklistEnabled()) {
    const block = checkBlocklist(command, getBlocklistConfig());
    if (block.blocked) {
      return {
        decision: 'ask', decidedBy: 'blocklist', riskLevel: 'high',
        reason: `Dangerous-command blocklist matched (${block.category}): ${block.reason}`,
        label: `⚠️ Blocklist: ${block.reason}`,
        blocklistCategory: block.category, blocklistPatternId: block.patternId,
      };
    }
  }

  // 2) Permission Manager rules (the user's explicit allow/deny).
  let permRules = [];
  try { permRules = typeof dbModule.listPermRules === 'function' ? dbModule.listPermRules({}) : []; } catch { permRules = []; }
  const permMatch = matchPermission({ toolName: context.toolName, command }, permRules);
  if (permMatch && permMatch.action === 'deny') {
    return {
      decision: 'ask', decidedBy: 'user-deny', riskLevel: 'high',
      reason: `Permission Manager deny rule matched: ${permMatch.rule}`, label: permMatch.rule,
    };
  }
  const userAllowed = !!(permMatch && permMatch.action === 'allow');

  // 3) Learned rules / per-clause heuristic risk classification.
  const matchingRule = findMatchingRule(context);
  const heuristic = matchingRule ? null : reviewWithHeuristics(context);
  const riskLevel = matchingRule ? (matchingRule.risk_level || 'low') : (heuristic ? (heuristic.riskLevel || 'low') : 'low');
  const decidedBy = userAllowed ? 'user-allow' : (matchingRule ? 'rule' : 'auto');
  const label = userAllowed ? `Allowed: ${permMatch.rule}`
    : matchingRule ? matchingRule.label : ((heuristic && heuristic.ruleLabel) || context.toolName);
  const reason = userAllowed ? `Permission Manager allow rule matched: ${permMatch.rule}`
    : matchingRule ? `Matched learned rule: ${matchingRule.label}`
    : 'Auto-approved by default (not on the denylist)';

  // 4) Goal-aligned verifier — medium+ risk only; user-allowed commands skip it
  //    (the user has explicitly vouched). Only a confident "unsafe" verdict
  //    escalates; a disabled/safe/unknown verdict falls through to allow.
  if (!userAllowed && (riskLevel === 'medium' || riskLevel === 'high')) {
    if (context && !context.sessionContext) context.sessionContext = _buildSessionContext(session);
    let verifier = { enabled: false, verdict: 'unknown' };
    try { verifier = await verifyIfEnabled({ context, dbModule, callModel }); } catch { verifier = { enabled: false, verdict: 'unknown' }; }
    if (verifier.enabled && verifier.verdict === 'unsafe') {
      const blocked = classifyBlockReason(context);
      return {
        decision: 'ask', decidedBy: 'verifier', riskLevel: 'high',
        reason: blocked.reason || verifier.reason || 'Auto-approval verifier flagged this command as high risk.',
        blockCategory: blocked.category || '', verifierVerdict: verifier.verdict, label,
      };
    }
  }

  return {
    decision: 'allow', decidedBy, riskLevel, reason, label,
    ruleId: matchingRule ? matchingRule.id : null,
  };
}

async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, providerId, headlessWorker, options = {}) {
  const callModel = options.callModel || null;
  const context = parseApprovalContext(cleanText, providerId);
  if (!context) return false;

  // Dedup: skip if this exact same approval was already handled recently.
  // This prevents duplicate toasts when the VTerm worker detects the same prompt
  // multiple times before the screen reset propagates.
  const fingerprint = `${context.toolName}:${context.command?.slice(0, 200)}`;
  const now = Date.now();
  const last = _lastApproval.get(sessionId);
  if (last && last.fingerprint === fingerprint && (now - last.ts) < DEDUP_WINDOW_MS) {
    return true; // Already handled — return true so caller applies cooldown
  }
  _lastApproval.set(sessionId, { fingerprint, ts: now });

  // Normalized signature for this command — recorded on every decision so the
  // self-adapt loop can reliably promote an "approved-after-escalation" into a
  // learned rule keyed on this exact signature.
  const commandSignature = normalizeCommandSignature(context.toolName, context.command);

  // Dangerous-command blocklist (defense-in-depth refusal gate).
  // Runs BEFORE learned rules / heuristics / AI — a blocklisted command is
  // never auto-approved regardless of what other signals say. Opt-in.
  if (isBlocklistEnabled()) {
    const blockCheck = checkBlocklist(context.command || '', getBlocklistConfig());
    if (blockCheck.blocked) {
      console.log(`[approval-agent] BLOCKLIST hit session=${sessionId} category=${blockCheck.category} reason="${blockCheck.reason}" cmd="${(context.command || '').slice(0, 200)}"`);
      const decision = {
        sessionId,
        toolName: context.toolName,
        commandSummary: `Blocklist: ${blockCheck.reason}`,
        fullContext: context.fullContext.slice(0, 2000),
        warning: context.warning,
        decision: 'escalated',
        reasoning: `Dangerous-command blocklist matched (${blockCheck.category}): ${blockCheck.reason}`,
        decidedBy: 'blocklist',
        riskLevel: 'high',
        commandSignature,
      };
      let decisionId;
      try { decisionId = dbModule.addApprovalDecision(decision); } catch (e) { console.error('[approval-agent] DB error:', e.message); }
      broadcastFn(sessionId, session, {
        type: 'approval-decision',
        sessionId,
        decision: 'escalated',
        decidedBy: 'blocklist',
        decisionId,
        label: `⚠️ Blocklist: ${blockCheck.reason}`,
        reasoning: decision.reasoning,
        riskLevel: 'high',
        blocklistCategory: blockCheck.category,
        blocklistPatternId: blockCheck.patternId,
        command: (context.command || '').slice(0, 500),
        warning: context.warning,
      });
      return true;
    }
  }

  // ── Permission Manager rules (the user's explicit allow/deny) ─────────────
  // perm_rules are the user's "permissions tab" decisions (e.g. Bash(node:*)).
  // They normally only configure Claude Code's own settings.json; honoring them
  // here makes them authoritative across every provider (Codex included).
  // deny → escalate; allow (without always_ask) → auto-approve and skip the
  // verifier (the user has explicitly vouched for it).
  let permRules = [];
  try { permRules = typeof dbModule.listPermRules === 'function' ? dbModule.listPermRules({}) : []; } catch { permRules = []; }
  const permMatch = matchPermission({ toolName: context.toolName, command: context.command }, permRules);
  if (permMatch && permMatch.action === 'deny') {
    const reasoning = `Permission Manager deny rule matched: ${permMatch.rule}`;
    try {
      dbModule.addApprovalDecision({
        sessionId, toolName: context.toolName, commandSummary: `Denied: ${permMatch.rule}`,
        fullContext: context.fullContext.slice(0, 2000), warning: context.warning,
        decision: 'escalated', reasoning, decidedBy: 'user-deny', riskLevel: 'high', commandSignature,
      });
    } catch (e) { console.error('[approval-agent] DB error:', e.message); }
    broadcastFn(sessionId, session, {
      type: 'approval-decision', sessionId, decision: 'escalated', decidedBy: 'user-deny',
      label: permMatch.rule, reasoning, riskLevel: 'high',
      command: (context.command || '').slice(0, 500), warning: context.warning,
    });
    return true;
  }
  const userAllowed = !!(permMatch && permMatch.action === 'allow');

  // ── Allow-by-default ──────────────────────────────────────────────────────
  // Auto-approve everything not on the denylist. The blocklist above is the
  // denylist. For commands the user has NOT explicitly allowed, an LLM verifier
  // (on by default; ctm_settings.auto_approval_verifier_enabled) gives a second
  // opinion on medium+ risk and can escalate. User-allowed commands skip it.
  const matchingRule = findMatchingRule(context);
  const heuristic = matchingRule ? null : reviewWithHeuristics(context);
  const label = userAllowed ? `Allowed: ${permMatch.rule}`
    : matchingRule ? matchingRule.label : (heuristic.ruleLabel || context.toolName);
  const decidedBy = userAllowed ? 'user-allow' : (matchingRule ? 'rule' : 'auto');
  const riskLevel = matchingRule ? (matchingRule.risk_level || 'low') : (heuristic ? (heuristic.riskLevel || 'low') : 'low');
  const reasoning = userAllowed
    ? `Permission Manager allow rule matched: ${permMatch.rule}`
    : matchingRule ? `Matched learned rule: ${matchingRule.label}`
    : 'Auto-approved by default (not on the denylist)';

  if (!userAllowed) {
    // Verifier scope: medium+ risk only — read-only/low-risk ops auto-approve fast.
    const verifierBlock = await _verifyAutoApprovalOrBlock(sessionId, session, context, broadcastFn, label, decidedBy, riskLevel, callModel);
    if (verifierBlock) return true;
  }

  try {
    dbModule.addApprovalDecision({
      sessionId,
      toolName: context.toolName,
      commandSummary: label,
      fullContext: context.fullContext.slice(0, 2000),
      warning: context.warning,
      decision: 'approved',
      reasoning,
      decidedBy,
      ruleId: matchingRule ? matchingRule.id : null,
      riskLevel,
      commandSignature,
    });
    if (matchingRule) dbModule.incrementApprovalRuleMatch(matchingRule.id);
  } catch (e) { console.error('[approval-agent] DB error:', e.message); }

  // Send the one-time approval keystroke, guarded against stale prompts.
  _scheduleGuardedApproval(session, context, headlessWorker, broadcastFn, sessionId, {
    type: 'approval-decision',
    sessionId,
    decision: 'approved',
    decidedBy,
    label,
    reasoning,
    riskLevel,
  }, { keystrokeOptions: { preferAllowAll: false } });

  return true;
}

module.exports = {
  parseApprovalContext,
  isLiveApprovalPrompt,
  hasComposerStatusFooter,
  reviewWithHeuristics,
  _splitShellClauses,
  _isProcessControlClause,
  _buildSessionContext,
  normalizeCommandSignature,
  escalationCommandParts,
  classifyBlockReason,
  _rescueCandidateActionable,
  findMatchingRule,
  getApproveKeystroke,
  sendApprovalKeystroke,
  reviewWithAI,
  reviewApprovalRescueCandidate,
  approvalRescueFingerprint,
  handleApprovalRescueCandidate,
  handleApprovalCheck,
  decideApproval,
  clearSessionDedup(sessionId) { _lastApproval.delete(sessionId); },
};
