Ctrl K
ComunidadGuías…

policy_edit.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Adding rules from the dashboard, safely: suggestions from what people keep approving, and starter packs per kind of
agent (support, finance, ops, back-office, crypto).

  suggestions()   calls held for a person, approved (or marked "legit") 5+ times in 30 days and never rejected, grouped by what they are
                  (a tool, or a shell command's program and subcommand): each comes with an `allow` rule to add.
                  Never suggested: money, an agent changing its own setup, or anything the command, history,
                  data or chain checks held (a rule can't and shouldn't open those).
  PACKS           rule blocks per kind of agent. A pack with "before" goes right before that rule, not after allow-reads.
  insert()        puts a block into rules.yaml right before the rule that held the call (so block rules above it
                  still win), keeps a backup, and puts the file back if the result doesn't load.
"""
from __future__ import annotations

import re
import shutil
import time
from pathlib import Path

import yaml

import commands
import risk

NEVER = {"approve-agent-setup-changes", "approve-guard-rail-changes", "approve-money-out", "approve-refunds", "approve-outbound-email"}
CHECK_PREFIXES = ("command:", "taint:", "history:", "sequence:", "data:", "session-stop", "emergency-stop")
WORD = re.compile(r"^[a-z][\w:.@/-]*$")


def shape(tool: str, input: dict | None, shell_tools: list[str]) -> tuple[str, list[str]] | None:
    """What a call is, for grouping: ("tool", [name]) or ("shell", [program, subcommand...])."""
    import fnmatch
    if any(fnmatch.fnmatchcase(tool.lower(), g.lower()) for g in shell_tools):
        line = commands.command_of(input or {})
        reading = commands.read(line) if line else None
        if not reading or len(reading.commands) != 1 or reading.hidden:
            return None                          # pipelines and hidden code stay with a person
        words = []
        for w in reading.commands[0].words:
            if len(words) == 3 or not WORD.match(w.lower()):
                break
            words.append(w)
        return ("shell", words) if words else None
    return "tool", [tool]


def _slug(s: str) -> str:
    return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")[:48] or "rule"


def rule_for(kind: str, words: list[str], tool: str, count: int) -> tuple[str, str]:
    """(id, YAML block) for an allow rule matching this group."""
    rid = "allow-" + _slug(" ".join(words) if kind == "shell" else tool)
    reason = f"People approved this (or marked it legit) {count} times in 30 days; added from Squidbrake's suggestions"
    if kind == "shell":
        rx = '"command":\\s*"' + re.escape(" ".join(words)).replace("'", "''") + '(\\s|")'
        match = f"      name: [\"{tool}\"]\n      input_regex: '{rx}'"
    else:
        match = f"      name: [\"{tool}\"]"
    return rid, f"  - id: {rid}\n    action: allow\n    reason: {reason}\n    match:\n{match}\n"


def vote(r) -> str | None:
    """What people said about one held call: a "legit" / "right" verdict (feedback) wins over how it was decided,
    so a hold that timed out or was rejected by mistake still teaches. -> "approved", "rejected" or None."""
    fb = getattr(r, "feedback", None)
    if fb in ("legit", "right"):
        return "approved" if fb == "legit" else "rejected"
    who = getattr(r, "decided_by", "a person")
    if not who or who == "timeout":
        return None
    return "approved" if r.decision == "allow" else "rejected"


def suggestions(rows, shell_tools: list[str], min_count: int = 5) -> list[dict]:
    """rows: held calls (name, input, rule_id, decision, decided_by, feedback). -> suggested rules, most approved
    first. One rejection, or one "right" verdict, and that kind of call is never suggested."""
    import json
    groups: dict[tuple, dict] = {}
    for r in rows:
        v = vote(r)
        if v is None:
            continue
        try:
            inp = json.loads(r.input) if isinstance(r.input, str) else r.input
        except ValueError:
            inp = None
        s = shape(r.name, inp if isinstance(inp, dict) else None, shell_tools)
        if s is None:
            continue
        g = groups.setdefault((r.name, s[0], tuple(s[1])), {"approved": 0, "rejected": 0, "rules": {}})
        g[v] += 1
        g["rules"][r.rule_id or ""] = g["rules"].get(r.rule_id or "", 0) + 1
    out = []
    for (tool, kind, words), g in groups.items():
        rules = set(g["rules"])
        if g["rejected"] or g["approved"] < min_count or rules & NEVER or risk.MONEY.search(tool) \
                or any(r.startswith(CHECK_PREFIXES) for r in rules if r):
            continue
        held_by = max(g["rules"], key=g["rules"].get) or None
        rid, block = rule_for(kind, list(words), tool, g["approved"])
        out.append({"id": rid, "what": " ".join(words) if kind == "shell" else tool, "tool": tool, "kind": kind,
                    "approved": g["approved"], "held_by": held_by, "yaml": block})
    return sorted(out, key=lambda x: -x["approved"])


PACKS: dict[str, dict] = {
    "support": {"title": "Support agent", "about": "Refunds, customer records and exports through your helpdesk, CRM "
                "and payment tools. Small refunds run; account deletions never do.", "yaml": """\
  - id: support-never-delete-customers
    action: deny
    reason: Deleting a customer or their account is not something a support agent does
    match:
      name: ["*delete_customer*", "*delete_user*", "*delete_account*", "*customer*delete*"]

  - id: support-small-refunds
    action: allow
    reason: A small refund (50 or less, in your payment API's units; Stripe counts cents) runs on its own
    match:
      name: ["*refund*"]
      input: { amount: { gte: 0, lte: 50 } }

  - id: support-plan-and-billing-changes
    action: review
    reason: Changes what a customer pays or can use
    match:
      name: ["*plan*", "*subscription*", "*billing*", "*credit*", "*discount*", "*coupon*"]

  - id: support-customer-exports
    action: review
    reason: Takes customer data out in bulk
    match:
      name: ["*export*", "*bulk*", "*download*"]
"""},
    "finance": {"title": "Finance agent", "about": "Payments, payouts and invoices. Money out needs finance, and very "
                "large transfers are refused.", "yaml": """\
  - id: finance-very-large-transfers
    action: deny
    reason: Over 10,000 (in your payment API's units) is never sent by an agent
    match:
      name: ["*transfer*", "*wire*", "*payout*", "*send_payment*"]
      input: { amount: { gt: 10000 } }

  - id: finance-invoices-and-ledgers
    action: review
    reason: Changes an invoice, a ledger or the books
    approvers: ["role:finance", "role:admin"]
    match:
      name: ["*invoice*", "*ledger*", "*journal*", "*credit_note*", "*write_off*", "*void*"]

  - id: finance-payee-changes
    action: review
    reason: Adds or changes where money goes (a payee or bank account)
    approvers: ["role:finance", "role:admin"]
    match:
      name: ["*payee*", "*beneficiary*", "*bank_account*", "*recipient*", "*vendor*"]
"""},
    "ops": {"title": "Ops agent", "about": "Deploys, feature flags, customer versions and plans in production, through "
            "your own tools. Each change waits for a person; looking runs.", "yaml": """\
  - id: ops-feature-flags
    action: review
    reason: Turns a feature on or off for real customers
    match:
      name: ["*flag*", "*toggle*", "*rollout*", "*experiment*"]

  - id: ops-customer-versions
    action: review
    reason: Moves a customer to another version
    match:
      name: ["*pin_version*", "*pin_customer*", "*set_version*", "*rollback*", "*upgrade*", "*downgrade*"]

  - id: ops-scale-and-restart
    action: review
    reason: Scales, restarts or stops a running service
    match:
      name: ["*scale*", "*restart*", "*stop*", "*drain*", "*failover*"]
"""},
    "back-office": {"title": "Back-office agent", "about": "CRM, orders, marketing and data-warehouse tools. Records are "
                    "never deleted; order and bulk changes wait for a person and say why; exports and campaigns run, "
                    "and the team is told.", "yaml": """\
  - id: back-office-never-delete-records
    action: deny
    reason: Deleting contacts, accounts, deals or orders is never an agent's call
    match:
      name: ["*delete_contact*", "*delete_account*", "*delete_deal*", "*delete_company*", "*delete_order*", "*purge*"]

  - id: back-office-order-changes
    action: review
    justification: required
    reason: Changes, cancels or re-prices a customer's order
    match:
      name: ["*cancel_order*", "*update_order*", "*order*price*", "*reship*", "*return*", "*chargeback*"]

  - id: back-office-bulk-changes
    action: review
    justification: required
    reason: Changes many records at once
    match:
      name: ["*bulk*", "*batch*", "*mass_update*", "*merge_contacts*", "*import*"]

  - id: back-office-campaigns
    action: review
    reason: Sends to customers (an email, SMS or push campaign)
    match:
      name: ["*send_campaign*", "*schedule_campaign*", "*send_sms*", "*broadcast*", "*send_newsletter*"]

  - id: back-office-exports
    action: notify
    reason: Customer data leaves the system (an export or a report download)
    match:
      name: ["*export*", "*download*", "*extract*", "*unload*"]

  - id: back-office-warehouse-writes
    action: review
    reason: Writes to the data warehouse (tables other teams read)
    match:
      name: ["*warehouse*", "*bigquery*", "*snowflake*", "*redshift*", "*dbt*"]
      input_regex: '\\b(insert|update|delete|merge|create|drop|alter|truncate)\\b'
"""},
    "crypto": {"title": "Crypto and wallets", "about": "Wallet keys and on-chain payments, for agents working where "
               "wallet keys live. Reading a key, signing or sending a transaction waits for a person.",
               "before": "allow-reads",   # reading a wallet key is a read, so this has to come before allow-reads
               "yaml": """\
  - id: crypto-wallet-keys
    action: review
    reason: Reads a wallet's private key, keystore or seed phrase
    match:
      name: ["read*", "file*", "cat*", "grep*", "bash*", "shell*", "powershell*", "run_command*"]
      input_regex: '(?i)keystore[\\\\/]|\\.keystore\\b|UTC--\\d|wallet\\.(dat|json)\\b|mnemonic\\.(txt|json)\\b|seed[-_ ]?phrase|solana[\\\\/]+id\\.json\\b'

  - id: crypto-sign-and-send
    action: review
    reason: Signs or sends a crypto transaction from the terminal
    timeout_seconds: 900
    match:
      name: ["bash*", "shell*", "powershell*", "run_command*"]
      input_regex: '\\bcast\\s+(send|publish|mktx|wallet\\s+sign)\\b|\\bbitcoin-cli\\b[^"&;|]*\\s(send\\w*|sign\\w*|walletpassphrase|dumpprivkey|dumpwallet)\\b|\\bsolana\\s+(transfer|pay|withdraw-stake|delegate-stake)\\b|\\bspl-token\\s+(transfer|burn|close)\\b|\\bsui\\s+client\\s+(pay\\w*|transfer\\w*)\\b|\\baptos\\s+account\\s+transfer\\b|\\bnear\\s+(send|tokens)\\b|\\bforge\\s+script\\b[^"&;|]*\\s--broadcast\\b|\\bhardhat\\s+run\\b[^"&;|]*\\s--network\\s+mainnet\\b'

  - id: crypto-agent-tools
    action: review
    reason: Moves crypto through an agent tool (a signed transaction, swap, trade, withdrawal, bridge or stake)
    timeout_seconds: 900
    match:
      name: ["*send_transaction*", "*sign_transaction*", "*sign_message*", "*swap*", "*trade*", "*withdraw*",
             "*bridge*", "*stake*", "*deploy_token*"]
"""},
}


def _ids(text: str) -> set[str]:
    return set(re.findall(r"^\s*-\s*id:\s*([\w.-]+)\s*$", text, re.M))


def replace_all(rules_path: Path, text: str) -> Path:
    """Write `text` as the whole rules file (an earlier version put back), keeping a backup. Returns the backup."""
    rules_path = Path(rules_path)
    backup = rules_path.with_name(f"{rules_path.name}.bak-{time.strftime('%Y%m%d-%H%M%S')}")
    shutil.copy2(rules_path, backup)
    rules_path.write_bytes(text.encode("utf-8"))       # bytes: the text is exactly what was fingerprinted
    return backup


def set_mode(rules_path: Path, mode: str) -> Path:
    """Switch the top-level `mode:` (enforce | shadow) in the rules file, keeping its comment and a backup."""
    rules_path = Path(rules_path)
    text = rules_path.read_text(encoding="utf-8")
    new, n = re.subn(r"^mode:[ \t]*[\w\"']*", f"mode: {mode}", text, count=1, flags=re.M)
    if not n:     # no top-level mode yet: put it right before `rules:`
        new, n = re.subn(r"^rules:[ \t]*$", f"mode: {mode}\n\nrules:", text, count=1, flags=re.M)
    if not n:
        raise ValueError("couldn't find `mode:` or `rules:` in the rules file")
    backup = rules_path.with_name(f"{rules_path.name}.bak-{time.strftime('%Y%m%d-%H%M%S')}")
    shutil.copy2(rules_path, backup)
    rules_path.write_text(new, encoding="utf-8")
    return backup


def insert(rules_path: Path, block: str, before_id: str | None, loads) -> Path:
    """Add `block` (rule items, indented for `rules:`) to the rules file: right before rule `before_id` if given, else
    right after allow-reads (so reads still run, and block rules above stay first), else at the top of `rules:`.
    Returns the backup. `loads(path)` must return the loaded rule ids, or raise."""
    rules_path = Path(rules_path)
    text = rules_path.read_text(encoding="utf-8")
    yaml.safe_load(block if block.lstrip().startswith("rules:") else "rules:\n" + block)   # the block parses alone
    clash = _ids(block) & _ids(text)
    if clash:
        raise ValueError(f"already in your rules: {', '.join(sorted(clash))}")
    lines = text.splitlines(keepends=True)
    rule_at = lambda rid: next((i for i, l in enumerate(lines) if re.match(rf"^\s*-\s*id:\s*{re.escape(rid)}\s*$", l)), None)
    at = rule_at(before_id) if before_id else None
    if at is None and (reads := rule_at("allow-reads")) is not None:
        # the line where the rule after allow-reads starts (or where the rules list ends)
        at = next((i for i in range(reads + 1, len(lines))
                   if re.match(r"^\s*-\s*id:", lines[i]) or re.match(r"^\S", lines[i])), len(lines))
        while at > reads + 1 and lines[at - 1].lstrip().startswith("#"):    # keep a rule's comment with it
            at -= 1
    if at is None:
        top = next((i for i, l in enumerate(lines) if re.match(r"^rules:\s*$", l)), None)
        if top is None:
            raise ValueError("couldn't find `rules:` in the rules file")
        at = top + 1
    backup = rules_path.with_name(f"{rules_path.name}.bak-{time.strftime('%Y%m%d-%H%M%S')}")
    shutil.copy2(rules_path, backup)
    rules_path.write_text("".join(lines[:at]) + block.rstrip("\n") + "\n\n" + "".join(lines[at:]), encoding="utf-8")
    try:
        if not _ids(block) <= set(loads(rules_path)):
            raise ValueError("the rules didn't load with the new block")
    except Exception:
        shutil.copy2(backup, rules_path)            # put it back as it was
        raise
    return backup