methodproof 0.7.0__tar.gz → 0.7.2__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. {methodproof-0.7.0 → methodproof-0.7.2}/CHANGELOG.md +11 -0
  2. {methodproof-0.7.0 → methodproof-0.7.2}/PKG-INFO +1 -1
  3. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/__init__.py +1 -1
  4. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/agents/base.py +8 -1
  5. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/cli.py +103 -6
  6. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/config.py +5 -0
  7. methodproof-0.7.2/methodproof/e2e.py +304 -0
  8. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/proxy_daemon.py +17 -2
  9. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/store.py +8 -2
  10. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/sync.py +27 -0
  11. {methodproof-0.7.0 → methodproof-0.7.2}/pyproject.toml +1 -1
  12. {methodproof-0.7.0 → methodproof-0.7.2}/.github/workflows/ci.yml +0 -0
  13. {methodproof-0.7.0 → methodproof-0.7.2}/.gitignore +0 -0
  14. {methodproof-0.7.0 → methodproof-0.7.2}/LICENSE +0 -0
  15. {methodproof-0.7.0 → methodproof-0.7.2}/README.md +0 -0
  16. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/__main__.py +0 -0
  17. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/_daemon.py +0 -0
  18. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/agents/__init__.py +0 -0
  19. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/agents/music.py +0 -0
  20. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/agents/terminal.py +0 -0
  21. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/agents/watcher.py +0 -0
  22. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/analysis.py +0 -0
  23. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/binding.py +0 -0
  24. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/bip39.py +0 -0
  25. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/bridge.py +0 -0
  26. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/crypto.py +0 -0
  27. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/graph.py +0 -0
  28. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hook.py +0 -0
  29. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/__init__.py +0 -0
  30. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/claude_code.py +0 -0
  31. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/claude_code.sh +0 -0
  32. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/cline_hook.sh +0 -0
  33. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/codex_hook.sh +0 -0
  34. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/gemini_hook.sh +0 -0
  35. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/install.py +0 -0
  36. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/kiro_hook.sh +0 -0
  37. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/mcp_register.py +0 -0
  38. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/openclaw/HOOK.md +0 -0
  39. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/openclaw/handler.ts +0 -0
  40. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/openclaw_install.py +0 -0
  41. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/opencode_plugin.js +0 -0
  42. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/wrappers.py +0 -0
  43. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/integrity.py +0 -0
  44. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/kdf.py +0 -0
  45. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/keychain.py +0 -0
  46. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/live.py +0 -0
  47. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/lock.py +0 -0
  48. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/mcp.py +0 -0
  49. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/migrate_db.py +0 -0
  50. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/proxy.py +0 -0
  51. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/repos.py +0 -0
  52. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/skills/methodproof/SKILL.md +0 -0
  53. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/viewer.py +0 -0
  54. {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/wordlist.py +0 -0
  55. {methodproof-0.7.0 → methodproof-0.7.2}/test_windows_compat.py +0 -0
  56. {methodproof-0.7.0 → methodproof-0.7.2}/tests/__init__.py +0 -0
  57. {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_analysis.py +0 -0
  58. {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_graph.py +0 -0
  59. {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_hooks.py +0 -0
  60. {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_live.py +0 -0
  61. {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_openclaw_hooks.py +0 -0
  62. {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_security.py +0 -0
  63. {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_store.py +0 -0
  64. {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_wrappers.py +0 -0
  65. {methodproof-0.7.0 → methodproof-0.7.2}/uv.lock +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.1] — 2026-04-07
4
+
5
+ ### Fixed
6
+ - **7 GB database bloat** — `artifacts` table lacked a UNIQUE constraint on `path`, causing `_ensure_artifact` to insert a duplicate row per `file_edit`. The `_link_action_artifacts` JOIN then produced a cartesian explosion (39.9M rows from 16K events). Added UNIQUE constraint + migration that deduplicates existing data on upgrade.
7
+ - **Stale PID after reboot** — `_is_daemon_alive()` only checked `os.kill(pid, 0)`, which succeeds if the OS reused the PID for an unrelated process. Now verifies the process name contains "methodproof" via `ps`. Prevents killing random processes and unblocks `mp stop`/`mp start` after a system restart.
8
+ - **SQLite hang on locked database** — `sqlite3.connect()` had no timeout, so a crashed daemon holding a WAL lock caused `mp stop` to hang forever. Added `timeout=10`.
9
+
10
+ ### Added
11
+ - **Research consent sync** — `mp init`, `mp consent`, `mp login`, and `mp push` now sync research opt-in state between CLI and platform. Local changes are flagged with `_pending_research_sync` and pushed on next authenticated call; canonical state is always pulled from the platform.
12
+
3
13
  ## [0.7.0] — 2026-04-07
4
14
 
5
15
  ### Fixed
@@ -7,6 +17,7 @@
7
17
  - **Claude Code hook errors** — hook script pointed to deleted pyenv site-packages path; added pidfile guard (`exit 0` when no session active) to prevent bridge connection failures
8
18
 
9
19
  ### Added
20
+ - **Configurable local AI ports** — `mp init` now asks about local LLM servers (Ollama, LM Studio, vLLM, etc.) and adds user-specified ports to the proxy allowlist. Previously only 3 hardcoded ports (11434, 18789, 1234) were captured; custom ports were invisible to the proxy decoder.
10
21
  - `mp start --streaming` — blocking foreground mode, streams every captured event to stdout in real-time with human-readable formatting
11
22
  - `mp start --verbose` / `-v` — structured debug logging at each step (config, auth, session, anchor, daemon spawn); daemon log includes agent init and buffer/flush stats
12
23
  - Step-by-step progress output on default `mp start` (`→ Loading config`, `→ Checking hooks`, etc.) so failures are immediately locatable
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodproof
3
- Version: 0.7.0
3
+ Version: 0.7.2
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
@@ -1,3 +1,3 @@
1
1
  """MethodProof — see how you code."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.2"
@@ -80,8 +80,15 @@ _FIELD_GATES: dict[str, list[tuple[str, str]]] = {
80
80
 
81
81
 
82
82
  def _load_encryption_key(cfg: dict) -> bytes | None:
83
- """Load db_key from keychain (preferred) or legacy e2e_key config."""
83
+ """Load encryption key: individual E2E > db_key from keychain > legacy e2e_key."""
84
84
  account_id = cfg.get("account_id", "")
85
+ # Individual E2E key — highest priority when session is E2E
86
+ if account_id and cfg.get("e2e_fingerprint") and cfg.get("_session_e2e"):
87
+ from methodproof.keychain import load_secret
88
+ e2e_key = load_secret(f"e2e:{account_id}")
89
+ if e2e_key:
90
+ return e2e_key
91
+ # Local db_key (for SQLite encryption)
85
92
  if account_id and cfg.get("master_key_fingerprint"):
86
93
  from methodproof.keychain import load_secret
87
94
  from methodproof.kdf import derive_master, derive_db_key
@@ -337,6 +337,11 @@ def cmd_init(args: argparse.Namespace) -> None:
337
337
  if not cfg.get("consent_acknowledged"):
338
338
  cfg = _run_consent(cfg)
339
339
  config.save(cfg)
340
+ if cfg.get("token"):
341
+ cfg["_pending_research_sync"] = True
342
+ config.save(cfg)
343
+ from methodproof.sync import sync_research_consent
344
+ sync_research_consent(cfg["token"], cfg["api_url"])
340
345
  print()
341
346
 
342
347
  capture = cfg.get("capture", {})
@@ -408,6 +413,23 @@ def cmd_init(args: argparse.Namespace) -> None:
408
413
  else:
409
414
  print("AI hooks: skipped (ai_prompts and ai_responses disabled)")
410
415
 
416
+ # Local AI ports — capture traffic from local LLM servers
417
+ if ai_enabled and not cfg.get("local_ai_ports_offered"):
418
+ answer = input("Run any local AI models (Ollama, LM Studio, vLLM, etc.)? [y/N]: ").strip().lower()
419
+ cfg["local_ai_ports_offered"] = True
420
+ if answer == "y":
421
+ raw = input("Enter ports (comma-separated, e.g. 8080,5000,7860): ").strip()
422
+ ports = [int(p.strip()) for p in raw.split(",") if p.strip().isdigit()]
423
+ cfg["local_ai_ports"] = ports
424
+ if ports:
425
+ print(f"Local AI ports: {', '.join(str(p) for p in ports)} (proxy will decode these)")
426
+ else:
427
+ print("Local AI ports: none added")
428
+ else:
429
+ cfg["local_ai_ports"] = []
430
+ print("Local AI ports: skipped (built-in: Ollama 11434, Jan 1234)")
431
+ config.save(cfg)
432
+
411
433
  # Signing keypair for attestation
412
434
  from methodproof.integrity import has_keypair
413
435
  if not has_keypair():
@@ -661,6 +683,11 @@ def cmd_consent(args: argparse.Namespace) -> None:
661
683
  print(f"\n{_banner()}\n")
662
684
  cfg = _run_consent_detailed(cfg)
663
685
  config.save(cfg)
686
+ if cfg.get("token"):
687
+ cfg["_pending_research_sync"] = True
688
+ config.save(cfg)
689
+ from methodproof.sync import sync_research_consent
690
+ sync_research_consent(cfg["token"], cfg["api_url"])
664
691
  print(f"\n{_banner()} settings saved.\n")
665
692
  _print_commands()
666
693
 
@@ -834,15 +861,21 @@ def _recover_master_key(cfg: dict, account_id: str) -> None:
834
861
 
835
862
 
836
863
  def _is_daemon_alive() -> bool:
837
- """Check if the recording daemon is still running."""
864
+ """Check if the recording daemon is still running (not a reused PID)."""
838
865
  if not PIDFILE.exists():
839
866
  return False
840
867
  try:
841
868
  pid = int(PIDFILE.read_text().strip())
842
- os.kill(pid, 0) # signal 0 = check existence without killing
843
- return True
869
+ os.kill(pid, 0)
844
870
  except (ProcessLookupError, ValueError, OSError):
845
871
  return False
872
+ # Verify the PID is actually a methodproof process (PIDs get reused after reboot)
873
+ try:
874
+ import subprocess
875
+ out = subprocess.check_output(["ps", "-p", str(pid), "-o", "args="], text=True).strip()
876
+ return "methodproof" in out
877
+ except Exception:
878
+ return False
846
879
 
847
880
 
848
881
  def _log_step(msg: str, verbose: bool = False) -> None:
@@ -957,7 +990,11 @@ def cmd_start(args: argparse.Namespace) -> None:
957
990
  print("Live mode requires login. Run `methodproof login` first.")
958
991
  sys.exit(1)
959
992
  from methodproof.sync import _request
960
- result = _request("POST", "/personal/sessions", cfg["api_url"], cfg["token"])
993
+ session_body: dict = {}
994
+ if cfg.get("e2e_fingerprint") and (getattr(args, "e2e", False) or cfg.get("e2e_mode")):
995
+ session_body["e2e_key_fingerprint"] = cfg["e2e_fingerprint"]
996
+ result = _request("POST", "/personal/sessions", cfg["api_url"], cfg["token"],
997
+ session_body or None)
961
998
  remote_id = result["session_id"]
962
999
  store.mark_synced(sid, remote_id)
963
1000
  from methodproof import live as live_mod
@@ -983,6 +1020,18 @@ def cmd_start(args: argparse.Namespace) -> None:
983
1020
  print("Journal mode ON for this session (full content capture).")
984
1021
  config.save(cfg)
985
1022
 
1023
+ # E2E mode
1024
+ want_e2e = getattr(args, "e2e", False) or (cfg.get("e2e_mode") and not getattr(args, "no_e2e", False))
1025
+ if want_e2e:
1026
+ fp = cfg.get("e2e_fingerprint")
1027
+ if not fp:
1028
+ print("E2E requires key setup. Run `methodproof e2e on` first.")
1029
+ sys.exit(1)
1030
+ cfg["_session_e2e"] = True
1031
+ config.save(cfg)
1032
+ print("E2E mode ON for this session (content encrypted with your key).")
1033
+ print(" Narration unavailable. Release later with: mp e2e release <session-id>")
1034
+
986
1035
  # Save live_url for daemon subprocess to pick up
987
1036
  if live_url:
988
1037
  cfg["_live_url"] = live_url
@@ -1077,9 +1126,17 @@ def _run_foreground(sid: str, watch_dir: str, cfg: dict, capture: dict,
1077
1126
  _log_step("Agent: terminal")
1078
1127
  if capture.get("browser", True):
1079
1128
  from methodproof import bridge
1129
+ e2e_key_hex = ""
1130
+ if cfg.get("_session_e2e") and cfg.get("account_id"):
1131
+ from methodproof.keychain import load_secret
1132
+ _raw = load_secret(f"e2e:{cfg['account_id']}")
1133
+ if _raw:
1134
+ e2e_key_hex = _raw.hex()
1135
+ else:
1136
+ e2e_key_hex = cfg.get("e2e_key", "")
1080
1137
  threads.append(threading.Thread(target=bridge.start, args=(
1081
1138
  sid, stop_event, 9877,
1082
- cfg.get("token", ""), cfg.get("api_url", ""), cfg.get("e2e_key", ""),
1139
+ cfg.get("token", ""), cfg.get("api_url", ""), e2e_key_hex,
1083
1140
  cfg.get("journal_mode", False),
1084
1141
  ), daemon=True))
1085
1142
  _log_step("Agent: bridge")
@@ -1194,6 +1251,21 @@ def cmd_log(args: argparse.Namespace) -> None:
1194
1251
  print(f" {s['id'][:8]} {dt} {dur} {s['total_events']} events{suffix}")
1195
1252
 
1196
1253
 
1254
+ def cmd_logout(args: argparse.Namespace) -> None:
1255
+ """Clear login credentials only. Keeps consent, sessions, and hooks."""
1256
+ cfg = config.load()
1257
+ if not cfg.get("token"):
1258
+ print("Not logged in.")
1259
+ return
1260
+ old_email = cfg.get("email", "")
1261
+ old_account = cfg.get("account_id", "")[:8]
1262
+ for key in ("token", "refresh_token", "email", "account_id", "last_auth_at"):
1263
+ cfg[key] = config._DEFAULTS.get(key, "")
1264
+ config.save(cfg)
1265
+ label = old_email or old_account or "account"
1266
+ print(f"Logged out ({label}). Run `mp login` to sign in again.")
1267
+
1268
+
1197
1269
  def cmd_login(args: argparse.Namespace) -> None:
1198
1270
  import webbrowser
1199
1271
  from methodproof.sync import _request
@@ -1201,6 +1273,13 @@ def cmd_login(args: argparse.Namespace) -> None:
1201
1273
  cfg = config.load()
1202
1274
  api = args.api_url or cfg["api_url"]
1203
1275
 
1276
+ if cfg.get("token") and not getattr(args, "force", False):
1277
+ current = cfg.get("email") or cfg.get("account_id", "")[:8] or "an account"
1278
+ print(f"Already logged in as {current}.")
1279
+ answer = input(" Switch accounts? [y/N]: ").strip().lower()
1280
+ if answer not in ("y", "yes"):
1281
+ return
1282
+
1204
1283
  # Start device auth flow
1205
1284
  result = _request("POST", "/auth/cli/start", api, "")
1206
1285
  code = result["code"]
@@ -1234,6 +1313,8 @@ def cmd_login(args: argparse.Namespace) -> None:
1234
1313
  config.save(cfg)
1235
1314
  print(" done.\n")
1236
1315
  _setup_master_key(cfg)
1316
+ from methodproof.sync import sync_research_consent
1317
+ sync_research_consent(cfg["token"], cfg["api_url"])
1237
1318
  print("Logged in. Run `methodproof push` to upload sessions.")
1238
1319
  return
1239
1320
  except Exception:
@@ -1248,6 +1329,9 @@ def cmd_push(args: argparse.Namespace) -> None:
1248
1329
  if not cfg.get("token"):
1249
1330
  print("Run `methodproof login` first.")
1250
1331
  sys.exit(1)
1332
+ from methodproof.sync import sync_research_consent
1333
+ sync_research_consent(cfg["token"], cfg["api_url"])
1334
+ cfg = config.load()
1251
1335
  sid = args.session_id or _latest()
1252
1336
  if not sid:
1253
1337
  print("No sessions to push.")
@@ -1641,6 +1725,8 @@ def main() -> None:
1641
1725
  s.add_argument("--live", action="store_true", help="Stream graph live to your private profile")
1642
1726
  s.add_argument("--live-public", action="store_true", help="Stream graph live — visible to anyone with the link")
1643
1727
  s.add_argument("--journal", action="store_true", help="Journal mode — full content capture (2 free credits, then Pro)")
1728
+ s.add_argument("--e2e", action="store_true", help="E2E encryption — content encrypted with your personal key")
1729
+ s.add_argument("--no-e2e", action="store_true", help="Disable E2E for this session (overrides config)")
1644
1730
  s.add_argument("--verbose", "-v", action="store_true", help="Debug logging at each step (still daemonizes)")
1645
1731
  s.add_argument("--streaming", action="store_true", help="Blocking foreground — stream every captured event to stdout")
1646
1732
  sub.add_parser("stop", help="Stop recording")
@@ -1649,6 +1735,8 @@ def main() -> None:
1649
1735
  sub.add_parser("log", help="List sessions")
1650
1736
  l = sub.add_parser("login", help="Connect to platform")
1651
1737
  l.add_argument("--api-url")
1738
+ l.add_argument("--force", "-f", action="store_true", help="Skip switch-account prompt")
1739
+ sub.add_parser("logout", help="Clear login credentials (keeps consent and sessions)")
1652
1740
  pu = sub.add_parser("push", help="Upload privately to your account")
1653
1741
  pu.add_argument("session_id", nargs="?")
1654
1742
  tg = sub.add_parser("tag", help="Tag a session")
@@ -1686,6 +1774,14 @@ def main() -> None:
1686
1774
  jr_sub.add_parser("on", help="Enable journal mode (persists full content)")
1687
1775
  jr_sub.add_parser("off", help="Disable journal mode (structural only)")
1688
1776
  jr_sub.add_parser("status", help="Show journal mode status")
1777
+ e2e_p = sub.add_parser("e2e", help="E2E encryption — personal key management")
1778
+ e2e_sub = e2e_p.add_subparsers(dest="e2e_cmd")
1779
+ e2e_sub.add_parser("on", help="Enable E2E mode (generates key on first use)")
1780
+ e2e_sub.add_parser("off", help="Disable E2E mode (key stays in keychain)")
1781
+ e2e_sub.add_parser("status", help="Show E2E mode status")
1782
+ e2e_sub.add_parser("recover", help="Recover key from passphrase")
1783
+ e2e_rel = e2e_sub.add_parser("release", help="Release a session from E2E encryption")
1784
+ e2e_rel.add_argument("session_id", help="Session ID to release")
1689
1785
  sub.add_parser("intro", help="Show the MethodProof intro")
1690
1786
  sub.add_parser("help", help="Show command reference")
1691
1787
  sub.add_parser("mcp-serve", help="Run MCP server (used by Claude Code)")
@@ -1699,12 +1795,13 @@ def main() -> None:
1699
1795
  args = p.parse_args()
1700
1796
  cmds = {
1701
1797
  "init": cmd_init, "start": cmd_start, "stop": cmd_stop,
1702
- "view": cmd_view, "log": cmd_log, "login": cmd_login,
1798
+ "view": cmd_view, "log": cmd_log, "login": cmd_login, "logout": cmd_logout,
1703
1799
  "push": cmd_push, "tag": cmd_tag, "publish": cmd_publish,
1704
1800
  "delete": cmd_delete, "review": cmd_review, "consent": cmd_consent,
1705
1801
  "update": cmd_update, "lock": cmd_lock, "reset": cmd_reset, "uninstall": cmd_uninstall,
1706
1802
  "extension": cmd_extension,
1707
1803
  "journal": cmd_journal,
1804
+ "e2e": lambda a: __import__("methodproof.e2e", fromlist=["cmd_e2e"]).cmd_e2e(a),
1708
1805
  "intro": lambda _: _print_intro(),
1709
1806
  "help": lambda _: _print_commands(),
1710
1807
  "mcp-serve": cmd_mcp_serve,
@@ -34,11 +34,16 @@ _DEFAULTS: dict[str, Any] = {
34
34
  "code_capture": False,
35
35
  },
36
36
  "research_consent": False,
37
+ "contribution_level": None,
38
+ "_pending_research_sync": False,
37
39
  "journal_mode": False,
38
40
  "journal_credits": 2,
41
+ "e2e_mode": False,
42
+ "e2e_fingerprint": "",
39
43
  "auto_update": False,
40
44
  "account_id": "",
41
45
  "last_auth_at": 0,
46
+ "local_ai_ports": [], # user-configured localhost ports for local LLM capture
42
47
  "publish_redact": {
43
48
  "command_output": True,
44
49
  "ai_prompts": True,
@@ -0,0 +1,304 @@
1
+ """E2E encryption mode — personal key management and session release."""
2
+
3
+ import argparse
4
+ import getpass
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import sys
9
+ from base64 import b64decode, b64encode
10
+
11
+ from methodproof import config, store
12
+
13
+
14
+ _RESET = "\033[0m"
15
+
16
+
17
+ def print_e2e_intro() -> None:
18
+ """Show E2E mode introduction."""
19
+ print(" ┌─────────────────────────────────────────────────────┐")
20
+ print(" │ E2E Mode — personal encryption │")
21
+ print(" └─────────────────────────────────────────────────────┘")
22
+ print(" When enabled, session content (prompts, responses, diffs,")
23
+ print(" terminal output) is encrypted with a key only you hold.")
24
+ print(" The MethodProof platform cannot read encrypted fields.")
25
+ print()
26
+ print(" Tradeoff: narration and AI features are unavailable for")
27
+ print(" encrypted sessions. You can release individual sessions")
28
+ print(" from E2E later to enable narration.")
29
+ print()
30
+ print(" Structural data (graph, timing, threads, scoring) is")
31
+ print(" always available regardless of E2E status.")
32
+ print()
33
+ print(" Try it: mp start --e2e")
34
+ print(" Toggle: mp e2e on / off / status")
35
+ print()
36
+
37
+
38
+ def _prompt_passphrase() -> str:
39
+ """Prompt for a passphrase (12+ chars, confirmed)."""
40
+ while True:
41
+ pp = getpass.getpass("Passphrase (12+ characters): ")
42
+ if len(pp) < 12:
43
+ print("Passphrase must be at least 12 characters.")
44
+ continue
45
+ confirm = getpass.getpass("Confirm passphrase: ")
46
+ if pp != confirm:
47
+ print("Passphrases do not match.")
48
+ continue
49
+ return pp
50
+
51
+
52
+ def _wrap_key(raw_key: bytes, passphrase: str) -> tuple[bytes, bytes]:
53
+ """Derive wrapping key from passphrase, encrypt raw_key. Returns (salt, blob)."""
54
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
55
+ salt = os.urandom(16)
56
+ wrapping_key = hashlib.pbkdf2_hmac("sha256", passphrase.encode(), salt, 600_000, dklen=32)
57
+ nonce = os.urandom(12)
58
+ ciphertext = AESGCM(wrapping_key).encrypt(nonce, raw_key, None)
59
+ blob = nonce + ciphertext
60
+ return salt, blob
61
+
62
+
63
+ def _unwrap_key(blob_b64: str, salt_hex: str, passphrase: str, iterations: int = 600_000) -> bytes:
64
+ """Decrypt recovery blob to recover raw key."""
65
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
66
+ salt = bytes.fromhex(salt_hex)
67
+ wrapping_key = hashlib.pbkdf2_hmac("sha256", passphrase.encode(), salt, iterations, dklen=32)
68
+ raw = b64decode(blob_b64)
69
+ nonce, ct = raw[:12], raw[12:]
70
+ return AESGCM(wrapping_key).decrypt(nonce, ct, None)
71
+
72
+
73
+ def _setup_key(cfg: dict) -> None:
74
+ """First-time key generation and registration."""
75
+ from methodproof import keychain
76
+ from methodproof.crypto import fingerprint
77
+ from methodproof.sync import _request
78
+
79
+ account_id = cfg.get("account_id", "")
80
+ token = cfg.get("token", "")
81
+ api_url = cfg.get("api_url", "")
82
+ if not token or not account_id:
83
+ print("E2E requires login. Run `methodproof login` first.")
84
+ sys.exit(1)
85
+
86
+ print("E2E Mode — Personal Encryption\n")
87
+ print("A 256-bit key will be generated and stored in your OS keychain.")
88
+ print("You will set a recovery passphrase in case you lose keychain access.\n")
89
+
90
+ answer = input("Enable E2E mode? [y/N] ").strip().lower()
91
+ if answer != "y":
92
+ print("E2E mode not enabled.")
93
+ return
94
+
95
+ raw_key = os.urandom(32)
96
+ fp = fingerprint(raw_key)
97
+ passphrase = _prompt_passphrase()
98
+ salt, blob = _wrap_key(raw_key, passphrase)
99
+
100
+ # Store raw key in keychain
101
+ keychain.store_secret(f"e2e:{account_id}", raw_key)
102
+
103
+ # Register with platform
104
+ _request("POST", "/personal/e2e-keys", api_url, token, {
105
+ "key_fingerprint": fp,
106
+ "recovery_blob": b64encode(blob).decode(),
107
+ "recovery_salt": salt.hex(),
108
+ "recovery_params": {"alg": "pbkdf2-sha256", "iterations": 600_000},
109
+ })
110
+
111
+ cfg["e2e_mode"] = True
112
+ cfg["e2e_fingerprint"] = fp
113
+ config.save(cfg)
114
+
115
+ # Passphrase warning box
116
+ W = "\033[1;97m"
117
+ Y = "\033[93m"
118
+ D = "\033[90m"
119
+ R = _RESET
120
+ print(f"\n ┌──────────────────────────────────────────────────┐")
121
+ print(f" │ {W}RECOVERY PASSPHRASE — REMEMBER THIS{R} │")
122
+ print(f" │ │")
123
+ print(f" │ {D}Your key is stored in the OS keychain.{R} │")
124
+ print(f" │ {D}If you lose keychain access, recover with:{R} │")
125
+ print(f" │ {Y} mp e2e recover{R} │")
126
+ print(f" │ │")
127
+ print(f" │ {D}Without the passphrase, encrypted sessions{R} │")
128
+ print(f" │ {D}cannot be recovered.{R} │")
129
+ print(f" └──────────────────────────────────────────────────┘\n")
130
+ print("E2E mode ON. Content will be encrypted with your personal key.")
131
+ print("Run `methodproof start --e2e` to begin an encrypted session.\n")
132
+
133
+
134
+ def _cmd_on(cfg: dict) -> None:
135
+ """Enable E2E mode."""
136
+ if cfg.get("e2e_fingerprint"):
137
+ cfg["e2e_mode"] = True
138
+ config.save(cfg)
139
+ print("E2E mode ON.")
140
+ else:
141
+ _setup_key(cfg)
142
+
143
+
144
+ def _cmd_off(cfg: dict) -> None:
145
+ """Disable E2E mode."""
146
+ cfg["e2e_mode"] = False
147
+ config.save(cfg)
148
+ print("E2E mode OFF. Sessions will use standard encryption.")
149
+
150
+
151
+ def _cmd_status(cfg: dict) -> None:
152
+ """Show E2E mode status."""
153
+ enabled = cfg.get("e2e_mode", False)
154
+ fp = cfg.get("e2e_fingerprint", "")
155
+ if enabled:
156
+ print(f"E2E mode: ON (fingerprint: {fp})")
157
+ else:
158
+ print("E2E mode: OFF")
159
+ if fp:
160
+ print(f" Key registered (fingerprint: {fp}) but mode is disabled.")
161
+ print(" Enable with: methodproof e2e on")
162
+ return
163
+ print(" No key registered. Enable with: methodproof e2e on")
164
+ return
165
+
166
+ # Check keychain
167
+ account_id = cfg.get("account_id", "")
168
+ if account_id:
169
+ from methodproof.keychain import has_secret
170
+ if has_secret(f"e2e:{account_id}"):
171
+ print(" Key: present in OS keychain")
172
+ else:
173
+ print(" Key: NOT in keychain (run `mp e2e recover` to restore)")
174
+
175
+
176
+ def _cmd_recover(cfg: dict) -> None:
177
+ """Recover E2E key from platform-stored recovery blob."""
178
+ from methodproof import keychain
179
+ from methodproof.crypto import fingerprint
180
+ from methodproof.sync import _request
181
+
182
+ token = cfg.get("token", "")
183
+ api_url = cfg.get("api_url", "")
184
+ account_id = cfg.get("account_id", "")
185
+ if not token:
186
+ print("Recovery requires login. Run `methodproof login` first.")
187
+ sys.exit(1)
188
+
189
+ # List keys to find the active fingerprint, then fetch recovery data
190
+ keys = _request("GET", "/personal/e2e-keys", api_url, token)
191
+ if not isinstance(keys, list) or not keys:
192
+ print("No E2E keys registered on your account.")
193
+ return
194
+
195
+ active = [k for k in keys if not k.get("revoked_at")]
196
+ if not active:
197
+ print("No active E2E keys found.")
198
+ return
199
+
200
+ fp = active[0]["fingerprint"]
201
+ recovery = _request("GET", f"/personal/e2e-keys/{fp}/recovery", api_url, token)
202
+ blob_b64 = recovery["recovery_blob"]
203
+ salt_hex = recovery["recovery_salt"]
204
+ params = recovery.get("recovery_params", {})
205
+ iterations = params.get("iterations", 600_000)
206
+
207
+ print(f"Recovering key (fingerprint: {fp})\n")
208
+ passphrase = getpass.getpass("Recovery passphrase: ")
209
+
210
+ try:
211
+ raw_key = _unwrap_key(blob_b64, salt_hex, passphrase, iterations)
212
+ except Exception:
213
+ print("Decryption failed. Wrong passphrase or corrupted recovery data.")
214
+ sys.exit(1)
215
+
216
+ if fingerprint(raw_key) != fp:
217
+ print("Key fingerprint mismatch. Recovery data may be corrupted.")
218
+ sys.exit(1)
219
+
220
+ keychain.store_secret(f"e2e:{account_id}", raw_key)
221
+ cfg["e2e_fingerprint"] = fp
222
+ config.save(cfg)
223
+ print(f"Key recovered and stored in OS keychain (fingerprint: {fp}).")
224
+
225
+
226
+ def _cmd_release(cfg: dict, session_id: str) -> None:
227
+ """Release a session from E2E encryption."""
228
+ from methodproof import keychain
229
+ from methodproof.crypto import decrypt_field, SENSITIVE_FIELDS
230
+ from methodproof.sync import _request
231
+
232
+ token = cfg.get("token", "")
233
+ api_url = cfg.get("api_url", "")
234
+ account_id = cfg.get("account_id", "")
235
+ if not token:
236
+ print("Release requires login. Run `methodproof login` first.")
237
+ sys.exit(1)
238
+
239
+ e2e_key = keychain.load_secret(f"e2e:{account_id}")
240
+ if not e2e_key:
241
+ print("E2E key not found in keychain. Run `mp e2e recover` first.")
242
+ sys.exit(1)
243
+
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
+
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})
260
+
261
+ if not decrypted_events:
262
+ print("No encrypted fields found in this session.")
263
+ return
264
+
265
+ # Resolve remote_id
266
+ sessions = store.list_sessions()
267
+ remote_id = None
268
+ for s in sessions:
269
+ if s["id"] == session_id or (s.get("remote_id") and s["id"].startswith(session_id)):
270
+ remote_id = s.get("remote_id")
271
+ session_id = s["id"]
272
+ break
273
+
274
+ if not remote_id:
275
+ print("Session not synced to platform. Push first with `mp push`.")
276
+ sys.exit(1)
277
+
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).")
281
+ print("Narration will be generated shortly.")
282
+
283
+
284
+ def cmd_e2e(args: argparse.Namespace) -> None:
285
+ """E2E encryption mode — personal key management."""
286
+ subcmd = getattr(args, "e2e_cmd", None)
287
+ cfg = config.load()
288
+
289
+ if subcmd == "on":
290
+ _cmd_on(cfg)
291
+ elif subcmd == "off":
292
+ _cmd_off(cfg)
293
+ elif subcmd == "status":
294
+ _cmd_status(cfg)
295
+ elif subcmd == "recover":
296
+ _cmd_recover(cfg)
297
+ elif subcmd == "release":
298
+ session_id = getattr(args, "session_id", "")
299
+ if not session_id:
300
+ print("Usage: methodproof e2e release <session-id>")
301
+ sys.exit(1)
302
+ _cmd_release(cfg, session_id)
303
+ else:
304
+ print("Usage: methodproof e2e [on|off|status|recover|release <session-id>]")
@@ -44,12 +44,27 @@ def _post_event(event_type: str, metadata: dict) -> None:
44
44
  sys.stderr.write(f"proxy.bridge_post_failed type={event_type} error={exc}\n")
45
45
 
46
46
 
47
+ _BUILTIN_LOCAL_PORTS = {"11434", "18789", "1234"} # Ollama, Claude Desktop, Jan
48
+
49
+
50
+ def _load_local_ports() -> set[str]:
51
+ """Load user-configured local AI ports from config."""
52
+ try:
53
+ from methodproof import config
54
+ cfg = config.load()
55
+ return _BUILTIN_LOCAL_PORTS | {str(p) for p in cfg.get("local_ai_ports", [])}
56
+ except Exception:
57
+ return _BUILTIN_LOCAL_PORTS
58
+
59
+
47
60
  def _is_ai_domain(domain: str) -> bool:
48
61
  for ai in AI_DOMAINS:
49
62
  if domain == ai or domain.endswith(f".{ai}"):
50
63
  return True
51
- if "localhost" in domain and any(p in domain for p in [":11434", ":18789", ":1234"]):
52
- return True
64
+ if "localhost" in domain or "127.0.0.1" in domain:
65
+ ports = _load_local_ports()
66
+ if any(f":{p}" in domain for p in ports):
67
+ return True
53
68
  if "bedrock-runtime" in domain and "amazonaws.com" in domain:
54
69
  return True
55
70
  if "openai.azure.com" in domain:
@@ -44,7 +44,7 @@ CREATE TABLE IF NOT EXISTS resources (
44
44
  UNIQUE(type, identifier)
45
45
  );
46
46
  CREATE TABLE IF NOT EXISTS artifacts (
47
- id TEXT PRIMARY KEY, path TEXT NOT NULL, type TEXT DEFAULT 'file',
47
+ id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, type TEXT DEFAULT 'file',
48
48
  size_bytes INTEGER DEFAULT 0
49
49
  );
50
50
  CREATE TABLE IF NOT EXISTS action_resources (
@@ -65,7 +65,7 @@ _conn: sqlite3.Connection | None = None
65
65
  def _db() -> sqlite3.Connection:
66
66
  global _conn
67
67
  if _conn is None:
68
- _conn = sqlite3.connect(str(config.DB_PATH), check_same_thread=False)
68
+ _conn = sqlite3.connect(str(config.DB_PATH), check_same_thread=False, timeout=10)
69
69
  _conn.execute("PRAGMA journal_mode=WAL")
70
70
  _conn.row_factory = sqlite3.Row
71
71
  return _conn
@@ -107,6 +107,12 @@ def _migrate() -> None:
107
107
  "CREATE TABLE IF NOT EXISTS event_hashes "
108
108
  "(event_id TEXT PRIMARY KEY REFERENCES events(id), hash TEXT NOT NULL)"
109
109
  )
110
+ # Deduplicate artifacts and add UNIQUE index on path (fixes cartesian join bug)
111
+ indexes = {r[1] for r in db.execute("PRAGMA index_list(artifacts)").fetchall()}
112
+ if "idx_artifacts_path" not in indexes:
113
+ db.execute("DELETE FROM action_artifacts")
114
+ db.execute("DELETE FROM artifacts WHERE rowid NOT IN (SELECT MIN(rowid) FROM artifacts GROUP BY path)")
115
+ db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_path ON artifacts(path)")
110
116
  db.commit()
111
117
 
112
118
 
@@ -179,3 +179,30 @@ def push(session_id: str, token: str, api_url: str) -> str:
179
179
  def _iso(ts: float) -> str:
180
180
  from datetime import datetime, UTC
181
181
  return datetime.fromtimestamp(ts, tz=UTC).isoformat()
182
+
183
+
184
+ def sync_research_consent(token: str, api_url: str) -> None:
185
+ """Sync research consent between CLI (cache) and platform (source of truth)."""
186
+ from methodproof import config
187
+ from methodproof.agents.base import log
188
+
189
+ try:
190
+ cfg = config.load()
191
+
192
+ # Push pending local change first
193
+ if cfg.get("_pending_research_sync"):
194
+ _request("PUT", "/research/opt-in", api_url, token, {
195
+ "opt_in": cfg.get("research_consent", False),
196
+ "contribution_level": cfg.get("contribution_level") or "structural",
197
+ })
198
+ cfg["_pending_research_sync"] = False
199
+ config.save(cfg)
200
+
201
+ # Pull canonical state from platform
202
+ status = _request("GET", "/research/status", api_url, token)
203
+ cfg = config.load()
204
+ cfg["research_consent"] = status.get("opt_in", False)
205
+ cfg["contribution_level"] = status.get("contribution_level")
206
+ config.save(cfg)
207
+ except (SystemExit, Exception) as exc:
208
+ log("warning", "sync.research_consent_failed", error=str(exc))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "methodproof"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "See how you code. Capture and visualize your engineering process."
5
5
  requires-python = ">=3.11"
6
6
  dependencies = ["watchdog>=4.0", "websocket-client>=1.7", "cryptography>=43.0", "keyring>=25.0"]
File without changes
File without changes
File without changes
File without changes