mattrguerra/better-memory-pipelinemainPrivate on GitHub, published here in full

content_gate.py

Language Python5.0 KB151 lines

content_gate.py

  1. #!/usr/bin/env python3
  2. """
  3. content_gate.py: optional content-level gate, run on staging after gate.py.
  4. gate.py walls off folders by path. This scans each staged file and removes it (or,
  5. in redact mode, replaces the tripped lines) when its frontmatter or a line trips a
  6. rule, so a private line filed in an ordinary note is still caught before the
  7. distiller reads it. It fails closed: a file it cannot read or decode is removed.
  8. Env: BM_STAGING_DIR (the staging directory gate.py writes, .../cowork-staging/staging)
  9. BM_GATE_MODE ("remove", the default, or "redact")
  10. """
  11. import json
  12. import os
  13. import re
  14. import sys
  15. from datetime import datetime, timezone
  16. from pathlib import Path
  17. STAGING_DIR = os.environ.get(
  18. "BM_STAGING_DIR",
  19. "/Users/CHANGEME/cowork-staging/staging",
  20. )
  21. # Mode: "remove" pulls a tripped file out of staging entirely.
  22. # "redact" keeps the file but replaces tripped lines with a marker.
  23. GATE_MODE = os.environ.get("BM_GATE_MODE", "remove")
  24. MODES = ("remove", "redact")
  25. # The same suffixes gate.py stages. Scanning only *.md would let a staged .txt
  26. # through unchecked.
  27. SCAN_SUFFIXES = {".md", ".markdown", ".txt"}
  28. # Rules. A file whose frontmatter has any FRONTMATTER_BLOCK key: value pair is
  29. # gated whole, so adding "private: true" to a note keeps it out wherever it lives.
  30. # A LINE_PATTERNS match gates the whole file in remove mode and only the matching
  31. # line in redact mode.
  32. FRONTMATTER_BLOCK = [
  33. ("private", "true"),
  34. ("gate", "block"),
  35. ("visibility", "private"),
  36. ]
  37. LINE_PATTERNS = [
  38. # Obsidian-style inline private marker you can drop on any line:
  39. # %% bm:private %% ... whole file gated in remove mode
  40. re.compile(r"%%\s*bm:private\s*%%", re.IGNORECASE),
  41. re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), # SSN-shaped
  42. ]
  43. REDACTION_MARKER = "[[bm-gated]]"
  44. def parse_frontmatter(text: str) -> dict:
  45. """Minimal YAML-frontmatter key:value reader. No external deps."""
  46. if not text.startswith("---"):
  47. return {}
  48. end = text.find("\n---", 3)
  49. if end == -1:
  50. return {}
  51. block = text[3:end]
  52. fm = {}
  53. for line in block.splitlines():
  54. if ":" in line:
  55. k, _, v = line.partition(":")
  56. fm[k.strip().lower()] = v.strip().strip("\"'").lower()
  57. return fm
  58. def file_trips_frontmatter(fm: dict) -> bool:
  59. for key, val in FRONTMATTER_BLOCK:
  60. if fm.get(key) == val:
  61. return True
  62. return False
  63. def main() -> int:
  64. staging = Path(STAGING_DIR).expanduser()
  65. if "CHANGEME" in str(staging):
  66. print("content_gate: refusing to run with placeholder staging path.", file=sys.stderr)
  67. return 2
  68. if not staging.is_dir():
  69. print(f"content_gate: staging dir not found: {staging}", file=sys.stderr)
  70. return 2
  71. # An unknown mode must not quietly fall through to one of the others.
  72. if GATE_MODE not in MODES:
  73. print(f"content_gate: unknown BM_GATE_MODE {GATE_MODE!r} (use remove or redact).",
  74. file=sys.stderr)
  75. return 2
  76. removed, redacted, passed = [], [], 0
  77. for path in sorted(p for p in staging.iterdir()
  78. if p.is_file() and p.suffix.lower() in SCAN_SUFFIXES):
  79. try:
  80. text = path.read_text(encoding="utf-8")
  81. except (OSError, UnicodeDecodeError):
  82. # Fail closed: a file we cannot read or decode gets pulled. A decode
  83. # error is not an OSError, and letting it escape would crash the run
  84. # with the rest of staging unscanned.
  85. path.unlink(missing_ok=True)
  86. removed.append(path.name + " (unreadable, fail-closed)")
  87. continue
  88. fm = parse_frontmatter(text)
  89. tripped_whole = file_trips_frontmatter(fm)
  90. line_hits = []
  91. for i, line in enumerate(text.splitlines()):
  92. if any(pat.search(line) for pat in LINE_PATTERNS):
  93. line_hits.append(i)
  94. if GATE_MODE == "remove":
  95. if tripped_whole or line_hits:
  96. path.unlink(missing_ok=True)
  97. removed.append(path.name)
  98. else:
  99. passed += 1
  100. else: # redact
  101. if tripped_whole:
  102. path.unlink(missing_ok=True)
  103. removed.append(path.name + " (frontmatter-blocked)")
  104. elif line_hits:
  105. lines = text.splitlines()
  106. for i in line_hits:
  107. lines[i] = REDACTION_MARKER
  108. path.write_text("\n".join(lines), encoding="utf-8")
  109. redacted.append(path.name)
  110. else:
  111. passed += 1
  112. report = {
  113. "run_utc": datetime.now(timezone.utc).isoformat(),
  114. "mode": GATE_MODE,
  115. "passed": passed,
  116. "removed": removed,
  117. "redacted": redacted,
  118. }
  119. (staging / "gate-report.json").write_text(
  120. json.dumps(report, indent=2), encoding="utf-8"
  121. )
  122. print(
  123. f"content_gate: mode={GATE_MODE} passed={passed} "
  124. f"removed={len(removed)} redacted={len(redacted)}"
  125. )
  126. return 0
  127. if __name__ == "__main__":
  128. raise SystemExit(main())