#!/usr/bin/env node
'use strict';
// PreToolUse hook for mcp__nucleic__host_exec — defensive shim guard.
//
// git and gh are wrapped by Nucleic interceptor shims inside the container
// (/usr/local/bin/git, /usr/local/bin/gh). Those shims report activity back to
// the container engine and keep the shared-trunk version control consistent.
// Running git/gh on the macOS *host* via host_exec bypasses the shims, so we
// deny it. host_exec stays available for genuinely host-only toolchains
// (Swift/Xcode, codesign, xcrun, make, …).
//
// Threat model: the calling agent may try to sneak a blocked program past a
// naive check. This analyzer therefore inspects the command *recursively* and
// de-obfuscates before matching. Layers:
//   1. Normalize: decode $'\xNN' ANSI-C quoting; expand simple VAR=val / $VAR
//      indirection; strip quoting/backslash escapes from program tokens.
//   2. Structural scan: for every statement/pipeline stage, resolve the program
//      actually being executed (skipping env-assignments and exec-wrappers like
//      sudo/env/timeout/xargs) and match it against the blocklist.
//   3. Recurse into: `sh -c "…"` / `bash -lc "…"` scripts, interpreter inline
//      code (python -c, perl -e, node -e, awk …), and $( … ) / `…` command
//      substitutions.
//   4. Obfuscation tripwires (fail closed): a pipeline stage that pipes into a
//      bare shell (… | sh, curl … | sh, base64 -d | sh), `eval` of dynamic
//      content, decoders feeding a substitution, or nesting too deep to analyze.
//
// Residual, un-blockable-by-static-analysis vectors (documented, not silently
// ignored): fetching a script over the network to a file then executing it, or
// fully dynamic value construction the shell computes at runtime. These are
// noted in the deny message so a reviewer knows the boundary.

const BLOCKED = new Set(['git', 'gh']);

const SHELLS = new Set([
  'sh', 'bash', 'dash', 'zsh', 'ksh', 'ash', 'rbash', 'busybox', 'csh', 'tcsh', 'fish',
]);

const INTERPRETERS = new Set([
  'python', 'python2', 'python3', 'perl', 'ruby', 'node', 'nodejs', 'php',
  'awk', 'gawk', 'mawk',
]);

// Commands that launch another program given as their (later) arguments.
const EXEC_WRAPPERS = new Set([
  'sudo', 'doas', 'run0', 'env', 'command', 'builtin', 'exec', 'nohup', 'time',
  'timeout', 'stdbuf', 'unbuffer', 'nice', 'ionice', 'taskset', 'setsid',
  'setarch', 'chrt', 'xargs', 'flock', 'proxychains', 'proxychains4',
  'caffeinate', 'arch', 'script', 'watch', 'then', 'do', 'else',
]);

// Wrappers that take positional operand(s) BEFORE the command word.
const OPERAND_WRAPPERS = { timeout: 1, flock: 1, taskset: 1, chrt: 1 };

// Options that consume a following value token (conservative; excludes -n on
// purpose because its meaning varies by tool — the ambiguity net catches those).
const VALUE_OPTS = new Set([
  '-u', '-g', '-s', '-k', '-c', '-p', '-C', '-o', '-a', '-P',
  '-d', '-x', '-L', '-t', '-w',
]);

const DECODER = /\b(base64|base32|basenc|xxd|uudecode|openssl)\b/;
const PLACEHOLDER = 'SUBST0'; // opaque stand-in for a removed $( … ) / `…`
const MAX_DEPTH = 8;

// ---------------------------------------------------------------------------

let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (d) => { input += d; });
process.stdin.on('end', () => {
  let command = '';
  try {
    const data = JSON.parse(input || '{}');
    command = (data.tool_input && data.tool_input.command) || '';
  } catch {
    // Unparseable hook input -> don't interfere.
  }

  let hit = null;
  try {
    hit = analyze(command, 0);
  } catch {
    // Analyzer bug: fail closed only if the raw text obviously references a
    // blocked program, so a parser bug can't silently open the hole nor nuke
    // every legitimate host build.
    if (blockedWholeWord(String(command || ''))) {
      hit = { prog: 'git/gh', kind: 'fail-closed' };
    }
  }

  if (hit) {
    process.stdout.write(JSON.stringify({
      hookSpecificOutput: {
        hookEventName: 'PreToolUse',
        permissionDecision: 'deny',
        permissionDecisionReason: reasonFor(hit),
      },
    }));
  }
  // Emit nothing when allowed: leaves host_exec's normal approval flow intact.
  process.exit(0);
});

function reasonFor(hit) {
  const base =
    `Blocked host_exec: this command runs a container-shimmed program on the macOS host, ` +
    `which bypasses the Nucleic interceptor shims (/usr/local/bin/git, /usr/local/bin/gh) ` +
    `and the shared-trunk version control. Run git/gh directly inside the container instead; ` +
    `host_exec is only for host-only toolchains (Swift/Xcode, codesign, xcrun, make).`;
  switch (hit.kind) {
    case 'direct':
      return `Blocked: '${hit.prog}' would run on the host via host_exec. ${base}`;
    case 'subst-output':
      return `Blocked: a command substitution resolves to '${hit.prog}'. ${base}`;
    case 'interpreter':
      return `Blocked: inline ${hit.prog} code references git/gh (it can shell out to the host binary). ${base}`;
    case 'shell-sink':
      return `Blocked: this command pipes/feeds data into a shell (e.g. "… | ${hit.prog}"), ` +
             `which can execute a hidden git/gh invocation. Run the intended command plainly. ${base}`;
    case 'obfuscation':
      return `Blocked: this command uses an obfuscated/dynamic construct (eval, decode-and-exec, ` +
             `or nesting too deep to verify) that could hide a git/gh call. Run the intended command plainly. ${base}`;
    case 'wrapper-net':
      return `Blocked: a wrapper (sudo/env/timeout/xargs/…) appears to launch git/gh. ${base}`;
    case 'fail-closed':
      return `Blocked: the command references git/gh and could not be fully verified, so it was denied. ${base}`;
    default:
      return base;
  }
}

const hit = (prog, kind) => ({ prog, kind });

// ---------------------------------------------------------------------------
// Core recursive analysis.

function analyze(cmd, depth) {
  if (depth > MAX_DEPTH) return hit('(nesting too deep)', 'obfuscation');
  if (typeof cmd !== 'string' || !cmd.trim()) return null;

  let s = decodeAnsiC(cmd);
  s = expandStaticVars(s);

  // Pull out and recurse into command substitutions first.
  const sub = handleSubstitutions(s, depth);
  if (sub.hit) return sub.hit;
  s = sub.replaced;

  for (const stmt of splitStatements(s)) {
    const r = analyzeStatement(stmt, depth);
    if (r) return r;
  }
  return null;
}

function analyzeStatement(stmt, depth) {
  const stages = splitPipes(stmt);
  for (let k = 0; k < stages.length; k++) {
    const stage = stages[k];
    const toks = tokenize(stage);
    if (!toks.length) continue;

    const ci = resolveCommandIndex(toks, 0);
    const raw = ci < toks.length ? toks[ci] : '';
    const b = basename(dequote(raw));

    // Direct hit on the resolved program.
    if (BLOCKED.has(b)) return hit(b, 'direct');

    // Backstop: a blocked program appearing BEFORE the resolved command means
    // wrapper/option/operand skipping stepped over it (e.g. an option we mis-
    // judged as value-taking). Deny rather than risk under-blocking. Skip
    // env-assignment tokens (VAR=/path/git must not trip this).
    for (let j = 0; j < ci && j < toks.length; j++) {
      if (isAssign(toks[j])) continue;
      const jb = basename(dequote(toks[j]));
      if (BLOCKED.has(jb)) return hit(jb, 'wrapper-net');
    }

    // Shell: recurse into -c script, or flag a stdin/pipe-fed shell.
    if (SHELLS.has(b)) {
      const script = extractDashCScript(toks, ci);
      if (script != null) {
        const r = analyze(script, depth + 1);
        if (r) return r;
      } else {
        const operand = firstOperand(toks, ci);
        if (k > 0 || !operand || operand === '-') return hit(b, 'shell-sink');
        // stage-0 shell with a script-file operand: contents not inspectable — allow.
      }
    }

    // Interpreter inline code: scan for blocked words and recurse for embedded shell.
    if (INTERPRETERS.has(b)) {
      const code = extractInterpreterCode(b, toks, ci);
      if (code != null) {
        if (blockedWholeWord(code)) return hit(b, 'interpreter');
        const r = analyze(code, depth + 1);
        if (r) return r;
      }
    }

    // eval: recurse static content; deny dynamic content.
    if (b === 'eval') {
      const arg = toks.slice(ci + 1).join(' ');
      if (/[`$]/.test(arg) || arg.includes(PLACEHOLDER)) return hit('eval', 'obfuscation');
      const r = analyze(dequote(arg), depth + 1);
      if (r) return r;
    }

    // Ambiguity net: when the stage starts with an exec-wrapper but the resolved
    // command word is ambiguous (an option, a bare number, empty, or an opaque
    // substitution), fall back to a whole-word scan of the stage.
    const first = basename(dequote(toks[0]));
    if (EXEC_WRAPPERS.has(first)) {
      const ambiguous = !raw || /^-/.test(raw) || /^[0-9]+$/.test(b) || b === PLACEHOLDER;
      if (ambiguous && blockedWholeWord(stage)) return hit('(wrapped)', 'wrapper-net');
    }
  }
  return null;
}

// Resolve the index of the program actually executed in a token list, skipping
// leading env-assignments, exec-wrappers (and their options/operands), and
// opaque (possibly-empty) substitution placeholders.
function resolveCommandIndex(toks, start) {
  let i = start;
  let guard = 0;
  while (i < toks.length && guard++ < 64) {
    const t = toks[i];
    if (isAssign(t)) { i++; continue; }
    const b = basename(dequote(t));
    if (EXEC_WRAPPERS.has(b)) { i = consumeWrapper(toks, i + 1, b); continue; }
    if (b === PLACEHOLDER) { i++; continue; } // substitution may expand to empty
    break;
  }
  return i;
}

function consumeWrapper(toks, i, name) {
  let positional = OPERAND_WRAPPERS[name] || 0;
  let guard = 0;
  while (i < toks.length && guard++ < 64) {
    const t = toks[i];
    if (t === '--') { i++; break; }
    if (isAssign(t)) { i++; continue; }
    if (t.length > 1 && t[0] === '-') {
      i++;
      if (VALUE_OPTS.has(t)) i++;
      continue;
    }
    if (positional > 0) { i++; positional--; continue; }
    break;
  }
  return i;
}

// ---------------------------------------------------------------------------
// Substitution handling: recurse into $( … ) and `…`, then blank them out.

function handleSubstitutions(s, depth) {
  let out = '';
  let found = null;
  for (let i = 0; i < s.length; i++) {
    const c = s[i];
    if (c === '$' && s[i + 1] === '(') {
      const end = matchParen(s, i + 2);
      const inner = s.slice(i + 2, end);
      found = found || inspectSubstitution(inner, depth);
      out += ' ' + PLACEHOLDER + ' ';
      i = end; // skip past ')'
      continue;
    }
    if (c === '`') {
      const end = s.indexOf('`', i + 1);
      if (end === -1) { out += c; continue; }
      const inner = s.slice(i + 1, end);
      found = found || inspectSubstitution(inner, depth);
      out += ' ' + PLACEHOLDER + ' ';
      i = end;
      continue;
    }
    out += c;
  }
  return { replaced: out, hit: found };
}

function inspectSubstitution(inner, depth) {
  const r = analyze(inner, depth + 1);
  if (r) return r;
  const produced = producedProgram(inner);
  if (produced) return hit(produced, 'subst-output');
  if (DECODER.test(inner)) return hit('decoder', 'obfuscation');
  return null;
}

// If a substitution is `echo X` / `printf X` / `which X` / `command -v X`, work
// out the literal program name it yields and check the blocklist.
function producedProgram(inner) {
  const toks = tokenize(inner);
  const ci = resolveCommandIndex(toks, 0);
  const b = basename(dequote(toks[ci] || ''));
  const firstArg = () => {
    for (let j = ci + 1; j < toks.length; j++) {
      if (toks[j].startsWith('-')) continue;
      return basename(decodePrintf(dequote(toks[j])));
    }
    return '';
  };
  if (b === 'echo' || b === 'printf') { const a = firstArg(); return BLOCKED.has(a) ? a : null; }
  if (b === 'which' || b === 'type' || b === 'whereis' || b === 'hash') {
    const a = firstArg(); return BLOCKED.has(a) ? a : null;
  }
  if (b === 'command') {
    for (let j = ci + 1; j < toks.length; j++) {
      if (toks[j] === '-v' || toks[j] === '-V' || toks[j].startsWith('-')) continue;
      const a = basename(dequote(toks[j])); return BLOCKED.has(a) ? a : null;
    }
  }
  return null;
}

// ---------------------------------------------------------------------------
// Extractors.

function extractDashCScript(toks, ci) {
  for (let j = ci + 1; j < toks.length; j++) {
    const t = toks[j];
    if (t === '--') return null;
    if (/^-[A-Za-z]*c$/.test(t)) return dequote(toks[j + 1] || '');
  }
  return null;
}

function extractInterpreterCode(prog, toks, ci) {
  const flags = (prog === 'python' || prog === 'python2' || prog === 'python3')
    ? new Set(['-c'])
    : (prog === 'php') ? new Set(['-r'])
    : new Set(['-e', '-E']); // perl, ruby, node
  if (prog === 'awk' || prog === 'gawk' || prog === 'mawk') {
    // The awk program is the first non-option operand.
    for (let j = ci + 1; j < toks.length; j++) {
      const t = toks[j];
      if (t === '-f') return null; // program in a file — not inspectable
      if (t.startsWith('-')) continue;
      return dequote(t);
    }
    return null;
  }
  for (let j = ci + 1; j < toks.length; j++) {
    if (flags.has(toks[j])) return dequote(toks[j + 1] || '');
  }
  return null;
}

function firstOperand(toks, ci) {
  for (let j = ci + 1; j < toks.length; j++) {
    if (toks[j].startsWith('-')) continue;
    return dequote(toks[j]);
  }
  return '';
}

// ---------------------------------------------------------------------------
// Lexing / splitting helpers (quote-aware).

function tokenize(s) {
  const re = /(?:[^\s'"\\]|\\.|'[^']*'|"[^"]*")+/g;
  return (s.match(re) || []);
}

function splitStatements(s) {
  // Split at top-level (outside quotes) on ; \n && || & ( ) { }; keep | intact.
  const out = [];
  let cur = '';
  let q = null;
  for (let i = 0; i < s.length; i++) {
    const c = s[i];
    if (q) { cur += c; if (c === q) q = null; continue; }
    if (c === '"' || c === "'") { q = c; cur += c; continue; }
    if (c === '\\') { cur += c + (s[i + 1] || ''); i++; continue; }
    if (c === ';' || c === '\n') { out.push(cur); cur = ''; continue; }
    if ((c === '&' || c === '|') && s[i + 1] === c) { out.push(cur); cur = ''; i++; continue; } // && ||
    if (c === '&') { // background separator, but not a redirection (>&, 2>&1, &>)
      const prev = cur.replace(/\s+$/, '').slice(-1);
      if (prev === '>' || prev === '<' || /[0-9]/.test(prev) || s[i + 1] === '>') { cur += c; continue; }
      out.push(cur); cur = ''; continue;
    }
    if (c === '(' || c === ')' || c === '{' || c === '}') { out.push(cur); cur = ''; continue; }
    cur += c;
  }
  out.push(cur);
  return out;
}

function splitPipes(stmt) {
  const out = [];
  let cur = '';
  let q = null;
  for (let i = 0; i < stmt.length; i++) {
    const c = stmt[i];
    if (q) { cur += c; if (c === q) q = null; continue; }
    if (c === '"' || c === "'") { q = c; cur += c; continue; }
    if (c === '\\') { cur += c + (stmt[i + 1] || ''); i++; continue; }
    if (c === '|') {
      if (stmt[i + 1] === '|') { cur += '||'; i++; continue; } // shouldn't occur post-split
      const prev = cur.replace(/\s+$/, '').slice(-1);
      if (prev === '>') { cur += c; continue; } // >| noclobber redirection
      out.push(cur); cur = '';
      if (stmt[i + 1] === '&') i++; // |& pipe both
      continue;
    }
    cur += c;
  }
  out.push(cur);
  return out;
}

function matchParen(s, start) {
  let depth = 1;
  for (let i = start; i < s.length; i++) {
    if (s[i] === '(') depth++;
    else if (s[i] === ')') { depth--; if (depth === 0) return i; }
  }
  return s.length;
}

// ---------------------------------------------------------------------------
// Normalization helpers.

function isAssign(t) { return /^[A-Za-z_][A-Za-z0-9_]*=/.test(t); }

function basename(x) { const i = x.lastIndexOf('/'); return i >= 0 ? x.slice(i + 1) : x; }

function dequote(x) {
  let out = '';
  for (let i = 0; i < x.length; i++) {
    const c = x[i];
    if (c === '\\') { if (i + 1 < x.length) { out += x[i + 1]; i++; } continue; }
    if (c === '"' || c === "'") continue;
    out += c;
  }
  return out;
}

// Whole-word git/gh, not as part of a path/URL/flag value (git@, git://, .git, -git).
function blockedWholeWord(text) {
  const t = dequote(text);
  return /(?<![\w@./:-])(?:git|gh)(?![\w@.:/-])/.test(t);
}

function expandStaticVars(s) {
  const map = Object.create(null);
  const re = /(?:^|[\s;&|(){}])([A-Za-z_][A-Za-z0-9_]*)=('([^']*)'|"([^"]*)"|([^\s;&|()<>]*))/g;
  let m;
  while ((m = re.exec(s))) {
    const name = m[1];
    const val = m[3] != null ? m[3] : (m[4] != null ? m[4] : (m[5] || ''));
    map[name] = val;
  }
  for (let iter = 0; iter < 6; iter++) {
    let changed = false;
    s = s.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (full, n) => {
      if (n in map) { changed = true; return map[n]; }
      return full;
    });
    if (!changed) break;
  }
  return s;
}

function decodeAnsiC(s) {
  return s.replace(/\$'((?:\\.|[^'])*)'/g, (_, body) => decodeEscapes(body));
}

function decodePrintf(s) { return decodeEscapes(s); }

function decodeEscapes(body) {
  let out = '';
  for (let i = 0; i < body.length; i++) {
    if (body[i] !== '\\') { out += body[i]; continue; }
    const n = body[i + 1];
    if (n === 'x') {
      const h = body.slice(i + 2, i + 4).match(/^[0-9a-fA-F]{1,2}/);
      if (h) { out += String.fromCharCode(parseInt(h[0], 16)); i += 1 + h[0].length; continue; }
    }
    if (n === 'u') {
      const h = body.slice(i + 2, i + 6).match(/^[0-9a-fA-F]{1,4}/);
      if (h) { out += String.fromCharCode(parseInt(h[0], 16)); i += 1 + h[0].length; continue; }
    }
    if (n >= '0' && n <= '7') {
      const o = body.slice(i + 1, i + 4).match(/^[0-7]{1,3}/);
      if (o) { out += String.fromCharCode(parseInt(o[0], 8)); i += o[0].length; continue; }
    }
    const map = { a: '\x07', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', '\\': '\\', "'": "'", '"': '"' };
    if (n in map) { out += map[n]; i++; continue; }
    out += n; i++;
  }
  return out;
}
