#!/usr/bin/env node
// kill-guard — decide whether a command could kill a human-owned session.
//
// Membership, never name matching: every target is resolved to a cgroup path and compared
// against the protected chains. Name matching is what killed the user's own sessions.
//
// Protected chains — a target sharing a root-path chain with any of these is refused, because
// an ancestor kills the leaf and a descendant IS the leaf:
//   * human.slice
//   * every terminal scope (vte-spawn-*, session-*) currently holding an agent runtime
//   * the terminal server's own cgroup
//
// A target that cannot be resolved to a cgroup is refused.
//
// API:  evaluate(commandString) / evaluateArgv(argvArray) -> { deny, reason }
// CLI:  node kill-guard.mjs "<command>" | node kill-guard.mjs --argv <verb> <arg>...
//       exit 0 allow, exit 1 deny with the reason on stderr.

import { readFileSync, readdirSync } from "node:fs";
import { execFileSync } from "node:child_process";

const CG_ROOT = "/sys/fs/cgroup";
const UID = process.getuid();
const HUMAN_SLICE = `/user.slice/user-${UID}.slice/user@${UID}.service/human.slice`;
const TERMINAL_SCOPE = /\/(?:vte-spawn-[0-9a-fA-F-]+\.scope|session-\d+\.scope)$/;
const RUNTIME_MARKER = /(?:^|\/)(?:claude|codex|cursor-agent|claude-code-proxy)(?:\0|$| )/;
// $! and %job can only name a child or job of the invoking shell, and a human session is
// never one, so these are provably safe without resolving anything.
const SHELL_LOCAL = /^(?:\$!|%[%+\-]?\w*)$/;
const HUMAN_SOCK = `${process.env.HOME || ""}/.local/state/human-session/tmux.sock`;
// Only a WRITE to cgroup.kill kills anything; reading or grepping for it is forensics, which
// is exactly what an incident needs.
const CGROUP_KILL_WRITE =
  /(?:>>?\s*\S*|of=\S*|\btee\b(?:\s+-\S+)*\s+\S*)cgroup\.kill/;

const KILLISH =
  /(cgroup\.kill|\bpkill\b|\bkillall\b|\bskill\b|\bkill\b|\bfuser\b|kill-server|kill-session|kill-window|kill-pane|systemctl[^\n]*\bstop\b)/;

const ALTERNATIVE = [
  "Safe alternatives:",
  "  * runaway BUILD → it lives in build.slice:   systemctl --user stop <scope>",
  "  * runaway AGENT → list before acting:        systemd-cgls --user-unit agent.slice",
  "  * which unit owns a pid:                     systemctl --user status <pid>",
  "  * never write cgroup.kill, and never kill a vte-spawn-*.scope — those are terminal",
  "    tabs holding the user's live sessions.",
].join("\n");

function readText(path) {
  try { return readFileSync(path, "utf8"); } catch { return null; }
}

function cgroupOfPid(pid) {
  const raw = readText(`/proc/${pid}/cgroup`);
  if (!raw) return null;
  for (const line of raw.split("\n")) {
    if (line.startsWith("0::")) return line.slice(3).trim() || null;
  }
  return null;
}

function protectedLeaves() {
  const leaves = new Set([HUMAN_SLICE]);
  let pids = [];
  try { pids = readdirSync("/proc").filter((n) => /^\d+$/.test(n)); } catch { return [...leaves]; }
  for (const pid of pids) {
    const cmdline = readText(`/proc/${pid}/cmdline`);
    if (!cmdline) continue;
    const argv0 = cmdline.split("\0")[0] || "";
    const comm = (readText(`/proc/${pid}/comm`) || "").trim();
    const isRuntime = RUNTIME_MARKER.test(`${argv0} `);
    const isTerminalServer =
      comm.startsWith("gnome-terminal") || comm.startsWith("xterm") ||
      comm === "konsole" || comm === "alacritty" || comm === "kitty";
    if (!isRuntime && !isTerminalServer) continue;
    const cg = cgroupOfPid(pid);
    if (!cg) continue;
    if (isTerminalServer || TERMINAL_SCOPE.test(cg)) leaves.add(cg);
  }
  return [...leaves];
}

function sameChain(a, b) {
  return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
}

function cgroupOfUnit(unit, userScope) {
  try {
    return execFileSync(
      "systemctl",
      [...(userScope ? ["--user"] : []), "show", unit, "-p", "ControlGroup", "--value"],
      { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] },
    ).trim() || null;
  } catch { return null; }
}

function pgrepPids(args) {
  try {
    return execFileSync("pgrep", args, { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] })
      .split("\n").map((l) => l.trim()).filter((l) => /^\d+$/.test(l));
  } catch { return []; }
}

function stripPrivilege(clause) {
  return clause.replace(/^(?:deck-sudo|sudo|doas|nohup|env(?:\s+\w+=\S*)*)\s+/, "");
}

// Examines one already-split command; appends resolved cgroups and unresolvable targets.
function analyzeWords(words, cgroups, unresolved, selfPids) {
  if (words.length === 0) return;
  const verb = words[0].split("/").pop();
  const rest = words.slice(1);
  const flags = rest.filter((w) => w.startsWith("-") && w !== "--");
  const operands = rest.filter((w) => !w.startsWith("-") && w !== "--");

  if (verb === "systemctl") {
    const action = words.find((w) => w === "kill" || w === "stop");
    if (!action) return;
    const units = words.slice(words.indexOf(action) + 1).filter((w) => !w.startsWith("-"));
    if (units.length === 0) { unresolved.push(`systemctl ${action} with no literal unit`); return; }
    const userScope = words.includes("--user");
    for (const unit of units) {
      if (/[$`*?]/.test(unit)) { unresolved.push(`systemctl ${action} ${unit} (unit name is not literal)`); continue; }
      // An inactive unit has an empty ControlGroup and no processes: nothing to protect.
      const cg = cgroupOfUnit(unit, userScope);
      if (cg) cgroups.push(cg);
    }
    return;
  }

  if (verb === "kill") {
    if (flags.includes("-0")) return;
    if (operands.length === 0) { unresolved.push("kill with no literal pid"); return; }
    for (const a of operands) {
      if (/^\d+$/.test(a)) {
        const cg = cgroupOfPid(a);
        if (cg) cgroups.push(cg);
      } else if (!SHELL_LOCAL.test(a)) {
        unresolved.push(`kill ${a} (target is not a literal pid)`);
      }
    }
    return;
  }

  if (verb === "pkill" || verb === "killall" || verb === "skill") {
    if (flags.includes("-0")) return;
    if (operands.length === 0) { unresolved.push(`${verb} with no literal pattern`); return; }
    const full = flags.some((f) => /^-[a-zA-Z]*f/.test(f));
    for (const op of operands) {
      if (/[$`]/.test(op)) { unresolved.push(`${verb} ${op} (pattern is not literal)`); continue; }
      const pids = (full
        ? pgrepPids(["-f", "--", op])
        : pgrepPids(verb === "killall" ? ["-x", "--", op] : ["--", op])
      ).filter((p) => !selfPids.has(p));
      for (const pid of pids) {
        const cg = cgroupOfPid(pid);
        if (cg) cgroups.push(cg);
      }
    }
    return;
  }

  if (verb === "fuser" && flags.some((f) => f.includes("k"))) {
    unresolved.push("fuser -k (kills by open file; the target set cannot be resolved ahead of time)");
    return;
  }

  if (verb === "tmux" && words.some((w) => /^kill-(server|session|window|pane)$/.test(w))) {
    if (words.includes(HUMAN_SOCK)) cgroups.push(HUMAN_SLICE);
  }
}

// -f matches full command lines, so this guard's own argv carries the pattern it was asked
// about. The hook path excludes only itself: its parent is a live agent process and so is a
// legitimate target.
function collectFromString(cmd) {
  const cgroups = [];
  const unresolved = [];
  const selfPids = new Set([String(process.pid)]);

  if (CGROUP_KILL_WRITE.test(cmd)) {
    const paths = cmd.match(new RegExp(`${CG_ROOT}[^\\s'"\`;&|>]*`, "g")) || [];
    if (paths.length === 0) unresolved.push("a write to cgroup.kill whose target path is not literal");
    for (const p of paths) {
      const rel = p.slice(CG_ROOT.length).replace(/\/cgroup\.kill$/, "");
      cgroups.push(rel === "" ? "/" : rel);
    }
  }

  for (const clause of cmd.split(/[;&|\n]+|\$\(|\)|`/)) {
    analyzeWords(stripPrivilege(clause.trim()).split(/\s+/).filter(Boolean), cgroups, unresolved, selfPids);
  }
  return { cgroups, unresolved };
}

function verdict(cgroups, unresolved) {
  if (cgroups.length === 0 && unresolved.length === 0) return { deny: false, reason: "" };

  const leaves = protectedLeaves();
  const hits = [];
  for (const target of cgroups) {
    const leaf = leaves.find((l) => sameChain(target, l));
    if (leaf) hits.push(`${target}   (holds: ${leaf})`);
  }
  if (hits.length === 0 && unresolved.length === 0) return { deny: false, reason: "" };

  const head = hits.length
    ? `Blocked: this kills a cgroup that holds a human-owned session.\n${hits.map((h) => `  ${h}`).join("\n")}`
    : `Blocked: this kill cannot be resolved to a cgroup, so it cannot be proven safe.\n${unresolved.map((u) => `  ${u}`).join("\n")}`;

  return {
    deny: true,
    reason: `${head}\n\nThe user's sessions must not be killed. There is no override flag.\n${ALTERNATIVE}`,
  };
}

export function evaluate(command) {
  if (typeof command !== "string" || !command || !KILLISH.test(command)) return { deny: false, reason: "" };
  const { cgroups, unresolved } = collectFromString(command);
  return verdict(cgroups, unresolved);
}

// The caller already has the words, so no shell quoting is invented and then re-parsed.
// The parent here is the PATH shim that will exec the real pkill over itself, not a target.
export function evaluateArgv(argv) {
  const cgroups = [];
  const unresolved = [];
  const selfPids = new Set([String(process.pid), String(process.ppid)]);
  analyzeWords(argv.filter((w) => typeof w === "string" && w !== ""), cgroups, unresolved, selfPids);
  return verdict(cgroups, unresolved);
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const args = process.argv.slice(2);
  let out;
  try {
    out = args[0] === "--argv" ? evaluateArgv(args.slice(1)) : evaluate(args.join(" "));
  } catch (err) {
    out = { deny: true, reason: `kill-guard could not evaluate this command (${err.message}); refusing.\n${ALTERNATIVE}` };
  }
  if (out.deny) {
    process.stderr.write(`${out.reason}\n`);
    process.exit(1);
  }
  process.exit(0);
}
