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.
- {methodproof-0.7.0 → methodproof-0.7.2}/CHANGELOG.md +11 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/PKG-INFO +1 -1
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/__init__.py +1 -1
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/agents/base.py +8 -1
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/cli.py +103 -6
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/config.py +5 -0
- methodproof-0.7.2/methodproof/e2e.py +304 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/proxy_daemon.py +17 -2
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/store.py +8 -2
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/sync.py +27 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/pyproject.toml +1 -1
- {methodproof-0.7.0 → methodproof-0.7.2}/.github/workflows/ci.yml +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/.gitignore +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/LICENSE +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/README.md +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/__main__.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/_daemon.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/agents/__init__.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/agents/music.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/agents/terminal.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/agents/watcher.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/analysis.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/binding.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/bip39.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/bridge.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/crypto.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/graph.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hook.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/__init__.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/claude_code.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/claude_code.sh +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/cline_hook.sh +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/codex_hook.sh +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/gemini_hook.sh +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/install.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/kiro_hook.sh +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/mcp_register.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/openclaw/HOOK.md +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/openclaw/handler.ts +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/openclaw_install.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/opencode_plugin.js +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/hooks/wrappers.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/integrity.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/kdf.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/keychain.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/live.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/lock.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/mcp.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/migrate_db.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/proxy.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/repos.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/skills/methodproof/SKILL.md +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/viewer.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/methodproof/wordlist.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/test_windows_compat.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/tests/__init__.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_analysis.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_graph.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_hooks.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_live.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_openclaw_hooks.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_security.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_store.py +0 -0
- {methodproof-0.7.0 → methodproof-0.7.2}/tests/test_wrappers.py +0 -0
- {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
|
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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", ""),
|
|
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
|
|
52
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|