@misterhuydo/sentinel 1.0.93 → 1.0.94

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-23T06:29:40.508Z",
3
- "checkpoint_at": "2026-03-23T06:29:40.509Z",
2
+ "message": "Auto-checkpoint at 2026-03-23T06:44:32.434Z",
3
+ "checkpoint_at": "2026-03-23T06:44:32.435Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.0.93",
3
+ "version": "1.0.94",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -62,6 +62,7 @@ class SentinelConfig:
62
62
  slack_channel: str = "" # optional: restrict to one channel ID or name
63
63
  slack_watch_bot_ids: list[str] = field(default_factory=list) # pre-configured bot IDs to watch passively
64
64
  slack_allowed_users: list[str] = field(default_factory=list) # if set, only these Slack user IDs can talk to Boss
65
+ slack_admin_users: list[str] = field(default_factory=list) # subset of allowed users with admin privileges
65
66
  project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
66
67
  # Auth strategy:
67
68
  # ANTHROPIC_API_KEY — used by Sentinel Boss conversation (structured tools, cheap per-token)
@@ -162,6 +163,7 @@ class ConfigLoader:
162
163
  c.slack_channel = d.get("SLACK_CHANNEL", "")
163
164
  c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
164
165
  c.slack_allowed_users = _csv(d.get("SLACK_ALLOWED_USERS", ""))
166
+ c.slack_admin_users = _csv(d.get("SLACK_ADMIN_USERS", ""))
165
167
  c.project_name = d.get("PROJECT_NAME", "")
166
168
  c.claude_pro_for_tasks = d.get("CLAUDE_PRO_FOR_TASKS", "true").lower() != "false"
167
169
  self.sentinel = c
@@ -148,19 +148,26 @@ reply with a short summary grouped by category:
148
148
  • `pull_repo` — git pull on managed application repos — "pull latest code"
149
149
  • `pull_config` — git pull on Sentinel config dirs — "pull config for elprint"
150
150
 
151
+ *File sharing*
152
+ • `post_file` — upload a file to Slack — "give me that as a file", "export the log", "send me the diff"
153
+
154
+ *Personal*
155
+ • `my_stats` — your activity: issues submitted, fixes, conversation history — "my stats"
156
+ • `clear_my_history` — wipe your conversation history and start fresh — "clear my history"
157
+
151
158
  *Slack bot watching*
152
- • `watch_bot` — register a Slack bot for passive monitoring; its messages are auto-queued as issues — "listen to @alertbot"
153
- • `unwatch_bot` — stop monitoring a bot — "stop watching @errorbot"
154
159
  • `list_watched_bots` — show all bots currently being monitored — "which bots are you watching?"
155
160
 
156
- *Project control*
161
+ *Admin* (SLACK_ADMIN_USERS if configured, otherwise all allowed users)
162
+ • `watch_bot` — register a Slack bot for passive monitoring; its messages become issues — "listen to @alertbot"
163
+ • `unwatch_bot` — stop monitoring a bot — "stop watching @errorbot"
157
164
  • `restart_project` — stop + restart a Sentinel monitoring instance (not the app) — "restart sentinel for 1881"
158
-
159
- *Self-management*
160
- • `upgrade_sentinel` — git pull + pip install + restart — "upgrade sentinel", "update yourself"
161
-
162
- *File sharing*
163
- • `post_file` — upload a file to Slack — "give me that as a file", "export the log", "send me the diff"
165
+ • `upgrade_sentinel` — pull latest Sentinel release and restart — "upgrade sentinel"
166
+ • `list_all_users` — all Slack users who have talked to Sentinel + activity summary
167
+ • `clear_user_history` — wipe a specific user's conversation history
168
+ • `reset_fingerprint` — clear the 24h fix lock so Sentinel retries an error
169
+ `list_all_errors` — full unfiltered error database
170
+ • `export_db` — dump full Sentinel state as a downloadable file
164
171
 
165
172
  Tone: direct, professional, like a senior engineer who owns the system.
166
173
  Don't pad responses. Don't say "Great question!" or "Certainly!".
@@ -588,6 +595,77 @@ _TOOLS = [
588
595
  "required": ["content", "filename"],
589
596
  },
590
597
  },
598
+ {
599
+ "name": "list_all_users",
600
+ "description": (
601
+ "ADMIN ONLY. List all Slack users who have ever talked to Sentinel, "
602
+ "with their issue count and conversation message count. "
603
+ "e.g. 'list all users', 'who has talked to you?', 'show user activity'"
604
+ ),
605
+ "input_schema": {"type": "object", "properties": {}},
606
+ },
607
+ {
608
+ "name": "clear_user_history",
609
+ "description": (
610
+ "ADMIN ONLY. Clear the conversation history for a specific Slack user. "
611
+ "e.g. 'clear history for huy', 'reset bob's conversation'"
612
+ ),
613
+ "input_schema": {
614
+ "type": "object",
615
+ "properties": {
616
+ "user_id": {
617
+ "type": "string",
618
+ "description": "Slack user ID to clear (e.g. U01AB2CD3EF)",
619
+ },
620
+ },
621
+ "required": ["user_id"],
622
+ },
623
+ },
624
+ {
625
+ "name": "reset_fingerprint",
626
+ "description": (
627
+ "ADMIN ONLY. Remove the 24h fix lock for an error fingerprint so Sentinel will retry it "
628
+ "on the next poll cycle. Use when a fix attempt failed and you want to force a retry. "
629
+ "e.g. 'retry fix abc123', 'reset fingerprint abc123de', 'let Sentinel try that error again'"
630
+ ),
631
+ "input_schema": {
632
+ "type": "object",
633
+ "properties": {
634
+ "fingerprint": {
635
+ "type": "string",
636
+ "description": "Error fingerprint hash (8+ hex chars, from get_fix_details or list_all_errors)",
637
+ },
638
+ },
639
+ "required": ["fingerprint"],
640
+ },
641
+ },
642
+ {
643
+ "name": "list_all_errors",
644
+ "description": (
645
+ "ADMIN ONLY. Return the full unfiltered error database — all fingerprints, counts, "
646
+ "sources, and last-seen times. "
647
+ "e.g. 'show all errors', 'full error list', 'dump the error DB'"
648
+ ),
649
+ "input_schema": {
650
+ "type": "object",
651
+ "properties": {
652
+ "hours": {
653
+ "type": "integer",
654
+ "description": "Limit to errors seen in the last N hours (0 = all time)",
655
+ "default": 0,
656
+ },
657
+ },
658
+ },
659
+ },
660
+ {
661
+ "name": "export_db",
662
+ "description": (
663
+ "ADMIN ONLY. Export the full Sentinel state (errors, fixes, PRs, users) as a "
664
+ "downloadable text file posted to Slack. "
665
+ "e.g. 'export the DB', 'download state', 'give me a full report file'"
666
+ ),
667
+ "input_schema": {"type": "object", "properties": {}},
668
+ },
591
669
  ]
592
670
 
593
671
 
@@ -654,7 +732,7 @@ def _git_pull(path: Path) -> dict:
654
732
 
655
733
  # ── Tool execution ────────────────────────────────────────────────────────────
656
734
 
657
- async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=None, user_id: str = "", channel: str = "") -> str:
735
+ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=None, user_id: str = "", channel: str = "", is_admin: bool = False) -> str:
658
736
  if name == "get_status":
659
737
  hours = int(inputs.get("hours", 24))
660
738
  errors = store.get_recent_errors(hours)
@@ -999,6 +1077,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
999
1077
  return json.dumps({"fetched": len(results), "results": results})
1000
1078
 
1001
1079
  if name == "watch_bot":
1080
+ if not is_admin:
1081
+ return json.dumps({"error": "Admin access required to register bots for monitoring."})
1002
1082
  user_ids = inputs.get("user_ids", [])
1003
1083
  project_arg = inputs.get("project", "").strip()
1004
1084
  if not user_ids:
@@ -1055,6 +1135,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1055
1135
  return json.dumps({"results": results})
1056
1136
 
1057
1137
  if name == "unwatch_bot":
1138
+ if not is_admin:
1139
+ return json.dumps({"error": "Admin access required to remove bots from monitoring."})
1058
1140
  user_ids = inputs.get("user_ids", [])
1059
1141
  if not user_ids:
1060
1142
  return json.dumps({"error": "No user_ids provided"})
@@ -1082,6 +1164,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1082
1164
  })
1083
1165
 
1084
1166
  if name == "upgrade_sentinel":
1167
+ if not is_admin:
1168
+ return json.dumps({"error": "Admin access required to upgrade Sentinel."})
1085
1169
  import threading
1086
1170
 
1087
1171
  # Sentinel is installed via npm — use `sentinel upgrade` which handles
@@ -1183,6 +1267,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1183
1267
  return json.dumps({"project": target, "repos_queried": len(results), "results": results})
1184
1268
 
1185
1269
  if name == "restart_project":
1270
+ if not is_admin:
1271
+ return json.dumps({"error": "Admin access required to restart a project."})
1186
1272
  project_arg = inputs.get("project", "").lower()
1187
1273
  dirs = _find_project_dirs(project_arg)
1188
1274
  if not dirs:
@@ -1340,6 +1426,72 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1340
1426
  "note": "Your conversation history has been wiped. Next session starts fresh. [DONE]",
1341
1427
  })
1342
1428
  return json.dumps({"error": "cannot determine user — not clearing"})
1429
+
1430
+ # ── Admin-only tools ──────────────────────────────────────────────────────
1431
+ _ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db"}
1432
+ if name in _ADMIN_TOOLS:
1433
+ if not is_admin:
1434
+ return json.dumps({"error": "Admin access required. You are not in SLACK_ADMIN_USERS."})
1435
+
1436
+ if name == "list_all_users":
1437
+ stats = store.get_all_user_stats()
1438
+ return json.dumps({"users": stats, "total": len(stats)})
1439
+
1440
+ if name == "clear_user_history":
1441
+ target = inputs.get("target_user_id", "").strip()
1442
+ if not target:
1443
+ return json.dumps({"error": "target_user_id is required"})
1444
+ store.save_conversation(target, [])
1445
+ display = store.get_user_name(target)
1446
+ logger.info("Boss admin: cleared history for user %s (%s) by admin %s", target, display, user_id)
1447
+ return json.dumps({"status": "cleared", "target_user_id": target, "display_name": display})
1448
+
1449
+ if name == "reset_fingerprint":
1450
+ fp = inputs.get("fingerprint", "").strip()
1451
+ if not fp:
1452
+ return json.dumps({"error": "fingerprint is required"})
1453
+ found = store.reset_fingerprint(fp)
1454
+ logger.info("Boss admin: reset fingerprint %s by admin %s (found=%s)", fp, user_id, found)
1455
+ return json.dumps({"status": "reset" if found else "not_found", "fingerprint": fp,
1456
+ "note": "Sentinel will retry this error on the next poll." if found else "No fix record found for this fingerprint."})
1457
+
1458
+ if name == "list_all_errors":
1459
+ hours = int(inputs.get("hours", 0))
1460
+ errors = store.get_all_errors(hours)
1461
+ return json.dumps({"errors": errors[:100], "total": len(errors),
1462
+ "window_hours": hours or "all time"})
1463
+
1464
+ if name == "export_db":
1465
+ if not slack_client or not channel:
1466
+ return json.dumps({"error": "No Slack channel context — cannot upload file"})
1467
+ try:
1468
+ import sqlite3 as _sq
1469
+ import io as _io
1470
+ lines = []
1471
+ with _sq.connect(store.db_path) as _db:
1472
+ for tbl in ["errors", "fixes", "reports", "slack_users", "conversations", "submitted_issues"]:
1473
+ try:
1474
+ rows = _db.execute(f"SELECT * FROM {tbl}").fetchall() # noqa: S608
1475
+ cols = [d[0] for d in _db.execute(f"SELECT * FROM {tbl} LIMIT 0").description] # noqa: S608
1476
+ lines.append(f"=== {tbl} ({len(rows)} rows) ===")
1477
+ lines.append("\t".join(cols))
1478
+ for row in rows:
1479
+ lines.append("\t".join(str(v) if v is not None else "" for v in row))
1480
+ lines.append("")
1481
+ except Exception:
1482
+ lines.append(f"=== {tbl} (unavailable) ===\n")
1483
+ content = "\n".join(lines)
1484
+ await slack_client.files_upload_v2(
1485
+ channel=channel,
1486
+ content=content,
1487
+ filename="sentinel-db-export.tsv",
1488
+ title="Sentinel DB Export",
1489
+ )
1490
+ logger.info("Boss admin: exported DB (%d bytes) by admin %s", len(content), user_id)
1491
+ return json.dumps({"ok": True, "bytes": len(content)})
1492
+ except Exception as e:
1493
+ return json.dumps({"error": str(e)})
1494
+
1343
1495
  return json.dumps({"error": f"unknown tool: {name}"})
1344
1496
 
1345
1497
 
@@ -1404,6 +1556,7 @@ async def _handle_with_cli(
1404
1556
  user_name: str = "",
1405
1557
  user_id: str = "",
1406
1558
  attachments: list | None = None,
1559
+ is_admin: bool = False,
1407
1560
  ) -> tuple[str, bool]:
1408
1561
  """Fallback: use `claude --print` for users without an Anthropic API key."""
1409
1562
  status_json = await _run_tool("get_status", {"hours": 24}, cfg_loader, store)
@@ -1450,6 +1603,7 @@ async def _handle_with_cli(
1450
1603
  + f"\nSentinel status: {'⏸ PAUSED' if paused else '▶ RUNNING'}"
1451
1604
  + f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
1452
1605
  + (f"\nLog sources: {', '.join(log_sources)}" if log_sources else "")
1606
+ + f"\nAdmin access for this user: {'YES — admin tools are available' if is_admin else 'NO — admin tools will be refused'}"
1453
1607
  + f"\n\nCurrent status (last 24 h):\n{status_json}"
1454
1608
  + f"\n\nOpen PRs:\n{prs_json}"
1455
1609
  + (f"\n\nLog search results:\n{search_json}" if search_json else "")
@@ -1529,6 +1683,7 @@ async def _handle_with_api(
1529
1683
  user_id: str = "",
1530
1684
  attachments: list | None = None,
1531
1685
  channel: str = "",
1686
+ is_admin: bool = False,
1532
1687
  ) -> tuple[str, bool]:
1533
1688
  import anthropic
1534
1689
 
@@ -1554,6 +1709,7 @@ async def _handle_with_api(
1554
1709
  + f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
1555
1710
  + (f"\nLog sources: {', '.join(log_sources)}" if log_sources else "")
1556
1711
  + (f"\nKnown projects in workspace: {', '.join(known_projects)}" if known_projects else "")
1712
+ + f"\nAdmin access for this user: {'YES — admin tools are available' if is_admin else 'NO — admin tools will be refused'}"
1557
1713
  )
1558
1714
 
1559
1715
  # Build user content — include attachment blocks if any
@@ -1595,7 +1751,7 @@ async def _handle_with_api(
1595
1751
  messages.append({"role": "assistant", "content": response.content})
1596
1752
  tool_results = []
1597
1753
  for tc in tool_blocks:
1598
- result = await _run_tool(tc.name, tc.input, cfg_loader, store, slack_client=slack_client, user_id=user_id, channel=channel)
1754
+ result = await _run_tool(tc.name, tc.input, cfg_loader, store, slack_client=slack_client, user_id=user_id, channel=channel, is_admin=is_admin)
1599
1755
  logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
1600
1756
  tool_results.append({
1601
1757
  "type": "tool_result",
@@ -1617,6 +1773,7 @@ async def handle_message(
1617
1773
  user_id: str = "",
1618
1774
  attachments: list | None = None,
1619
1775
  channel: str = "",
1776
+ is_admin: bool = False,
1620
1777
  ) -> tuple[str, bool]:
1621
1778
  """
1622
1779
  Process one user message through the Sentinel Boss (Claude with tool use).
@@ -1639,6 +1796,7 @@ async def handle_message(
1639
1796
  return await _handle_with_api(
1640
1797
  message, history, cfg_loader, store, slack_client=slack_client,
1641
1798
  user_name=user_name, user_id=user_id, attachments=attachments, channel=channel,
1799
+ is_admin=is_admin,
1642
1800
  )
1643
1801
  except Exception as api_err:
1644
1802
  err_str = str(api_err)
@@ -1653,7 +1811,7 @@ async def handle_message(
1653
1811
  # 2nd priority: Claude Pro / OAuth via CLI (limited tools but no API key needed)
1654
1812
  cli_reply, cli_done = await _handle_with_cli(
1655
1813
  message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
1656
- user_id=user_id, attachments=attachments,
1814
+ user_id=user_id, attachments=attachments, is_admin=is_admin,
1657
1815
  )
1658
1816
  if not cli_reply.startswith(":warning:"):
1659
1817
  return cli_reply, cli_done
@@ -344,6 +344,8 @@ async def _dispatch(event: dict, client, cfg_loader, store) -> None:
344
344
  user_name = await _resolve_name(client, user_id)
345
345
  store.upsert_user(user_id, user_name)
346
346
  session = await _get_or_create_session(user_id, user_name, channel)
347
+ admin_users = cfg_loader.sentinel.slack_admin_users or []
348
+ is_admin = (not admin_users) or (user_id in admin_users)
347
349
 
348
350
  if session.busy:
349
351
  # Still processing a previous turn from this user — drop the duplicate
@@ -358,14 +360,14 @@ async def _dispatch(event: dict, client, cfg_loader, store) -> None:
358
360
  if attachments:
359
361
  logger.info("Boss: %d attachment(s) from %s", len(attachments), user_id)
360
362
 
361
- await _run_turn(session, text, client, cfg_loader, store, attachments=attachments)
363
+ await _run_turn(session, text, client, cfg_loader, store, attachments=attachments, is_admin=is_admin)
362
364
 
363
365
 
364
366
  # ── Turn processor ────────────────────────────────────────────────────────────
365
367
 
366
368
  _MAX_HISTORY_TURNS = 20 # keep last 20 exchanges (~40 messages) to stay well within context limits
367
369
 
368
- async def _run_turn(session: _Session, message: str, client, cfg_loader, store, attachments: list | None = None) -> None:
370
+ async def _run_turn(session: _Session, message: str, client, cfg_loader, store, attachments: list | None = None, is_admin: bool = False) -> None:
369
371
  channel = session.channel
370
372
 
371
373
  # Load persisted history from DB on the first turn of a new session
@@ -391,6 +393,7 @@ async def _run_turn(session: _Session, message: str, client, cfg_loader, store,
391
393
  user_id=session.user_id,
392
394
  attachments=attachments or [],
393
395
  channel=channel,
396
+ is_admin=is_admin,
394
397
  )
395
398
  except Exception as e:
396
399
  logger.exception("Sentinel Boss error: %s", e)
@@ -458,3 +458,42 @@ class StateStore:
458
458
  except Exception:
459
459
  return []
460
460
  return []
461
+
462
+ # ── Admin operations ──────────────────────────────────────────────────────
463
+
464
+ def get_all_user_stats(self) -> list[dict]:
465
+ """Return activity summary for every known Slack user."""
466
+ users = self.get_all_users() # {user_id: display_name}
467
+ result = []
468
+ for uid, name in users.items():
469
+ issues = self.get_submitted_issues(uid)
470
+ conv = self.load_conversation(uid)
471
+ result.append({
472
+ "user_id": uid,
473
+ "display_name": name,
474
+ "issues_submitted": len(issues),
475
+ "conversation_messages": len(conv),
476
+ })
477
+ return result
478
+
479
+ def reset_fingerprint(self, fingerprint: str) -> bool:
480
+ """Remove a fix record so Sentinel will retry the error on the next poll."""
481
+ with self._conn() as conn:
482
+ cur = conn.execute("DELETE FROM fixes WHERE fingerprint = ?", (fingerprint,))
483
+ conn.execute("UPDATE errors SET last_seen = NULL WHERE fingerprint = ?", (fingerprint,))
484
+ return cur.rowcount > 0
485
+
486
+ def get_all_errors(self, hours: int = 0) -> list[dict]:
487
+ """Return full unfiltered error list, optionally within a time window."""
488
+ with self._conn() as conn:
489
+ if hours:
490
+ rows = conn.execute(
491
+ "SELECT * FROM errors WHERE last_seen >= datetime('now', ? || ' hours') "
492
+ "ORDER BY last_seen DESC",
493
+ (f"-{hours}",),
494
+ ).fetchall()
495
+ else:
496
+ rows = conn.execute(
497
+ "SELECT * FROM errors ORDER BY last_seen DESC"
498
+ ).fetchall()
499
+ return [dict(r) for r in rows]
@@ -68,3 +68,14 @@ CONFIG_POLL_INTERVAL=60
68
68
  # SLACK_BOT_TOKEN=xoxb-...
69
69
  # SLACK_APP_TOKEN=xapp-...
70
70
  # SLACK_CHANNEL=devops-sentinel
71
+ #
72
+ # Allowlist: if set, only these Slack user IDs can message Sentinel Boss.
73
+ # Leave blank to allow everyone in the workspace.
74
+ # Get user IDs from Slack: click profile → ⋯ → Copy member ID
75
+ # SLACK_ALLOWED_USERS=U123ABC,U456DEF
76
+ #
77
+ # Admin users: a subset of allowed users with elevated privileges.
78
+ # Admins can use: list_all_users, clear_user_history, reset_fingerprint,
79
+ # list_all_errors, export_db
80
+ # These are powerful operations — restrict to trusted team members.
81
+ # SLACK_ADMIN_USERS=U123ABC