@misterhuydo/sentinel 1.0.92 → 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.
package/.cairn/.hint-lock CHANGED
@@ -1 +1 @@
1
- 2026-03-23T06:03:48.001Z
1
+ 2026-03-23T06:43:35.264Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-23T06:24:16.501Z",
3
- "checkpoint_at": "2026-03-23T06:24:16.503Z",
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.92",
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
@@ -107,8 +107,9 @@ What you can do (tools available):
107
107
  e.g. "what does the 1881 backend do?", "find PIN validation in elprint",
108
108
  "any TODOs in cairn?", "are there security issues in elprint-sales?"
109
109
 
110
- 20. restart_project — Stop and restart a specific project instance (stop.sh + start.sh).
111
- e.g. "restart 1881", "reboot elprint", "restart the cairn project"
110
+ 20. restart_project — Stop and restart a specific Sentinel monitoring instance (stop.sh + start.sh).
111
+ This restarts the Sentinel agent for that project, NOT the application itself.
112
+ e.g. "restart sentinel for 1881", "restart the 1881 monitor", "reload elprint sentinel"
112
113
 
113
114
  21. tail_log — Fetch the last N lines of a log source live, without a grep filter.
114
115
  e.g. "show recent SSOLWA logs", "tail STS", "last 200 lines from 1881 logs"
@@ -147,19 +148,26 @@ reply with a short summary grouped by category:
147
148
  • `pull_repo` — git pull on managed application repos — "pull latest code"
148
149
  • `pull_config` — git pull on Sentinel config dirs — "pull config for elprint"
149
150
 
150
- *Slack bot watching*
151
- • `watch_bot` — register a Slack bot for passive monitoring; its messages are auto-queued as issues "listen to @alertbot"
152
- • `unwatch_bot` — stop monitoring a bot — "stop watching @errorbot"
153
- • `list_watched_bots` — show all bots currently being monitored — "which bots are you watching?"
151
+ *File sharing*
152
+ • `post_file` — upload a file to Slack "give me that as a file", "export the log", "send me the diff"
154
153
 
155
- *Project control*
156
- • `restart_project` — stop + restart a specific project — "restart 1881"
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
157
 
158
- *Self-management*
159
- • `upgrade_sentinel` — git pull + pip install + restart — "upgrade sentinel", "update yourself"
158
+ *Slack bot watching*
159
+ • `list_watched_bots` — show all bots currently being monitored — "which bots are you watching?"
160
160
 
161
- *File sharing*
162
- • `post_file` — upload a file to Slack "give me that as a file", "export the log", "send me the diff"
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"
164
+ • `restart_project` — stop + restart a Sentinel monitoring instance (not the app) — "restart sentinel for 1881"
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
163
171
 
164
172
  Tone: direct, professional, like a senior engineer who owns the system.
165
173
  Don't pad responses. Don't say "Great question!" or "Certainly!".
@@ -488,8 +496,9 @@ _TOOLS = [
488
496
  {
489
497
  "name": "restart_project",
490
498
  "description": (
491
- "Stop and restart a specific Sentinel project instance (runs stop.sh then start.sh). "
492
- "Use when: 'restart 1881', 'restart elprint', 'reboot the cairn project'. "
499
+ "Stop and restart a specific Sentinel monitoring instance (runs stop.sh then start.sh). "
500
+ "This restarts the Sentinel agent process for that project — it does NOT restart the application itself. "
501
+ "Use when: 'restart sentinel for 1881', 'reload the 1881 monitor', 'restart elprint sentinel'. "
493
502
  "Safer than restarting all projects at once."
494
503
  ),
495
504
  "input_schema": {
@@ -586,6 +595,77 @@ _TOOLS = [
586
595
  "required": ["content", "filename"],
587
596
  },
588
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
+ },
589
669
  ]
590
670
 
591
671
 
@@ -652,7 +732,7 @@ def _git_pull(path: Path) -> dict:
652
732
 
653
733
  # ── Tool execution ────────────────────────────────────────────────────────────
654
734
 
655
- 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:
656
736
  if name == "get_status":
657
737
  hours = int(inputs.get("hours", 24))
658
738
  errors = store.get_recent_errors(hours)
@@ -997,6 +1077,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
997
1077
  return json.dumps({"fetched": len(results), "results": results})
998
1078
 
999
1079
  if name == "watch_bot":
1080
+ if not is_admin:
1081
+ return json.dumps({"error": "Admin access required to register bots for monitoring."})
1000
1082
  user_ids = inputs.get("user_ids", [])
1001
1083
  project_arg = inputs.get("project", "").strip()
1002
1084
  if not user_ids:
@@ -1053,6 +1135,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1053
1135
  return json.dumps({"results": results})
1054
1136
 
1055
1137
  if name == "unwatch_bot":
1138
+ if not is_admin:
1139
+ return json.dumps({"error": "Admin access required to remove bots from monitoring."})
1056
1140
  user_ids = inputs.get("user_ids", [])
1057
1141
  if not user_ids:
1058
1142
  return json.dumps({"error": "No user_ids provided"})
@@ -1080,6 +1164,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1080
1164
  })
1081
1165
 
1082
1166
  if name == "upgrade_sentinel":
1167
+ if not is_admin:
1168
+ return json.dumps({"error": "Admin access required to upgrade Sentinel."})
1083
1169
  import threading
1084
1170
 
1085
1171
  # Sentinel is installed via npm — use `sentinel upgrade` which handles
@@ -1181,6 +1267,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1181
1267
  return json.dumps({"project": target, "repos_queried": len(results), "results": results})
1182
1268
 
1183
1269
  if name == "restart_project":
1270
+ if not is_admin:
1271
+ return json.dumps({"error": "Admin access required to restart a project."})
1184
1272
  project_arg = inputs.get("project", "").lower()
1185
1273
  dirs = _find_project_dirs(project_arg)
1186
1274
  if not dirs:
@@ -1338,6 +1426,72 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1338
1426
  "note": "Your conversation history has been wiped. Next session starts fresh. [DONE]",
1339
1427
  })
1340
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
+
1341
1495
  return json.dumps({"error": f"unknown tool: {name}"})
1342
1496
 
1343
1497
 
@@ -1402,6 +1556,7 @@ async def _handle_with_cli(
1402
1556
  user_name: str = "",
1403
1557
  user_id: str = "",
1404
1558
  attachments: list | None = None,
1559
+ is_admin: bool = False,
1405
1560
  ) -> tuple[str, bool]:
1406
1561
  """Fallback: use `claude --print` for users without an Anthropic API key."""
1407
1562
  status_json = await _run_tool("get_status", {"hours": 24}, cfg_loader, store)
@@ -1448,6 +1603,7 @@ async def _handle_with_cli(
1448
1603
  + f"\nSentinel status: {'⏸ PAUSED' if paused else '▶ RUNNING'}"
1449
1604
  + f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
1450
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'}"
1451
1607
  + f"\n\nCurrent status (last 24 h):\n{status_json}"
1452
1608
  + f"\n\nOpen PRs:\n{prs_json}"
1453
1609
  + (f"\n\nLog search results:\n{search_json}" if search_json else "")
@@ -1527,6 +1683,7 @@ async def _handle_with_api(
1527
1683
  user_id: str = "",
1528
1684
  attachments: list | None = None,
1529
1685
  channel: str = "",
1686
+ is_admin: bool = False,
1530
1687
  ) -> tuple[str, bool]:
1531
1688
  import anthropic
1532
1689
 
@@ -1552,6 +1709,7 @@ async def _handle_with_api(
1552
1709
  + f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
1553
1710
  + (f"\nLog sources: {', '.join(log_sources)}" if log_sources else "")
1554
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'}"
1555
1713
  )
1556
1714
 
1557
1715
  # Build user content — include attachment blocks if any
@@ -1593,7 +1751,7 @@ async def _handle_with_api(
1593
1751
  messages.append({"role": "assistant", "content": response.content})
1594
1752
  tool_results = []
1595
1753
  for tc in tool_blocks:
1596
- 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)
1597
1755
  logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
1598
1756
  tool_results.append({
1599
1757
  "type": "tool_result",
@@ -1615,6 +1773,7 @@ async def handle_message(
1615
1773
  user_id: str = "",
1616
1774
  attachments: list | None = None,
1617
1775
  channel: str = "",
1776
+ is_admin: bool = False,
1618
1777
  ) -> tuple[str, bool]:
1619
1778
  """
1620
1779
  Process one user message through the Sentinel Boss (Claude with tool use).
@@ -1637,6 +1796,7 @@ async def handle_message(
1637
1796
  return await _handle_with_api(
1638
1797
  message, history, cfg_loader, store, slack_client=slack_client,
1639
1798
  user_name=user_name, user_id=user_id, attachments=attachments, channel=channel,
1799
+ is_admin=is_admin,
1640
1800
  )
1641
1801
  except Exception as api_err:
1642
1802
  err_str = str(api_err)
@@ -1651,7 +1811,7 @@ async def handle_message(
1651
1811
  # 2nd priority: Claude Pro / OAuth via CLI (limited tools but no API key needed)
1652
1812
  cli_reply, cli_done = await _handle_with_cli(
1653
1813
  message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
1654
- user_id=user_id, attachments=attachments,
1814
+ user_id=user_id, attachments=attachments, is_admin=is_admin,
1655
1815
  )
1656
1816
  if not cli_reply.startswith(":warning:"):
1657
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