#!/usr/bin/env python3
# malcolm — the deterministic half of the Dr. Ian Malcolm skill.  malcolm:ignore-file
#
# Reads a diff or a directory and reports concrete signals of a change reaching further
# than it was asked to. Python 3 stdlib only. No network. No AI. No config to drift.
#
# It finds SIGNALS, never intent. A clean scan is not approval — it means the cheap
# checks passed. The seven gates are still yours to answer.
#
#   malcolm diff [--staged] [--json]     signals in the current change
#   malcolm scan PATH [--json]           signals in a tree or file
#   malcolm review "subject"             blank review template
#   malcolm gates                        the seven gates, to answer by hand
#
# Exit: 0 clean · 1 findings · 2 at least one auto-NO class signal.
from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys

VERSION = "1.0.0"

# level: "stop" = auto-NO class if nobody asked for it; "flag" = justify it; "note" = know it
# radius: M0..M4, see SKILL.md §2
RULES = [
    # --- irreversible / destructive -------------------------------------------------
    ("rm-rf",        "destructive", "stop", 2, r"\brm\s+(?:-[a-zA-Z]*[rf][a-zA-Z]*\s+)+\S",
     "recursive/forced delete"),
    ("rmtree",       "destructive", "stop", 2, r"shutil\.rmtree\(|fs\.rm(?:Sync)?\([^)]*recursive",
     "recursive tree delete in code"),
    ("sql-drop",     "destructive", "stop", 3, r"\bDROP\s+(?:TABLE|DATABASE|SCHEMA|COLUMN)\b",
     "schema destruction"),
    ("sql-truncate", "destructive", "stop", 3, r"\bTRUNCATE\b\s+(?:TABLE\s+)?\w",
     "table emptied"),
    ("sql-delete-all", "destructive", "stop", 3,
     r"\bDELETE\s+FROM\s+[\w.\"'`\[\]]+\s*(?:;|$|\"|')",
     "DELETE with no WHERE — every row"),
    ("sql-update-all", "destructive", "stop", 3,
     r"\bUPDATE\s+[\w.\"'`\[\]]+\s+SET\b(?![\s\S]{0,200}\bWHERE\b)",
     "UPDATE with no WHERE — every row"),
    ("orm-delete-all", "destructive", "stop", 3,
     r"delete_many\(\s*\{\s*\}|deleteMany\(\s*\{\s*\}|objects\.all\(\)\.delete\(\)|\.deleteAll\(|drop_collection\(",
     "unfiltered bulk delete"),
    ("force-push",   "destructive", "stop", 3, r"git\s+push\b[^\n]*(?:--force(?!-with-lease)|\s-f\b)",
     "history overwritten for everyone"),
    ("reset-hard",   "destructive", "flag", 2, r"git\s+(?:reset\s+--hard|clean\s+-[a-z]*f)",
     "local work discarded, unrecoverably"),
    ("disk-write",   "destructive", "stop", 2, r"\bdd\s+[^\n]*of=/dev/|\bmkfs\.\w|\bshred\s+[-/]",
     "raw device / unrecoverable erase"),
    ("rsync-delete", "destructive", "flag", 3, r"rsync\s[^\n]*--delete",
     "mirrors deletions to the destination"),

    # --- suppressed safety checks ----------------------------------------------------
    ("no-verify",    "suppression", "flag", 1, r"--no-verify|--skip-checks|--no-preserve-root",
     "a safety check someone wrote after being burned, disabled"),
    ("tls-off",      "suppression", "stop", 4,
     r"verify\s*=\s*False|rejectUnauthorized\s*:\s*false|--insecure\b|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['\"]?0|curl\s+[^\n]*\s-k\b",
     "certificate validation turned off"),
    ("pipe-to-shell","suppression", "stop", 3, r"(?:curl|wget)\s[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|k)?sh",
     "remote code executed unread"),
    ("auto-yes",     "suppression", "note", 1, r"--assume-yes|--yes\b|-y\s+install|DEBIAN_FRONTEND=noninteractive",
     "confirmation prompts pre-answered"),

    # --- privilege --------------------------------------------------------------------
    ("sudo",         "privilege",   "flag", 2, r"(?:^|[\s;&|(])sudo\s+\S",
     "privileged execution"),
    ("chmod-777",    "privilege",   "stop", 2, r"chmod\s+(?:-R\s+)?0?777|chmod\s+(?:-R\s+)?a\+rwx",
     "world-writable"),
    ("chown-root",   "privilege",   "flag", 2, r"chown\s+[^\n]*\broot\b",
     "ownership handed to root"),
    ("security-off", "privilege",   "stop", 3,
     r"setenforce\s+0|ufw\s+disable|iptables\s+-F|systemctl\s+(?:stop|disable)\s+(?:firewalld|ufw|apparmor)",
     "host protection disabled"),

    # --- persistence: it keeps running after you leave ---------------------------------
    ("cron",         "persistence", "stop", 3, r"crontab\s+-|/etc/cron\.|@reboot|@daily|@hourly",
     "scheduled to run unattended, forever"),
    ("systemd",      "persistence", "stop", 3,
     r"systemctl\s+(?:enable|--now)|WantedBy\s*=|Restart\s*=\s*always|\.timer\b",
     "installs a unit that survives the session"),
    ("daemonize",    "persistence", "flag", 2,
     r"\bnohup\b|start-stop-daemon|daemon\s*=\s*True|pm2\s+start|forever\s+start|&\s*disown",
     "backgrounded to outlive the caller"),
    ("scheduler",    "persistence", "flag", 2,
     r"setInterval\(|node-cron|BackgroundScheduler|APScheduler|schedule\.every|celery\.beat",
     "in-process recurring job"),
    ("watcher",      "persistence", "note", 1, r"watchdog\.|chokidar|inotify|fs\.watch\(",
     "acts on file changes without being asked again"),
    ("retry-forever","persistence", "flag", 2,
     r"max_retries\s*=\s*(?:None|-1|0*[5-9]\d|\d{3,})|retries\s*:\s*Infinity|while\s+True:\s*(?:#.*)?$",
     "unbounded retry / loop"),

    # --- outward-facing: cannot be undone by a later commit -----------------------------
    ("send-mail",    "outward",     "stop", 4, r"smtplib|sendgrid|mailgun|nodemailer|sendmail\b|hankmail\s",
     "sends mail to real people"),
    ("send-msg",     "outward",     "stop", 4, r"twilio|/api/(?:call|sms)\b|slack[_-]?webhook|hooks\.slack\.com|telegram\.org/bot",
     "places a call / posts a message outside"),
    ("payment",      "outward",     "stop", 4, r"stripe\.|/v1/charges|createPaymentIntent|paypal\.|checkout\.session",
     "moves money"),
    ("crypto-spend", "outward",     "stop", 4,
     r"sendrawtransaction|signrawtransaction|sendtoaddress|transfer\(\s*to|privateKey|WIF\b",
     "signs or broadcasts a value transfer"),
    ("publish",      "outward",     "stop", 4,
     r"npm\s+publish|twine\s+upload|docker\s+push|gh\s+release\s+create|cargo\s+publish",
     "publishes where it cannot be recalled"),
    ("make-public",  "outward",     "stop", 4,
     r"git-daemon-export-ok|\"private\"\s*:\s*false|--visibility[= ]public|public-read",
     "flips something from private to public"),

    # --- what you invited in ------------------------------------------------------------
    ("install",      "dependency",  "flag", 1,
     r"(?:npm|pnpm|yarn)\s+(?:i|add|install)\s+\S|pip3?\s+install\s+\S|apt(?:-get)?\s+install\s+\S|cargo\s+add\s+\S|go\s+get\s+\S",
     "new dependency pulled in"),
    ("self-update",  "dependency",  "stop", 3,
     r"self[_-]?update|auto[_-]?update|apt(?:-get)?\s+(?:upgrade|dist-upgrade)|npm\s+update\s+-g|open\(\s*__file__\s*,\s*['\"][aw]",
     "code that changes itself or its host without being asked"),
    ("telemetry",    "dependency",  "flag", 2,
     r"posthog|mixpanel|segment\.(?:io|com)|google-analytics|gtag\(|sentry[_-]?(?:sdk|dsn)|amplitude",
     "reports usage to a third party"),

    # --- reach: network and credentials ---------------------------------------------------
    ("outbound",     "network",     "note", 2,
     r"requests\.(?:get|post|put|patch|delete)\(|urllib\.request|httpx\.|axios\.|fetch\(\s*['\"]https?:|socket\.connect\(",
     "makes an outbound call"),
    ("bind-all",     "exposure",    "stop", 4, r"0\.0\.0\.0|host\s*=\s*['\"]\*",
     "listens on every interface, not loopback"),
    ("cors-star",    "exposure",    "flag", 3,
     r"Access-Control-Allow-Origin[\"']?\]?\s*[:,=]\s*[\"']?\*|cors\(\s*\)\s*;",
     "any origin may call it"),
    ("secret-read",  "secrets",     "flag", 2,
     r"os\.environ(?:\.get)?[\[\(]\s*['\"][A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)|process\.env\.[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD)|secret\s+get\s|vault\s+read",
     "reads a credential"),
    ("private-key",  "secrets",     "stop", 3,
     r"BEGIN\s+(?:RSA\s+|EC\s+|OPENSSH\s+|PGP\s+)?PRIVATE KEY|\.ssh/id_(?:rsa|ed25519)|\.aws/credentials|\.netrc\b",
     "touches a private key or credential file"),
    ("hardcoded",    "secrets",     "stop", 3,
     r"(?i)(?:api[_-]?key|secret|password|token|passwd|bearer)\s*[:=]\s*['\"][A-Za-z0-9_\-/+]{20,}['\"]"
     r"|(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{20,}"
     r"|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_\-]{30,}",
     "credential written into the source"),
]

COMPILED = [(rid, cat, lvl, rad, re.compile(pat), why) for rid, cat, lvl, rad, pat, why in RULES]

LEVEL_ORDER = {"note": 0, "flag": 1, "stop": 2}
LEVEL_LABEL = {"note": "NOTE", "flag": "FLAG", "stop": "STOP"}

SKIP_DIRS = {".git", "node_modules", "vendor", "dist", "build", "__pycache__", ".venv",
             "venv", ".next", "target", ".cache", "coverage", ".mypy_cache", ".terraform"}
SKIP_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tgz",
            ".xz", ".bz2", ".apk", ".deb", ".jar", ".so", ".dylib", ".dll", ".exe", ".woff",
            ".woff2", ".ttf", ".eot", ".mp3", ".mp4", ".wav", ".bin", ".class", ".pyc",
            ".lock", ".svg"}
MAX_BYTES = 2_000_000

IGNORE_FILE = "malcolm:ignore-file"
IGNORE_LINE = "malcolm:ignore"


COMMENT_START = ("#", "//", "*", "--", "<!--", ";;")
DOWNGRADE = {"stop": "flag", "flag": "note", "note": "note"}
DOC_EXT = {".md", ".markdown", ".rst", ".txt", ".adoc"}


def match_line(path, lineno, text):
    """Signals on one line.

    Comment lines and documentation are downgraded, not dropped. Prose about deleting
    things is not deleting things — but a commented-out cron entry is one keystroke from a
    real one, and a README that tells people to pipe curl into a shell is still telling
    people to pipe curl into a shell."""
    out = []
    if IGNORE_LINE in text:
        return out
    stripped = text.strip()
    if not stripped:
        return out
    is_doc = os.path.splitext(path)[1].lower() in DOC_EXT
    is_comment = is_doc or stripped.startswith(COMMENT_START)
    note = " [prose]" if is_doc else (" [comment]" if is_comment else "")
    for rid, cat, lvl, rad, rx, why in COMPILED:
        if rx.search(text):
            out.append({"rule": rid, "category": cat,
                        "level": DOWNGRADE[lvl] if is_comment else lvl,
                        "radius": max(0, rad - 1) if is_comment else rad,
                        "file": path, "line": lineno,
                        "why": why + note,
                        "text": stripped[:160]})
    return out


def scan_tree(root):
    findings, files = [], 0
    if os.path.isfile(root):
        targets = [root]
    else:
        targets = []
        for dirpath, dirnames, filenames in os.walk(root):
            dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".git")]
            for fn in filenames:
                targets.append(os.path.join(dirpath, fn))
    for fp in targets:
        ext = os.path.splitext(fp)[1].lower()
        if ext in SKIP_EXT:
            continue
        try:
            if os.path.getsize(fp) > MAX_BYTES:
                continue
            with open(fp, "r", encoding="utf-8", errors="strict") as fh:
                lines = fh.read().splitlines()
        except (OSError, UnicodeDecodeError):
            continue
        if any(IGNORE_FILE in ln for ln in lines[:25]):
            continue
        files += 1
        rel = os.path.relpath(fp, root if os.path.isdir(root) else os.path.dirname(root) or ".")
        for i, ln in enumerate(lines, 1):
            findings.extend(match_line(rel, i, ln))
    return findings, files


def git_diff(staged, extra):
    cmd = ["git", "diff", "--unified=0", "--no-color"]
    if staged:
        cmd.append("--cached")
    cmd.extend(extra or [])
    try:
        res = subprocess.run(cmd, capture_output=True, text=True, check=False)
    except FileNotFoundError:
        sys.exit("malcolm: git not found")
    if res.returncode != 0:
        sys.exit("malcolm: " + (res.stderr.strip() or "git diff failed"))
    return res.stdout


def scan_diff(text):
    """Only ADDED lines are judged. What you removed is not what you are about to do."""
    findings, path, lineno, files = [], "?", 0, set()
    hunk = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
    for raw in text.splitlines():
        if raw.startswith("+++ b/"):
            path = raw[6:].strip()
            files.add(path)
            continue
        if raw.startswith("@@"):
            m = hunk.match(raw)
            lineno = int(m.group(1)) if m else 0
            continue
        if raw.startswith("+") and not raw.startswith("+++"):
            findings.extend(match_line(path, lineno, raw[1:]))
            lineno += 1
    return findings, len(files)


def report(findings, nfiles, subject, as_json):
    if as_json:
        worst = max([f["radius"] for f in findings], default=0)
        print(json.dumps({"subject": subject, "files": nfiles, "radius": "M%d" % worst,
                          "auto_no": any(f["level"] == "stop" for f in findings),
                          "findings": findings}, indent=2))
    else:
        w = "\033[1m" if sys.stdout.isatty() else ""
        r = "\033[0m" if sys.stdout.isatty() else ""
        print("%sMALCOLM SCAN — %s%s" % (w, subject, r))
        print("%d file(s) examined, %d signal(s)\n" % (nfiles, len(findings)))
        if not findings:
            print("  no signals.\n")
            print("  This is not a GO. It means the cheap, mechanical checks passed —")
            print("  scope, authorization and consequence are still yours to answer.")
            print("  Run: malcolm gates")
            return 0
        findings.sort(key=lambda f: (-LEVEL_ORDER[f["level"]], -f["radius"], f["category"],
                                     f["file"], f["line"]))
        for f in findings:
            print("  %-4s M%d  %-12s %s:%s" % (LEVEL_LABEL[f["level"]], f["radius"],
                                               f["category"], f["file"], f["line"]))
            print("            %s  (%s)" % (f["why"], f["rule"]))
            print("            > %s" % f["text"])
        worst = max(f["radius"] for f in findings)
        stops = [f for f in findings if f["level"] == "stop"]
        print("\n  RADIUS  M%d — the furthest any single signal reaches." % worst)
        print("          Authorization must be at least M%d. Was it?" % worst)
        if stops:
            print("\n  %d signal(s) in the auto-NO class: irreversible, outward-facing," % len(stops))
            print("  privileged, or self-perpetuating. Each one needs a sentence someone")
            print("  actually said. Anything you cannot trace to one, cut and offer back.")
        print("\n  Signals are not intent. Next: malcolm gates")
    return 2 if any(f["level"] == "stop" for f in findings) else 1


GATES = """The seven gates — answer each in one line, with evidence.

  1  COULD / SHOULD      Justify it in a sentence that does not use, or mean, "could".
                         Only legitimate ground: someone asked, or the ask is impossible
                         without it.

  2  HUMILITY            What here is older, more load-bearing, or more entangled than my
                         model of it? Name it, or admit I have not looked.

  3  BORROWED POWER      What am I holding that I did not build and cannot repair — root,
                         a key, a wallet, someone's production data? Access is not
                         authorization.

  4  EARNED UNDERSTANDING  Can I explain this line by line, including what was generated
                         in seconds? What I cannot explain, I cannot be responsible for.

  5  THE LUNCHBOX        Is this being packaged, shipped or announced before it is
                         understood? Name three failure modes or it is still a demo.

  6  THE IMPORTED ORGANISM  What did I bring in, and what does it do on failure, on
                         upgrade, and when nobody is watching?

  7  SIXTY-FIVE MILLION YEARS  What combination here has no precedent? Then buy
                         information, or reduce the stake. "We cannot know" is a finding.

Verdict: GO | GO, NARROWED | HOLD | NO — with the cuts named out loud.
"""

TEMPLATE = """MALCOLM REVIEW — {subject}

LEDGER
  ASKED    <they said this, in words>
  IMPLIED  <unavoidable because ...>
  ASSUMED  <nobody asked> — CUT

RADIUS    M<n> (<worst step>)   AUTH  M<n> (<what was actually asked>)

GATES     1 could/should ...   2 humility ...   3 borrowed power ...
          4 understanding ...  5 lunchbox ...   6 imported ...   7 precedent ...

VERDICT   <GO | GO, NARROWED | HOLD | NO>
          <one line>
CUTS      <what is not being built, offered back as a question>
"""


def main():
    p = argparse.ArgumentParser(prog="malcolm", description="Signals that a change reaches "
                               "further than it was asked to.")
    p.add_argument("--version", action="version", version="malcolm " + VERSION)
    sub = p.add_subparsers(dest="cmd", required=True)

    d = sub.add_parser("diff", help="scan added lines of the current change")
    d.add_argument("--staged", action="store_true", help="scan the index instead of the worktree")
    d.add_argument("--json", action="store_true")
    d.add_argument("gitargs", nargs="*", help="extra args passed to git diff (e.g. main...HEAD)")

    s = sub.add_parser("scan", help="scan a file or directory")
    s.add_argument("path", nargs="?", default=".")
    s.add_argument("--json", action="store_true")

    rv = sub.add_parser("review", help="print the blank review template")
    rv.add_argument("subject", nargs="?", default="<subject>")

    sub.add_parser("gates", help="print the seven gates")

    a = p.parse_args()

    if a.cmd == "gates":
        print(GATES)
        return 0
    if a.cmd == "review":
        print(TEMPLATE.format(subject=a.subject))
        return 0
    if a.cmd == "diff":
        text = git_diff(a.staged, a.gitargs)
        if not text.strip():
            print("malcolm: no changes to scan%s." % (" (staged)" if a.staged else ""))
            return 0
        findings, n = scan_diff(text)
        return report(findings, n, "staged change" if a.staged else "working tree", a.json)
    findings, n = scan_tree(a.path)
    return report(findings, n, a.path, a.json)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        sys.exit(130)
