gate.py
Language Python13.8 KB384 lines
#!/usr/bin/env python3"""gate.py: the deterministic path gate in front of the better-memory distiller.A plain script, not a model. Each run wipes STAGING_DIR/staging, walks the vaultswith a fail-closed denylist (checked after resolving traversal and symlinks), skipsvault scaffolding, hashes each safe note's post-gate content and stages only thenotes whose hash differs from the manifest. A manifest of content hashes, ratherthan a record of seen notes, means an edited note always re-stages.Env: VAULT_ROOTS (colon-separated vault paths), STAGING_DIR (staging root, default~/cowork-staging), EXTRA_DENY (comma-separated extra denied prefixes, optional)."""from __future__ import annotationsimport osimport reimport jsonimport shutilimport hashlibfrom pathlib import Path# --- ConfigurationVAULT_ROOTS = [Path(p).resolve()for p in os.environ.get("VAULT_ROOTS", "").split(":")if p.strip()]STAGING_ROOT = Path(os.environ.get("STAGING_DIR", str(Path.home() / "cowork-staging"))).resolve()STAGING_DIR = STAGING_ROOT / "staging"MANIFEST_PATH = STAGING_ROOT / ".manifest.json"# Fail-closed denylist. Prefixes are anchored to a vault root; components match a# folder name at any depth. The destination is a private database only the owner# reads, so the list covers genuinely private content; research folders may stage._BASE_DENY_PREFIXES = ["_private", # the wall"efforts/finance", # financial records"efforts/journal", # the personal journal, whole tree]DENY_COMPONENTS = [".obsidian", # plugin configs, which can hold API keys: root and nested vaults".git", # repo internals, at any depth (nested vaults can be repos)".trash", # Obsidian deleted-notes, any depth"_excalidraw", # excalidraw scratch dirs, any depth".basic-memory", # Basic Memory's SQLite index + internal state".venv", # virtualenv noise]_EXTRA_DENY = [p.strip().lower().strip("/")for p in os.environ.get("EXTRA_DENY", "").split(",")if p.strip()]DENY_PREFIXES = _BASE_DENY_PREFIXES + _EXTRA_DENY# Only these are ever staged.STAGE_SUFFIXES = {".md", ".markdown", ".txt"}# --- Scaffolding: vault machinery that is not memory about the owner## Operational instructions, changelogs, model guides, run logs and templates are# not facts about the owner, and distilling them pollutes recall. They are skipped,# except that each nested vault keeps one explainer (see EXPLAINER_PRIORITY) so it# still contributes a "what is this vault for" note.# Exact filenames (case-insensitive, basename match) that are always scaffolding.SCAFFOLD_FILENAMES = {"heartbeat.md", "setup.md", "model_guide.md", "changelog.md","skills_reference.md", "subagents.md", "memory.md","knowledge_index.md", "inventory_index.md", "topics_index.md","sources_index.md",# Project plumbing. decisions.md is kept for _meta only; see SCAFFOLD_KEEP_PATHS."decisions.md", "skill.md", "agent.md", "automation.md","backlog.md", "investigation-plan.md","test.md", "scratch.md", "url-inventory.md",}# Also scaffolding by pattern: per-folder instruction files (claude.md, claude-*.md),# index files (NN-index.md, *_index.md, *-index.md), done and deprecated notes, probe# files and bare-date daily notes. A vault's chosen explainer is kept regardless.def _is_scaffold_name(basename: str) -> bool:b = basename.lower()if b in SCAFFOLD_FILENAMES:return Trueif b == "claude.md" or b.startswith("claude-"):return Trueif re.match(r"^\d{2}-index\.md$", b) or b.endswith("_index.md") or b.endswith("-index.md"):return Trueif "deprecated" in b:return Trueif b.endswith("-block-done.md") or b.endswith("-done.md"):return Trueif re.match(r"^probe\d*\.txt$", b):return True# bare date-named daily scratch at any level (e.g. 2026-06-17.md), but not# dated content notes that have a descriptive suffix (those keep their words)if re.match(r"^\d{4}-\d{2}-\d{2}\.md$", b):return Truereturn False# Directory segments whose notes are templates, run logs, session transcripts or# scratch. A note whose vault-relative path contains one is skipped wholesale.SCAFFOLD_PATH_FRAGMENTS = ("/_templates/","/08 - templates/","/02-run-logs/","/01-sessions/","/initiation-prompts/","/_scratch/","/_scratch-junk/",)# A scaffolding-named file is kept when its lowercased vault-relative path ends with# one of these: decisions.md is plumbing everywhere except the _meta identity layer,# where it records the owner's system decisions.SCAFFOLD_KEEP_PATHS = ("_meta/decisions.md",)# When picking the single explainer note for a nested vault, prefer in this order.EXPLAINER_PRIORITY = ("readme.md", "00-index.md", "claude.md")# A "nested vault" for the one-explainer rule = an immediate child directory of# one of these parents (relative to a vault root). Each such folder keeps exactly# one explainer; the root vault itself also keeps one.EXPLAINER_PARENT_DIRS = ("efforts", "atlas")# --- Section gate: stage only a note's framing when it mixes in sensitive detail## Off by design: both lists are empty, so every note stages whole. Private material# is already path-denied before it reaches this point, and the patterns only ever# truncated benign notes. Adding a pattern or a link name re-enables it.def _alternation_or_never(patterns: list[str], before: str = "",after: str = "") -> re.Pattern[str]:"""Compile `before + "|".join(patterns) + after`, case-insensitive."""# Guard: an empty alternation matches everywhere (for the link pattern, every# "[["), framing everything. When the list is empty, use a pattern that can# never match.if not patterns:return re.compile(r"(?!x)x")return re.compile(before + "|".join(patterns) + after, re.IGNORECASE)SENSITIVE_PATTERNS: list[str] = []_SENSITIVE_RE = _alternation_or_never(SENSITIVE_PATTERNS)# Wikilink targets whose presence marks a note as mixed.EXCLUDED_LINK_NAMES: list[str] = []_EXCLUDED_LINK_RE = _alternation_or_never([re.escape(n) for n in EXCLUDED_LINK_NAMES], before=r"\[\[\s*(", after=r")")# First structured heading: framing is everything before it._FIRST_SECTION_RE = re.compile(r"^##\s+(observations|relations)\b", re.IGNORECASE | re.MULTILINE)# --- The path gateclass PathDenied(Exception):passdef _is_denied(rel: str) -> bool:"""True if the lowercased vault-relative path is, or sits under, a denied prefix."""return any(rel == deny or rel.startswith(deny + "/") for deny in DENY_PREFIXES)def _resolve_safe(root: Path, rel: str) -> Path:"""Resolve a vault-relative path and fail closed on traversal, symlink or deny."""rel_norm = rel.replace("\\", "/").lstrip("/")candidate = (root / rel_norm).resolve()try:candidate.relative_to(root)except ValueError:raise PathDenied(f"escapes root: {rel!r}")rel_from_root = candidate.relative_to(root).as_posix().lower()if _is_denied(rel_from_root):raise PathDenied(f"denied prefix: {rel!r}")if set(rel_from_root.split("/")) & set(DENY_COMPONENTS):raise PathDenied(f"denied component: {rel!r}")return candidatedef _iter_safe_files(root: Path):"""Walk one vault root, pruning denied dirs, yielding gate-passing notes."""deny_components = set(DENY_COMPONENTS)for dirpath, dirnames, filenames in os.walk(root):here = Path(dirpath)rel_dir = here.relative_to(root).as_posix().lower()rel_dir = "" if rel_dir == "." else rel_dirkept = []for d in dirnames:if d.lower() in deny_components:continueif _is_denied(f"{rel_dir}/{d}".lstrip("/").lower()):continuekept.append(d)dirnames[:] = keptfor fn in filenames:fp = here / fnif fp.suffix.lower() not in STAGE_SUFFIXES:continuetry:_resolve_safe(root, fp.relative_to(root).as_posix())except (PathDenied, ValueError):continueyield fpdef _self_check(root: Path) -> None:"""Refuse to run unless every known-private probe is denied."""for probe in ("_private", "_Private", "Efforts/finance","Efforts/Journal/anything.md", ".obsidian","Efforts/some-vault/.obsidian/x.md", ".basic-memory"):try:_resolve_safe(root, probe)except PathDenied:passelse:raise SystemExit(f"DENYLIST FAILED to block {probe!r}: refusing to run.")# --- Section gate: whole note or framing onlydef _framing_only(text: str) -> str:"""Return the H1 and framing prose before the first ## Observations/Relations,capped at 1500 characters when there is no such heading, with excluded-notewikilinks redacted."""m = _FIRST_SECTION_RE.search(text)framing = text[: m.start()] if m else text[:1500]framing = _EXCLUDED_LINK_RE.sub("[[redacted", framing)return framing.rstrip() + "\n"def _gate_content(text: str) -> str:"""Return the note whole, or its framing only if it matches a sensitive patternor links to an excluded note."""mixed = bool(_SENSITIVE_RE.search(text)) or bool(_EXCLUDED_LINK_RE.search(text))return _framing_only(text) if mixed else text# --- Scaffolding skip and one explainer per vaultdef _vault_key(vault_name: str, rel_posix: str) -> str:"""The explainer bucket for a note: `<vault>/Efforts/<X>` or `<vault>/Atlas/<X>`for notes inside a nested vault, otherwise the root vault's name."""parts = rel_posix.split("/")low = [p.lower() for p in parts]if len(parts) >= 2 and low[0] in EXPLAINER_PARENT_DIRS:return f"{vault_name}/{parts[0]}/{parts[1]}"return vault_name # root-level vault bucketdef _has_path_fragment_skip(rel_posix: str) -> bool:low = "/" + rel_posix.lower() + "/"return any(frag in low for frag in SCAFFOLD_PATH_FRAGMENTS)def _choose_explainers(safe_files):"""Given (vault_name, fp, rel_posix) tuples, pick one explainer per vault bucketin EXPLAINER_PRIORITY order. Returns the manifest keys to keep even thoughthey would otherwise be skipped as scaffolding."""by_vault: dict[str, dict[str, str]] = {}for vault_name, fp, rel in safe_files:base = fp.name.lower()if base in EXPLAINER_PRIORITY:vk = _vault_key(vault_name, rel)by_vault.setdefault(vk, {})[base] = f"{vault_name}/{rel}"keep = set()for vk, candidates in by_vault.items():for pref in EXPLAINER_PRIORITY:if pref in candidates:keep.add(candidates[pref])breakreturn keep# --- Manifestdef _load_manifest() -> dict:if MANIFEST_PATH.exists():try:return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))except (OSError, json.JSONDecodeError):return {}return {}def _save_manifest(m: dict) -> None:MANIFEST_PATH.write_text(json.dumps(m, indent=2, sort_keys=True), encoding="utf-8")def _hash(s: str) -> str:return hashlib.sha256(s.encode("utf-8")).hexdigest()# --- Maindef main() -> int:if not VAULT_ROOTS:raise SystemExit("Set VAULT_ROOTS (colon-separated absolute vault paths).")for r in VAULT_ROOTS:if not r.is_dir():raise SystemExit(f"Not a directory: {r}")_self_check(VAULT_ROOTS[0])# 1. Wipe staging, so the distiller never reads a previous run's leftovers.if STAGING_DIR.exists():shutil.rmtree(STAGING_DIR)STAGING_DIR.mkdir(parents=True, exist_ok=True)manifest = _load_manifest()new_manifest: dict = {}staged = skipped = framed = scaffold_skipped = 0# Pre-pass: collect all safe files, then resolve the one explainer per vault.safe_files = []for root in VAULT_ROOTS:vault_name = root.namefor fp in _iter_safe_files(root):rel = fp.relative_to(root).as_posix()safe_files.append((vault_name, fp, rel))explainer_keep = _choose_explainers(safe_files)# 2-3. Walk, apply scaffolding skip, gate, hash, compare, stage new/changed.for vault_name, fp, rel in safe_files:key = f"{vault_name}/{rel}"# Path-fragment skips (templates, run logs) are absolute: never kept, even# if named like an explainer. Other scaffolding is kept only as the vault's# chosen explainer or a SCAFFOLD_KEEP_PATHS exception.if _has_path_fragment_skip(rel):scaffold_skipped += 1continueif _is_scaffold_name(fp.name) and key not in explainer_keep:rel_low = rel.lower()if not any(rel_low.endswith(p) for p in SCAFFOLD_KEEP_PATHS):scaffold_skipped += 1continuetry:raw = fp.read_text(encoding="utf-8", errors="ignore")except OSError:continuecontent = _gate_content(raw)if not content.strip():continueif content != raw:framed += 1# Hash what is staged, so a change the gate strips never re-stages a note.h = _hash(content)new_manifest[key] = hif manifest.get(key) == h:skipped += 1continuesafe_name = key.replace("/", "__")(STAGING_DIR / safe_name).write_text(content, encoding="utf-8")staged += 1# 4. The manifest holds only this run's notes, so deleted notes drop out of it._save_manifest(new_manifest)print(f"gate: staged={staged} skipped={skipped} framing-only={framed} "f"scaffold-skipped={scaffold_skipped} total-staged={len(new_manifest)} "f"-> {STAGING_DIR}")return 0if __name__ == "__main__":raise SystemExit(main())