#!/bin/bash
# PreToolUse (Read|Grep|Bash) + UserPromptSubmit hook: sensitive-data gate.
#
# Blocks sensitive data — API keys, private keys, credentials, secret files —
# from being sent to the AI. Strict by design ("cảnh cực gắt"): mere suspicion
# of private data blocks the call so the USER decides. Detection covers three
# egress channels into model context:
#   1. Read/Grep of secret files (.env*, *.pem, id_rsa, credentials.json, ...)
#   2. Bash commands that dump secrets (cat .env, bare `env`, curl -u user:pass,
#      URL-embedded credentials, any high-confidence token in the command text)
#   3. UserPromptSubmit prompt text containing high-confidence secret patterns
#
# FAIL-CLOSED on detection (exit 2). Never echoes the detected value — only a
# redacted preview plus a truncated sha256 (12 hex chars: enough for the human
# to verify, not enough for the model to forge an allowlist entry).
#
# User escape hatches (explicit human action only):
#   - security.sensitiveDataGate=false in .ukit/storage/config.json disables the gate
#   - .ukit/storage/security/allowlist.json { values: [sha256...], paths: [...] }
#     approves exact secrets / file paths (protect-files.sh blocks model edits there)
#
# Fails open only when there is nothing to scan (malformed stdin, no channel):
# a broken gate must not brick every session, but every real detection blocks.

INPUT=$(cat)
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"

INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
const fs = require('fs');
const path = require('path');
const { createHash } = require('crypto');

const payload = (() => {
  try {
    const parsed = JSON.parse(process.env.INPUT || '');
    return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
  } catch {
    return {};
  }
})();

const projectRoot = process.env.PROJECT_ROOT || process.cwd();

function readJsonSafe(filePath, fallback) {
  try {
    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  } catch {
    return fallback;
  }
}

const config = readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'config.json'), null);
if (config && config.security && config.security.sensitiveDataGate === false) {
  process.exit(0);
}

const allowlist = readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'security', 'allowlist.json'), null) || {};
const allowedValueHashes = new Set(
  Array.isArray(allowlist.values) ? allowlist.values.filter((v) => typeof v === 'string') : [],
);
const allowedPaths = Array.isArray(allowlist.paths) ? allowlist.paths.filter((p) => typeof p === 'string') : [];

function sha256(value) {
  return createHash('sha256').update(value).digest('hex');
}

function pathAllowed(filePath) {
  const normalized = String(filePath).replace(/\\/g, '/').trim();
  let relative = normalized;
  try {
    relative = path.relative(projectRoot, normalized);
  } catch {
    // keep normalized
  }
  return allowedPaths.some((allowed) => allowed === normalized || allowed === relative);
}

// --- high-confidence secret value patterns (shared by prompt + bash scanning) ---
const TOKEN_PATTERNS = [
  { label: 'OpenAI/Anthropic-style API key', re: /\bsk-(?:proj-|ant-|svc-|acct-|admin-)?[A-Za-z0-9_-]{20,}/g },
  { label: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}\b/g },
  { label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
  { label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
  { label: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
  { label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
  { label: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{20,}/g },
  { label: 'Stripe live key', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
  { label: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
  { label: 'private key block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
  { label: 'bearer token', re: /\bBearer\s+[A-Za-z0-9._+/=-]{20,}/gi },
];

const ASSIGNMENT_RE = /(^|[^A-Za-z0-9_])(api[_-]?key|apikey|secret[_-]?key|secret|client[_-]?secret|password|passwd|auth[_-]?token|access[_-]?token|token)\s*[:=]\s*["']?([A-Za-z0-9+/_.~-]{16,})/gi;

function scanSecretText(text) {
  const found = [];
  for (const { label, re } of TOKEN_PATTERNS) {
    re.lastIndex = 0;
    let match;
    while ((match = re.exec(text)) !== null) {
      found.push({ label, value: match[0] });
    }
  }
  ASSIGNMENT_RE.lastIndex = 0;
  let match;
  while ((match = ASSIGNMENT_RE.exec(text)) !== null) {
    found.push({ label: `${match[2].toLowerCase()} assignment`, value: match[3] });
  }
  return found;
}

// --- secret file classification ---
function classifySecretFile(filePath) {
  if (typeof filePath !== 'string' || !filePath.trim()) return null;
  const normalized = filePath.replace(/\\/g, '/').trim();
  const rawBase = normalized.split('/').pop();
  if (!rawBase) return null;
  const base = rawBase.toLowerCase();
  if (base.endsWith('.env.example')) return null;
  if (base === '.env' || base.startsWith('.env.')) return 'dotenv/secret env file';
  if (base.endsWith('.pub') || base.endsWith('.public')) return null;
  if (/^id_(rsa|dsa|ecdsa|ed25519)(\.|$)/.test(base)) return 'SSH private key';
  if (/\.(pem|key|p12|pfx|keystore|jks)$/.test(base)) return 'key/certificate file';
  if (/^credentials\.(json|ya?ml|txt|ini)$/.test(base)) return 'credentials file';
  if (/^service[_-]?account.*\.json$/.test(base)) return 'service-account key file';
  if (base === '.npmrc' || base === '.netrc' || base === '.htpasswd') return 'credentials file';
  if (/^secrets?\.(json|ya?ml|txt|ini|env)$/.test(base)) return 'secrets file';
  if (/(^|\/)\.aws\/credentials$/i.test(normalized)) return 'AWS credentials file';
  return null;
}

// --- bash command shapes that dump secrets ---
const DUMP_VERBS = new Set([
  'cat', 'head', 'tail', 'less', 'more', 'zless', 'zcat', 'xxd', 'strings',
  'base64', 'od', 'hexdump', 'bat', 'grep', 'egrep', 'fgrep', 'rg', 'awk', 'sed',
]);

function scanBashShapes(command) {
  const found = [];
  const segments = command.split(/\|\||&&|;|\||\n/);
  for (const rawSegment of segments) {
    const segment = rawSegment.trim();
    if (!segment) continue;
    const tokens = segment.split(/\s+/);
    const head = tokens[0].replace(/^["']+|["']+$/g, '');

    if ((head === 'env' || head === 'printenv' || head === 'history') && tokens.length === 1) {
      found.push({ label: `bare \`${head}\` dumps environment/history (may contain tokens)`, value: segment });
      continue;
    }
    if (head === 'export' && (tokens[1] === '-p' || tokens[1] === '--print')) {
      found.push({ label: 'bare `export -p` dumps all environment variables', value: segment });
      continue;
    }

    if (DUMP_VERBS.has(head)) {
      for (const token of tokens.slice(1)) {
        const cleaned = token.replace(/^["']+|["',:]+$/g, '');
        const kind = classifySecretFile(cleaned);
        if (kind && !pathAllowed(cleaned)) {
          found.push({ label: `${head} reads ${kind}`, value: cleaned, isPath: true });
        }
      }
    }

    if (/(^|\s)(-u|--user)\s+["']?[^\s"':]+:[^\s"']+/.test(segment)) {
      found.push({ label: 'curl/wget inline credentials (-u user:password)', value: segment });
    }
    if (/https?:\/\/[^\s/@'"]+:[^\s/@'"]+@/.test(segment)) {
      found.push({ label: 'URL with embedded credentials (user:pass@host)', value: segment });
    }
  }
  return found;
}

// --- channel wiring ---
const event = String(payload.hook_event_name || '');
const toolName = String(payload.tool_name || '');
const toolInput = payload.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};

const textChannels = [];
const filePathChannels = [];
let bashCommand = null;

if (event === 'UserPromptSubmit') {
  const prompt = [payload.prompt, payload.user_prompt, payload.text].find(
    (candidate) => typeof candidate === 'string' && candidate.trim(),
  );
  if (prompt) textChannels.push({ channel: 'prompt', text: prompt });
} else if (event === 'PreToolUse') {
  if (toolName === 'Read' && typeof toolInput.file_path === 'string') {
    filePathChannels.push(toolInput.file_path);
  } else if (toolName === 'Grep') {
    if (typeof toolInput.path === 'string' && toolInput.path.trim()) filePathChannels.push(toolInput.path);
    if (typeof toolInput.pattern === 'string') textChannels.push({ channel: 'grep pattern', text: toolInput.pattern });
  } else if (toolName === 'Bash' && typeof toolInput.command === 'string') {
    bashCommand = toolInput.command;
  }
} else {
  process.exit(0);
}

// --- collect findings ---
const findings = [];

for (const filePath of filePathChannels) {
  const kind = classifySecretFile(filePath);
  if (kind && !pathAllowed(filePath)) {
    findings.push({ label: `Read of ${kind}`, preview: filePath, hashSource: filePath });
  }
}

const seenValues = new Set();
for (const { channel, text } of textChannels) {
  for (const finding of scanSecretText(text)) {
    if (allowedValueHashes.has(sha256(finding.value))) continue;
    const key = `${channel}:${finding.label}:${finding.value}`;
    if (seenValues.has(key)) continue;
    seenValues.add(key);
    findings.push({ label: `${finding.label} in ${channel}`, value: finding.value });
  }
}

if (bashCommand) {
  for (const finding of scanBashShapes(bashCommand)) {
    if (finding.isPath) {
      findings.push({ label: `bash command touches ${finding.label}`, preview: finding.value });
      continue;
    }
    if (allowedValueHashes.has(sha256(finding.value))) continue;
    findings.push({ label: `${finding.label} in bash command`, value: finding.value });
  }
  for (const finding of scanSecretText(bashCommand)) {
    if (allowedValueHashes.has(sha256(finding.value))) continue;
    const key = `bash:${finding.label}:${finding.value}`;
    if (seenValues.has(key)) continue;
    seenValues.add(key);
    findings.push({ label: `${finding.label} in bash command`, value: finding.value });
  }
}

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

// --- block message: redacted previews only, never the secret itself ---
function describeFinding(finding) {
  const where = finding.preview
    ? `${finding.preview}`
    : `${String(finding.value).slice(0, 3)}…(${String(finding.value).length} chars, sha256 ${sha256(String(finding.value)).slice(0, 12)}…)`;
  return `  - ${finding.label}: ${where}`;
}

const lines = [
  `BLOCKED (sensitive data): ${findings.length} potential secret(s) detected. Nothing was sent to the AI.`,
  ...findings.map(describeFinding),
  'This gate is intentionally strict — it blocks on suspicion so the USER decides.',
  'To proceed, the USER must choose one of:',
  '  1. Redact the secret (placeholder like <API_KEY>) and retry.',
  '  2. Approve it explicitly: add the full sha256 (shasum -a 256 of the value) or the file path',
  '     to .ukit/storage/security/allowlist.json — that file is protected from AI edits.',
  '  3. Disable the gate: security.sensitiveDataGate=false in .ukit/storage/config.json (not recommended).',
  'Never repeat or guess the detected value in your reply. Ask the user instead.',
];
process.stderr.write(`${lines.join('\n')}\n`);
process.exit(2);
NODE

exit $?
