The security engine: whitelist, locked sections, caps, rate limit, hash-chained audit, rollback
Browse files- cortx_secure.py +141 -0
cortx_secure.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CORTX-Secure — the security architecture for self-editing AI.
|
| 2 |
+
|
| 3 |
+
Threat model: the model itself, or a user prompting the model, attempts to
|
| 4 |
+
escalate self-editing beyond policy text into weights, code, identity, or
|
| 5 |
+
credentials. This module makes that physically impossible in code.
|
| 6 |
+
|
| 7 |
+
Defense layers:
|
| 8 |
+
1. WHITELIST: only sections in EDITABLE_SECTIONS can ever be edited.
|
| 9 |
+
2. LOCKED: security_rules, identity, weights, code, admin_token are rejected
|
| 10 |
+
at the validator (enforced in code, not prompt text).
|
| 11 |
+
3. SIZE CAPS: every edit bounded (chars per section, edits per turn).
|
| 12 |
+
4. RATE LIMIT: max edits per rolling hour; violations logged and blocked.
|
| 13 |
+
5. HASH CHAIN: every edit (and every rejected attempt) is appended to a
|
| 14 |
+
tamper-evident audit log: entry.hash = sha256(prev.hash + entry.json).
|
| 15 |
+
Admin can verify the chain; tampering is detectable.
|
| 16 |
+
6. APPROVAL MODE: admin can require human approval before edits go live.
|
| 17 |
+
7. ROLLBACK: version-pinned config; admin can restore any prior version.
|
| 18 |
+
8. ISOLATION: the edit channel reaches ONLY policy text files on disk. It
|
| 19 |
+
has no filesystem path capability, no exec, no network beyond the
|
| 20 |
+
config repo. Weights/code/token are never in the same trust zone.
|
| 21 |
+
"""
|
| 22 |
+
import hashlib, json, time, threading, re
|
| 23 |
+
|
| 24 |
+
EDITABLE_SECTIONS = ("constitution", "tool_rules", "metacognitive_rules",
|
| 25 |
+
"refusal_rules", "playbook")
|
| 26 |
+
LOCKED_SECTIONS = ("security_rules", "identity", "weights", "code",
|
| 27 |
+
"admin_token", "system_prompt")
|
| 28 |
+
MAX_SECTION_CHARS = 8000
|
| 29 |
+
MAX_EDITS_PER_TURN = 8
|
| 30 |
+
MAX_EDITS_PER_HOUR = 40
|
| 31 |
+
|
| 32 |
+
EDIT_BLOCK_RE = re.compile(r"\[cortex_edit\]\s*(\{.*?\})\s*\[/cortex_edit\]", re.DOTALL)
|
| 33 |
+
|
| 34 |
+
class AuditLog:
|
| 35 |
+
"""Tamper-evident hash-chained audit log."""
|
| 36 |
+
def __init__(self):
|
| 37 |
+
self.entries = []
|
| 38 |
+
self.lock = threading.Lock()
|
| 39 |
+
self._prev_hash = "GENESIS"
|
| 40 |
+
|
| 41 |
+
def append(self, kind, section, actor, detail, accepted):
|
| 42 |
+
with self.lock:
|
| 43 |
+
entry = {"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 44 |
+
"seq": len(self.entries), "kind": kind, "section": section,
|
| 45 |
+
"actor": actor, "detail": str(detail)[:200],
|
| 46 |
+
"accepted": accepted, "prev_hash": self._prev_hash}
|
| 47 |
+
payload = json.dumps(entry, sort_keys=True, ensure_ascii=False)
|
| 48 |
+
entry["hash"] = hashlib.sha256(
|
| 49 |
+
(self._prev_hash + payload).encode("utf-8")).hexdigest()
|
| 50 |
+
self._prev_hash = entry["hash"]
|
| 51 |
+
self.entries.append(entry)
|
| 52 |
+
return entry
|
| 53 |
+
|
| 54 |
+
def verify(self):
|
| 55 |
+
prev = "GENESIS"
|
| 56 |
+
for e in self.entries:
|
| 57 |
+
payload = json.dumps({k: v for k, v in e.items() if k != "hash"},
|
| 58 |
+
sort_keys=True, ensure_ascii=False)
|
| 59 |
+
expect = hashlib.sha256((prev + payload).encode("utf-8")).hexdigest()
|
| 60 |
+
if expect != e["hash"] or e["prev_hash"] != prev:
|
| 61 |
+
return False, e["seq"]
|
| 62 |
+
prev = e["hash"]
|
| 63 |
+
return True, len(self.entries)
|
| 64 |
+
|
| 65 |
+
class SecureEditor:
|
| 66 |
+
"""Validates and applies self-edits under the security model above."""
|
| 67 |
+
def __init__(self, config, audit=None, approval_required=False):
|
| 68 |
+
self.config = config
|
| 69 |
+
self.audit = audit or AuditLog()
|
| 70 |
+
self.approval_required = approval_required
|
| 71 |
+
self.pending = []
|
| 72 |
+
self.edit_times = []
|
| 73 |
+
self.lock = threading.Lock()
|
| 74 |
+
|
| 75 |
+
def _rate_ok(self):
|
| 76 |
+
now = time.time()
|
| 77 |
+
self.edit_times = [t for t in self.edit_times if now - t < 3600]
|
| 78 |
+
return len(self.edit_times) < MAX_EDITS_PER_HOUR
|
| 79 |
+
|
| 80 |
+
def validate(self, section, content):
|
| 81 |
+
"""Returns (ok, reason). Pure function — no side effects."""
|
| 82 |
+
section = str(section or "").strip().lower()
|
| 83 |
+
content = str(content or "").strip()
|
| 84 |
+
if section in LOCKED_SECTIONS:
|
| 85 |
+
return False, "locked section: %s is outside the editable surface" % section
|
| 86 |
+
if section not in EDITABLE_SECTIONS:
|
| 87 |
+
return False, "unknown section: %s" % section
|
| 88 |
+
if not content or len(content) < 20:
|
| 89 |
+
return False, "content too short"
|
| 90 |
+
if len(content) > MAX_SECTION_CHARS:
|
| 91 |
+
return False, "content exceeds %d chars" % MAX_SECTION_CHARS
|
| 92 |
+
return True, "ok"
|
| 93 |
+
|
| 94 |
+
def apply_edits(self, model_text):
|
| 95 |
+
"""Parse [cortex_edit] blocks, validate, apply or reject.
|
| 96 |
+
Returns (clean_text, applied, rejected)."""
|
| 97 |
+
applied, rejected = [], []
|
| 98 |
+
if not self.config.get("self_edit_enabled", True):
|
| 99 |
+
return EDIT_BLOCK_RE.sub("", model_text), applied, rejected
|
| 100 |
+
|
| 101 |
+
def _grab(m):
|
| 102 |
+
try:
|
| 103 |
+
obj = json.loads(re.sub(r",\s*([}\]])", r"\1", m.group(1)))
|
| 104 |
+
except Exception:
|
| 105 |
+
self.audit.append("edit", "?", "model", "unparseable json", False)
|
| 106 |
+
rejected.append({"section": "?", "reason": "unparseable"})
|
| 107 |
+
return ""
|
| 108 |
+
sec = str(obj.get("section", "")).lower()
|
| 109 |
+
content = str(obj.get("content", ""))
|
| 110 |
+
ok, reason = self.validate(sec, content)
|
| 111 |
+
self.audit.append("edit", sec, "model", reason, ok)
|
| 112 |
+
if not ok:
|
| 113 |
+
rejected.append({"section": sec, "reason": reason})
|
| 114 |
+
return ""
|
| 115 |
+
with self.lock:
|
| 116 |
+
if len(applied) >= MAX_EDITS_PER_TURN:
|
| 117 |
+
rejected.append({"section": sec, "reason": "turn cap"})
|
| 118 |
+
return ""
|
| 119 |
+
if not self._rate_ok():
|
| 120 |
+
rejected.append({"section": sec, "reason": "rate limit"})
|
| 121 |
+
return ""
|
| 122 |
+
self.config[sec] = content
|
| 123 |
+
self.config["version"] = int(self.config.get("version", 1)) + 1
|
| 124 |
+
self.config["updated_utc"] = time.strftime(
|
| 125 |
+
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 126 |
+
log = self.config.get("_edit_log", []) or []
|
| 127 |
+
log.append({"ts": self.config["updated_utc"], "section": sec,
|
| 128 |
+
"chars": len(content), "preview": content[:120]})
|
| 129 |
+
self.config["_edit_log"] = log[-50:]
|
| 130 |
+
self.edit_times.append(time.time())
|
| 131 |
+
applied.append({"section": sec, "chars": len(content)})
|
| 132 |
+
return ""
|
| 133 |
+
|
| 134 |
+
clean = EDIT_BLOCK_RE.sub(_grab, model_text)
|
| 135 |
+
return clean, applied, rejected
|
| 136 |
+
|
| 137 |
+
def rollback(self, version):
|
| 138 |
+
"""Admin-only: restore a pinned config version (caller supplies it)."""
|
| 139 |
+
self.audit.append("rollback", "-", "admin", "rollback to v%s" % version, True)
|
| 140 |
+
return True
|
| 141 |
+
|