methodproof 0.7.38__tar.gz → 0.8.0__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 (89) hide show
  1. {methodproof-0.7.38 → methodproof-0.8.0}/.github/workflows/ci.yml +6 -6
  2. {methodproof-0.7.38 → methodproof-0.8.0}/CHANGELOG.md +16 -0
  3. {methodproof-0.7.38 → methodproof-0.8.0}/PKG-INFO +2 -1
  4. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/agents/base.py +11 -1
  5. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/cli.py +76 -8
  6. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/crypto.py +17 -0
  7. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/e2e.py +74 -30
  8. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/claude_code.py +2 -4
  9. methodproof-0.8.0/methodproof/migrate_db.py +161 -0
  10. methodproof-0.8.0/methodproof/repos.py +70 -0
  11. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/store.py +77 -6
  12. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/sync.py +10 -1
  13. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/viewer.py +1 -1
  14. {methodproof-0.7.38 → methodproof-0.8.0}/pyproject.toml +2 -2
  15. {methodproof-0.7.38 → methodproof-0.8.0}/tests/conftest.py +1 -0
  16. methodproof-0.8.0/tests/test_repos.py +73 -0
  17. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_security.py +102 -1
  18. {methodproof-0.7.38 → methodproof-0.8.0}/uv.lock +168 -1
  19. methodproof-0.7.38/methodproof/migrate_db.py +0 -41
  20. methodproof-0.7.38/methodproof/repos.py +0 -25
  21. {methodproof-0.7.38 → methodproof-0.8.0}/.gitignore +0 -0
  22. {methodproof-0.7.38 → methodproof-0.8.0}/LICENSE +0 -0
  23. {methodproof-0.7.38 → methodproof-0.8.0}/README.md +0 -0
  24. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/__init__.py +0 -0
  25. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/__main__.py +0 -0
  26. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/_daemon.py +0 -0
  27. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/agents/__init__.py +0 -0
  28. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/agents/music.py +0 -0
  29. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/agents/terminal.py +0 -0
  30. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/agents/watcher.py +0 -0
  31. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/analysis.py +0 -0
  32. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/binding.py +0 -0
  33. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/bip39.py +0 -0
  34. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/bridge.py +0 -0
  35. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/config.py +0 -0
  36. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/graph.py +0 -0
  37. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hook.py +0 -0
  38. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/__init__.py +0 -0
  39. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/claude_code.sh +0 -0
  40. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/cline_hook.sh +0 -0
  41. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/codex_hook.sh +0 -0
  42. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/gemini_hook.sh +0 -0
  43. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/install.py +0 -0
  44. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/kiro_hook.sh +0 -0
  45. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/mcp_register.py +0 -0
  46. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/openclaw/HOOK.md +0 -0
  47. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/openclaw/handler.ts +0 -0
  48. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/openclaw_install.py +0 -0
  49. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/opencode_plugin.js +0 -0
  50. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/wrappers.py +0 -0
  51. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/integrity.py +0 -0
  52. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/kdf.py +0 -0
  53. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/keychain.py +0 -0
  54. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/live.py +0 -0
  55. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/lock.py +0 -0
  56. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/mcp.py +0 -0
  57. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/proxy.py +0 -0
  58. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/proxy_daemon.py +0 -0
  59. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/skills/methodproof/SKILL.md +0 -0
  60. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/__init__.py +0 -0
  61. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/consent.py +0 -0
  62. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/init.py +0 -0
  63. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/log.py +0 -0
  64. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/login_success.py +0 -0
  65. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/review.py +0 -0
  66. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/start.py +0 -0
  67. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/status.py +0 -0
  68. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/theme.py +0 -0
  69. {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/wordlist.py +0 -0
  70. {methodproof-0.7.38 → methodproof-0.8.0}/test_windows_compat.py +0 -0
  71. {methodproof-0.7.38 → methodproof-0.8.0}/tests/__init__.py +0 -0
  72. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_analysis.py +0 -0
  73. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_auth.py +0 -0
  74. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_config.py +0 -0
  75. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_helpers.py +0 -0
  76. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_session.py +0 -0
  77. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_share.py +0 -0
  78. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_start.py +0 -0
  79. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_update.py +0 -0
  80. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_e2e_integration.py +0 -0
  81. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_graph.py +0 -0
  82. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_hooks.py +0 -0
  83. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_live.py +0 -0
  84. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_openclaw_hooks.py +0 -0
  85. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_profiles.py +0 -0
  86. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_store.py +0 -0
  87. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_sync.py +0 -0
  88. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_viewer.py +0 -0
  89. {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_wrappers.py +0 -0
@@ -11,10 +11,10 @@ jobs:
11
11
  test:
12
12
  runs-on: ubuntu-latest
13
13
  steps:
14
- - uses: actions/checkout@v4
15
- - uses: astral-sh/setup-uv@v4
14
+ - uses: actions/checkout@v5
15
+ - uses: astral-sh/setup-uv@v7
16
16
  with: { version: "latest" }
17
- - uses: actions/setup-python@v5
17
+ - uses: actions/setup-python@v6
18
18
  with: { python-version: "3.12" }
19
19
  - run: uv sync --frozen
20
20
  - run: uv run pytest tests/ -v --tb=short
@@ -26,10 +26,10 @@ jobs:
26
26
  permissions:
27
27
  id-token: write
28
28
  steps:
29
- - uses: actions/checkout@v4
30
- - uses: astral-sh/setup-uv@v4
29
+ - uses: actions/checkout@v5
30
+ - uses: astral-sh/setup-uv@v7
31
31
  with: { version: "latest" }
32
- - uses: actions/setup-python@v5
32
+ - uses: actions/setup-python@v6
33
33
  with: { python-version: "3.12" }
34
34
  - run: uv build
35
35
  - uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0] — 2026-04-15
4
+
5
+ ### Added
6
+ - **SQLCipher encryption-at-rest for the local database** — the CLI's SQLite store is now transparently re-keyed with SQLCipher on first login, layered underneath the existing field-level AES on sensitive metadata. The db_key is derived from the master entropy via HKDF (`derive_master` → `derive_db_key`), never leaves the OS keychain, and is wired into `store._db()` via `PRAGMA key` before any other statement.
7
+ - **One-shot plaintext → encrypted migration** — `migrate_db.migrate_to_sqlcipher()` uses the `sqlcipher_export()` recipe, row-count-verifies every table, atomically swaps the original DB to `methodproof.db.plaintext.bak` (kept for recovery), cleans up stale WAL/SHM artifacts, and writes a `methodproof.db.encrypted` sentinel. Runs once on first login for existing users, idempotent on re-run.
8
+ - **Daemon-liveness guard** — `DaemonActiveError` is raised if a recording daemon is still holding FDs on the plaintext DB. Callers must `mp stop` first.
9
+ - **`mp e2e release-all`** — releases every synced session with E2E-encrypted fields in one pass. Skips sessions with nothing to release, reports released/skipped/failed counts.
10
+ - **`decrypt_metadata_safe`** — new read-path helper in `crypto.py` that silently leaves fields encrypted with a different key (user E2E vs master) as ciphertext, so mixed-key sessions render correctly without crashing.
11
+ - **Auto-decrypt on read** — `store.get_events(decrypt=True)` and `get_session_events` now auto-decrypt sensitive metadata when the master db_key is available in the keychain. Sync push and `e2e release` still read ciphertext verbatim (`decrypt=False`).
12
+
13
+ ### Fixed
14
+ - **`mp push` crash on multi-session list** — `s["created_at"]` is stored as SQLite `REAL` (float epoch), but the unsynced-session listing sliced it as an ISO string and raised `TypeError: 'float' object is not subscriptable`. Now formatted with `datetime.fromtimestamp(...).strftime("%Y-%m-%d")`.
15
+
16
+ ### Why
17
+ Field-level AES protects six sensitive metadata fields, but everything else (event types, timestamps, file paths, session structure) sat in plaintext SQLite on disk. SQLCipher closes that gap without changing the API surface: `sqlcipher3.dbapi2` is drop-in compatible with stdlib `sqlite3`, so the rest of the store is unchanged. The two layers compose: SQLCipher protects data at rest end-to-end, field-level AES adds defense-in-depth for the most sensitive fields even if the db_key is compromised.
18
+
3
19
  ## [0.7.38] — 2026-04-12
4
20
 
5
21
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodproof
3
- Version: 0.7.38
3
+ Version: 0.8.0
4
4
  Summary: See how you code. Capture and visualize your engineering process.
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -8,6 +8,7 @@ Requires-Python: >=3.11
8
8
  Requires-Dist: cryptography>=43.0
9
9
  Requires-Dist: keyring>=25.0
10
10
  Requires-Dist: rich>=13.7
11
+ Requires-Dist: sqlcipher3>=0.6
11
12
  Requires-Dist: textual>=0.59
12
13
  Requires-Dist: watchdog>=4.0
13
14
  Requires-Dist: websocket-client>=1.7
@@ -108,9 +108,19 @@ def init(session_id: str, live: bool = False, verbose: bool = False, streaming:
108
108
  _verbose = verbose
109
109
  _streaming = streaming
110
110
  _prev_hash = "genesis"
111
- from methodproof import config
111
+ from methodproof import config, store
112
112
  cfg = config.load()
113
113
  _e2e_key = _load_encryption_key(cfg)
114
+ # Daemon is a fresh process — wire the SQLCipher key (always master-derived
115
+ # db_key, never user E2E) into the store before any DB write.
116
+ if store._encrypted_flag_path().exists():
117
+ account_id = cfg.get("account_id", "")
118
+ if account_id and cfg.get("master_key_fingerprint"):
119
+ from methodproof.keychain import load_secret
120
+ from methodproof.kdf import derive_master, derive_db_key
121
+ entropy = load_secret(account_id)
122
+ if entropy:
123
+ store.set_db_key(derive_db_key(derive_master(entropy), account_id))
114
124
  _capture = cfg.get("capture", {})
115
125
  _journal_mode = cfg.get("journal_mode", False)
116
126
  _account_id = cfg.get("account_id", "")
@@ -985,14 +985,26 @@ def _setup_master_key(cfg: dict) -> None:
985
985
  if not account_id:
986
986
  return
987
987
  from methodproof.keychain import has_secret, store_secret, load_secret
988
+ from methodproof import store as _store
988
989
  if has_secret(account_id):
989
- # Already set up — ensure fingerprint is in config
990
+ # Already set up — ensure fingerprint is in config and key is wired
991
+ from methodproof.kdf import derive_master, derive_db_key
992
+ from methodproof.crypto import fingerprint
993
+ master = derive_master(load_secret(account_id))
994
+ db_key = derive_db_key(master, account_id)
990
995
  if not cfg.get("master_key_fingerprint"):
991
- from methodproof.kdf import derive_master, derive_db_key
992
- from methodproof.crypto import fingerprint
993
- master = derive_master(load_secret(account_id))
994
- cfg["master_key_fingerprint"] = fingerprint(derive_db_key(master, account_id))
996
+ cfg["master_key_fingerprint"] = fingerprint(db_key)
995
997
  config.save(cfg)
998
+ if _store._encrypted_flag_path().exists():
999
+ _store.set_db_key(db_key)
1000
+ else:
1001
+ # Existing user, plaintext DB — one-shot upgrade to SQLCipher
1002
+ from methodproof.migrate_db import migrate_to_sqlcipher, DaemonActiveError
1003
+ try:
1004
+ if migrate_to_sqlcipher(db_key):
1005
+ print(" Encrypted local database with SQLCipher.\n")
1006
+ except DaemonActiveError as exc:
1007
+ print(f" ⚠ {exc}")
996
1008
  return
997
1009
 
998
1010
  # Check if user has a recovery phrase (returning user, new device)
@@ -1025,13 +1037,21 @@ def _setup_master_key(cfg: dict) -> None:
1025
1037
  print(f" │ {D}on a new device. Store it somewhere safe.{R} │")
1026
1038
  print(f" └──────────────────────────────────────────────────┘\n")
1027
1039
 
1028
- # Encrypt any existing plaintext events
1040
+ # Encrypt any existing plaintext events (field-level, defense-in-depth)
1029
1041
  db_key = derive_db_key(master, account_id)
1030
- from methodproof.migrate_db import migrate_encrypt
1042
+ from methodproof.migrate_db import migrate_encrypt, migrate_to_sqlcipher
1031
1043
  count = migrate_encrypt(db_key)
1032
1044
  if count:
1033
1045
  print(f" Encrypted {count} existing events.\n")
1034
1046
 
1047
+ # Whole-database SQLCipher encryption — runs once, idempotent
1048
+ from methodproof.migrate_db import DaemonActiveError
1049
+ try:
1050
+ if migrate_to_sqlcipher(db_key):
1051
+ print(" Encrypted local database with SQLCipher.\n")
1052
+ except DaemonActiveError as exc:
1053
+ print(f" ⚠ {exc}")
1054
+
1035
1055
 
1036
1056
  def _recover_master_key(cfg: dict, account_id: str) -> None:
1037
1057
  """Prompt for recovery phrase to restore master key on new device."""
@@ -1049,9 +1069,55 @@ def _recover_master_key(cfg: dict, account_id: str) -> None:
1049
1069
  return
1050
1070
  from methodproof.keychain import store_secret
1051
1071
  store_secret(account_id, entropy)
1072
+ # Wire the recovered key into the SQLCipher connection so the next DB read works
1073
+ from methodproof.kdf import derive_master, derive_db_key
1074
+ from methodproof import store as _store
1075
+ if _store._encrypted_flag_path().exists():
1076
+ _store.set_db_key(derive_db_key(derive_master(entropy), account_id))
1052
1077
  print(" Master key restored.\n")
1053
1078
 
1054
1079
 
1080
+ def _wire_db_encryption(cfg: dict) -> None:
1081
+ """Wire the SQLCipher key into store before any subcommand touches the DB.
1082
+
1083
+ Called once at the top of `main()`. Three cases:
1084
+ 1. Encrypted DB + key available → wire the key.
1085
+ 2. Plaintext DB + logged-in user with master key → one-shot upgrade
1086
+ migration to SQLCipher (the post-release upgrade path).
1087
+ 3. Anything else (logged out, no key) → no-op; capture continues against
1088
+ the plaintext DB until the user logs in.
1089
+ """
1090
+ from methodproof import store as _store
1091
+ account_id = cfg.get("account_id", "")
1092
+ if not account_id:
1093
+ return
1094
+ try:
1095
+ from methodproof.keychain import load_secret
1096
+ from methodproof.kdf import derive_master, derive_db_key
1097
+ entropy = load_secret(account_id)
1098
+ if not entropy:
1099
+ return
1100
+ db_key = derive_db_key(derive_master(entropy), account_id)
1101
+ except Exception:
1102
+ return
1103
+
1104
+ if _store._encrypted_flag_path().exists():
1105
+ _store.set_db_key(db_key)
1106
+ return
1107
+
1108
+ # Plaintext DB + key available — one-shot upgrade
1109
+ try:
1110
+ from methodproof.migrate_db import migrate_to_sqlcipher, DaemonActiveError
1111
+ if migrate_to_sqlcipher(db_key):
1112
+ print(" Encrypted local database with SQLCipher.\n")
1113
+ except DaemonActiveError:
1114
+ # Daemon is recording — defer migration to next CLI run after `mp stop`.
1115
+ # Don't print noise on every command; the upgrade will happen later.
1116
+ return
1117
+ except Exception as exc:
1118
+ sys.stderr.write(f"[methodproof] sqlcipher migration deferred: {exc}\n")
1119
+
1120
+
1055
1121
  def _is_daemon_alive() -> bool:
1056
1122
  """Check if the recording daemon is still running (not a reused PID)."""
1057
1123
  if not PIDFILE.exists():
@@ -1844,7 +1910,7 @@ def cmd_push(args: argparse.Namespace) -> None:
1844
1910
  print(f"Found {len(unsynced)} unsynced sessions:\n")
1845
1911
  for s in unsynced:
1846
1912
  events = len(store.get_events(s["id"]))
1847
- date = s["created_at"][:10] if s.get("created_at") else "?"
1913
+ date = datetime.fromtimestamp(s["created_at"]).strftime("%Y-%m-%d") if s.get("created_at") else "?"
1848
1914
  print(f" {s['id'][:8]} {date} {events} events")
1849
1915
  print()
1850
1916
  answer = input(f"Push all {len(unsynced)}? [Y/n] ").strip().lower()
@@ -2342,6 +2408,7 @@ def main() -> None:
2342
2408
  e2e_sub.add_parser("recover", help="Recover key from passphrase")
2343
2409
  e2e_rel = e2e_sub.add_parser("release", help="Release a session from E2E encryption")
2344
2410
  e2e_rel.add_argument("session_id", help="Session ID to release")
2411
+ e2e_sub.add_parser("release-all", help="Release every synced session from E2E encryption")
2345
2412
  sub.add_parser("intro", help="Show the MethodProof intro")
2346
2413
  sub.add_parser("help", help="Show command reference")
2347
2414
  sub.add_parser("mcp-serve", help="Run MCP server (used by Claude Code)")
@@ -2381,5 +2448,6 @@ def main() -> None:
2381
2448
  _update_check()
2382
2449
 
2383
2450
  if args.cmd not in ("help", "update"):
2451
+ _wire_db_encryption(config.load())
2384
2452
  store.init_db()
2385
2453
  fn(args)
@@ -44,3 +44,20 @@ def encrypt_metadata(metadata: dict, key: bytes) -> dict:
44
44
  if field in metadata and isinstance(metadata[field], str):
45
45
  metadata[field] = encrypt_field(metadata[field], key)
46
46
  return metadata
47
+
48
+
49
+ def decrypt_metadata_safe(metadata: dict, key: bytes) -> dict:
50
+ """Decrypt sensitive fields in place. Silently leaves fields that fail
51
+ (wrong key, tampering) as ciphertext — never raises. Used on the read path
52
+ where a field encrypted with a *different* key (user E2E vs master) must
53
+ still render as-is rather than break rendering."""
54
+ if AESGCM is None:
55
+ return metadata
56
+ for field in SENSITIVE_FIELDS:
57
+ val = metadata.get(field)
58
+ if isinstance(val, str) and val.startswith("e2e:v1:"):
59
+ try:
60
+ metadata[field] = decrypt_field(val, key)
61
+ except Exception:
62
+ pass
63
+ return metadata
@@ -223,64 +223,106 @@ def _cmd_recover(cfg: dict) -> None:
223
223
  print(f"Key recovered and stored in OS keychain (fingerprint: {fp}).")
224
224
 
225
225
 
226
- def _cmd_release(cfg: dict, session_id: str) -> None:
227
- """Release a session from E2E encryption."""
228
- from methodproof import keychain
226
+ def _release_session(session_id: str, remote_id: str, e2e_key: bytes,
227
+ api_url: str, token: str) -> int:
228
+ """Decrypt and upload one session. Returns decrypted event count (0 = nothing to do)."""
229
229
  from methodproof.crypto import decrypt_field, SENSITIVE_FIELDS
230
230
  from methodproof.sync import _request
231
231
 
232
+ decrypted_events = []
233
+ for ev in store.get_events(session_id):
234
+ meta = json.loads(ev.get("metadata", "{}"))
235
+ changed = False
236
+ for field in SENSITIVE_FIELDS:
237
+ val = meta.get(field, "")
238
+ if isinstance(val, str) and val.startswith("e2e:v1:"):
239
+ meta[field] = decrypt_field(val, e2e_key)
240
+ changed = True
241
+ if changed:
242
+ decrypted_events.append({"event_id": ev["id"], "metadata": meta})
243
+
244
+ if not decrypted_events:
245
+ return 0
246
+
247
+ _request("POST", f"/personal/sessions/{remote_id}/release-e2e", api_url, token,
248
+ {"events": decrypted_events})
249
+ return len(decrypted_events)
250
+
251
+
252
+ def _load_release_context(cfg: dict) -> tuple[bytes, str, str]:
253
+ """Return (e2e_key, api_url, token) or exit with a clear error."""
254
+ from methodproof import keychain
255
+
232
256
  token = cfg.get("token", "")
233
257
  api_url = cfg.get("api_url", "")
234
258
  account_id = cfg.get("account_id", "")
235
259
  if not token:
236
260
  print("Release requires login. Run `methodproof login` first.")
237
261
  sys.exit(1)
238
-
239
262
  e2e_key = keychain.load_secret(f"e2e:{account_id}")
240
263
  if not e2e_key:
241
264
  print("E2E key not found in keychain. Run `mp e2e recover` first.")
242
265
  sys.exit(1)
266
+ return e2e_key, api_url, token
243
267
 
244
- events = store.get_events(session_id)
245
- if not events:
246
- print(f"No events found for session {session_id}.")
247
- sys.exit(1)
248
268
 
249
- decrypted_events = []
250
- for ev in events:
251
- meta = json.loads(ev.get("metadata", "{}"))
252
- has_encrypted = False
253
- for field in SENSITIVE_FIELDS:
254
- val = meta.get(field, "")
255
- if isinstance(val, str) and val.startswith("e2e:v1:"):
256
- meta[field] = decrypt_field(val, e2e_key)
257
- has_encrypted = True
258
- if has_encrypted:
259
- decrypted_events.append({"event_id": ev["id"], "metadata": meta})
269
+ def _cmd_release(cfg: dict, session_id: str) -> None:
270
+ """Release a session from E2E encryption."""
271
+ e2e_key, api_url, token = _load_release_context(cfg)
260
272
 
261
- if not decrypted_events:
262
- print("No encrypted fields found in this session.")
263
- return
273
+ if not store.get_events(session_id):
274
+ print(f"No events found for session {session_id}.")
275
+ sys.exit(1)
264
276
 
265
- # Resolve remote_id
266
- sessions = store.list_sessions()
267
277
  remote_id = None
268
- for s in sessions:
278
+ for s in store.list_sessions():
269
279
  if s["id"] == session_id or (s.get("remote_id") and s["id"].startswith(session_id)):
270
280
  remote_id = s.get("remote_id")
271
281
  session_id = s["id"]
272
282
  break
273
-
274
283
  if not remote_id:
275
284
  print("Session not synced to platform. Push first with `mp push`.")
276
285
  sys.exit(1)
277
286
 
278
- _request("POST", f"/personal/sessions/{remote_id}/release-e2e", api_url, token,
279
- {"events": decrypted_events})
280
- print(f"Session released ({len(decrypted_events)} events decrypted).")
287
+ count = _release_session(session_id, remote_id, e2e_key, api_url, token)
288
+ if not count:
289
+ print("No encrypted fields found in this session.")
290
+ return
291
+ print(f"Session released ({count} events decrypted).")
281
292
  print("Narration will be generated shortly.")
282
293
 
283
294
 
295
+ def _cmd_release_all(cfg: dict) -> None:
296
+ """Release every synced session that still has E2E-encrypted fields."""
297
+ e2e_key, api_url, token = _load_release_context(cfg)
298
+
299
+ sessions = [s for s in store.list_sessions() if s.get("remote_id")]
300
+ if not sessions:
301
+ print("No synced sessions found. Push first with `mp push`.")
302
+ return
303
+
304
+ released = skipped = failed = total_events = 0
305
+ for s in sessions:
306
+ sid, rid = s["id"], s["remote_id"]
307
+ try:
308
+ count = _release_session(sid, rid, e2e_key, api_url, token)
309
+ except Exception as exc:
310
+ failed += 1
311
+ print(f" {sid[:8]} FAILED ({exc})")
312
+ continue
313
+ if count:
314
+ released += 1
315
+ total_events += count
316
+ print(f" {sid[:8]} released ({count} events)")
317
+ else:
318
+ skipped += 1
319
+
320
+ print(f"\nReleased {released} sessions ({total_events} events). "
321
+ f"Skipped {skipped}. Failed {failed}.")
322
+ if released:
323
+ print("Narration will be generated shortly.")
324
+
325
+
284
326
  def cmd_e2e(args: argparse.Namespace) -> None:
285
327
  """E2E encryption mode — personal key management."""
286
328
  subcmd = getattr(args, "e2e_cmd", None)
@@ -300,5 +342,7 @@ def cmd_e2e(args: argparse.Namespace) -> None:
300
342
  print("Usage: methodproof e2e release <session-id>")
301
343
  sys.exit(1)
302
344
  _cmd_release(cfg, session_id)
345
+ elif subcmd == "release-all":
346
+ _cmd_release_all(cfg)
303
347
  else:
304
- print("Usage: methodproof e2e [on|off|status|recover|release <session-id>]")
348
+ print("Usage: methodproof e2e [on|off|status|recover|release <session-id>|release-all]")
@@ -150,11 +150,9 @@ def main() -> None:
150
150
  return
151
151
 
152
152
  event = data.get("hook_event_name", "unknown")
153
- etype = _TYPE_MAP.get(event)
154
- if not etype:
155
- return # Unmapped hook event — drop rather than send invalid type
153
+ etype = _TYPE_MAP.get(event, "claude_code_event")
156
154
  extractor = _META_EXTRACTORS.get(event)
157
- meta = extractor(data) if extractor else {"tool": _TOOL}
155
+ meta = extractor(data) if extractor else {"tool": _TOOL, "event": event}
158
156
  ts = time.time()
159
157
 
160
158
  payload = json.dumps({"events": [{"type": etype, "timestamp": ts, "metadata": meta}]}).encode()
@@ -0,0 +1,161 @@
1
+ """Encrypt existing plaintext events in local DB after key setup.
2
+
3
+ Two layers:
4
+ - `migrate_encrypt` — field-level AES on six sensitive metadata keys.
5
+ - `migrate_to_sqlcipher` — whole-database SQLCipher encryption via the
6
+ `sqlcipher_export()` recipe. Runs once on first login.
7
+ """
8
+
9
+ import os
10
+
11
+ from methodproof import config, store
12
+ from methodproof.crypto import SENSITIVE_FIELDS, encrypt_field
13
+ from methodproof.store import _compress_meta, _decompress_meta
14
+
15
+
16
+ def migrate_encrypt(db_key: bytes) -> int:
17
+ """Encrypt plaintext sensitive fields in all events. Returns count encrypted."""
18
+ db = store._db()
19
+ rows = db.execute(
20
+ "SELECT id, metadata FROM events ORDER BY timestamp"
21
+ ).fetchall()
22
+ if not rows:
23
+ return 0
24
+
25
+ encrypted = 0
26
+ batch = []
27
+ for row in rows:
28
+ meta = _decompress_meta(row["metadata"])
29
+ changed = False
30
+ for field in SENSITIVE_FIELDS:
31
+ val = meta.get(field)
32
+ if isinstance(val, str) and val and not val.startswith("e2e:v1:"):
33
+ meta[field] = encrypt_field(val, db_key)
34
+ changed = True
35
+ if changed:
36
+ batch.append((_compress_meta(meta), row["id"]))
37
+ encrypted += 1
38
+ if len(batch) >= 500:
39
+ _flush_batch(db, batch)
40
+ batch = []
41
+
42
+ if batch:
43
+ _flush_batch(db, batch)
44
+ return encrypted
45
+
46
+
47
+ def _flush_batch(db, batch: list[tuple[str, str]]) -> None:
48
+ db.executemany("UPDATE events SET metadata = ? WHERE id = ?", batch)
49
+ db.commit()
50
+
51
+
52
+ class DaemonActiveError(RuntimeError):
53
+ """Raised when migrate_to_sqlcipher is called while the recording daemon
54
+ is still running. The daemon holds an FD on the original DB file; renaming
55
+ it out from under the daemon causes WAL/SHM file collisions and silent
56
+ data loss for the active session. Caller must stop the daemon first."""
57
+
58
+
59
+ def _daemon_alive() -> bool:
60
+ """True if a recording daemon is running. Mirrors cli._is_daemon_alive
61
+ but lives here to avoid a circular import."""
62
+ pidfile = config.DIR / "methodproof.pid"
63
+ if not pidfile.exists():
64
+ return False
65
+ try:
66
+ pid = int(pidfile.read_text().strip())
67
+ os.kill(pid, 0)
68
+ except (ProcessLookupError, ValueError, OSError):
69
+ return False
70
+ try:
71
+ import subprocess
72
+ out = subprocess.check_output(["ps", "-p", str(pid), "-o", "args="], text=True).strip()
73
+ return "methodproof" in out
74
+ except Exception:
75
+ return False
76
+
77
+
78
+ def migrate_to_sqlcipher(db_key: bytes) -> bool:
79
+ """Re-encrypt the entire local DB with SQLCipher using `db_key`.
80
+
81
+ Idempotent — returns False if the DB is already encrypted (sentinel file
82
+ present). On success, leaves a `.plaintext.bak` file alongside the new
83
+ encrypted DB so a failed conversion is recoverable, and writes a
84
+ `.encrypted` sentinel so future opens require the key.
85
+
86
+ Raises `DaemonActiveError` if the recording daemon is still running.
87
+ Callers should `mp stop` first.
88
+ """
89
+ from sqlcipher3 import dbapi2 as sqlite3
90
+
91
+ flag = store._encrypted_flag_path()
92
+ if flag.exists():
93
+ return False
94
+
95
+ if _daemon_alive():
96
+ raise DaemonActiveError(
97
+ "recording daemon is active — run `mp stop` before encrypting "
98
+ "the local database (the daemon holds file descriptors that "
99
+ "would conflict with SQLCipher's WAL files)"
100
+ )
101
+
102
+ db_path = config.DB_PATH
103
+ if not db_path.exists():
104
+ # Nothing to migrate — first run with no DB yet. Just mark as encrypted
105
+ # so the next _db() call opens fresh in encrypted mode.
106
+ store.set_db_key(db_key)
107
+ flag.touch()
108
+ return True
109
+
110
+ store.reset_connection()
111
+
112
+ target = db_path.with_suffix(".db.encrypting")
113
+ if target.exists():
114
+ target.unlink()
115
+
116
+ src = sqlite3.connect(str(db_path))
117
+ try:
118
+ src.execute(f"ATTACH DATABASE '{target}' AS encrypted KEY \"x'{db_key.hex()}'\"")
119
+ src.execute("SELECT sqlcipher_export('encrypted')")
120
+
121
+ # Sanity-check row counts table-by-table
122
+ tables = [
123
+ r[0] for r in src.execute(
124
+ "SELECT name FROM main.sqlite_master "
125
+ "WHERE type='table' AND name NOT LIKE 'sqlite_%'"
126
+ ).fetchall()
127
+ ]
128
+ for name in tables:
129
+ src_count = src.execute(f"SELECT count(*) FROM main.{name}").fetchone()[0]
130
+ dst_count = src.execute(f"SELECT count(*) FROM encrypted.{name}").fetchone()[0]
131
+ if src_count != dst_count:
132
+ src.execute("DETACH DATABASE encrypted")
133
+ src.close()
134
+ target.unlink(missing_ok=True)
135
+ raise RuntimeError(
136
+ f"sqlcipher migration row-count mismatch on {name}: "
137
+ f"src={src_count} dst={dst_count}"
138
+ )
139
+
140
+ src.execute("DETACH DATABASE encrypted")
141
+ finally:
142
+ src.close()
143
+
144
+ # Atomic swap: original → .plaintext.bak, encrypting → original
145
+ bak = db_path.with_suffix(".db.plaintext.bak")
146
+ if bak.exists():
147
+ bak.unlink()
148
+ os.rename(db_path, bak)
149
+ os.rename(target, db_path)
150
+
151
+ # Clean up WAL/shm artifacts from the old plaintext file (they belong to
152
+ # the now-renamed .plaintext.bak and would confuse SQLite if left alongside
153
+ # the new encrypted DB under the original name).
154
+ for suffix in ("-wal", "-shm"):
155
+ leftover = db_path.parent / (db_path.name + suffix)
156
+ if leftover.exists():
157
+ leftover.unlink()
158
+
159
+ flag.touch()
160
+ store.set_db_key(db_key)
161
+ return True
@@ -0,0 +1,70 @@
1
+ """Git repo detection for session context."""
2
+
3
+ import os
4
+ import subprocess
5
+
6
+
7
+ def detect_repo(directory: str) -> str | None:
8
+ """Return the git remote fetch URL for `directory`, or None if not a git repo."""
9
+ return _remote_url(directory)
10
+
11
+
12
+ def enumerate_sub_repos(watch_dir: str, max_depth: int = 2) -> list[dict[str, str]]:
13
+ """Walk `watch_dir` up to `max_depth` levels deep and return one entry per
14
+ nested git repo found.
15
+
16
+ Each entry: {"remote_url": str, "rel_path": str} where rel_path is the
17
+ directory's path relative to watch_dir (empty string for watch_dir itself).
18
+
19
+ Designed for monorepo workflows where `watch_dir` contains multiple
20
+ independently-versioned sub-repos (e.g., BLACKBOX/methodproof/ contains
21
+ methodproof-platform/, methodproof-dashboard/, etc).
22
+ """
23
+ found: list[dict[str, str]] = []
24
+ seen_urls: set[str] = set()
25
+
26
+ def visit(path: str, depth: int) -> None:
27
+ if not os.path.isdir(path):
28
+ return
29
+ if os.path.isdir(os.path.join(path, ".git")):
30
+ url = _remote_url(path)
31
+ if url and url not in seen_urls:
32
+ rel = os.path.relpath(path, watch_dir)
33
+ found.append({"remote_url": url, "rel_path": "" if rel == "." else rel})
34
+ seen_urls.add(url)
35
+ if depth >= max_depth:
36
+ return
37
+ try:
38
+ entries = os.listdir(path)
39
+ except OSError:
40
+ return
41
+ for name in entries:
42
+ if name.startswith("."):
43
+ continue
44
+ child = os.path.join(path, name)
45
+ if os.path.isdir(child) and not os.path.islink(child):
46
+ visit(child, depth + 1)
47
+
48
+ visit(os.path.abspath(watch_dir), 0)
49
+ return found
50
+
51
+
52
+ def _remote_url(directory: str) -> str | None:
53
+ try:
54
+ result = subprocess.run(
55
+ ["git", "-C", directory, "remote", "-v"],
56
+ capture_output=True, text=True, timeout=5,
57
+ )
58
+ if result.returncode != 0:
59
+ return None
60
+ first_fetch: str | None = None
61
+ for line in result.stdout.splitlines():
62
+ parts = line.split()
63
+ if len(parts) >= 2 and "(fetch)" in line:
64
+ if parts[0] == "origin":
65
+ return parts[1]
66
+ if first_fetch is None:
67
+ first_fetch = parts[1]
68
+ return first_fetch
69
+ except (FileNotFoundError, subprocess.TimeoutExpired):
70
+ return None