methodproof 0.7.37__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.37 → methodproof-0.8.0}/.github/workflows/ci.yml +6 -6
- {methodproof-0.7.37 → methodproof-0.8.0}/CHANGELOG.md +26 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/PKG-INFO +2 -1
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/agents/base.py +18 -1
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/agents/watcher.py +101 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/cli.py +76 -8
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/crypto.py +17 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/e2e.py +74 -30
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/claude_code.py +2 -4
- methodproof-0.8.0/methodproof/hooks/gemini_hook.sh +123 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/install.py +5 -1
- methodproof-0.8.0/methodproof/migrate_db.py +161 -0
- methodproof-0.8.0/methodproof/repos.py +70 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/store.py +77 -6
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/sync.py +10 -1
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/viewer.py +1 -1
- {methodproof-0.7.37 → methodproof-0.8.0}/pyproject.toml +2 -2
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/conftest.py +1 -0
- methodproof-0.8.0/tests/test_repos.py +73 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_security.py +102 -1
- {methodproof-0.7.37 → methodproof-0.8.0}/uv.lock +168 -1
- methodproof-0.7.37/methodproof/hooks/gemini_hook.sh +0 -58
- methodproof-0.7.37/methodproof/migrate_db.py +0 -41
- methodproof-0.7.37/methodproof/repos.py +0 -25
- {methodproof-0.7.37 → methodproof-0.8.0}/.gitignore +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/LICENSE +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/README.md +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/__init__.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/__main__.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/_daemon.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/agents/__init__.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/agents/music.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/agents/terminal.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/analysis.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/binding.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/bip39.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/bridge.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/config.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/graph.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hook.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/__init__.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/claude_code.sh +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/cline_hook.sh +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/codex_hook.sh +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/kiro_hook.sh +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/mcp_register.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/openclaw/HOOK.md +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/openclaw/handler.ts +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/openclaw_install.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/opencode_plugin.js +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/wrappers.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/integrity.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/kdf.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/keychain.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/live.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/lock.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/mcp.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/proxy.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/proxy_daemon.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/skills/methodproof/SKILL.md +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/__init__.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/consent.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/init.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/log.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/login_success.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/review.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/start.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/status.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/theme.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/wordlist.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/test_windows_compat.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/__init__.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_analysis.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_auth.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_config.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_helpers.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_session.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_share.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_start.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_update.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_e2e_integration.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_graph.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_hooks.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_live.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_openclaw_hooks.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_profiles.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_store.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_sync.py +0 -0
- {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_viewer.py +0 -0
- {methodproof-0.7.37 → 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,31 @@
|
|
|
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
|
+
|
|
19
|
+
## [0.7.38] — 2026-04-12
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **Structural hunk capture on `file_edit` and `git_commit`** — watcher parses `@@ -old,count +new,count @@` headers from `git diff --unified=0` and `git show --format= --unified=0 <sha>` and emits them as `hunks: [{old_start, old_count, new_start, new_count}, ...]` on `file_edit` metadata and `file_hunks: {path: [hunks, ...]}` on `git_commit` metadata. Pure range data — no code content, no identifiers. Stays inside the structural-capture boundary alongside `lines_added` / `lines_removed`.
|
|
23
|
+
- **Journal-mode hunk content** — when `code_capture` consent is on (Pro tier, journal mode), each hunk additionally carries a `lines: ["+foo", "-bar", ...]` field with the actual diff lines for that hunk, capped at 2000 lines per event. Gated by the same consent flag that gates the raw `diff` field.
|
|
24
|
+
- **`base.is_content_captured()`** — public helper in `methodproof/agents/base.py` exposing the `code_capture` capture-flag check so agents can decide what content to include without reaching into private module state.
|
|
25
|
+
|
|
26
|
+
### Why
|
|
27
|
+
Enables the platform's post-processing pipeline to distinguish `SUPERSEDES` (later commit replaces 100% of an earlier commit's added lines on each shared path) from `EXTENSION_TO` (anything less — the default) without ever needing the raw diff content. See `methodproof` changelog v0.6.30.
|
|
28
|
+
|
|
3
29
|
## [0.7.37] — 2026-04-12
|
|
4
30
|
|
|
5
31
|
### Changed
|
|
@@ -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", "")
|
|
@@ -125,6 +135,13 @@ def log(level: str, event: str, **kw: object) -> None:
|
|
|
125
135
|
sys.stderr.write(json.dumps(entry, default=str) + "\n")
|
|
126
136
|
|
|
127
137
|
|
|
138
|
+
def is_content_captured() -> bool:
|
|
139
|
+
"""True when code_capture consent is on (journal/Pro mode). Agents use this
|
|
140
|
+
to decide whether to include line-level diff content alongside structural
|
|
141
|
+
metadata."""
|
|
142
|
+
return bool(_capture.get("code_capture", False))
|
|
143
|
+
|
|
144
|
+
|
|
128
145
|
def emit(event_type: str, metadata: dict[str, Any]) -> None:
|
|
129
146
|
if not _initialized:
|
|
130
147
|
log("warning", "emit.before_init", type=event_type)
|
|
@@ -46,6 +46,99 @@ IGNORE_PATTERNS = re.compile(
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
_MAX_DIFF_BYTES = 50_000
|
|
49
|
+
_MAX_HUNK_LINES = 2_000
|
|
50
|
+
|
|
51
|
+
_HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
52
|
+
_DIFF_FILE_RE = re.compile(r"^diff --git a/(.+?) b/(.+?)$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_hunks(diff_text: str, include_lines: bool) -> list[dict[str, object]]:
|
|
56
|
+
"""Parse unified diff text into structured hunks.
|
|
57
|
+
|
|
58
|
+
Each hunk: {old_start, old_count, new_start, new_count, [lines]}.
|
|
59
|
+
Lines are only included when `include_lines` is True (journal / code_capture).
|
|
60
|
+
"""
|
|
61
|
+
hunks: list[dict[str, object]] = []
|
|
62
|
+
current: dict[str, object] | None = None
|
|
63
|
+
total_lines = 0
|
|
64
|
+
for line in diff_text.splitlines():
|
|
65
|
+
header = _HUNK_HEADER_RE.match(line)
|
|
66
|
+
if header:
|
|
67
|
+
if current is not None:
|
|
68
|
+
hunks.append(current)
|
|
69
|
+
current = {
|
|
70
|
+
"old_start": int(header.group(1)),
|
|
71
|
+
"old_count": int(header.group(2) or 1),
|
|
72
|
+
"new_start": int(header.group(3)),
|
|
73
|
+
"new_count": int(header.group(4) or 1),
|
|
74
|
+
}
|
|
75
|
+
if include_lines:
|
|
76
|
+
current["lines"] = []
|
|
77
|
+
continue
|
|
78
|
+
if current is None or not include_lines:
|
|
79
|
+
continue
|
|
80
|
+
if not (line.startswith("+") or line.startswith("-")):
|
|
81
|
+
continue
|
|
82
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
83
|
+
continue
|
|
84
|
+
if total_lines >= _MAX_HUNK_LINES:
|
|
85
|
+
continue
|
|
86
|
+
current["lines"].append(line) # type: ignore[union-attr]
|
|
87
|
+
total_lines += 1
|
|
88
|
+
if current is not None:
|
|
89
|
+
hunks.append(current)
|
|
90
|
+
return hunks
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_show_hunks(
|
|
94
|
+
show_text: str, include_lines: bool,
|
|
95
|
+
) -> dict[str, list[dict[str, object]]]:
|
|
96
|
+
"""Split `git show --unified=0` output by file and parse hunks per file."""
|
|
97
|
+
file_hunks: dict[str, list[dict[str, object]]] = {}
|
|
98
|
+
current_path: str | None = None
|
|
99
|
+
buffer: list[str] = []
|
|
100
|
+
|
|
101
|
+
def _flush() -> None:
|
|
102
|
+
if current_path and buffer:
|
|
103
|
+
file_hunks[current_path] = _parse_hunks("\n".join(buffer), include_lines)
|
|
104
|
+
|
|
105
|
+
for line in show_text.splitlines():
|
|
106
|
+
m = _DIFF_FILE_RE.match(line)
|
|
107
|
+
if m:
|
|
108
|
+
_flush()
|
|
109
|
+
current_path = m.group(2)
|
|
110
|
+
buffer = []
|
|
111
|
+
continue
|
|
112
|
+
if current_path is not None:
|
|
113
|
+
buffer.append(line)
|
|
114
|
+
_flush()
|
|
115
|
+
return file_hunks
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _git_diff_hunks(repo: str, path: str, include_lines: bool) -> list[dict[str, object]]:
|
|
119
|
+
"""Structured hunks for a pending file_edit. Ranges always; line content when allowed."""
|
|
120
|
+
try:
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
["git", "-C", repo, "diff", "--unified=0", "--", path],
|
|
123
|
+
capture_output=True, text=True, timeout=5,
|
|
124
|
+
)
|
|
125
|
+
return _parse_hunks(result.stdout[:_MAX_DIFF_BYTES], include_lines)
|
|
126
|
+
except Exception:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _git_show_file_hunks(
|
|
131
|
+
repo: str, sha: str, include_lines: bool,
|
|
132
|
+
) -> dict[str, list[dict[str, object]]]:
|
|
133
|
+
"""Per-file structured hunks for a commit."""
|
|
134
|
+
try:
|
|
135
|
+
result = subprocess.run(
|
|
136
|
+
["git", "-C", repo, "show", "--format=", "--unified=0", sha],
|
|
137
|
+
capture_output=True, text=True, timeout=10,
|
|
138
|
+
)
|
|
139
|
+
return _parse_show_hunks(result.stdout[:_MAX_DIFF_BYTES * 4], include_lines)
|
|
140
|
+
except Exception:
|
|
141
|
+
return {}
|
|
49
142
|
|
|
50
143
|
|
|
51
144
|
def _git_diff_stats(repo: str, path: str) -> tuple[int, int]:
|
|
@@ -125,10 +218,14 @@ class _Handler(FileSystemEventHandler):
|
|
|
125
218
|
|
|
126
219
|
added, removed = _git_diff_stats(self._root, path)
|
|
127
220
|
lang = Path(event.src_path).suffix.lstrip(".")
|
|
221
|
+
include_lines = base.is_content_captured()
|
|
222
|
+
hunks = _git_diff_hunks(self._root, path, include_lines)
|
|
128
223
|
meta: dict[str, object] = {
|
|
129
224
|
"path": path, "language": lang,
|
|
130
225
|
"lines_added": added, "lines_removed": removed,
|
|
131
226
|
}
|
|
227
|
+
if hunks:
|
|
228
|
+
meta["hunks"] = hunks
|
|
132
229
|
diff = _git_diff_content(self._root, path)
|
|
133
230
|
if diff:
|
|
134
231
|
meta["diff"] = diff
|
|
@@ -192,6 +289,10 @@ def _log_commit(watch_dir: str, sha: str) -> None:
|
|
|
192
289
|
meta["parent_hash"] = parent_hash
|
|
193
290
|
if body and body != subject:
|
|
194
291
|
meta["body"] = body[:2000]
|
|
292
|
+
include_lines = base.is_content_captured()
|
|
293
|
+
file_hunks = _git_show_file_hunks(watch_dir, sha, include_lines)
|
|
294
|
+
if file_hunks:
|
|
295
|
+
meta["file_hunks"] = file_hunks
|
|
195
296
|
diff = _git_show_diff(watch_dir, sha)
|
|
196
297
|
if diff:
|
|
197
298
|
meta["diff"] = diff
|
|
@@ -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()
|