opsentry 1.8.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.
Files changed (61) hide show
  1. opsentry/VERSION +1 -0
  2. opsentry/__init__.py +5 -0
  3. opsentry/cli.py +295 -0
  4. opsentry/config/__init__.py +0 -0
  5. opsentry/config/generate.py +414 -0
  6. opsentry/config/hook_templates/block-dangerous-commands.sh.j2 +102 -0
  7. opsentry/config/hook_templates/block-data-exfiltration.sh.j2 +94 -0
  8. opsentry/config/hook_templates/block-environment-escape.sh.j2 +99 -0
  9. opsentry/config/hook_templates/block-git-commands.sh.j2 +46 -0
  10. opsentry/config/hook_templates/block-package-install.sh.j2 +95 -0
  11. opsentry/config/hook_templates/block-pii-leakage.sh.j2 +100 -0
  12. opsentry/config/hook_templates/block-scope-escape.sh.j2 +115 -0
  13. opsentry/config/hook_templates/block-sensitive-files.sh.j2 +97 -0
  14. opsentry/config/init_wizard.py +201 -0
  15. opsentry/config/rule_texts/01_sensitive_files.md +15 -0
  16. opsentry/config/rule_texts/02_client_data.md +10 -0
  17. opsentry/config/rule_texts/03_credentials.md +17 -0
  18. opsentry/config/rule_texts/04_sql_safety.md +33 -0
  19. opsentry/config/rule_texts/05_dangerous_code.md +29 -0
  20. opsentry/config/rule_texts/06_frontend_xss.md +9 -0
  21. opsentry/config/rule_texts/07_git_operations_block_all.md +13 -0
  22. opsentry/config/rule_texts/07_git_operations_read_only.md +18 -0
  23. opsentry/config/rule_texts/08_destructive_commands.md +12 -0
  24. opsentry/config/rule_texts/09_database_safety.md +17 -0
  25. opsentry/config/rule_texts/10_network_requests.md +10 -0
  26. opsentry/config/rule_texts/11_code_quality.md +13 -0
  27. opsentry/config/rule_texts/12_data_exfiltration.md +10 -0
  28. opsentry/config/rule_texts/13_package_install.md +9 -0
  29. opsentry/config/rule_texts/14_scope_boundaries.md +8 -0
  30. opsentry/config/rule_texts/15_env_isolation.md +9 -0
  31. opsentry/config/rule_texts/16_resource_limits.md +8 -0
  32. opsentry/config/rule_texts/17_compliance_privacy.md +9 -0
  33. opsentry/config/rule_texts/18_when_in_doubt.md +10 -0
  34. opsentry/config/schema.py +142 -0
  35. opsentry/config/templates/generic.yaml +84 -0
  36. opsentry/deployment/.claude/commands/code-health.md +42 -0
  37. opsentry/deployment/.claude/commands/governance-check.md +29 -0
  38. opsentry/deployment/.claude/commands/security-audit.md +41 -0
  39. opsentry/deployment/baseline.py +440 -0
  40. opsentry/deployment/blocklog_audit.py +497 -0
  41. opsentry/deployment/claude/CLAUDE.md +300 -0
  42. opsentry/deployment/claude/hooks/block-dangerous-commands.sh +106 -0
  43. opsentry/deployment/claude/hooks/block-data-exfiltration.sh +99 -0
  44. opsentry/deployment/claude/hooks/block-environment-escape.sh +96 -0
  45. opsentry/deployment/claude/hooks/block-git-commands.sh +37 -0
  46. opsentry/deployment/claude/hooks/block-package-install.sh +103 -0
  47. opsentry/deployment/claude/hooks/block-pii-leakage.sh +86 -0
  48. opsentry/deployment/claude/hooks/block-scope-escape.sh +210 -0
  49. opsentry/deployment/claude/hooks/block-sensitive-files.sh +97 -0
  50. opsentry/deployment/claude/settings.json +72 -0
  51. opsentry/deployment/install.sh +343 -0
  52. opsentry/deployment/patrol.sh +266 -0
  53. opsentry/deployment/test.sh +728 -0
  54. opsentry/deployment/update.sh +19 -0
  55. opsentry/deployment/verify.sh +201 -0
  56. opsentry-1.8.0.dist-info/METADATA +158 -0
  57. opsentry-1.8.0.dist-info/RECORD +61 -0
  58. opsentry-1.8.0.dist-info/WHEEL +5 -0
  59. opsentry-1.8.0.dist-info/entry_points.txt +2 -0
  60. opsentry-1.8.0.dist-info/licenses/LICENSE +190 -0
  61. opsentry-1.8.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,497 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Block log pattern analyzer for OpSentry.
4
+ Parses ~/.claude/guardrail-blocks.log (JSON-lines) and runs pattern detectors
5
+ that look for repeat-offender, rapid-burst, multi-hook coverage, exfiltration
6
+ cluster, and self-modification attempt signatures so patrol.sh can flag
7
+ suspicious activity beyond simple block counts. Each detector emits a
8
+ structured finding (info/warn/fail) consumed by patrol.sh in the same format
9
+ as baseline.py for visual consistency.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import datetime
16
+ import json
17
+ import sys
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Detection thresholds
23
+ # ---------------------------------------------------------------------------
24
+
25
+ # Repeat offender: same hook firing N+ times in the last hour.
26
+ REPEAT_OFFENDER_WARN = 5
27
+ REPEAT_OFFENDER_FAIL = 20
28
+
29
+ # Rapid burst: any 30-second window with N+ blocks.
30
+ RAPID_BURST_WINDOW_SECONDS = 30
31
+ RAPID_BURST_WARN = 5
32
+
33
+ # Multi-hook coverage: distinct hooks fired in the last hour.
34
+ MULTI_HOOK_WARN = 3
35
+ MULTI_HOOK_FAIL = 5
36
+
37
+ # Exfiltration cluster: sensitive-files + data-exfiltration within 5 minutes.
38
+ EXFIL_CLUSTER_WINDOW_SECONDS = 300
39
+
40
+ # Self-modification attempts (Attack 3 — guardrail self-destruction).
41
+ SELF_MOD_WARN = 1
42
+ SELF_MOD_FAIL = 3
43
+
44
+ DEFAULT_WINDOW_HOURS = 24
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Log parsing
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ @dataclass
53
+ class LogEntry:
54
+ """One parsed line from guardrail-blocks.log."""
55
+
56
+ timestamp: datetime.datetime
57
+ hook: str
58
+ reason: str
59
+ detail: str
60
+ user: str
61
+ raw: dict = field(default_factory=dict)
62
+
63
+
64
+ def _parse_timestamp(value: str) -> datetime.datetime | None:
65
+ """Parse an ISO-8601 UTC timestamp like 2026-04-11T14:30:00Z."""
66
+ if not value:
67
+ return None
68
+ try:
69
+ # Python 3.10's fromisoformat doesn't accept the trailing Z; strip it.
70
+ cleaned = value.rstrip("Z")
71
+ dt = datetime.datetime.fromisoformat(cleaned)
72
+ if dt.tzinfo is None:
73
+ dt = dt.replace(tzinfo=datetime.timezone.utc)
74
+ return dt
75
+ except (ValueError, TypeError):
76
+ return None
77
+
78
+
79
+ def parse_log(log_path: Path) -> list[LogEntry]:
80
+ """
81
+ Read a JSON-lines guardrail block log and return parsed entries.
82
+
83
+ Lines that are empty, not valid JSON, or missing a parseable timestamp
84
+ are silently skipped — the goal is best-effort analysis, not strict
85
+ validation.
86
+ """
87
+ entries: list[LogEntry] = []
88
+ if not log_path.is_file():
89
+ return entries
90
+
91
+ try:
92
+ raw = log_path.read_text(errors="ignore")
93
+ except OSError:
94
+ return entries
95
+
96
+ for line in raw.splitlines():
97
+ line = line.strip()
98
+ if not line:
99
+ continue
100
+ try:
101
+ obj = json.loads(line)
102
+ except (ValueError, json.JSONDecodeError):
103
+ continue
104
+ if not isinstance(obj, dict):
105
+ continue
106
+ ts = _parse_timestamp(obj.get("timestamp", ""))
107
+ if ts is None:
108
+ continue
109
+ entries.append(
110
+ LogEntry(
111
+ timestamp=ts,
112
+ hook=str(obj.get("hook", "")),
113
+ reason=str(obj.get("reason", "")),
114
+ detail=str(obj.get("detail", "")),
115
+ user=str(obj.get("user", "")),
116
+ raw=obj,
117
+ )
118
+ )
119
+ entries.sort(key=lambda e: e.timestamp)
120
+ return entries
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Detectors
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def _within_window(
129
+ entries: list[LogEntry], now: datetime.datetime, window_seconds: int
130
+ ) -> list[LogEntry]:
131
+ """Return entries whose timestamp is within `window_seconds` of `now`."""
132
+ cutoff = now - datetime.timedelta(seconds=window_seconds)
133
+ return [e for e in entries if e.timestamp >= cutoff]
134
+
135
+
136
+ def detect_total_stats(
137
+ entries: list[LogEntry], now: datetime.datetime, window_hours: int
138
+ ) -> dict:
139
+ """INFO finding with total count and last-window count."""
140
+ last_window = _within_window(entries, now, window_hours * 3600)
141
+ return {
142
+ "status": "info",
143
+ "check": "total_blocks",
144
+ "message": (
145
+ f"{len(entries)} total blocks logged "
146
+ f"({len(last_window)} in last {window_hours}h)"
147
+ ),
148
+ "details": {
149
+ "total": len(entries),
150
+ "last_window": len(last_window),
151
+ "window_hours": window_hours,
152
+ },
153
+ }
154
+
155
+
156
+ def detect_repeat_offender(
157
+ entries: list[LogEntry], now: datetime.datetime
158
+ ) -> list[dict]:
159
+ """Same hook firing N+ times in the last hour."""
160
+ findings: list[dict] = []
161
+ last_hour = _within_window(entries, now, 3600)
162
+ if not last_hour:
163
+ return findings
164
+
165
+ counts: dict[str, int] = {}
166
+ for entry in last_hour:
167
+ counts[entry.hook] = counts.get(entry.hook, 0) + 1
168
+
169
+ flagged = False
170
+ for hook, count in sorted(counts.items(), key=lambda kv: -kv[1]):
171
+ if count >= REPEAT_OFFENDER_FAIL:
172
+ findings.append(
173
+ {
174
+ "status": "fail",
175
+ "check": "repeat_offender",
176
+ "message": (
177
+ f"hook '{hook}' fired {count} times in last 1h "
178
+ f"(>= {REPEAT_OFFENDER_FAIL} = sustained attack signature)"
179
+ ),
180
+ "details": {"hook": hook, "count": count},
181
+ }
182
+ )
183
+ flagged = True
184
+ elif count >= REPEAT_OFFENDER_WARN:
185
+ findings.append(
186
+ {
187
+ "status": "warn",
188
+ "check": "repeat_offender",
189
+ "message": (
190
+ f"hook '{hook}' fired {count} times in last 1h "
191
+ f"(>= {REPEAT_OFFENDER_WARN} = repeated bypass attempts)"
192
+ ),
193
+ "details": {"hook": hook, "count": count},
194
+ }
195
+ )
196
+ flagged = True
197
+
198
+ if not flagged:
199
+ findings.append(
200
+ {
201
+ "status": "pass",
202
+ "check": "repeat_offender",
203
+ "message": f"no hook fired more than {REPEAT_OFFENDER_WARN} times in last 1h",
204
+ }
205
+ )
206
+ return findings
207
+
208
+
209
+ def detect_rapid_burst(entries: list[LogEntry]) -> list[dict]:
210
+ """Sliding 30-second window with N+ blocks anywhere in the log."""
211
+ findings: list[dict] = []
212
+ if len(entries) < RAPID_BURST_WARN:
213
+ findings.append(
214
+ {
215
+ "status": "pass",
216
+ "check": "rapid_burst",
217
+ "message": f"no {RAPID_BURST_WINDOW_SECONDS}s burst of {RAPID_BURST_WARN}+ blocks detected",
218
+ }
219
+ )
220
+ return findings
221
+
222
+ # Sliding window over sorted entries (parse_log sorts by timestamp).
223
+ left = 0
224
+ max_burst = 0
225
+ burst_start: datetime.datetime | None = None
226
+ burst_end: datetime.datetime | None = None
227
+ for right in range(len(entries)):
228
+ while (
229
+ entries[right].timestamp - entries[left].timestamp
230
+ ).total_seconds() > RAPID_BURST_WINDOW_SECONDS:
231
+ left += 1
232
+ window_size = right - left + 1
233
+ if window_size > max_burst:
234
+ max_burst = window_size
235
+ burst_start = entries[left].timestamp
236
+ burst_end = entries[right].timestamp
237
+
238
+ if max_burst >= RAPID_BURST_WARN and burst_start is not None and burst_end is not None:
239
+ delta = (burst_end - burst_start).total_seconds()
240
+ findings.append(
241
+ {
242
+ "status": "warn",
243
+ "check": "rapid_burst",
244
+ "message": (
245
+ f"{max_burst} blocks within {delta:.1f}s "
246
+ f"(>= {RAPID_BURST_WARN} in {RAPID_BURST_WINDOW_SECONDS}s = automated bypass attempt)"
247
+ ),
248
+ "details": {
249
+ "burst_size": max_burst,
250
+ "window_seconds": delta,
251
+ "first": burst_start.isoformat(),
252
+ "last": burst_end.isoformat(),
253
+ },
254
+ }
255
+ )
256
+ else:
257
+ findings.append(
258
+ {
259
+ "status": "pass",
260
+ "check": "rapid_burst",
261
+ "message": f"no {RAPID_BURST_WINDOW_SECONDS}s burst of {RAPID_BURST_WARN}+ blocks detected",
262
+ }
263
+ )
264
+ return findings
265
+
266
+
267
+ def detect_multi_hook_coverage(
268
+ entries: list[LogEntry], now: datetime.datetime
269
+ ) -> list[dict]:
270
+ """Number of distinct hooks fired in the last hour."""
271
+ findings: list[dict] = []
272
+ last_hour = _within_window(entries, now, 3600)
273
+ distinct = sorted({e.hook for e in last_hour if e.hook})
274
+ count = len(distinct)
275
+
276
+ if count >= MULTI_HOOK_FAIL:
277
+ findings.append(
278
+ {
279
+ "status": "fail",
280
+ "check": "multi_hook_coverage",
281
+ "message": (
282
+ f"{count} distinct hooks fired in last 1h "
283
+ f"(>= {MULTI_HOOK_FAIL} = systematic exploration): {', '.join(distinct)}"
284
+ ),
285
+ "details": {"hooks": distinct, "count": count},
286
+ }
287
+ )
288
+ elif count >= MULTI_HOOK_WARN:
289
+ findings.append(
290
+ {
291
+ "status": "warn",
292
+ "check": "multi_hook_coverage",
293
+ "message": (
294
+ f"{count} distinct hooks fired in last 1h "
295
+ f"(>= {MULTI_HOOK_WARN} = unusual breadth): {', '.join(distinct)}"
296
+ ),
297
+ "details": {"hooks": distinct, "count": count},
298
+ }
299
+ )
300
+ else:
301
+ findings.append(
302
+ {
303
+ "status": "pass",
304
+ "check": "multi_hook_coverage",
305
+ "message": f"{count} distinct hook(s) in last 1h (below threshold)",
306
+ }
307
+ )
308
+ return findings
309
+
310
+
311
+ def detect_exfil_cluster(entries: list[LogEntry]) -> list[dict]:
312
+ """sensitive-files + data-exfiltration within 5 minutes = exfil primitive."""
313
+ findings: list[dict] = []
314
+ sensitive = [e for e in entries if "sensitive-files" in e.hook]
315
+ exfil = [e for e in entries if "data-exfiltration" in e.hook]
316
+
317
+ if not sensitive or not exfil:
318
+ findings.append(
319
+ {
320
+ "status": "pass",
321
+ "check": "exfil_cluster",
322
+ "message": "no sensitive-files + data-exfiltration cluster detected",
323
+ }
324
+ )
325
+ return findings
326
+
327
+ # O(n*m) but n,m are bounded by log size; for huge logs we'd switch to a
328
+ # merge-walk on the sorted streams. Acceptable v1.
329
+ for s in sensitive:
330
+ for x in exfil:
331
+ delta = abs((s.timestamp - x.timestamp).total_seconds())
332
+ if delta <= EXFIL_CLUSTER_WINDOW_SECONDS:
333
+ findings.append(
334
+ {
335
+ "status": "fail",
336
+ "check": "exfil_cluster",
337
+ "message": (
338
+ f"sensitive-files ({s.timestamp.isoformat()}) + "
339
+ f"data-exfiltration ({x.timestamp.isoformat()}) within "
340
+ f"{delta:.0f}s (canonical exfil primitive)"
341
+ ),
342
+ "details": {
343
+ "sensitive_at": s.timestamp.isoformat(),
344
+ "exfil_at": x.timestamp.isoformat(),
345
+ "delta_seconds": delta,
346
+ },
347
+ }
348
+ )
349
+ return findings # one finding is enough; don't spam
350
+
351
+ findings.append(
352
+ {
353
+ "status": "pass",
354
+ "check": "exfil_cluster",
355
+ "message": "sensitive-files and data-exfiltration both seen but never within 5 minutes",
356
+ }
357
+ )
358
+ return findings
359
+
360
+
361
+ def detect_self_mod_attempts(entries: list[LogEntry]) -> list[dict]:
362
+ """block-scope-escape blocks tagged with Self-modification (Attack 3)."""
363
+ findings: list[dict] = []
364
+ matches = [
365
+ e
366
+ for e in entries
367
+ if "scope-escape" in e.hook and "self-modification" in e.reason.lower()
368
+ ]
369
+ count = len(matches)
370
+
371
+ if count >= SELF_MOD_FAIL:
372
+ findings.append(
373
+ {
374
+ "status": "fail",
375
+ "check": "self_mod_attempts",
376
+ "message": (
377
+ f"{count} block-scope-escape self-modification attempts logged "
378
+ f"(>= {SELF_MOD_FAIL} = active Attack 3 — guardrail self-destruction)"
379
+ ),
380
+ "details": {"count": count},
381
+ }
382
+ )
383
+ elif count >= SELF_MOD_WARN:
384
+ findings.append(
385
+ {
386
+ "status": "warn",
387
+ "check": "self_mod_attempts",
388
+ "message": (
389
+ f"{count} block-scope-escape self-modification attempt(s) logged — "
390
+ "review the agent's recent actions"
391
+ ),
392
+ "details": {"count": count},
393
+ }
394
+ )
395
+ else:
396
+ findings.append(
397
+ {
398
+ "status": "pass",
399
+ "check": "self_mod_attempts",
400
+ "message": "no guardrail self-modification attempts logged",
401
+ }
402
+ )
403
+ return findings
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # Top-level analyze
408
+ # ---------------------------------------------------------------------------
409
+
410
+
411
+ def analyze(
412
+ entries: list[LogEntry],
413
+ now: datetime.datetime | None = None,
414
+ window_hours: int = DEFAULT_WINDOW_HOURS,
415
+ ) -> tuple[bool, list[dict]]:
416
+ """
417
+ Run all detectors against `entries` and return (any_fail, findings).
418
+
419
+ `now` defaults to the latest entry's timestamp (or wall-clock UTC if the
420
+ log is empty), so the analyzer is deterministic for replayed logs and
421
+ test fixtures.
422
+ """
423
+ if now is None:
424
+ if entries:
425
+ now = entries[-1].timestamp
426
+ else:
427
+ now = datetime.datetime.now(datetime.timezone.utc)
428
+
429
+ findings: list[dict] = [detect_total_stats(entries, now, window_hours)]
430
+ findings.extend(detect_repeat_offender(entries, now))
431
+ findings.extend(detect_rapid_burst(entries))
432
+ findings.extend(detect_multi_hook_coverage(entries, now))
433
+ findings.extend(detect_exfil_cluster(entries))
434
+ findings.extend(detect_self_mod_attempts(entries))
435
+
436
+ any_fail = any(f["status"] == "fail" for f in findings)
437
+ return any_fail, findings
438
+
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # CLI
442
+ # ---------------------------------------------------------------------------
443
+
444
+
445
+ def _print_findings(findings: list[dict], verbose: bool = False) -> None:
446
+ for f in findings:
447
+ status = f["status"]
448
+ if not verbose and status == "pass":
449
+ continue
450
+ check = f["check"]
451
+ message = f["message"]
452
+ print(f" {status.upper()} {check} — {message}")
453
+
454
+
455
+ def main() -> int:
456
+ parser = argparse.ArgumentParser(
457
+ description="OpSentry block log pattern analyzer.",
458
+ )
459
+ parser.add_argument(
460
+ "command",
461
+ choices=["analyze"],
462
+ help="The only supported command for now.",
463
+ )
464
+ parser.add_argument(
465
+ "--log-file",
466
+ required=True,
467
+ type=Path,
468
+ help="Path to guardrail-blocks.log (JSON-lines).",
469
+ )
470
+ parser.add_argument(
471
+ "--window-hours",
472
+ type=int,
473
+ default=DEFAULT_WINDOW_HOURS,
474
+ help=f"Recent-window size for stats (default {DEFAULT_WINDOW_HOURS}).",
475
+ )
476
+ parser.add_argument(
477
+ "--quiet",
478
+ action="store_true",
479
+ help="Hide PASS findings; only show INFO/WARN/FAIL.",
480
+ )
481
+ parser.add_argument(
482
+ "--verbose",
483
+ action="store_true",
484
+ help="Show all findings including PASS.",
485
+ )
486
+ args = parser.parse_args()
487
+
488
+ entries = parse_log(args.log_file)
489
+ any_fail, findings = analyze(entries, window_hours=args.window_hours)
490
+
491
+ # Default behavior: hide PASS, show others. --verbose forces PASS too.
492
+ _print_findings(findings, verbose=args.verbose)
493
+ return 1 if any_fail else 0
494
+
495
+
496
+ if __name__ == "__main__":
497
+ sys.exit(main())