deadpush 0.2.0__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.
- deadpush/__init__.py +1 -0
- deadpush/churn.py +189 -0
- deadpush/cli.py +1584 -0
- deadpush/comments.py +265 -0
- deadpush/complexity.py +254 -0
- deadpush/config.py +284 -0
- deadpush/crawler.py +133 -0
- deadpush/deadness.py +477 -0
- deadpush/debris.py +729 -0
- deadpush/deps.py +323 -0
- deadpush/deps_guard.py +382 -0
- deadpush/entrypoints.py +193 -0
- deadpush/graph.py +401 -0
- deadpush/guard.py +1386 -0
- deadpush/hooks.py +369 -0
- deadpush/importgraph.py +122 -0
- deadpush/imports.py +239 -0
- deadpush/intercept.py +995 -0
- deadpush/languages/__init__.py +143 -0
- deadpush/languages/base.py +70 -0
- deadpush/languages/cpp.py +150 -0
- deadpush/languages/go_.py +177 -0
- deadpush/languages/java.py +185 -0
- deadpush/languages/javascript.py +202 -0
- deadpush/languages/python_.py +278 -0
- deadpush/languages/rust.py +147 -0
- deadpush/languages/typescript.py +192 -0
- deadpush/layers.py +197 -0
- deadpush/mcp_server.py +1061 -0
- deadpush/reachability.py +183 -0
- deadpush/registration.py +280 -0
- deadpush/report.py +113 -0
- deadpush/rules.py +190 -0
- deadpush/sarif.py +123 -0
- deadpush/scorer.py +151 -0
- deadpush/security.py +187 -0
- deadpush/session.py +224 -0
- deadpush/tests.py +333 -0
- deadpush/ui.py +156 -0
- deadpush/verifier.py +168 -0
- deadpush/watch.py +103 -0
- deadpush-0.2.0.dist-info/METADATA +230 -0
- deadpush-0.2.0.dist-info/RECORD +46 -0
- deadpush-0.2.0.dist-info/WHEEL +4 -0
- deadpush-0.2.0.dist-info/entry_points.txt +2 -0
- deadpush-0.2.0.dist-info/licenses/LICENSE +21 -0
deadpush/guard.py
ADDED
|
@@ -0,0 +1,1386 @@
|
|
|
1
|
+
"""
|
|
2
|
+
deadpush Guard Mode - The AI Agent Guardian (Production Grade v2)
|
|
3
|
+
|
|
4
|
+
Major improvements:
|
|
5
|
+
- More robust daemon management with lock files and health checks
|
|
6
|
+
- Stronger intervention logic (quarantine instead of hard delete, modification blocking)
|
|
7
|
+
- Better error recovery
|
|
8
|
+
- Strict mode support
|
|
9
|
+
- More detailed intervention logging
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import atexit
|
|
15
|
+
import fcntl
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import signal
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from watchdog.observers import Observer
|
|
27
|
+
from watchdog.events import FileSystemEventHandler
|
|
28
|
+
WATCHDOG_AVAILABLE = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
Observer = None
|
|
31
|
+
FileSystemEventHandler = None
|
|
32
|
+
WATCHDOG_AVAILABLE = False
|
|
33
|
+
|
|
34
|
+
from .config import load_config
|
|
35
|
+
from .debris import DebrisDetector
|
|
36
|
+
from .intercept import FEEDBACK_DIR
|
|
37
|
+
from .session import SessionManager
|
|
38
|
+
|
|
39
|
+
# For Local Control Interface (AGENT priority 4 - for automatic interaction by Claude/Cursor/etc agents)
|
|
40
|
+
import json
|
|
41
|
+
import subprocess
|
|
42
|
+
import threading
|
|
43
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
44
|
+
from socketserver import ThreadingMixIn
|
|
45
|
+
from urllib.parse import urlparse, parse_qs
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# =============================================================================
|
|
49
|
+
# Logging
|
|
50
|
+
# =============================================================================
|
|
51
|
+
def setup_logging(log_file: Optional[Path] = None, level=logging.INFO, daemon: bool = False):
|
|
52
|
+
"""Setup logging.
|
|
53
|
+
|
|
54
|
+
In daemon mode: ONLY file logging (headless/silent on stdout/stderr).
|
|
55
|
+
Foreground: file + console.
|
|
56
|
+
"""
|
|
57
|
+
if log_file is None:
|
|
58
|
+
log_file = Path.home() / ".deadpush" / "guardian.log"
|
|
59
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
handlers = [logging.FileHandler(log_file)]
|
|
62
|
+
if not daemon:
|
|
63
|
+
handlers.append(logging.StreamHandler(sys.stdout))
|
|
64
|
+
|
|
65
|
+
logging.basicConfig(
|
|
66
|
+
level=level,
|
|
67
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
68
|
+
handlers=handlers
|
|
69
|
+
)
|
|
70
|
+
return logging.getLogger("deadpush.guardian")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# =============================================================================
|
|
74
|
+
# Improved Daemon Management with Lock File
|
|
75
|
+
# =============================================================================
|
|
76
|
+
class DaemonManager:
|
|
77
|
+
"""Robust daemon management with file locking."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, pidfile: Path, lockfile: Optional[Path] = None):
|
|
80
|
+
self.pidfile = pidfile
|
|
81
|
+
self.lockfile = lockfile or pidfile.with_suffix(".lock")
|
|
82
|
+
self.lock_fd = None
|
|
83
|
+
self.logger = logging.getLogger("deadpush.guardian")
|
|
84
|
+
|
|
85
|
+
def acquire_lock(self) -> bool:
|
|
86
|
+
"""Try to acquire exclusive lock."""
|
|
87
|
+
try:
|
|
88
|
+
self.lock_fd = open(self.lockfile, "w")
|
|
89
|
+
fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
90
|
+
return True
|
|
91
|
+
except (IOError, OSError):
|
|
92
|
+
if self.lock_fd:
|
|
93
|
+
self.lock_fd.close()
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def write_pid(self):
|
|
97
|
+
pid = os.getpid()
|
|
98
|
+
with self.pidfile.open("w") as f:
|
|
99
|
+
f.write(str(pid))
|
|
100
|
+
self.logger.info(f"Daemon started with PID {pid}")
|
|
101
|
+
|
|
102
|
+
def cleanup(self):
|
|
103
|
+
if self.lock_fd:
|
|
104
|
+
try:
|
|
105
|
+
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
|
|
106
|
+
self.lock_fd.close()
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
if self.pidfile.exists():
|
|
110
|
+
try:
|
|
111
|
+
self.pidfile.unlink()
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
if self.lockfile.exists():
|
|
115
|
+
try:
|
|
116
|
+
self.lockfile.unlink()
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
def is_running(self) -> bool:
|
|
121
|
+
if not self.pidfile.exists():
|
|
122
|
+
return False
|
|
123
|
+
try:
|
|
124
|
+
with self.pidfile.open() as f:
|
|
125
|
+
pid = int(f.read().strip())
|
|
126
|
+
os.kill(pid, 0)
|
|
127
|
+
return True
|
|
128
|
+
except (OSError, ValueError):
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# =============================================================================
|
|
133
|
+
# Quarantine System (Safer than hard delete)
|
|
134
|
+
# =============================================================================
|
|
135
|
+
class QuarantineManager:
|
|
136
|
+
"""Moves dangerous files to a quarantine folder instead of deleting them."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, base_dir: Path):
|
|
139
|
+
self.quarantine_dir = base_dir / ".deadpush-quarantine"
|
|
140
|
+
self.quarantine_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
def quarantine(self, path: Path, reason: str) -> Path:
|
|
143
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
144
|
+
dest = self.quarantine_dir / f"{timestamp}_{path.name}"
|
|
145
|
+
try:
|
|
146
|
+
path.rename(dest)
|
|
147
|
+
with dest.with_suffix(dest.suffix + ".reason").open("w") as f:
|
|
148
|
+
f.write(f"Quarantined at {datetime.now()}\nReason: {reason}\nOriginal path: {path}\n")
|
|
149
|
+
return dest
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logging.getLogger("deadpush.guardian").error(f"Failed to quarantine {path}: {e}")
|
|
152
|
+
return path
|
|
153
|
+
|
|
154
|
+
def list_quarantined(self):
|
|
155
|
+
"""Return list of dicts with info about quarantined files (newest first)."""
|
|
156
|
+
entries = []
|
|
157
|
+
if not self.quarantine_dir.exists():
|
|
158
|
+
return entries
|
|
159
|
+
for f in sorted(self.quarantine_dir.iterdir(), reverse=True):
|
|
160
|
+
if f.name.endswith(".reason"):
|
|
161
|
+
continue
|
|
162
|
+
reason_path = self.quarantine_dir / (f.name + ".reason")
|
|
163
|
+
info = {
|
|
164
|
+
"quarantined_file": f,
|
|
165
|
+
"name": f.name,
|
|
166
|
+
"size": f.stat().st_size if f.exists() else 0,
|
|
167
|
+
"mtime": datetime.fromtimestamp(f.stat().st_mtime) if f.exists() else None,
|
|
168
|
+
}
|
|
169
|
+
if reason_path.exists():
|
|
170
|
+
try:
|
|
171
|
+
text = reason_path.read_text(errors="ignore")
|
|
172
|
+
for line in text.splitlines():
|
|
173
|
+
if line.startswith("Quarantined at "):
|
|
174
|
+
info["quarantined_at"] = line.split("Quarantined at ", 1)[1]
|
|
175
|
+
elif line.startswith("Reason: "):
|
|
176
|
+
info["reason"] = line.split("Reason: ", 1)[1]
|
|
177
|
+
elif line.startswith("Original path: "):
|
|
178
|
+
info["original_path"] = line.split("Original path: ", 1)[1]
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
entries.append(info)
|
|
182
|
+
return entries
|
|
183
|
+
|
|
184
|
+
def restore(self, quarantined_name_or_path: str) -> Path | None:
|
|
185
|
+
"""Restore a quarantined file back to its original location if possible.
|
|
186
|
+
Returns the restored path or None on failure.
|
|
187
|
+
"""
|
|
188
|
+
qpath = Path(quarantined_name_or_path)
|
|
189
|
+
if not qpath.is_absolute():
|
|
190
|
+
qpath = self.quarantine_dir / qpath.name
|
|
191
|
+
if not qpath.exists() or qpath.name.endswith(".reason"):
|
|
192
|
+
# try finding by name
|
|
193
|
+
candidates = list(self.quarantine_dir.glob(f"*{Path(quarantined_name_or_path).name}*"))
|
|
194
|
+
qpath = next((c for c in candidates if not c.name.endswith(".reason")), None)
|
|
195
|
+
if not qpath:
|
|
196
|
+
return None
|
|
197
|
+
reason_path = self.quarantine_dir / (qpath.name + ".reason")
|
|
198
|
+
original = None
|
|
199
|
+
if reason_path.exists():
|
|
200
|
+
for line in reason_path.read_text(errors="ignore").splitlines():
|
|
201
|
+
if line.startswith("Original path: "):
|
|
202
|
+
original = Path(line.split("Original path: ", 1)[1].strip())
|
|
203
|
+
break
|
|
204
|
+
if not original:
|
|
205
|
+
# fallback: strip timestamp_ prefix
|
|
206
|
+
name = qpath.name
|
|
207
|
+
if "_" in name and name.split("_", 1)[0].isdigit():
|
|
208
|
+
original = self.quarantine_dir.parent / name.split("_", 1)[1]
|
|
209
|
+
else:
|
|
210
|
+
original = self.quarantine_dir.parent / name
|
|
211
|
+
if original.exists():
|
|
212
|
+
logging.getLogger("deadpush.guardian").warning(f"Refusing to restore: original already exists at {original}")
|
|
213
|
+
return None
|
|
214
|
+
try:
|
|
215
|
+
original.parent.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
qpath.rename(original)
|
|
217
|
+
if reason_path.exists():
|
|
218
|
+
reason_path.unlink()
|
|
219
|
+
logging.getLogger("deadpush.guardian").info(f"Restored {qpath.name} -> {original}")
|
|
220
|
+
return original
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logging.getLogger("deadpush.guardian").error(f"Restore failed for {qpath}: {e}")
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
def clear(self, older_than_days: int | None = None) -> int:
|
|
226
|
+
"""Delete quarantined files (and their .reason). Returns count deleted.
|
|
227
|
+
If older_than_days, only those older than N days.
|
|
228
|
+
"""
|
|
229
|
+
count = 0
|
|
230
|
+
if not self.quarantine_dir.exists():
|
|
231
|
+
return 0
|
|
232
|
+
now = datetime.now()
|
|
233
|
+
for f in list(self.quarantine_dir.iterdir()):
|
|
234
|
+
if f.name.endswith(".reason"):
|
|
235
|
+
# will be handled with main file or orphaned cleanup
|
|
236
|
+
try:
|
|
237
|
+
if older_than_days is None:
|
|
238
|
+
f.unlink()
|
|
239
|
+
else:
|
|
240
|
+
mtime = datetime.fromtimestamp(f.stat().st_mtime)
|
|
241
|
+
if (now - mtime).days >= older_than_days:
|
|
242
|
+
f.unlink()
|
|
243
|
+
count += 1
|
|
244
|
+
continue
|
|
245
|
+
except Exception:
|
|
246
|
+
continue
|
|
247
|
+
# main file
|
|
248
|
+
try:
|
|
249
|
+
if older_than_days is not None:
|
|
250
|
+
mtime = datetime.fromtimestamp(f.stat().st_mtime)
|
|
251
|
+
if (now - mtime).days < older_than_days:
|
|
252
|
+
continue
|
|
253
|
+
f.unlink()
|
|
254
|
+
count += 1
|
|
255
|
+
rp = self.quarantine_dir / (f.name + ".reason")
|
|
256
|
+
if rp.exists():
|
|
257
|
+
rp.unlink()
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logging.getLogger("deadpush.guardian").error(f"Failed clearing {f}: {e}")
|
|
260
|
+
return count
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# =============================================================================
|
|
264
|
+
# Session Safety Score (Improved)
|
|
265
|
+
# =============================================================================
|
|
266
|
+
class SessionSafetyScore:
|
|
267
|
+
"""Improved Safety Score + simple multi-agent / burst / session tracking.
|
|
268
|
+
|
|
269
|
+
Designed for users running many AI agents in parallel who step away.
|
|
270
|
+
- Penalizes bursts of activity ( >3 incidents in 60s window gets extra hit)
|
|
271
|
+
- Tracks total session events and recent unique files for "intelligence"
|
|
272
|
+
- get_activity_level() and get_session_summary() used in logs + status cmd
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
def __init__(self):
|
|
276
|
+
self.score = 100
|
|
277
|
+
self.incidents = []
|
|
278
|
+
self.recent_window = 60 # seconds
|
|
279
|
+
# Multi-agent / session tracking
|
|
280
|
+
self.events_count = 0
|
|
281
|
+
self.session_start = datetime.now()
|
|
282
|
+
self.recent_paths: list[str] = [] # last ~10 distinct-ish paths touched
|
|
283
|
+
|
|
284
|
+
def report_incident(self, severity: int, reason: str, filepath: str = ""):
|
|
285
|
+
now = datetime.now()
|
|
286
|
+
self.score = max(0, self.score - severity)
|
|
287
|
+
self.events_count += 1
|
|
288
|
+
if filepath:
|
|
289
|
+
# keep recent unique-ish
|
|
290
|
+
if filepath not in self.recent_paths:
|
|
291
|
+
self.recent_paths = (self.recent_paths + [filepath])[-10:]
|
|
292
|
+
|
|
293
|
+
self.incidents.append({
|
|
294
|
+
"time": now,
|
|
295
|
+
"severity": severity,
|
|
296
|
+
"reason": reason,
|
|
297
|
+
"file": filepath
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
# Decay old incidents for recent activity calculation
|
|
301
|
+
self.incidents = [inc for inc in self.incidents if (now - inc["time"]).total_seconds() < self.recent_window]
|
|
302
|
+
|
|
303
|
+
# Bonus penalty for high recent activity (multi-agent bursts from parallel Claude/Cursor etc)
|
|
304
|
+
recent_count = len(self.incidents)
|
|
305
|
+
if recent_count > 3:
|
|
306
|
+
extra_penalty = min(5 * (recent_count - 3), 20)
|
|
307
|
+
self.score = max(0, self.score - extra_penalty)
|
|
308
|
+
if recent_count >= 6:
|
|
309
|
+
# Very bursty - many agents firing at once
|
|
310
|
+
self.score = max(0, self.score - 5)
|
|
311
|
+
|
|
312
|
+
return self.score
|
|
313
|
+
|
|
314
|
+
def get_status(self) -> str:
|
|
315
|
+
if self.score >= 90: return "🟢 Excellent"
|
|
316
|
+
if self.score >= 70: return "🟡 Good"
|
|
317
|
+
if self.score >= 50: return "🟠 Caution"
|
|
318
|
+
return "🔴 At Risk"
|
|
319
|
+
|
|
320
|
+
def get_activity_level(self) -> str:
|
|
321
|
+
"""Simple heuristic for 'how busy are the agents right now?'"""
|
|
322
|
+
recent = len([inc for inc in self.incidents if (datetime.now() - inc["time"]).total_seconds() < self.recent_window])
|
|
323
|
+
if recent >= 8:
|
|
324
|
+
return "🔥 High (multiple agents in parallel?)"
|
|
325
|
+
if recent >= 4:
|
|
326
|
+
return "⚡ Elevated burst"
|
|
327
|
+
return "Normal"
|
|
328
|
+
|
|
329
|
+
def get_session_summary(self) -> str:
|
|
330
|
+
dur_min = (datetime.now() - self.session_start).total_seconds() / 60.0
|
|
331
|
+
recent_files = len(set(self.recent_paths))
|
|
332
|
+
return f"Session: {dur_min:.1f}min | Total events: {self.events_count} | Recent files: {recent_files}"
|
|
333
|
+
|
|
334
|
+
def get_summary(self) -> str:
|
|
335
|
+
recent = len([inc for inc in self.incidents if (datetime.now() - inc["time"]).total_seconds() < self.recent_window])
|
|
336
|
+
return f"Score: {self.score}/100 | Status: {self.get_status()} | Recent incidents (last 60s): {recent} | Activity: {self.get_activity_level()}"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# =============================================================================
|
|
340
|
+
# Local Control Interface (AGENT.md Priority 4 - key new feature for automatic agent interaction)
|
|
341
|
+
# Lightweight HTTP server on localhost only. Allows AI coding agents (Claude, Cursor, etc.)
|
|
342
|
+
# to query the guardian autonomously for status, score, recent risks, quarantines,
|
|
343
|
+
# and even trigger light analysis -- all without the human user having to run commands.
|
|
344
|
+
# Started automatically in daemon mode. Uses only stdlib (http.server + threading) for minimal footprint.
|
|
345
|
+
# Port is fixed for easy discovery by agents (or written to ~/.deadpush/guardian.control.port).
|
|
346
|
+
# All endpoints are read-only or safe actions. No auth needed since localhost only.
|
|
347
|
+
# =============================================================================
|
|
348
|
+
|
|
349
|
+
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
|
350
|
+
"""Handle requests in separate threads so guardian main loop isn't blocked."""
|
|
351
|
+
daemon_threads = True
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
DASHBOARD_HTML = """\
|
|
355
|
+
<!DOCTYPE html>
|
|
356
|
+
<html lang="en">
|
|
357
|
+
<head>
|
|
358
|
+
<meta charset="UTF-8">
|
|
359
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
360
|
+
<title>deadpush Dashboard</title>
|
|
361
|
+
<style>
|
|
362
|
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
363
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
364
|
+
background: #0d1117; color: #c9d1d9; padding: 20px; }}
|
|
365
|
+
h1 {{ color: #58a6ff; margin-bottom: 8px; }}
|
|
366
|
+
h2 {{ color: #8b949e; font-size: 16px; margin: 24px 0 12px;
|
|
367
|
+
border-bottom: 1px solid #30363d; padding-bottom: 6px; }}
|
|
368
|
+
.summary {{ display: flex; gap: 16px; flex-wrap: wrap; margin: 16px 0; }}
|
|
369
|
+
.card {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
|
370
|
+
padding: 16px; flex: 1; min-width: 200px; }}
|
|
371
|
+
.card h3 {{ color: #8b949e; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }}
|
|
372
|
+
.card .value {{ font-size: 28px; font-weight: 600; margin: 8px 0 0; }}
|
|
373
|
+
.card .value.green {{ color: #3fb950; }}
|
|
374
|
+
.card .value.red {{ color: #f85149; }}
|
|
375
|
+
.card .value.yellow {{ color: #d29922; }}
|
|
376
|
+
table {{ width: 100%; border-collapse: collapse; margin: 8px 0; }}
|
|
377
|
+
th, td {{ text-align: left; padding: 8px 12px; border-bottom: 1px solid #21262d; font-size: 13px; }}
|
|
378
|
+
th {{ color: #8b949e; font-weight: 600; }}
|
|
379
|
+
tr:hover td {{ background: #1c2128; }}
|
|
380
|
+
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 12px;
|
|
381
|
+
font-size: 11px; font-weight: 600; }}
|
|
382
|
+
.badge.blocked {{ background: #f8514920; color: #f85149; }}
|
|
383
|
+
.badge.approved {{ background: #3fb95020; color: #3fb950; }}
|
|
384
|
+
.nav {{ display: flex; gap: 12px; margin: 16px 0; }}
|
|
385
|
+
.nav a {{ color: #58a6ff; text-decoration: none; font-size: 14px; }}
|
|
386
|
+
.nav a:hover {{ text-decoration: underline; }}
|
|
387
|
+
.empty {{ color: #484f58; font-style: italic; padding: 12px; }}
|
|
388
|
+
.actions button {{ background: #21262d; border: 1px solid #30363d; color: #c9d1d9;
|
|
389
|
+
padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px; }}
|
|
390
|
+
.actions button:hover {{ background: #30363d; }}
|
|
391
|
+
pre {{ background: #0d1117; padding: 12px; border-radius: 6px; overflow-x: auto;
|
|
392
|
+
font-size: 12px; border: 1px solid #30363d; }}
|
|
393
|
+
.meta {{ color: #484f58; font-size: 12px; }}
|
|
394
|
+
form {{ margin: 8px 0; }}
|
|
395
|
+
input {{ background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
|
396
|
+
padding: 6px 10px; border-radius: 6px; }}
|
|
397
|
+
.violations {{ margin: 8px 0 0 12px; font-size: 12px; color: #f85149; }}
|
|
398
|
+
</style>
|
|
399
|
+
</head>
|
|
400
|
+
<body>
|
|
401
|
+
<h1>deadpush Dashboard</h1>
|
|
402
|
+
<p class="meta">Repo: {repo} · Updated: {ts}</p>
|
|
403
|
+
<div class="nav">
|
|
404
|
+
<a href="/dashboard">Overview</a>
|
|
405
|
+
<a href="/dashboard/blocks">Blocks</a>
|
|
406
|
+
<a href="/dashboard/quarantine">Quarantine</a>
|
|
407
|
+
<a href="/dashboard/allowlist">Allowlist</a>
|
|
408
|
+
<a href="/dashboard">↻ Refresh</a>
|
|
409
|
+
</div>
|
|
410
|
+
{content}
|
|
411
|
+
</body>
|
|
412
|
+
</html>"""
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class GuardianControlHandler(BaseHTTPRequestHandler):
|
|
416
|
+
"""Simple JSON API handler for the guardian control interface."""
|
|
417
|
+
|
|
418
|
+
# Reference to the running GuardianControlServer (set by server)
|
|
419
|
+
control_server = None
|
|
420
|
+
|
|
421
|
+
def _send_html(self, html: str):
|
|
422
|
+
self.send_response(200)
|
|
423
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
424
|
+
self.send_header("Connection", "close")
|
|
425
|
+
self.end_headers()
|
|
426
|
+
self.wfile.write(html.encode("utf-8"))
|
|
427
|
+
|
|
428
|
+
def _send_json(self, obj, status=200):
|
|
429
|
+
self.send_response(status)
|
|
430
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
431
|
+
self.send_header("Access-Control-Allow-Origin", "http://localhost") # for any local browser tools
|
|
432
|
+
self.end_headers()
|
|
433
|
+
try:
|
|
434
|
+
body = json.dumps(obj, default=str, indent=2).encode("utf-8")
|
|
435
|
+
except Exception:
|
|
436
|
+
body = json.dumps({"error": "serialization failed"}).encode("utf-8")
|
|
437
|
+
self.wfile.write(body)
|
|
438
|
+
|
|
439
|
+
def _get_handler(self):
|
|
440
|
+
return self.control_server.guardian_handler if self.control_server else None
|
|
441
|
+
|
|
442
|
+
# ------------------------------------------------------------------
|
|
443
|
+
# Dashboard helpers
|
|
444
|
+
# ------------------------------------------------------------------
|
|
445
|
+
def _read_feedback(self, limit: int = 20) -> list[dict]:
|
|
446
|
+
handler = self._get_handler()
|
|
447
|
+
if not handler:
|
|
448
|
+
return []
|
|
449
|
+
feedback_dir = handler.config.repo_root / FEEDBACK_DIR
|
|
450
|
+
entries = []
|
|
451
|
+
if feedback_dir.exists():
|
|
452
|
+
for f in sorted(feedback_dir.glob("*.json"), reverse=True)[:limit]:
|
|
453
|
+
try:
|
|
454
|
+
entries.append(json.loads(f.read_text(encoding="utf-8")))
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
return entries
|
|
458
|
+
|
|
459
|
+
def _read_runtime_config(self) -> dict:
|
|
460
|
+
try:
|
|
461
|
+
from .rules import RuntimeConfig
|
|
462
|
+
handler = self._get_handler()
|
|
463
|
+
if not handler:
|
|
464
|
+
return {}
|
|
465
|
+
rc = RuntimeConfig(handler.config.repo_root)
|
|
466
|
+
return rc.to_dict()
|
|
467
|
+
except Exception:
|
|
468
|
+
return {}
|
|
469
|
+
|
|
470
|
+
def _dashboard_page(self, content: str) -> str:
|
|
471
|
+
handler = self._get_handler()
|
|
472
|
+
repo = str(handler.config.repo_root) if handler else "?"
|
|
473
|
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
474
|
+
return DASHBOARD_HTML.format(repo=repo, ts=ts, content=content)
|
|
475
|
+
|
|
476
|
+
def _handle_dashboard(self, subpath: str):
|
|
477
|
+
handler = self._get_handler()
|
|
478
|
+
if not handler:
|
|
479
|
+
return self._send_html("<h1>Guardian not ready</h1>", 503)
|
|
480
|
+
|
|
481
|
+
if subpath == "" or subpath == "/":
|
|
482
|
+
feedback = self._read_feedback(limit=5)
|
|
483
|
+
blocks = [e for e in feedback if e.get("status") == "blocked"]
|
|
484
|
+
approvals = [e for e in feedback if e.get("status") == "approved"]
|
|
485
|
+
quarantine = handler.quarantine.list_quarantined()
|
|
486
|
+
score = handler.safety_score
|
|
487
|
+
|
|
488
|
+
cards = f"""
|
|
489
|
+
<div class="summary">
|
|
490
|
+
<div class="card"><h3>Recent Blocks</h3><div class="value red">{len(blocks)}</div></div>
|
|
491
|
+
<div class="card"><h3>Recent Approvals</h3><div class="value green">{len(approvals)}</div></div>
|
|
492
|
+
<div class="card"><h3>Quarantined</h3><div class="value yellow">{len(quarantine)}</div></div>
|
|
493
|
+
<div class="card"><h3>Safety Score</h3><div class="value">{score.get_summary()}</div></div>
|
|
494
|
+
</div>"""
|
|
495
|
+
|
|
496
|
+
config = self._read_runtime_config()
|
|
497
|
+
allowed = config.get("allowed_patterns", [])
|
|
498
|
+
levels = config.get("guardrail_levels", {})
|
|
499
|
+
config_section = f"""
|
|
500
|
+
<h2>Runtime Configuration</h2>
|
|
501
|
+
<table>
|
|
502
|
+
<tr><th>Setting</th><th>Value</th></tr>
|
|
503
|
+
<tr><td>Allowed Patterns</td><td>{len(allowed)} pattern(s)</td></tr>
|
|
504
|
+
<tr><td>Ignored Paths</td><td>{len(config.get('ignored_paths', []))} path(s)</td></tr>
|
|
505
|
+
<tr><td>Guardrail Levels</td><td>{', '.join(f'{k}={v}' for k, v in levels.items()) if levels else 'all defaults'}</td></tr>
|
|
506
|
+
<tr><td>Activity Level</td><td>{score.get_activity_level()}</td></tr>
|
|
507
|
+
</table>"""
|
|
508
|
+
|
|
509
|
+
recent_rows = ""
|
|
510
|
+
for e in feedback:
|
|
511
|
+
recent_rows += f"""<tr>
|
|
512
|
+
<td>{e.get('file', '?')}</td>
|
|
513
|
+
<td><span class="badge {e.get('status', 'approved')}">{e.get('status', '?')}</span></td>
|
|
514
|
+
<td>{len(e.get('violations', []))}</td>
|
|
515
|
+
<td class="meta">{e.get('timestamp', '').replace('T', ' ')[:19]} UTC</td>
|
|
516
|
+
</tr>"""
|
|
517
|
+
recent_section = f"""
|
|
518
|
+
<h2>Recent Activity</h2>
|
|
519
|
+
<table>
|
|
520
|
+
<tr><th>File</th><th>Status</th><th>Violations</th><th>Time</th></tr>
|
|
521
|
+
{recent_rows}
|
|
522
|
+
</table>""" if feedback else ""
|
|
523
|
+
|
|
524
|
+
self._send_html(self._dashboard_page(cards + recent_section + config_section))
|
|
525
|
+
|
|
526
|
+
elif subpath == "/blocks":
|
|
527
|
+
feedback = self._read_feedback(limit=50)
|
|
528
|
+
blocks = [e for e in feedback if e.get("status") == "blocked"]
|
|
529
|
+
if not blocks:
|
|
530
|
+
content = '<p class="empty">No blocked files yet.</p>'
|
|
531
|
+
else:
|
|
532
|
+
rows = ""
|
|
533
|
+
for e in blocks:
|
|
534
|
+
violations_html = "".join(
|
|
535
|
+
f'<div class="violations">\\u2022 {v.get("category")}: {v.get("description")} (line {v.get("line")}, {v.get("severity")})</div>'
|
|
536
|
+
for v in e.get("violations", [])
|
|
537
|
+
)
|
|
538
|
+
diff = e.get("diff", "(no diff)")
|
|
539
|
+
rows += f"""<tr>
|
|
540
|
+
<td>{e.get('file', '?')}</td>
|
|
541
|
+
<td>{violations_html}<details><summary class="meta">Show diff</summary><pre>{diff}</pre></details></td>
|
|
542
|
+
<td class="meta">{e.get('timestamp', '').replace('T', ' ')[:19]} UTC</td>
|
|
543
|
+
</tr>"""
|
|
544
|
+
content = f"""<h2>Blocked Files</h2>
|
|
545
|
+
<p class="meta">{len(blocks)} block(s)</p>
|
|
546
|
+
<table><tr><th>File</th><th>Violations / Diff</th><th>Time</th></tr>{rows}</table>"""
|
|
547
|
+
self._send_html(self._dashboard_page(content))
|
|
548
|
+
|
|
549
|
+
elif subpath == "/quarantine":
|
|
550
|
+
entries = handler.quarantine.list_quarantined()
|
|
551
|
+
if not entries:
|
|
552
|
+
content = '<p class="empty">No quarantined files.</p>'
|
|
553
|
+
else:
|
|
554
|
+
rows = ""
|
|
555
|
+
for e in entries:
|
|
556
|
+
rows += f"""<tr>
|
|
557
|
+
<td>{e.get('name', '?')}</td>
|
|
558
|
+
<td>{e.get('original_path', '?')}</td>
|
|
559
|
+
<td>{e.get('reason', e.get('violations', 'N/A'))}</td>
|
|
560
|
+
<td class="actions">
|
|
561
|
+
<form action="/dashboard/quarantine/restore" method="post" style="display:inline">
|
|
562
|
+
<input type="hidden" name="name" value="{e.get('name', '')}">
|
|
563
|
+
<button type="submit">Restore</button>
|
|
564
|
+
</form>
|
|
565
|
+
</td>
|
|
566
|
+
</tr>"""
|
|
567
|
+
content = f"""<h2>Quarantine Manager</h2>
|
|
568
|
+
<p class="meta">{len(entries)} quarantined file(s)</p>
|
|
569
|
+
<table><tr><th>Name</th><th>Original Path</th><th>Reason</th><th>Action</th></tr>{rows}</table>"""
|
|
570
|
+
self._send_html(self._dashboard_page(content))
|
|
571
|
+
|
|
572
|
+
elif subpath == "/allowlist":
|
|
573
|
+
config = self._read_runtime_config()
|
|
574
|
+
patterns = config.get("allowed_patterns", [])
|
|
575
|
+
levels = config.get("guardrail_levels", {})
|
|
576
|
+
|
|
577
|
+
if patterns:
|
|
578
|
+
rows = ""
|
|
579
|
+
for p in patterns:
|
|
580
|
+
rows += f"""<tr>
|
|
581
|
+
<td>{p.get('pattern', '?')}</td>
|
|
582
|
+
<td>{p.get('description', '')}</td>
|
|
583
|
+
<td class="actions"><form action="/dashboard/allowlist/remove" method="post" style="display:inline">
|
|
584
|
+
<input type="hidden" name="pattern" value="{p.get('pattern', '')}">
|
|
585
|
+
<button type="submit">Remove</button>
|
|
586
|
+
</form></td>
|
|
587
|
+
</tr>"""
|
|
588
|
+
patterns_html = f"""<h3>Allowed Patterns ({len(patterns)})</h3>
|
|
589
|
+
<table><tr><th>Pattern</th><th>Description</th><th>Action</th></tr>{rows}</table>"""
|
|
590
|
+
else:
|
|
591
|
+
patterns_html = '<p class="empty">No allowed patterns.</p>'
|
|
592
|
+
|
|
593
|
+
level_rows = "".join(
|
|
594
|
+
f"<tr><td>{cat}</td><td>{lvl}</td></tr>"
|
|
595
|
+
for cat, lvl in sorted(levels.items())
|
|
596
|
+
) if levels else '<tr><td colspan="2"><span class="empty">All defaults</span></td></tr>'
|
|
597
|
+
|
|
598
|
+
content = f"""{patterns_html}
|
|
599
|
+
<form action="/dashboard/allowlist/add" method="post">
|
|
600
|
+
<input type="text" name="pattern" placeholder="regex pattern" required>
|
|
601
|
+
<input type="text" name="description" placeholder="description (optional)">
|
|
602
|
+
<button type="submit">Add Pattern</button>
|
|
603
|
+
</form>
|
|
604
|
+
<h3>Guardrail Levels</h3>
|
|
605
|
+
<table><tr><th>Category</th><th>Level</th></tr>{level_rows}</table>
|
|
606
|
+
<form action="/dashboard/allowlist/reset" method="post">
|
|
607
|
+
<button type="submit" style="background:#f8514920;border:1px solid #f85149;color:#f85149;padding:6px 14px;border-radius:6px;cursor:pointer">Reset All Config</button>
|
|
608
|
+
</form>"""
|
|
609
|
+
self._send_html(self._dashboard_page(content))
|
|
610
|
+
|
|
611
|
+
else:
|
|
612
|
+
self._send_json({"error": "unknown dashboard page"}, 404)
|
|
613
|
+
|
|
614
|
+
def do_GET(self):
|
|
615
|
+
parsed = urlparse(self.path)
|
|
616
|
+
qs = parse_qs(parsed.query)
|
|
617
|
+
path = parsed.path.rstrip("/")
|
|
618
|
+
|
|
619
|
+
handler = self._get_handler()
|
|
620
|
+
if not handler:
|
|
621
|
+
return self._send_json({"error": "guardian not ready"}, 503)
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
if path in ("/", "/status"):
|
|
625
|
+
score = handler.safety_score
|
|
626
|
+
data = {
|
|
627
|
+
"running": True,
|
|
628
|
+
"safety_score": score.get_summary(),
|
|
629
|
+
"activity_level": score.get_activity_level(),
|
|
630
|
+
"session_summary": score.get_session_summary(),
|
|
631
|
+
"recent_incidents_count": len([i for i in score.incidents if (datetime.now() - i["time"]).total_seconds() < score.recent_window]),
|
|
632
|
+
"quarantine_count": len(handler.quarantine.list_quarantined()),
|
|
633
|
+
"intervention_enabled": handler.intervention,
|
|
634
|
+
"strict_mode": handler.strict_mode,
|
|
635
|
+
}
|
|
636
|
+
self._send_json(data)
|
|
637
|
+
elif path == "/safety-score":
|
|
638
|
+
self._send_json({"safety_score": handler.safety_score.get_summary(), "details": handler.safety_score.get_session_summary()})
|
|
639
|
+
elif path == "/recent-incidents":
|
|
640
|
+
limit = int(qs.get("limit", [10])[0])
|
|
641
|
+
recent = handler.safety_score.incidents[-limit:]
|
|
642
|
+
self._send_json({"incidents": recent})
|
|
643
|
+
elif path == "/quarantine-list":
|
|
644
|
+
limit = int(qs.get("limit", [20])[0])
|
|
645
|
+
qlist = handler.quarantine.list_quarantined()[:limit]
|
|
646
|
+
self._send_json({"quarantined": qlist, "dir": str(handler.quarantine.quarantine_dir)})
|
|
647
|
+
elif path == "/health":
|
|
648
|
+
self._send_json({"status": "ok", "guardian": "alive"})
|
|
649
|
+
elif path.startswith("/dashboard"):
|
|
650
|
+
subpath = path[len("/dashboard"):]
|
|
651
|
+
self._handle_dashboard(subpath)
|
|
652
|
+
else:
|
|
653
|
+
self._send_json({"error": "unknown endpoint", "available": ["/status", "/safety-score", "/recent-incidents", "/quarantine-list", "/health", "/dashboard"]}, 404)
|
|
654
|
+
except Exception as e:
|
|
655
|
+
self._send_json({"error": str(e)}, 500)
|
|
656
|
+
|
|
657
|
+
def _handle_dashboard_post(self, path: str, params: dict[str, str]):
|
|
658
|
+
handler = self._get_handler()
|
|
659
|
+
if not handler:
|
|
660
|
+
return self._send_json({"error": "guardian not ready"}, 503)
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
from .rules import RuntimeConfig
|
|
664
|
+
rc = RuntimeConfig(handler.config.repo_root)
|
|
665
|
+
|
|
666
|
+
if path == "/quarantine/restore":
|
|
667
|
+
name = params.get("name", "")
|
|
668
|
+
if name:
|
|
669
|
+
handler.quarantine.restore(name)
|
|
670
|
+
self._redirect("/dashboard/quarantine")
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
elif path == "/allowlist/add":
|
|
674
|
+
pattern = params.get("pattern", "")
|
|
675
|
+
description = params.get("description", "")
|
|
676
|
+
if pattern:
|
|
677
|
+
import re
|
|
678
|
+
rc.add_allowed_pattern(pattern, description)
|
|
679
|
+
self._redirect("/dashboard/allowlist")
|
|
680
|
+
return
|
|
681
|
+
|
|
682
|
+
elif path == "/allowlist/remove":
|
|
683
|
+
pattern = params.get("pattern", "")
|
|
684
|
+
if pattern:
|
|
685
|
+
rc.remove_allowed_pattern(pattern)
|
|
686
|
+
self._redirect("/dashboard/allowlist")
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
elif path == "/allowlist/reset":
|
|
690
|
+
rc.reset()
|
|
691
|
+
self._redirect("/dashboard/allowlist")
|
|
692
|
+
return
|
|
693
|
+
|
|
694
|
+
except Exception as e:
|
|
695
|
+
self._send_json({"error": str(e)}, 500)
|
|
696
|
+
return
|
|
697
|
+
|
|
698
|
+
self._redirect("/dashboard")
|
|
699
|
+
|
|
700
|
+
def _redirect(self, path: str):
|
|
701
|
+
self.send_response(302)
|
|
702
|
+
self.send_header("Location", path)
|
|
703
|
+
self.send_header("Connection", "close")
|
|
704
|
+
self.end_headers()
|
|
705
|
+
|
|
706
|
+
def do_POST(self):
|
|
707
|
+
parsed = urlparse(self.path)
|
|
708
|
+
path = parsed.path.rstrip("/")
|
|
709
|
+
|
|
710
|
+
# Handle dashboard form posts
|
|
711
|
+
if path.startswith("/dashboard"):
|
|
712
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
713
|
+
body = self.rfile.read(length).decode("utf-8") if length else ""
|
|
714
|
+
params = {}
|
|
715
|
+
if body:
|
|
716
|
+
for pair in body.split("&"):
|
|
717
|
+
if "=" in pair:
|
|
718
|
+
k, v = pair.split("=", 1)
|
|
719
|
+
from urllib.parse import unquote_plus
|
|
720
|
+
params[unquote_plus(k)] = unquote_plus(v)
|
|
721
|
+
self._handle_dashboard_post(path[len("/dashboard"):], params)
|
|
722
|
+
return
|
|
723
|
+
|
|
724
|
+
handler = self._get_handler()
|
|
725
|
+
if not handler:
|
|
726
|
+
return self._send_json({"error": "guardian not ready"}, 503)
|
|
727
|
+
|
|
728
|
+
try:
|
|
729
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
730
|
+
body = self.rfile.read(length).decode("utf-8") if length else "{}"
|
|
731
|
+
payload = json.loads(body) if body.strip() else {}
|
|
732
|
+
|
|
733
|
+
if path == "/trigger-light-analysis":
|
|
734
|
+
# Light / safe action: run a quick debris scan on the repo root (non-blocking hint)
|
|
735
|
+
# For deep analysis agents can still call full scan, this is for "is it safe?" quick check
|
|
736
|
+
from .debris import DebrisDetector
|
|
737
|
+
detector = DebrisDetector(handler.config)
|
|
738
|
+
# Quick: just scan for high-risk debris without full graph
|
|
739
|
+
files = [] # could use crawler but to keep light, just note
|
|
740
|
+
# In practice, return current quarantine + score as "analysis"
|
|
741
|
+
result = {
|
|
742
|
+
"message": "Light analysis triggered. Current guardian state returned.",
|
|
743
|
+
"safety": handler.safety_score.get_summary(),
|
|
744
|
+
"quarantine_count": len(handler.quarantine.list_quarantined()),
|
|
745
|
+
"recommendation": "Use /quarantine-list for details. Run full `deadpush scan` for deep static analysis if needed."
|
|
746
|
+
}
|
|
747
|
+
self._send_json(result)
|
|
748
|
+
elif path == "/quarantine/restore":
|
|
749
|
+
qname = payload.get("path") or payload.get("name")
|
|
750
|
+
if not qname:
|
|
751
|
+
return self._send_json({"error": "missing 'path' in payload"}, 400)
|
|
752
|
+
restored = handler.quarantine.restore(qname)
|
|
753
|
+
if restored:
|
|
754
|
+
self._send_json({"success": True, "restored_to": str(restored)})
|
|
755
|
+
else:
|
|
756
|
+
self._send_json({"success": False, "error": "restore failed or original exists"}, 409)
|
|
757
|
+
else:
|
|
758
|
+
self._send_json({"error": "unknown action", "supported_post": ["/trigger-light-analysis", "/quarantine/restore"]}, 404)
|
|
759
|
+
except json.JSONDecodeError:
|
|
760
|
+
self._send_json({"error": "invalid json body"}, 400)
|
|
761
|
+
except Exception as e:
|
|
762
|
+
self._send_json({"error": str(e)}, 500)
|
|
763
|
+
|
|
764
|
+
def log_message(self, format, *args):
|
|
765
|
+
# Silent by default (agents don't need our access logs). Can be verbose in debug.
|
|
766
|
+
pass
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
class GuardianControlServer:
|
|
770
|
+
"""Manages the lightweight local HTTP control interface for AI agents.
|
|
771
|
+
|
|
772
|
+
Uses a small range of ports starting from DEFAULT_PORT for reliability
|
|
773
|
+
(avoids conflicts if multiple instances or previous unclean shutdowns).
|
|
774
|
+
Writes the actual port to ~/.deadpush/guardian.control.port for agents
|
|
775
|
+
to discover easily.
|
|
776
|
+
"""
|
|
777
|
+
|
|
778
|
+
DEFAULT_PORT = 14242
|
|
779
|
+
PORT_RANGE = 5 # try up to 5 ports
|
|
780
|
+
|
|
781
|
+
def __init__(self, guardian_handler, port: int | None = None):
|
|
782
|
+
self.guardian_handler = guardian_handler
|
|
783
|
+
self.requested_port = port or self.DEFAULT_PORT
|
|
784
|
+
self.port = None
|
|
785
|
+
self.httpd = None
|
|
786
|
+
self.thread = None
|
|
787
|
+
self.logger = logging.getLogger("deadpush.guardian")
|
|
788
|
+
self.port_file = Path.home() / ".deadpush" / "guardian.control.port"
|
|
789
|
+
|
|
790
|
+
def start(self):
|
|
791
|
+
if self.httpd:
|
|
792
|
+
return
|
|
793
|
+
|
|
794
|
+
handler_class = type(
|
|
795
|
+
"BoundGuardianControlHandler",
|
|
796
|
+
(GuardianControlHandler,),
|
|
797
|
+
{"control_server": self}
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
self.port = None
|
|
801
|
+
for offset in range(self.PORT_RANGE):
|
|
802
|
+
candidate = self.requested_port + offset
|
|
803
|
+
try:
|
|
804
|
+
self.httpd = ThreadedHTTPServer(("127.0.0.1", candidate), handler_class)
|
|
805
|
+
self.port = candidate
|
|
806
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True, name="GuardianControlHTTP")
|
|
807
|
+
self.thread.start()
|
|
808
|
+
self.port_file.parent.mkdir(parents=True, exist_ok=True)
|
|
809
|
+
self.port_file.write_text(str(self.port))
|
|
810
|
+
self.logger.info(f"Local control interface started: http://127.0.0.1:{self.port} (for AI agents)")
|
|
811
|
+
return
|
|
812
|
+
except OSError as e:
|
|
813
|
+
if e.errno == 48: # Address already in use
|
|
814
|
+
self.logger.warning(f"Port {candidate} in use, trying next...")
|
|
815
|
+
continue
|
|
816
|
+
else:
|
|
817
|
+
self.logger.error(f"Failed to bind control interface on port {candidate}: {e}")
|
|
818
|
+
break
|
|
819
|
+
except Exception as e:
|
|
820
|
+
self.logger.error(f"Failed to start local control interface on port {candidate}: {e}")
|
|
821
|
+
break
|
|
822
|
+
|
|
823
|
+
self.httpd = None
|
|
824
|
+
self.logger.error(f"Could not start local control interface on any port in range {self.requested_port}-{self.requested_port + self.PORT_RANGE - 1}")
|
|
825
|
+
|
|
826
|
+
def stop(self):
|
|
827
|
+
if self.httpd:
|
|
828
|
+
try:
|
|
829
|
+
self.httpd.shutdown()
|
|
830
|
+
self.httpd.server_close()
|
|
831
|
+
except Exception:
|
|
832
|
+
pass
|
|
833
|
+
self.httpd = None
|
|
834
|
+
if self.thread:
|
|
835
|
+
self.thread.join(timeout=2)
|
|
836
|
+
self.thread = None
|
|
837
|
+
if self.port_file.exists():
|
|
838
|
+
try:
|
|
839
|
+
self.port_file.unlink()
|
|
840
|
+
except Exception:
|
|
841
|
+
pass
|
|
842
|
+
self.logger.debug("Local control interface stopped.")
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# =============================================================================
|
|
846
|
+
# Enhanced Guardian Handler with Stronger Intervention
|
|
847
|
+
# =============================================================================
|
|
848
|
+
class GuardianHandler(FileSystemEventHandler or object):
|
|
849
|
+
"""Real-time guardian with unified guardrail pipeline.
|
|
850
|
+
|
|
851
|
+
Uses the full 7-category guardrails from intercept.py:
|
|
852
|
+
- Watches the entire repo root via watchdog
|
|
853
|
+
- Every file write goes through _run_guardrails
|
|
854
|
+
- Block-level violations → quarantine + git restore + structured feedback
|
|
855
|
+
"""
|
|
856
|
+
|
|
857
|
+
def __init__(self, config, intervention: bool = True, strict_mode: bool = False, logger=None):
|
|
858
|
+
self.config = config
|
|
859
|
+
self.intervention = intervention
|
|
860
|
+
self.strict_mode = strict_mode
|
|
861
|
+
self.logger = logger or logging.getLogger("deadpush.guardian")
|
|
862
|
+
self.detector = DebrisDetector(config)
|
|
863
|
+
self.quarantine = QuarantineManager(config.repo_root)
|
|
864
|
+
self.safety_score = SessionSafetyScore()
|
|
865
|
+
self.session_mgr = SessionManager()
|
|
866
|
+
|
|
867
|
+
# Rate limiting for multi-agent scenarios
|
|
868
|
+
self.last_intervention_ts = 0.0
|
|
869
|
+
self.cooldown_seconds = 0.5 # Aggressive but not spammy
|
|
870
|
+
|
|
871
|
+
def on_created(self, event):
|
|
872
|
+
if event.is_directory:
|
|
873
|
+
return
|
|
874
|
+
self._evaluate(Path(event.src_path), event_type="created")
|
|
875
|
+
|
|
876
|
+
def on_modified(self, event):
|
|
877
|
+
if event.is_directory:
|
|
878
|
+
return
|
|
879
|
+
self._evaluate(Path(event.src_path), event_type="modified")
|
|
880
|
+
|
|
881
|
+
# ------------------------------------------------------------------
|
|
882
|
+
# Git helpers for old-source retrieval and file restoration
|
|
883
|
+
# ------------------------------------------------------------------
|
|
884
|
+
def _git_show(self, rel: str) -> str:
|
|
885
|
+
"""Get file content from git HEAD. Returns empty string if missing."""
|
|
886
|
+
try:
|
|
887
|
+
result = subprocess.run(
|
|
888
|
+
["git", "show", f"HEAD:{rel}"],
|
|
889
|
+
capture_output=True, text=True, timeout=5,
|
|
890
|
+
cwd=self.config.repo_root,
|
|
891
|
+
)
|
|
892
|
+
if result.returncode == 0:
|
|
893
|
+
return result.stdout
|
|
894
|
+
except Exception:
|
|
895
|
+
pass
|
|
896
|
+
return ""
|
|
897
|
+
|
|
898
|
+
def _restore_from_git(self, rel: str) -> bool:
|
|
899
|
+
"""Restore a file from git HEAD. Returns True on success."""
|
|
900
|
+
old = self._git_show(rel)
|
|
901
|
+
if not old:
|
|
902
|
+
return False
|
|
903
|
+
try:
|
|
904
|
+
dest = self.config.repo_root / rel
|
|
905
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
906
|
+
dest.write_text(old, encoding="utf-8")
|
|
907
|
+
self.logger.info(f"Restored {rel} from git HEAD (reverted agent write)")
|
|
908
|
+
return True
|
|
909
|
+
except Exception as e:
|
|
910
|
+
self.logger.error(f"Failed to restore {rel} from git: {e}")
|
|
911
|
+
return False
|
|
912
|
+
|
|
913
|
+
# ------------------------------------------------------------------
|
|
914
|
+
# Main evaluation pipeline (called on every file create/modify)
|
|
915
|
+
# ------------------------------------------------------------------
|
|
916
|
+
def _evaluate(self, path: Path, event_type: str):
|
|
917
|
+
try:
|
|
918
|
+
if not path.exists():
|
|
919
|
+
return
|
|
920
|
+
# Skip internal dirs
|
|
921
|
+
skip_names = {"__pycache__", ".git", "node_modules",
|
|
922
|
+
".deadpush-quarantine", ".deadpush-archive",
|
|
923
|
+
".deadpush-config-backups"}
|
|
924
|
+
if any(part in skip_names for part in path.parts):
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
# Rate limiting for multi-agent
|
|
928
|
+
now = time.time()
|
|
929
|
+
if now - self.last_intervention_ts < self.cooldown_seconds:
|
|
930
|
+
return
|
|
931
|
+
|
|
932
|
+
try:
|
|
933
|
+
rel = path.relative_to(self.config.repo_root).as_posix()
|
|
934
|
+
except (ValueError, Exception):
|
|
935
|
+
return
|
|
936
|
+
|
|
937
|
+
filename = path.name.lower()
|
|
938
|
+
|
|
939
|
+
# === STEP 1: Check blocked files (deadpush.toml blocked_files/blocked_patterns) ===
|
|
940
|
+
if self.config.is_blocked(rel):
|
|
941
|
+
self._intervene_blocked(path, rel, event_type)
|
|
942
|
+
return
|
|
943
|
+
|
|
944
|
+
# === STEP 2: Run full guardrail pipeline ===
|
|
945
|
+
from .intercept import _run_guardrails, _write_feedback, GuardrailResult, FEEDBACK_DIR
|
|
946
|
+
|
|
947
|
+
old_source = self._git_show(rel)
|
|
948
|
+
try:
|
|
949
|
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
|
950
|
+
except Exception:
|
|
951
|
+
return
|
|
952
|
+
|
|
953
|
+
result = _run_guardrails(
|
|
954
|
+
path, self.config.repo_root, self.config,
|
|
955
|
+
_old_source=old_source, _rel_path_override=rel,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
# === STEP 3: Enforce guardrail results ===
|
|
959
|
+
if not result.allowed:
|
|
960
|
+
self._intervene_guardrails(path, rel, result, event_type, old_source=old_source)
|
|
961
|
+
return # File was quarantined; don't continue evaluation
|
|
962
|
+
|
|
963
|
+
if result.violations:
|
|
964
|
+
# Warn-level violations — log + write feedback + safety score hit
|
|
965
|
+
for v in result.violations:
|
|
966
|
+
penalty = 8 if v.severity == "high" else 4
|
|
967
|
+
self.safety_score.report_incident(penalty, f"Warn: {v.description}", str(path))
|
|
968
|
+
self.logger.warning(
|
|
969
|
+
f"WARN [{event_type.upper()}] {rel} | "
|
|
970
|
+
f"{len(result.violations)} warn-level violation(s) | "
|
|
971
|
+
f"Safety: {self.safety_score.score}/100"
|
|
972
|
+
)
|
|
973
|
+
try:
|
|
974
|
+
_write_feedback(self.config.repo_root / FEEDBACK_DIR, rel, result)
|
|
975
|
+
except Exception:
|
|
976
|
+
pass
|
|
977
|
+
self.session_mgr.record_incident({
|
|
978
|
+
"type": "guardrail_warn", "file": rel,
|
|
979
|
+
"count": len(result.violations),
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
# === STEP 4: Debris scan (secondary, in addition to guardrails) ===
|
|
983
|
+
try:
|
|
984
|
+
from .crawler import FileInfo
|
|
985
|
+
fi = FileInfo(
|
|
986
|
+
path=path,
|
|
987
|
+
rel_path=path.relative_to(self.config.repo_root),
|
|
988
|
+
size=path.stat().st_size,
|
|
989
|
+
is_text=True,
|
|
990
|
+
mtime=time.time(),
|
|
991
|
+
)
|
|
992
|
+
debris = self.detector.scan([fi])
|
|
993
|
+
blocking = [d for d in debris if d.block_push]
|
|
994
|
+
if blocking:
|
|
995
|
+
self._intervene_blocking_debris(path, blocking, event_type)
|
|
996
|
+
except Exception:
|
|
997
|
+
pass
|
|
998
|
+
|
|
999
|
+
# === STEP 5: Record in session ===
|
|
1000
|
+
try:
|
|
1001
|
+
self.session_mgr.record_file_change(rel)
|
|
1002
|
+
self.session_mgr.update_safety_score(self.safety_score.score)
|
|
1003
|
+
except Exception:
|
|
1004
|
+
pass
|
|
1005
|
+
|
|
1006
|
+
except Exception as e:
|
|
1007
|
+
self.logger.debug(f"Evaluation error on {path}: {e}")
|
|
1008
|
+
|
|
1009
|
+
# ------------------------------------------------------------------
|
|
1010
|
+
# Intervention actions
|
|
1011
|
+
# ------------------------------------------------------------------
|
|
1012
|
+
def _intervene_blocked(self, path: Path, rel: str, event_type: str):
|
|
1013
|
+
"""Intervene when a blocked file is written (claude.md, etc.)."""
|
|
1014
|
+
from .intercept import GuardrailResult, Violation, _write_feedback, FEEDBACK_DIR
|
|
1015
|
+
|
|
1016
|
+
self.last_intervention_ts = time.time()
|
|
1017
|
+
score = self.safety_score.report_incident(25, f"Blocked file written: {rel}", str(path))
|
|
1018
|
+
|
|
1019
|
+
result = GuardrailResult()
|
|
1020
|
+
result.reject(Violation("blocked_file", f"File {rel} is in the blocked list and cannot be written", 0, "critical"))
|
|
1021
|
+
|
|
1022
|
+
old_source = self._git_show(rel)
|
|
1023
|
+
self._quarantine_and_restore(path, rel, result, old_source)
|
|
1024
|
+
|
|
1025
|
+
self.logger.warning(
|
|
1026
|
+
f"INTERVENTION [{event_type.upper()}] BLOCKED FILE: {rel} | "
|
|
1027
|
+
f"Moved to quarantine + restored from git | Safety: {score}/100"
|
|
1028
|
+
)
|
|
1029
|
+
try:
|
|
1030
|
+
self.session_mgr.record_incident({
|
|
1031
|
+
"type": "blocked_file", "file": rel, "score": score,
|
|
1032
|
+
})
|
|
1033
|
+
self.session_mgr.update_safety_score(score)
|
|
1034
|
+
except Exception:
|
|
1035
|
+
pass
|
|
1036
|
+
|
|
1037
|
+
def _intervene_guardrails(self, path: Path, rel: str, result, event_type: str, old_source: str = ""):
|
|
1038
|
+
"""Intervene when guardrails detect block-level violations."""
|
|
1039
|
+
from .intercept import _write_feedback, FEEDBACK_DIR
|
|
1040
|
+
|
|
1041
|
+
self.last_intervention_ts = time.time()
|
|
1042
|
+
penalty = min(25, 5 * len(result.violations))
|
|
1043
|
+
score = self.safety_score.report_incident(penalty, f"Guardrail block: {result.violations[0].description}", str(path))
|
|
1044
|
+
|
|
1045
|
+
self._quarantine_and_restore(path, rel, result, old_source)
|
|
1046
|
+
|
|
1047
|
+
self.logger.warning(
|
|
1048
|
+
f"INTERVENTION [{event_type.upper()}] GUARDRAIL BLOCK: {rel} | "
|
|
1049
|
+
f"{len(result.violations)} violation(s) | "
|
|
1050
|
+
f"Top: {result.violations[0].description} | "
|
|
1051
|
+
f"Safety: {score}/100"
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
try:
|
|
1055
|
+
_write_feedback(self.config.repo_root / FEEDBACK_DIR, rel, result)
|
|
1056
|
+
except Exception:
|
|
1057
|
+
pass
|
|
1058
|
+
|
|
1059
|
+
try:
|
|
1060
|
+
self.session_mgr.record_incident({
|
|
1061
|
+
"type": "guardrail_block", "file": rel,
|
|
1062
|
+
"violations": [v.to_dict() for v in result.violations],
|
|
1063
|
+
"score": score,
|
|
1064
|
+
})
|
|
1065
|
+
self.session_mgr.update_safety_score(score)
|
|
1066
|
+
except Exception:
|
|
1067
|
+
pass
|
|
1068
|
+
|
|
1069
|
+
def _quarantine_and_restore(self, path: Path, rel: str, result, old_source: str):
|
|
1070
|
+
"""Quarantine the violating file and restore the original from git."""
|
|
1071
|
+
if self.intervention and path.exists():
|
|
1072
|
+
reason = result.violations[0].description if result.violations else "guardrail violation"
|
|
1073
|
+
try:
|
|
1074
|
+
quarantined = self.quarantine.quarantine(path, reason)
|
|
1075
|
+
self.logger.info(f"Quarantined: {quarantined}")
|
|
1076
|
+
except Exception as e:
|
|
1077
|
+
self.logger.error(f"Failed to quarantine {path}: {e}")
|
|
1078
|
+
try:
|
|
1079
|
+
path.unlink(missing_ok=True)
|
|
1080
|
+
except Exception:
|
|
1081
|
+
pass
|
|
1082
|
+
|
|
1083
|
+
# Restore original from git (if the file existed before)
|
|
1084
|
+
if old_source:
|
|
1085
|
+
self._restore_from_git(rel)
|
|
1086
|
+
|
|
1087
|
+
def _intervene_blocking_debris(self, path: Path, blocking_items, event_type: str):
|
|
1088
|
+
self.last_intervention_ts = time.time()
|
|
1089
|
+
for item in blocking_items:
|
|
1090
|
+
score = self.safety_score.report_incident(12, item.reason, str(path))
|
|
1091
|
+
try:
|
|
1092
|
+
self.session_mgr.record_incident({
|
|
1093
|
+
"type": "blocking_debris", "reason": item.reason,
|
|
1094
|
+
"file": str(path), "score": score,
|
|
1095
|
+
})
|
|
1096
|
+
self.session_mgr.update_safety_score(score)
|
|
1097
|
+
except Exception:
|
|
1098
|
+
pass
|
|
1099
|
+
self.logger.warning(
|
|
1100
|
+
f"INTERVENTION [{event_type.upper()}] {item.category} in {path.name} | "
|
|
1101
|
+
f"{item.reason} | Safety: {score}/100"
|
|
1102
|
+
)
|
|
1103
|
+
if self.intervention and item.category == "hardcoded_secret":
|
|
1104
|
+
try:
|
|
1105
|
+
if path.exists():
|
|
1106
|
+
quarantined = self.quarantine.quarantine(path, item.reason)
|
|
1107
|
+
self.logger.critical(f"QUARANTINED FILE WITH HARDCODED SECRET: {quarantined}")
|
|
1108
|
+
except Exception as e:
|
|
1109
|
+
self.logger.error(f"Failed to quarantine secret file: {e}")
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
# =============================================================================
|
|
1113
|
+
# Main Runner with Improved Daemon Support
|
|
1114
|
+
# =============================================================================
|
|
1115
|
+
def run_guardian(intervention: bool = True, daemon: bool = False, strict: bool = False):
|
|
1116
|
+
if not WATCHDOG_AVAILABLE:
|
|
1117
|
+
print("Error: watchdog package required. pip install deadpush[watch]")
|
|
1118
|
+
return
|
|
1119
|
+
|
|
1120
|
+
config = load_config()
|
|
1121
|
+
logger = setup_logging(daemon=daemon)
|
|
1122
|
+
|
|
1123
|
+
pid_dir = Path.home() / ".deadpush"
|
|
1124
|
+
pid_dir.mkdir(parents=True, exist_ok=True)
|
|
1125
|
+
pidfile = pid_dir / "guardian.pid"
|
|
1126
|
+
lockfile = pid_dir / "guardian.lock"
|
|
1127
|
+
|
|
1128
|
+
daemon_mgr = DaemonManager(pidfile, lockfile)
|
|
1129
|
+
|
|
1130
|
+
if daemon_mgr.is_running():
|
|
1131
|
+
logger.warning("Guardian is already running.")
|
|
1132
|
+
return
|
|
1133
|
+
|
|
1134
|
+
if not daemon_mgr.acquire_lock():
|
|
1135
|
+
logger.error("Could not acquire lock. Another instance may be running.")
|
|
1136
|
+
return
|
|
1137
|
+
|
|
1138
|
+
handler = GuardianHandler(config, intervention=intervention, strict_mode=strict, logger=logger)
|
|
1139
|
+
|
|
1140
|
+
# Start the Local Control Interface so AI agents can query/interact autonomously
|
|
1141
|
+
# (status, quarantine list, safety score, light analysis, safe restores).
|
|
1142
|
+
# Works for both foreground `guard` and `--daemon`.
|
|
1143
|
+
control_server = GuardianControlServer(handler)
|
|
1144
|
+
control_server.start()
|
|
1145
|
+
if control_server.port:
|
|
1146
|
+
logger.info(f"Local control interface on http://127.0.0.1:{control_server.port} (port file: {control_server.port_file})")
|
|
1147
|
+
logger.info("AI agents can now query the guardian autonomously (GET /status, /quarantine-list, etc.)")
|
|
1148
|
+
atexit.register(control_server.stop)
|
|
1149
|
+
else:
|
|
1150
|
+
logger.warning("Local control interface could not be started (agents can fall back to `deadpush status` / CLI)")
|
|
1151
|
+
|
|
1152
|
+
if daemon:
|
|
1153
|
+
logger.info("Starting in DAEMON mode...")
|
|
1154
|
+
try:
|
|
1155
|
+
# Double fork
|
|
1156
|
+
if os.fork() > 0:
|
|
1157
|
+
sys.exit(0)
|
|
1158
|
+
os.setsid()
|
|
1159
|
+
if os.fork() > 0:
|
|
1160
|
+
sys.exit(0)
|
|
1161
|
+
os.chdir("/")
|
|
1162
|
+
os.umask(0)
|
|
1163
|
+
|
|
1164
|
+
# Headless daemon: ensure no stray output to terminal (even if stdio inherited).
|
|
1165
|
+
# Logging is already file-only because daemon=True was passed to setup_logging.
|
|
1166
|
+
try:
|
|
1167
|
+
sys.stdout.flush()
|
|
1168
|
+
sys.stderr.flush()
|
|
1169
|
+
except Exception:
|
|
1170
|
+
pass
|
|
1171
|
+
with open(os.devnull, "w") as devnull:
|
|
1172
|
+
os.dup2(devnull.fileno(), sys.stdout.fileno())
|
|
1173
|
+
os.dup2(devnull.fileno(), sys.stderr.fileno())
|
|
1174
|
+
|
|
1175
|
+
daemon_mgr.write_pid()
|
|
1176
|
+
atexit.register(daemon_mgr.cleanup)
|
|
1177
|
+
|
|
1178
|
+
_run_observer(handler, logger)
|
|
1179
|
+
except Exception as e:
|
|
1180
|
+
logger.error(f"Daemon failed: {e}")
|
|
1181
|
+
daemon_mgr.cleanup()
|
|
1182
|
+
else:
|
|
1183
|
+
logger.info("Starting in FOREGROUND mode...")
|
|
1184
|
+
daemon_mgr.write_pid()
|
|
1185
|
+
atexit.register(daemon_mgr.cleanup)
|
|
1186
|
+
_run_observer(handler, logger)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _run_observer(handler: GuardianHandler, logger):
|
|
1190
|
+
"""Run the filesystem observer with automatic recovery on crashes.
|
|
1191
|
+
|
|
1192
|
+
This improves daemon reliability: if the watcher thread dies (e.g. transient FS error,
|
|
1193
|
+
handler bug), we log, wait with backoff, and restart the observer without killing the daemon.
|
|
1194
|
+
"""
|
|
1195
|
+
if Observer is None:
|
|
1196
|
+
logger.error("Cannot start observer: watchdog not installed. Use `pip install deadpush[watch]`")
|
|
1197
|
+
return
|
|
1198
|
+
|
|
1199
|
+
backoff = 1
|
|
1200
|
+
max_backoff = 30
|
|
1201
|
+
|
|
1202
|
+
def shutdown(signum, frame):
|
|
1203
|
+
logger.info("Guardian shutting down gracefully...")
|
|
1204
|
+
if 'observer' in locals() and observer:
|
|
1205
|
+
try:
|
|
1206
|
+
observer.stop()
|
|
1207
|
+
except Exception:
|
|
1208
|
+
pass
|
|
1209
|
+
# note: sys.exit will be caught by outer if needed
|
|
1210
|
+
|
|
1211
|
+
signal.signal(signal.SIGTERM, shutdown)
|
|
1212
|
+
signal.signal(signal.SIGINT, shutdown)
|
|
1213
|
+
|
|
1214
|
+
observer = None
|
|
1215
|
+
try:
|
|
1216
|
+
while True:
|
|
1217
|
+
try:
|
|
1218
|
+
if observer is None or not getattr(observer, 'is_alive', lambda: False)():
|
|
1219
|
+
if observer is not None:
|
|
1220
|
+
try:
|
|
1221
|
+
observer.stop()
|
|
1222
|
+
observer.join(timeout=2)
|
|
1223
|
+
except Exception:
|
|
1224
|
+
pass
|
|
1225
|
+
observer = Observer()
|
|
1226
|
+
observer.schedule(handler, str(handler.config.repo_root), recursive=True)
|
|
1227
|
+
observer.start()
|
|
1228
|
+
logger.info(f"Guardian (re)watching: {handler.config.repo_root}")
|
|
1229
|
+
logger.info(f"Safety Score: {handler.safety_score.get_summary()}")
|
|
1230
|
+
backoff = 1 # reset on healthy start
|
|
1231
|
+
|
|
1232
|
+
time.sleep(2)
|
|
1233
|
+
except Exception as e:
|
|
1234
|
+
logger.error(f"Watcher error (auto-recovering in {backoff}s): {e}")
|
|
1235
|
+
if observer:
|
|
1236
|
+
try:
|
|
1237
|
+
observer.stop()
|
|
1238
|
+
except Exception:
|
|
1239
|
+
pass
|
|
1240
|
+
time.sleep(backoff)
|
|
1241
|
+
backoff = min(backoff * 2, max_backoff)
|
|
1242
|
+
observer = None # force recreate next iter
|
|
1243
|
+
except KeyboardInterrupt:
|
|
1244
|
+
logger.info("Guardian interrupted.")
|
|
1245
|
+
finally:
|
|
1246
|
+
if observer:
|
|
1247
|
+
try:
|
|
1248
|
+
observer.stop()
|
|
1249
|
+
observer.join(timeout=3)
|
|
1250
|
+
except Exception:
|
|
1251
|
+
pass
|
|
1252
|
+
logger.info("Observer stopped.")
|
|
1253
|
+
# When the guardian stops, show a clean session summary (AGENT.md polish requirement)
|
|
1254
|
+
try:
|
|
1255
|
+
logger.info(f"SESSION SUMMARY: {handler.safety_score.get_session_summary()}")
|
|
1256
|
+
logger.info(f"FINAL SAFETY: {handler.safety_score.get_summary()}")
|
|
1257
|
+
except Exception:
|
|
1258
|
+
pass
|
|
1259
|
+
# Session summary on stop (AGENT.md polish): give the user a clear recap
|
|
1260
|
+
# of what the guardian did during this run. Uses the handler's score if available.
|
|
1261
|
+
try:
|
|
1262
|
+
if handler and hasattr(handler, "safety_score"):
|
|
1263
|
+
summary = handler.safety_score.get_summary()
|
|
1264
|
+
logger.info(f"SESSION SUMMARY: {summary} | Total incidents this session: {len(handler.safety_score.incidents)}")
|
|
1265
|
+
print(f"\n[Guardian Session Summary]\n{summary}\nIncidents logged: {len(handler.safety_score.incidents)}")
|
|
1266
|
+
print("Review full activity in ~/.deadpush/guardian.log")
|
|
1267
|
+
except Exception:
|
|
1268
|
+
pass
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
# =============================================================================
|
|
1272
|
+
# Basic Auto-Start Support (systemd user / launchd)
|
|
1273
|
+
# Called / documented from protect for "survive reboots with minimal intervention"
|
|
1274
|
+
# =============================================================================
|
|
1275
|
+
def setup_autostart(repo_root: Path) -> str:
|
|
1276
|
+
"""Generate OS-specific auto-start configuration for the guardian daemon.
|
|
1277
|
+
|
|
1278
|
+
This helps fulfill "survive across sessions/reboots with minimal user intervention".
|
|
1279
|
+
|
|
1280
|
+
- On Linux: writes ~/.config/systemd/user/deadpush-guardian.service
|
|
1281
|
+
- On macOS: writes ~/Library/LaunchAgents/com.deadpush.guardian.plist
|
|
1282
|
+
|
|
1283
|
+
Returns a string with the file path + exact commands the user should run to enable it.
|
|
1284
|
+
Safe to call multiple times (idempotent overwrite).
|
|
1285
|
+
Does not auto-enable (user must run the printed commands, for safety/permissions).
|
|
1286
|
+
"""
|
|
1287
|
+
import sys as _sys
|
|
1288
|
+
home = Path.home()
|
|
1289
|
+
exe = _sys.executable # use the exact python that has deadpush installed
|
|
1290
|
+
|
|
1291
|
+
if _sys.platform.startswith("linux"):
|
|
1292
|
+
unit_dir = home / ".config" / "systemd" / "user"
|
|
1293
|
+
unit_dir.mkdir(parents=True, exist_ok=True)
|
|
1294
|
+
unit_path = unit_dir / "deadpush-guardian.service"
|
|
1295
|
+
content = f"""[Unit]
|
|
1296
|
+
Description=deadpush AI Agent Guardian - persistent background protection for vibe coding
|
|
1297
|
+
After=network.target
|
|
1298
|
+
|
|
1299
|
+
[Service]
|
|
1300
|
+
Type=simple
|
|
1301
|
+
ExecStart={exe} -m deadpush.cli guard --daemon
|
|
1302
|
+
Restart=always
|
|
1303
|
+
RestartSec=5
|
|
1304
|
+
WorkingDirectory={repo_root}
|
|
1305
|
+
# Nice low priority so it doesn't interfere with agents
|
|
1306
|
+
Nice=10
|
|
1307
|
+
# Inherit PATH so 'deadpush' etc work if needed
|
|
1308
|
+
Environment="PATH=/usr/local/bin:/usr/bin:/bin:{home}/.local/bin"
|
|
1309
|
+
|
|
1310
|
+
[Install]
|
|
1311
|
+
WantedBy=default.target
|
|
1312
|
+
"""
|
|
1313
|
+
unit_path.write_text(content)
|
|
1314
|
+
return f"""Linux systemd --user unit written:
|
|
1315
|
+
{unit_path}
|
|
1316
|
+
|
|
1317
|
+
To enable auto-start on login / reboot (run these once):
|
|
1318
|
+
systemctl --user daemon-reload
|
|
1319
|
+
systemctl --user enable --now deadpush-guardian.service
|
|
1320
|
+
|
|
1321
|
+
Useful commands:
|
|
1322
|
+
systemctl --user status deadpush-guardian.service
|
|
1323
|
+
journalctl --user -u deadpush-guardian -f # live logs (file logs also at ~/.deadpush/guardian.log)
|
|
1324
|
+
systemctl --user stop deadpush-guardian.service
|
|
1325
|
+
"""
|
|
1326
|
+
|
|
1327
|
+
elif _sys.platform == "darwin":
|
|
1328
|
+
plist_dir = home / "Library" / "LaunchAgents"
|
|
1329
|
+
plist_dir.mkdir(parents=True, exist_ok=True)
|
|
1330
|
+
plist_path = plist_dir / "com.deadpush.guardian.plist"
|
|
1331
|
+
log_dir = home / ".deadpush"
|
|
1332
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
1333
|
+
content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
1334
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1335
|
+
<plist version="1.0">
|
|
1336
|
+
<dict>
|
|
1337
|
+
<key>Label</key>
|
|
1338
|
+
<string>com.deadpush.guardian</string>
|
|
1339
|
+
<key>ProgramArguments</key>
|
|
1340
|
+
<array>
|
|
1341
|
+
<string>{exe}</string>
|
|
1342
|
+
<string>-m</string>
|
|
1343
|
+
<string>deadpush.cli</string>
|
|
1344
|
+
<string>guard</string>
|
|
1345
|
+
<string>--daemon</string>
|
|
1346
|
+
</array>
|
|
1347
|
+
<key>WorkingDirectory</key>
|
|
1348
|
+
<string>{repo_root}</string>
|
|
1349
|
+
<key>RunAtLoad</key>
|
|
1350
|
+
<true/>
|
|
1351
|
+
<key>KeepAlive</key>
|
|
1352
|
+
<dict>
|
|
1353
|
+
<key>SuccessfulExit</key>
|
|
1354
|
+
<false/>
|
|
1355
|
+
</dict>
|
|
1356
|
+
<key>ThrottleInterval</key>
|
|
1357
|
+
<integer>10</integer>
|
|
1358
|
+
<key>StandardOutPath</key>
|
|
1359
|
+
<string>{log_dir}/guardian.launchd.out.log</string>
|
|
1360
|
+
<key>StandardErrorPath</key>
|
|
1361
|
+
<string>{log_dir}/guardian.launchd.err.log</string>
|
|
1362
|
+
</dict>
|
|
1363
|
+
</plist>
|
|
1364
|
+
"""
|
|
1365
|
+
plist_path.write_text(content)
|
|
1366
|
+
return f"""macOS launchd plist written:
|
|
1367
|
+
{plist_path}
|
|
1368
|
+
|
|
1369
|
+
To load (start now + on login/reboot):
|
|
1370
|
+
launchctl load {plist_path}
|
|
1371
|
+
|
|
1372
|
+
To unload / stop:
|
|
1373
|
+
launchctl unload {plist_path}
|
|
1374
|
+
|
|
1375
|
+
Logs: tail -f {log_dir}/guardian.launchd.*.log
|
|
1376
|
+
(Also file logs at {log_dir}/guardian.log )
|
|
1377
|
+
"""
|
|
1378
|
+
|
|
1379
|
+
else:
|
|
1380
|
+
return f"""Auto-start unit generation not supported on this platform ({_sys.platform}).
|
|
1381
|
+
You can still achieve "survive reboot" by:
|
|
1382
|
+
- Adding `deadpush guard --daemon` to your shell's startup (~/.bashrc, ~/.zshrc, etc) with nohup or similar, or
|
|
1383
|
+
- Using your distro's service manager manually pointing at: {exe} -m deadpush.cli guard --daemon
|
|
1384
|
+
- Or cron with @reboot (advanced).
|
|
1385
|
+
See `deadpush guard --daemon` for the core persistent mode.
|
|
1386
|
+
"""
|