content_gate.py
Language Python5.0 KB151 lines
#!/usr/bin/env python3"""content_gate.py: optional content-level gate, run on staging after gate.py.gate.py walls off folders by path. This scans each staged file and removes it (or,in redact mode, replaces the tripped lines) when its frontmatter or a line trips arule, so a private line filed in an ordinary note is still caught before thedistiller reads it. It fails closed: a file it cannot read or decode is removed.Env: BM_STAGING_DIR (the staging directory gate.py writes, .../cowork-staging/staging)BM_GATE_MODE ("remove", the default, or "redact")"""import jsonimport osimport reimport sysfrom datetime import datetime, timezonefrom pathlib import PathSTAGING_DIR = os.environ.get("BM_STAGING_DIR","/Users/CHANGEME/cowork-staging/staging",)# Mode: "remove" pulls a tripped file out of staging entirely.# "redact" keeps the file but replaces tripped lines with a marker.GATE_MODE = os.environ.get("BM_GATE_MODE", "remove")MODES = ("remove", "redact")# The same suffixes gate.py stages. Scanning only *.md would let a staged .txt# through unchecked.SCAN_SUFFIXES = {".md", ".markdown", ".txt"}# Rules. A file whose frontmatter has any FRONTMATTER_BLOCK key: value pair is# gated whole, so adding "private: true" to a note keeps it out wherever it lives.# A LINE_PATTERNS match gates the whole file in remove mode and only the matching# line in redact mode.FRONTMATTER_BLOCK = [("private", "true"),("gate", "block"),("visibility", "private"),]LINE_PATTERNS = [# Obsidian-style inline private marker you can drop on any line:# %% bm:private %% ... whole file gated in remove modere.compile(r"%%\s*bm:private\s*%%", re.IGNORECASE),re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), # SSN-shaped]REDACTION_MARKER = "[[bm-gated]]"def parse_frontmatter(text: str) -> dict:"""Minimal YAML-frontmatter key:value reader. No external deps."""if not text.startswith("---"):return {}end = text.find("\n---", 3)if end == -1:return {}block = text[3:end]fm = {}for line in block.splitlines():if ":" in line:k, _, v = line.partition(":")fm[k.strip().lower()] = v.strip().strip("\"'").lower()return fmdef file_trips_frontmatter(fm: dict) -> bool:for key, val in FRONTMATTER_BLOCK:if fm.get(key) == val:return Truereturn Falsedef main() -> int:staging = Path(STAGING_DIR).expanduser()if "CHANGEME" in str(staging):print("content_gate: refusing to run with placeholder staging path.", file=sys.stderr)return 2if not staging.is_dir():print(f"content_gate: staging dir not found: {staging}", file=sys.stderr)return 2# An unknown mode must not quietly fall through to one of the others.if GATE_MODE not in MODES:print(f"content_gate: unknown BM_GATE_MODE {GATE_MODE!r} (use remove or redact).",file=sys.stderr)return 2removed, redacted, passed = [], [], 0for path in sorted(p for p in staging.iterdir()if p.is_file() and p.suffix.lower() in SCAN_SUFFIXES):try:text = path.read_text(encoding="utf-8")except (OSError, UnicodeDecodeError):# Fail closed: a file we cannot read or decode gets pulled. A decode# error is not an OSError, and letting it escape would crash the run# with the rest of staging unscanned.path.unlink(missing_ok=True)removed.append(path.name + " (unreadable, fail-closed)")continuefm = parse_frontmatter(text)tripped_whole = file_trips_frontmatter(fm)line_hits = []for i, line in enumerate(text.splitlines()):if any(pat.search(line) for pat in LINE_PATTERNS):line_hits.append(i)if GATE_MODE == "remove":if tripped_whole or line_hits:path.unlink(missing_ok=True)removed.append(path.name)else:passed += 1else: # redactif tripped_whole:path.unlink(missing_ok=True)removed.append(path.name + " (frontmatter-blocked)")elif line_hits:lines = text.splitlines()for i in line_hits:lines[i] = REDACTION_MARKERpath.write_text("\n".join(lines), encoding="utf-8")redacted.append(path.name)else:passed += 1report = {"run_utc": datetime.now(timezone.utc).isoformat(),"mode": GATE_MODE,"passed": passed,"removed": removed,"redacted": redacted,}(staging / "gate-report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")print(f"content_gate: mode={GATE_MODE} passed={passed} "f"removed={len(removed)} redacted={len(redacted)}")return 0if __name__ == "__main__":raise SystemExit(main())