integrity.py
Rama main · réplica de lectura del repositorio original.
"""
Has anything changed Squidbrake underneath it? On start and every minute the gateway compares, with the copy it
made the first time (data/integrity.json):
hooks Squidbrake's own hook lines in each agent's config (other settings in those files may change freely)
package the installed program's files (a pip / uv / pipx install; a checkout being worked on isn't checked)
rules rules.yaml (a person edits it, so this is only recorded; the policy history keeps every version anyway)
keys keys.json, secret.key and ledger.key: Squidbrake's own changes (Team, `add-key`) are noted as they happen;
any other change is tampering (alert, audit entry, and with fail_closed risky actions stop)
Not alerts, only recorded and taken as the new normal: an agent newly connected (its hook added), an upgrade (the
version changed, so the package's files did too), and rules.yaml edits.
A change is put on the record (integrity.changed), sent wherever approvals go, and, with rules.yaml
`integrity: fail_closed`, actions that can't be undone are blocked until a person runs `squidbrake integrity accept`.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
NAME = "integrity.json"
KEY_FILES = ("keys.json", "secret.key", "ledger.key") # who may approve, the link secret, the ledger key
def _sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def hook_lines(path: Path) -> str | None:
"""The Squidbrake hook commands in an agent's config, fingerprinted (None: no such file)."""
import connect
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
except FileNotFoundError:
return None
except (OSError, ValueError):
return "unreadable"
ours = connect._hook_commands(data)
return _sha("\n".join(sorted(ours)).encode()) if ours else None # no hook of ours: like no file
def current(rules: Path, package: Path | None, hooks: dict[str, Path]) -> dict[str, dict[str, str | None]]:
state: dict[str, dict[str, str | None]] = {"hooks": {}, "package": {}, "rules": {}}
for agent, path in hooks.items():
state["hooks"][agent] = hook_lines(path)
if package is not None:
for f in sorted(package.rglob("*.py")):
if "__pycache__" not in f.parts:
state["package"][f.relative_to(package).as_posix()] = _sha(f.read_bytes())
try:
state["rules"]["rules.yaml"] = _sha(rules.read_bytes().replace(b"\r\n", b"\n"))
except OSError:
state["rules"]["rules.yaml"] = None
return state
def keys_state(data_dir: Path) -> dict[str, str | None]:
out: dict[str, str | None] = {}
for f in KEY_FILES:
try:
out[f] = _sha((data_dir / f).read_bytes())
except OSError:
out[f] = None
return out
def note_keys(data_dir: Path) -> None:
"""Squidbrake itself just changed its keys (Team, `squidbrake add-key`): that is the new normal, not tampering."""
path = data_dir / NAME
try:
was = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return
was["keys"] = keys_state(data_dir)
path.write_text(json.dumps(was, indent=1), encoding="utf-8")
def compare(data_dir: Path, now: dict) -> list[dict]:
"""What differs from the copy kept the first time (made now if there is none). -> [{kind, what, change}]"""
path = data_dir / NAME
try:
was = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
accept(data_dir, now)
return []
if "keys" in now and "keys" not in was: # a copy kept before keys were watched: start now
was["keys"] = now["keys"]
accept(data_dir, was)
out = []
upgraded = was.get("version") != now.get("version") # a new release replaces the package's files
for kind in ("hooks", "package", "rules", "keys"):
if kind == "package" and upgraded:
continue
before, after = was.get(kind) or {}, now.get(kind) or {}
for what in sorted(set(before) | set(after)):
if before.get(what) != after.get(what):
change = "removed" if after.get(what) is None else "added" if before.get(what) is None else "changed"
out.append({"kind": kind, "what": what, "change": change})
return out
def accept(data_dir: Path, now: dict) -> None:
data_dir.mkdir(parents=True, exist_ok=True)
(data_dir / NAME).write_text(json.dumps(now, indent=1), encoding="utf-8")
def describe(changes: list[dict]) -> str:
words = {"hooks": "Squidbrake's hook in", "package": "the installed file", "rules": "", "keys": "Squidbrake's key file"}
return "; ".join(f"{words[c['kind']]} {c['what']} {c['change']}".strip() for c in changes[:8]) + \
(f" and {len(changes) - 8} more" if len(changes) > 8 else "")