#!/usr/bin/env node
// notif-authoring-gate.mjs — PreToolUse hook. Stops a notification being ADDED to this
// machine, rather than catching it after it has already buzzed the user.
//
// CONTRACT
//   stdin: Claude Code PreToolUse JSON (tool_name + tool_input).
//   deny:  {hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason}}
//   allow: print nothing, exit 0.
//   FAIL-CLOSED: an unreadable or unparseable payload denies.
//
// Denies Edit/Write content and Bash commands that emit a desktop notification —
// notify-send, org.freedesktop.Notifications.Notify, libnotify, PushNotification, or a
// unit whose job is to notify — unless a quoted summary in the same text already has an
// id in the gate's registry. Exempt: the gate's own tree and prose (.md).
import { readFileSync } from "node:fs";
import { homedir } from "node:os";

const STATE_DIRS = [...new Set([
  process.env.XDG_STATE_HOME ? `${process.env.XDG_STATE_HOME}/notif-gate` : null,
  `${homedir()}/.local/state/notif-gate`,
].filter(Boolean))];
const REGISTRIES = STATE_DIRS.flatMap((d) => [`${d}/approved.json`, `${d}/pending.json`]);
const SELF_PATH_RE = /(?:modules\/security\/notif-gate\/|notif-authoring-gate|notif-registry-gate|\/notif-approve$)/;

const EMIT_RE =
  /\bnotify-send\b|org\.freedesktop\.Notifications|Notifications\.Notify\b|\bNotify\.Notification\b|require_version\(\s*["']Notify["']|\blibnotify\b|\bPushNotification\b|\bnotify_send\b/;

const DIAGNOSTIC_RE =
  /^(?:FT_FULL=1\s+)?(?:cat|head|tail|less|wc|grep|rg|jq|ls|stat|file|readlink|realpath|basename|dirname|find|cmp|diff|which|type|command\s+-v|dpkg-divert\s+--list|dpkg\s+-S|md5sum|sha256sum|sort|uniq|awk|printf|echo|journalctl|systemctl(?:\s+--user)?\s+(?:show|status|cat|list-[a-z-]+|is-[a-z-]+))\b/;
const WRITE_VERB_RE = /\b(?:tee|sed|cp|mv|rm|install|dd|truncate|chmod|chown|ln|touch)\b|>/;
// Discarding stderr is not a write; every other redirection is.
const DISCARD_RE = /\s\d*>\s*\/dev\/null/g;

function deny(reason) {
  process.stdout.write(JSON.stringify({
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: reason,
    },
  }));
  process.exit(0);
}

function normalize(s) { return s.replace(/\d+/g, "#").trim(); }
function slug(s) {
  return (s.toLowerCase().replace(/[^a-z0-9#]+/g, "-").replace(/^-+|-+$/g, "")).slice(0, 60);
}

function registeredSlugs() {
  const out = new Set();
  for (const p of REGISTRIES) {
    let j;
    try { j = JSON.parse(readFileSync(p, "utf8")); } catch { continue; }
    for (const id of Object.keys(j?.entries ?? {})) {
      const i = id.indexOf(":");
      if (i > 0) out.add(id.slice(i + 1));
    }
  }
  return out;
}

let payload;
try {
  payload = JSON.parse(readFileSync(0, "utf8"));
} catch {
  deny("notif-authoring-gate: could not read the hook payload; denying to protect the notification gate.");
}

const tool = payload?.tool_name ?? "";
const input = payload?.tool_input ?? {};
const filePath = [input.file_path, input.path, input.notebook_path]
  .find((v) => typeof v === "string") || "";

if (SELF_PATH_RE.test(filePath)) process.exit(0);
if (/\.(?:md|markdown|txt)$/i.test(filePath)) process.exit(0);

const command = typeof input.command === "string" ? input.command : "";
// The gate's own CLI names ids that contain "notify-send"; it can emit nothing itself.
if (/^\s*notif-approve\b[^;&|<>`$(]*$/.test(command)) process.exit(0);
// Inspecting the gate's own wiring must stay possible: every segment a read-only
// verb, no write verb, so no segment can be an emitter.
const diagnosable = command.replace(DISCARD_RE, "");
if (command && !WRITE_VERB_RE.test(diagnosable)) {
  const segments = diagnosable.split(/(?:;|&&|\|\||\||\n)/).map((s) => s.trim()).filter(Boolean);
  if (segments.length && segments.every((s) => DIAGNOSTIC_RE.test(s))) process.exit(0);
}

const text = [input.new_string, input.content, command]
  .filter((v) => typeof v === "string").join("\n");
if (!text) process.exit(0);

if (!EMIT_RE.test(text)) process.exit(0);

const known = registeredSlugs();
const literals = [...text.matchAll(/["'`]([^"'`\n]{3,120})["'`]/g)].map((m) => m[1]);
if (literals.some((l) => known.has(slug(normalize(l))))) process.exit(0);

deny(
  `notif-authoring-gate: this ${tool} adds a desktop-notification emitter, and its summary is not in the ` +
  "approval registry. Notifications on this machine are DEFAULT-DENY: nothing new may notify the user " +
  "until he approves it himself.\n" +
  "Next step:\n" +
  "  1. notif-approve register <absolute-program-path> \"<exact summary>\"   (records it as PENDING; emits nothing)\n" +
  "  2. tell the user it is pending and what it would say\n" +
  "  3. he runs: notif-approve pending  →  notif-approve approve <id>\n" +
  "Only then will this edit be allowed, and only then can the notification reach his screen.",
);
