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/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} &middot; 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">&#x21bb; 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
+ """