compartment 2.2__tar.gz → 2.3__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 (65) hide show
  1. {compartment-2.2 → compartment-2.3}/PKG-INFO +1 -1
  2. {compartment-2.2 → compartment-2.3}/pyproject.toml +1 -1
  3. {compartment-2.2 → compartment-2.3}/src/compartment/__init__.py +1 -1
  4. compartment-2.3/src/compartment/audit.py +103 -0
  5. {compartment-2.2 → compartment-2.3}/src/compartment/cli.py +21 -0
  6. {compartment-2.2 → compartment-2.3}/src/compartment/vault.py +7 -5
  7. {compartment-2.2 → compartment-2.3}/src/compartment.egg-info/PKG-INFO +1 -1
  8. {compartment-2.2 → compartment-2.3}/src/compartment.egg-info/SOURCES.txt +1 -0
  9. compartment-2.3/tests/test_audit_chain.py +123 -0
  10. compartment-2.2/src/compartment/audit.py +0 -54
  11. {compartment-2.2 → compartment-2.3}/README.md +0 -0
  12. {compartment-2.2 → compartment-2.3}/setup.cfg +0 -0
  13. {compartment-2.2 → compartment-2.3}/src/compartment/acl.py +0 -0
  14. {compartment-2.2 → compartment-2.3}/src/compartment/bench.py +0 -0
  15. {compartment-2.2 → compartment-2.3}/src/compartment/claude_hooks.py +0 -0
  16. {compartment-2.2 → compartment-2.3}/src/compartment/claude_memory.py +0 -0
  17. {compartment-2.2 → compartment-2.3}/src/compartment/crypto.py +0 -0
  18. {compartment-2.2 → compartment-2.3}/src/compartment/dash.py +0 -0
  19. {compartment-2.2 → compartment-2.3}/src/compartment/data/hermes-plugin/__init__.py +0 -0
  20. {compartment-2.2 → compartment-2.3}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
  21. {compartment-2.2 → compartment-2.3}/src/compartment/data/menubar.png +0 -0
  22. {compartment-2.2 → compartment-2.3}/src/compartment/data/menubar@2x.png +0 -0
  23. {compartment-2.2 → compartment-2.3}/src/compartment/data/starter.mpack +0 -0
  24. {compartment-2.2 → compartment-2.3}/src/compartment/data/tray.ico +0 -0
  25. {compartment-2.2 → compartment-2.3}/src/compartment/embed.py +0 -0
  26. {compartment-2.2 → compartment-2.3}/src/compartment/home.py +0 -0
  27. {compartment-2.2 → compartment-2.3}/src/compartment/longmemeval.py +0 -0
  28. {compartment-2.2 → compartment-2.3}/src/compartment/menubar.py +0 -0
  29. {compartment-2.2 → compartment-2.3}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
  30. {compartment-2.2 → compartment-2.3}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
  31. {compartment-2.2 → compartment-2.3}/src/compartment/offline_guard.py +0 -0
  32. {compartment-2.2 → compartment-2.3}/src/compartment/packs.py +0 -0
  33. {compartment-2.2 → compartment-2.3}/src/compartment/platforms.py +0 -0
  34. {compartment-2.2 → compartment-2.3}/src/compartment/salience.py +0 -0
  35. {compartment-2.2 → compartment-2.3}/src/compartment/selftest.py +0 -0
  36. {compartment-2.2 → compartment-2.3}/src/compartment/server.py +0 -0
  37. {compartment-2.2 → compartment-2.3}/src/compartment/session.py +0 -0
  38. {compartment-2.2 → compartment-2.3}/src/compartment/store.py +0 -0
  39. {compartment-2.2 → compartment-2.3}/src/compartment/systray.py +0 -0
  40. {compartment-2.2 → compartment-2.3}/src/compartment/vaultfile.py +0 -0
  41. {compartment-2.2 → compartment-2.3}/src/compartment/vindex.py +0 -0
  42. {compartment-2.2 → compartment-2.3}/src/compartment/wire.py +0 -0
  43. {compartment-2.2 → compartment-2.3}/src/compartment.egg-info/dependency_links.txt +0 -0
  44. {compartment-2.2 → compartment-2.3}/src/compartment.egg-info/entry_points.txt +0 -0
  45. {compartment-2.2 → compartment-2.3}/src/compartment.egg-info/requires.txt +0 -0
  46. {compartment-2.2 → compartment-2.3}/src/compartment.egg-info/top_level.txt +0 -0
  47. {compartment-2.2 → compartment-2.3}/tests/test_acl_audit_packs.py +0 -0
  48. {compartment-2.2 → compartment-2.3}/tests/test_claude_hooks_and_integrate.py +0 -0
  49. {compartment-2.2 → compartment-2.3}/tests/test_claude_memory_and_recent.py +0 -0
  50. {compartment-2.2 → compartment-2.3}/tests/test_crypto.py +0 -0
  51. {compartment-2.2 → compartment-2.3}/tests/test_dash.py +0 -0
  52. {compartment-2.2 → compartment-2.3}/tests/test_instructions.py +0 -0
  53. {compartment-2.2 → compartment-2.3}/tests/test_macos_bundle.py +0 -0
  54. {compartment-2.2 → compartment-2.3}/tests/test_menubar.py +0 -0
  55. {compartment-2.2 → compartment-2.3}/tests/test_offline_selftest_bench.py +0 -0
  56. {compartment-2.2 → compartment-2.3}/tests/test_ondisk_format.py +0 -0
  57. {compartment-2.2 → compartment-2.3}/tests/test_platforms.py +0 -0
  58. {compartment-2.2 → compartment-2.3}/tests/test_relations.py +0 -0
  59. {compartment-2.2 → compartment-2.3}/tests/test_salience.py +0 -0
  60. {compartment-2.2 → compartment-2.3}/tests/test_session_lock.py +0 -0
  61. {compartment-2.2 → compartment-2.3}/tests/test_starter_packs.py +0 -0
  62. {compartment-2.2 → compartment-2.3}/tests/test_systray.py +0 -0
  63. {compartment-2.2 → compartment-2.3}/tests/test_tamper.py +0 -0
  64. {compartment-2.2 → compartment-2.3}/tests/test_twofa.py +0 -0
  65. {compartment-2.2 → compartment-2.3}/tests/test_vault_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 2.2
3
+ Version: 2.3
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compartment"
7
- version = "2.2"
7
+ version = "2.3"
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"
@@ -1,6 +1,6 @@
1
1
  """Compartment - high-security, fully offline, encrypted vector memory for AI agents."""
2
2
 
3
- __version__ = "2.2"
3
+ __version__ = "2.3"
4
4
 
5
5
  from . import offline_guard as _og
6
6
 
@@ -0,0 +1,103 @@
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 any edit, deletion,
4
+ or reordering of history breaks the chain at a detectable point.
5
+ A failure to write the audit entry fails the operation (fail-fast), never
6
+ the other way around.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import time
13
+
14
+ GENESIS = "GENESIS"
15
+
16
+
17
+ def _entry_hash(prev_hash: str, ts: float, caller: str, op: str, detail: str) -> str:
18
+ body = json.dumps(
19
+ {"prev": prev_hash, "ts": ts, "caller": caller, "op": op, "detail": detail},
20
+ sort_keys=True, separators=(",", ":"),
21
+ ).encode()
22
+ return hashlib.sha256(body).hexdigest()
23
+
24
+
25
+ def append(conn, caller: str, op: str, detail: str, ts: float | None = None) -> str:
26
+ """Add an entry, linked to whatever is currently at the head.
27
+
28
+ `ts` is only passed when replaying a journalled operation, so the entry
29
+ keeps the time it actually happened. It is still linked to the head as it
30
+ stands at replay time, never to the head the original writer saw: that
31
+ head may never have reached disk (read operations are audited in RAM and
32
+ persist only on save), and chaining to an entry nobody else has is what
33
+ leaves a dangling link no future verify can resolve."""
34
+ row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
35
+ prev = row["hash"] if row else GENESIS
36
+ ts = time.time() if ts is None else ts
37
+ h = _entry_hash(prev, ts, caller, op, detail)
38
+ conn.execute(
39
+ "INSERT INTO audit (ts, caller, op, detail, prev_hash, hash) VALUES (?,?,?,?,?,?)",
40
+ (ts, caller, op, detail, prev, h),
41
+ )
42
+ return h
43
+
44
+
45
+ def head(conn) -> str:
46
+ row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
47
+ return row["hash"] if row else GENESIS
48
+
49
+
50
+ def verify(conn) -> tuple[bool, int, str]:
51
+ """Walk the chain. Returns (ok, entries_checked, message)."""
52
+ prev = GENESIS
53
+ n = 0
54
+ for row in conn.execute("SELECT * FROM audit ORDER BY seq"):
55
+ if row["prev_hash"] != prev:
56
+ return False, n, f"chain break at seq {row['seq']}: prev_hash mismatch"
57
+ want = _entry_hash(prev, row["ts"], row["caller"], row["op"], row["detail"])
58
+ if row["hash"] != want:
59
+ return False, n, f"chain break at seq {row['seq']}: entry hash mismatch"
60
+ prev = row["hash"]
61
+ n += 1
62
+ return True, n, f"audit chain intact ({n} entries)"
63
+
64
+
65
+ def relink(conn) -> tuple[int, int | None]:
66
+ """Re-link entries whose prev_hash points at something not in the log.
67
+
68
+ Only for repairing damage done by builds before this one, which replayed a
69
+ journalled entry with the head its original writer saw rather than the head
70
+ the log actually has. The result was a permanent dangling link: verify
71
+ stopped at it and never reported anything after it again.
72
+
73
+ What this does NOT do is paper over an edit. Every entry's own hash covers
74
+ its content, so content tampering is caught by the self-hash check and this
75
+ refuses to touch a log that fails it. Only the links are rebuilt, and only
76
+ forward from the first break. Nothing is deleted, reordered or reworded:
77
+ ts, caller, op and detail are exactly what they were.
78
+
79
+ Returns (entries relinked, seq of the first break) - (0, None) if intact."""
80
+ rows = conn.execute("SELECT * FROM audit ORDER BY seq").fetchall()
81
+ for row in rows:
82
+ want = _entry_hash(row["prev_hash"], row["ts"], row["caller"], row["op"],
83
+ row["detail"])
84
+ if row["hash"] != want:
85
+ raise ValueError(
86
+ f"audit entry seq {row['seq']} does not hash to its own content. "
87
+ "That is content tampering, not a dangling link, and relinking "
88
+ "would destroy the evidence. Refusing.")
89
+
90
+ prev, first, changed = GENESIS, None, 0
91
+ for row in rows:
92
+ if row["prev_hash"] != prev:
93
+ if first is None:
94
+ first = row["seq"]
95
+ if first is not None: # rewrite everything after it
96
+ h = _entry_hash(prev, row["ts"], row["caller"], row["op"], row["detail"])
97
+ conn.execute("UPDATE audit SET prev_hash = ?, hash = ? WHERE seq = ?",
98
+ (prev, h, row["seq"]))
99
+ changed += 1
100
+ prev = h
101
+ else:
102
+ prev = row["hash"]
103
+ return changed, first
@@ -468,6 +468,25 @@ def cmd_audit(args) -> None:
468
468
  sys.exit(2)
469
469
 
470
470
 
471
+ def cmd_audit_repair(args) -> None:
472
+ v = _open_vault(args)
473
+ try:
474
+ changed, first = audit.relink(v.db.conn)
475
+ except ValueError as exc:
476
+ _die(str(exc))
477
+ if not changed:
478
+ _print({"ok": True, "relinked": 0, "message": "audit chain already intact"})
479
+ return
480
+ v._audit_and_capture("user", "audit-repair",
481
+ f"relinked {changed} entries from seq {first}")
482
+ v.save()
483
+ ok, n, msg = audit.verify(v.db.conn)
484
+ _print({"ok": ok, "relinked": changed, "first_break_at_seq": first,
485
+ "entries": n, "message": msg, "head": audit.head(v.db.conn)})
486
+ if not ok:
487
+ sys.exit(2)
488
+
489
+
471
490
  def cmd_verify(args) -> None:
472
491
  loaded = read_vault_file(args.vault) # structure + format checks
473
492
  out = {"vault": args.vault, "format": "ok",
@@ -1165,6 +1184,8 @@ def main(argv: list[str] | None = None) -> None:
1165
1184
  pa_sub = pa.add_subparsers(dest="audit_cmd", required=True)
1166
1185
  p = pa_sub.add_parser("verify", help="verify the hash chain")
1167
1186
  p.set_defaults(fn=cmd_audit)
1187
+ p = pa_sub.add_parser("repair", help="re-link a chain broken by an older build")
1188
+ p.set_defaults(fn=cmd_audit_repair)
1168
1189
 
1169
1190
  p = sub.add_parser("verify", help="check vault structure + signed manifest")
1170
1191
  p.set_defaults(fn=cmd_verify)
@@ -359,12 +359,14 @@ class Vault:
359
359
  self.db.delete_relation(e["id"])
360
360
  else:
361
361
  raise TamperError(f"Unknown journal op {op!r}")
362
+ # Link to the head this log actually has, keeping the original time.
363
+ # The writer's own head may never have reached disk - a read is audited
364
+ # in RAM and persists only on save - so replaying its prev_hash
365
+ # verbatim leaves a link pointing at an entry no one else has, and
366
+ # verify stops there permanently. The journal entry's contents are
367
+ # already AEAD-authenticated, so nothing is lost by re-linking.
362
368
  a = e["audit"]
363
- self.db.conn.execute(
364
- "INSERT INTO audit (ts, caller, op, detail, prev_hash, hash)"
365
- " VALUES (?,?,?,?,?,?)",
366
- (a["ts"], a["caller"], a["op"], a["detail"], a["prev_hash"], a["hash"]),
367
- )
369
+ audit.append(self.db.conn, a["caller"], a["op"], a["detail"], ts=a["ts"])
368
370
 
369
371
  def _audit_and_capture(self, caller: str, op: str, detail: str) -> dict:
370
372
  audit.append(self.db.conn, caller, op, detail)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 2.2
3
+ Version: 2.3
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
@@ -41,6 +41,7 @@ src/compartment/data/hermes-plugin/plugin.yaml
41
41
  src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx
42
42
  src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json
43
43
  tests/test_acl_audit_packs.py
44
+ tests/test_audit_chain.py
44
45
  tests/test_claude_hooks_and_integrate.py
45
46
  tests/test_claude_memory_and_recent.py
46
47
  tests/test_crypto.py
@@ -0,0 +1,123 @@
1
+ """The audit chain has to survive ordinary multi-process use.
2
+
3
+ It did not. A write is journalled the moment it happens; a read is audited in
4
+ RAM and only reaches disk on the next save. So a `store` would chain off a
5
+ `search` entry that no other process had, its journal entry was replayed
6
+ verbatim into a log missing that entry, and the link dangled. verify() stopped
7
+ there and never reported on anything after it again.
8
+
9
+ That is not a hypothetical either: it happened five times in one day on a real
10
+ vault, every one of them a `store` from a long-running MCP server.
11
+ """
12
+ import pytest
13
+
14
+ from compartment import audit, store, vaultfile
15
+ from compartment.vault import Vault
16
+
17
+ from conftest import PASS
18
+
19
+
20
+ def _audit_rows(conn):
21
+ return conn.execute("SELECT * FROM audit ORDER BY seq").fetchall()
22
+
23
+
24
+ def test_replaying_a_journalled_write_keeps_the_chain_linked(vault_path):
25
+ """The regression. A read between saves used to poison the next write."""
26
+ v = Vault.create(vault_path, PASS, creator="test")
27
+ v.save()
28
+
29
+ # a read: audited in RAM, deliberately not journalled and not saved
30
+ v.search("anything", caller="test")
31
+ # a write: journalled immediately, chaining off that RAM-only entry
32
+ v.store("a memory written after a search", caller="test")
33
+ # crash before save, so the payload never learns about either entry
34
+ del v
35
+
36
+ reopened = Vault.unlock(vault_path, passphrase=PASS)
37
+ ok, n, msg = audit.verify(reopened.db.conn)
38
+ assert ok, msg
39
+ texts = {reopened.db.decrypt_text(r, reopened._master)
40
+ for r in reopened.db.conn.execute("SELECT * FROM records")}
41
+ assert "a memory written after a search" in texts
42
+
43
+
44
+ def test_replay_preserves_when_the_operation_happened(vault_path):
45
+ v = Vault.create(vault_path, PASS, creator="test")
46
+ v.save()
47
+ v.store("timed", caller="test")
48
+ stored_at = _audit_rows(v.db.conn)[-1]["ts"]
49
+ del v
50
+
51
+ reopened = Vault.unlock(vault_path, passphrase=PASS)
52
+ replayed = [r for r in _audit_rows(reopened.db.conn) if r["op"] == "store"][-1]
53
+ assert replayed["ts"] == stored_at, "replay must not restamp history"
54
+
55
+
56
+ def test_append_links_to_the_head_it_can_see(vault_path):
57
+ v = Vault.create(vault_path, PASS, creator="test")
58
+ conn = v.db.conn
59
+ audit.append(conn, "a", "op1", "one")
60
+ head = audit.head(conn)
61
+ audit.append(conn, "b", "op2", "two", ts=123.0)
62
+ last = _audit_rows(conn)[-1]
63
+ assert last["prev_hash"] == head
64
+ assert last["ts"] == 123.0
65
+ ok, _, msg = audit.verify(conn)
66
+ assert ok, msg
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Repairing a log an older build already broke
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def _break_the_chain(conn):
74
+ """Exactly the old damage: a dangling prev_hash, contents untouched."""
75
+ rows = _audit_rows(conn)
76
+ victim = rows[len(rows) // 2]
77
+ bad = "f" * 64
78
+ h = audit._entry_hash(bad, victim["ts"], victim["caller"], victim["op"],
79
+ victim["detail"])
80
+ conn.execute("UPDATE audit SET prev_hash = ?, hash = ? WHERE seq = ?",
81
+ (bad, h, victim["seq"]))
82
+ return victim["seq"]
83
+
84
+
85
+ def test_relink_repairs_a_dangling_link_and_keeps_every_entry(vault):
86
+ conn = vault.db.conn
87
+ for i in range(6):
88
+ audit.append(conn, "test", "store", f"entry {i}")
89
+ before = [(r["ts"], r["caller"], r["op"], r["detail"]) for r in _audit_rows(conn)]
90
+
91
+ broken_at = _break_the_chain(conn)
92
+ assert not audit.verify(conn)[0]
93
+
94
+ changed, first = audit.relink(conn)
95
+ assert first == broken_at
96
+ assert changed >= 1
97
+ ok, n, msg = audit.verify(conn)
98
+ assert ok, msg
99
+ assert n == len(before)
100
+
101
+ after = [(r["ts"], r["caller"], r["op"], r["detail"]) for r in _audit_rows(conn)]
102
+ assert after == before, "relink must not alter what the log says"
103
+
104
+
105
+ def test_relink_is_a_no_op_on_an_intact_chain(vault):
106
+ conn = vault.db.conn
107
+ audit.append(conn, "test", "store", "x")
108
+ assert audit.relink(conn) == (0, None)
109
+ assert audit.verify(conn)[0]
110
+
111
+
112
+ def test_relink_refuses_to_hide_edited_content(vault):
113
+ """The distinction that matters: a dangling link is damage, a reworded
114
+ entry is tampering, and relinking the second would erase the proof."""
115
+ conn = vault.db.conn
116
+ audit.append(conn, "test", "forget", "id=secret-thing")
117
+ rows = _audit_rows(conn)
118
+ conn.execute("UPDATE audit SET detail = ? WHERE seq = ?",
119
+ ("id=something-harmless", rows[-1]["seq"]))
120
+
121
+ with pytest.raises(ValueError, match="content tampering"):
122
+ audit.relink(conn)
123
+ assert not audit.verify(conn)[0], "and it stays broken, as it should"
@@ -1,54 +0,0 @@
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 any edit, deletion,
4
- or reordering of history breaks the chain at a detectable point.
5
- A failure to write the audit entry fails the operation (fail-fast), never
6
- the other way around.
7
- """
8
- from __future__ import annotations
9
-
10
- import hashlib
11
- import json
12
- import time
13
-
14
- GENESIS = "GENESIS"
15
-
16
-
17
- def _entry_hash(prev_hash: str, ts: float, caller: str, op: str, detail: str) -> str:
18
- body = json.dumps(
19
- {"prev": prev_hash, "ts": ts, "caller": caller, "op": op, "detail": detail},
20
- sort_keys=True, separators=(",", ":"),
21
- ).encode()
22
- return hashlib.sha256(body).hexdigest()
23
-
24
-
25
- def append(conn, caller: str, op: str, detail: str) -> str:
26
- row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
27
- prev = row["hash"] if row else GENESIS
28
- ts = time.time()
29
- h = _entry_hash(prev, ts, caller, op, detail)
30
- conn.execute(
31
- "INSERT INTO audit (ts, caller, op, detail, prev_hash, hash) VALUES (?,?,?,?,?,?)",
32
- (ts, caller, op, detail, prev, h),
33
- )
34
- return h
35
-
36
-
37
- def head(conn) -> str:
38
- row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
39
- return row["hash"] if row else GENESIS
40
-
41
-
42
- def verify(conn) -> tuple[bool, int, str]:
43
- """Walk the chain. Returns (ok, entries_checked, message)."""
44
- prev = GENESIS
45
- n = 0
46
- for row in conn.execute("SELECT * FROM audit ORDER BY seq"):
47
- if row["prev_hash"] != prev:
48
- return False, n, f"chain break at seq {row['seq']}: prev_hash mismatch"
49
- want = _entry_hash(prev, row["ts"], row["caller"], row["op"], row["detail"])
50
- if row["hash"] != want:
51
- return False, n, f"chain break at seq {row['seq']}: entry hash mismatch"
52
- prev = row["hash"]
53
- n += 1
54
- return True, n, f"audit chain intact ({n} entries)"
File without changes
File without changes
File without changes
File without changes