methodproof 0.7.38__tar.gz → 0.8.1__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.8.1/.code-review-graph/.gitignore +3 -0
- methodproof-0.8.1/.code-review-graph/graph.db +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/.github/workflows/ci.yml +6 -6
- {methodproof-0.7.38 → methodproof-0.8.1}/CHANGELOG.md +35 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/PKG-INFO +2 -1
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/__init__.py +1 -1
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/agents/base.py +40 -1
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/agents/terminal.py +45 -5
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/agents/watcher.py +62 -19
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/cli.py +76 -10
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/crypto.py +17 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/e2e.py +74 -30
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/claude_code.py +4 -7
- methodproof-0.8.1/methodproof/migrate_db.py +161 -0
- methodproof-0.8.1/methodproof/repos.py +70 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/store.py +77 -6
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/sync.py +10 -1
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/log.py +106 -12
- methodproof-0.8.1/methodproof/tui/theme.py +156 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/viewer.py +1 -1
- {methodproof-0.7.38 → methodproof-0.8.1}/pyproject.toml +2 -2
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/conftest.py +1 -0
- methodproof-0.8.1/tests/test_repos.py +73 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_security.py +102 -1
- {methodproof-0.7.38 → methodproof-0.8.1}/uv.lock +168 -1
- methodproof-0.7.38/methodproof/migrate_db.py +0 -41
- methodproof-0.7.38/methodproof/repos.py +0 -25
- methodproof-0.7.38/methodproof/tui/theme.py +0 -106
- {methodproof-0.7.38 → methodproof-0.8.1}/.gitignore +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/LICENSE +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/README.md +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/__main__.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/_daemon.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/agents/__init__.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/agents/music.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/analysis.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/binding.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/bip39.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/bridge.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/config.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/graph.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hook.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/__init__.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/claude_code.sh +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/cline_hook.sh +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/codex_hook.sh +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/gemini_hook.sh +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/install.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/kiro_hook.sh +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/mcp_register.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/openclaw/HOOK.md +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/openclaw/handler.ts +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/openclaw_install.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/opencode_plugin.js +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/wrappers.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/integrity.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/kdf.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/keychain.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/live.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/lock.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/mcp.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/proxy.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/proxy_daemon.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/skills/methodproof/SKILL.md +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/__init__.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/consent.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/init.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/login_success.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/review.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/start.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/status.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/wordlist.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/test_windows_compat.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/__init__.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_analysis.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_auth.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_config.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_helpers.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_session.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_share.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_start.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_update.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_e2e_integration.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_graph.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_hooks.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_live.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_openclaw_hooks.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_profiles.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_store.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_sync.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_viewer.py +0 -0
- {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_wrappers.py +0 -0
|
Binary file
|
|
@@ -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,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.1] — 2026-04-17
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **File rename detection** — watcher `on_moved` handler emits `file_rename` atoms with `old_path`/`new_path` instead of producing two incorrect atoms (delete + create).
|
|
7
|
+
- **`git_branch_switch` event** — git poller reads `.git/HEAD` on each 2s cycle and emits `git_branch_switch` with `old_branch`/`new_branch` when the active branch changes.
|
|
8
|
+
- **`git_commit` branch name** — commits now carry a `branch` field read from `.git/HEAD`.
|
|
9
|
+
- **`git_commit` per-file change status** — `files_changed` is now accompanied by `file_statuses` (`{path: "A"|"M"|"D"|"R"}`) via `--name-status` instead of `--name-only`.
|
|
10
|
+
- **11 new test frameworks** — detection and result parsing for vitest, mocha, rspec, minitest, phpunit, unittest, dotnet test, swift test, gradle test, maven test, and ExUnit (total: 15).
|
|
11
|
+
- **`test_run` carries `cwd`** — forwarded from the shell hook JSONL instead of being discarded.
|
|
12
|
+
- **`is_journal_mode()` public API** — exposed on `agents.base` for agents making structural-vs-full content capture decisions.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **15 hook event types bypassed consent gates** — `claude_session_end`, `agent_turn_end`, `agent_turn_error`, `context_compact_start/end`, `permission_request/denied`, `mcp_elicitation/result`, `worktree_create/remove`, `cwd_changed`, `tool_failure`, plus new `file_rename` and `git_branch_switch` — all now properly gated in `_EVENT_GATES`.
|
|
16
|
+
- **Removed all arbitrary data truncation** — no more 50KB diff cap, 2000-line hunk cap, 2000-char commit body cap, 500-char terminal output cap, or 200-char error cap. The CLI captures faithfully; downstream consumers decide what to do with large payloads. Preview fields (derived summaries alongside raw data) are unchanged.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **TUI inline detail screen** — `mp log` enter key opens a full-screen `DetailScreen` with event mix breakdown and per-type timeline instead of exiting to an external viewer.
|
|
20
|
+
- **SHOMEN/KINMYAKU theme toggle** — `MP_THEME=shomen` selects the light theme; default remains `kinmyaku` (dark). `Theme` dataclass adds `accent`, `cursor_bg`, `cursor_fg` semantic roles. DataTable cursor CSS for both themes.
|
|
21
|
+
|
|
22
|
+
## [0.8.0] — 2026-04-15
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **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.
|
|
26
|
+
- **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.
|
|
27
|
+
- **Daemon-liveness guard** — `DaemonActiveError` is raised if a recording daemon is still holding FDs on the plaintext DB. Callers must `mp stop` first.
|
|
28
|
+
- **`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.
|
|
29
|
+
- **`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.
|
|
30
|
+
- **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`).
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- **`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")`.
|
|
34
|
+
|
|
35
|
+
### Why
|
|
36
|
+
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.
|
|
37
|
+
|
|
3
38
|
## [0.7.38] — 2026-04-12
|
|
4
39
|
|
|
5
40
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: methodproof
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
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
|
|
@@ -68,6 +68,24 @@ _EVENT_GATES: dict[str, str] = {
|
|
|
68
68
|
"gemini_session_end": "ai_responses",
|
|
69
69
|
"kiro_session_start": "ai_prompts",
|
|
70
70
|
"kiro_session_end": "ai_responses",
|
|
71
|
+
# Hook lifecycle events (continued)
|
|
72
|
+
"claude_session_end": "ai_responses",
|
|
73
|
+
"agent_turn_end": "ai_responses",
|
|
74
|
+
"agent_turn_error": "ai_responses",
|
|
75
|
+
"context_compact_start": "ai_responses",
|
|
76
|
+
"context_compact_end": "ai_responses",
|
|
77
|
+
"permission_request": "ai_responses",
|
|
78
|
+
"permission_denied": "ai_responses",
|
|
79
|
+
"mcp_elicitation": "ai_responses",
|
|
80
|
+
"mcp_elicitation_result": "ai_responses",
|
|
81
|
+
"worktree_create": "ai_responses",
|
|
82
|
+
"worktree_remove": "ai_responses",
|
|
83
|
+
"cwd_changed": "ai_responses",
|
|
84
|
+
"tool_failure": "ai_responses",
|
|
85
|
+
# File system events
|
|
86
|
+
"file_rename": "file_changes",
|
|
87
|
+
# Git events
|
|
88
|
+
"git_branch_switch": "git_commits",
|
|
71
89
|
}
|
|
72
90
|
|
|
73
91
|
# Maps capture categories to (event_type, field) pairs for field-level gating.
|
|
@@ -108,9 +126,19 @@ def init(session_id: str, live: bool = False, verbose: bool = False, streaming:
|
|
|
108
126
|
_verbose = verbose
|
|
109
127
|
_streaming = streaming
|
|
110
128
|
_prev_hash = "genesis"
|
|
111
|
-
from methodproof import config
|
|
129
|
+
from methodproof import config, store
|
|
112
130
|
cfg = config.load()
|
|
113
131
|
_e2e_key = _load_encryption_key(cfg)
|
|
132
|
+
# Daemon is a fresh process — wire the SQLCipher key (always master-derived
|
|
133
|
+
# db_key, never user E2E) into the store before any DB write.
|
|
134
|
+
if store._encrypted_flag_path().exists():
|
|
135
|
+
account_id = cfg.get("account_id", "")
|
|
136
|
+
if account_id and cfg.get("master_key_fingerprint"):
|
|
137
|
+
from methodproof.keychain import load_secret
|
|
138
|
+
from methodproof.kdf import derive_master, derive_db_key
|
|
139
|
+
entropy = load_secret(account_id)
|
|
140
|
+
if entropy:
|
|
141
|
+
store.set_db_key(derive_db_key(derive_master(entropy), account_id))
|
|
114
142
|
_capture = cfg.get("capture", {})
|
|
115
143
|
_journal_mode = cfg.get("journal_mode", False)
|
|
116
144
|
_account_id = cfg.get("account_id", "")
|
|
@@ -132,6 +160,13 @@ def is_content_captured() -> bool:
|
|
|
132
160
|
return bool(_capture.get("code_capture", False))
|
|
133
161
|
|
|
134
162
|
|
|
163
|
+
def is_journal_mode() -> bool:
|
|
164
|
+
"""True when journal_mode is enabled — full content capture (prompts,
|
|
165
|
+
completions, diffs, terminal output). Agents use this to decide whether
|
|
166
|
+
to include content fields that are stripped in structural-only mode."""
|
|
167
|
+
return _journal_mode
|
|
168
|
+
|
|
169
|
+
|
|
135
170
|
def emit(event_type: str, metadata: dict[str, Any]) -> None:
|
|
136
171
|
if not _initialized:
|
|
137
172
|
log("warning", "emit.before_init", type=event_type)
|
|
@@ -216,6 +251,10 @@ def _stream_event(entry: dict[str, Any]) -> None:
|
|
|
216
251
|
detail = f'{meta.get("path", "?")} ({meta.get("size", 0)}B)'
|
|
217
252
|
elif etype == "file_delete":
|
|
218
253
|
detail = meta.get("path", "?")
|
|
254
|
+
elif etype == "file_rename":
|
|
255
|
+
detail = f'{meta.get("old_path", "?")} → {meta.get("new_path", "?")}'
|
|
256
|
+
elif etype == "git_branch_switch":
|
|
257
|
+
detail = f'{meta.get("old_branch", "?")} → {meta.get("new_branch", "?")}'
|
|
219
258
|
elif etype == "terminal_cmd":
|
|
220
259
|
detail = f'{meta.get("command", "?")[:60]} → exit {meta.get("exit_code", "?")}'
|
|
221
260
|
elif etype == "test_run":
|
|
@@ -20,11 +20,27 @@ TEST_FRAMEWORKS = {
|
|
|
20
20
|
"jest": re.compile(r"\bjest\b|npx jest|npm test"),
|
|
21
21
|
"go_test": re.compile(r"\bgo test\b"),
|
|
22
22
|
"cargo_test": re.compile(r"\bcargo test\b"),
|
|
23
|
+
"vitest": re.compile(r"\bvitest\b|npx vitest"),
|
|
24
|
+
"mocha": re.compile(r"\bmocha\b|npx mocha"),
|
|
25
|
+
"rspec": re.compile(r"\brspec\b|bundle exec rspec"),
|
|
26
|
+
"minitest": re.compile(r"\bruby\b.*test|rake test"),
|
|
27
|
+
"phpunit": re.compile(r"\bphpunit\b|vendor/bin/phpunit"),
|
|
28
|
+
"unittest": re.compile(r"python.*-m\s+unittest|python.*-m\s+nose"),
|
|
29
|
+
"dotnet_test": re.compile(r"\bdotnet test\b"),
|
|
30
|
+
"swift_test": re.compile(r"\bswift test\b"),
|
|
31
|
+
"gradle_test": re.compile(r"\bgradle\b.*\btest\b|gradlew.*\btest\b"),
|
|
32
|
+
"maven_test": re.compile(r"\bmvn\b.*\btest\b|mvnw.*\btest\b"),
|
|
33
|
+
"exunit": re.compile(r"\bmix test\b"),
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
_PYTEST_RE = re.compile(r"(\d+) passed(?:.*?(\d+) failed)?(?:.*?(\d+) skipped)?")
|
|
26
37
|
_JEST_RE = re.compile(r"Tests:\s+(?:(\d+) failed,\s+)?(\d+) passed(?:,\s+(\d+) skipped)?")
|
|
27
38
|
_CARGO_RE = re.compile(r"(\d+) passed.*?(\d+) failed")
|
|
39
|
+
_VITEST_RE = re.compile(r"Tests\s+(\d+) passed(?:\s*\|\s*(\d+) failed)?(?:\s*\|\s*(\d+) skipped)?")
|
|
40
|
+
_RSPEC_RE = re.compile(r"(\d+) examples?,\s*(\d+) failures?(?:,\s*(\d+) pending)?")
|
|
41
|
+
_PHPUNIT_RE = re.compile(r"OK \((\d+) tests?|Tests:\s*(\d+).*?Failures:\s*(\d+)")
|
|
42
|
+
_DOTNET_RE = re.compile(r"Passed!\s+-\s+Failed:\s+(\d+),\s+Passed:\s+(\d+),\s+Skipped:\s+(\d+)")
|
|
43
|
+
_EXUNIT_RE = re.compile(r"(\d+) tests?,\s*(\d+) failures?(?:,\s*(\d+) excluded)?")
|
|
28
44
|
|
|
29
45
|
|
|
30
46
|
def _detect_test(command: str) -> str | None:
|
|
@@ -40,9 +56,12 @@ def _parse_test_results(output: str, framework: str, exit_code: int) -> tuple[in
|
|
|
40
56
|
m = _PYTEST_RE.search(output)
|
|
41
57
|
if m:
|
|
42
58
|
return int(m.group(1)), int(m.group(2) or 0), int(m.group(3) or 0)
|
|
43
|
-
elif framework
|
|
44
|
-
|
|
59
|
+
elif framework in ("jest", "vitest"):
|
|
60
|
+
pat = _VITEST_RE if framework == "vitest" else _JEST_RE
|
|
61
|
+
m = pat.search(output)
|
|
45
62
|
if m:
|
|
63
|
+
if framework == "vitest":
|
|
64
|
+
return int(m.group(1) or 0), int(m.group(2) or 0), int(m.group(3) or 0)
|
|
46
65
|
return int(m.group(2) or 0), int(m.group(1) or 0), int(m.group(3) or 0)
|
|
47
66
|
elif framework == "go_test":
|
|
48
67
|
return output.count("--- PASS"), output.count("--- FAIL"), output.count("--- SKIP")
|
|
@@ -50,6 +69,24 @@ def _parse_test_results(output: str, framework: str, exit_code: int) -> tuple[in
|
|
|
50
69
|
m = _CARGO_RE.search(output)
|
|
51
70
|
if m:
|
|
52
71
|
return int(m.group(1)), int(m.group(2)), 0
|
|
72
|
+
elif framework == "rspec":
|
|
73
|
+
m = _RSPEC_RE.search(output)
|
|
74
|
+
if m:
|
|
75
|
+
return int(m.group(1)) - int(m.group(2)), int(m.group(2)), int(m.group(3) or 0)
|
|
76
|
+
elif framework == "phpunit":
|
|
77
|
+
m = _PHPUNIT_RE.search(output)
|
|
78
|
+
if m:
|
|
79
|
+
if m.group(1):
|
|
80
|
+
return int(m.group(1)), 0, 0
|
|
81
|
+
return int(m.group(2) or 0) - int(m.group(3) or 0), int(m.group(3) or 0), 0
|
|
82
|
+
elif framework == "dotnet_test":
|
|
83
|
+
m = _DOTNET_RE.search(output)
|
|
84
|
+
if m:
|
|
85
|
+
return int(m.group(2)), int(m.group(1)), int(m.group(3))
|
|
86
|
+
elif framework == "exunit":
|
|
87
|
+
m = _EXUNIT_RE.search(output)
|
|
88
|
+
if m:
|
|
89
|
+
return int(m.group(1)) - int(m.group(2)), int(m.group(2)), int(m.group(3) or 0)
|
|
53
90
|
if exit_code == 0:
|
|
54
91
|
return 1, 0, 0
|
|
55
92
|
return 0, 1, 0
|
|
@@ -91,7 +128,7 @@ def _process(line: str) -> None:
|
|
|
91
128
|
return
|
|
92
129
|
exit_code = entry.get("exit_code", 0)
|
|
93
130
|
duration = entry.get("duration_ms", 0)
|
|
94
|
-
output = entry.get("output", "")
|
|
131
|
+
output = entry.get("output", "")
|
|
95
132
|
if SENSITIVE.search(output):
|
|
96
133
|
output = "[redacted — contains sensitive content]"
|
|
97
134
|
cwd = entry.get("cwd", "")
|
|
@@ -107,7 +144,10 @@ def _process(line: str) -> None:
|
|
|
107
144
|
framework = _detect_test(command)
|
|
108
145
|
if framework:
|
|
109
146
|
passed, failed, skipped = _parse_test_results(output, framework, exit_code)
|
|
110
|
-
|
|
147
|
+
test_meta: dict = {
|
|
111
148
|
"framework": framework, "passed": passed, "failed": failed,
|
|
112
149
|
"skipped": skipped, "duration_ms": duration,
|
|
113
|
-
}
|
|
150
|
+
}
|
|
151
|
+
if cwd:
|
|
152
|
+
test_meta["cwd"] = cwd
|
|
153
|
+
base.emit("test_run", test_meta)
|
|
@@ -45,9 +45,6 @@ IGNORE_PATTERNS = re.compile(
|
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
_MAX_DIFF_BYTES = 50_000
|
|
49
|
-
_MAX_HUNK_LINES = 2_000
|
|
50
|
-
|
|
51
48
|
_HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
52
49
|
_DIFF_FILE_RE = re.compile(r"^diff --git a/(.+?) b/(.+?)$")
|
|
53
50
|
|
|
@@ -60,7 +57,6 @@ def _parse_hunks(diff_text: str, include_lines: bool) -> list[dict[str, object]]
|
|
|
60
57
|
"""
|
|
61
58
|
hunks: list[dict[str, object]] = []
|
|
62
59
|
current: dict[str, object] | None = None
|
|
63
|
-
total_lines = 0
|
|
64
60
|
for line in diff_text.splitlines():
|
|
65
61
|
header = _HUNK_HEADER_RE.match(line)
|
|
66
62
|
if header:
|
|
@@ -81,10 +77,7 @@ def _parse_hunks(diff_text: str, include_lines: bool) -> list[dict[str, object]]
|
|
|
81
77
|
continue
|
|
82
78
|
if line.startswith("+++") or line.startswith("---"):
|
|
83
79
|
continue
|
|
84
|
-
if total_lines >= _MAX_HUNK_LINES:
|
|
85
|
-
continue
|
|
86
80
|
current["lines"].append(line) # type: ignore[union-attr]
|
|
87
|
-
total_lines += 1
|
|
88
81
|
if current is not None:
|
|
89
82
|
hunks.append(current)
|
|
90
83
|
return hunks
|
|
@@ -122,7 +115,7 @@ def _git_diff_hunks(repo: str, path: str, include_lines: bool) -> list[dict[str,
|
|
|
122
115
|
["git", "-C", repo, "diff", "--unified=0", "--", path],
|
|
123
116
|
capture_output=True, text=True, timeout=5,
|
|
124
117
|
)
|
|
125
|
-
return _parse_hunks(result.stdout
|
|
118
|
+
return _parse_hunks(result.stdout, include_lines)
|
|
126
119
|
except Exception:
|
|
127
120
|
return []
|
|
128
121
|
|
|
@@ -136,7 +129,7 @@ def _git_show_file_hunks(
|
|
|
136
129
|
["git", "-C", repo, "show", "--format=", "--unified=0", sha],
|
|
137
130
|
capture_output=True, text=True, timeout=10,
|
|
138
131
|
)
|
|
139
|
-
return _parse_show_hunks(result.stdout
|
|
132
|
+
return _parse_show_hunks(result.stdout, include_lines)
|
|
140
133
|
except Exception:
|
|
141
134
|
return {}
|
|
142
135
|
|
|
@@ -160,25 +153,25 @@ def _git_diff_stats(repo: str, path: str) -> tuple[int, int]:
|
|
|
160
153
|
|
|
161
154
|
|
|
162
155
|
def _git_diff_content(repo: str, path: str) -> str:
|
|
163
|
-
"""Get full diff content for a file
|
|
156
|
+
"""Get full diff content for a file."""
|
|
164
157
|
try:
|
|
165
158
|
result = subprocess.run(
|
|
166
159
|
["git", "-C", repo, "diff", "--", path],
|
|
167
160
|
capture_output=True, text=True, timeout=5,
|
|
168
161
|
)
|
|
169
|
-
return result.stdout
|
|
162
|
+
return result.stdout
|
|
170
163
|
except Exception:
|
|
171
164
|
return ""
|
|
172
165
|
|
|
173
166
|
|
|
174
167
|
def _git_show_diff(repo: str, sha: str) -> str:
|
|
175
|
-
"""Get full diff for a commit
|
|
168
|
+
"""Get full diff for a commit."""
|
|
176
169
|
try:
|
|
177
170
|
result = subprocess.run(
|
|
178
171
|
["git", "-C", repo, "show", "--format=", sha],
|
|
179
172
|
capture_output=True, text=True, timeout=10,
|
|
180
173
|
)
|
|
181
|
-
return result.stdout
|
|
174
|
+
return result.stdout
|
|
182
175
|
except Exception:
|
|
183
176
|
return ""
|
|
184
177
|
|
|
@@ -239,14 +232,44 @@ class _Handler(FileSystemEventHandler):
|
|
|
239
232
|
lang = Path(event.src_path).suffix.lstrip(".")
|
|
240
233
|
base.emit("file_delete", {"path": path, "language": lang})
|
|
241
234
|
|
|
235
|
+
def on_moved(self, event: FileSystemEvent) -> None:
|
|
236
|
+
if event.is_directory:
|
|
237
|
+
return
|
|
238
|
+
src = event.src_path
|
|
239
|
+
dest = getattr(event, "dest_path", "")
|
|
240
|
+
if IGNORE_PATTERNS.search(src) and IGNORE_PATTERNS.search(dest or src):
|
|
241
|
+
return
|
|
242
|
+
old_path = self._relpath(src)
|
|
243
|
+
new_path = self._relpath(dest) if dest else old_path
|
|
244
|
+
lang = Path(dest or src).suffix.lstrip(".")
|
|
245
|
+
old_hash = self._hashes.pop(old_path, None)
|
|
246
|
+
if old_hash:
|
|
247
|
+
self._hashes[new_path] = old_hash
|
|
248
|
+
base.emit("file_rename", {
|
|
249
|
+
"old_path": old_path, "new_path": new_path, "language": lang,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _read_branch(head_file: Path) -> str:
|
|
254
|
+
"""Read current branch from .git/HEAD. Returns '' for detached HEAD."""
|
|
255
|
+
try:
|
|
256
|
+
content = head_file.read_text().strip()
|
|
257
|
+
if content.startswith("ref: refs/heads/"):
|
|
258
|
+
return content[16:]
|
|
259
|
+
return ""
|
|
260
|
+
except OSError:
|
|
261
|
+
return ""
|
|
262
|
+
|
|
242
263
|
|
|
243
264
|
def _poll_git(watch_dir: str, stop: threading.Event) -> None:
|
|
244
|
-
"""Poll .git/refs for new commits
|
|
265
|
+
"""Poll .git/refs for new commits and HEAD for branch switches."""
|
|
245
266
|
git_dir = Path(watch_dir) / ".git"
|
|
246
267
|
if not git_dir.exists():
|
|
247
268
|
return
|
|
248
269
|
seen: set[str] = set()
|
|
249
270
|
refs = git_dir / "refs" / "heads"
|
|
271
|
+
head_file = git_dir / "HEAD"
|
|
272
|
+
last_branch = _read_branch(head_file)
|
|
250
273
|
while not stop.is_set():
|
|
251
274
|
try:
|
|
252
275
|
for ref in refs.iterdir():
|
|
@@ -257,12 +280,17 @@ def _poll_git(watch_dir: str, stop: threading.Event) -> None:
|
|
|
257
280
|
_log_commit(watch_dir, sha)
|
|
258
281
|
except OSError:
|
|
259
282
|
pass
|
|
283
|
+
current_branch = _read_branch(head_file)
|
|
284
|
+
if current_branch and current_branch != last_branch and last_branch:
|
|
285
|
+
base.emit("git_branch_switch", {
|
|
286
|
+
"old_branch": last_branch, "new_branch": current_branch,
|
|
287
|
+
})
|
|
288
|
+
last_branch = current_branch
|
|
260
289
|
stop.wait(2)
|
|
261
290
|
|
|
262
291
|
|
|
263
292
|
def _log_commit(watch_dir: str, sha: str) -> None:
|
|
264
293
|
try:
|
|
265
|
-
# Single git call: subject\x00author\x00email\x00iso_date\x00parent_hash\x00full_body
|
|
266
294
|
fmt = subprocess.run(
|
|
267
295
|
["git", "-C", watch_dir, "log", "-1",
|
|
268
296
|
"--format=%s%x00%an%x00%ae%x00%ai%x00%P%x00%B", sha],
|
|
@@ -275,12 +303,22 @@ def _log_commit(watch_dir: str, sha: str) -> None:
|
|
|
275
303
|
committed_at = parts[3].strip() if len(parts) > 3 else ""
|
|
276
304
|
parent_hash = parts[4].strip()[:7] if len(parts) > 4 else ""
|
|
277
305
|
body = parts[5].strip() if len(parts) > 5 else ""
|
|
278
|
-
|
|
279
|
-
["git", "-C", watch_dir, "diff-tree", "--no-commit-id", "-r", "--name-
|
|
306
|
+
status_lines = subprocess.run(
|
|
307
|
+
["git", "-C", watch_dir, "diff-tree", "--no-commit-id", "-r", "--name-status", sha],
|
|
280
308
|
capture_output=True, text=True, timeout=5,
|
|
281
309
|
).stdout.strip().splitlines()
|
|
310
|
+
files: list[str] = []
|
|
311
|
+
file_statuses: dict[str, str] = {}
|
|
312
|
+
for sl in status_lines:
|
|
313
|
+
sl_parts = sl.split("\t", 1)
|
|
314
|
+
if len(sl_parts) == 2:
|
|
315
|
+
file_statuses[sl_parts[1]] = sl_parts[0]
|
|
316
|
+
files.append(sl_parts[1])
|
|
317
|
+
elif sl.strip():
|
|
318
|
+
files.append(sl.strip())
|
|
282
319
|
except Exception:
|
|
283
|
-
subject, author, author_email, committed_at, parent_hash, body
|
|
320
|
+
subject, author, author_email, committed_at, parent_hash, body = "", "", "", "", "", ""
|
|
321
|
+
files, file_statuses = [], {}
|
|
284
322
|
meta: dict[str, object] = {
|
|
285
323
|
"hash": sha[:7], "message": subject, "files_changed": files,
|
|
286
324
|
"author": author, "author_email": author_email, "committed_at": committed_at,
|
|
@@ -288,7 +326,12 @@ def _log_commit(watch_dir: str, sha: str) -> None:
|
|
|
288
326
|
if parent_hash:
|
|
289
327
|
meta["parent_hash"] = parent_hash
|
|
290
328
|
if body and body != subject:
|
|
291
|
-
meta["body"] = body
|
|
329
|
+
meta["body"] = body
|
|
330
|
+
if file_statuses:
|
|
331
|
+
meta["file_statuses"] = file_statuses
|
|
332
|
+
branch = _read_branch(Path(watch_dir) / ".git" / "HEAD")
|
|
333
|
+
if branch:
|
|
334
|
+
meta["branch"] = branch
|
|
292
335
|
include_lines = base.is_content_captured()
|
|
293
336
|
file_hunks = _git_show_file_hunks(watch_dir, sha, include_lines)
|
|
294
337
|
if file_hunks:
|
|
@@ -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():
|
|
@@ -1511,8 +1577,6 @@ def cmd_log(args: argparse.Namespace) -> None:
|
|
|
1511
1577
|
fake = _ap.Namespace(session_id=sid, local=False)
|
|
1512
1578
|
if action == "push":
|
|
1513
1579
|
cmd_push(fake)
|
|
1514
|
-
elif action == "view":
|
|
1515
|
-
cmd_view(fake)
|
|
1516
1580
|
return
|
|
1517
1581
|
sessions = store.list_sessions()
|
|
1518
1582
|
if not sessions:
|
|
@@ -1844,7 +1908,7 @@ def cmd_push(args: argparse.Namespace) -> None:
|
|
|
1844
1908
|
print(f"Found {len(unsynced)} unsynced sessions:\n")
|
|
1845
1909
|
for s in unsynced:
|
|
1846
1910
|
events = len(store.get_events(s["id"]))
|
|
1847
|
-
date = s["created_at"]
|
|
1911
|
+
date = datetime.fromtimestamp(s["created_at"]).strftime("%Y-%m-%d") if s.get("created_at") else "?"
|
|
1848
1912
|
print(f" {s['id'][:8]} {date} {events} events")
|
|
1849
1913
|
print()
|
|
1850
1914
|
answer = input(f"Push all {len(unsynced)}? [Y/n] ").strip().lower()
|
|
@@ -2342,6 +2406,7 @@ def main() -> None:
|
|
|
2342
2406
|
e2e_sub.add_parser("recover", help="Recover key from passphrase")
|
|
2343
2407
|
e2e_rel = e2e_sub.add_parser("release", help="Release a session from E2E encryption")
|
|
2344
2408
|
e2e_rel.add_argument("session_id", help="Session ID to release")
|
|
2409
|
+
e2e_sub.add_parser("release-all", help="Release every synced session from E2E encryption")
|
|
2345
2410
|
sub.add_parser("intro", help="Show the MethodProof intro")
|
|
2346
2411
|
sub.add_parser("help", help="Show command reference")
|
|
2347
2412
|
sub.add_parser("mcp-serve", help="Run MCP server (used by Claude Code)")
|
|
@@ -2381,5 +2446,6 @@ def main() -> None:
|
|
|
2381
2446
|
_update_check()
|
|
2382
2447
|
|
|
2383
2448
|
if args.cmd not in ("help", "update"):
|
|
2449
|
+
_wire_db_encryption(config.load())
|
|
2384
2450
|
store.init_db()
|
|
2385
2451
|
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
|