shadow-sentinel 1.0.1__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.
@@ -0,0 +1,7 @@
1
+ """
2
+ Shadow Sentinel - Autonomous OS-Level Revision & Backup Daemon
3
+ """
4
+ __version__ = "1.0.1"
5
+ __author__ = "automatician (Torsten Frieser)"
6
+ __copyright__ = "Copyright (c) 2026 automatician (Torsten Frieser), Germany / Saxony / Zittau"
7
+ __license__ = "CC-BY-SA-3.0"
@@ -0,0 +1,926 @@
1
+ #!/usr/bin/env python3
2
+ # ==============================================================================
3
+ # SPDX-License-Identifier: CC-BY-SA-3.0
4
+ #
5
+ # Shadow Sentinel - Autonomous OS-Level Revision & Backup Guard
6
+ #
7
+ # Copyright (c) 2026 automatician (Torsten Frieser), Germany / Saxony / Zittau.
8
+ # All rights reserved.
9
+ #
10
+ # Licensed under the Creative Commons Attribution-ShareAlike 3.0 Germany
11
+ # (CC BY-SA 3.0 DE). You may redistribute and modify this software under the
12
+ # terms of the CC BY-SA 3.0 license.
13
+ # ==============================================================================
14
+
15
+ """
16
+ Shadow Sentinel Core Engine
17
+ Single Source of Truth - Linux FHS Compliant Open-Source Daemon
18
+ """
19
+
20
+ import os
21
+ import sys
22
+ import time
23
+ import json
24
+ import shutil
25
+ import hashlib
26
+ import difflib
27
+ import ctypes
28
+ import struct
29
+ import select
30
+ import signal
31
+ import re
32
+ import subprocess
33
+
34
+ __version__ = "1.0.1"
35
+ __author__ = "automatician (Torsten Frieser)"
36
+ __copyright__ = "Copyright (c) 2026 automatician (Torsten Frieser), Germany / Saxony / Zittau"
37
+ __license__ = "CC-BY-SA-3.0"
38
+
39
+ def get_storage_dirs():
40
+ """
41
+ Resolves storage directory standard-compliantly:
42
+ 1. Environment variable SENTINEL_STORAGE_DIR
43
+ 2. Optional config: /etc/shadow-sentinel/config.json or ~/.config/shadow-sentinel/config.json
44
+ 3. Linux FHS default: /var/lib/shadow-sentinel (root) or ~/.local/share/shadow-sentinel (user)
45
+ """
46
+ env_dir = os.getenv("SENTINEL_STORAGE_DIR")
47
+ if env_dir:
48
+ base = os.path.abspath(env_dir)
49
+ else:
50
+ cfg_file = "/etc/shadow-sentinel/config.json" if os.geteuid() == 0 else os.path.expanduser("~/.config/shadow-sentinel/config.json")
51
+ custom_base = None
52
+ if os.path.exists(cfg_file):
53
+ try:
54
+ with open(cfg_file, "r", encoding="utf-8") as f:
55
+ data = json.load(f)
56
+ if "storage_dir" in data:
57
+ custom_base = data["storage_dir"]
58
+ except Exception:
59
+ pass
60
+
61
+ if custom_base:
62
+ base = os.path.abspath(custom_base)
63
+ elif os.geteuid() == 0:
64
+ base = "/var/lib/shadow-sentinel"
65
+ else:
66
+ base = os.path.expanduser("~/.local/share/shadow-sentinel")
67
+
68
+ backup_dir = os.path.join(base, "backups")
69
+ vault_dir = os.path.join(base, ".shadow_vault")
70
+ index_file = os.path.join(backup_dir, "sentinel_index.json")
71
+ return base, backup_dir, vault_dir, index_file
72
+
73
+ CENTRAL_BASE, BACKUP_DIR, VAULT_DIR, INDEX_FILE = get_storage_dirs()
74
+
75
+ MAX_VERSIONS_PER_FILE = 10
76
+ MAX_BACKUP_AGE_DAYS = 14
77
+ MAX_LOG_AGE_DAYS = 30
78
+ MAX_BACKUP_DIR_MB = 500
79
+ PRUNE_INTERVAL_SEC = 21600
80
+ COOLDOWN_SEC = 3
81
+
82
+ DEFAULT_MAX_FILE_SIZE_MB = 5
83
+
84
+ os.makedirs(BACKUP_DIR, exist_ok=True)
85
+ os.makedirs(VAULT_DIR, exist_ok=True)
86
+
87
+ libc = ctypes.CDLL("libc.so.6", use_errno=True)
88
+
89
+ IN_CLOSE_WRITE = 0x00000008
90
+ IN_MOVED_FROM = 0x00000040
91
+ IN_MOVED_TO = 0x00000080
92
+ IN_CREATE = 0x00000100
93
+ IN_DELETE = 0x00000200
94
+ IN_ISDIR = 0x40000000
95
+
96
+ EVENT_MASK = IN_CLOSE_WRITE | IN_DELETE | IN_MOVED_FROM | IN_MOVED_TO | IN_CREATE
97
+
98
+ running = True
99
+ last_prune_ts = 0
100
+ pending_writes = {}
101
+ marker_targets = {}
102
+
103
+ def signal_handler(sig, frame):
104
+ global running
105
+ print(f"\n[SENTINEL] Signal {sig} received. Terminating guard service...")
106
+ running = False
107
+
108
+ signal.signal(signal.SIGTERM, signal_handler)
109
+ signal.signal(signal.SIGINT, signal_handler)
110
+
111
+ def is_sentinel_internal(path: str) -> bool:
112
+ """Prevents recursion loops: vault and backup directories do not monitor themselves."""
113
+ abs_p = os.path.abspath(path)
114
+ if abs_p == BACKUP_DIR or abs_p.startswith(BACKUP_DIR + os.sep):
115
+ return True
116
+ if abs_p == VAULT_DIR or abs_p.startswith(VAULT_DIR + os.sep):
117
+ return True
118
+ if abs_p == "/etc/shadow-sentinel" or abs_p.startswith("/etc/shadow-sentinel" + os.sep):
119
+ return True
120
+ return False
121
+
122
+ def load_index() -> list:
123
+ """Loads index and automatically discovers unindexed .bak files on the drive."""
124
+ entries = []
125
+ if os.path.exists(INDEX_FILE):
126
+ try:
127
+ with open(INDEX_FILE, "r", encoding="utf-8") as f:
128
+ entries = json.load(f)
129
+ except Exception:
130
+ entries = []
131
+
132
+ # Autonomer Reconcile-Check: Prรผft ob .bak Dateien auf der Platte fehlen
133
+ existing_baks = {item.get("bak_file") for item in entries}
134
+ pattern = re.compile(r"^(.*?)\.(\d{8}_\d{6})\.(bak|deleted\.bak)$")
135
+ log_pattern = re.compile(r"\[SENTINEL_AUTO_(?:BACKUP|DELETED)\]\s+(?:DATEI|FILE):\s+(\S+).*?BACKUP:\s+(\S+)")
136
+
137
+ file_map = {}
138
+ if os.path.exists(BACKUP_DIR):
139
+ for fname in os.listdir(BACKUP_DIR):
140
+ if fname.startswith("diff-") and fname.endswith(".log"):
141
+ try:
142
+ with open(os.path.join(BACKUP_DIR, fname), "r", encoding="utf-8", errors="replace") as lf:
143
+ for line in lf:
144
+ m = log_pattern.search(line)
145
+ if m:
146
+ file_map[m.group(2)] = m.group(1)
147
+ except Exception:
148
+ pass
149
+
150
+ modified = False
151
+ if os.path.exists(BACKUP_DIR):
152
+ for fname in sorted(os.listdir(BACKUP_DIR)):
153
+ if fname in existing_baks:
154
+ continue
155
+ match = pattern.match(fname)
156
+ if match:
157
+ fpath = os.path.join(BACKUP_DIR, fname)
158
+ orig_name = match.group(1)
159
+ ts_str = match.group(2)
160
+ orig_path = file_map.get(fname, os.path.join(CENTRAL_BASE, orig_name))
161
+ diff_file = fname.replace(".bak", ".diff")
162
+ if not os.path.exists(os.path.join(BACKUP_DIR, diff_file)):
163
+ diff_file = ""
164
+
165
+ try:
166
+ formatted_ts = time.strftime("%Y-%m-%d %H:%M:%S", time.strptime(ts_str, "%Y%m%d_%H%M%S"))
167
+ except Exception:
168
+ formatted_ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(fpath)))
169
+
170
+ entries.append({
171
+ "id": len(entries) + 1,
172
+ "orig_path": os.path.abspath(orig_path),
173
+ "bak_file": fname,
174
+ "diff_file": diff_file,
175
+ "timestamp": formatted_ts,
176
+ "size_bytes": os.path.getsize(fpath)
177
+ })
178
+ existing_baks.add(fname)
179
+ modified = True
180
+
181
+ if modified or not os.path.exists(INDEX_FILE):
182
+ entries.sort(key=lambda x: x.get("timestamp", ""))
183
+ for idx, item in enumerate(entries, 1):
184
+ item["id"] = idx
185
+ save_index(entries)
186
+
187
+ return entries
188
+
189
+ def save_index(index_list: list):
190
+ try:
191
+ with open(INDEX_FILE, "w", encoding="utf-8") as f:
192
+ json.dump(index_list, f, indent=2, ensure_ascii=False)
193
+ except Exception as e:
194
+ print(f"[SENTINEL-INDEX-ERR] {e}")
195
+
196
+ def record_backup(orig_path: str, bak_name: str, diff_name: str, size_bytes: int):
197
+ idx = load_index()
198
+ next_id = max([item.get("id", 0) for item in idx], default=0) + 1
199
+
200
+ entry = {
201
+ "id": next_id,
202
+ "orig_path": os.path.abspath(orig_path),
203
+ "bak_file": bak_name,
204
+ "diff_file": diff_name,
205
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
206
+ "size_bytes": size_bytes
207
+ }
208
+ idx.append(entry)
209
+ save_index(idx)
210
+
211
+ def discover_markers() -> dict:
212
+ targets = {}
213
+ cmd = ["find", "/", "-name", "sentinel_marker", "-o", "-name", ".sentinel_marker"]
214
+ try:
215
+ res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
216
+ lines = [line.strip() for line in res.stdout.splitlines() if line.strip()]
217
+ except Exception as e:
218
+ print(f"[SENTINEL] Find error: {e}")
219
+ lines = []
220
+
221
+ for mpath in lines:
222
+ if not os.path.isfile(mpath):
223
+ continue
224
+ watch_root = os.path.abspath(os.path.dirname(mpath))
225
+ if is_sentinel_internal(watch_root):
226
+ continue
227
+
228
+ limit_mb = DEFAULT_MAX_FILE_SIZE_MB
229
+ try:
230
+ content = ""
231
+ with open(mpath, "r", encoding="utf-8") as f:
232
+ content = f.read().strip()
233
+
234
+ if not content:
235
+ with open(mpath, "w", encoding="utf-8") as f:
236
+ f.write(f"{DEFAULT_MAX_FILE_SIZE_MB}\n")
237
+ limit_mb = DEFAULT_MAX_FILE_SIZE_MB
238
+ else:
239
+ parsed = int(float(content))
240
+ if parsed > 0:
241
+ limit_mb = parsed
242
+ except Exception:
243
+ limit_mb = DEFAULT_MAX_FILE_SIZE_MB
244
+
245
+ targets[watch_root] = limit_mb * 1024 * 1024
246
+
247
+ return targets
248
+
249
+ def find_nearest_marker(start_dir: str):
250
+ cur = os.path.abspath(start_dir)
251
+ while True:
252
+ m1 = os.path.join(cur, "sentinel_marker")
253
+ m2 = os.path.join(cur, ".sentinel_marker")
254
+ if os.path.isfile(m1):
255
+ return cur, m1
256
+ if os.path.isfile(m2):
257
+ return cur, m2
258
+ parent = os.path.dirname(cur)
259
+ if parent == cur:
260
+ break
261
+ cur = parent
262
+ return None, None
263
+
264
+ def get_sha256(filepath: str) -> str:
265
+ try:
266
+ h = hashlib.sha256()
267
+ with open(filepath, "rb") as f:
268
+ while chunk := f.read(65536):
269
+ h.update(chunk)
270
+ return h.hexdigest()
271
+ except Exception:
272
+ return ""
273
+
274
+ def is_monitored_file(path: str) -> bool:
275
+ base = os.path.basename(path)
276
+ if base in ("sentinel_marker", ".sentinel_marker"):
277
+ return False
278
+ if is_sentinel_internal(path):
279
+ return False
280
+ return True
281
+
282
+ def get_vault_path(abs_path: str) -> str:
283
+ clean_path = os.path.abspath(abs_path).lstrip("/")
284
+ return os.path.join(VAULT_DIR, clean_path)
285
+
286
+ def get_max_size_for_file(abs_path: str) -> int:
287
+ matched_root = None
288
+ matched_size = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024
289
+ abs_p = os.path.abspath(abs_path)
290
+ for root, size in marker_targets.items():
291
+ if abs_p == root or abs_p.startswith(root + "/"):
292
+ if matched_root is None or len(root) > len(matched_root):
293
+ matched_root = root
294
+ matched_size = size
295
+ return matched_size
296
+
297
+ def auto_prune_backups():
298
+ global last_prune_ts
299
+ now = time.time()
300
+ last_prune_ts = now
301
+ print("[SENTINEL-PRUNER] Starting automated backup pruning...")
302
+
303
+ deleted_count = 0
304
+ freed_bytes = 0
305
+
306
+ pattern = re.compile(r"^(.*?)\.(\d{8}_\d{6})\.(bak|diff|deleted\.bak)$")
307
+ file_groups = {}
308
+
309
+ for fname in os.listdir(BACKUP_DIR):
310
+ fpath = os.path.join(BACKUP_DIR, fname)
311
+ if not os.path.isfile(fpath):
312
+ continue
313
+
314
+ if fname.startswith("diff-") and fname.endswith(".log"):
315
+ try:
316
+ st = os.stat(fpath)
317
+ if (now - st.st_mtime) > (MAX_LOG_AGE_DAYS * 86400):
318
+ freed_bytes += st.st_size
319
+ os.remove(fpath)
320
+ deleted_count += 1
321
+ except Exception:
322
+ pass
323
+ continue
324
+
325
+ match = pattern.match(fname)
326
+ if match:
327
+ orig_name = match.group(1)
328
+ ext_type = match.group(3)
329
+ key = (orig_name, ext_type)
330
+ if key not in file_groups:
331
+ file_groups[key] = []
332
+ file_groups[key].append((fname, fpath, os.path.getmtime(fpath), os.path.getsize(fpath)))
333
+
334
+ for (orig_name, ext_type), items in file_groups.items():
335
+ items.sort(key=lambda x: x[2], reverse=True)
336
+
337
+ for idx, (fname, fpath, mtime, size) in enumerate(items):
338
+ should_delete = False
339
+ if idx >= MAX_VERSIONS_PER_FILE:
340
+ should_delete = True
341
+ elif idx > 0 and (now - mtime) > (MAX_BACKUP_AGE_DAYS * 86400):
342
+ should_delete = True
343
+
344
+ if should_delete:
345
+ try:
346
+ os.remove(fpath)
347
+ deleted_count += 1
348
+ freed_bytes += size
349
+ except Exception:
350
+ pass
351
+
352
+ total_size = sum(os.path.getsize(os.path.join(BACKUP_DIR, f)) for f in os.listdir(BACKUP_DIR) if os.path.isfile(os.path.join(BACKUP_DIR, f)))
353
+ max_bytes = MAX_BACKUP_DIR_MB * 1024 * 1024
354
+
355
+ if total_size > max_bytes:
356
+ all_backups = []
357
+ for fname in os.listdir(BACKUP_DIR):
358
+ fpath = os.path.join(BACKUP_DIR, fname)
359
+ if os.path.isfile(fpath):
360
+ all_backups.append((fpath, os.path.getmtime(fpath), os.path.getsize(fpath)))
361
+ all_backups.sort(key=lambda x: x[1])
362
+ for fpath, _, size in all_backups:
363
+ if total_size <= max_bytes * 0.8:
364
+ break
365
+ try:
366
+ os.remove(fpath)
367
+ total_size -= size
368
+ freed_bytes += size
369
+ deleted_count += 1
370
+ except Exception:
371
+ pass
372
+
373
+ freed_mb = round(freed_bytes / (1024 * 1024), 2)
374
+ print(f"[SENTINEL-PRUNER] Pruning complete: {deleted_count} stale versions deleted ({freed_mb} MB freed).")
375
+
376
+ def sync_initial_vault():
377
+ print(f"[SENTINEL] Synchronizing shadow vault (storage base: {CENTRAL_BASE})...")
378
+ count = 0
379
+ ejected_count = 0
380
+
381
+ for root_dir, max_bytes in marker_targets.items():
382
+ if not os.path.exists(root_dir) or is_sentinel_internal(root_dir):
383
+ continue
384
+ for root, dirs, files in os.walk(root_dir):
385
+ dirs[:] = [d for d in dirs if not is_sentinel_internal(os.path.join(root, d))]
386
+ for f in files:
387
+ full_path = os.path.join(root, f)
388
+ if not is_monitored_file(full_path):
389
+ continue
390
+
391
+ try:
392
+ cur_size = os.path.getsize(full_path)
393
+ except Exception:
394
+ continue
395
+
396
+ vault_path = get_vault_path(full_path)
397
+
398
+ if cur_size > max_bytes:
399
+ if os.path.exists(vault_path):
400
+ try:
401
+ os.remove(vault_path)
402
+ ejected_count += 1
403
+ except Exception:
404
+ pass
405
+ continue
406
+
407
+ os.makedirs(os.path.dirname(vault_path), exist_ok=True)
408
+ if not os.path.exists(vault_path) or get_sha256(full_path) != get_sha256(vault_path):
409
+ shutil.copy2(full_path, vault_path)
410
+ count += 1
411
+
412
+ print(f"[SENTINEL] Shadow vault ready ({count} files tracked, {ejected_count} exceeded limit and ejected).")
413
+ auto_prune_backups()
414
+
415
+ def handle_file_written(abs_path: str):
416
+ if not os.path.exists(abs_path) or not is_monitored_file(abs_path):
417
+ return
418
+
419
+ vault_path = get_vault_path(abs_path)
420
+ max_bytes = get_max_size_for_file(abs_path)
421
+
422
+ try:
423
+ cur_size = os.path.getsize(abs_path)
424
+ except Exception:
425
+ return
426
+
427
+ if cur_size > max_bytes:
428
+ if os.path.exists(vault_path):
429
+ try:
430
+ os.remove(vault_path)
431
+ size_mb = round(cur_size / (1024 * 1024), 2)
432
+ limit_mb = round(max_bytes / (1024 * 1024), 1)
433
+ print(f"[SENTINEL] SIZE CEILING EXCEEDED ({size_mb} MB > {limit_mb} MB): {abs_path} ejected from shadow vault.")
434
+ except Exception:
435
+ pass
436
+ return
437
+
438
+ os.makedirs(os.path.dirname(vault_path), exist_ok=True)
439
+ new_hash = get_sha256(abs_path)
440
+ if not new_hash:
441
+ return
442
+
443
+ ts = time.strftime("%Y%m%d_%H%M%S")
444
+ base_name = os.path.basename(abs_path)
445
+
446
+ if os.path.exists(vault_path):
447
+ old_hash = get_sha256(vault_path)
448
+ if old_hash != new_hash:
449
+ bak_name = f"{base_name}.{ts}.bak"
450
+ bak_path = os.path.join(BACKUP_DIR, bak_name)
451
+ shutil.copy2(vault_path, bak_path)
452
+
453
+ diff_name = f"{base_name}.{ts}.diff"
454
+ diff_path = os.path.join(BACKUP_DIR, diff_name)
455
+ try:
456
+ with open(vault_path, "r", encoding="utf-8", errors="replace") as fo, \
457
+ open(abs_path, "r", encoding="utf-8", errors="replace") as fn:
458
+ old_lines = fo.readlines()
459
+ new_lines = fn.readlines()
460
+ diff = list(difflib.unified_diff(
461
+ old_lines, new_lines,
462
+ fromfile=f"CURRENT: {abs_path}",
463
+ tofile=f"TARGET: {abs_path}",
464
+ lineterm="\n"
465
+ ))
466
+ if diff:
467
+ with open(diff_path, "w", encoding="utf-8") as df:
468
+ df.write(f"=== SENTINEL AUTO-DIFF: {abs_path} ===\n")
469
+ df.write(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')} | Hash: {new_hash[:12]}\n\n")
470
+ df.writelines(diff)
471
+ except Exception:
472
+ pass
473
+
474
+ today_str = time.strftime("%Y%m%d")
475
+ with open(os.path.join(BACKUP_DIR, f"diff-{today_str}.log"), "a", encoding="utf-8") as lf:
476
+ lf.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [SENTINEL_AUTO_BACKUP] FILE: {abs_path} | SHA256: {new_hash[:12]} | BACKUP: {bak_name} | DIFF: {diff_name}\n")
477
+
478
+ record_backup(abs_path, bak_name, diff_name, cur_size)
479
+ print(f"[SENTINEL] MODIFICATION BACKED UP: {abs_path} -> {bak_name}")
480
+ shutil.copy2(abs_path, vault_path)
481
+ else:
482
+ shutil.copy2(abs_path, vault_path)
483
+ print(f"[SENTINEL] NEW FILE REGISTERED IN VAULT: {abs_path}")
484
+
485
+ def handle_file_deleted(abs_path: str):
486
+ if not is_monitored_file(abs_path):
487
+ return
488
+
489
+ vault_path = get_vault_path(abs_path)
490
+ base_name = os.path.basename(abs_path)
491
+
492
+ if os.path.exists(vault_path):
493
+ ts = time.strftime("%Y%m%d_%H%M%S")
494
+ del_bak = f"{base_name}.{ts}.deleted.bak"
495
+ bak_path = os.path.join(BACKUP_DIR, del_bak)
496
+ shutil.copy2(vault_path, bak_path)
497
+
498
+ today_str = time.strftime("%Y%m%d")
499
+ with open(os.path.join(BACKUP_DIR, f"diff-{today_str}.log"), "a", encoding="utf-8") as lf:
500
+ lf.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [SENTINEL_AUTO_DELETED] FILE: {abs_path} | BACKUP: {del_bak}\n")
501
+
502
+ record_backup(abs_path, del_bak, "", 0)
503
+ os.remove(vault_path)
504
+ print(f"[SENTINEL] DELETION BACKED UP: {abs_path} -> {del_bak}")
505
+
506
+ def run_daemon():
507
+ global last_prune_ts, pending_writes, marker_targets
508
+ marker_targets = discover_markers()
509
+
510
+ print(f"[SENTINEL] Guard active. Storage base: {CENTRAL_BASE}")
511
+ if not marker_targets:
512
+ print("[SENTINEL] Info: No 'sentinel_marker' found. Create a marker with 'sentinel init' in any directory to begin monitoring.")
513
+ else:
514
+ print("[SENTINEL] Active marker roots:")
515
+ for root, max_bytes in marker_targets.items():
516
+ print(f" * {root} (Limit: {max_bytes // (1024*1024)} MB)")
517
+
518
+ sync_initial_vault()
519
+
520
+ inotify_fd = libc.inotify_init1(0)
521
+ if inotify_fd < 0:
522
+ print("[SENTINEL] Error: inotify_init1 failed!")
523
+ sys.exit(1)
524
+
525
+ wd_to_dir = {}
526
+
527
+ def add_watch_recursive(path: str):
528
+ if not os.path.exists(path) or is_sentinel_internal(path):
529
+ return
530
+ for root, dirs, _ in os.walk(path):
531
+ dirs[:] = [d for d in dirs if not is_sentinel_internal(os.path.join(root, d))]
532
+ wd = libc.inotify_add_watch(inotify_fd, root.encode("utf-8"), EVENT_MASK)
533
+ if wd >= 0:
534
+ wd_to_dir[wd] = root
535
+
536
+ for p in marker_targets.keys():
537
+ add_watch_recursive(p)
538
+
539
+ print(f"[SENTINEL] Inotify engine watching {len(wd_to_dir)} directories.")
540
+
541
+ poll = select.poll()
542
+ poll.register(inotify_fd, select.POLLIN)
543
+
544
+ while running:
545
+ try:
546
+ now = time.time()
547
+ if now - last_prune_ts > PRUNE_INTERVAL_SEC:
548
+ auto_prune_backups()
549
+
550
+ if pending_writes:
551
+ ready_to_write = []
552
+ for fpath, (first_ts, last_ts) in list(pending_writes.items()):
553
+ if (now - last_ts >= COOLDOWN_SEC) or (now - first_ts >= 30.0):
554
+ ready_to_write.append(fpath)
555
+
556
+ for fpath in ready_to_write:
557
+ del pending_writes[fpath]
558
+ handle_file_written(fpath)
559
+
560
+ events = poll.poll(500)
561
+ if not events:
562
+ continue
563
+
564
+ raw = os.read(inotify_fd, 65536)
565
+ offset = 0
566
+ while offset < len(raw):
567
+ wd, mask, cookie, length = struct.unpack_from("iIII", raw, offset)
568
+ offset += 16
569
+ name = raw[offset:offset + length].rstrip(b"\x00").decode("utf-8", errors="replace")
570
+ offset += length
571
+
572
+ dir_path = wd_to_dir.get(wd)
573
+ if not dir_path or not name:
574
+ continue
575
+
576
+ full_path = os.path.join(dir_path, name)
577
+
578
+ if mask & IN_ISDIR:
579
+ if (mask & (IN_CREATE | IN_MOVED_TO)) and not is_sentinel_internal(full_path):
580
+ new_wd = libc.inotify_add_watch(inotify_fd, full_path.encode("utf-8"), EVENT_MASK)
581
+ if new_wd >= 0:
582
+ wd_to_dir[new_wd] = full_path
583
+ continue
584
+
585
+ if mask & (IN_CLOSE_WRITE | IN_MOVED_TO):
586
+ if COOLDOWN_SEC <= 0:
587
+ handle_file_written(full_path)
588
+ else:
589
+ event_time = time.time()
590
+ if full_path in pending_writes:
591
+ pending_writes[full_path] = (pending_writes[full_path][0], event_time)
592
+ else:
593
+ pending_writes[full_path] = (event_time, event_time)
594
+
595
+ elif mask & (IN_DELETE | IN_MOVED_FROM):
596
+ if full_path in pending_writes:
597
+ del pending_writes[full_path]
598
+ handle_file_deleted(full_path)
599
+
600
+ except Exception as e:
601
+ if running:
602
+ print(f"[SENTINEL-ERROR]: {e}")
603
+ time.sleep(1)
604
+
605
+ if pending_writes:
606
+ for fpath in list(pending_writes.keys()):
607
+ handle_file_written(fpath)
608
+ pending_writes.clear()
609
+
610
+ os.close(inotify_fd)
611
+ print("[SENTINEL] Guard terminated.")
612
+
613
+ def manage_service(action: str):
614
+ service_file = "/etc/systemd/system/shadow-sentinel.service"
615
+ py_exec = sys.executable
616
+
617
+ if action == "install":
618
+ content = f"""[Unit]
619
+ Description=Shadow Sentinel - Autonomous Filesystem Revision & Backup Guard
620
+ After=network.target
621
+
622
+ [Service]
623
+ Type=simple
624
+ ExecStart={py_exec} -m shadow_sentinel.core daemon
625
+ Restart=always
626
+ RestartSec=5
627
+ StandardOutput=journal
628
+ StandardError=journal
629
+
630
+ [Install]
631
+ WantedBy=multi-user.target
632
+ """
633
+ try:
634
+ with open(service_file, "w", encoding="utf-8") as f:
635
+ f.write(content)
636
+ subprocess.run(["systemctl", "daemon-reload"], check=True)
637
+ subprocess.run(["systemctl", "enable", "--now", "shadow-sentinel"], check=True)
638
+ print(f"[SENTINEL] โœ… Service successfully installed and started: {service_file}")
639
+ print("Check status with: sentinel service status")
640
+ except Exception as e:
641
+ print(f"[SENTINEL-ERROR] Service installation failed (root privileges required?): {e}")
642
+
643
+ elif action in ("status", "restart", "stop", "start"):
644
+ subprocess.run(["systemctl", action, "shadow-sentinel"])
645
+ else:
646
+ print("Unknown service action. Available: install, status, restart, start, stop")
647
+
648
+ def print_version():
649
+ print(f"Shadow Sentinel v{__version__}")
650
+ print(f"{__copyright__}")
651
+ print(f"License: {__license__} (Creative Commons Attribution-ShareAlike 3.0 Germany)")
652
+ print("Project: https://github.com/automatician/shadow-sentinel")
653
+
654
+ def show_help():
655
+ manual = f"""
656
+ ====================================================================================================
657
+ SHADOW SENTINEL - AUTONOMOUS OS-LEVEL REVISION & BACKUP DAEMON
658
+ ====================================================================================================
659
+
660
+ VERSION & LICENSE
661
+ Version: v{__version__}
662
+ Author: {__author__}
663
+ Copyright: {__copyright__}
664
+ License: {__license__} (Creative Commons Attribution-ShareAlike 3.0 DE)
665
+ Repository: https://github.com/automatician/shadow-sentinel
666
+
667
+ DESCRIPTION
668
+ Shadow Sentinel is a lightweight, zero-dependency, kernel-level filesystem revision guard.
669
+ It monitors arbitrary directory trees via Linux inotify (libc), captures atomic backups (.bak)
670
+ and human-readable unified diffs (.diff) on every write, and provides instant 1-command restores
671
+ with loop-coupling safety.
672
+
673
+ CORE ARCHITECTURAL CONCEPTS
674
+
675
+ 1. Decentralized Marker Control (sentinel_marker)
676
+ - No cumbersome central path registration required.
677
+ - Placing a file named 'sentinel_marker' into any directory instructs the daemon to monitor
678
+ that directory and all its subdirectories recursively.
679
+ - On daemon startup or server reboot, the daemon scans the system and binds all marked roots.
680
+
681
+ 2. Dynamic File-Size Ceiling (Self-Pruning Vault)
682
+ - The integer stored inside 'sentinel_marker' defines the maximum protected file size in MB.
683
+ Default is 5 (for 5 MB).
684
+ - If a file (such as a runaway logfile) exceeds this ceiling, it is automatically ejected
685
+ from the shadow vault. Small scripts, configurations, and source code stay 100% protected.
686
+
687
+ 3. Loop-Coupling (Undo-Safe Restore)
688
+ - Restoring a file with 'sentinel restore <ID>' overwrites the current file.
689
+ - The running inotify daemon immediately intercepts this write and seals the overwritten
690
+ state into a brand-new backup revision.
691
+ - An accidental restore never destroys data; any mistaken restore can instantly be undone!
692
+
693
+ 4. Flapping & Cooldown Debounce
694
+ - High-frequency write streams (e.g. active apps writing multiple times per second) are debounced
695
+ with a 3-second quiet window to prevent backup spam.
696
+
697
+ AUTOMATED DISK SANITATION & RETENTION DOCTRINE
698
+
699
+ Sentinel guarantees that it will never exhaust disk space through a 4-tier automated policy:
700
+ 1. Version Cap per File (Max 10 Revisions):
701
+ Only the 10 most recent .bak and .diff files per target are kept. Older versions are deleted.
702
+ 2. Version Age Expiration (14 Days):
703
+ Revisions older than 14 days are automatically pruned.
704
+ 3. Log Expiration (30 Days):
705
+ Daily audit diff logs (diff-YYYYMMDD.log) are removed after 30 days.
706
+ 4. Emergency Storage Ceiling (500 MB Cap):
707
+ If total backup directory size exceeds 500 MB, an emergency purge trims the oldest backups
708
+ across all files until total disk usage drops back down to 80% (400 MB).
709
+ 5. Active Vault Ejection:
710
+ Files that grow beyond the MB ceiling in 'sentinel_marker' are immediately removed from
711
+ the .shadow_vault to recover disk space instantly.
712
+
713
+ *The pruning engine runs automatically every 6 hours and immediately upon service startup.*
714
+
715
+ COMMANDS
716
+
717
+ sentinel [filter_term]
718
+ Lists ALL backup revisions for the current context (no artificial cutoffs). Sentinel walks
719
+ upwards from the current directory to locate the nearest 'sentinel_marker'.
720
+ Optional: pass a search term (e.g. 'sentinel .py' or 'sentinel nginx') to filter.
721
+
722
+ sentinel restore <ID>
723
+ Restores the exact file revision identified by <ID>. Overwrites the target file while
724
+ automatically creating a safety backup of the overwritten version (loop-coupling).
725
+
726
+ sentinel diff <ID>
727
+ Outputs the full unified diff of revision <ID> directly to stdout.
728
+
729
+ sentinel init [MAX_SIZE_MB]
730
+ Initializes the current working directory as a monitored root by creating 'sentinel_marker'.
731
+ Optional argument MAX_SIZE_MB sets the size ceiling (default: 5 MB).
732
+
733
+ sentinel markers
734
+ Scans and lists all active 'sentinel_marker' roots across the entire system.
735
+
736
+ sentinel daemon
737
+ Runs the Sentinel monitoring engine in the foreground.
738
+
739
+ sentinel service <action>
740
+ Manages the systemd background daemon.
741
+ Actions:
742
+ install - Generates /etc/systemd/system/shadow-sentinel.service and starts it.
743
+ status - Checks the systemd service status.
744
+ restart - Restarts the background daemon (rescans all markers).
745
+ stop - Stops the daemon.
746
+ start - Starts the daemon.
747
+
748
+ sentinel version, --version, -v
749
+ Displays version, copyright, and license information.
750
+
751
+ sentinel help, --help, -h
752
+ Displays this manual.
753
+
754
+ STORAGE & CONFIGURATION
755
+
756
+ Default Linux FHS Storage Paths:
757
+ - Root daemon: /var/lib/shadow-sentinel/
758
+ - User daemon: ~/.local/share/shadow-sentinel/
759
+
760
+ Custom Storage Redirection (/etc/shadow-sentinel/config.json):
761
+ Sentinel supports an optional configuration file to redirect backup storage:
762
+ Root: /etc/shadow-sentinel/config.json
763
+ User: ~/.config/shadow-sentinel/config.json
764
+
765
+ Example configuration:
766
+ {{
767
+ "storage_dir": "/custom/path/to/backups"
768
+ }}
769
+
770
+ When present, all backups, shadow vaults, and index records are stored under 'storage_dir'.
771
+
772
+ Environment Variable Override:
773
+ Export SENTINEL_STORAGE_DIR=/path/to/storage to override both defaults and configuration files.
774
+
775
+ ====================================================================================================
776
+ """
777
+ print(manual)
778
+
779
+ def cli_main():
780
+ args = sys.argv[1:]
781
+ cmd = args[0] if args else "list"
782
+
783
+ if cmd in ("version", "--version", "-v"):
784
+ print_version()
785
+ return
786
+
787
+ if cmd in ("help", "--help", "-h"):
788
+ show_help()
789
+ return
790
+
791
+ if cmd in ("daemon", "--daemon", "run"):
792
+ run_daemon()
793
+ return
794
+
795
+ if cmd == "service":
796
+ subaction = args[1] if len(args) > 1 else "status"
797
+ manage_service(subaction)
798
+ return
799
+
800
+ if cmd == "init":
801
+ limit = args[1] if len(args) > 1 else str(DEFAULT_MAX_FILE_SIZE_MB)
802
+ mfile = os.path.join(os.getcwd(), "sentinel_marker")
803
+ with open(mfile, "w", encoding="utf-8") as f:
804
+ f.write(f"{limit}\n")
805
+ print(f"[SENTINEL] โœ… Marker created: {mfile} (Max file size: {limit} MB)")
806
+ print("Reload daemon with: sentinel service restart")
807
+ return
808
+
809
+ if cmd == "markers":
810
+ print("\n" + "=" * 70)
811
+ print(" ๐Ÿ›ก๏ธ SHADOW SENTINEL - ACTIVE MARKERS ON SYSTEM")
812
+ print("=" * 70)
813
+ targets = discover_markers()
814
+ if not targets:
815
+ print(" (No markers found. Use 'sentinel init' to set one)")
816
+ for root, max_bytes in targets.items():
817
+ print(f" * {root:<50} [Ceiling: {max_bytes // (1024*1024)} MB]")
818
+ print("=" * 70 + "\n")
819
+ return
820
+
821
+ if cmd == "diff":
822
+ if len(args) < 2:
823
+ print("Usage: sentinel diff <ID>")
824
+ sys.exit(1)
825
+ target_id = args[1]
826
+ idx = load_index()
827
+ found = next((item for item in idx if str(item.get("id")) == target_id), None)
828
+ if not found or not found.get("diff_file"):
829
+ print(f"[SENTINEL] No diff available for ID {target_id}.")
830
+ return
831
+ diff_path = os.path.join(BACKUP_DIR, found["diff_file"])
832
+ if os.path.exists(diff_path):
833
+ with open(diff_path, "r", encoding="utf-8", errors="replace") as df:
834
+ print(df.read())
835
+ else:
836
+ print(f"[SENTINEL] Diff file no longer exists: {diff_path}")
837
+ return
838
+
839
+ if cmd == "restore":
840
+ if len(args) < 2:
841
+ print("Usage: sentinel restore <ID>")
842
+ sys.exit(1)
843
+
844
+ target_id = args[1]
845
+ idx = load_index()
846
+ found = next((item for item in idx if str(item.get("id")) == target_id), None)
847
+
848
+ if not found:
849
+ print(f"[SENTINEL-ERROR] No backup revision found with ID {target_id}!")
850
+ sys.exit(1)
851
+
852
+ bak_path = os.path.join(BACKUP_DIR, found["bak_file"])
853
+ orig_path = found["orig_path"]
854
+
855
+ if not os.path.exists(bak_path):
856
+ print(f"[SENTINEL-ERROR] Backup file no longer exists: {bak_path}")
857
+ sys.exit(1)
858
+
859
+ os.makedirs(os.path.dirname(orig_path), exist_ok=True)
860
+ # LOOP-COUPLING: Triggers inotify -> previous version automatically sealed as new backup
861
+ shutil.copy2(bak_path, orig_path)
862
+ print(f"\n[SENTINEL] โœ… Revision {target_id} restored to:\n -> {orig_path}")
863
+ print("[SENTINEL] ๐Ÿ”„ Loop-coupling active: Overwritten version was automatically backed up!\n")
864
+ return
865
+
866
+ # Standard: Liste ALLE Backups im aktuellen Marker-Zweig (kein 30er Limit mehr!)
867
+ cwd = os.getcwd()
868
+ nearest_root, marker_path = find_nearest_marker(cwd)
869
+ idx = load_index()
870
+
871
+ # Optionaler Suchfilter (z.B. 'sentinel .py' oder 'sentinel mre')
872
+ filter_pattern = args[0] if (args and args[0] != "list") else None
873
+
874
+ print("\n" + "=" * 90)
875
+ print(" ๐Ÿ›ก๏ธ SHADOW SENTINEL - FILE REVISIONS & RESTORATION")
876
+ print("=" * 90)
877
+
878
+ if nearest_root:
879
+ marker_val = "5 MB (Default)"
880
+ try:
881
+ with open(marker_path, "r", encoding="utf-8") as mf:
882
+ val = mf.read().strip()
883
+ if val:
884
+ marker_val = f"{val} MB"
885
+ except Exception:
886
+ pass
887
+ print(f"Current Context: {cwd}")
888
+ print(f"Responsible Sentinel Root: {nearest_root} (Ceiling: {marker_val})")
889
+ filtered = [item for item in idx if item["orig_path"].startswith(nearest_root)]
890
+ else:
891
+ print(f"Current Context: {cwd} (No local marker found - showing all)")
892
+ filtered = idx
893
+
894
+ if filter_pattern:
895
+ print(f"Active Filter: '{filter_pattern}'")
896
+ filtered = [item for item in filtered if filter_pattern.lower() in item["orig_path"].lower()]
897
+
898
+ print("-" * 90)
899
+ if not filtered:
900
+ print(" No backup revisions found for this context.")
901
+ else:
902
+ print(f"{'ID':<6} {'TIMESTAMP':<20} {'FILE SIZE':<14} {'DIFF':<6} ORIGINAL PATH")
903
+ print("-" * 90)
904
+ # Vollstรคndige Liste ausgeben โ€“ KEIN CUTOFF MEHR!
905
+ for item in reversed(filtered):
906
+ diff_label = "[YES]" if item.get("diff_file") else "[--]"
907
+ size_kb = f"{round(item.get('size_bytes', 0) / 1024, 1)} KB"
908
+ display_path = item["orig_path"]
909
+ if nearest_root and display_path.startswith(nearest_root):
910
+ display_path = os.path.relpath(display_path, nearest_root)
911
+
912
+ print(f"[{item['id']}] {item['timestamp']:<20} {size_kb:<14} {diff_label:<6} {display_path}")
913
+
914
+ print("=" * 90)
915
+ print(f" Total Revisions: {len(filtered)} (Scroll up to view older entries)")
916
+ print(" ๐Ÿ’ก RESTORE: sentinel restore <ID>")
917
+ print(" ๐Ÿ” VIEW DIFF: sentinel diff <ID>")
918
+ print(" โž• NEW MARKER: sentinel init [max_mb]")
919
+ print(" ๐Ÿ“ LIST MARKERS: sentinel markers")
920
+ print(" โš™๏ธ SYSTEMD SERVICE: sentinel service install|restart|status")
921
+ print(" ๐Ÿท๏ธ VERSION & LICENSE: sentinel version")
922
+ print(" ๐Ÿ“– HELP & MANUAL: sentinel help")
923
+ print("=" * 90 + "\n")
924
+
925
+ if __name__ == "__main__":
926
+ cli_main()
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: shadow-sentinel
3
+ Version: 1.0.1
4
+ Summary: Autonomous OS-level revision & backup guard with decentralized marker control and loop-coupling
5
+ Author-email: automatician <235166879+automatician@users.noreply.github.com>
6
+ License-Expression: CC-BY-SA-3.0
7
+ Project-URL: Homepage, https://github.com/automatician/shadow-sentinel
8
+ Project-URL: Bug Tracker, https://github.com/automatician/shadow-sentinel/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Topic :: System :: Archiving :: Backup
12
+ Classifier: Topic :: System :: Monitoring
13
+ Classifier: Topic :: Utilities
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # ๐Ÿ›ก๏ธ Shadow Sentinel
20
+
21
+ **Autonomous OS-Level Revision & Backup Daemon with Decentralized Marker Control and Loop-Coupling.**
22
+
23
+ Shadow Sentinel monitors directory trees in real-time via the Linux kernel (`inotify`), captures
24
+ atomic `.bak` revisions and unified diffs (`.diff`) on every file modification, and provides
25
+ instant 1-command restores with loop-coupling safety.
26
+
27
+ ---
28
+
29
+ ## ๐Ÿš€ Key Features
30
+
31
+ 1. **Decentralized Marker Control (`sentinel_marker`)**:
32
+ - No tedious editing of central configuration files.
33
+ - Simply run `sentinel init [MB]` in any directory or place a `sentinel_marker` file.
34
+ - The daemon automatically scans the system on startup and binds all marked roots.
35
+
36
+ 2. **Dynamic File-Size Ceiling (Self-Pruning Vault)**:
37
+ - The value inside `sentinel_marker` sets the protected size ceiling (e.g. `5` for 5 MB).
38
+ - If a file (such as a runaway logfile) exceeds this ceiling, it is automatically ejected
39
+ from the shadow vault. Small scripts, configurations, and source code stay 100% protected.
40
+
41
+ 3. **Loop-Coupling (Undo-Safe Restore)**:
42
+ - Restoring a file with `sentinel restore <ID>` overwrites the target file.
43
+ - The running inotify daemon immediately intercepts this write and seals the overwritten
44
+ version into a brand-new backup revision. Mistaken restores can instantly be undone!
45
+
46
+ 4. **Flapping & Cooldown Debounce**:
47
+ - Rapid consecutive writes are debounced with a 3-second quiet window to prevent spam.
48
+
49
+ 5. **Zero Dependencies**:
50
+ - Pure Python standard library with native `ctypes` bindings directly to `libc.so.6`.
51
+ - No external tools like `inotifywait` or background Git daemons required.
52
+
53
+ ---
54
+
55
+ ## ๐Ÿงน Automated Disk Sanitation & Retention Doctrine
56
+
57
+ Sentinel guarantees that it will never exhaust disk space through a 4-tier automated policy:
58
+
59
+ 1. **Version Cap per File (Max 10 Revisions)**:
60
+ Only the 10 most recent `.bak` and `.diff` files per target are kept. Older versions are deleted.
61
+
62
+ 2. **Version Age Expiration (14 Days)**:
63
+ Revisions older than 14 days are automatically pruned.
64
+
65
+ 3. **Log Expiration (30 Days)**:
66
+ Daily audit diff logs (`diff-YYYYMMDD.log`) are removed after 30 days.
67
+
68
+ 4. **Emergency Storage Ceiling (500 MB Cap)**:
69
+ If total backup directory size exceeds 500 MB, an emergency purge trims the oldest backups
70
+ across all files until total disk usage drops back down to 80% (400 MB).
71
+
72
+ 5. **Active Vault Ejection**:
73
+ Files that grow beyond the MB ceiling in `sentinel_marker` are immediately removed from
74
+ the `.shadow_vault` to recover disk space instantly.
75
+
76
+ *The pruning engine runs automatically every 6 hours and immediately upon service startup.*
77
+
78
+ ---
79
+
80
+ ## ๐Ÿ“ฆ Installation
81
+
82
+ ```bash
83
+ pip install shadow-sentinel
84
+ ```
85
+
86
+ Set up and start the systemd background service in one step:
87
+ ```bash
88
+ sudo sentinel service install
89
+ ```
90
+
91
+ ---
92
+
93
+ ## ๐Ÿ› ๏ธ Usage
94
+
95
+ ### Protect a Directory
96
+ Navigate to the directory and run:
97
+ ```bash
98
+ sentinel init 5
99
+ ```
100
+ *(Creates `sentinel_marker` with a 5 MB file size limit).*
101
+
102
+ ### List Backups
103
+ Simply type in any terminal:
104
+ ```bash
105
+ sentinel
106
+ ```
107
+ Automatically detects the nearest marker upwards and lists all protected revisions.
108
+
109
+ ### Restore a File
110
+ ```bash
111
+ sentinel restore 3
112
+ ```
113
+
114
+ ### View Unified Diff
115
+ ```bash
116
+ sentinel diff 3
117
+ ```
118
+
119
+ ### List All Active Markers on System
120
+ ```bash
121
+ sentinel markers
122
+ ```
123
+
124
+ ### Full Interactive Manual
125
+ ```bash
126
+ sentinel help
127
+ ```
128
+
129
+ ---
130
+
131
+ ## โš™๏ธ Storage & Configuration
132
+
133
+ ### Linux FHS Standard Paths:
134
+ - **Root Daemon:** `/var/lib/shadow-sentinel/`
135
+ - **User Daemon:** `~/.local/share/shadow-sentinel/`
136
+
137
+ ### Custom Storage Redirection (`/etc/shadow-sentinel/config.json`):
138
+ To store backups, vaults, and indices in a custom directory, create:
139
+ `/etc/shadow-sentinel/config.json` (for root) or `~/.config/shadow-sentinel/config.json` (for user):
140
+
141
+ ```json
142
+ {
143
+ "storage_dir": "/custom/path/to/backups"
144
+ }
145
+ ```
146
+
147
+ ### Environment Variable Override:
148
+ ```bash
149
+ export SENTINEL_STORAGE_DIR=/custom/path/to/backups
150
+ ```
@@ -0,0 +1,8 @@
1
+ shadow_sentinel/__init__.py,sha256=B_3ORMUVfHwwWm_a5oolBNwNGuj0EA7lPigK3iOiP9o,263
2
+ shadow_sentinel/core.py,sha256=b51MKecbgHE7EMFm1PgJE6gPhx-OM1rsFl-9Fs9_1VE,35095
3
+ shadow_sentinel-1.0.1.dist-info/licenses/LICENSE,sha256=l1xiBsxhKiw_f2XZozn49olDjISJttxCkZ_0DPpDxJg,904
4
+ shadow_sentinel-1.0.1.dist-info/METADATA,sha256=TU4sl9roM2-DITlU8mmxTi_ODsZOpzVAhi0HSgvCI1g,4701
5
+ shadow_sentinel-1.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ shadow_sentinel-1.0.1.dist-info/entry_points.txt,sha256=3T7ru6skGVzgXSV69g1IrcMz4of0wg6Hn94WLfH33Y0,59
7
+ shadow_sentinel-1.0.1.dist-info/top_level.txt,sha256=CQKiZFAsDpG5gAFXLu4koFyX1T1DIXrHk86qtINY_jE,16
8
+ shadow_sentinel-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sentinel = shadow_sentinel.core:cli_main
@@ -0,0 +1,18 @@
1
+ Creative Commons Attribution-ShareAlike 3.0 Germany (CC BY-SA 3.0 DE)
2
+
3
+ Copyright (c) 2026 automatician (Torsten Frieser), Germany / Saxony / Zittau.
4
+
5
+ You are free to:
6
+ Share โ€” copy and redistribute the material in any medium or format
7
+ Adapt โ€” remix, transform, and build upon the material for any purpose, even commercially.
8
+
9
+ Under the following terms:
10
+ Attribution โ€” You must give appropriate credit, provide a link to the license,
11
+ and indicate if changes were made. You may do so in any reasonable manner, but
12
+ not in any way that suggests the licensor endorses you or your use.
13
+
14
+ ShareAlike โ€” If you remix, transform, or build upon the material, you must
15
+ distribute your contributions under the same license as the original.
16
+
17
+ No additional restrictions โ€” You may not apply legal terms or technological measures
18
+ that legally restrict others from doing anything the license permits.
@@ -0,0 +1 @@
1
+ shadow_sentinel