brainiac-cli 0.20.2__tar.gz → 0.20.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. {brainiac_cli-0.20.2/src/brainiac_cli.egg-info → brainiac_cli-0.20.4}/PKG-INFO +1 -1
  2. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/pyproject.toml +1 -1
  3. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/AGENTS.md +39 -4
  4. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/tools/cos_reconcile_metrics.py +24 -7
  5. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_version.py +1 -1
  6. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/audit.py +77 -6
  7. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/config.py +31 -0
  8. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/core.py +13 -0
  9. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/doctor.py +24 -6
  10. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/index.py +16 -0
  11. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4/src/brainiac_cli.egg-info}/PKG-INFO +1 -1
  12. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/LICENSE +0 -0
  13. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/MANIFEST.in +0 -0
  14. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/README.md +0 -0
  15. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/setup.cfg +0 -0
  16. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/__init__.py +0 -0
  17. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/__main__.py +0 -0
  18. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/assets/graph-explorer-template.html +0 -0
  19. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/overlay/template/brand/brand-guide.md +0 -0
  20. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/overlay/template/cos/auto-archive.md +0 -0
  21. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/overlay/template/cos/drafts.md +0 -0
  22. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/overlay/template/cos/ingest.md +0 -0
  23. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/overlay/template/cos/priorities.md +0 -0
  24. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/overlay/template/keywords/glossary.md +0 -0
  25. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/overlay/template/people/roster.md +0 -0
  26. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/overlay/template/voice/voice-profile.md +0 -0
  27. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/routines/manifest.json +0 -0
  28. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/scripts/brain-brief-mac.plist +0 -0
  29. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/scripts/brain-brief.sh +0 -0
  30. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/scripts/brain-synthesis-mac.plist +0 -0
  31. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/scripts/brain-synthesis.sh +0 -0
  32. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/scripts/brainiac-alerts.sh +0 -0
  33. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/scripts/install-brief-mac.sh +0 -0
  34. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/scripts/install-brief-windows.ps1 +0 -0
  35. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/scripts/register_tasks.py +0 -0
  36. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/scripts/vm-boundary-probe.sh +0 -0
  37. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/scripts/vm-selftest.sh +0 -0
  38. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/templates/company.md +0 -0
  39. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/templates/concept.md +0 -0
  40. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/templates/daily.md +0 -0
  41. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/templates/decision.md +0 -0
  42. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/templates/meeting.md +0 -0
  43. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/templates/person.md +0 -0
  44. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/templates/project.md +0 -0
  45. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/templates/state-moc.md +0 -0
  46. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/tools/cos_browser_scan.mjs +0 -0
  47. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/tools/cos_contract.py +0 -0
  48. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/_assets/tools/cos_retro.py +0 -0
  49. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/anchor.py +0 -0
  50. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/autolink.py +0 -0
  51. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/backup.py +0 -0
  52. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/brief.py +0 -0
  53. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/capture.py +0 -0
  54. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/chunk.py +0 -0
  55. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/classification.py +0 -0
  56. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/cli.py +0 -0
  57. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/connect.py +0 -0
  58. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/context.py +0 -0
  59. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/conventions.py +0 -0
  60. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/cos.py +0 -0
  61. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/cos_chips.py +0 -0
  62. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/cos_corpus.py +0 -0
  63. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/cos_deploy.py +0 -0
  64. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/cos_runverify.py +0 -0
  65. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/dbretry.py +0 -0
  66. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/egress.py +0 -0
  67. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/embed.py +0 -0
  68. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/encryption.py +0 -0
  69. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/frontmatter.py +0 -0
  70. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/golden_probe.py +0 -0
  71. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/graph.py +0 -0
  72. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/graphify.py +0 -0
  73. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/graphreport.py +0 -0
  74. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/healthreport.py +0 -0
  75. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/inbox.py +0 -0
  76. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/__init__.py +0 -0
  77. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/__init__.py +0 -0
  78. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/base.py +0 -0
  79. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/docx.py +0 -0
  80. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/email.py +0 -0
  81. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/html.py +0 -0
  82. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/image.py +0 -0
  83. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/pdf.py +0 -0
  84. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/pptx.py +0 -0
  85. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/tables.py +0 -0
  86. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/text.py +0 -0
  87. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/xlsx.py +0 -0
  88. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/handlers/zip.py +0 -0
  89. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/pipeline.py +0 -0
  90. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/ingest/transcript.py +0 -0
  91. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/init.py +0 -0
  92. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/lock.py +0 -0
  93. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/maintenance.py +0 -0
  94. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/mcp_adapter.py +0 -0
  95. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/multihop.py +0 -0
  96. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/notes.py +0 -0
  97. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/overlay.py +0 -0
  98. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/progress.py +0 -0
  99. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/projection.py +0 -0
  100. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/provenance.py +0 -0
  101. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/querylog.py +0 -0
  102. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/rerank.py +0 -0
  103. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/retro.py +0 -0
  104. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/snapshot.py +0 -0
  105. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/spine.py +0 -0
  106. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/update.py +0 -0
  107. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/vectors.py +0 -0
  108. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brain/versionlink.py +0 -0
  109. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brainiac_cli.egg-info/SOURCES.txt +0 -0
  110. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brainiac_cli.egg-info/dependency_links.txt +0 -0
  111. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brainiac_cli.egg-info/entry_points.txt +0 -0
  112. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brainiac_cli.egg-info/requires.txt +0 -0
  113. {brainiac_cli-0.20.2 → brainiac_cli-0.20.4}/src/brainiac_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brainiac-cli
3
- Version: 0.20.2
3
+ Version: 0.20.4
4
4
  Summary: Brainiac — local any-LLM second-brain core engine + brain CLI (Markdown truth, derived SQLite index).
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.9
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
8
8
  # check. The IMPORT package stays `brain` and the console script stays
9
9
  # `brain` — only the distribution/project name changes.
10
10
  name = "brainiac-cli"
11
- version = "0.20.2"
11
+ version = "0.20.4"
12
12
  description = "Brainiac — local any-LLM second-brain core engine + brain CLI (Markdown truth, derived SQLite index)."
13
13
  readme = "README.md"
14
14
  license = "Apache-2.0"
@@ -547,6 +547,33 @@ round-trip). Both temporal flags stay **VM_ALLOWED** — they are read-only
547
547
  filters over already-gated rows, no different in trust from any other
548
548
  `bases-query`.
549
549
 
550
+ **Breadth-intent routing (RTE-01).** A heuristic adapted from NapMem's
551
+ observed navigator behavior (arXiv 2607.05794) — the paper never classifies
552
+ query breadth itself, but its top-down-vs-bottom-up entry choice transfers as
553
+ a rule of thumb for frontier-model navigators over this vault. This governs
554
+ *entry point* only, never a mandated step sequence: probing lexically first
555
+ and escalating only when needed (§5's agentic tool surface) still applies
556
+ once you're in. When a question is BROAD — "state of play", "overview", "how
557
+ do we usually…" asked about VAULT knowledge — enter TOP-DOWN instead of
558
+ grepping cold:
559
+
560
+ ```
561
+ brain get index --json # the map note (id: index) — start here
562
+ ```
563
+
564
+ then drill down via wikilinks with `get`/`graph-expand`, confirming each
565
+ candidate on its cited note (`graph-expand` stays DISCOVERY-ONLY — never
566
+ treat its derived graph as authoritative). If no state-MOC or `index.md`
567
+ entry fits, fall back to `search`. **Owner persona/voice/preference
568
+ questions are NOT this route:** voice/brand/keywords/people live in
569
+ `vault/overlay/`, which is excluded from retrieval indexing entirely, so the
570
+ vault map can never contain those answers — keep the existing overlay-loading
571
+ path for those. When a question is NARROW factual recall — a specific
572
+ entity, exact term, date — keep the existing lexical-first entry
573
+ (`grep`/`search`). This rule is additive only: decision-state questions still
574
+ route to `brain dossier` and temporal questions still route to TMP-03 above,
575
+ regardless of breadth.
576
+
550
577
  **`brain supersede <old-id> <new-id> [--reason R]`** retires `old-id` in favour
551
578
  of `new-id` — both sides of the version chain, written through the audited
552
579
  `write_note` path in one call. **HOST-broker only** (refused on `role=vm`
@@ -724,10 +751,18 @@ Obsidian "five-step retrieval cascade" rule for any harness reading this file.
724
751
  (`--check-content` adds the per-note list), `brain doctor` carries the same
725
752
  count as a gating row, and any UNEXPLAINED drift makes the health verdict
726
753
  DEGRADED. A vault carrying historical drift (notes edited outside the audited
727
- write path before this was visible) triages it once into
728
- `<vault>/.brain/audit-drift-dispositions.json`; each disposition is **pinned
729
- to the bytes it was ruled on**, so the same note changing again returns as
730
- unexplained. Never re-sign or delete drifted notes to clear the count.
754
+ write path before this was visible) triages it once into a **host-private**
755
+ disposition file (`brain doctor --json` prints its path); each disposition is
756
+ **pinned to the bytes it was ruled on**, so the same note changing again
757
+ returns as unexplained. Never re-sign or delete drifted notes to clear the
758
+ count. That file moved OFF `<vault>/.brain/` on 2026-08-07: it decides
759
+ whether tampering counts as EXPLAINED, and a match needs only path + issue +
760
+ observed hash — every one of which is known to whoever edited the note — so
761
+ on the shared mount the untrusted VM leg could forge one and drive
762
+ `unexplained` to 0 while `verify-audit` still reported `ok`. Same treatment
763
+ and same reason as the approved queue (INT-01), the attachment anchors
764
+ (INT-04) and the writer lock (INT-05). An existing file is carried forward
765
+ once, stamped `migrated_from_mount`.
731
766
 
732
767
  ---
733
768
 
@@ -54,6 +54,7 @@ import json
54
54
  import os
55
55
  import re
56
56
  import sys
57
+ import tempfile
57
58
  from collections import defaultdict
58
59
  from pathlib import Path
59
60
 
@@ -455,13 +456,29 @@ def append_metric(ops_dir: Path, row: dict) -> str:
455
456
  f"conflicting metrics row for {(row['date'], row['run'])!r}")
456
457
 
457
458
  prior = path.read_text(encoding="utf-8") if path.exists() else ""
458
- tmp = path.with_suffix(".jsonl.tmp")
459
- tmp.write_text(
460
- prior.rstrip("\n") + ("\n" if prior else "")
461
- + json.dumps(row, separators=(",", ":"), ensure_ascii=False) + "\n",
462
- encoding="utf-8",
463
- )
464
- os.replace(tmp, path)
459
+ payload = (prior.rstrip("\n") + ("\n" if prior else "")
460
+ + json.dumps(row, separators=(",", ":"), ensure_ascii=False) + "\n")
461
+ # The temp file used to be the fixed name `_cos_metrics.jsonl.tmp`, written
462
+ # with `Path.write_text` (Codex cloud review, 2026-08-07). A symlink planted
463
+ # at that predictable path is FOLLOWED by the open, so the write truncates
464
+ # and overwrites whatever it points at -- outside the vault included -- and
465
+ # the later `os.replace` only swaps the link, long after the damage. A COS
466
+ # run writes here as a documented step, so nothing unusual has to happen.
467
+ #
468
+ # `mkstemp` is the stdlib answer to exactly this: a random name created
469
+ # with O_CREAT|O_EXCL|O_NOFOLLOW-equivalent semantics (it refuses to open an
470
+ # existing path at all), 0600, in the same directory so `os.replace` stays
471
+ # atomic on one filesystem.
472
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix="._cos_metrics-",
473
+ suffix=".jsonl.tmp")
474
+ tmp = Path(tmp_name)
475
+ try:
476
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
477
+ fh.write(payload)
478
+ os.replace(tmp, path)
479
+ except BaseException:
480
+ tmp.unlink(missing_ok=True)
481
+ raise
465
482
  return "appended"
466
483
 
467
484
 
@@ -9,4 +9,4 @@ pip-installed host, importlib.metadata stays primary; brain/__init__.py reads
9
9
  this file only as the fallback before 0.0.0+unknown.
10
10
  """
11
11
 
12
- __version__ = "0.20.2"
12
+ __version__ = "0.20.4"
@@ -507,21 +507,92 @@ class AuditChain:
507
507
  # drifting AGAIN produces a different `actual_sha256`, no longer matches, and
508
508
  # comes back as unexplained. Absorbing a whole path forever is exactly the
509
509
  # failure mode this instrument exists to prevent.
510
+ #
511
+ # WHERE IT LIVES (changed 2026-08-07): the host-private app-data dir, NOT
512
+ # `<vault>/.brain/`. This file decides whether tampering counts as explained,
513
+ # and a match needs only path + issue + observed hash -- all of which whoever
514
+ # edited the note already knows. On the old path, the Cowork VM could write it,
515
+ # so the untrusted leg could silence the host's own tamper alarm. Pinning to
516
+ # bytes is what stops a disposition absorbing a path forever; being off the
517
+ # mount is what stops it being forged. Both are needed.
510
518
  DRIFT_DISPOSITIONS_FILENAME = "audit-drift-dispositions.json"
511
519
 
512
520
 
513
521
  def drift_dispositions_path(vault: Path) -> Path:
514
- """Host-only triage file. Lives under ``.brain/`` (gitignored, never
515
- indexed, never published to the VM snapshot)."""
522
+ """Host-private triage file, OFF the VM-visible mount (2026-08-07).
523
+
524
+ Raises ``config.HostPathUnsafe`` when it cannot resolve somewhere the
525
+ Cowork VM is unable to reach — see ``config.audit_drift_dispositions_path``
526
+ for why this file in particular must be out of reach."""
527
+ from . import config
528
+
529
+ return config.audit_drift_dispositions_path(vault)
530
+
531
+
532
+ def legacy_drift_dispositions_path(vault: Path) -> Path:
533
+ """Where this file lived until 2026-08-07: on the shared mount. Read ONLY
534
+ by the one-time carry-forward below; nothing else may consult it again."""
516
535
  return Path(vault) / ".brain" / DRIFT_DISPOSITIONS_FILENAME
517
536
 
518
537
 
538
+ def migrate_drift_dispositions(vault: Path) -> str | None:
539
+ """Carry a pre-2026-08-07 triage file forward to the host-private location.
540
+
541
+ Copy, never move: the destination is the only thing read from now on, and
542
+ deleting the operator's historical record on their behalf is not this
543
+ function's call. Returns a one-line note when it acted, else ``None``.
544
+
545
+ The carried-forward records came from a VM-writable path, so the copy is
546
+ STAMPED (``migrated_from_mount``) rather than laundered into looking
547
+ host-authored. Without this, a vault with triaged historical drift would
548
+ report every previously-explained record as unexplained on first run after
549
+ the upgrade — a false tamper alarm, which is the fastest way to teach an
550
+ operator to ignore a real one."""
551
+ legacy = legacy_drift_dispositions_path(vault)
552
+ if not legacy.is_file():
553
+ return None
554
+ try:
555
+ dest = drift_dispositions_path(vault)
556
+ except Exception: # noqa: BLE001 — unsafe destination: stay fail-closed
557
+ return None
558
+ if dest.exists():
559
+ return None
560
+ try:
561
+ raw = json.loads(legacy.read_text(encoding="utf-8"))
562
+ except (OSError, ValueError):
563
+ return None
564
+ records = raw.get("dispositions") if isinstance(raw, dict) else raw
565
+ if not isinstance(records, list):
566
+ return None
567
+ try:
568
+ dest.parent.mkdir(parents=True, exist_ok=True)
569
+ try:
570
+ dest.parent.chmod(0o700)
571
+ except OSError:
572
+ pass
573
+ dest.write_text(json.dumps(
574
+ {"dispositions": records,
575
+ "migrated_from_mount": legacy.as_posix()},
576
+ indent=2), encoding="utf-8")
577
+ except OSError:
578
+ return None
579
+ return (f"carried {len(records)} drift disposition(s) forward from the shared "
580
+ f"mount to {dest} — they were recorded where a Cowork VM could write, "
581
+ f"so re-check them if you have any reason to doubt that host")
582
+
583
+
519
584
  def load_drift_dispositions(vault: Path) -> dict[str, dict]:
520
- """``{path: record}`` from the triage file; ``{}`` when absent or
521
- unreadable. Fails OPEN into "nothing is explained" — an unreadable
522
- disposition file must never silently clear a drift count."""
585
+ """``{path: record}`` from the triage file; ``{}`` when absent, unreadable,
586
+ or resolvable only to a VM-visible path. Fails CLOSED into "nothing is
587
+ explained" — an unreadable or untrustworthy disposition file must never
588
+ silently clear a drift count."""
589
+ migrate_drift_dispositions(vault)
590
+ try:
591
+ path = drift_dispositions_path(vault)
592
+ except Exception: # noqa: BLE001 — HostPathUnsafe and anything else
593
+ return {}
523
594
  try:
524
- raw = json.loads(drift_dispositions_path(vault).read_text(encoding="utf-8"))
595
+ raw = json.loads(path.read_text(encoding="utf-8"))
525
596
  except (OSError, ValueError):
526
597
  return {}
527
598
  records = raw.get("dispositions") if isinstance(raw, dict) else raw
@@ -633,6 +633,37 @@ def writer_lock_path(vault: str | os.PathLike[str] | None = None) -> Path:
633
633
  return host_lock_dir(vault) / f"writer-{vault_slug8(vault)}.lock"
634
634
 
635
635
 
636
+ def audit_drift_dispositions_dir(vault: str | os.PathLike[str] | None = None) -> Path:
637
+ """Directory holding the INT-02 drift-triage files. Resolution only — the
638
+ writing path creates it, same convention as ``host_lock_dir``."""
639
+ return proven_off_mount(host_private_base() / "audit-drift", vault,
640
+ what="audit drift disposition store")
641
+
642
+
643
+ def audit_drift_dispositions_path(vault: str | os.PathLike[str] | None = None) -> Path:
644
+ """The file that decides whether post-signing content drift is EXPLAINED.
645
+
646
+ OFF THE MOUNT (2026-08-07, Codex cloud security review). It used to be
647
+ ``<vault>/.brain/audit-drift-dispositions.json``, on the VirtioFS mount a
648
+ Cowork VM session can write. That made it a tamper-suppression authority
649
+ sitting in reach of the untrusted leg: a disposition matches on path, issue
650
+ and OBSERVED hash — every one of which is known to whoever just edited the
651
+ note — so adding one record drives ``drift_summary()['unexplained']`` to 0,
652
+ and ``verify_audit`` keeps reporting ``ok`` while a signed note's bytes have
653
+ changed. The signature check cannot catch it; the whole point of the content
654
+ pass was to catch what signatures cannot.
655
+
656
+ Same treatment, same reason, and now the same helper as the COS approved
657
+ queue (INT-01), the attachment acceptance anchors (INT-04) and the
658
+ single-writer lock (INT-05): being unreachable is the control, not a
659
+ permission bit on a mount that "may only partially honour POSIX bits".
660
+
661
+ Raises :class:`HostPathUnsafe` when the resolved location is VM-visible.
662
+ Callers must treat that as "nothing is explained" — never as a clean bill.
663
+ Keyed by vault identity so two vaults never share one triage file."""
664
+ return audit_drift_dispositions_dir(vault) / f"dispositions-{vault_slug8(vault)}.json"
665
+
666
+
636
667
  def graph_dir(vault: str | os.PathLike[str] | None = None) -> Path:
637
668
  """GRF-01 discovery-graph runtime artifacts (ADR-0003 Ruling 6/(a)) —
638
669
  gitignored, host-only, never published into the VM snapshot. Holds the
@@ -3202,6 +3202,19 @@ class BrainCore:
3202
3202
  # `brain maintain`, an interactive session, and the test suite must NEVER
3203
3203
  # trigger an unattended pip upgrade / plugin reinstall of the machine —
3204
3204
  # so default OFF and require the explicit opt-in the scheduler provides.
3205
+ # KILL SWITCH, checked first (2026-08-07). Unattended auto-apply is an
3206
+ # ACCEPTED RISK, not an oversight -- see docs/adr/0005-update-versioning-ux.md
3207
+ # "Risk acceptance" for what is being accepted and why. But an acceptance
3208
+ # you cannot revoke is not an acceptance: `scripts/brain-brief.sh` sets
3209
+ # BRAIN_AUTO_UPDATE=1 INLINE, so an inherited 0 cannot switch it off, and
3210
+ # that runner is a shipped file the next update overwrites. This is the
3211
+ # one way to turn auto-apply off without editing shipped files or
3212
+ # enabling full BRAIN_MANAGED lockdown -- set it in the environment the
3213
+ # scheduled task runs in (launchd plist / cron). Default off: setting
3214
+ # nothing changes nothing.
3215
+ if _os.environ.get("BRAIN_NO_AUTO_UPDATE", "").strip().lower() in (
3216
+ "1", "true", "yes", "on"):
3217
+ return {"auto_update": "disabled", "reason": "BRAIN_NO_AUTO_UPDATE set"}
3205
3218
  if _os.environ.get("BRAIN_AUTO_UPDATE") != "1":
3206
3219
  return {"auto_update": "disabled", "reason": "BRAIN_AUTO_UPDATE!=1 (scheduled-task only)"}
3207
3220
 
@@ -601,10 +601,25 @@ def check_desktop_plugin_store(
601
601
  # slash-command skill); in a Cowork session /skill-creator is what
602
602
  # repackages + presents the skill for Save-and-Replace. /brainiac-update
603
603
  # is host-only (refuses --role vm) so it is NOT the Cowork fix.
604
- if _compare(str(version), ssot) < 0:
604
+ #
605
+ # ANY mismatch needs action, not just `installed < ssot` (Codex cloud
606
+ # review, 2026-08-07). `installed > ssot` was reported as "looks current
607
+ # — no action needed", but ADR-0004 Ruling 5 / the CLI-plugin path
608
+ # explicitly handle RECONCILIATION DOWNGRADES, where an old installed
609
+ # plugin legitimately carries a numerically higher version than the
610
+ # current SSOT. The surface left stale by a false green here is the
611
+ # LLM-facing Cowork/Desktop skill instructions, so "newer" is not a
612
+ # reason to leave it alone after a security-relevant release.
613
+ skew = _compare(str(version), ssot)
614
+ if skew < 0:
605
615
  remediation = ("in a Cowork session use /skill-creator to repackage + "
606
616
  "Save-and-Replace the stale skill(s); re-run brain doctor on "
607
617
  "the host to confirm it took")
618
+ elif skew > 0:
619
+ remediation = (f"installed {version} is AHEAD of SSOT {ssot} — a "
620
+ "reconciliation downgrade, not a current install; in a "
621
+ "Cowork session use /skill-creator to repackage + "
622
+ "Save-and-Replace so the shipped skill matches SSOT")
608
623
  else:
609
624
  remediation = "looks current — no action needed"
610
625
  rows.append(_row(surface, MANUAL_REQUIRED, detail, remediation=remediation,
@@ -1209,10 +1224,12 @@ def check_audit_content_drift(vault: Path) -> dict:
1209
1224
  files against hashes already in the log, so this row costs one vault read
1210
1225
  and never resolves the signing key (``verify-audit`` does that separately).
1211
1226
 
1212
- Only UNEXPLAINED drift gates. Drift a human triaged into
1213
- ``.brain/audit-drift-dispositions.json`` is reported in the detail and
1214
- subtracted from the verdict — pinned to the bytes it was ruled on, so the
1215
- same file changing again comes straight back as unexplained."""
1227
+ Only UNEXPLAINED drift gates. Drift a human triaged into the disposition
1228
+ file is reported in the detail and subtracted from the verdict — pinned to
1229
+ the bytes it was ruled on, so the same file changing again comes straight
1230
+ back as unexplained. That file lives in the HOST-PRIVATE app-data dir since
1231
+ 2026-08-07 (``config.audit_drift_dispositions_path``); on the old
1232
+ ``.brain/`` path a Cowork VM could write it and zero out this very row."""
1216
1233
  from . import audit as _audit
1217
1234
  from . import config
1218
1235
 
@@ -1233,7 +1250,8 @@ def check_audit_content_drift(vault: Path) -> dict:
1233
1250
  f"{unexplained} signed note(s) changed after signing with no recorded "
1234
1251
  f"disposition ({explained} triaged, {total} total)",
1235
1252
  remediation="brain verify-audit --check-content --json # then triage into "
1236
- ".brain/audit-drift-dispositions.json or restore the note",
1253
+ "the host-private disposition file (brain doctor --json shows "
1254
+ "its path) or restore the note",
1237
1255
  raw={"total": total, "unexplained": unexplained})
1238
1256
  detail = ("no drift — every signed note matches its signed bytes" if not total
1239
1257
  else f"0 unexplained ({explained} triaged historical drift record(s))")
@@ -2868,7 +2868,23 @@ class BrainIndex:
2868
2868
  row = self.conn.execute(
2869
2869
  "SELECT rowid, classification FROM notes WHERE id=?", (target_id,)
2870
2870
  ).fetchone()
2871
+ # EXISTENCE ORACLE (Codex cloud review, 2026-08-07). A missing id used
2872
+ # to answer `candidate-miss` and echo the id back, while an id that
2873
+ # exists above the caller's cap answered `withheld` — two distinguishable
2874
+ # replies, on a verb the VM is allowed to call. That let an untrusted leg
2875
+ # probe guessed slugs and learn WHICH Restricted/MNPI notes exist, which
2876
+ # is precisely what the egress gate is there to prevent. Note ids are
2877
+ # readable slugs, so guessing is cheap.
2878
+ #
2879
+ # Whenever a cap can hide something, both cases now return the identical
2880
+ # `withheld` payload, so the two are indistinguishable. On an UNCAPPED
2881
+ # caller (the host default is the full vault) nothing can be hidden, so
2882
+ # there is no oracle to close and `candidate-miss` stays the useful
2883
+ # answer for a typo'd id.
2884
+ capped = max_tier != cls_mod.TIERS[-1]
2871
2885
  if not row:
2886
+ if capped:
2887
+ return {"target": "withheld", "verdict": "withheld"}
2872
2888
  return {
2873
2889
  "target": target_id,
2874
2890
  "verdict": "candidate-miss",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brainiac-cli
3
- Version: 0.20.2
3
+ Version: 0.20.4
4
4
  Summary: Brainiac — local any-LLM second-brain core engine + brain CLI (Markdown truth, derived SQLite index).
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.9
File without changes
File without changes
File without changes
File without changes