securenow 8.17.0 → 8.19.0

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/README.md CHANGED
@@ -174,8 +174,11 @@ Everything the MCP server can do is also a command. Run `npx securenow help` for
174
174
 
175
175
  | Command | Description |
176
176
  |---|---|
177
- | `securenow human list` / `human show <row>` | Review AI-prepared decisions awaiting a human |
177
+ | `securenow review` / `review show <row>` | Alert Review: rules that fired too wide and paused themselves |
178
+ | `securenow review group <row> --outcome rule_tuning --reason "..." --yes` | Close every run of a group after fixing its rule |
179
+ | `securenow human list` / `human show <row>` | Requires Human: AI-prepared decisions awaiting a human |
178
180
  | `securenow human block <row> --yes --reason "..."` | Approve a proposed block |
181
+ | `securenow human close <row> --outcome deferred --reason "..." --yes` | Close a row without blocking |
179
182
  | `securenow blocklist add <ip> [--route /admin*] [--mode prefix] [--duration 24h] [--reason "..."]` | Block an IP globally or per-route |
180
183
  | `securenow blocklist unblock <id> --reason "..."` | Stop enforcement, keep the history |
181
184
  | `securenow ratelimit from-text "5 per minute per IP on POST /api/login for 2h" --yes` | Rate-limit rule from natural language |
package/SKILL-CLI.md CHANGED
@@ -28,6 +28,22 @@ npx -p securenow securenow-mcp
28
28
 
29
29
  The MCP server reads admin/control-plane auth from `.securenow/admin.json` and SDK runtime app credentials from `.securenow/runtime.json`, with legacy `.securenow/credentials.json` still supported. Admin/global tools use admin auth; runtime-scoped read tools can use the runtime API key where its scopes allow it. Write tools require `confirm:true` plus a reason.
30
30
 
31
+ **For agents, prefer the hosted MCP** (`https://api.securenow.ai/mcp`, OAuth sign-in, no install and always the current tools). With the local stdio server, warm the package once so the first connection doesn't time out on the download (`npx -y securenow@latest --version`), and give the client a longer connect timeout (Claude Code: `MCP_TIMEOUT=120000`).
32
+
33
+ **Tool profiles** (stdio server): `securenow mcp --profile <name>` (or `SECURENOW_MCP_PROFILE=<name>`) serves only the tools one job needs, so the agent carries less context. Every profile keeps `securenow_auth_status`, `securenow_apps_list` and `securenow_apps_info`; calling a tool outside the profile returns an error naming the profile.
34
+
35
+ | Profile | Tools | What's in it |
36
+ |---|---|---|
37
+ | `all` (default) | 112 | everything |
38
+ | `investigator` | 58 | every read-only tool, plus forensics question/chat/confirm |
39
+ | `detection` | 47 | `securenow_alert_rule*` (draft, create, test, run, runs, explain, versions, exclusions, request promotion…), traces, logs, forensics, IP intelligence, notifications list/get |
40
+ | `responder` | 77 | notifications, human queue, alert reviews, blocklist, allowlist, trusted IPs, rate limits, challenges, automations, firewall, IP intelligence, plus rule get/runs/explain and traces/logs for context |
41
+
42
+ ```bash
43
+ claude mcp add securenow-investigator -- npx -y securenow@latest mcp --profile investigator
44
+ codex mcp add securenow-detection -- npx securenow mcp --profile detection
45
+ ```
46
+
31
47
  ### Authenticate
32
48
 
33
49
  ```bash
@@ -213,8 +229,11 @@ securenow traces analyze <traceId> --env production # AI-powered trace analysi
213
229
  securenow logs [--app <key>] [--env production] [--limit N] [--minutes M] [--level error|warn|info]
214
230
  securenow logs list --app my-app --env production --minutes 30 --level error
215
231
  securenow logs trace <traceId> --env production # logs correlated to a specific trace
232
+ securenow logs errors --app my-app --since 6h [--warn] # what is failing: error lines grouped by message (ids/numbers normalized), counts, first/last seen, sample traces
216
233
  ```
217
234
 
235
+ For "what's broken right now?", start with `logs errors` (MCP `securenow_logs_errors`): one call returns the distinct failures, not hundreds of raw lines.
236
+
218
237
  ### Notifications
219
238
 
220
239
  ```bash
@@ -225,7 +244,41 @@ securenow notifications read-all # mark all as read
225
244
  securenow notifications unread # unread count
226
245
  ```
227
246
 
228
- ### Human Actions — AI-Prepared Decision Queue
247
+ ### Two review queues
248
+
249
+ SecureNow decides most things alone (instant blocks, automations, IP investigations, self-pausing
250
+ rules). What it won't decide alone waits in two queues. Work **Alert Review** first: one rule fix
251
+ closes every run it caused and the Requires Human rows that came from the same noise.
252
+
253
+ | Queue | Question | Decisions | CLI |
254
+ |---|---|---|---|
255
+ | Alert Review | "Is this rule right?" A rule fired far too wide, or on an IP treated as your own infrastructure; it usually pauses itself | tune the rule, keep it on, close/dismiss with a reason (never blocks or unblocks) | `securenow review` |
256
+ | Requires Human | "What do we do about this IP or plan?" An investigated IP with a verdict SecureNow won't act on alone, or a proposed plan | block, false positive, close without blocking, approve/reject a plan | `securenow human` |
257
+
258
+ ### Alert Review — is this rule right?
259
+
260
+ ```bash
261
+ securenow review # open runs grouped by root cause (rule + route + evidence shape)
262
+ securenow review list --search lab-auth # one app (name or key), rule, route or IP
263
+ securenow review show 1 # group 1: rule, route, IPs, proposed guard, its runs
264
+ securenow review show <notificationId> # one run
265
+ securenow review keep <notificationId> --reason "Correct detection" --yes # turn the rule back on, stop it auto-pausing, close the run
266
+ securenow review close <notificationId> --outcome rule_tuning --reason "..." --yes
267
+ securenow review dismiss <notificationId> --reason "Not worth acting on" --yes
268
+ securenow review group 1 --outcome rule_tuning --reason "Guard added in v4" --preview # what would close
269
+ securenow review group 1 --outcome rule_tuning --reason "Guard added in v4" --yes # close the whole group
270
+ ```
271
+
272
+ Outcomes: `rule_tuning`, `false_positive`, `clean`, `deferred`, `new_alert_rule`, `rate_limited`,
273
+ `ambiguous`, `skipped`, `other`. Closing writes a decision report on the alert and changes no
274
+ enforcement. Tune the rule first (`securenow alerts rules change <id> "..."`), then close its group.
275
+ A mixed group (rows disagree on verdict or sender) needs `--allow-mixed` after reviewing every row.
276
+
277
+ MCP parity: `securenow_alert_review_runs_list`, `securenow_alert_review_run_get`,
278
+ `securenow_alert_review_run_keep_alive`, `securenow_alert_review_run_resolve`,
279
+ `securenow_alert_review_run_dismiss`, `securenow_alert_review_run_apply_group_decision`; prompt `work_alert_review`.
280
+
281
+ ### Requires Human — what do we do about this IP or plan?
229
282
 
230
283
  Use this for the same work shown in **Requires Human**: AI has already grouped alerts, fetched traces, built a report, and left a final Block IP or False Positive decision.
231
284
 
@@ -235,6 +288,7 @@ securenow human list --limit 20
235
288
  securenow human show 1 # inspect row 1 with AI report, investigation steps, proofs, trace links
236
289
  securenow human block 1 --yes --reason "AI evidence confirmed malicious"
237
290
  securenow human fp 1 --yes --reason "Scoped false positive after evidence review"
291
+ securenow human close 1 --outcome deferred --reason "Scanner, nothing reachable; not worth a block" --yes # leaves the queue, nothing blocked
238
292
  securenow human action 1 --status rejected --yes --reason "Tuning guard is too broad"
239
293
  securenow human prompt 1 # print a Codex/Claude MCP prompt for this row
240
294
  securenow human work --limit 10 # list queue + print MCP runbook for deep queue work
@@ -247,9 +301,26 @@ MCP parity:
247
301
  - `securenow_human_action_report`
248
302
  - `securenow_human_action_block`
249
303
  - `securenow_human_action_false_positive`
304
+ - `securenow_human_action_close_no_write`
250
305
  - `securenow_human_case_action_update`
251
306
  - prompts: `investigate_human_action_row`, `work_human_actions`
252
307
 
308
+ `human report` only adds a note: the row stays. `human close` removes it without enforcement.
309
+ Approving a `tune_rule` or `create_exclusion` plan with `human action` records the decision and
310
+ changes nothing: change the rule first, then approve. Only `approve_block` blocks.
311
+
312
+ ### Run the queues with your own agent
313
+
314
+ ```bash
315
+ claude "$(npx -y securenow@latest agent prompt patrol)" # read-only pass: a plan
316
+ claude -p "$(npx -y securenow@latest agent prompt patrol --search my-app --confirm-writes)" --allowedTools "mcp__securenow" # unattended, acts on one app
317
+ ```
318
+
319
+ `soc_patrol` works Alert Review then Requires Human, up to 10 groups and 10 rows per pass
320
+ (`--max-alert-review-groups`, `--max-human-rows`). The API key's scopes are the hard limit: a
321
+ Read Only key cannot change anything; acting needs `notifications:write`, `alerts:write` and
322
+ `rate_limits:write`. Setup for Claude Code, Codex and Cursor: dashboard → Run With Your Agent.
323
+
253
324
  Write tools still require `confirm:true` plus a reason. False positives should stay restrictive to the app, alert rule, path, method/status, user-agent, body pattern, or other exact evidence the AI report supports. Notification IP investigations can include `metadata.matchedSubdetectors`; prefer that exact field over inferring which subdetector fired from the title or sample body.
254
325
 
255
326
  ### Alerts
@@ -295,6 +366,7 @@ securenow alerts rules delete <id> --yes # delete a custom rule (system rules
295
366
  securenow alerts rules update <id> --applications-all # all current & future apps
296
367
  securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
297
368
  securenow alerts rules test <id> --wait # dry-run an existing rule's query (default; --mode live inserts 6 synthetic attack requests)
369
+ securenow alerts rules change <id> "raise the threshold to 20" # rewrite a rule from English: checked, shown before/after, applied as a new version
298
370
  securenow alerts rules runs <id> [--since 1h] # what each run saw and why it did or didn't notify (+ next scheduled run)
299
371
  securenow alerts rules run <id> # run a periodic rule now and print the run (--no-wait to just queue it)
300
372
  securenow alerts rules why <id> --ip <ip> [--since 1h] # why the rule did or didn't alert on one address: verdict + findings
@@ -322,6 +394,8 @@ right tenant-scope column per table: logs (`signoz_logs.distributed_logs_v2`) us
322
394
 
323
395
  MCP parity for quiet rules: `securenow_alert_rule_runs` (run history), `securenow_alert_rule_run_now` (write, confirm) and `securenow_alert_rule_explain` (`ip`, `since`: verdict + findings).
324
396
 
397
+ **Agents don't promote rules; they ask.** When a test-mode rule has proved itself, an agent runs `securenow alerts rules request-promotion <id> --reason "..." --evidence "..."` (MCP `securenow_alert_rule_request_promotion`). A person sees it on the rules page or with `securenow human promotions` and runs `securenow human approve-promotion <request-id> --yes` (the rule goes to prod, recorded as a rule version naming the approver) or `deny-promotion <request-id> --reason "..."`. API keys and MCP tokens can ask but not decide.
398
+
325
399
  MCP parity for noisy alert-rule reviews:
326
400
 
327
401
  - `securenow_alert_rule_get`
package/cli/human.js CHANGED
@@ -509,13 +509,60 @@ async function report(args, flags) {
509
509
  }
510
510
  s.stop('Decision report recorded');
511
511
  if (flags.json) ui.json(data);
512
- else ui.success(`Recorded decision report: ${decisionReport.outcome}`);
512
+ else {
513
+ ui.success(`Recorded decision report: ${decisionReport.outcome}`);
514
+ if (task.ip) ui.info(`A report is a note: the row stays in the queue. To close it without enforcement: securenow human close ${task.notificationId}:${task.ip} --outcome ${decisionReport.outcome}`);
515
+ }
513
516
  } catch (err) {
514
517
  s.fail('Failed to record decision report');
515
518
  throw err;
516
519
  }
517
520
  }
518
521
 
522
+ const CLOSE_OUTCOMES = ['rate_limited', 'deferred', 'rule_tuning', 'new_alert_rule', 'ambiguous', 'skipped', 'other'];
523
+
524
+ // Close an IP row without blocking or marking it a false positive ("not now", "rule needs tuning",
525
+ // "already rate-limited"...). Unlike `report`, the row leaves the queue.
526
+ async function close(args, flags) {
527
+ requireAuth();
528
+ const ref = args[0];
529
+ const { task } = await resolveTask(ref, flags);
530
+ if (!task.ip) {
531
+ ui.error('`close` is for IP rows. For a case-level plan use: securenow human action <row> --status approved|rejected');
532
+ process.exit(1);
533
+ }
534
+ const outcome = flags.outcome || 'skipped';
535
+ if (!CLOSE_OUTCOMES.includes(outcome)) {
536
+ ui.error(`--outcome must be one of: ${CLOSE_OUTCOMES.join(', ')}`);
537
+ process.exit(1);
538
+ }
539
+ const summary = flags.summary || flags.reason;
540
+ if (!summary) {
541
+ ui.error('Say why with --reason "..." (and --missing-proof "..." when evidence is missing): the reason is kept on the case.');
542
+ process.exit(1);
543
+ }
544
+
545
+ if (!flags.yes && !flags.force) {
546
+ const ok = await ui.confirm(`Close ${task.ip} without blocking it (${outcome})?`);
547
+ if (!ok) return;
548
+ }
549
+
550
+ const s = ui.spinner(`Closing ${task.ip}`);
551
+ try {
552
+ const decisionReport = buildDecisionReport(task, flags, outcome, summary);
553
+ const data = await api.post(
554
+ `/notifications/${encodeURIComponent(task.notificationId)}/ips/${encodeURIComponent(task.ip)}/close-no-write`,
555
+ { reportSource: 'cli', reason: flags.reason || summary, summary, outcome, decisionReport }
556
+ );
557
+ s.stop(`${task.ip} closed (${outcome})`);
558
+ if (flags.json) ui.json(data);
559
+ else ui.success(`Closed without enforcement: ${task.ip} is out of the queue, nothing was blocked. The reason is on the case.`);
560
+ } catch (err) {
561
+ s.fail('Failed to close the row');
562
+ throw err;
563
+ }
564
+ }
565
+
519
566
  function mcpPromptText(ref, flags = {}) {
520
567
  const rowText = ref ? `Start with human action row/id: ${ref}.` : 'Work the human action queue from most urgent to least urgent.';
521
568
  const limit = flags.limit || 10;
@@ -569,6 +616,66 @@ async function work(args, flags) {
569
616
  console.log('');
570
617
  }
571
618
 
619
+ // ── Rule promotion requests ──
620
+ // Agents create rules in test mode and ask a person to promote them. A person approves (the rule
621
+ // goes to prod, recorded as a rule version) or denies here or on the dashboard's rules page.
622
+
623
+ async function promotions(args, flags) {
624
+ requireAuth();
625
+ const status = typeof flags.status === 'string' ? flags.status : 'pending';
626
+ const s = ui.spinner('Fetching promotion requests');
627
+ let data;
628
+ try {
629
+ data = await api.get('/alert-rules/promotion-requests', { query: { status, limit: flags.limit || 50 } });
630
+ s.stop(`Found ${(data.requests || []).length} ${status === 'all' ? '' : `${status} `}request${(data.requests || []).length === 1 ? '' : 's'}`);
631
+ } catch (err) {
632
+ s.fail('Failed to fetch promotion requests');
633
+ throw err;
634
+ }
635
+ if (flags.json) { ui.json(data); return; }
636
+ const requests = data.requests || [];
637
+ if (requests.length === 0) { ui.info('No promotion requests waiting.'); return; }
638
+ console.log('');
639
+ for (const r of requests) {
640
+ console.log(` ${ui.c.bold(r.ruleName || r.ruleId)} ${ui.c.dim(r.id)} ${r.status}`);
641
+ console.log(` asked by ${r.requestedBy?.name || r.requestedBy?.kind || 'unknown'} ${ui.timeAgo(r.createdAt)}: ${r.reason}`);
642
+ if (r.evidence) console.log(ui.c.dim(` evidence: ${ui.truncate(String(r.evidence).replace(/\s+/g, ' '), 160)}`));
643
+ if (r.status !== 'pending') console.log(ui.c.dim(` ${r.status} by ${r.decidedByName || 'someone'}${r.decisionNote ? `: ${r.decisionNote}` : ''}`));
644
+ }
645
+ console.log('');
646
+ if (requests.some((r) => r.status === 'pending')) {
647
+ console.log(ui.c.dim(' Approve: securenow human approve-promotion <id> --yes [--note "..."] Deny: securenow human deny-promotion <id> --reason "..."'));
648
+ console.log(ui.c.dim(' Before approving, check what it would arm: securenow alerts rules show <ruleId> and securenow alerts rules runs <ruleId>'));
649
+ console.log('');
650
+ }
651
+ }
652
+
653
+ async function decidePromotion(args, flags, decision) {
654
+ requireAuth();
655
+ const id = args[0];
656
+ const verb = decision === 'approve' ? 'approve-promotion' : 'deny-promotion';
657
+ if (!id) {
658
+ ui.error(`Usage: securenow human ${verb} <request-id> ${decision === 'approve' ? '--yes [--note "..."]' : '--reason "..."'}`);
659
+ process.exit(1);
660
+ }
661
+ if (decision === 'approve' && !flags.yes && !flags.force) {
662
+ ui.warn('Approving moves the rule to prod: its instant block, matching automations and the AI pipeline can act on its alerts.');
663
+ const ok = await ui.confirm('Approve this promotion?');
664
+ if (!ok) { ui.info('Cancelled'); return; }
665
+ }
666
+ const note = flags.note || flags.reason || '';
667
+ const s = ui.spinner(decision === 'approve' ? 'Approving' : 'Denying');
668
+ try {
669
+ const data = await api.post(`/alert-rules/promotion-requests/${id}/${decision}`, { note });
670
+ s.stop(decision === 'approve' ? `Approved: "${data.alertRule?.name || data.request?.ruleName}" is in prod` : 'Denied: the rule stays in test mode');
671
+ for (const warning of data.warnings || []) ui.warn(warning);
672
+ if (flags.json) ui.json(data);
673
+ } catch (err) {
674
+ s.fail(`Failed to ${decision} the promotion`);
675
+ throw err;
676
+ }
677
+ }
678
+
572
679
  module.exports = {
573
680
  list,
574
681
  show,
@@ -576,6 +683,10 @@ module.exports = {
576
683
  block,
577
684
  fp,
578
685
  report,
686
+ close,
579
687
  prompt,
580
688
  work,
689
+ promotions,
690
+ approvePromotion: (args, flags) => decidePromotion(args, flags, 'approve'),
691
+ denyPromotion: (args, flags) => decidePromotion(args, flags, 'deny'),
581
692
  };
package/cli/monitor.js CHANGED
@@ -294,6 +294,48 @@ async function logsList(args, flags) {
294
294
  }
295
295
  }
296
296
 
297
+ // Error lines grouped by message (ids and numbers normalized): what is failing, how often, since when.
298
+ async function logsErrors(args, flags) {
299
+ requireAuth();
300
+ const appKey = resolveApp(flags);
301
+ if (!appKey) {
302
+ ui.error('No app specified. Use --app <key> or set a default.');
303
+ process.exit(1);
304
+ }
305
+ const query = { appKeys: appKey, since: flags.since || '1h', limit: flags.limit || 20 };
306
+ if (flags.warn) query.includeWarn = 'true';
307
+ addEnvironment(query, flags);
308
+ const s = ui.spinner('Grouping error logs');
309
+ let data;
310
+ try {
311
+ data = await api.get('/logs/errors', { query });
312
+ const kind = data.includeWarn ? 'error/warning' : 'error';
313
+ s.stop(`${data.total} ${kind} line${data.total === 1 ? '' : 's'} in ${data.distinct} group${data.distinct === 1 ? '' : 's'}`);
314
+ } catch (err) {
315
+ s.fail('Failed to summarize error logs');
316
+ throw err;
317
+ }
318
+ if (flags.json) { ui.json(data); return; }
319
+ const groups = data.groups || [];
320
+ console.log('');
321
+ if (groups.length === 0) { ui.info(`No ${data.includeWarn ? 'warnings or errors' : 'errors'} since ${data.window?.from}.`); return; }
322
+ for (const g of groups) {
323
+ const last = g.lastSeen ? ui.timeAgo(g.lastSeen) : '';
324
+ console.log(` ${ui.c.red(String(g.occurrences).padStart(5))} ${ui.truncate(String(g.sample || g.signature).replace(/\s+/g, ' '), 110)}`);
325
+ const meta = [
326
+ (g.severities || []).join('/'),
327
+ (g.environments || []).join(', '),
328
+ g.exceptionType,
329
+ last && `last ${last}`,
330
+ g.sampleTraceIds?.[0] && `trace ${g.sampleTraceIds[0]}`,
331
+ ].filter(Boolean).join(' · ');
332
+ if (meta) console.log(ui.c.dim(` ${meta}`));
333
+ }
334
+ console.log('');
335
+ console.log(ui.c.dim(' A group\'s requests: securenow logs trace <traceId> Ask why: securenow forensics chat --app <key> "<question>"'));
336
+ console.log('');
337
+ }
338
+
297
339
  async function logsTrace(args, flags) {
298
340
  requireAuth();
299
341
  const traceId = args[0];
@@ -499,6 +541,7 @@ module.exports = {
499
541
  tracesAnalyze,
500
542
  logsList,
501
543
  logsTrace,
544
+ logsErrors,
502
545
  notificationsList,
503
546
  notificationsRead,
504
547
  notificationsReadAll,
package/cli/review.js ADDED
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ // Alert Review: "is this rule right?". A rule fired on too many IPs, or on an IP SecureNow treats as
4
+ // your own infrastructure, so it was paused and the run waits for a person. Rows that share one root
5
+ // cause (same rule, route, evidence shape) are grouped so one decision can close them all. Closing a
6
+ // row never blocks or unblocks anything; keeping a rule on is the only write to the rule itself.
7
+
8
+ const { api, requireAuth } = require('./client');
9
+ const ui = require('./ui');
10
+
11
+ const OUTCOMES = ['rule_tuning', 'false_positive', 'clean', 'deferred', 'new_alert_rule', 'rate_limited', 'ambiguous', 'skipped', 'other'];
12
+
13
+ const VERDICTS = {
14
+ false_positive_pattern: 'looks benign',
15
+ rule_tuning: 'rule needs tuning',
16
+ real_probe: 'real probe',
17
+ ambiguous: 'unclear',
18
+ };
19
+
20
+ function verdictLabel(decisionTypes = {}) {
21
+ const keys = Object.keys(decisionTypes);
22
+ if (keys.length === 0) return '-';
23
+ if (keys.length > 1) return 'mixed';
24
+ return VERDICTS[keys[0]] || keys[0];
25
+ }
26
+
27
+ // Alerts carry app keys; print the names people know them by.
28
+ async function appNames() {
29
+ try {
30
+ const data = await api.get('/applications');
31
+ return new Map((data.applications || []).map((app) => [app.key, app.name]));
32
+ } catch {
33
+ return new Map();
34
+ }
35
+ }
36
+
37
+ function namesOf(keys, names) {
38
+ return (keys || []).map((key) => names.get(key) || key).join(', ') || '-';
39
+ }
40
+
41
+ function isNotificationId(ref) {
42
+ return /^[0-9a-f]{24}$/i.test(String(ref || ''));
43
+ }
44
+
45
+ async function fetchGroups(flags = {}) {
46
+ const query = { page: flags.page || 1, limit: flags.limit || 20 };
47
+ if (flags.search) query.search = flags.search;
48
+ return api.get('/notifications/alert-review-runs', { query });
49
+ }
50
+
51
+ // A group is named by its row on the current page or by its root-cause key (arc_...).
52
+ async function resolveGroup(ref, flags = {}) {
53
+ if (!ref) {
54
+ ui.error('Group row or root-cause key required. Use `securenow review list` first.');
55
+ process.exit(1);
56
+ }
57
+ const text = String(ref).trim();
58
+ if (/^\d+$/.test(text)) {
59
+ // Rows are numbered across pages, as `review list` prints them.
60
+ const row = Math.max(1, parseInt(text, 10));
61
+ const limit = Number(flags.limit) || 20;
62
+ const data = await fetchGroups({ ...flags, limit, page: Math.ceil(row / limit) });
63
+ const group = (data.groups || [])[(row - 1) % limit];
64
+ if (!group) {
65
+ ui.error(`Row ${text} was not found. Run \`securenow review list\` again: the queue may have changed.`);
66
+ process.exit(1);
67
+ }
68
+ return group;
69
+ }
70
+ const data = await api.get('/notifications/alert-review-runs', { query: { rootCauseKey: text, limit: 1 } });
71
+ const group = (data.groups || [])[0];
72
+ if (!group) {
73
+ ui.error(`No open group "${text}". It may already be closed.`);
74
+ process.exit(1);
75
+ }
76
+ return group;
77
+ }
78
+
79
+ function requireReason(flags) {
80
+ const reason = flags.reason || flags.summary;
81
+ if (!reason) {
82
+ ui.error('Say why with --reason "...": the reason is kept on the alert as its decision report.');
83
+ process.exit(1);
84
+ }
85
+ return reason;
86
+ }
87
+
88
+ function requireOutcome(flags, fallback) {
89
+ const outcome = flags.outcome || fallback;
90
+ if (!OUTCOMES.includes(outcome)) {
91
+ ui.error(`--outcome must be one of: ${OUTCOMES.join(', ')}`);
92
+ process.exit(1);
93
+ }
94
+ return outcome;
95
+ }
96
+
97
+ async function list(args, flags) {
98
+ requireAuth();
99
+ const s = ui.spinner('Fetching Alert Review');
100
+ let data;
101
+ try {
102
+ data = await fetchGroups(flags);
103
+ const summary = data.summary || {};
104
+ s.stop(`${summary.totalRuns ?? 0} alert run${summary.totalRuns === 1 ? '' : 's'} to review in ${summary.totalGroups ?? 0} group${summary.totalGroups === 1 ? '' : 's'}`);
105
+ } catch (err) {
106
+ s.fail('Failed to fetch Alert Review');
107
+ throw err;
108
+ }
109
+ if (flags.json) { ui.json(data); return; }
110
+
111
+ const groups = data.groups || [];
112
+ console.log('');
113
+ if (!groups.length) {
114
+ ui.info('Nothing to review: no paused or noisy rule is waiting for a decision.');
115
+ console.log('');
116
+ return;
117
+ }
118
+ const offset = ((data.pagination?.page || 1) - 1) * (data.pagination?.limit || groups.length);
119
+ const rows = groups.map((group, index) => [
120
+ String(offset + index + 1),
121
+ String(group.count || 0),
122
+ ui.truncate(group.rootCause?.detectionName || group.title || '-', 40),
123
+ ui.truncate(group.rootCause?.routeTemplate || '-', 28),
124
+ verdictLabel(group.decisionTypes),
125
+ group.batchActionPreview?.canApplyOneDecision ? 'yes' : 'no',
126
+ group.lastOccurredAt ? ui.timeAgo(group.lastOccurredAt) : '-',
127
+ ]);
128
+ ui.table(['Row', 'Runs', 'Rule', 'Route', 'Looks like', 'One decision', 'Last'], rows, { maxColWidth: 44 });
129
+ console.log('');
130
+ ui.info('Inspect a group: securenow review show <row>');
131
+ ui.info('Rule is right, keep it on: securenow review keep <notificationId> --reason "..."');
132
+ ui.info('Close a whole group: securenow review group <row> --outcome rule_tuning --reason "..." [--preview]');
133
+ console.log('');
134
+ }
135
+
136
+ function printRun(run, names) {
137
+ ui.keyValue([
138
+ ['Alert', `${run.title || '-'} ${ui.c.dim(run.notificationId)}`],
139
+ ['Rule', `${run.alertRule?.name || run.rootCause?.alertRuleName || '-'}${run.alertRule?.status ? ` (${run.alertRule.status})` : ''}`],
140
+ ['Route', run.rootCause?.routeTemplate || '-'],
141
+ ['Looks like', VERDICTS[run.rootCause?.decisionType] || run.rootCause?.decisionType || '-'],
142
+ ['Who sent it', run.rootCause?.attributionType || '-'],
143
+ ['Apps', namesOf(run.applications, names)],
144
+ ['IP', run.primaryIp || '-'],
145
+ ['Evidence', `${run.evidenceQuality?.score ?? '-'} / 100${run.proofContract?.missingProof?.length ? `, missing: ${run.proofContract.missingProof.join(', ')}` : ''}`],
146
+ ['Proposed guard', run.rootCause?.proposedGuard || '-'],
147
+ ['Last seen', run.lastOccurredAt ? ui.timeAgo(run.lastOccurredAt) : '-'],
148
+ ]);
149
+ }
150
+
151
+ async function show(args, flags) {
152
+ requireAuth();
153
+ const ref = args[0];
154
+ if (isNotificationId(ref)) {
155
+ const data = await api.get(`/notifications/alert-review-runs/${ref}`);
156
+ if (flags.json) { ui.json(data); return; }
157
+ ui.heading('Alert run');
158
+ printRun(data.run, await appNames());
159
+ if (data.group) {
160
+ console.log('');
161
+ ui.info(`${data.group.count} open run${data.group.count === 1 ? '' : 's'} share this root cause (${data.group.key}).`);
162
+ }
163
+ console.log('');
164
+ return;
165
+ }
166
+
167
+ const group = await resolveGroup(ref, flags);
168
+ if (flags.json) { ui.json(group); return; }
169
+ const names = await appNames();
170
+ ui.heading(group.title || 'Alert Review group');
171
+ ui.keyValue([
172
+ ['Key', group.key],
173
+ ['Runs', String(group.count)],
174
+ ['Looks like', verdictLabel(group.decisionTypes)],
175
+ ['Apps', namesOf(group.applications, names)],
176
+ ['IPs', `${(group.ips || []).length}${group.ips?.length ? `: ${group.ips.slice(0, 5).join(', ')}${group.ips.length > 5 ? ' ...' : ''}` : ''}`],
177
+ ['Evidence', `${group.evidenceQuality?.min ?? '-'}-${group.evidenceQuality?.max ?? '-'} / 100`],
178
+ ['Proposed guard', group.rootCause?.proposedGuard || '-'],
179
+ ['One decision', group.batchActionPreview?.canApplyOneDecision ? 'yes' : `no: ${group.batchActionPreview?.reason || 'single run'}`],
180
+ ]);
181
+ if (group.runs?.length) {
182
+ console.log('');
183
+ ui.table(['Alert id', 'IP', 'Evidence', 'Last'], group.runs.map((run) => [
184
+ run.notificationId,
185
+ run.primaryIp || '-',
186
+ String(run.evidenceQuality?.score ?? '-'),
187
+ run.lastOccurredAt ? ui.timeAgo(run.lastOccurredAt) : '-',
188
+ ]));
189
+ }
190
+ console.log('');
191
+ ui.info(`One run: securenow review show <alert id>`);
192
+ ui.info(`All runs: securenow review group ${group.key} --outcome rule_tuning --reason "..." --preview`);
193
+ console.log('');
194
+ }
195
+
196
+ async function keep(args, flags) {
197
+ requireAuth();
198
+ const id = args[0];
199
+ if (!isNotificationId(id)) {
200
+ ui.error('Usage: securenow review keep <notificationId> --reason "..." --yes');
201
+ process.exit(1);
202
+ }
203
+ const reason = requireReason(flags);
204
+ if (!flags.yes && !flags.force) {
205
+ ui.warn('This turns the rule back on and stops it from pausing itself again.');
206
+ const ok = await ui.confirm('Keep the rule on and close this run?');
207
+ if (!ok) return;
208
+ }
209
+ const s = ui.spinner('Keeping the rule on');
210
+ try {
211
+ const data = await api.post(`/notifications/alert-review-runs/${id}/keep-alive`, { confirm: true, reason, reportSource: 'cli' });
212
+ s.stop(`"${data.alertRule?.name || 'Rule'}" is ${data.verification?.ruleActive ? 'on' : data.alertRule?.status || 'unchanged'}; the run is closed`);
213
+ if (flags.json) ui.json(data);
214
+ } catch (err) {
215
+ s.fail('Failed to keep the rule on');
216
+ throw err;
217
+ }
218
+ }
219
+
220
+ async function closeOne(args, flags, mode) {
221
+ requireAuth();
222
+ const id = args[0];
223
+ if (!isNotificationId(id)) {
224
+ ui.error(`Usage: securenow review ${mode} <notificationId> ${mode === 'close' ? '--outcome rule_tuning ' : ''}--reason "..." --yes`);
225
+ process.exit(1);
226
+ }
227
+ const outcome = requireOutcome(flags, 'skipped');
228
+ const reason = requireReason(flags);
229
+ if (!flags.yes && !flags.force) {
230
+ const ok = await ui.confirm(`${mode === 'close' ? 'Close' : 'Dismiss'} this run (${outcome})? Nothing is blocked or unblocked.`);
231
+ if (!ok) return;
232
+ }
233
+ const route = mode === 'close' ? 'resolve' : 'dismiss';
234
+ const s = ui.spinner(mode === 'close' ? 'Closing the run' : 'Dismissing the run');
235
+ try {
236
+ const data = await api.post(`/notifications/alert-review-runs/${id}/${route}`, {
237
+ confirm: true,
238
+ outcome,
239
+ reason,
240
+ summary: flags.summary || reason,
241
+ reportSource: 'cli',
242
+ });
243
+ s.stop(data.alreadyClosed ? 'Already closed' : `Run ${mode === 'close' ? 'closed' : 'dismissed'} (${outcome}); the reason is on the alert`);
244
+ if (flags.json) ui.json(data);
245
+ } catch (err) {
246
+ s.fail(`Failed to ${mode} the run`);
247
+ throw err;
248
+ }
249
+ }
250
+
251
+ async function group(args, flags) {
252
+ requireAuth();
253
+ const target = await resolveGroup(args[0], flags);
254
+ const preview = Boolean(flags.preview);
255
+ const outcome = preview ? flags.outcome : requireOutcome(flags);
256
+ const reason = preview ? flags.reason : requireReason(flags);
257
+ if (!preview && !flags.yes && !flags.force) {
258
+ ui.warn(`This closes ${target.count} run${target.count === 1 ? '' : 's'} of "${target.title}". Nothing is blocked or unblocked.`);
259
+ const ok = await ui.confirm(`Close the group (${outcome})?`);
260
+ if (!ok) return;
261
+ }
262
+ const s = ui.spinner(preview ? 'Previewing the group decision' : 'Closing the group');
263
+ try {
264
+ const data = await api.post('/notifications/alert-review-runs/apply-group-decision', {
265
+ rootCauseKey: target.key,
266
+ previewOnly: preview,
267
+ confirm: !preview,
268
+ allowMixed: Boolean(flags['allow-mixed']),
269
+ outcome,
270
+ reason,
271
+ summary: flags.summary || reason,
272
+ reportSource: 'cli',
273
+ });
274
+ if (preview) {
275
+ s.stop(`Would close ${data.willApplyCount} run${data.willApplyCount === 1 ? '' : 's'}`);
276
+ if (target.mixed) ui.warn('Mixed group: rows disagree on verdict or sender. Review each row, then pass --allow-mixed.');
277
+ } else {
278
+ s.stop(`Closed ${data.closedCount} of ${data.requestedCount}${data.failedCount ? `, ${data.failedCount} failed` : ''}; ${data.verification?.remainingOpenInGroup ?? 0} still open in the group`);
279
+ }
280
+ if (flags.json) ui.json(data);
281
+ } catch (err) {
282
+ s.fail(preview ? 'Preview failed' : 'Failed to close the group');
283
+ throw err;
284
+ }
285
+ }
286
+
287
+ module.exports = {
288
+ list,
289
+ show,
290
+ keep,
291
+ close: (args, flags) => closeOne(args, flags, 'close'),
292
+ dismiss: (args, flags) => closeOne(args, flags, 'dismiss'),
293
+ group,
294
+ OUTCOMES,
295
+ };