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.
- {compartment-2.3 → compartment-3.1}/PKG-INFO +23 -20
- {compartment-2.3 → compartment-3.1}/README.md +19 -16
- {compartment-2.3 → compartment-3.1}/pyproject.toml +17 -11
- {compartment-2.3 → compartment-3.1}/src/compartment/__init__.py +1 -1
- compartment-3.1/src/compartment/acl.py +140 -0
- compartment-3.1/src/compartment/audit.py +223 -0
- compartment-3.1/src/compartment/bench.py +158 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/claude_hooks.py +8 -1
- {compartment-2.3 → compartment-3.1}/src/compartment/cli.py +149 -14
- {compartment-2.3 → compartment-3.1}/src/compartment/crypto.py +59 -3
- {compartment-2.3 → compartment-3.1}/src/compartment/dash.py +285 -102
- {compartment-2.3 → compartment-3.1}/src/compartment/data/hermes-plugin/__init__.py +35 -22
- {compartment-2.3 → compartment-3.1}/src/compartment/data/starter.mpack +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/longmemeval.py +35 -14
- {compartment-2.3 → compartment-3.1}/src/compartment/menubar.py +65 -3
- compartment-3.1/src/compartment/offline_guard.py +307 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/packs.py +100 -13
- {compartment-2.3 → compartment-3.1}/src/compartment/platforms.py +23 -8
- compartment-3.1/src/compartment/server.py +532 -0
- compartment-3.1/src/compartment/session.py +531 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/systray.py +22 -1
- {compartment-2.3 → compartment-3.1}/src/compartment/vault.py +97 -17
- {compartment-2.3 → compartment-3.1}/src/compartment/vaultfile.py +79 -10
- {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/PKG-INFO +23 -20
- {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/SOURCES.txt +8 -0
- {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/requires.txt +7 -7
- {compartment-2.3 → compartment-3.1}/tests/test_acl_audit_packs.py +14 -8
- compartment-3.1/tests/test_acl_precedence.py +246 -0
- compartment-3.1/tests/test_attempt_pacing.py +96 -0
- {compartment-2.3 → compartment-3.1}/tests/test_claude_hooks_and_integrate.py +33 -0
- compartment-3.1/tests/test_journal_framing.py +155 -0
- compartment-3.1/tests/test_offline_guard_paths.py +165 -0
- compartment-3.1/tests/test_offline_selftest_bench.py +178 -0
- {compartment-2.3 → compartment-3.1}/tests/test_ondisk_format.py +2 -1
- compartment-3.1/tests/test_pack_trust.py +188 -0
- compartment-3.1/tests/test_session_entropy.py +255 -0
- {compartment-2.3 → compartment-3.1}/tests/test_session_lock.py +20 -0
- compartment-3.1/tests/test_starter_data_quality.py +108 -0
- {compartment-2.3 → compartment-3.1}/tests/test_starter_packs.py +9 -9
- compartment-3.1/tests/test_status_bar_install.py +160 -0
- {compartment-2.3 → compartment-3.1}/tests/test_tamper.py +4 -3
- compartment-2.3/src/compartment/acl.py +0 -92
- compartment-2.3/src/compartment/audit.py +0 -103
- compartment-2.3/src/compartment/bench.py +0 -94
- compartment-2.3/src/compartment/offline_guard.py +0 -53
- compartment-2.3/src/compartment/server.py +0 -327
- compartment-2.3/src/compartment/session.py +0 -131
- compartment-2.3/tests/test_offline_selftest_bench.py +0 -81
- {compartment-2.3 → compartment-3.1}/setup.cfg +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/claude_memory.py +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/data/menubar.png +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/data/menubar@2x.png +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/data/tray.ico +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/embed.py +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/home.py +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/salience.py +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/selftest.py +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/store.py +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/vindex.py +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment/wire.py +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/dependency_links.txt +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/entry_points.txt +0 -0
- {compartment-2.3 → compartment-3.1}/src/compartment.egg-info/top_level.txt +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_audit_chain.py +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_claude_memory_and_recent.py +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_crypto.py +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_dash.py +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_instructions.py +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_macos_bundle.py +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_menubar.py +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_platforms.py +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_relations.py +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_salience.py +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_systray.py +0 -0
- {compartment-2.3 → compartment-3.1}/tests/test_twofa.py +0 -0
- {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:
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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) |
|
|
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
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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) |
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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 = "
|
|
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
|
|
68
|
-
#
|
|
69
|
-
# compartment[
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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]
|
|
@@ -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 []
|