pairling 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +660 -0
  56. package/payload/mac/install/install-runtime.sh +1241 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. package/payload-manifest.json +255 -0
@@ -0,0 +1,542 @@
1
+ """Phase-0 bridge for a future Pairling Safety Monitor.
2
+
3
+ This module deliberately does not use Endpoint Security. It only exposes an
4
+ absent/simulated monitor contract so the runtime and iOS UI can integrate with
5
+ redacted safety summaries before a separately signed System Extension exists.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import time
17
+ import uuid
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+
22
+ SAFETY_BRIDGE_CONTRACT_VERSION = "pairling-safety-v0"
23
+ SAFETY_CONTRACT_VERSION = "pairling-safety-v1"
24
+ SAFETY_STATUS_STALE_SECONDS = int(os.environ.get("PAIRLING_SAFETY_STATUS_STALE_SECONDS", str(24 * 60 * 60)))
25
+ SAFETY_EVENTS_MAX_READ_BYTES = max(
26
+ 64 * 1024,
27
+ int(os.environ.get("PAIRLING_SAFETY_EVENTS_MAX_READ_BYTES", str(1024 * 1024))),
28
+ )
29
+ DEFAULT_STATUS = {
30
+ "contract_version": SAFETY_BRIDGE_CONTRACT_VERSION,
31
+ "mode": "absent",
32
+ "installed": False,
33
+ "approved": False,
34
+ "running": False,
35
+ "full_disk_access": "unknown",
36
+ "visibility": "unavailable",
37
+ "summary": "Pairling Safety Monitor is not installed.",
38
+ }
39
+ DEFAULT_EVIDENCE_TEST = {
40
+ "status": "not_run",
41
+ "process_observed": False,
42
+ "file_observed": False,
43
+ "message": "Evidence test has not been run.",
44
+ }
45
+
46
+ _ALLOWED_SEVERITIES = {"info", "watch", "warning", "critical", "danger"}
47
+ _SENSITIVE_PATH_RE = re.compile(
48
+ r"(^|/)(\.ssh|\.gnupg|\.aws|\.config|Library/Keychains|Library/LaunchAgents)(/|$)"
49
+ )
50
+
51
+
52
+ def _now() -> float:
53
+ return time.time()
54
+
55
+
56
+ def _safe_string(value: Any, fallback: str = "") -> str:
57
+ if isinstance(value, str):
58
+ return value[:500]
59
+ if value is None:
60
+ return fallback
61
+ return str(value)[:500]
62
+
63
+
64
+ def _safe_int(value: Any) -> int | None:
65
+ try:
66
+ if value is None or value == "":
67
+ return None
68
+ return int(value)
69
+ except (TypeError, ValueError):
70
+ return None
71
+
72
+
73
+ def _severity(value: Any) -> str:
74
+ raw = _safe_string(value, "info").lower()
75
+ if raw == "danger":
76
+ return "critical"
77
+ if raw in _ALLOWED_SEVERITIES:
78
+ return raw
79
+ return "info"
80
+
81
+
82
+ def _redact_path(value: Any, home: Path) -> str | None:
83
+ raw = _safe_string(value).strip()
84
+ if not raw:
85
+ return None
86
+ if raw.startswith("~/"):
87
+ return raw
88
+ if raw == "~":
89
+ return raw
90
+ home_text = str(home)
91
+ if raw == home_text:
92
+ return "~"
93
+ if raw.startswith(home_text + "/"):
94
+ return "~/" + raw[len(home_text) + 1 :]
95
+ return re.sub(r"^/Users/[^/]+", "/Users/<user>", raw)
96
+
97
+
98
+ def _safe_string_list(value: Any, *, limit: int = 12) -> list[str]:
99
+ if not isinstance(value, list):
100
+ return []
101
+ items: list[str] = []
102
+ for item in value[:limit]:
103
+ text = _safe_string(item).strip()
104
+ if text:
105
+ items.append(text)
106
+ return items
107
+
108
+
109
+ class SafetyMonitorBridge:
110
+ def __init__(self, root: Path, home: Path | None = None) -> None:
111
+ self.root = Path(root)
112
+ self.home = Path(home or Path.home())
113
+ self.dir = self.root / "safety"
114
+ self.status_path = Path(os.environ.get("PAIRLING_SAFETY_STATUS_PATH", self.dir / "status.json"))
115
+ self.events_path_env = os.environ.get("PAIRLING_SAFETY_EVENTS_PATH")
116
+ self.events_path = Path(self.events_path_env or self.dir / "events.jsonl")
117
+ self.system_events_path = Path(
118
+ os.environ.get(
119
+ "PAIRLING_SAFETY_SYSTEM_EVENTS_PATH",
120
+ "/Library/Application Support/Pairling/safety/events.jsonl",
121
+ )
122
+ )
123
+ self.acks_path = Path(os.environ.get("PAIRLING_SAFETY_ACKS_PATH", self.dir / "acks.jsonl"))
124
+ self.evidence_test_path = Path(
125
+ os.environ.get("PAIRLING_SAFETY_EVIDENCE_TEST_PATH", self.dir / "evidence-test.json")
126
+ )
127
+
128
+ def status(self) -> dict[str, Any]:
129
+ status = dict(DEFAULT_STATUS)
130
+ loaded, status_error = self._read_status_file_with_error()
131
+ bridge_contract_version = SAFETY_BRIDGE_CONTRACT_VERSION
132
+ if loaded:
133
+ bridge_contract_version = _safe_string(
134
+ loaded.get("contract_version"),
135
+ SAFETY_BRIDGE_CONTRACT_VERSION,
136
+ ) or SAFETY_BRIDGE_CONTRACT_VERSION
137
+ for key in (
138
+ "mode",
139
+ "installed",
140
+ "approved",
141
+ "running",
142
+ "full_disk_access",
143
+ "full_disk_access_detail",
144
+ "full_disk_access_probe",
145
+ "visibility",
146
+ "summary",
147
+ "updated_at",
148
+ ):
149
+ if key in loaded:
150
+ status[key] = loaded[key]
151
+ status["bridge_contract_version"] = bridge_contract_version
152
+ status["contract_version"] = SAFETY_CONTRACT_VERSION
153
+ if status_error:
154
+ status["status_error"] = status_error
155
+ status["status_stale"] = self._status_stale(status, loaded is not None)
156
+ status["secure_mode_state"] = self._secure_mode_state(status)
157
+ status["guarded_mode_state"] = "guarded_deferred"
158
+ status["system_extension_status"] = self._system_extension_status(status)
159
+ status["capabilities"] = self._capabilities(status)
160
+ status["evidence_test"] = self._read_evidence_test()
161
+ events = self.events(limit=200)
162
+ status["event_count"] = len(events)
163
+ status["high_risk_count"] = sum(
164
+ 1 for event in events if event.get("severity") in {"warning", "critical"}
165
+ )
166
+ status["updated_at"] = status.get("updated_at") or _now()
167
+ return status
168
+
169
+ def events(self, since: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
170
+ rows: list[dict[str, Any]] = []
171
+ for path in self._event_paths():
172
+ if not path.exists():
173
+ continue
174
+ lines = self._recent_event_lines(path)
175
+ for line in lines:
176
+ line = line.strip()
177
+ if not line:
178
+ continue
179
+ try:
180
+ raw = json.loads(line)
181
+ except json.JSONDecodeError:
182
+ continue
183
+ if not isinstance(raw, dict):
184
+ continue
185
+ event = self._normalize_event(raw)
186
+ rows.append(event)
187
+ if not rows:
188
+ return []
189
+ rows.sort(key=lambda item: (item.get("timestamp") or 0, item.get("id") or ""))
190
+ if since:
191
+ since_index = next((idx for idx, event in enumerate(rows) if event.get("id") == since), None)
192
+ rows = rows[since_index + 1 :] if since_index is not None else []
193
+ rows.sort(key=lambda item: (item.get("timestamp") or 0, item.get("id") or ""), reverse=True)
194
+ return rows[: max(1, min(int(limit or 100), 300))]
195
+
196
+ def _recent_event_lines(self, path: Path) -> list[str]:
197
+ try:
198
+ size = path.stat().st_size
199
+ with path.open("rb") as fh:
200
+ if size > SAFETY_EVENTS_MAX_READ_BYTES:
201
+ fh.seek(-SAFETY_EVENTS_MAX_READ_BYTES, os.SEEK_END)
202
+ fh.readline()
203
+ data = fh.read(SAFETY_EVENTS_MAX_READ_BYTES)
204
+ except OSError:
205
+ return []
206
+ return data.decode("utf-8", errors="replace").splitlines()
207
+
208
+ def ack(self, ids: list[str] | None = None) -> dict[str, Any]:
209
+ self.dir.mkdir(parents=True, exist_ok=True)
210
+ normalized = [item for item in (ids or []) if isinstance(item, str) and item]
211
+ record = {
212
+ "ts": _now(),
213
+ "ids": normalized,
214
+ "scope": "selected" if normalized else "all_visible",
215
+ }
216
+ with self.acks_path.open("a", encoding="utf-8") as fh:
217
+ fh.write(json.dumps(record, sort_keys=True) + "\n")
218
+ return {"ok": True, "acknowledged": len(normalized), "scope": record["scope"]}
219
+
220
+ def request_activation(self) -> dict[str, Any]:
221
+ app_path = self._find_safety_app()
222
+ if app_path is None:
223
+ return {
224
+ "ok": False,
225
+ "state": "safety_app_missing",
226
+ "error": {
227
+ "code": "safety_app_missing",
228
+ "message": "PairlingSafety.app is not installed on this Mac.",
229
+ },
230
+ }
231
+ opener = shutil.which("open") or "/usr/bin/open"
232
+ try:
233
+ proc = subprocess.run(
234
+ [opener, "-n", str(app_path), "--args", "--pairling-request-activation"],
235
+ capture_output=True,
236
+ text=True,
237
+ timeout=10,
238
+ )
239
+ except (OSError, subprocess.SubprocessError) as exc:
240
+ return {
241
+ "ok": False,
242
+ "state": "activation_launch_failed",
243
+ "error": {"code": "activation_launch_failed", "message": str(exc)[:300]},
244
+ }
245
+ if proc.returncode != 0:
246
+ return {
247
+ "ok": False,
248
+ "state": "activation_launch_failed",
249
+ "error": {
250
+ "code": "activation_launch_failed",
251
+ "message": (proc.stderr or proc.stdout or "open failed")[:300],
252
+ },
253
+ }
254
+ return {
255
+ "ok": True,
256
+ "state": "approval_requested",
257
+ "app_path": _redact_path(str(app_path), self.home),
258
+ "message": "Open System Settings on the Mac to approve Pairling Safety Monitor.",
259
+ }
260
+
261
+ def open_full_disk_access(self) -> dict[str, Any]:
262
+ opener = shutil.which("open") or "/usr/bin/open"
263
+ uri = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
264
+ try:
265
+ proc = subprocess.run([opener, uri], capture_output=True, text=True, timeout=10)
266
+ except (OSError, subprocess.SubprocessError) as exc:
267
+ return {
268
+ "ok": False,
269
+ "state": "settings_open_failed",
270
+ "error": {"code": "settings_open_failed", "message": str(exc)[:300]},
271
+ }
272
+ if proc.returncode != 0:
273
+ return {
274
+ "ok": False,
275
+ "state": "settings_open_failed",
276
+ "error": {
277
+ "code": "settings_open_failed",
278
+ "message": (proc.stderr or proc.stdout or "open failed")[:300],
279
+ },
280
+ }
281
+ return {
282
+ "ok": True,
283
+ "state": "settings_opened",
284
+ "message": "Full Disk Access settings opened on the Mac.",
285
+ }
286
+
287
+ def run_evidence_test(self, wait_seconds: float = 8.0) -> dict[str, Any]:
288
+ wait_seconds = max(0.0, min(float(wait_seconds), 8.0))
289
+ test_id = f"evidence_test_{uuid.uuid4().hex[:12]}"
290
+ before = {event.get("id") for event in self.events(limit=300)}
291
+ started_at = _now()
292
+ probe_dir = self.dir / "evidence-tests" / test_id
293
+ process_ok = False
294
+ file_ok = False
295
+ error_message = ""
296
+ try:
297
+ probe_dir.mkdir(parents=True, exist_ok=True)
298
+ subprocess.run(["/bin/echo", test_id], capture_output=True, text=True, timeout=5)
299
+ probe_file = probe_dir / "probe.txt"
300
+ probe_file.write_text(f"{test_id}\n", encoding="utf-8")
301
+ probe_file.unlink(missing_ok=True)
302
+ process_ok, file_ok = self._observe_evidence_events(before, started_at, wait_seconds)
303
+ except (OSError, subprocess.SubprocessError) as exc:
304
+ error_message = str(exc)[:300]
305
+ finally:
306
+ try:
307
+ if probe_dir.exists():
308
+ for child in probe_dir.iterdir():
309
+ child.unlink(missing_ok=True)
310
+ probe_dir.rmdir()
311
+ except OSError:
312
+ pass
313
+ secure_state = self.status().get("secure_mode_state", "secure_unavailable")
314
+ if process_ok and file_ok:
315
+ result_status = "passed"
316
+ message = "Process and file evidence passed."
317
+ elif process_ok:
318
+ result_status = "limited"
319
+ message = "Process evidence passed. File visibility is limited until Full Disk Access is granted."
320
+ elif error_message:
321
+ result_status = "failed"
322
+ message = f"Evidence test failed: {error_message}"
323
+ elif secure_state == "secure_unavailable":
324
+ result_status = "failed"
325
+ message = "Safety Monitor is unavailable, so no OS evidence was observed."
326
+ else:
327
+ result_status = "timed_out"
328
+ message = "Evidence test timed out before matching OS evidence was observed."
329
+ record = {
330
+ "test_id": test_id,
331
+ "last_run_at": started_at,
332
+ "status": result_status,
333
+ "secure_mode_state": secure_state,
334
+ "process_observed": process_ok,
335
+ "file_observed": file_ok,
336
+ "message": message,
337
+ }
338
+ self._write_evidence_test(record)
339
+ return {"ok": result_status in {"passed", "limited"}, **record}
340
+
341
+ def _read_status_file(self) -> dict[str, Any] | None:
342
+ payload, _ = self._read_status_file_with_error()
343
+ return payload
344
+
345
+ def _event_paths(self) -> list[Path]:
346
+ paths = [self.events_path]
347
+ if self.events_path_env:
348
+ return paths
349
+ default_user_root = self.home / "Library" / "Application Support" / "Pairling"
350
+ if self.root == default_user_root and self.system_events_path != self.events_path:
351
+ paths.append(self.system_events_path)
352
+ return paths
353
+
354
+ def _read_status_file_with_error(self) -> tuple[dict[str, Any] | None, str | None]:
355
+ try:
356
+ payload = json.loads(self.status_path.read_text(encoding="utf-8"))
357
+ except FileNotFoundError:
358
+ return None, None
359
+ except json.JSONDecodeError:
360
+ return None, "status_json_invalid"
361
+ except OSError:
362
+ return None, "status_unreadable"
363
+ return (payload, None) if isinstance(payload, dict) else (None, "status_shape_invalid")
364
+
365
+ def _normalize_event(self, raw: dict[str, Any]) -> dict[str, Any]:
366
+ path_display = _redact_path(
367
+ raw.get("path") or raw.get("path_display") or raw.get("redacted_path"),
368
+ self.home,
369
+ )
370
+ project_root = _redact_path(raw.get("project_root") or raw.get("project"), self.home)
371
+ severity = _severity(raw.get("severity") or raw.get("risk"))
372
+ title = _safe_string(raw.get("title"), "Safety event").strip() or "Safety event"
373
+ process_display = _safe_string(raw.get("process_display")).strip()
374
+ subtitle = _safe_string(raw.get("summary") or raw.get("subtitle")).strip()
375
+ if path_display and not subtitle:
376
+ subtitle = path_display
377
+ if process_display and not subtitle:
378
+ subtitle = process_display
379
+ event = {
380
+ "contract_version": SAFETY_CONTRACT_VERSION,
381
+ "id": _safe_string(raw.get("id")) or self._event_id(raw),
382
+ "type": _safe_string(raw.get("type") or raw.get("event"), "safety") or "safety",
383
+ "severity": severity,
384
+ "timestamp": int(raw.get("timestamp") or raw.get("ts") or _now()),
385
+ "session_id": _safe_string(raw.get("session_id"), "safety-monitor") or "safety-monitor",
386
+ "project": _redact_path(raw.get("project"), self.home) or "Pairling Safety Monitor",
387
+ "project_root": project_root,
388
+ "orchestration_id": _safe_string(raw.get("orchestration_id")) or None,
389
+ "worker_id": _safe_string(raw.get("worker_id")) or None,
390
+ "provider_id": _safe_string(raw.get("provider_id")) or None,
391
+ "pid": _safe_int(raw.get("pid")),
392
+ "ppid": _safe_int(raw.get("ppid")),
393
+ "title": title,
394
+ "subtitle": subtitle or None,
395
+ "state": _safe_string(raw.get("state")) or None,
396
+ "tool": _safe_string(raw.get("tool")) or None,
397
+ "context_pct": None,
398
+ "entry_id": _safe_string(raw.get("entry_id")) or None,
399
+ "path_display": path_display,
400
+ "path_scope": _safe_string(raw.get("path_scope")) or self._path_scope(path_display, project_root),
401
+ "process_display": process_display or None,
402
+ "parent_chain": _safe_string_list(raw.get("parent_chain")),
403
+ "code_signing_identity": _safe_string(raw.get("code_signing_identity")) or None,
404
+ "source": _safe_string(raw.get("source"), "endpoint_security_notify") or "endpoint_security_notify",
405
+ "raw_path_redacted": bool(raw.get("raw_path_redacted")) or bool(raw.get("path") or raw.get("project")),
406
+ "notify_only": bool(raw.get("notify_only")),
407
+ "file_contents_collected": False,
408
+ "prompt_or_transcript_collected": False,
409
+ }
410
+ correlation = raw.get("correlation")
411
+ if isinstance(correlation, dict):
412
+ event["correlation"] = {
413
+ "strategy": _safe_string(correlation.get("strategy")) or "unknown",
414
+ "confidence": _safe_string(correlation.get("confidence")) or "unknown",
415
+ }
416
+ if path_display and _SENSITIVE_PATH_RE.search(path_display):
417
+ event["severity"] = "critical" if severity == "warning" else severity
418
+ if event["severity"] == "info":
419
+ event["severity"] = "warning"
420
+ return event
421
+
422
+ def _event_id(self, raw: dict[str, Any]) -> str:
423
+ stable = json.dumps(raw, sort_keys=True, default=str)
424
+ digest = hashlib.sha256(stable.encode("utf-8")).hexdigest()[:16]
425
+ return f"safety-{digest}"
426
+
427
+ def _status_stale(self, status: dict[str, Any], loaded: bool) -> bool:
428
+ if not loaded:
429
+ return False
430
+ updated_at = _safe_int(status.get("updated_at"))
431
+ if updated_at is None:
432
+ return True
433
+ return (_now() - updated_at) > SAFETY_STATUS_STALE_SECONDS
434
+
435
+ def _secure_mode_state(self, status: dict[str, Any]) -> str:
436
+ if status.get("status_stale"):
437
+ return "secure_unavailable"
438
+ if not bool(status.get("installed")) or not bool(status.get("approved")) or not bool(status.get("running")):
439
+ return "secure_unavailable"
440
+ if _safe_string(status.get("full_disk_access")).lower() == "validated" or status.get("visibility") == "full":
441
+ return "secure_full"
442
+ return "secure_limited"
443
+
444
+ def _system_extension_status(self, status: dict[str, Any]) -> str:
445
+ if status.get("status_stale"):
446
+ return "status_stale"
447
+ if bool(status.get("running")):
448
+ return "active"
449
+ if bool(status.get("installed")) and not bool(status.get("approved")):
450
+ return "approval_required"
451
+ if bool(status.get("installed")):
452
+ return "failed"
453
+ return "not_installed"
454
+
455
+ def _capabilities(self, status: dict[str, Any]) -> dict[str, Any]:
456
+ secure_state = status.get("secure_mode_state") or self._secure_mode_state(status)
457
+ running = secure_state in {"secure_limited", "secure_full"}
458
+ full = secure_state == "secure_full"
459
+ return {
460
+ "notify_only": True,
461
+ "auth_blocking": False,
462
+ "process_lifecycle": "available" if running else "unavailable",
463
+ "file_touches": "available" if full else ("limited" if running else "unavailable"),
464
+ "process_tree": "available" if running else "unavailable",
465
+ "sensitive_path_warnings": "available" if running else "unavailable",
466
+ }
467
+
468
+ def _read_evidence_test(self) -> dict[str, Any]:
469
+ try:
470
+ payload = json.loads(self.evidence_test_path.read_text(encoding="utf-8"))
471
+ except (OSError, json.JSONDecodeError):
472
+ return dict(DEFAULT_EVIDENCE_TEST)
473
+ if not isinstance(payload, dict):
474
+ return dict(DEFAULT_EVIDENCE_TEST)
475
+ return {
476
+ "test_id": _safe_string(payload.get("test_id")) or None,
477
+ "last_run_at": payload.get("last_run_at"),
478
+ "status": _safe_string(payload.get("status"), "not_run") or "not_run",
479
+ "secure_mode_state": _safe_string(payload.get("secure_mode_state")) or None,
480
+ "process_observed": bool(payload.get("process_observed")),
481
+ "file_observed": bool(payload.get("file_observed")),
482
+ "message": _safe_string(payload.get("message"), DEFAULT_EVIDENCE_TEST["message"]),
483
+ }
484
+
485
+ def _write_evidence_test(self, record: dict[str, Any]) -> None:
486
+ self.dir.mkdir(parents=True, exist_ok=True)
487
+ tmp = self.evidence_test_path.with_suffix(".json.tmp")
488
+ tmp.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8")
489
+ os.replace(tmp, self.evidence_test_path)
490
+
491
+ def _observe_evidence_events(
492
+ self,
493
+ before_ids: set[Any],
494
+ started_at: float,
495
+ wait_seconds: float,
496
+ ) -> tuple[bool, bool]:
497
+ deadline = _now() + wait_seconds
498
+ process_ok = False
499
+ file_ok = False
500
+ while True:
501
+ for event in self.events(limit=300):
502
+ if event.get("id") in before_ids:
503
+ continue
504
+ if float(event.get("timestamp") or 0) < started_at - 1:
505
+ continue
506
+ event_type = _safe_string(event.get("type"))
507
+ if event_type.startswith("process_"):
508
+ process_ok = True
509
+ if event_type.startswith("file_"):
510
+ file_ok = True
511
+ if process_ok and file_ok:
512
+ return True, True
513
+ if _now() >= deadline:
514
+ return process_ok, file_ok
515
+ time.sleep(0.25)
516
+
517
+ def _find_safety_app(self) -> Path | None:
518
+ candidates = []
519
+ configured = os.environ.get("PAIRLING_SAFETY_APP_PATH", "").strip()
520
+ if configured:
521
+ candidates.append(Path(configured))
522
+ repo_root = os.environ.get("PAIRLING_REPO_ROOT", "").strip()
523
+ if repo_root:
524
+ candidates.append(Path(repo_root) / "dist" / "safety-monitor" / "PairlingSafety.app")
525
+ candidates.extend([
526
+ Path("/Applications/PairlingSafety.app"),
527
+ self.home / "Applications" / "PairlingSafety.app",
528
+ self.home / "projects" / "Pairling" / "dist" / "safety-monitor" / "PairlingSafety.app",
529
+ ])
530
+ for candidate in candidates:
531
+ if candidate.exists() and candidate.suffix == ".app":
532
+ return candidate
533
+ return None
534
+
535
+ def _path_scope(self, path_display: str | None, project_root: str | None) -> str | None:
536
+ if not path_display:
537
+ return None
538
+ if project_root and (path_display == project_root or path_display.startswith(project_root.rstrip("/") + "/")):
539
+ return "inside_project"
540
+ if path_display.startswith("~/") or path_display.startswith("/"):
541
+ return "outside_project"
542
+ return None