personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / scripts / check_journal_io_access.py
14 kB 394 lines
1#!/usr/bin/env python3 2# SPDX-License-Identifier: AGPL-3.0-only 3# Copyright (c) 2026 sol pbc 4 5"""Journal I/O write-primitive access lint. 6 7This check is CI mechanics, not a permissions model. The shared 8``solstone.think.journal_io`` write and lock primitives are mechanisms; the L2 9domain write-owner set is the policy. A non-owner module violates the rule only 10when it imports one of the gated journal_io primitives and calls through that 11import binding. 12 13Policy source: ``AGENTS.md`` §7, L2 — Domain write ownership. This script 14transcribes that owner set into path exclusions so the check can enforce the 15policy without restating it in prose. 16 17Design decisions: 18 19 D1 — Import-binding resolution. Bare-name matching is forbidden. For every 20 scanned module, the detector first records imports that bind names or module 21 aliases to ``solstone.think.journal_io`` or its write-primitive submodules, 22 then flags calls only when the call target resolves through one of those 23 bindings. 24 25 D2 — Allowed callers. The journal_io package itself and the L2 domain 26 write-owner files/directories are excluded during module discovery, so owner 27 modules are never scanned or counted as violations. 28 29 D3 — Violation kind and message. The violation ``kind`` is the primitive name 30 itself, and every failure line names both the file and primitive. 31 32 D4 — Script home and scope. This is a standalone sibling check for 33 journal_io access. It intentionally does not modify 34 ``scripts/check_layer_hygiene.py``. 35 36The check ships green via a committed ``ALLOWLIST`` keyed by ``(file, kind)`` 37with an allowed **count**. A new violation that pushes any ``(file, kind)`` 38count above its allowed number fails the check; fixing occurrences lets the 39allowed count be lowered, so the allowlist ratchets toward empty. It is never 40keyed by line number and never a blanket per-file disable. 41 42Exit codes: 43 0 — no un-allowlisted violations 44 1 — a (file, kind) count exceeds its allowlisted number 45""" 46 47from __future__ import annotations 48 49import argparse 50import ast 51import sys 52from pathlib import Path 53 54ROOT = Path(__file__).resolve().parent.parent 55 56GATED_PRIMITIVES: frozenset[str] = frozenset( 57 { 58 "append_jsonl", 59 "append_text", 60 "atomic_replace", 61 "hold_lock", 62 "install_file", 63 "save_npz", 64 "update_npz", 65 "write_json", 66 "write_jsonl", 67 "write_npz", 68 "write_text", 69 } 70) 71 72MODULE_PRIMITIVES: dict[str, frozenset[str]] = { 73 "solstone.think.journal_io": GATED_PRIMITIVES, 74 "solstone.think.journal_io.append": frozenset({"append_jsonl", "append_text"}), 75 "solstone.think.journal_io.atomic": frozenset( 76 {"atomic_replace", "install_file", "write_json", "write_jsonl", "write_text"} 77 ), 78 "solstone.think.journal_io.locking": frozenset({"hold_lock"}), 79 "solstone.think.journal_io.npz": frozenset({"save_npz", "update_npz", "write_npz"}), 80} 81 82OWNER_FILES: frozenset[str] = frozenset( 83 { 84 "solstone/apps/chat/config.py", 85 "solstone/convey/config.py", 86 "solstone/apps/speakers/attribution.py", 87 "solstone/apps/speakers/candidate_tracker.py", 88 "solstone/apps/speakers/discovery.py", 89 "solstone/apps/speakers/owner.py", 90 "solstone/apps/speakers/routes.py", 91 "solstone/apps/settings/vertex_credentials.py", 92 "solstone/apps/timeline/maintenance.py", 93 "solstone/apps/timeline/talent/segment_summary.py", 94 "solstone/apps/import/ingest.py", 95 "solstone/apps/import/facet_ingest.py", 96 "solstone/apps/import/journal_sources.py", 97 "solstone/apps/import/resolve.py", 98 "solstone/apps/observer/utils.py", 99 "solstone/think/activities.py", 100 "solstone/think/awareness.py", 101 "solstone/think/day_accumulator.py", 102 "solstone/think/entities/consolidation.py", 103 "solstone/think/entities/journal.py", 104 "solstone/think/entities/merge.py", 105 "solstone/think/entities/observations.py", 106 "solstone/think/entities/relationships.py", 107 "solstone/think/entities/review_candidates.py", 108 "solstone/think/entities/saving.py", 109 "solstone/think/entities/voiceprints.py", 110 "solstone/think/facet_review_candidates.py", 111 "solstone/think/speaker_review_candidates.py", 112 "solstone/think/facets.py", 113 "solstone/think/identity.py", 114 "solstone/think/journal_config.py", 115 "solstone/think/schedule_config.py", 116 "solstone/think/push/devices.py", 117 # Link domain — device-pairing service state. 118 "solstone/think/link/auth.py", 119 "solstone/think/link/ca.py", 120 "solstone/think/link/nonces.py", 121 "solstone/think/link/paths.py", 122 "solstone/think/sense_splitter.py", 123 "solstone/think/streams.py", 124 "solstone/think/thinking.py", 125 # Chronicle day content direct writers. Importer modules that merely 126 # route through importers/shared.py are not direct owners and are 127 # intentionally omitted. 128 "solstone/observe/depict.py", 129 "solstone/observe/describe.py", 130 "solstone/observe/extract_pdf.py", 131 "solstone/observe/transcribe/main.py", 132 "solstone/observe/transfer.py", 133 "solstone/think/importers/cli.py", 134 "solstone/think/importers/documents.py", 135 "solstone/think/importers/shared.py", 136 "solstone/convey/chat_stream.py", 137 # imports/** bundle + sync-cursor writers (local/CLI import flows). 138 "solstone/think/importers/plaud.py", # streamed imported-audio install. 139 "solstone/think/importers/sync.py", 140 "solstone/think/importers/utils.py", 141 } 142) 143 144OWNER_PREFIXES: tuple[str, ...] = ( 145 "solstone/apps/facets/", 146 "solstone/think/indexer/", 147) 148 149# Committed allowlist of violations on the current tree, keyed by 150# (posix-relative-path, kind) -> allowed count. Ratchets toward empty: lower a 151# count as occurrences are fixed; never raise one to admit a new violation. 152# Never line-keyed; never a per-file blanket. 153ALLOWLIST: dict[tuple[str, str], int] = {} 154 155 156def _is_owner(rel: Path) -> bool: 157 rel_str = rel.as_posix() 158 return rel_str in OWNER_FILES or any( 159 rel_str.startswith(prefix) for prefix in OWNER_PREFIXES 160 ) 161 162 163def _is_test_file(rel: Path) -> bool: 164 return ( 165 "tests" in rel.parts 166 or rel.name == "conftest.py" 167 or (rel.name.startswith("test_") and rel.suffix == ".py") 168 ) 169 170 171def discover_modules(root: Path) -> list[Path]: 172 """Return posix-relative non-owner, non-test modules under ``solstone/``.""" 173 scope = root / "solstone" 174 if not scope.is_dir(): 175 return [] 176 177 found: list[Path] = [] 178 for path in sorted(scope.rglob("*.py")): 179 rel = path.relative_to(root) 180 rel_str = rel.as_posix() 181 if "__pycache__" in rel.parts: 182 continue 183 if rel_str.startswith("solstone/think/journal_io/"): 184 continue 185 if _is_test_file(rel): 186 continue 187 if _is_owner(rel): 188 continue 189 found.append(rel) 190 return found 191 192 193def _dotted_name(node: ast.AST) -> str | None: 194 if isinstance(node, ast.Name): 195 return node.id 196 if isinstance(node, ast.Attribute): 197 base = _dotted_name(node.value) 198 if base: 199 return f"{base}.{node.attr}" 200 return None 201 202 203def _bind_from_import( 204 node: ast.ImportFrom, 205 direct_bindings: dict[str, str], 206 module_bindings: dict[str, frozenset[str]], 207) -> None: 208 module = node.module or "" 209 if module == "solstone.think": 210 for alias in node.names: 211 if alias.name == "journal_io": 212 module_bindings[alias.asname or alias.name] = GATED_PRIMITIVES 213 return 214 215 primitives = MODULE_PRIMITIVES.get(module) 216 if not primitives: 217 return 218 219 for alias in node.names: 220 if alias.name == "*": 221 for primitive in primitives: 222 direct_bindings[primitive] = primitive 223 elif alias.name in primitives: 224 direct_bindings[alias.asname or alias.name] = alias.name 225 226 227def _bind_import( 228 node: ast.Import, 229 module_bindings: dict[str, frozenset[str]], 230 dotted_bindings: dict[str, frozenset[str]], 231) -> None: 232 for alias in node.names: 233 primitives = MODULE_PRIMITIVES.get(alias.name) 234 if not primitives: 235 continue 236 if alias.asname: 237 module_bindings[alias.asname] = primitives 238 else: 239 dotted_bindings[alias.name] = primitives 240 241 242def _collect_bindings( 243 tree: ast.AST, 244) -> tuple[ 245 dict[str, str], 246 dict[str, frozenset[str]], 247 dict[str, frozenset[str]], 248]: 249 direct_bindings: dict[str, str] = {} 250 module_bindings: dict[str, frozenset[str]] = {} 251 dotted_bindings: dict[str, frozenset[str]] = {} 252 253 for node in ast.walk(tree): 254 if isinstance(node, ast.ImportFrom): 255 _bind_from_import(node, direct_bindings, module_bindings) 256 elif isinstance(node, ast.Import): 257 _bind_import(node, module_bindings, dotted_bindings) 258 259 return direct_bindings, module_bindings, dotted_bindings 260 261 262def _called_primitive( 263 func: ast.expr, 264 direct_bindings: dict[str, str], 265 module_bindings: dict[str, frozenset[str]], 266 dotted_bindings: dict[str, frozenset[str]], 267) -> tuple[str, str] | None: 268 if isinstance(func, ast.Name): 269 primitive = direct_bindings.get(func.id) 270 if primitive: 271 return primitive, func.id 272 return None 273 274 if not isinstance(func, ast.Attribute): 275 return None 276 277 if ( 278 isinstance(func.value, ast.Name) 279 and func.value.id in module_bindings 280 and func.attr in module_bindings[func.value.id] 281 ): 282 return func.attr, f"{func.value.id}.{func.attr}" 283 284 dotted = _dotted_name(func) 285 if not dotted: 286 return None 287 for prefix, primitives in dotted_bindings.items(): 288 for primitive in primitives: 289 if dotted == f"{prefix}.{primitive}": 290 return primitive, dotted 291 return None 292 293 294def scan_source(source: str, filename: str = "<source>") -> list[tuple[int, str, str]]: 295 """Return ``(lineno, primitive, bound_name)`` violations for source.""" 296 tree = ast.parse(source, filename=filename) 297 direct_bindings, module_bindings, dotted_bindings = _collect_bindings(tree) 298 299 findings: list[tuple[int, str, str]] = [] 300 for node in ast.walk(tree): 301 if not isinstance(node, ast.Call): 302 continue 303 called = _called_primitive( 304 node.func, 305 direct_bindings, 306 module_bindings, 307 dotted_bindings, 308 ) 309 if called: 310 primitive, bound_name = called 311 findings.append((node.lineno, primitive, bound_name)) 312 findings.sort() 313 return findings 314 315 316def scan_file(path: Path) -> list[tuple[int, str, str]]: 317 return scan_source(path.read_text(encoding="utf-8"), filename=str(path)) 318 319 320def count_violations(root: Path) -> dict[tuple[str, str], int]: 321 """Map ``(posix-relpath, primitive)`` -> occurrence count across the tree.""" 322 counts: dict[tuple[str, str], int] = {} 323 for rel in discover_modules(root): 324 for _lineno, primitive, _bound_name in scan_file(root / rel): 325 key = (rel.as_posix(), primitive) 326 counts[key] = counts.get(key, 0) + 1 327 return counts 328 329 330def evaluate( 331 root: Path, 332 allowlist: dict[tuple[str, str], int], 333) -> tuple[list[str], list[str]]: 334 """Return ``(new_violations, tracked)`` human-readable lines.""" 335 new: list[str] = [] 336 tracked: list[str] = [] 337 for rel in discover_modules(root): 338 rel_str = rel.as_posix() 339 findings = scan_file(root / rel) 340 by_primitive: dict[str, list[int]] = {} 341 for lineno, primitive, _bound_name in findings: 342 by_primitive.setdefault(primitive, []).append(lineno) 343 for primitive, linenos in sorted(by_primitive.items()): 344 count = len(linenos) 345 allowed = allowlist.get((rel_str, primitive), 0) 346 if count > allowed: 347 lines = ", ".join(str(n) for n in sorted(linenos)) 348 new.append( 349 f"{rel_str}: {primitive} imported from journal_io and called " 350 f"by a non-owner ({count} occurrence(s), allowed {allowed}) " 351 f"at line(s) {lines}" 352 ) 353 elif allowed: 354 tracked.append( 355 f"{rel_str}: {count}/{allowed} {primitive} (allowlisted)" 356 ) 357 return new, tracked 358 359 360def main(argv: list[str] | None = None) -> int: 361 parser = argparse.ArgumentParser(description="Journal I/O access lint") 362 parser.add_argument( 363 "--root", 364 type=Path, 365 default=ROOT, 366 help="Repository root to scan (defaults to the checkout root).", 367 ) 368 args = parser.parse_args(argv) 369 370 new, tracked = evaluate(args.root, ALLOWLIST) 371 372 if tracked: 373 print("journal-io-access: known violations (allowlisted, ratcheting down):") 374 for line in tracked: 375 print(f" {line}") 376 print() 377 378 if new: 379 print("journal-io-access: NEW violations:", file=sys.stderr) 380 for line in new: 381 print(f" {line}", file=sys.stderr) 382 print(file=sys.stderr) 383 print( 384 "Route writes through the L2 owner for that domain — see AGENTS.md §7.", 385 file=sys.stderr, 386 ) 387 return 1 388 389 print("journal-io-access: pass") 390 return 0 391 392 393if __name__ == "__main__": 394 raise SystemExit(main())