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.
- {methodproof-0.7.38 → methodproof-0.8.0}/.github/workflows/ci.yml +6 -6
- {methodproof-0.7.38 → methodproof-0.8.0}/CHANGELOG.md +16 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/PKG-INFO +2 -1
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/agents/base.py +11 -1
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/cli.py +76 -8
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/crypto.py +17 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/e2e.py +74 -30
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/claude_code.py +2 -4
- methodproof-0.8.0/methodproof/migrate_db.py +161 -0
- methodproof-0.8.0/methodproof/repos.py +70 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/store.py +77 -6
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/sync.py +10 -1
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/viewer.py +1 -1
- {methodproof-0.7.38 → methodproof-0.8.0}/pyproject.toml +2 -2
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/conftest.py +1 -0
- methodproof-0.8.0/tests/test_repos.py +73 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_security.py +102 -1
- {methodproof-0.7.38 → methodproof-0.8.0}/uv.lock +168 -1
- methodproof-0.7.38/methodproof/migrate_db.py +0 -41
- methodproof-0.7.38/methodproof/repos.py +0 -25
- {methodproof-0.7.38 → methodproof-0.8.0}/.gitignore +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/LICENSE +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/README.md +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/__init__.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/__main__.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/_daemon.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/agents/__init__.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/agents/music.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/agents/terminal.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/agents/watcher.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/analysis.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/binding.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/bip39.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/bridge.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/config.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/graph.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hook.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/__init__.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/claude_code.sh +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/cline_hook.sh +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/codex_hook.sh +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/gemini_hook.sh +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/install.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/kiro_hook.sh +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/mcp_register.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/openclaw/HOOK.md +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/openclaw/handler.ts +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/openclaw_install.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/opencode_plugin.js +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/hooks/wrappers.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/integrity.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/kdf.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/keychain.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/live.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/lock.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/mcp.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/proxy.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/proxy_daemon.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/skills/methodproof/SKILL.md +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/__init__.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/consent.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/init.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/log.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/login_success.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/review.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/start.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/status.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/tui/theme.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/methodproof/wordlist.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/test_windows_compat.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/__init__.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_analysis.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_auth.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_config.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_helpers.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_session.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_share.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_start.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_cli_update.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_e2e_integration.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_graph.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_hooks.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_live.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_openclaw_hooks.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_profiles.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_store.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_sync.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.0}/tests/test_viewer.py +0 -0
- {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@
|
|
15
|
-
- uses: astral-sh/setup-uv@
|
|
14
|
+
- uses: actions/checkout@v5
|
|
15
|
+
- uses: astral-sh/setup-uv@v7
|
|
16
16
|
with: { version: "latest" }
|
|
17
|
-
- uses: actions/setup-python@
|
|
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@
|
|
30
|
-
- uses: astral-sh/setup-uv@
|
|
29
|
+
- uses: actions/checkout@v5
|
|
30
|
+
- uses: astral-sh/setup-uv@v7
|
|
31
31
|
with: { version: "latest" }
|
|
32
|
-
- uses: actions/setup-python@
|
|
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.
|
|
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
|
-
|
|
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"]
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
262
|
-
print("No
|
|
263
|
-
|
|
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
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|