#!/usr/bin/env node
// deny-gate.mjs — PreToolUse Bash hook. Forbids NAIVE-WRONG raw commands that have a tested wrapper,
// and points the agent to the wrapper. Manifest-driven (tools.json, global + per-project).
//
// DOCTRINE (CLAUDE.md Bash): wrap a command when its naive form is wrong/unsafe; enforce by denying the
// always-wrong signature token (robust — not a full-cluster shape, which reorder/`;`/split-calls defeat).
//
// CONTRACT
//   stdin: Claude Code PreToolUse JSON. We read .tool_input.command.
//   deny:  print {hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason}}
//   allow: print nothing, exit 0.
//   FAIL-OPEN: ANY error/parse problem → print nothing, exit 0. This hook must NEVER break a command.
//   ESCAPE:  a command containing the token `# raw-ok` is always allowed (deliberate one-off raw use).
//
// MATCHING per rule (all conditions that are present must hold):
//   all_of[]         — every regex must match the command            (AND)
//   any_of[]         — at least one regex must match                 (OR)
//   unless_contains[]— if any of these substrings is present, SKIP the rule (it's the wrapper itself)
//   script_content_all_of[] / script_content_any_of[]
//                    — regexes matched against the CONTENT of script files named in the command.
//                      Holds when ONE file satisfies all_of AND (any_of ? one of any_of : true).
//                      Catches a workload by what it does, not by what it is called.
//   A rule with no positive condition (all_of / any_of / script_content_*) never fires (safety).
import { readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";

const MAX_SCRIPT_BYTES = 262144;
const RUNNER_RE = /(^|[;&|]\s*|\s)(node|bun|tsx|ts-node|deno)\s/;
const SCRIPT_TOKEN_RE = /(?:^|\s)['"]?([^\s'";|&]+\.(?:mjs|cjs|js|ts|mts|cts))['"]?/g;

// Directories a `cd`/`-C` in the command would make the script paths relative to,
// plus the tool's own cwd. Any of them resolving to a readable file is enough.
function candidateDirs(cmd, cwd) {
  const dirs = [cwd || process.cwd()];
  for (const m of cmd.matchAll(/\bcd\s+['"]?([^\s'";|&]+)/g)) dirs.push(m[1]);
  return dirs;
}
function readScripts(cmd, cwd) {
  if (!RUNNER_RE.test(cmd)) return [];
  const dirs = candidateDirs(cmd, cwd);
  const out = [];
  for (const m of cmd.matchAll(SCRIPT_TOKEN_RE)) {
    const tok = m[1];
    for (const dir of dirs) {
      const p = isAbsolute(tok) ? tok : resolve(dir, tok);
      try {
        if (statSync(p).size > MAX_SCRIPT_BYTES) break;
        out.push(readFileSync(p, "utf8"));
        break;
      } catch { /* not here — try the next candidate dir */ }
    }
  }
  return out;
}
function scriptCondition(rule, cmd, cwd) {
  const all = reList(rule.script_content_all_of), any = reList(rule.script_content_any_of);
  if (!all.length && !any.length) return null;             // rule does not use content matching
  for (const src of readScripts(cmd, cwd)) {
    if (all.length && !all.every((re) => re.test(src))) continue;
    if (any.length && !any.some((re) => re.test(src))) continue;
    return true;
  }
  return false;
}

function readStdin() {
  try { return readFileSync(0, "utf8"); } catch { return ""; }
}
function loadManifest(path) {
  try {
    const j = JSON.parse(readFileSync(path, "utf8"));
    return Array.isArray(j?.rules) ? j.rules : [];
  } catch { return []; }   // missing / malformed → no rules from this scope (fail-open)
}
function reList(arr) {
  // compile regex list; a bad pattern is dropped, not fatal
  const out = [];
  for (const p of arr || []) { try { out.push(new RegExp(p)); } catch { /* skip */ } }
  return out;
}
function ruleFires(cmd, rule, cwd) {
  for (const s of rule.unless_contains || []) if (cmd.includes(s)) return false;
  const all = reList(rule.all_of), any = reList(rule.any_of);
  const script = scriptCondition(rule, cmd, cwd);
  if (!all.length && !any.length && script === null) return false;  // no positive condition → never fire
  if (all.length && !all.every((re) => re.test(cmd))) return false;
  if (any.length && !any.some((re) => re.test(cmd))) return false;
  if (script === false) return false;
  return true;
}

function main() {
  const input = readStdin();
  if (!input) return 0;
  let cmd = "", cwd = "";
  try {
    const j = JSON.parse(input);
    cmd = j?.tool_input?.command ?? "";
    cwd = typeof j?.cwd === "string" ? j.cwd : "";
  } catch { return 0; }
  if (!cmd || typeof cmd !== "string") return 0;
  if (cmd.includes("# raw-ok")) return 0;                 // deliberate escape hatch

  // scopes: global, then per-project (cwd/.claude/tools.json). Project rules add to global.
  const manifests = [
    join(homedir(), ".claude", "tools.json"),
    join(process.cwd(), ".claude", "tools.json"),
  ];
  const rules = manifests.flatMap(loadManifest);

  for (const rule of rules) {
    let fires = false;
    try { fires = ruleFires(cmd, rule, cwd); } catch { fires = false; }
    if (!fires) continue;
    // rewrite_from + rewrite_to: rewrite the command instead of denying it
    if (rule.rewrite_from && rule.rewrite_to) {
      try {
        const rewritten = cmd.replace(new RegExp(rule.rewrite_from), rule.rewrite_to);
        if (rewritten !== cmd) {
          process.stdout.write(JSON.stringify({
            hookSpecificOutput: {
              hookEventName: "PreToolUse",
              updatedInput: { command: rewritten },
            },
          }));
          return 0;
        }
      } catch { /* fall through to deny */ }
    }
    const reason =
      `Blocked: this raw command has a tested wrapper — use it instead.\n` +
      `${rule.reason || ""}\n` +
      `→ ${rule.wrapper || "(see the skill / project tools.json)"}\n` +
      `(Deliberate one-off raw use? append "# raw-ok" to the command.)`;
    process.stdout.write(JSON.stringify({
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: reason,
      },
    }));
    return 0;
  }
  return 0;
}
try { process.exit(main() || 0); } catch { process.exit(0); }
