@misterhuydo/sentinel 1.5.36 → 1.5.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.5.36",
3
+ "version": "1.5.37",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -115,6 +115,10 @@ fetch_from_properties() {
115
115
 
116
116
  # Apply defaults for fetch parameters
117
117
  TAIL="${TAIL:-$DEFAULT_TAIL}"
118
+ # Env var override takes precedence over config file value (used by Boss fetch_logs tool)
119
+ if [[ -n "$SENTINEL_GREP_FILTER_OVERRIDE" ]]; then
120
+ GREP_FILTER="$SENTINEL_GREP_FILTER_OVERRIDE"
121
+ fi
118
122
  if [[ -z "$GREP_FILTER" ]]; then
119
123
  GREP_FILTER="$DEFAULT_GREP_FILTER"
120
124
  elif [[ "$GREP_FILTER" == "none" || "$GREP_FILTER" == "*" ]]; then
@@ -1 +1 @@
1
- __version__ = "1.5.36"
1
+ __version__ = "1.5.37"
@@ -119,7 +119,7 @@ def _find_props_file(src):
119
119
  return None
120
120
 
121
121
 
122
- def _fetch_ssh(src, cfg):
122
+ def _fetch_ssh(src, cfg, grep_filter_override: str = ""):
123
123
  props_file = _find_props_file(src)
124
124
  if not props_file:
125
125
  raise FileNotFoundError(f"Properties file not found for {src.name}")
@@ -130,9 +130,13 @@ def _fetch_ssh(src, cfg):
130
130
  tmp_dir = workspace / f"_tmp_{src.name}"
131
131
  tmp_dir.mkdir(exist_ok=True)
132
132
 
133
+ env = {**os.environ.copy(), "OUTPUT_DIR": str(tmp_dir)}
134
+ if grep_filter_override:
135
+ env["SENTINEL_GREP_FILTER_OVERRIDE"] = grep_filter_override
136
+
133
137
  result = subprocess.run(
134
138
  ["bash", str(FETCH_LOG_SH), str(props_file)],
135
- env={**os.environ.copy(), "OUTPUT_DIR": str(tmp_dir)},
139
+ env=env,
136
140
  capture_output=True,
137
141
  text=True,
138
142
  timeout=120,
@@ -351,6 +351,12 @@ reply with a grouped summary like this:
351
351
  • `watch_bot` — register a bot for passive monitoring; its messages become issues
352
352
  • `unwatch_bot` — remove a bot from the watch list
353
353
  • `list_watched_bots` — show all monitored bots
354
+ • `save_service_alias` — remember that a short service name (e.g. "STS") maps to a repo name.
355
+ Call this when:
356
+ – The user replies to a routing question with a repo name
357
+ (e.g. Sentinel asked "which repo is STS?" and user replies "Whydah-SecurityTokenService")
358
+ – The user explicitly says "STS is Whydah-SecurityTokenService" or "route STS to ..."
359
+ After saving, Sentinel automatically reprocesses any pending routing for that service.
354
360
 
355
361
  *File sharing*
356
362
  • `post_file` — upload any output as a Slack file (logs, diffs, reports)
@@ -1226,6 +1232,31 @@ _TOOLS = [
1226
1232
  ),
1227
1233
  "input_schema": {"type": "object", "properties": {}},
1228
1234
  },
1235
+ {
1236
+ "name": "save_service_alias",
1237
+ "description": (
1238
+ "Save a mapping from a short service name to the full repo name so Sentinel can "
1239
+ "auto-route future bot alerts. Call this when: (1) the user replies to a routing "
1240
+ "question with a repo name (e.g. Sentinel asked 'which repo is STS?' and user "
1241
+ "replies 'Whydah-SecurityTokenService'), or (2) the user explicitly says "
1242
+ "'STS is Whydah-SecurityTokenService' or 'route STS alerts to ...'. "
1243
+ "After saving, also reprocess any pending routing for that service hint."
1244
+ ),
1245
+ "input_schema": {
1246
+ "type": "object",
1247
+ "properties": {
1248
+ "service_name": {
1249
+ "type": "string",
1250
+ "description": "The short name used in bot reports (e.g. 'STS', 'SSOLWA', 'UAS')",
1251
+ },
1252
+ "repo_name": {
1253
+ "type": "string",
1254
+ "description": "The full repo name to route this service to (must match a configured repo)",
1255
+ },
1256
+ },
1257
+ "required": ["service_name", "repo_name"],
1258
+ },
1259
+ },
1229
1260
  {
1230
1261
  "name": "upgrade_sentinel",
1231
1262
  "description": (
@@ -3044,7 +3075,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
3044
3075
  if tail_override:
3045
3076
  env["TAIL"] = str(tail_override)
3046
3077
  if grep_override:
3047
- env["GREP_FILTER"] = grep_override
3078
+ env["SENTINEL_GREP_FILTER_OVERRIDE"] = grep_override
3048
3079
 
3049
3080
  cmd = ["bash", str(script)]
3050
3081
  if debug:
@@ -3170,6 +3201,56 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
3170
3201
  ],
3171
3202
  })
3172
3203
 
3204
+ if name == "save_service_alias":
3205
+ service_name = inputs.get("service_name", "").strip()
3206
+ repo_name = inputs.get("repo_name", "").strip()
3207
+ if not service_name or not repo_name:
3208
+ return json.dumps({"error": "service_name and repo_name are required"})
3209
+
3210
+ # Validate repo_name against configured repos
3211
+ known_repos = list(cfg_loader.repos.keys())
3212
+ if repo_name not in cfg_loader.repos:
3213
+ # Try case-insensitive match
3214
+ matches = [r for r in known_repos if r.lower() == repo_name.lower()]
3215
+ if matches:
3216
+ repo_name = matches[0]
3217
+ else:
3218
+ return json.dumps({
3219
+ "error": f"'{repo_name}' does not match any configured repo.",
3220
+ "known_repos": known_repos,
3221
+ })
3222
+
3223
+ store.save_service_alias(service_name, repo_name, added_by=user_id or "boss")
3224
+
3225
+ # Reprocess pending routings for this service hint
3226
+ pending = store.resolve_pending_routing(service_name)
3227
+ requeued = 0
3228
+ if pending:
3229
+ for row in pending:
3230
+ idir = row.get("issues_dir") or str(Path(cfg_loader.sentinel.workspace_dir).parent / "issues")
3231
+ issues_dir = Path(idir)
3232
+ issues_dir.mkdir(parents=True, exist_ok=True)
3233
+ ts_str = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
3234
+ fp = str(uuid.uuid4())[:8]
3235
+ ifile = issues_dir / f"bot-{ts_str}-{fp}.txt"
3236
+ body = row.get("content", "")
3237
+ header = f"TARGET_REPO: {repo_name}\n"
3238
+ ifile.write_text(header + body, encoding="utf-8")
3239
+ requeued += 1
3240
+ logger.info("save_service_alias: requeued %d pending routings for %s → %s",
3241
+ requeued, service_name, repo_name)
3242
+
3243
+ return json.dumps({
3244
+ "status": "saved",
3245
+ "alias": f"{service_name} → {repo_name}",
3246
+ "requeued": requeued,
3247
+ "note": (
3248
+ f"Saved alias: '{service_name}' → '{repo_name}'. "
3249
+ + (f"Re-queued {requeued} pending issue(s) for processing." if requeued else
3250
+ "No pending issues to requeue.")
3251
+ ),
3252
+ })
3253
+
3173
3254
  if name == "upgrade_sentinel":
3174
3255
  if not is_admin:
3175
3256
  return json.dumps({"error": "upgrade is admin-only. Ask a Sentinel admin to perform the upgrade."})
@@ -181,28 +181,40 @@ import uuid as _uuid
181
181
  from pathlib import Path as _Path
182
182
 
183
183
 
184
- def _infer_target_repo(text: str, repos: dict) -> str:
184
+ def _infer_target_repo(text: str, repos: dict, store=None) -> str:
185
185
  """
186
186
  Try to infer a target repo from bot message content.
187
- Looks for 'Service: <name>' or 'service: <name>' patterns and fuzzy-matches
188
- the extracted name against known repo names. Returns the repo name on a unique
189
- match, empty string if ambiguous or no match found.
187
+ Checks in order:
188
+ 1. Saved aliases in DB (e.g. "STS" "Whydah-SecurityTokenService")
189
+ 2. Case-insensitive substring match against repo names
190
+ Returns the repo name on a unique match, empty string otherwise.
190
191
  """
191
192
  import re as _re
192
- # Extract service hints: "Service: STS", "service: SSOLWA", etc.
193
193
  hints = _re.findall(r'(?i)service:\s*(\S+)', text)
194
- # Also try class names: "Class: SomeService" — extract first word segment
195
194
  hints += _re.findall(r'(?i)class:\s*(\w+)', text)
196
195
 
197
196
  for hint in hints:
198
- hint_lower = hint.lower().rstrip(".,:")
199
- matches = [r for r in repos if hint_lower in r.lower()]
200
- if len(matches) == 1:
201
- return matches[0]
197
+ hint_clean = hint.rstrip(".,:")
198
+ # 1. Saved alias lookup
199
+ if store:
200
+ alias = store.get_service_alias(hint_clean)
201
+ if alias and alias in repos:
202
+ return alias
203
+ # 2. Case-insensitive substring match
204
+ sub_matches = [r for r in repos if hint_clean.lower() in r.lower()]
205
+ if len(sub_matches) == 1:
206
+ return sub_matches[0]
202
207
 
203
208
  return ""
204
209
 
205
210
 
211
+ def _extract_service_hint(text: str) -> str:
212
+ """Extract the first 'Service: <name>' value from a bot message."""
213
+ import re as _re
214
+ m = _re.search(r'(?i)service:\s*(\S+)', text)
215
+ return m.group(1).rstrip(".,: ") if m else ""
216
+
217
+
206
218
  async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
207
219
  """
208
220
  Called when a watched bot posts a message.
@@ -240,23 +252,40 @@ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
240
252
 
241
253
  # Auto-detect target_repo from message content when not pre-configured
242
254
  if not target_repo and hasattr(cfg_loader, "repos") and cfg_loader.repos:
243
- target_repo = _infer_target_repo(text, cfg_loader.repos)
255
+ target_repo = _infer_target_repo(text, cfg_loader.repos, store=store)
244
256
  if not target_repo and len(cfg_loader.repos) > 1:
245
- # Can't determine — ask in the channel where the bot posted
257
+ # Can't determine — ask in the channel and store as pending routing
246
258
  bot_name = (bot_info or {}).get("bot_name") or bot_id
259
+ service_hint = _extract_service_hint(text)
260
+ hint_label = f"`{service_hint}`" if service_hint else "this alert"
247
261
  repo_names = ", ".join(f"`{r}`" for r in sorted(cfg_loader.repos))
248
262
  try:
249
263
  await client.chat_postMessage(
250
264
  channel=channel,
251
265
  text=(
252
- f":mag: I got an alert from *{bot_name}* but can't tell which repo to route it to.\n"
253
- f"Available repos: {repo_names}\n"
254
- f"Re-register with: `@Sentinel watch @{bot_name} for <repo-name>`"
266
+ f":mag: Got an alert from *{bot_name}* can't tell which repo to route {hint_label} to.\n"
267
+ f"Reply with the repo name and I'll remember it for next time.\n"
268
+ f"Available: {repo_names}\n\n"
269
+ f"_{text[:300]}_"
255
270
  ),
256
271
  )
257
272
  except Exception as _e:
258
273
  logger.warning("Bot watcher: could not post routing question: %s", _e)
259
- return # drop this message don't queue an unroutable issue
274
+ # Store as pending so Boss can reprocess when user replies
275
+ if service_hint:
276
+ try:
277
+ default_issues = str(_Path(cfg_loader.sentinel.workspace_dir).parent / "issues")
278
+ store.add_pending_routing(
279
+ service_hint=service_hint,
280
+ bot_id=bot_id,
281
+ channel=channel,
282
+ ts=ts,
283
+ content=text,
284
+ issues_dir=default_issues,
285
+ )
286
+ except Exception as _e:
287
+ logger.warning("Bot watcher: could not store pending routing: %s", _e)
288
+ return # drop this message until routing is resolved
260
289
 
261
290
  # Resolve the project issues directory
262
291
  workspace = _Path(cfg_loader.sentinel.workspace_dir).parent
@@ -401,6 +401,81 @@ class StateStore:
401
401
  (key, _now()),
402
402
  )
403
403
 
404
+ # ── Service name aliases (e.g. "STS" → "Whydah-SecurityTokenService") ───────
405
+
406
+ def _ensure_service_aliases_table(self, conn):
407
+ conn.execute(
408
+ "CREATE TABLE IF NOT EXISTS service_aliases "
409
+ "(service_name TEXT PRIMARY KEY, repo_name TEXT, added_by TEXT, added_at TEXT)"
410
+ )
411
+
412
+ def save_service_alias(self, service_name: str, repo_name: str, added_by: str = "boss") -> None:
413
+ with self._conn() as conn:
414
+ self._ensure_service_aliases_table(conn)
415
+ conn.execute(
416
+ "INSERT OR REPLACE INTO service_aliases (service_name, repo_name, added_by, added_at) "
417
+ "VALUES (?, ?, ?, ?)",
418
+ (service_name.strip(), repo_name.strip(), added_by, _now()),
419
+ )
420
+
421
+ def get_service_alias(self, service_name: str) -> str:
422
+ with self._conn() as conn:
423
+ self._ensure_service_aliases_table(conn)
424
+ row = conn.execute(
425
+ "SELECT repo_name FROM service_aliases WHERE service_name = ? COLLATE NOCASE",
426
+ (service_name.strip(),),
427
+ ).fetchone()
428
+ return row["repo_name"] if row else ""
429
+
430
+ def get_all_service_aliases(self) -> list[dict]:
431
+ with self._conn() as conn:
432
+ self._ensure_service_aliases_table(conn)
433
+ rows = conn.execute("SELECT * FROM service_aliases ORDER BY service_name").fetchall()
434
+ return [dict(r) for r in rows]
435
+
436
+ # ── Pending bot-message routing questions ─────────────────────────────────
437
+
438
+ def _ensure_pending_routings_table(self, conn):
439
+ conn.execute(
440
+ "CREATE TABLE IF NOT EXISTS pending_routings "
441
+ "(id INTEGER PRIMARY KEY AUTOINCREMENT, service_hint TEXT, bot_id TEXT, "
442
+ "channel TEXT, ts TEXT, content TEXT, issues_dir TEXT, asked_at TEXT)"
443
+ )
444
+
445
+ def add_pending_routing(self, service_hint: str, bot_id: str, channel: str,
446
+ ts: str, content: str, issues_dir: str) -> None:
447
+ with self._conn() as conn:
448
+ self._ensure_pending_routings_table(conn)
449
+ conn.execute(
450
+ "INSERT INTO pending_routings "
451
+ "(service_hint, bot_id, channel, ts, content, issues_dir, asked_at) "
452
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
453
+ (service_hint, bot_id, channel, ts, content, issues_dir, _now()),
454
+ )
455
+
456
+ def get_pending_routings(self) -> list[dict]:
457
+ with self._conn() as conn:
458
+ self._ensure_pending_routings_table(conn)
459
+ rows = conn.execute(
460
+ "SELECT * FROM pending_routings ORDER BY asked_at DESC LIMIT 20"
461
+ ).fetchall()
462
+ return [dict(r) for r in rows]
463
+
464
+ def resolve_pending_routing(self, service_hint: str) -> list[dict]:
465
+ """Return and delete all pending routings for a given service hint."""
466
+ with self._conn() as conn:
467
+ self._ensure_pending_routings_table(conn)
468
+ rows = conn.execute(
469
+ "SELECT * FROM pending_routings WHERE service_hint = ? COLLATE NOCASE",
470
+ (service_hint,),
471
+ ).fetchall()
472
+ result = [dict(r) for r in rows]
473
+ conn.execute(
474
+ "DELETE FROM pending_routings WHERE service_hint = ? COLLATE NOCASE",
475
+ (service_hint,),
476
+ )
477
+ return result
478
+
404
479
  # ── Watched bots (Slack passive monitor) ─────────────────────────────────
405
480
 
406
481
  def _ensure_watched_bots_table(self, conn):