#!/usr/bin/env node
// Hook: pre-compact-scan.mjs
// Event: PreCompact
// Purpose: Scan conversation transcript for injection patterns and credentials
//          BEFORE the harness compacts the context. Prevents poisoned content
//          from surviving compaction in a more compact form where the model
//          can no longer see the surrounding injection context.
//
// Protocol:
//   - Read JSON from stdin: { session_id, transcript_path, cwd, hook_event_name,
//     trigger, compaction_trigger }
//   - Both `trigger` (official docs) and `compaction_trigger` (research-brief)
//     are read defensively.
//   - Read transcript file (JSONL), read at most the last 500 KB for <500ms
//     latency on large transcripts.
//   - Run injection-patterns + a small set of credential regexes against
//     the transcript content.
//   - Mode from env var LLM_SECURITY_PRECOMPACT_MODE:
//       * "off"   → exit 0, no scan
//       * "warn"  → exit 0, write systemMessage JSON to stdout if findings
//       * "block" → exit 2 if findings, exit 0 otherwise
//     Default: warn.
//
// Exit codes:
//   0  = allow compaction to proceed
//   2  = block compaction (only in block mode with findings)
//
// Env: LLM_SECURITY_PRECOMPACT_MODE=block|warn|off
//      LLM_SECURITY_PRECOMPACT_MAX_BYTES (default 512000)

import { readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
import { scanForInjection } from '../../scanners/lib/injection-patterns.mjs';

const VALID_MODES = new Set(['block', 'warn', 'off']);
const modeEnv = process.env.LLM_SECURITY_PRECOMPACT_MODE;
const mode = VALID_MODES.has(modeEnv) ? modeEnv : 'warn';

if (mode === 'off') {
  process.exit(0);
}

const MAX_BYTES = (() => {
  const n = parseInt(process.env.LLM_SECURITY_PRECOMPACT_MAX_BYTES || '', 10);
  return Number.isFinite(n) && n > 0 ? n : 512_000;
})();

const SECRET_PATTERNS = [
  { name: 'AWS Access Key ID', pattern: /AKIA[0-9A-Z]{16}/ },
  { name: 'GitHub Token', pattern: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/ },
  { name: 'npm Token', pattern: /npm_[A-Za-z0-9]{36}/ },
  { name: 'Private Key PEM Block', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
  { name: 'Generic credential assignment', pattern: /(?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*['"][^'"]{8,}['"]/i },
  { name: 'Authorization bearer token', pattern: /[Bb]earer [A-Za-z0-9\-._~+/]{20,}/ },
];

function readStdinSync() {
  try {
    return readFileSync(0, 'utf8');
  } catch {
    return '';
  }
}

function readTailCapped(filePath, maxBytes) {
  const st = statSync(filePath);
  if (st.size <= maxBytes) {
    return readFileSync(filePath, 'utf8');
  }
  const fd = openSync(filePath, 'r');
  try {
    const buf = Buffer.alloc(maxBytes);
    readSync(fd, buf, 0, maxBytes, st.size - maxBytes);
    return buf.toString('utf8');
  } finally {
    closeSync(fd);
  }
}

function extractTextFromTranscript(raw) {
  const chunks = [];
  for (const line of raw.split(/\r?\n/)) {
    if (!line) continue;
    try {
      const obj = JSON.parse(line);
      const content = obj?.message?.content ?? obj?.content;
      if (typeof content === 'string') {
        chunks.push(content);
      } else if (Array.isArray(content)) {
        for (const block of content) {
          if (typeof block?.text === 'string') chunks.push(block.text);
          else if (typeof block === 'string') chunks.push(block);
        }
      }
    } catch {
      chunks.push(line);
    }
  }
  return chunks.join('\n');
}

function scanForSecrets(text) {
  const findings = [];
  for (const { name, pattern } of SECRET_PATTERNS) {
    if (pattern.test(text)) {
      findings.push({ type: 'secret', label: name });
    }
  }
  return findings;
}

function emit(obj) {
  try {
    process.stdout.write(JSON.stringify(obj));
  } catch {
    // swallow — hook must never crash harness
  }
}

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
let input;
try {
  input = JSON.parse(readStdinSync() || '{}');
} catch {
  process.exit(0);
}

const transcriptPath = input?.transcript_path;
const trigger = input?.trigger ?? input?.compaction_trigger ?? 'unknown';

if (!transcriptPath) {
  process.exit(0);
}

let transcriptText = '';
try {
  const raw = readTailCapped(transcriptPath, MAX_BYTES);
  transcriptText = extractTextFromTranscript(raw);
} catch {
  process.exit(0);
}

const injectionResult = scanForInjection(transcriptText) || { patterns: [] };
const injectionFindings = Array.isArray(injectionResult.patterns)
  ? injectionResult.patterns.map((p) => ({ type: 'injection', label: p.label, severity: p.severity }))
  : [];
const secretFindings = scanForSecrets(transcriptText);
const allFindings = [...injectionFindings, ...secretFindings];

if (allFindings.length === 0) {
  process.exit(0);
}

const labels = allFindings.slice(0, 8).map((f) => f.label || f.type || 'finding').join(', ');
const summary =
  `pre-compact-scan (${trigger}): ${allFindings.length} finding(s) in transcript. ` +
  `Compaction may preserve poisoned content in condensed form. Top: ${labels}.`;

if (mode === 'block') {
  emit({ decision: 'block', reason: summary });
  process.exit(2);
}

// warn mode
emit({ systemMessage: summary });
process.exit(0);
