securenow 8.18.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
@@ -244,7 +244,41 @@ securenow notifications read-all # mark all as read
244
244
  securenow notifications unread # unread count
245
245
  ```
246
246
 
247
- ### 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?
248
282
 
249
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.
250
284
 
@@ -254,6 +288,7 @@ securenow human list --limit 20
254
288
  securenow human show 1 # inspect row 1 with AI report, investigation steps, proofs, trace links
255
289
  securenow human block 1 --yes --reason "AI evidence confirmed malicious"
256
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
257
292
  securenow human action 1 --status rejected --yes --reason "Tuning guard is too broad"
258
293
  securenow human prompt 1 # print a Codex/Claude MCP prompt for this row
259
294
  securenow human work --limit 10 # list queue + print MCP runbook for deep queue work
@@ -266,9 +301,26 @@ MCP parity:
266
301
  - `securenow_human_action_report`
267
302
  - `securenow_human_action_block`
268
303
  - `securenow_human_action_false_positive`
304
+ - `securenow_human_action_close_no_write`
269
305
  - `securenow_human_case_action_update`
270
306
  - prompts: `investigate_human_action_row`, `work_human_actions`
271
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
+
272
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.
273
325
 
274
326
  ### Alerts
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;
@@ -636,6 +683,7 @@ module.exports = {
636
683
  block,
637
684
  fp,
638
685
  report,
686
+ close,
639
687
  prompt,
640
688
  work,
641
689
  promotions,
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
+ };
package/cli.js CHANGED
@@ -249,8 +249,8 @@ const COMMANDS = {
249
249
  defaultSub: 'list',
250
250
  },
251
251
  human: {
252
- desc: 'Work the human action queue prepared by SecureNow AI',
253
- usage: 'securenow human <list|show|block|fp|report|action|prompt|work|promotions|approve-promotion|deny-promotion> [row|notificationId:ip|request-id] [options]',
252
+ desc: 'Requires Human: decisions SecureNow prepared but won\'t make alone (block an IP, false positive, close, approve a plan)',
253
+ usage: 'securenow human <list|show|block|fp|close|report|action|prompt|work|promotions|approve-promotion|deny-promotion> [row|notificationId:ip|request-id] [options]',
254
254
  flags: {
255
255
  json: 'Output as JSON',
256
256
  note: 'With approve-promotion / deny-promotion: a note recorded with the decision',
@@ -284,8 +284,9 @@ const COMMANDS = {
284
284
  show: { desc: 'Show one row with AI report, proofs, investigation steps, and trace links', usage: 'securenow human show <row|notificationId:ip>', run: (a, f) => require('./cli/human').show(a, f) },
285
285
  block: { desc: 'Approve the AI block recommendation for a row', usage: 'securenow human block <row|notificationId:ip> --yes --reason "..."', run: (a, f) => require('./cli/human').block(a, f) },
286
286
  fp: { desc: 'Mark a row as a scoped false positive', usage: 'securenow human fp <row|notificationId:ip> --yes --reason "..."', run: (a, f) => require('./cli/human').fp(a, f) },
287
- report: { desc: 'Record a structured decision report on a row without changing status', usage: 'securenow human report <row|notificationId:ip> --yes --outcome ambiguous --summary "..."', run: (a, f) => require('./cli/human').report(a, f) },
288
- action: { desc: 'Approve/reject/execute a case-level proposed action', usage: 'securenow human action <row|notificationId> [actionKey] --status approved --yes --reason "..."', run: (a, f) => require('./cli/human').action(a, f) },
287
+ close: { desc: 'Close an IP row without blocking it (not now, rule needs tuning, rate-limited...): it leaves the queue, the reason stays on the case', usage: 'securenow human close <row|notificationId:ip> --outcome deferred|rule_tuning|new_alert_rule|ambiguous|skipped|rate_limited|other --reason "..." [--missing-proof "..."] --yes', run: (a, f) => require('./cli/human').close(a, f) },
288
+ report: { desc: 'Add a decision note to a row; the row stays in the queue (use close to remove it)', usage: 'securenow human report <row|notificationId:ip> --yes --outcome ambiguous --summary "..."', run: (a, f) => require('./cli/human').report(a, f) },
289
+ action: { desc: 'Approve/reject a case-level plan. approve_block blocks; approving tune_rule/create_exclusion only records it (change the rule first)', usage: 'securenow human action <row|notificationId> [actionKey] --status approved --yes --reason "..."', run: (a, f) => require('./cli/human').action(a, f) },
289
290
  prompt: { desc: 'Print a Codex/Claude MCP prompt for row or queue work', usage: 'securenow human prompt [row|notificationId:ip] [--limit 10]', run: (a, f) => require('./cli/human').prompt(a, f) },
290
291
  work: { desc: 'List the queue and print the MCP runbook to work it deeply', usage: 'securenow human work [--limit 10]', run: (a, f) => require('./cli/human').work(a, f) },
291
292
  promotions: { desc: 'List requests to promote a test-mode rule to prod (agents ask, a person decides)', usage: 'securenow human promotions [--status pending|approved|denied|all]', run: (a, f) => require('./cli/human').promotions(a, f) },
@@ -294,6 +295,32 @@ const COMMANDS = {
294
295
  },
295
296
  defaultSub: 'list',
296
297
  },
298
+ review: {
299
+ desc: 'Alert Review: "is this rule right?" Rules that fired too wide or on your own infrastructure, paused until someone decides',
300
+ usage: 'securenow review <list|show|keep|close|dismiss|group> [row|rootCauseKey|notificationId] [options]',
301
+ flags: {
302
+ json: 'Output as JSON',
303
+ page: 'Groups page number',
304
+ limit: 'Groups page size',
305
+ search: 'Search rule, route, IP, or app name',
306
+ outcome: 'Why it closes: rule_tuning, false_positive, clean, deferred, new_alert_rule, rate_limited, ambiguous, skipped, other',
307
+ reason: 'Why, in one sentence: kept on the alert as its decision report',
308
+ summary: 'Longer decision summary (defaults to --reason)',
309
+ preview: 'With group: show what would close, change nothing',
310
+ 'allow-mixed': 'With group: close a group whose rows disagree, after reviewing every row',
311
+ yes: 'Confirm without prompting',
312
+ force: 'Alias for --yes',
313
+ },
314
+ sub: {
315
+ list: { desc: 'List open alert runs grouped by root cause (one decision can close a group)', usage: 'securenow review list [--search lab-auth]', run: (a, f) => require('./cli/review').list(a, f) },
316
+ show: { desc: 'Show a group (row or arc_ key) or one alert run (notification id)', usage: 'securenow review show <row|rootCauseKey|notificationId>', run: (a, f) => require('./cli/review').show(a, f) },
317
+ keep: { desc: 'The rule is right: turn it back on, stop it pausing itself, close the run', usage: 'securenow review keep <notificationId> --reason "..." --yes', run: (a, f) => require('./cli/review').keep(a, f) },
318
+ close: { desc: 'Close one run with an outcome; nothing is blocked or unblocked', usage: 'securenow review close <notificationId> --outcome rule_tuning --reason "..." --yes', run: (a, f) => require('./cli/review').close(a, f) },
319
+ dismiss: { desc: 'Dismiss one run (not worth acting on); nothing is blocked or unblocked', usage: 'securenow review dismiss <notificationId> --reason "..." --yes', run: (a, f) => require('./cli/review').dismiss(a, f) },
320
+ group: { desc: 'Close every run in a group with one decision; --preview first', usage: 'securenow review group <row|rootCauseKey> --outcome rule_tuning --reason "..." [--preview] [--allow-mixed] --yes', run: (a, f) => require('./cli/review').group(a, f) },
321
+ },
322
+ defaultSub: 'list',
323
+ },
297
324
  agent: {
298
325
  desc: 'SOC runbook prompts for running the SecureNow agent work with your own harness (Claude, Hermes, OpenClaw, ...)',
299
326
  usage: 'securenow agent <prompt|prompts> [patrol|alert-review|human-queue] [options]',
@@ -854,7 +881,7 @@ function showHelp(commandName) {
854
881
  'Authentication': ['login', 'logout', 'whoami', 'credentials'],
855
882
  'Applications': ['apps', 'init', 'status'],
856
883
  'Observe': ['traces', 'logs', 'analytics'],
857
- 'Detect & Respond': ['human', 'notifications', 'alerts', 'fp'],
884
+ 'Detect & Respond': ['human', 'review', 'notifications', 'alerts', 'fp'],
858
885
  'Investigate': ['ip', 'forensics'],
859
886
  'Firewall': ['firewall'],
860
887
  'Remediation': ['automation', 'ratelimit', 'challenge', 'blocklist', 'revoke', 'allowlist', 'trusted'],
package/mcp/catalog.js CHANGED
@@ -532,7 +532,7 @@ const TOOLS = [
532
532
  rootCauseKey: string('Optional root-cause group key to filter to one group.'),
533
533
  groupKey: string('Compatibility alias for rootCauseKey.'),
534
534
  includeRuns: boolean('Whether to include compact matching run rows in the response.'),
535
- maxScan: number('Maximum pending-review rows to scan before grouping. Defaults to 500, capped at 1000.', { minimum: 1, maximum: 1000 }),
535
+ maxScan: number('Maximum pending-review rows to scan before grouping. Defaults to 1000 (the dashboard count), the maximum.', { minimum: 1, maximum: 1000 }),
536
536
  }),
537
537
  },
538
538
  {
@@ -747,7 +747,7 @@ const TOOLS = [
747
747
  {
748
748
  name: 'securenow_human_action_close_no_write',
749
749
  title: 'Close Human Action Without Enforcement',
750
- description: 'Close one Requires Human IP row with an auditable no-write decision report. Use for ambiguous, skipped, rule-tuning-needed, new-rule-needed, rate-limited, or already-handled rows.',
750
+ description: 'Close one Requires Human IP row without blocking or marking it a false positive, with an auditable decision report (outcome: rate_limited, deferred, rule_tuning, new_alert_rule, ambiguous, skipped or other). This is how a "not now / not enforceable" decision leaves the queue; a decision report on its own does not.',
751
751
  scope: 'notifications:write',
752
752
  readOnly: false,
753
753
  confirm: true,
@@ -792,7 +792,7 @@ const TOOLS = [
792
792
  {
793
793
  name: 'securenow_human_case_action_update',
794
794
  title: 'Update Case-Level Human Action',
795
- description: 'Approve, reject, execute, or fail a case-level proposed action such as tune_rule or create_exclusion. Write action; requires confirmation.',
795
+ description: 'Approve, reject, execute, or fail a case-level proposed action such as tune_rule or create_exclusion. Approving approve_block blocks the IPs and investigate_cluster starts investigations; approving tune_rule, create_exclusion or review_protected_ip only records the decision (apply the rule change first). Write action; requires confirmation.',
796
796
  scope: 'notifications:write',
797
797
  readOnly: false,
798
798
  confirm: true,
@@ -2463,10 +2463,11 @@ const PROMPTS = [
2463
2463
  {
2464
2464
  name: 'soc_patrol',
2465
2465
  title: 'SOC Patrol (Scheduled)',
2466
- description: 'The recurring SOC pass SecureNow\'s built-in agent used to run on a 5-minute cron. Run it from your own harness (Claude, Hermes, OpenClaw, ...) on a schedule: Alert Review root-cause fixes first, then the Requires Human queue.',
2466
+ description: 'One bounded pass over both review queues, meant to run on a schedule from your own agent (Claude Code, Codex, Cursor, ...): Alert Review rule fixes first, then the Requires Human queue.',
2467
2467
  arguments: [
2468
2468
  { name: 'maxAlertReviewGroups', description: 'Maximum alert-review root-cause groups to work this pass. Defaults to 10.', required: false },
2469
2469
  { name: 'maxHumanRows', description: 'Maximum Requires Human rows to work this pass. Defaults to 10.', required: false },
2470
+ { name: 'search', description: 'Optional filter applied to both queues (an app name, rule, route or IP), e.g. to let a pass write only on one app.', required: false },
2470
2471
  { name: 'confirmWrites', description: 'Set true only when this scheduled run is allowed to execute decisions autonomously.', required: false },
2471
2472
  ],
2472
2473
  },
@@ -2595,7 +2596,8 @@ function promptMessages(name, args = {}) {
2595
2596
  `Review up to ${limit} row(s), most urgent first.${args.search ? ` Search filter: ${args.search}.` : ''}`,
2596
2597
  'Start with securenow_human_actions_list. For each row, call securenow_notifications_get and securenow_human_action_report, inspect the AI report/investigation steps/proofs/trace IDs, and fetch trace/log evidence where useful.',
2597
2598
  'For each row choose exactly one outcome: Block IP, Rate Limit, False Positive, Rule Tuning Applied/Needed, New Alert Rule Needed, Case Action Approved/Rejected, or Ambiguous/Skipped with exact missing proof.',
2598
- 'Finished does not always mean enforcement changed. A row is also finished when a structured no-write decision report records the exact proof gap, missing permission, or vague guard and no weaker substitute write is used.',
2599
+ 'A row leaves the queue only when its status changes: a block, a false positive, an approved or rejected case action, or securenow_human_action_close_no_write (closes an IP row without enforcement, with an outcome and the exact proof gap, missing permission or vague guard). A decision report alone (securenow_human_action_decision_report_add) only adds a note: the row stays open.',
2600
+ 'Approving a tune_rule, create_exclusion or review_protected_ip case action records the decision and closes the row; it does not change the rule by itself. Apply the change first (rule tools), then approve. Reject a plan that is vague or wrong.',
2599
2601
  'If a global/system rule update is the right fix but access is denied, do not mark customer-specific false positives just to clear the row. Prepare the exact attack-preserving guard/SQL, dry-run it when possible, and record outcome=rule_tuning with missingProof.',
2600
2602
  'If evidence is mixed, do partial resolution: block proven malicious IPs, false-positive exact benign shapes, rate-limit route-specific volume abuse, and leave ambiguous IPs with their missing proof.',
2601
2603
  'Reject vague AI proposed actions. If the prompt/action lacks concrete rule id, path, method, status, user-agent/body/header guard, benign samples, and attack examples that still match, record it as ambiguous or rule_tuning needed.',
@@ -2603,9 +2605,9 @@ function promptMessages(name, args = {}) {
2603
2605
  'Do not rate-limit confirmed high-risk attacks that should be blocked, benign traffic that should be false-positive scoped, broad noisy rules that need tuning, app-server/proxy attribution problems, or isolated one-off requests.',
2604
2606
  'Do not create or recommend IP Allowlist entries while working this queue unless the user explicitly approves deny-by-default allowlist mode for the whole app/environment. Use Trusted IPs for trusted/bypass cases.',
2605
2607
  confirmWrites
2606
- ? 'The user requested execution. For supported decisions, call the correct write tool with confirm:true, a precise reason, and a decisionReport containing summary, evidence, reviewedHistory, traceIds, and missingProof when relevant, then continue. For skipped/ambiguous/rule-tuning-needed/new-rule-needed rows that should be auditable without changing enforcement status, use securenow_human_action_decision_report_add or securenow_human_case_decision_report_add.'
2607
- : 'Do not execute write tools yet. Produce a row-by-row action plan and exact MCP write calls for user approval.',
2608
- 'For block decisions, use securenow_human_action_block. For rate-limit decisions, use securenow_rate_limit_create_from_text and then securenow_human_action_decision_report_add with outcome=rate_limited. For false positives, use securenow_human_action_false_positive with restrictive conditions. For case-level tune_rule/create_exclusion rows, inspect securenow_notifications_get and then use securenow_human_case_action_update only when the action is safe to approve/reject.',
2608
+ ? 'The user requested execution. For supported decisions, call the correct write tool with confirm:true, a precise reason, and a decisionReport containing summary, evidence, reviewedHistory, traceIds, and missingProof when relevant, then continue. For IP rows you decide not to enforce (skipped, ambiguous, rule tuning needed, new rule needed, already rate-limited), close them with securenow_human_action_close_no_write and that outcome so they leave the queue.'
2609
+ : 'Do not execute write tools yet. Read-only tools are fine, and so are the dry-run test tools (securenow_alert_rule_candidate_test, securenow_alert_rule_condition_candidate_test, securenow_alert_rule_condition_diff): they change nothing (pass confirm:true; they need an alerts:write key, so on a read-only key mark the guard untested). Produce a row-by-row action plan and exact MCP write calls for user approval.',
2610
+ 'For block decisions, use securenow_human_action_block. For rate-limit decisions, use securenow_rate_limit_create_from_text and then close the row with securenow_human_action_close_no_write outcome=rate_limited. For false positives, use securenow_human_action_false_positive with restrictive conditions. For case-level tune_rule/create_exclusion rows, inspect securenow_notifications_get and then use securenow_human_case_action_update: approve only after the change is applied, reject when the plan is vague or wrong.',
2609
2611
  'End with counts: handled, blocked/proposed block, rate limits created/proposed, false positives, rule tuning/admin permission blockers with exact guard, partial resolutions, skipped with missing proof, and still waiting.',
2610
2612
  ].join('\n'),
2611
2613
  },
@@ -2632,7 +2634,7 @@ function promptMessages(name, args = {}) {
2632
2634
  'After a rule fix, close the covered rows with securenow_alert_review_run_apply_group_decision (use previewOnly:true first; set allowMixed only after reviewing every row). For correct detections use securenow_alert_review_run_keep_alive. For one-off rows use securenow_alert_review_run_resolve or securenow_alert_review_run_dismiss with a full decision report.',
2633
2635
  confirmWrites
2634
2636
  ? 'The user requested execution. Call write tools with confirm:true, a precise reason, and a decisionReport containing summary, evidence, reviewedHistory, traceIds, and missingProof when relevant.'
2635
- : 'Do not execute write tools yet. Produce a group-by-group plan with the exact validated guards and MCP write calls for user approval.',
2637
+ : 'Do not execute write tools yet. Read-only tools are fine, and so are the dry-run test tools (securenow_alert_rule_candidate_test, securenow_alert_rule_condition_candidate_test, securenow_alert_rule_condition_diff): they change nothing (pass confirm:true), so validate every guard you propose. They need an alerts:write key: on a read-only key, mark each guard untested instead. Produce a group-by-group plan with the exact validated guards and MCP write calls for user approval.',
2636
2638
  'Never resolve a group as false positive without concrete benign samples AND proof the guard preserves attack detection. When uncertain, keep the rule alive and leave the row open with the missing proof named.',
2637
2639
  'End with counts: groups worked, rules tuned (with the exact guards), rows resolved/dismissed/kept alive, rows left open with missing proof, and any permission blockers.',
2638
2640
  ].join('\n'),
@@ -2651,23 +2653,24 @@ function promptMessages(name, args = {}) {
2651
2653
  content: {
2652
2654
  type: 'text',
2653
2655
  text: [
2654
- 'Run one scheduled SecureNow SOC patrol pass using the MCP tools. This is the recurring pass the built-in SecureNow agent used to run every 5 minutes; you are its external replacement, so be conservative, auditable, and finish in one bounded pass.',
2656
+ 'Run one scheduled SecureNow SOC patrol pass using the MCP tools. It works the two review queues: Alert Review ("is this rule right?": noisy or self-involving alert runs) and Requires Human ("what do we do about this IP or plan?": decisions SecureNow prepared but will not make alone). Be conservative and auditable, and finish in one bounded pass.',
2655
2657
  '',
2656
2658
  'Phase 0 — check for work:',
2657
2659
  '- Call securenow_alert_review_runs_count and securenow_human_actions_list (page=1, limit=1).',
2658
2660
  '- If both queues are empty, stop and report "no open SOC work".',
2661
+ ...(args.search ? [`- Scope: only work rows and groups matching "${args.search}" (pass it as the search argument to the list tools); leave everything else for another pass.`] : []),
2659
2662
  '',
2660
2663
  'Phase 1 — Alert Review first (root-cause rule fixes are the upstream fix; they auto-resolve downstream human work):',
2661
2664
  `- Follow the work_alert_review runbook for up to ${maxAlertReviewGroups} root-cause group(s): list groups, inspect prototypes and siblings, validate narrow attack-preserving guards (two or more of path/method/status_code/user_agent) with condition tests/diffs before applying, tune rules, batch-resolve covered rows, keep real detections alive, and leave unclear rows open with the exact missing proof.`,
2662
2665
  '',
2663
2666
  'Phase 2 — Requires Human queue:',
2664
- `- Follow the work_human_actions runbook for up to ${maxHumanRows} row(s): list the queue most urgent first, read each AI report and trace evidence, then choose exactly one outcome per row (block, rate limit, scoped false positive, rule tuning, case action approve/reject, or an auditable no-write decision report with the missing proof).`,
2667
+ `- Follow the work_human_actions runbook for up to ${maxHumanRows} row(s): list the queue most urgent first, read each AI report and trace evidence, then choose exactly one outcome per row (block, rate limit, scoped false positive, rule tuning, case action approve/reject, or close without enforcement via securenow_human_action_close_no_write with the missing proof). A decision report alone does not close a row.`,
2665
2668
  '- Re-check rows against any rule changes from Phase 1 before deciding — a Phase 1 fix often already covers them.',
2666
2669
  '',
2667
2670
  'Safety (this run may be unattended):',
2668
2671
  confirmWrites
2669
2672
  ? '- Writes are allowed this pass: call write tools with confirm:true, a precise reason, and a full decisionReport. Never block without high-confidence malicious evidence (riskScore >= 90 or equivalent proof). Never create broad false positives or single-condition exclusions. Prefer an auditable no-write decision report whenever evidence is mixed.'
2670
- : '- Read-only pass: do NOT call write tools. Produce the exact prepared write calls (with guards and reasons) as a reviewable plan.',
2673
+ : '- Read-only pass: do NOT call write tools, except the dry-run test tools (securenow_alert_rule_candidate_test, securenow_alert_rule_condition_candidate_test, securenow_alert_rule_condition_diff), which change nothing (on a read-only key they are refused: mark those guards untested). Produce the exact prepared write calls (with guards and reasons) as a reviewable plan.',
2671
2674
  '- Never trust an IP globally, never create IP allowlist entries, never suppress on attacker-controllable fields alone.',
2672
2675
  '- Stay within the row/group budgets; leave the rest for the next scheduled pass.',
2673
2676
  '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.18.0",
3
+ "version": "8.19.0",
4
4
  "mcpName": "ai.securenow/securenow",
5
5
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
6
6
  "type": "commonjs",