methodproof 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """MethodProof — see how you code."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ """Allow `python -m methodproof`."""
2
+ from methodproof.cli import main
3
+
4
+ main()
File without changes
@@ -0,0 +1,136 @@
1
+ """Shared telemetry primitives — one log, one emit, one flush."""
2
+
3
+ import json
4
+ import sys
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from typing import Any
9
+
10
+ from methodproof import store
11
+
12
+ _session_id = ""
13
+ _initialized = False
14
+ _e2e_key: bytes | None = None
15
+ _capture: dict[str, bool] = {}
16
+ _live_mode = False
17
+ _lock = threading.Lock()
18
+ _buffer: list[dict[str, Any]] = []
19
+ _FLUSH_SIZE = 50
20
+ _MAX_RETRIES = 3
21
+ _prev_hash = "genesis"
22
+
23
+ # Maps event types to the capture category that gates them
24
+ _EVENT_GATES: dict[str, str] = {
25
+ "terminal_cmd": "terminal_commands",
26
+ "test_run": "test_results",
27
+ "file_create": "file_changes",
28
+ "file_edit": "file_changes",
29
+ "file_delete": "file_changes",
30
+ "git_commit": "git_commits",
31
+ "llm_prompt": "ai_prompts",
32
+ "agent_prompt": "ai_prompts",
33
+ "llm_completion": "ai_responses",
34
+ "agent_completion": "ai_responses",
35
+ "agent_tool_dispatch": "ai_responses",
36
+ "agent_tool_result": "ai_responses",
37
+ "agent_skill_invoke": "ai_responses",
38
+ "agent_session_event": "ai_responses",
39
+ "inline_completion_shown": "ai_responses",
40
+ "inline_completion_accepted": "ai_responses",
41
+ "inline_completion_rejected": "ai_responses",
42
+ "browser_visit": "browser",
43
+ "browser_search": "browser",
44
+ "browser_tab_switch": "browser",
45
+ "browser_copy": "browser",
46
+ "browser_ai_chat": "browser",
47
+ "music_playing": "music",
48
+ }
49
+
50
+ # Maps capture categories to (event_type, field) pairs for field-level gating.
51
+ # When the category is disabled, these fields are stripped from emitted events.
52
+ # When enabled (code_capture), these fields are populated by the agent.
53
+ _FIELD_GATES: dict[str, list[tuple[str, str]]] = {
54
+ "command_output": [("terminal_cmd", "output_snippet")],
55
+ "code_capture": [("file_edit", "diff"), ("git_commit", "diff")],
56
+ }
57
+
58
+
59
+ def init(session_id: str, live: bool = False) -> None:
60
+ global _session_id, _initialized, _e2e_key, _capture, _live_mode, _prev_hash
61
+ _session_id = session_id
62
+ _initialized = True
63
+ _live_mode = live
64
+ _prev_hash = "genesis"
65
+ from methodproof import config
66
+ cfg = config.load()
67
+ raw = cfg.get("e2e_key", "")
68
+ _e2e_key = bytes.fromhex(raw) if raw else None
69
+ _capture = cfg.get("capture", {})
70
+
71
+
72
+ def log(level: str, event: str, **kw: object) -> None:
73
+ entry = {"ts": time.time(), "level": level, "event": event, "sid": _session_id, **kw}
74
+ sys.stderr.write(json.dumps(entry, default=str) + "\n")
75
+
76
+
77
+ def emit(event_type: str, metadata: dict[str, Any]) -> None:
78
+ if not _initialized:
79
+ log("warning", "emit.before_init", type=event_type)
80
+ return
81
+ # Event-level consent gate
82
+ gate = _EVENT_GATES.get(event_type)
83
+ if gate and not _capture.get(gate, True):
84
+ return
85
+ # Field-level consent gate — strip opted-out fields
86
+ for category, pairs in _FIELD_GATES.items():
87
+ for etype, field in pairs:
88
+ if event_type == etype and not _capture.get(category, True):
89
+ metadata.pop(field, None)
90
+
91
+ entry = {
92
+ "id": uuid.uuid4().hex,
93
+ "session_id": _session_id,
94
+ "type": event_type,
95
+ "timestamp": time.time(),
96
+ "duration_ms": metadata.pop("duration_ms", 0),
97
+ "metadata": metadata,
98
+ }
99
+ if _e2e_key:
100
+ from methodproof.crypto import encrypt_metadata
101
+ entry["metadata"] = encrypt_metadata(dict(entry["metadata"]), _e2e_key)
102
+ global _prev_hash
103
+ from methodproof.integrity import compute_event_hash
104
+ entry["_chain_hash"] = compute_event_hash(entry, _prev_hash)
105
+ _prev_hash = entry["_chain_hash"]
106
+ if _live_mode:
107
+ from methodproof import live as live_mod
108
+ live_mod.send(entry)
109
+ with _lock:
110
+ _buffer.append(entry)
111
+ if len(_buffer) >= _FLUSH_SIZE:
112
+ _flush_locked()
113
+
114
+
115
+ def flush() -> None:
116
+ with _lock:
117
+ _flush_locked()
118
+
119
+
120
+ def _flush_locked() -> None:
121
+ if not _buffer:
122
+ return
123
+ batch = list(_buffer)
124
+ hashes = [(e["id"], e.pop("_chain_hash")) for e in batch if "_chain_hash" in e]
125
+ for attempt in range(_MAX_RETRIES):
126
+ try:
127
+ store.insert_events(_session_id, batch)
128
+ if hashes:
129
+ store.insert_event_hashes(hashes)
130
+ _buffer.clear()
131
+ return
132
+ except Exception as exc:
133
+ log("warning", "flush.retry", attempt=attempt + 1, error=str(exc))
134
+ time.sleep(0.1 * (attempt + 1))
135
+ # Final attempt failed — keep events in buffer for next flush cycle
136
+ log("error", "flush.failed", count=len(batch), retries=_MAX_RETRIES)
@@ -0,0 +1,105 @@
1
+ """OS-level Now Playing detection — polls Spotify / Music.app / MPRIS."""
2
+
3
+ import platform
4
+ import subprocess
5
+ import threading
6
+ import time
7
+
8
+ from methodproof.agents import base
9
+
10
+ _POLL_INTERVAL = 10
11
+ _HEARTBEAT_INTERVAL = 60
12
+
13
+
14
+ def _get_now_playing_macos() -> dict[str, str] | None:
15
+ script = (
16
+ 'set output to ""\n'
17
+ 'if application "Spotify" is running then\n'
18
+ ' tell application "Spotify"\n'
19
+ ' if player state is playing then\n'
20
+ ' set output to name of current track & "|||" & artist of current track & "|||spotify_desktop"\n'
21
+ ' end if\n'
22
+ ' end tell\n'
23
+ 'end if\n'
24
+ 'if output is "" and application "Music" is running then\n'
25
+ ' tell application "Music"\n'
26
+ ' if player state is playing then\n'
27
+ ' set output to name of current track & "|||" & artist of current track & "|||apple_music_desktop"\n'
28
+ ' end if\n'
29
+ ' end tell\n'
30
+ 'end if\n'
31
+ 'return output'
32
+ )
33
+ try:
34
+ result = subprocess.run(
35
+ ["osascript", "-e", script],
36
+ capture_output=True, text=True, timeout=5,
37
+ )
38
+ parts = result.stdout.strip().split("|||")
39
+ if len(parts) != 3 or not parts[0]:
40
+ return None
41
+ return {"track": parts[0], "artist": parts[1], "player": parts[2]}
42
+ except Exception:
43
+ return None
44
+
45
+
46
+ def _get_now_playing_linux() -> dict[str, str] | None:
47
+ try:
48
+ result = subprocess.run(
49
+ ["playerctl", "metadata", "--format", "{{artist}}|||{{title}}|||{{playerName}}"],
50
+ capture_output=True, text=True, timeout=5,
51
+ )
52
+ if result.returncode != 0:
53
+ return None
54
+ parts = result.stdout.strip().split("|||")
55
+ if len(parts) != 3 or not parts[1]:
56
+ return None
57
+ player_map = {"spotify": "spotify_desktop", "chromium": "unknown", "firefox": "unknown"}
58
+ return {"track": parts[1], "artist": parts[0], "player": player_map.get(parts[2].lower(), "unknown")}
59
+ except FileNotFoundError:
60
+ return None
61
+ except Exception:
62
+ return None
63
+
64
+
65
+ def start(stop: threading.Event) -> None:
66
+ getter = (
67
+ _get_now_playing_macos if platform.system() == "Darwin"
68
+ else _get_now_playing_linux if platform.system() == "Linux"
69
+ else None
70
+ )
71
+ if getter is None:
72
+ base.log("info", "music.unsupported_platform", platform=platform.system())
73
+ return
74
+
75
+ base.log("info", "music.started")
76
+ last_track_key = ""
77
+ last_emit_time = 0.0
78
+
79
+ while not stop.is_set():
80
+ info = getter()
81
+ now = time.time()
82
+
83
+ if info:
84
+ track_key = f"{info['artist']}::{info['track']}"
85
+ if track_key != last_track_key:
86
+ base.emit("music_playing", {
87
+ "track": info["track"], "artist": info["artist"],
88
+ "source": "os_media", "player": info["player"],
89
+ "event_kind": "track_change",
90
+ })
91
+ last_track_key = track_key
92
+ last_emit_time = now
93
+ elif now - last_emit_time >= _HEARTBEAT_INTERVAL:
94
+ base.emit("music_playing", {
95
+ "track": info["track"], "artist": info["artist"],
96
+ "source": "os_media", "player": info["player"],
97
+ "event_kind": "heartbeat",
98
+ })
99
+ last_emit_time = now
100
+ elif last_track_key:
101
+ last_track_key = ""
102
+
103
+ stop.wait(_POLL_INTERVAL)
104
+
105
+ base.log("info", "music.stopped")
@@ -0,0 +1,111 @@
1
+ """Terminal monitor — captures commands from shell hook JSONL log."""
2
+
3
+ import json
4
+ import re
5
+ import threading
6
+
7
+ from methodproof.agents import base
8
+ from methodproof.config import CMD_LOG
9
+
10
+ SENSITIVE = re.compile(
11
+ r"(^export\s|^set\s|password|token|secret|api[_-]?key|access[_-]?key"
12
+ r"|credential|private[_-]?key|Authorization:\s*Bearer|ssh\s+-i\s"
13
+ r"|postgres://\S+:\S+@|mysql://\S+:\S+@|--password|--secret|--token"
14
+ r"|AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36})",
15
+ re.IGNORECASE,
16
+ )
17
+
18
+ TEST_FRAMEWORKS = {
19
+ "pytest": re.compile(r"\bpytest\b"),
20
+ "jest": re.compile(r"\bjest\b|npx jest|npm test"),
21
+ "go_test": re.compile(r"\bgo test\b"),
22
+ "cargo_test": re.compile(r"\bcargo test\b"),
23
+ }
24
+
25
+ _PYTEST_RE = re.compile(r"(\d+) passed(?:.*?(\d+) failed)?")
26
+ _JEST_RE = re.compile(r"Tests:\s+(?:(\d+) failed,\s+)?(\d+) passed")
27
+ _CARGO_RE = re.compile(r"(\d+) passed.*?(\d+) failed")
28
+
29
+
30
+ def _detect_test(command: str) -> str | None:
31
+ for name, pattern in TEST_FRAMEWORKS.items():
32
+ if pattern.search(command):
33
+ return name
34
+ return None
35
+
36
+
37
+ def _parse_test_results(output: str, framework: str, exit_code: int) -> tuple[int, int]:
38
+ """Extract pass/fail counts from test output. Falls back to exit code heuristic."""
39
+ if framework == "pytest":
40
+ m = _PYTEST_RE.search(output)
41
+ if m:
42
+ return int(m.group(1)), int(m.group(2) or 0)
43
+ elif framework == "jest":
44
+ m = _JEST_RE.search(output)
45
+ if m:
46
+ return int(m.group(2) or 0), int(m.group(1) or 0)
47
+ elif framework == "go_test":
48
+ return output.count("--- PASS"), output.count("--- FAIL")
49
+ elif framework == "cargo_test":
50
+ m = _CARGO_RE.search(output)
51
+ if m:
52
+ return int(m.group(1)), int(m.group(2))
53
+ # Fallback: exit code 0 = all passed, non-zero = at least 1 failure
54
+ if exit_code == 0:
55
+ return 1, 0
56
+ return 0, 1
57
+
58
+
59
+ def start(stop: threading.Event) -> None:
60
+ """Tail the command log and emit events until stop is set."""
61
+ base.log("info", "terminal.started", log=str(CMD_LOG))
62
+ pos = 0
63
+ if CMD_LOG.exists():
64
+ pos = CMD_LOG.stat().st_size
65
+
66
+ while not stop.is_set():
67
+ if not CMD_LOG.exists():
68
+ stop.wait(1)
69
+ continue
70
+ size = CMD_LOG.stat().st_size
71
+ if size <= pos:
72
+ stop.wait(0.5)
73
+ continue
74
+ with open(CMD_LOG) as f:
75
+ f.seek(pos)
76
+ for line in f:
77
+ _process(line.strip())
78
+ pos = f.tell()
79
+
80
+ base.log("info", "terminal.stopped")
81
+
82
+
83
+ def _process(line: str) -> None:
84
+ if not line:
85
+ return
86
+ try:
87
+ entry = json.loads(line)
88
+ except json.JSONDecodeError:
89
+ return
90
+ command = entry.get("command", "")
91
+ if not command or SENSITIVE.search(command):
92
+ return
93
+ exit_code = entry.get("exit_code", 0)
94
+ duration = entry.get("duration_ms", 0)
95
+ output = entry.get("output", "")[:500]
96
+ # Redact output if it contains secrets
97
+ if SENSITIVE.search(output):
98
+ output = "[redacted — contains sensitive content]"
99
+
100
+ base.emit("terminal_cmd", {
101
+ "command": command, "exit_code": exit_code,
102
+ "output_snippet": output, "duration_ms": duration,
103
+ })
104
+
105
+ framework = _detect_test(command)
106
+ if framework:
107
+ passed, failed = _parse_test_results(output, framework, exit_code)
108
+ base.emit("test_run", {
109
+ "framework": framework, "passed": passed, "failed": failed,
110
+ "duration_ms": duration,
111
+ })
@@ -0,0 +1,170 @@
1
+ """File watcher agent — captures file changes and git commits."""
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import threading
8
+ from pathlib import Path
9
+
10
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
11
+ from watchdog.observers import Observer
12
+
13
+ from methodproof.agents import base
14
+
15
+ IGNORE_PATTERNS = re.compile(
16
+ r"(__pycache__|\.pyc|\.git/|node_modules|\.DS_Store|\.swp|~$)"
17
+ )
18
+
19
+
20
+ _MAX_DIFF_BYTES = 50_000
21
+
22
+
23
+ def _git_diff_stats(repo: str, path: str) -> tuple[int, int]:
24
+ """Run git diff --stat for a file, return (lines_added, lines_removed)."""
25
+ try:
26
+ result = subprocess.run(
27
+ ["git", "-C", repo, "diff", "--numstat", "--", path],
28
+ capture_output=True, text=True, timeout=5,
29
+ )
30
+ line = result.stdout.strip()
31
+ if not line:
32
+ return 0, 0
33
+ parts = line.split("\t")
34
+ added = int(parts[0]) if parts[0] != "-" else 0
35
+ removed = int(parts[1]) if parts[1] != "-" else 0
36
+ return added, removed
37
+ except Exception:
38
+ return 0, 0
39
+
40
+
41
+ def _git_diff_content(repo: str, path: str) -> str:
42
+ """Get full diff content for a file, capped at 50KB."""
43
+ try:
44
+ result = subprocess.run(
45
+ ["git", "-C", repo, "diff", "--", path],
46
+ capture_output=True, text=True, timeout=5,
47
+ )
48
+ return result.stdout[:_MAX_DIFF_BYTES]
49
+ except Exception:
50
+ return ""
51
+
52
+
53
+ def _git_show_diff(repo: str, sha: str) -> str:
54
+ """Get full diff for a commit, capped at 50KB."""
55
+ try:
56
+ result = subprocess.run(
57
+ ["git", "-C", repo, "show", "--format=", sha],
58
+ capture_output=True, text=True, timeout=10,
59
+ )
60
+ return result.stdout[:_MAX_DIFF_BYTES]
61
+ except Exception:
62
+ return ""
63
+
64
+
65
+ class _Handler(FileSystemEventHandler):
66
+ def __init__(self, watch_dir: str) -> None:
67
+ self._root = watch_dir
68
+ self._hashes: dict[str, str] = {}
69
+
70
+ def _relpath(self, path: str) -> str:
71
+ return os.path.relpath(path, self._root)
72
+
73
+ def on_created(self, event: FileSystemEvent) -> None:
74
+ if event.is_directory or IGNORE_PATTERNS.search(event.src_path):
75
+ return
76
+ path = self._relpath(event.src_path)
77
+ lang = Path(event.src_path).suffix.lstrip(".")
78
+ try:
79
+ size = os.path.getsize(event.src_path)
80
+ except OSError:
81
+ size = 0
82
+ base.emit("file_create", {"path": path, "size": size, "language": lang})
83
+
84
+ def on_modified(self, event: FileSystemEvent) -> None:
85
+ if event.is_directory or IGNORE_PATTERNS.search(event.src_path):
86
+ return
87
+ # Skip if content unchanged (watchdog fires on metadata changes)
88
+ try:
89
+ content = Path(event.src_path).read_bytes()
90
+ except OSError:
91
+ return
92
+ path = self._relpath(event.src_path)
93
+ h = hashlib.md5(content).hexdigest()
94
+ if self._hashes.get(path) == h:
95
+ return
96
+ self._hashes[path] = h
97
+
98
+ added, removed = _git_diff_stats(self._root, path)
99
+ lang = Path(event.src_path).suffix.lstrip(".")
100
+ meta: dict[str, object] = {
101
+ "path": path, "language": lang,
102
+ "lines_added": added, "lines_removed": removed,
103
+ }
104
+ diff = _git_diff_content(self._root, path)
105
+ if diff:
106
+ meta["diff"] = diff
107
+ base.emit("file_edit", meta)
108
+
109
+ def on_deleted(self, event: FileSystemEvent) -> None:
110
+ if event.is_directory or IGNORE_PATTERNS.search(event.src_path):
111
+ return
112
+ path = self._relpath(event.src_path)
113
+ self._hashes.pop(path, None)
114
+ base.emit("file_delete", {"path": path})
115
+
116
+
117
+ def _poll_git(watch_dir: str, stop: threading.Event) -> None:
118
+ """Poll .git/refs for new commits every 2 seconds."""
119
+ git_dir = Path(watch_dir) / ".git"
120
+ if not git_dir.exists():
121
+ return
122
+ seen: set[str] = set()
123
+ refs = git_dir / "refs" / "heads"
124
+ while not stop.is_set():
125
+ try:
126
+ for ref in refs.iterdir():
127
+ sha = ref.read_text().strip()
128
+ if sha not in seen:
129
+ seen.add(sha)
130
+ if len(seen) > 1:
131
+ _log_commit(watch_dir, sha)
132
+ except OSError:
133
+ pass
134
+ stop.wait(2)
135
+
136
+
137
+ def _log_commit(watch_dir: str, sha: str) -> None:
138
+ try:
139
+ msg = subprocess.run(
140
+ ["git", "-C", watch_dir, "log", "-1", "--format=%s", sha],
141
+ capture_output=True, text=True, timeout=5,
142
+ ).stdout.strip()
143
+ files = subprocess.run(
144
+ ["git", "-C", watch_dir, "diff-tree", "--no-commit-id", "-r", "--name-only", sha],
145
+ capture_output=True, text=True, timeout=5,
146
+ ).stdout.strip().splitlines()
147
+ except Exception:
148
+ msg, files = "", []
149
+ meta: dict[str, object] = {"hash": sha[:7], "message": msg, "files_changed": files}
150
+ diff = _git_show_diff(watch_dir, sha)
151
+ if diff:
152
+ meta["diff"] = diff
153
+ base.emit("git_commit", meta)
154
+
155
+
156
+ def start(watch_dir: str, stop: threading.Event) -> None:
157
+ """Run file watcher + git poller until stop is set."""
158
+ handler = _Handler(watch_dir)
159
+ observer = Observer()
160
+ observer.schedule(handler, watch_dir, recursive=True)
161
+ observer.start()
162
+
163
+ git_thread = threading.Thread(target=_poll_git, args=(watch_dir, stop), daemon=True)
164
+ git_thread.start()
165
+
166
+ base.log("info", "watcher.started", dir=watch_dir)
167
+ stop.wait()
168
+ observer.stop()
169
+ observer.join(timeout=3)
170
+ base.log("info", "watcher.stopped")
methodproof/bridge.py ADDED
@@ -0,0 +1,86 @@
1
+ """Local HTTP bridge — accepts browser extension events into SQLite."""
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ import uuid
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+ from typing import Any
9
+
10
+ from methodproof import store
11
+ from methodproof.agents import base
12
+
13
+ _session_id = ""
14
+ MAX_BODY = 10 * 1024 * 1024 # 10 MB
15
+
16
+
17
+ class _Handler(BaseHTTPRequestHandler):
18
+ def log_message(self, fmt: str, *args: Any) -> None:
19
+ pass
20
+
21
+ def do_GET(self) -> None:
22
+ if self.path == "/session":
23
+ self._json({"session_id": _session_id, "active": bool(_session_id)})
24
+ else:
25
+ self.send_error(404)
26
+
27
+ def do_POST(self) -> None:
28
+ if self.path == "/events":
29
+ length = int(self.headers.get("Content-Length", 0))
30
+ if length > MAX_BODY:
31
+ self.send_error(413, "Request too large")
32
+ return
33
+ try:
34
+ body = json.loads(self.rfile.read(length)) if length else {}
35
+ except (json.JSONDecodeError, ValueError):
36
+ self.send_error(400, "Invalid JSON")
37
+ return
38
+ events = body.get("events", [])
39
+ for e in events:
40
+ e.setdefault("id", uuid.uuid4().hex)
41
+ e.setdefault("timestamp", time.time())
42
+ e.setdefault("duration_ms", 0)
43
+ e.setdefault("metadata", e.get("metadata", {}))
44
+ accepted = 0
45
+ if events:
46
+ try:
47
+ store.insert_events(_session_id, events)
48
+ accepted = len(events)
49
+ except Exception:
50
+ self.send_error(500, "Storage error")
51
+ return
52
+ self._json({"accepted": accepted})
53
+ else:
54
+ self.send_error(404)
55
+
56
+ def do_OPTIONS(self) -> None:
57
+ self.send_response(204)
58
+ self._cors()
59
+ self.end_headers()
60
+
61
+ def _json(self, data: Any) -> None:
62
+ body = json.dumps(data).encode()
63
+ self.send_response(200)
64
+ self.send_header("Content-Type", "application/json")
65
+ self._cors()
66
+ self.end_headers()
67
+ self.wfile.write(body)
68
+
69
+ def _cors(self) -> None:
70
+ origin = self.headers.get("Origin", "")
71
+ allowed = origin.startswith("chrome-extension://") or origin.startswith("http://localhost")
72
+ self.send_header("Access-Control-Allow-Origin", origin if allowed else "http://localhost:9877")
73
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
74
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
75
+
76
+
77
+ def start(session_id: str, stop: threading.Event, port: int = 9877) -> None:
78
+ global _session_id
79
+ _session_id = session_id
80
+ server = HTTPServer(("127.0.0.1", port), _Handler)
81
+ server.timeout = 1
82
+ base.log("info", "bridge.started", port=port)
83
+ while not stop.is_set():
84
+ server.handle_request()
85
+ server.server_close()
86
+ base.log("info", "bridge.stopped")