#!/usr/bin/env node
// secret-file-gate.mjs — PreToolUse hook. Denies any tool call that would read or
// modify the sudo password file, so its contents never reach a transcript.
//
// 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. A credential gate that
//   fails open is not a gate.
//   ESCAPE: only `deck-sudo`, the wrapper that reads the file inside a spawned process
//   and never prints it.
//
// Guarded paths: DECK_SECRET_FILES (colon-separated) or ~/stupid.user by default.
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename } from "node:path";

const WRAPPER = "deck-sudo";

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

const guarded = (process.env.DECK_SECRET_FILES || `${homedir()}/stupid.user`)
  .split(":").filter(Boolean);
// The stem is guarded too, so a glob (`stupid*`) or a partial path cannot slip
// past a match on the full name — that is exactly how the file was destroyed once.
const names = [...new Set(guarded.flatMap((p) => {
  const base = basename(p);
  return [p, base, base.replace(/\.[^.]*$/, "")];
}))];

let payload;
try {
  payload = JSON.parse(readFileSync(0, "utf8"));
} catch {
  deny(`secret-file-gate: could not read the hook payload; denying to protect ${names[0]}.`);
}

const input = payload?.tool_input ?? {};
const command = typeof input.command === "string" ? input.command : "";
// Only fields that can OPEN the file. Edit/Write string bodies and grep patterns
// are excluded: naming the path in a doc is harmless, and file_path/path already
// catch an edit aimed at the file itself.
const haystack = [
  command, input.file_path, input.path, input.notebook_path, input.code,
].filter((v) => typeof v === "string").join("\n");

// A glob is a reference too: `rm /home/user/stu*` names the file without spelling it.
// Every wildcard token is expanded against the guarded paths, not just matched literally.
const globToRe = (t) =>
  new RegExp(`^${t.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
for (const token of haystack.split(/[\s'";|&()]+/)) {
  if (!/[*?]/.test(token)) continue;
  let re;
  try { re = globToRe(token); } catch { continue; }
  if (guarded.some((p) => re.test(p) || re.test(basename(p)))) {
    deny(`secret-file-gate: '${token}' matches the guarded password file; refused.`);
  }
}

// No wrapper allowlist: deck-sudo reads the file inside its own process and never
// needs it on the command line, so any call that names the file is refused whoever
// makes it. Quotes and backslashes are stripped first — `stu"pid".user` is the
// same reference as the plain name.
for (const n of names) {
  if (haystack.includes(n) || haystack.replace(/["'\\]/g, "").includes(n)) {
    deny(
      `secret-file-gate: ${n} holds the sudo password and must never be read into a transcript. ` +
      `To run something as root use the wrapper instead: ${WRAPPER} <command> [args...] ` +
      `(modules/workstation/bin/deck-sudo) — it reads the file inside a spawned process and never prints it.`,
    );
  }
}
