@misterhuydo/sentinel 1.5.35 → 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
|
@@ -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.
|
|
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=
|
|
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)
|
|
@@ -540,7 +546,13 @@ When to act vs. when to ask:
|
|
|
540
546
|
state. Session memory is a snapshot — tasks complete, commits land, queues drain between turns.
|
|
541
547
|
If you remember "task X was in-flight", check whether it finished before telling the user to wait.
|
|
542
548
|
- Prefer filter_logs over search_logs when synced logs are available — it's instant and never causes session timeout.
|
|
543
|
-
Use search_logs
|
|
549
|
+
Use search_logs (live SSH fetch) when:
|
|
550
|
+
• The user explicitly wants live/real-time data
|
|
551
|
+
• Synced logs are not yet available
|
|
552
|
+
• filter_logs returns no results AND the context involves a recent deploy/release/commit
|
|
553
|
+
(synced logs may simply be stale — do NOT conclude the change isn't live yet; fetch live first)
|
|
554
|
+
When filter_logs returns no hits after a recent release, always retry with search_logs before
|
|
555
|
+
telling the user the log line isn't there.
|
|
544
556
|
- If a tool call will take a moment (search, fetch, pull), prefix your reply with a brief "working" line ending in "..." before the results, e.g. "Searching SSOLWA for TryDig activity..." then the actual output.
|
|
545
557
|
Never just say a working line and stop — always follow it with the results in the same message.
|
|
546
558
|
|
|
@@ -1220,6 +1232,31 @@ _TOOLS = [
|
|
|
1220
1232
|
),
|
|
1221
1233
|
"input_schema": {"type": "object", "properties": {}},
|
|
1222
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
|
+
},
|
|
1223
1260
|
{
|
|
1224
1261
|
"name": "upgrade_sentinel",
|
|
1225
1262
|
"description": (
|
|
@@ -3038,7 +3075,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
3038
3075
|
if tail_override:
|
|
3039
3076
|
env["TAIL"] = str(tail_override)
|
|
3040
3077
|
if grep_override:
|
|
3041
|
-
env["
|
|
3078
|
+
env["SENTINEL_GREP_FILTER_OVERRIDE"] = grep_override
|
|
3042
3079
|
|
|
3043
3080
|
cmd = ["bash", str(script)]
|
|
3044
3081
|
if debug:
|
|
@@ -3164,6 +3201,56 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
3164
3201
|
],
|
|
3165
3202
|
})
|
|
3166
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
|
+
|
|
3167
3254
|
if name == "upgrade_sentinel":
|
|
3168
3255
|
if not is_admin:
|
|
3169
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
if
|
|
201
|
-
|
|
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
|
|
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:
|
|
253
|
-
f"
|
|
254
|
-
f"
|
|
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
|
-
|
|
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):
|