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.
- package/package.json +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- 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
|