compartment 2.3__tar.gz → 3.1__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 (79) hide show
  1. {compartment-2.3 → compartment-3.1}/PKG-INFO +23 -20
  2. {compartment-2.3 → compartment-3.1}/README.md +19 -16
  3. {compartment-2.3 → compartment-3.1}/pyproject.toml +17 -11
  4. {compartment-2.3 → compartment-3.1}/src/compartment/__init__.py +1 -1
  5. compartment-3.1/src/compartment/acl.py +140 -0
  6. compartment-3.1/src/compartment/audit.py +223 -0
  7. compartment-3.1/src/compartment/bench.py +158 -0
  8. {compartment-2.3 → compartment-3.1}/src/compartment/claude_hooks.py +8 -1
  9. {compartment-2.3 → compartment-3.1}/src/compartment/cli.py +149 -14
  10. {compartment-2.3 → compartment-3.1}/src/compartment/crypto.py +59 -3
  11. {compartment-2.3 → compartment-3.1}/src/compartment/dash.py +285 -102
  12. {compartment-2.3 → compartment-3.1}/src/compartment/data/hermes-plugin/__init__.py +35 -22
  13. {compartment-2.3 → compartment-3.1}/src/compartment/data/starter.mpack +0 -0
  14. {compartment-2.3 → compartment-3.1}/src/compartment/longmemeval.py +35 -14
  15. {compartment-2.3 → compartment-3.1}/src/compartment/menubar.py +65 -3
  16. compartment-3.1/src/compartment/offline_guard.py +307 -0
  17. {compartment-2.3 → compartment-3.1}/src/compartment/packs.py +100 -13
  18. {compartment-2.3 → compartment-3.1}/src/compartment/platforms.py +23 -8
  19. compartment-3.1/src/compartment/server.py +532 -0
  20. compartment-3.1/src/compartment/session.py +531 -0
  21. {compartment-2.3 → compartment-3.1}/src/compartment/systray.py +22 -1
  22. {compartment-2.3 → compartment-3.1}/src/compartment/vault.py +97 -17
  23. {compartment-2.3 → compartment-3.1}/src/compartment/vaultfile.py +79 -10
  24. {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/PKG-INFO +23 -20
  25. {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/SOURCES.txt +8 -0
  26. {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/requires.txt +7 -7
  27. {compartment-2.3 → compartment-3.1}/tests/test_acl_audit_packs.py +14 -8
  28. compartment-3.1/tests/test_acl_precedence.py +246 -0
  29. compartment-3.1/tests/test_attempt_pacing.py +96 -0
  30. {compartment-2.3 → compartment-3.1}/tests/test_claude_hooks_and_integrate.py +33 -0
  31. compartment-3.1/tests/test_journal_framing.py +155 -0
  32. compartment-3.1/tests/test_offline_guard_paths.py +165 -0
  33. compartment-3.1/tests/test_offline_selftest_bench.py +178 -0
  34. {compartment-2.3 → compartment-3.1}/tests/test_ondisk_format.py +2 -1
  35. compartment-3.1/tests/test_pack_trust.py +188 -0
  36. compartment-3.1/tests/test_session_entropy.py +255 -0
  37. {compartment-2.3 → compartment-3.1}/tests/test_session_lock.py +20 -0
  38. compartment-3.1/tests/test_starter_data_quality.py +108 -0
  39. {compartment-2.3 → compartment-3.1}/tests/test_starter_packs.py +9 -9
  40. compartment-3.1/tests/test_status_bar_install.py +160 -0
  41. {compartment-2.3 → compartment-3.1}/tests/test_tamper.py +4 -3
  42. compartment-2.3/src/compartment/acl.py +0 -92
  43. compartment-2.3/src/compartment/audit.py +0 -103
  44. compartment-2.3/src/compartment/bench.py +0 -94
  45. compartment-2.3/src/compartment/offline_guard.py +0 -53
  46. compartment-2.3/src/compartment/server.py +0 -327
  47. compartment-2.3/src/compartment/session.py +0 -131
  48. compartment-2.3/tests/test_offline_selftest_bench.py +0 -81
  49. {compartment-2.3 → compartment-3.1}/setup.cfg +0 -0
  50. {compartment-2.3 → compartment-3.1}/src/compartment/claude_memory.py +0 -0
  51. {compartment-2.3 → compartment-3.1}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
  52. {compartment-2.3 → compartment-3.1}/src/compartment/data/menubar.png +0 -0
  53. {compartment-2.3 → compartment-3.1}/src/compartment/data/menubar@2x.png +0 -0
  54. {compartment-2.3 → compartment-3.1}/src/compartment/data/tray.ico +0 -0
  55. {compartment-2.3 → compartment-3.1}/src/compartment/embed.py +0 -0
  56. {compartment-2.3 → compartment-3.1}/src/compartment/home.py +0 -0
  57. {compartment-2.3 → compartment-3.1}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
  58. {compartment-2.3 → compartment-3.1}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
  59. {compartment-2.3 → compartment-3.1}/src/compartment/salience.py +0 -0
  60. {compartment-2.3 → compartment-3.1}/src/compartment/selftest.py +0 -0
  61. {compartment-2.3 → compartment-3.1}/src/compartment/store.py +0 -0
  62. {compartment-2.3 → compartment-3.1}/src/compartment/vindex.py +0 -0
  63. {compartment-2.3 → compartment-3.1}/src/compartment/wire.py +0 -0
  64. {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/dependency_links.txt +0 -0
  65. {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/entry_points.txt +0 -0
  66. {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/top_level.txt +0 -0
  67. {compartment-2.3 → compartment-3.1}/tests/test_audit_chain.py +0 -0
  68. {compartment-2.3 → compartment-3.1}/tests/test_claude_memory_and_recent.py +0 -0
  69. {compartment-2.3 → compartment-3.1}/tests/test_crypto.py +0 -0
  70. {compartment-2.3 → compartment-3.1}/tests/test_dash.py +0 -0
  71. {compartment-2.3 → compartment-3.1}/tests/test_instructions.py +0 -0
  72. {compartment-2.3 → compartment-3.1}/tests/test_macos_bundle.py +0 -0
  73. {compartment-2.3 → compartment-3.1}/tests/test_menubar.py +0 -0
  74. {compartment-2.3 → compartment-3.1}/tests/test_platforms.py +0 -0
  75. {compartment-2.3 → compartment-3.1}/tests/test_relations.py +0 -0
  76. {compartment-2.3 → compartment-3.1}/tests/test_salience.py +0 -0
  77. {compartment-2.3 → compartment-3.1}/tests/test_systray.py +0 -0
  78. {compartment-2.3 → compartment-3.1}/tests/test_twofa.py +0 -0
  79. {compartment-2.3 → compartment-3.1}/tests/test_vault_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 2.3
3
+ Version: 3.1
4
4
  Summary: Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)
5
5
  Project-URL: Homepage, https://github.com/MaxFreedomPollard/Compartment
6
6
  Project-URL: Source, https://github.com/MaxFreedomPollard/Compartment
@@ -33,13 +33,13 @@ Requires-Dist: onnxruntime>=1.17
33
33
  Requires-Dist: tokenizers>=0.15
34
34
  Requires-Dist: numpy>=1.26
35
35
  Requires-Dist: mcp>=1.0
36
+ Requires-Dist: pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin"
37
+ Requires-Dist: pystray>=0.19; sys_platform == "win32"
38
+ Requires-Dist: pillow>=10; sys_platform == "win32"
36
39
  Provides-Extra: hnsw
37
40
  Requires-Dist: usearch>=2.9; extra == "hnsw"
38
41
  Provides-Extra: menubar
39
- Requires-Dist: pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin" and extra == "menubar"
40
42
  Provides-Extra: tray
41
- Requires-Dist: pystray>=0.19; sys_platform == "win32" and extra == "tray"
42
- Requires-Dist: pillow>=10; sys_platform == "win32" and extra == "tray"
43
43
  Provides-Extra: dev
44
44
  Requires-Dist: pytest>=8; extra == "dev"
45
45
  Requires-Dist: usearch>=2.9; extra == "dev"
@@ -51,7 +51,7 @@ Requires-Dist: usearch>=2.9; extra == "dev"
51
51
  Your AI agent forgets you the moment the session ends. Compartment ends
52
52
  that. With Compartment, your AI agent gets better with experience: it keeps
53
53
  every decision, preference and detail you give it, permanently, encrypted,
54
- on your own computer. Hermes, Claude, OpenClaw and other AI Agents can
54
+ on your own computer. Hermes, Claude, OpenClaw and other AI Agents can
55
55
  install in one command. One fully-transferable memory store is shared
56
56
  simultaneously by all agents on the computer. 100% offline: no network, no
57
57
  API key, no cloud account, no telemetry. The embedding model ships inside
@@ -181,7 +181,7 @@ own - Claude Code keeps per-project Markdown files with an auto-loaded
181
181
  index. Two memories means facts land in whichever one the model happened to
182
182
  think of, and neither is complete. Compartment takes over on install: it imports
183
183
  what the file memory already holds, and both the MCP handshake and the
184
- managed CLAUDE.md block tell the model that compartment supersedes it - write
184
+ managed CLAUDE.md block tell the model that Compartment supersedes it - write
185
185
  every new memory here, treat the files as a read-only archive. One vault,
186
186
  encrypted, shared by every agent and project on the machine. Nothing is
187
187
  deleted; the files stay exactly where they were.
@@ -190,7 +190,7 @@ deleted; the files stay exactly where they were.
190
190
  and a host that declares its own memory in its system prompt outranks
191
191
  anything a tool says. So `compartment integrate claude` also installs a
192
192
  `PostToolUse` hook: when Claude Code writes a memory file, the fact lands in
193
- the vault whether or not the model ever thought about compartment. The hook is
193
+ the vault whether or not the model ever thought about Compartment. The hook is
194
194
  additive and idempotent (your other hooks are untouched, settings.json is
195
195
  backed up first), it exits successfully no matter what - a memory tool must
196
196
  never break your editor - and it stays quiet when the vault is locked.
@@ -203,14 +203,15 @@ never break your editor - and it stays quiet when the vault is locked.
203
203
  alt="The Compartment panel in the Windows notification area.">
204
204
  </p>
205
205
 
206
- **A menu bar and system tray app.** Download **Compartment.pkg** from the
207
- [latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest)
208
- and open it - the installer asks whether you also want the menu bar utility,
209
- and everything (Python included) is self-contained, so there is nothing to
210
- install first. From a checkout, `pip install 'compartment[menubar]'`
211
- then `compartment menubar` does the same thing; on Windows,
212
- `pip install 'compartment[tray]'` then `compartment tray`. Either way it puts
213
- Compartment in the status bar: click the icon and a
206
+ **A menu bar and system tray app, from the same one install.** `pip install
207
+ compartment && compartment init` sets it up on macOS and on Windows: the app
208
+ starts straight away and comes back at every login. There is no separate
209
+ package, no extra to remember and no second command. On the Mac you can
210
+ instead open **Compartment.pkg** from the
211
+ [latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest),
212
+ which carries Python and every dependency inside it, so there is nothing to
213
+ install first. `compartment init --no-app` skips the app for headless boxes
214
+ and CI. Either way it puts Compartment in the status bar: click the icon and a
214
215
  panel shows whether the vault is open, how much it has learned, the three
215
216
  settings worth changing day to day (capture hook, whether starter facts join
216
217
  searches, auto-lock), and the last five things it remembered. Unlock, lock and
@@ -327,7 +328,7 @@ and `compartment bench`.
327
328
  | Peak RSS, model + vault + index resident | 319 MB |
328
329
  | Store one memory (embed + encrypt + fsync journal) | ~40 ms |
329
330
  | Wheel size, model included | ~30 MB |
330
- | Test suite (crypto, tamper, crash, offline, concurrency, 2FA, graph, dash) | 199 tests, ~60 s |
331
+ | Test suite (crypto, tamper, crash, offline, concurrency, 2FA, graph, dash) | 249 tests, ~60 s |
331
332
 
332
333
  A single network round-trip to a cloud memory API costs more than this
333
334
  entire pipeline. The property that makes Compartment secure (no plaintext
@@ -397,10 +398,12 @@ The default unlock mode is convenience, not a cage: after a normal
397
398
  unlock, the vault stays usable across processes, logouts, and logins -
398
399
  for weeks or months if you leave it that way - until the next restart or
399
400
  power loss, or until you lock it yourself. Restart/power loss always
400
- locks it: the stored credential is the master key wrapped by a key
401
- derived from the kernel's boot timestamp plus the stable machine id; a
402
- new boot can never open the old wrap. That is arithmetic, not a policy
403
- check.
401
+ locks it: the stored credential is the master key wrapped under a random
402
+ 32-byte per-boot secret, held in a volatile kernel object that is never
403
+ written to any filesystem, so a restart destroys it and a new boot can
404
+ never open the old wrap. A copy of the credential file on its own is
405
+ useless, because the key it needs was never on the disk. That is
406
+ arithmetic, not a policy check.
404
407
 
405
408
  If you prefer reboot-surviving unlock on macOS, that is an explicit
406
409
  opt-in (`compartment unlock --keychain`), with the tradeoff documented. At any
@@ -5,7 +5,7 @@
5
5
  Your AI agent forgets you the moment the session ends. Compartment ends
6
6
  that. With Compartment, your AI agent gets better with experience: it keeps
7
7
  every decision, preference and detail you give it, permanently, encrypted,
8
- on your own computer. Hermes, Claude, OpenClaw and other AI Agents can
8
+ on your own computer. Hermes, Claude, OpenClaw and other AI Agents can
9
9
  install in one command. One fully-transferable memory store is shared
10
10
  simultaneously by all agents on the computer. 100% offline: no network, no
11
11
  API key, no cloud account, no telemetry. The embedding model ships inside
@@ -135,7 +135,7 @@ own - Claude Code keeps per-project Markdown files with an auto-loaded
135
135
  index. Two memories means facts land in whichever one the model happened to
136
136
  think of, and neither is complete. Compartment takes over on install: it imports
137
137
  what the file memory already holds, and both the MCP handshake and the
138
- managed CLAUDE.md block tell the model that compartment supersedes it - write
138
+ managed CLAUDE.md block tell the model that Compartment supersedes it - write
139
139
  every new memory here, treat the files as a read-only archive. One vault,
140
140
  encrypted, shared by every agent and project on the machine. Nothing is
141
141
  deleted; the files stay exactly where they were.
@@ -144,7 +144,7 @@ deleted; the files stay exactly where they were.
144
144
  and a host that declares its own memory in its system prompt outranks
145
145
  anything a tool says. So `compartment integrate claude` also installs a
146
146
  `PostToolUse` hook: when Claude Code writes a memory file, the fact lands in
147
- the vault whether or not the model ever thought about compartment. The hook is
147
+ the vault whether or not the model ever thought about Compartment. The hook is
148
148
  additive and idempotent (your other hooks are untouched, settings.json is
149
149
  backed up first), it exits successfully no matter what - a memory tool must
150
150
  never break your editor - and it stays quiet when the vault is locked.
@@ -157,14 +157,15 @@ never break your editor - and it stays quiet when the vault is locked.
157
157
  alt="The Compartment panel in the Windows notification area.">
158
158
  </p>
159
159
 
160
- **A menu bar and system tray app.** Download **Compartment.pkg** from the
161
- [latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest)
162
- and open it - the installer asks whether you also want the menu bar utility,
163
- and everything (Python included) is self-contained, so there is nothing to
164
- install first. From a checkout, `pip install 'compartment[menubar]'`
165
- then `compartment menubar` does the same thing; on Windows,
166
- `pip install 'compartment[tray]'` then `compartment tray`. Either way it puts
167
- Compartment in the status bar: click the icon and a
160
+ **A menu bar and system tray app, from the same one install.** `pip install
161
+ compartment && compartment init` sets it up on macOS and on Windows: the app
162
+ starts straight away and comes back at every login. There is no separate
163
+ package, no extra to remember and no second command. On the Mac you can
164
+ instead open **Compartment.pkg** from the
165
+ [latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest),
166
+ which carries Python and every dependency inside it, so there is nothing to
167
+ install first. `compartment init --no-app` skips the app for headless boxes
168
+ and CI. Either way it puts Compartment in the status bar: click the icon and a
168
169
  panel shows whether the vault is open, how much it has learned, the three
169
170
  settings worth changing day to day (capture hook, whether starter facts join
170
171
  searches, auto-lock), and the last five things it remembered. Unlock, lock and
@@ -281,7 +282,7 @@ and `compartment bench`.
281
282
  | Peak RSS, model + vault + index resident | 319 MB |
282
283
  | Store one memory (embed + encrypt + fsync journal) | ~40 ms |
283
284
  | Wheel size, model included | ~30 MB |
284
- | Test suite (crypto, tamper, crash, offline, concurrency, 2FA, graph, dash) | 199 tests, ~60 s |
285
+ | Test suite (crypto, tamper, crash, offline, concurrency, 2FA, graph, dash) | 249 tests, ~60 s |
285
286
 
286
287
  A single network round-trip to a cloud memory API costs more than this
287
288
  entire pipeline. The property that makes Compartment secure (no plaintext
@@ -351,10 +352,12 @@ The default unlock mode is convenience, not a cage: after a normal
351
352
  unlock, the vault stays usable across processes, logouts, and logins -
352
353
  for weeks or months if you leave it that way - until the next restart or
353
354
  power loss, or until you lock it yourself. Restart/power loss always
354
- locks it: the stored credential is the master key wrapped by a key
355
- derived from the kernel's boot timestamp plus the stable machine id; a
356
- new boot can never open the old wrap. That is arithmetic, not a policy
357
- check.
355
+ locks it: the stored credential is the master key wrapped under a random
356
+ 32-byte per-boot secret, held in a volatile kernel object that is never
357
+ written to any filesystem, so a restart destroys it and a new boot can
358
+ never open the old wrap. A copy of the credential file on its own is
359
+ useless, because the key it needs was never on the disk. That is
360
+ arithmetic, not a policy check.
358
361
 
359
362
  If you prefer reboot-surviving unlock on macOS, that is an explicit
360
363
  opt-in (`compartment unlock --keychain`), with the tradeoff documented. At any
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compartment"
7
- version = "2.3"
7
+ version = "3.1"
8
8
  description = "Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -36,6 +36,16 @@ dependencies = [
36
36
  "tokenizers>=0.15",
37
37
  "numpy>=1.26",
38
38
  "mcp>=1.0",
39
+ # The status bar app ships with every install, on the platforms that have
40
+ # one. It is not an add-on: unlocking, locking and changing the passphrase
41
+ # are things people do through the icon, not through a terminal, so an
42
+ # install that only lays down a CLI is an incomplete install. The markers
43
+ # keep it honest - nothing macOS-only is fetched on Windows, nothing
44
+ # Windows-only on macOS, and Linux (which has no status bar app here)
45
+ # installs neither and compiles nothing.
46
+ "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'",
47
+ "pystray>=0.19; sys_platform == 'win32'",
48
+ "pillow>=10; sys_platform == 'win32'",
39
49
  ]
40
50
 
41
51
  # PyPI is the repo's declared homepage, so it is a real front door - and
@@ -64,16 +74,12 @@ engram = "compartment.cli:main"
64
74
  # `pip install` compiles nothing and installs cleanly everywhere; large
65
75
  # corpora add it with `pip install compartment[hnsw]`.
66
76
  hnsw = ["usearch>=2.9"]
67
- # The macOS menu bar app. PyObjC is macOS-only and irrelevant to the CLI and
68
- # MCP server, so it stays out of the default install: `pip install
69
- # compartment[menubar]`, then `compartment menubar`.
70
- menubar = ["pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'"]
71
- # The Windows tray app. tkinter draws the panel and comes with Python itself;
72
- # pystray owns the notification-area icon and is pure Python, so a Windows
73
- # install still compiles nothing: `pip install compartment[tray]`, then
74
- # `compartment tray`.
75
- tray = ["pystray>=0.19; sys_platform == 'win32'",
76
- "pillow>=10; sys_platform == 'win32'"]
77
+ # The status bar app is part of the base install now, so these two are kept
78
+ # only so that an existing `pip install compartment[menubar]` or
79
+ # `compartment[tray]` in someone's notes or script keeps resolving instead of
80
+ # failing on an unknown extra. They add nothing.
81
+ menubar = []
82
+ tray = []
77
83
  dev = ["pytest>=8", "usearch>=2.9"]
78
84
 
79
85
  [tool.setuptools.packages.find]
@@ -1,6 +1,6 @@
1
1
  """Compartment - high-security, fully offline, encrypted vector memory for AI agents."""
2
2
 
3
- __version__ = "2.3"
3
+ __version__ = "3.1"
4
4
 
5
5
  from . import offline_guard as _og
6
6
 
@@ -0,0 +1,140 @@
1
+ """Per-caller namespace access control + vault-adjacent settings.
2
+
3
+ Config lives NEXT to the vault as `<vault>.config.json` (it contains no
4
+ secrets - only ACLs and preferences). `packs/*` namespaces are ALWAYS
5
+ read-only for every caller, including "*". Violations are hard errors.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field
13
+
14
+ from .crypto import CryptoError
15
+
16
+ # The whole grant vocabulary. Anything outside it is not a grant at all, so it
17
+ # denies both reads and writes rather than being waved through as "not rw".
18
+ ALLOWED_GRANTS = ("rw", "ro", "none")
19
+ PERMITTED_GRANTS = ("rw", "ro")
20
+
21
+ DEFAULT_CONFIG = {
22
+ "callers": {
23
+ "*": {"default_namespace": "main", "grants": {"*": "rw"}},
24
+ },
25
+ "settings": {
26
+ "auto_lock_minutes": 30,
27
+ "include_packs_in_search": True,
28
+ "unlock_tool_enabled": False,
29
+ "duplicate_threshold": 0.97,
30
+ "index_precision": "f32",
31
+ },
32
+ }
33
+
34
+
35
+ class AclError(CryptoError):
36
+ pass
37
+
38
+
39
+ @dataclass
40
+ class VaultConfig:
41
+ # Deep copies: a shallow copy would hand every VaultConfig in the process
42
+ # the same inner grants dict, so editing one vault's ACLs would silently
43
+ # edit the module default and every other open vault.
44
+ callers: dict = field(
45
+ default_factory=lambda: copy.deepcopy(DEFAULT_CONFIG["callers"]))
46
+ settings: dict = field(
47
+ default_factory=lambda: copy.deepcopy(DEFAULT_CONFIG["settings"]))
48
+
49
+ @staticmethod
50
+ def path_for(vault_path: str) -> str:
51
+ return vault_path + ".config.json"
52
+
53
+ @classmethod
54
+ def load(cls, vault_path: str) -> "VaultConfig":
55
+ p = cls.path_for(vault_path)
56
+ if not os.path.exists(p):
57
+ return cls()
58
+ with open(p, encoding="utf-8") as f:
59
+ data = json.load(f)
60
+ cfg = cls()
61
+ cfg.callers = data.get("callers", cfg.callers)
62
+ cfg.settings = {**cfg.settings, **data.get("settings", {})}
63
+ return cfg
64
+
65
+ def save(self, vault_path: str) -> None:
66
+ with open(self.path_for(vault_path), "w", encoding="utf-8") as f:
67
+ json.dump({"callers": self.callers, "settings": self.settings}, f, indent=2)
68
+
69
+ # -- ACL ---------------------------------------------------------------
70
+
71
+ def _caller_entry(self, caller: str) -> dict:
72
+ # An entry that EXISTS is used as written, even when it is empty. Only
73
+ # a missing caller falls back to "*". Writing {"evil": {}} is how a
74
+ # caller gets locked down, so it must never inherit the wildcard.
75
+ if caller in self.callers:
76
+ entry = self.callers[caller]
77
+ else:
78
+ entry = self.callers.get("*")
79
+ if not isinstance(entry, dict):
80
+ raise AclError(f"Caller {caller!r} has no access to this vault")
81
+ return entry
82
+
83
+ def default_namespace(self, caller: str) -> str:
84
+ return self._caller_entry(caller).get("default_namespace", "main")
85
+
86
+ @staticmethod
87
+ def _match(grants: dict, namespace: str):
88
+ """The most specific grant for `namespace`, or None if nothing matches.
89
+
90
+ Specificity, not dict order, decides. An exact namespace key beats
91
+ every wildcard, and among wildcards the longest matching prefix wins,
92
+ so "secret/*" beats "*" and "a/b/*" beats "a/*". Note that "*" is
93
+ itself a wildcard whose prefix is empty: it is the weakest possible
94
+ match, never the first one taken.
95
+ """
96
+ if not isinstance(grants, dict):
97
+ return None
98
+ if namespace in grants:
99
+ return grants[namespace]
100
+ best_len = -1
101
+ best = None
102
+ for pattern, g in grants.items():
103
+ if not isinstance(pattern, str) or not pattern.endswith("*"):
104
+ continue
105
+ prefix = pattern[:-1]
106
+ if namespace.startswith(prefix) and len(prefix) > best_len:
107
+ best_len, best = len(prefix), g
108
+ return best
109
+
110
+ def grant_for(self, caller: str, namespace: str) -> str:
111
+ """Returns 'rw' or 'ro'. Anything else raises AclError.
112
+
113
+ 'none' and any value outside the documented rw/ro/none vocabulary deny
114
+ the namespace outright - reads included. packs/* is always ro.
115
+ """
116
+ entry = self._caller_entry(caller)
117
+ grant = self._match(entry.get("grants", {}), namespace)
118
+ if grant is None:
119
+ raise AclError(
120
+ f"Caller {caller!r} is not granted access to namespace {namespace!r}")
121
+ if grant not in PERMITTED_GRANTS:
122
+ if grant in ALLOWED_GRANTS: # explicit "none"
123
+ raise AclError(
124
+ f"Caller {caller!r} is denied access to namespace "
125
+ f"{namespace!r}")
126
+ raise AclError(
127
+ f"Caller {caller!r} has an unrecognized grant {grant!r} for "
128
+ f"namespace {namespace!r}; expected one of "
129
+ f"{', '.join(ALLOWED_GRANTS)}")
130
+ if namespace.startswith("packs/"):
131
+ return "ro" # pack namespaces are immutable for everyone
132
+ return grant
133
+
134
+ def check(self, caller: str, namespace: str, write: bool) -> None:
135
+ # grant_for already denies reads for 'none' and for unknown values, so
136
+ # this only has to police the read-only case on writes.
137
+ grant = self.grant_for(caller, namespace)
138
+ if write and grant != "rw":
139
+ raise AclError(
140
+ f"Caller {caller!r} has read-only access to namespace {namespace!r}")
@@ -0,0 +1,223 @@
1
+ """Hash-chained, tamper-evident audit log (stored inside the sealed payload).
2
+
3
+ Each entry's hash covers the previous entry's hash, so an edit or a reordering
4
+ in the MIDDLE of the log breaks the chain at a detectable point.
5
+
6
+ A forward-only chain cannot catch a truncation of its own tail: lop the last
7
+ three entries off and what remains still verifies, just shorter. So the head
8
+ and the length are anchored outside the chain, in the meta table, every time
9
+ the vault is saved. verify() then requires the chain to EXTEND that anchor:
10
+ fewer entries than were anchored, or a different hash at the anchored
11
+ position, is reported as removal rather than passing as a clean shorter log.
12
+
13
+ The anchor is written on save and is absent in vaults written by older
14
+ builds. A missing anchor is not a failure - it is an unanchored log, reported
15
+ as such, and the next save anchors it.
16
+
17
+ A failure to write the audit entry fails the operation (fail-fast), never
18
+ the other way around.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ import time
25
+
26
+ GENESIS = "GENESIS"
27
+
28
+ # meta keys holding the anchor. Additive: a vault without them predates
29
+ # anchoring and still opens.
30
+ ANCHOR_HEAD = "audit_anchor_head"
31
+ ANCHOR_COUNT = "audit_anchor_count"
32
+ RELINK_LOG = "audit_relink_log"
33
+
34
+
35
+ def _meta_get(conn, k: str) -> str | None:
36
+ row = conn.execute("SELECT v FROM meta WHERE k = ?", (k,)).fetchone()
37
+ return row["v"] if row else None
38
+
39
+
40
+ def _meta_set(conn, k: str, v: str) -> None:
41
+ conn.execute(
42
+ "INSERT INTO meta (k, v) VALUES (?, ?) "
43
+ "ON CONFLICT(k) DO UPDATE SET v = excluded.v", (k, v))
44
+
45
+
46
+ def count(conn) -> int:
47
+ return conn.execute("SELECT COUNT(*) c FROM audit").fetchone()["c"]
48
+
49
+
50
+ def anchor(conn) -> None:
51
+ """Pin the current head and length. Called on every save."""
52
+ _meta_set(conn, ANCHOR_HEAD, head(conn))
53
+ _meta_set(conn, ANCHOR_COUNT, str(count(conn)))
54
+
55
+
56
+ def read_anchor(conn) -> tuple[str, int] | None:
57
+ """The pinned (head, count), or None for a vault written before anchoring."""
58
+ h = _meta_get(conn, ANCHOR_HEAD)
59
+ c = _meta_get(conn, ANCHOR_COUNT)
60
+ if h is None or c is None:
61
+ return None
62
+ try:
63
+ return h, int(c)
64
+ except ValueError:
65
+ return None
66
+
67
+
68
+ def _entry_hash(prev_hash: str, ts: float, caller: str, op: str, detail: str) -> str:
69
+ body = json.dumps(
70
+ {"prev": prev_hash, "ts": ts, "caller": caller, "op": op, "detail": detail},
71
+ sort_keys=True, separators=(",", ":"),
72
+ ).encode()
73
+ return hashlib.sha256(body).hexdigest()
74
+
75
+
76
+ def append(conn, caller: str, op: str, detail: str, ts: float | None = None) -> str:
77
+ """Add an entry, linked to whatever is currently at the head.
78
+
79
+ `ts` is only passed when replaying a journalled operation, so the entry
80
+ keeps the time it actually happened. It is still linked to the head as it
81
+ stands at replay time, never to the head the original writer saw: that
82
+ head may never have reached disk (read operations are audited in RAM and
83
+ persist only on save), and chaining to an entry nobody else has is what
84
+ leaves a dangling link no future verify can resolve."""
85
+ row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
86
+ prev = row["hash"] if row else GENESIS
87
+ ts = time.time() if ts is None else ts
88
+ h = _entry_hash(prev, ts, caller, op, detail)
89
+ conn.execute(
90
+ "INSERT INTO audit (ts, caller, op, detail, prev_hash, hash) VALUES (?,?,?,?,?,?)",
91
+ (ts, caller, op, detail, prev, h),
92
+ )
93
+ return h
94
+
95
+
96
+ def head(conn) -> str:
97
+ row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
98
+ return row["hash"] if row else GENESIS
99
+
100
+
101
+ def verify(conn) -> tuple[bool, int, str]:
102
+ """Walk the chain, then check it against the anchor.
103
+
104
+ Returns (ok, entries_checked, message). The walk catches edits and
105
+ reorderings; the anchor catches a truncated tail, which the walk alone
106
+ cannot see because a shortened chain is still internally consistent."""
107
+ prev = GENESIS
108
+ n = 0
109
+ hashes: list[str] = []
110
+ for row in conn.execute("SELECT * FROM audit ORDER BY seq"):
111
+ if row["prev_hash"] != prev:
112
+ return False, n, f"chain break at seq {row['seq']}: prev_hash mismatch"
113
+ want = _entry_hash(prev, row["ts"], row["caller"], row["op"], row["detail"])
114
+ if row["hash"] != want:
115
+ return False, n, f"chain break at seq {row['seq']}: entry hash mismatch"
116
+ prev = row["hash"]
117
+ hashes.append(prev)
118
+ n += 1
119
+
120
+ anc = read_anchor(conn)
121
+ if anc is None:
122
+ # Every vault is anchored at creation and re-anchored on every save.
123
+ # A missing anchor means the meta rows were removed, which is exactly
124
+ # the move someone makes to hide a truncation, so it is a failure and
125
+ # not a tolerated older shape.
126
+ return False, n, (
127
+ "audit log has no anchor. Every vault is anchored when it is "
128
+ "created and on every save, so an absent anchor means the meta "
129
+ "rows were removed. A truncated log cannot be detected without "
130
+ "it, so this is reported as tampering.")
131
+ a_head, a_count = anc
132
+ if n < a_count:
133
+ return False, n, (
134
+ f"audit log is SHORTER than its anchor: {n} entries present, "
135
+ f"{a_count} anchored at the last save. {a_count - n} entries were "
136
+ "removed from the end. A forward walk cannot see this, which is "
137
+ "why the length is pinned.")
138
+ if a_count == 0:
139
+ pinned_ok = True
140
+ else:
141
+ pinned_ok = len(hashes) >= a_count and hashes[a_count - 1] == a_head
142
+ if not pinned_ok:
143
+ return False, n, (
144
+ f"audit entry {a_count} does not match the anchored head: history "
145
+ "was rewritten at or before the last save, not merely appended to.")
146
+ return True, n, f"audit chain intact ({n} entries, anchored at {a_count})"
147
+
148
+
149
+ def relink(conn) -> tuple[int, int | None]:
150
+ """Re-link entries whose prev_hash points at something not in the log.
151
+
152
+ Only for repairing damage done by builds before this one, which replayed a
153
+ journalled entry with the head its original writer saw rather than the head
154
+ the log actually has. The result was a permanent dangling link: verify
155
+ stopped at it and never reported anything after it again.
156
+
157
+ What this does NOT do is paper over an edit. Every entry's own hash covers
158
+ its content, so content tampering is caught by the self-hash check and this
159
+ refuses to touch a log that fails it. Only the links are rebuilt, and only
160
+ forward from the first break. Nothing is deleted, reordered or reworded:
161
+ ts, caller, op and detail are exactly what they were.
162
+
163
+ Returns (entries relinked, seq of the first break) - (0, None) if intact."""
164
+ rows = conn.execute("SELECT * FROM audit ORDER BY seq").fetchall()
165
+ for row in rows:
166
+ want = _entry_hash(row["prev_hash"], row["ts"], row["caller"], row["op"],
167
+ row["detail"])
168
+ if row["hash"] != want:
169
+ raise ValueError(
170
+ f"audit entry seq {row['seq']} does not hash to its own content. "
171
+ "That is content tampering, not a dangling link, and relinking "
172
+ "would destroy the evidence. Refusing.")
173
+
174
+ # A dangling link is damage worth repairing. A missing entry is not: the
175
+ # self-hash check above passes for a log with rows deleted, because every
176
+ # surviving row still hashes to its own content, and relinking would then
177
+ # rewrite the chain around the hole and report it as intact. The anchor is
178
+ # the only thing that knows how long the log used to be.
179
+ anc = read_anchor(conn)
180
+ if anc is not None and len(rows) < anc[1]:
181
+ raise ValueError(
182
+ f"audit log has {len(rows)} entries but {anc[1]} were anchored at "
183
+ "the last save. Entries were deleted, not merely unlinked, and "
184
+ "relinking would rewrite the chain around the gap and call it "
185
+ "intact. Refusing.")
186
+
187
+ prev, first, changed = GENESIS, None, 0
188
+ for row in rows:
189
+ if row["prev_hash"] != prev:
190
+ if first is None:
191
+ first = row["seq"]
192
+ if first is not None: # rewrite everything after it
193
+ h = _entry_hash(prev, row["ts"], row["caller"], row["op"], row["detail"])
194
+ conn.execute("UPDATE audit SET prev_hash = ?, hash = ? WHERE seq = ?",
195
+ (prev, h, row["seq"]))
196
+ changed += 1
197
+ prev = h
198
+ else:
199
+ prev = row["hash"]
200
+
201
+ if changed:
202
+ # Leave a permanent, non-erasable record that the chain was rebuilt.
203
+ # It goes in meta rather than in the log itself: appending an entry
204
+ # would change the log's length, and the repair must not look like
205
+ # ordinary activity. Anyone auditing later can see that a repair
206
+ # happened, when, where it started, and what the head was before.
207
+ try:
208
+ prior = json.loads(_meta_get(conn, RELINK_LOG) or "[]")
209
+ except (ValueError, TypeError):
210
+ prior = []
211
+ prior.append({"when": time.time(), "first_break_seq": first,
212
+ "entries_relinked": changed,
213
+ "head_before": rows[-1]["hash"], "head_after": prev})
214
+ _meta_set(conn, RELINK_LOG, json.dumps(prior))
215
+ return changed, first
216
+
217
+
218
+ def relink_history(conn) -> list[dict]:
219
+ """Every repair ever performed on this log. Empty is the normal case."""
220
+ try:
221
+ return json.loads(_meta_get(conn, RELINK_LOG) or "[]")
222
+ except (ValueError, TypeError):
223
+ return []