securenow 7.6.4 → 7.6.6

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/NPM_README.md CHANGED
@@ -1236,7 +1236,6 @@ Legacy env fallback aliases are listed below for existing installs only.
1236
1236
  |----------|-------------|---------|
1237
1237
  | `SECURENOW_API_KEY` | Legacy firewall key override. Prefer `apiKey` in `.securenow/credentials.json`. | from creds file |
1238
1238
  | `SECURENOW_API_URL` | SecureNow API base URL. Auto-detected for co-located deployments (falls back to `http://localhost:4000` on ECONNREFUSED). | `https://api.securenow.ai` |
1239
- | `SECURENOW_FIREWALL_ENABLED` | Master kill-switch. Set to `0` to disable. | `1` |
1240
1239
  | `SECURENOW_FIREWALL_VERSION_INTERVAL` | Seconds between version checks (lightweight ETag-based). | `10` |
1241
1240
  | `SECURENOW_FIREWALL_SYNC_INTERVAL` | Full blocklist refresh interval in seconds (safety net). | `300` |
1242
1241
  | `SECURENOW_FIREWALL_FAIL_MODE` | `open` (allow when unavailable) or `closed` (block all). | `open` |
package/SKILL-API.md CHANGED
@@ -503,10 +503,9 @@ Local development and production use `.securenow/credentials.json`. Every settin
503
503
  | Variable | Description | Default |
504
504
  |----------|-------------|---------|
505
505
  | `SECURENOW_API_KEY` | Legacy env override for the `apiKey` field (`snk_live_...`). Since v7.5.1, login writes the scoped firewall key to `.securenow/credentials.json`. | - |
506
- | `SECURENOW_API_URL` | SecureNow API base URL | `https://api.securenow.ai` |
507
- | `SECURENOW_FIREWALL_ENABLED` | Master kill-switch (`0` to disable) | `1` |
508
- | `SECURENOW_FIREWALL_VERSION_INTERVAL` | Seconds between lightweight version checks | `10` |
509
- | `SECURENOW_FIREWALL_SYNC_INTERVAL` | Full blocklist refresh interval in seconds | `300` |
506
+ | `SECURENOW_API_URL` | SecureNow API base URL | `https://api.securenow.ai` |
507
+ | `SECURENOW_FIREWALL_VERSION_INTERVAL` | Seconds between lightweight version checks | `10` |
508
+ | `SECURENOW_FIREWALL_SYNC_INTERVAL` | Full blocklist refresh interval in seconds | `300` |
510
509
  | `SECURENOW_FIREWALL_FAIL_MODE` | `open` (allow all when unavailable) or `closed` | `open` |
511
510
  | `SECURENOW_FIREWALL_STATUS_CODE` | HTTP status for blocked requests | `403` |
512
511
  | `SECURENOW_FIREWALL_LOG` | Log blocked requests | `1` |
package/SKILL-CLI.md CHANGED
@@ -203,20 +203,45 @@ securenow logs list --app my-app --env production --minutes 30 --level error
203
203
  securenow logs trace <traceId> --env production # logs correlated to a specific trace
204
204
  ```
205
205
 
206
- ### Notifications
207
-
208
- ```bash
209
- securenow notifications [--limit N] [--page P]
210
- securenow notifications list --limit 20
211
- securenow notifications read <id> # mark one as read
212
- securenow notifications read-all # mark all as read
213
- securenow notifications unread # unread count
214
- ```
215
-
216
- ### Alerts
217
-
218
- ```bash
219
- securenow alerts # list alert rules (default)
206
+ ### Notifications
207
+
208
+ ```bash
209
+ securenow notifications [--limit N] [--page P]
210
+ securenow notifications list --limit 20
211
+ securenow notifications read <id> # mark one as read
212
+ securenow notifications read-all # mark all as read
213
+ securenow notifications unread # unread count
214
+ ```
215
+
216
+ ### Human Actions — AI-Prepared Decision Queue
217
+
218
+ 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.
219
+
220
+ ```bash
221
+ securenow human # list human decisions, most urgent first
222
+ securenow human list --limit 20
223
+ securenow human show 1 # inspect row 1 with AI report, DAG, proofs, trace links
224
+ securenow human block 1 --yes --reason "AI evidence confirmed malicious"
225
+ securenow human fp 1 --yes --reason "Scoped false positive after evidence review"
226
+ securenow human prompt 1 # print a Codex/Claude MCP prompt for this row
227
+ securenow human work --limit 10 # list queue + print MCP runbook for deep queue work
228
+ ```
229
+
230
+ MCP parity:
231
+
232
+ - `securenow_human_actions_list`
233
+ - `securenow_notifications_get`
234
+ - `securenow_human_action_report`
235
+ - `securenow_human_action_block`
236
+ - `securenow_human_action_false_positive`
237
+ - prompts: `investigate_human_action_row`, `work_human_actions`
238
+
239
+ 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.
240
+
241
+ ### Alerts
242
+
243
+ ```bash
244
+ securenow alerts # list alert rules (default)
220
245
  securenow alerts rules # list alert rules (columns: Status, Applications, Schedule)
221
246
  securenow alerts rules show <id> # one rule; JSON: --json
222
247
  securenow alerts rules update <id> --applications-all # all current & future apps
package/app-config.js CHANGED
@@ -131,7 +131,6 @@ const ENV_TO_CONFIG_PATH = Object.freeze({
131
131
  SECURENOW_STRICT: 'runtime.strict',
132
132
  SECURENOW_TEST_SPAN: 'runtime.testSpan',
133
133
  SECURENOW_HIDE_BANNER: 'runtime.hideBanner',
134
- SECURENOW_FIREWALL_ENABLED: 'firewall.enabled',
135
134
  SECURENOW_API_URL: 'firewall.apiUrl',
136
135
  SECURENOW_FIREWALL_VERSION_INTERVAL: 'firewall.versionCheckInterval',
137
136
  SECURENOW_FIREWALL_SYNC_INTERVAL: 'firewall.syncInterval',
@@ -548,6 +547,7 @@ function resolveEnvKey(key) {
548
547
  if (upper === 'OTEL_SERVICE_NAME') return resolveAppName();
549
548
  if (upper === 'SECURENOW_API_KEY') return resolveApiKey();
550
549
  if (upper === 'SECURENOW_INSTANCE' || upper === 'OTEL_EXPORTER_OTLP_ENDPOINT') return resolveInstance();
550
+ if (upper === 'SECURENOW_FIREWALL_ENABLED') return resolveFirewallEnabled();
551
551
 
552
552
  const configPath = ENV_TO_CONFIG_PATH[upper];
553
553
  if (configPath) return resolveConfigPath(configPath);
@@ -597,12 +597,19 @@ function resolveEndpoints(options = {}) {
597
597
  };
598
598
  }
599
599
 
600
+ function resolveFirewallEnabled() {
601
+ const fromConfig = pick(getPath(loadCredentials()?.config, 'firewall.enabled'));
602
+ if (fromConfig != null) return parseBool(fromConfig, true);
603
+
604
+ return parseBool(getPath(DEFAULT_CONFIG, 'firewall.enabled'), true);
605
+ }
606
+
600
607
  function resolveFirewallOptions() {
601
608
  return {
602
609
  apiKey: resolveApiKey(),
603
610
  appKey: resolveAppKey() || null,
604
611
  environment: resolveDeploymentEnvironment(),
605
- enabled: boolEnv('SECURENOW_FIREWALL_ENABLED', true),
612
+ enabled: resolveFirewallEnabled(),
606
613
  apiUrl: env('SECURENOW_API_URL') || DEFAULT_API_URL,
607
614
  versionCheckInterval: numberEnv('SECURENOW_FIREWALL_VERSION_INTERVAL', 10, 1),
608
615
  syncInterval: numberEnv('SECURENOW_FIREWALL_SYNC_INTERVAL', 300, 1),
@@ -651,6 +658,7 @@ module.exports = {
651
658
  resolveOtlpHeaders,
652
659
  resolveOtlpHeaderString,
653
660
  resolveEndpoints,
661
+ resolveFirewallEnabled,
654
662
  resolveFirewallOptions,
655
663
  env,
656
664
  boolEnv,
package/cli/auth.js CHANGED
@@ -237,7 +237,7 @@ async function login(args, flags) {
237
237
  const email = payload?.email || 'unknown';
238
238
  const exp = payload?.exp ? payload.exp * 1000 : null;
239
239
 
240
- config.setAuth(token, email, exp, { local });
240
+ config.setAuth(token, email, exp, { local, enableFirewall: true });
241
241
  if (local) config.ensureLocalGitignore();
242
242
  console.log('');
243
243
  ui.success(`Logged in as ${ui.c.bold(email)}`);
@@ -255,7 +255,7 @@ async function login(args, flags) {
255
255
  const email = payload?.email || 'unknown';
256
256
  const exp = payload?.exp ? payload.exp * 1000 : null;
257
257
 
258
- config.setAuth(token, email, exp, { local, app });
258
+ config.setAuth(token, email, exp, { local, app, enableFirewall: true });
259
259
  if (apiKey) config.setApiKey(apiKey, { local });
260
260
  if (local) config.ensureLocalGitignore();
261
261
  console.log('');
package/cli/config.js CHANGED
@@ -101,11 +101,22 @@ function saveCredentials(creds, { local = false } = {}) {
101
101
  saveJSON(targetFile, appConfig.withCredentialDefaults(creds) || {});
102
102
  }
103
103
 
104
- function ensureCredentialDefaults({ local } = {}) {
104
+ function withOnboardingFirewallEnabled(creds) {
105
+ const payload = appConfig.withCredentialDefaults(creds || {}) || {};
106
+ payload.config = payload.config || {};
107
+ payload.config.firewall = payload.config.firewall || {};
108
+ payload.config.firewall.enabled = true;
109
+ return payload;
110
+ }
111
+
112
+ function ensureCredentialDefaults({ local, enableFirewall = false } = {}) {
105
113
  const useLocal = local === true || (local == null && hasLocalCredentials());
106
114
  const targetFile = credentialsFileForLocal(useLocal);
107
115
  const existing = loadJSON(targetFile);
108
- saveJSON(targetFile, appConfig.withCredentialDefaults(existing || {}) || {});
116
+ const payload = enableFirewall
117
+ ? withOnboardingFirewallEnabled(existing || {})
118
+ : appConfig.withCredentialDefaults(existing || {}) || {};
119
+ saveJSON(targetFile, payload);
109
120
  }
110
121
 
111
122
  function clearCredentials({ local } = {}) {
@@ -132,7 +143,7 @@ function getToken() {
132
143
  return creds.token;
133
144
  }
134
145
 
135
- function setAuth(token, email, expiresAt, { local = false, app = null } = {}) {
146
+ function setAuth(token, email, expiresAt, { local = false, app = null, enableFirewall = false } = {}) {
136
147
  const targetFile = credentialsFileForLocal(local);
137
148
  const payload = { ...loadJSON(targetFile), token, email, expiresAt };
138
149
  if (app && (app.key || app.name || app.instance)) {
@@ -142,7 +153,10 @@ function setAuth(token, email, expiresAt, { local = false, app = null } = {}) {
142
153
  instance: app.instance || null,
143
154
  };
144
155
  }
145
- saveCredentials(payload, { local });
156
+ saveJSON(
157
+ targetFile,
158
+ enableFirewall ? withOnboardingFirewallEnabled(payload) : appConfig.withCredentialDefaults(payload) || {}
159
+ );
146
160
  }
147
161
 
148
162
  function getApp() {
@@ -154,7 +168,7 @@ function setApiKey(apiKey, { local } = {}) {
154
168
  const useLocal = local === true || (local == null && hasLocalCredentials());
155
169
  const targetFile = credentialsFileForLocal(useLocal);
156
170
  const existing = loadJSON(targetFile);
157
- saveJSON(targetFile, appConfig.withCredentialDefaults({ ...existing, apiKey }) || { apiKey });
171
+ saveJSON(targetFile, withOnboardingFirewallEnabled({ ...existing, apiKey }));
158
172
  }
159
173
 
160
174
  function clearApiKey({ local } = {}) {
@@ -238,6 +252,7 @@ module.exports = {
238
252
  getAuthSource,
239
253
  hasLocalCredentials,
240
254
  ensureCredentialDefaults,
255
+ withOnboardingFirewallEnabled,
241
256
  ensureLocalGitignore,
242
257
  getApiUrl,
243
258
  getAppUrl,
@@ -20,7 +20,7 @@ function buildRuntimeCredentials(options = {}) {
20
20
  options.env ||
21
21
  appConfig.resolveDeploymentEnvironment() ||
22
22
  'production';
23
- const runtime = appConfig.withCredentialDefaults({
23
+ const runtime = config.withOnboardingFirewallEnabled({
24
24
  apiKey: creds.apiKey || null,
25
25
  app: {
26
26
  key: creds.app?.key || null,
@@ -33,6 +33,10 @@ function buildRuntimeCredentials(options = {}) {
33
33
  ...(creds.config?.runtime || {}),
34
34
  deploymentEnvironment,
35
35
  },
36
+ firewall: {
37
+ ...(creds.config?.firewall || {}),
38
+ enabled: true,
39
+ },
36
40
  },
37
41
  _securenow: {
38
42
  ...(creds._securenow || {}),
package/cli/human.js ADDED
@@ -0,0 +1,366 @@
1
+ 'use strict';
2
+
3
+ const config = require('./config');
4
+ const { api, requireAuth } = require('./client');
5
+ const ui = require('./ui');
6
+
7
+ function appUrl(pathname) {
8
+ return `${config.getAppUrl()}${pathname}`;
9
+ }
10
+
11
+ function asArray(value) {
12
+ return Array.isArray(value) ? value : [];
13
+ }
14
+
15
+ function formatList(values, limit = 3) {
16
+ const list = asArray(values).filter(Boolean).map(String);
17
+ if (!list.length) return '-';
18
+ return list.slice(0, limit).join(', ') + (list.length > limit ? ` +${list.length - limit}` : '');
19
+ }
20
+
21
+ function confidenceLabel(value) {
22
+ if (value === null || value === undefined) return 'AI ready';
23
+ return `${Math.round(Number(value))}%`;
24
+ }
25
+
26
+ function taskLabel(task) {
27
+ return task?.headline || task?.title || task?.detectionName || 'AI investigation';
28
+ }
29
+
30
+ function parseTaskRef(ref) {
31
+ if (!ref) return null;
32
+ const text = String(ref).trim();
33
+ if (/^\d+$/.test(text)) return { kind: 'row', row: Math.max(1, parseInt(text, 10)) };
34
+ const colon = text.indexOf(':');
35
+ if (colon > 0) {
36
+ return { kind: 'id', notificationId: text.slice(0, colon), ip: text.slice(colon + 1) };
37
+ }
38
+ const slash = text.indexOf('/');
39
+ if (slash > 0) {
40
+ return { kind: 'id', notificationId: text.slice(0, slash), ip: text.slice(slash + 1) };
41
+ }
42
+ return { kind: 'notification', notificationId: text };
43
+ }
44
+
45
+ async function fetchQueue(flags = {}) {
46
+ const query = {
47
+ page: flags.page || 1,
48
+ limit: flags.limit || 20,
49
+ };
50
+ if (flags.search) query.search = flags.search;
51
+ return api.get('/notifications/approval-tasks', { query });
52
+ }
53
+
54
+ async function resolveTask(ref, flags = {}) {
55
+ const parsed = parseTaskRef(ref);
56
+ if (!parsed) {
57
+ ui.error('Task row or id required. Use `securenow human list` first.');
58
+ process.exit(1);
59
+ }
60
+
61
+ const queue = await fetchQueue({ ...flags, limit: flags.limit || 50 });
62
+ const tasks = queue.tasks || [];
63
+
64
+ if (parsed.kind === 'row') {
65
+ const task = tasks[parsed.row - 1];
66
+ if (!task) {
67
+ ui.error(`Row ${parsed.row} was not found on page ${flags.page || 1}.`);
68
+ process.exit(1);
69
+ }
70
+ return { task, queue, row: parsed.row };
71
+ }
72
+
73
+ const task = tasks.find((candidate) => (
74
+ candidate.id === ref ||
75
+ (candidate.notificationId === parsed.notificationId && (!parsed.ip || candidate.ip === parsed.ip))
76
+ ));
77
+
78
+ if (task) return { task, queue, row: tasks.indexOf(task) + 1 };
79
+
80
+ if (parsed.kind === 'id' && parsed.notificationId && parsed.ip) {
81
+ return {
82
+ task: {
83
+ id: `${parsed.notificationId}:${parsed.ip}`,
84
+ notificationId: parsed.notificationId,
85
+ ip: parsed.ip,
86
+ href: `/dashboard/notifications/ip/${parsed.notificationId}/${encodeURIComponent(parsed.ip)}`,
87
+ },
88
+ queue,
89
+ row: null,
90
+ };
91
+ }
92
+
93
+ ui.error(`Could not find task "${ref}". Use row number or <notificationId>:<ip>.`);
94
+ process.exit(1);
95
+ }
96
+
97
+ async function list(args, flags) {
98
+ requireAuth();
99
+ const s = ui.spinner('Fetching human action queue');
100
+ try {
101
+ const data = await fetchQueue(flags);
102
+ const tasks = data.tasks || [];
103
+ s.stop(`Found ${data.summary?.total ?? data.pagination?.total ?? tasks.length} human action${tasks.length === 1 ? '' : 's'}`);
104
+
105
+ if (flags.json) {
106
+ ui.json(data);
107
+ return;
108
+ }
109
+
110
+ console.log('');
111
+ if (!tasks.length) {
112
+ ui.info('No human decisions waiting.');
113
+ console.log('');
114
+ return;
115
+ }
116
+
117
+ const rows = tasks.map((task, index) => [
118
+ String(index + 1),
119
+ task.urgencyScore || 0,
120
+ ui.statusBadge(task.severity || 'info'),
121
+ task.ip || '-',
122
+ confidenceLabel(task.aiDecision?.confidence),
123
+ ui.truncate(formatList(task.proofs?.paths, 2), 28),
124
+ ui.truncate(taskLabel(task), 56),
125
+ task.lastSeenAt ? ui.timeAgo(task.lastSeenAt) : '-',
126
+ ]);
127
+ ui.table(['Row', 'Urgency', 'Severity', 'IP', 'Conf', 'Paths', 'Case', 'Seen'], rows, { maxColWidth: 60 });
128
+ console.log('');
129
+ ui.info('Inspect a row: securenow human show <row>');
130
+ ui.info('Use MCP deeply: securenow human prompt <row>');
131
+ console.log('');
132
+ } catch (err) {
133
+ s.fail('Failed to fetch human action queue');
134
+ throw err;
135
+ }
136
+ }
137
+
138
+ function printTaskSummary(task, row) {
139
+ ui.heading(row ? `Human action row ${row}` : 'Human action');
140
+ ui.keyValue([
141
+ ['Task ID', task.id || `${task.notificationId}:${task.ip}`],
142
+ ['IP', task.ip || '-'],
143
+ ['Case', taskLabel(task)],
144
+ ['Severity', task.severity || '-'],
145
+ ['Urgency', String(task.urgencyScore || 0)],
146
+ ['AI confidence', confidenceLabel(task.aiDecision?.confidence)],
147
+ ['Recommendation', task.aiDecision?.label || task.aiDecision?.recommendation || 'Review'],
148
+ ['Dashboard', appUrl(task.href || `/dashboard/notifications/ip/${task.notificationId}/${encodeURIComponent(task.ip || '')}`)],
149
+ ]);
150
+ }
151
+
152
+ function printAiReport(ipReport = {}, notification = {}) {
153
+ const ip = ipReport.ip || {};
154
+ const report = ip.aiReport || {};
155
+ const raw = report.rawData || {};
156
+ const steps = asArray(report.steps);
157
+ const finalDecision = report.finalDecision || {};
158
+
159
+ ui.subheading('AI report');
160
+ ui.keyValue([
161
+ ['Status', report.status || '-'],
162
+ ['Verdict', ip.verdict || raw.verdict || '-'],
163
+ ['Risk', ip.riskScore != null ? `${ip.riskScore}/100` : raw.riskScore != null ? `${raw.riskScore}/100` : '-'],
164
+ ['Final decision', finalDecision.decision || '-'],
165
+ ['Final reason', ui.truncate(finalDecision.reason || report.summary || '-', 120)],
166
+ ]);
167
+
168
+ if (report.summary) {
169
+ console.log('');
170
+ console.log(` ${ui.c.bold('Summary')} ${report.summary}`);
171
+ }
172
+
173
+ if (asArray(report.findings).length) {
174
+ console.log('');
175
+ console.log(` ${ui.c.bold('Findings')}`);
176
+ for (const finding of asArray(report.findings).slice(0, 6)) {
177
+ console.log(` - ${finding}`);
178
+ }
179
+ }
180
+
181
+ if (steps.length) {
182
+ console.log('');
183
+ console.log(` ${ui.c.bold('DAG steps')}`);
184
+ const rows = steps.map((step) => [
185
+ step.id,
186
+ step.status || '-',
187
+ step.durationMs != null ? `${step.durationMs}ms` : '-',
188
+ ui.truncate(step.outputSummary || step.inputSummary || step.error || '', 80),
189
+ ]);
190
+ ui.table(['Step', 'Status', 'Dur', 'Output'], rows);
191
+ }
192
+
193
+ const traceIds = [
194
+ ...asArray(ip.metadata?.traceIds),
195
+ ...steps.flatMap((step) => asArray(step.data?.traceIds)),
196
+ ...steps.flatMap((step) => asArray(step.data?.suspectTraceIds)),
197
+ ].filter(Boolean);
198
+ const uniqueTraceIds = [...new Set(traceIds)].slice(0, 10);
199
+ if (uniqueTraceIds.length) {
200
+ const apps = asArray(notification.notification?.applications || notification.applications);
201
+ const appKeys = apps.join(',');
202
+ console.log('');
203
+ console.log(` ${ui.c.bold('Trace links')}`);
204
+ for (const traceId of uniqueTraceIds) {
205
+ const qs = appKeys ? `?appKeys=${encodeURIComponent(appKeys)}` : '';
206
+ console.log(` - ${traceId} ${ui.c.dim(appUrl(`/trace-details/${encodeURIComponent(traceId)}${qs}`))}`);
207
+ }
208
+ }
209
+ }
210
+
211
+ async function show(args, flags) {
212
+ requireAuth();
213
+ const ref = args[0];
214
+ const s = ui.spinner('Fetching human action detail');
215
+ try {
216
+ const { task, row } = await resolveTask(ref, flags);
217
+ const [ipReport, notification] = await Promise.all([
218
+ api.get(`/notifications/${encodeURIComponent(task.notificationId)}/ips/${encodeURIComponent(task.ip)}`),
219
+ api.get(`/notifications/${encodeURIComponent(task.notificationId)}`).catch(() => ({})),
220
+ ]);
221
+ s.stop('Loaded human action detail');
222
+
223
+ if (flags.json) {
224
+ ui.json({ task, ipReport, notification });
225
+ return;
226
+ }
227
+
228
+ printTaskSummary(task, row);
229
+ printAiReport(ipReport, notification);
230
+
231
+ ui.subheading('Proofs');
232
+ ui.keyValue([
233
+ ['Paths', formatList(task.proofs?.paths, 5)],
234
+ ['Methods', formatList(task.proofs?.methods, 5)],
235
+ ['Status', formatList((task.proofs?.statusCodes || []).map(String), 5)],
236
+ ['User agents', formatList(task.proofs?.userAgents, 3)],
237
+ ['Traces', formatList(task.proofs?.traceIds, 3)],
238
+ ]);
239
+
240
+ console.log('');
241
+ ui.info(`Approve block: securenow human block ${ref} --yes --reason "AI evidence confirmed malicious"`);
242
+ ui.info(`Mark FP: securenow human fp ${ref} --yes --reason "Scoped false positive after evidence review"`);
243
+ ui.info(`MCP prompt: securenow human prompt ${ref}`);
244
+ console.log('');
245
+ } catch (err) {
246
+ s.fail('Failed to fetch human action detail');
247
+ throw err;
248
+ }
249
+ }
250
+
251
+ async function block(args, flags) {
252
+ requireAuth();
253
+ const ref = args[0];
254
+ const { task } = await resolveTask(ref, flags);
255
+ const reason = flags.reason || task.aiDecision?.reason || task.aiDecision?.verdict || 'Approved SecureNow AI block recommendation';
256
+
257
+ if (!flags.yes && !flags.force) {
258
+ const ok = await ui.confirm(`Block ${task.ip} based on AI evidence?`);
259
+ if (!ok) return;
260
+ }
261
+
262
+ const s = ui.spinner(`Blocking ${task.ip}`);
263
+ try {
264
+ const body = {
265
+ status: 'blocked',
266
+ verdict: task.aiDecision?.verdict || 'Blocked after SecureNow AI approval',
267
+ riskScore: task.aiDecision?.riskScore ?? undefined,
268
+ decisionSource: 'ai_dag',
269
+ note: reason,
270
+ };
271
+ const data = await api.put(`/notifications/${encodeURIComponent(task.notificationId)}/ips/${encodeURIComponent(task.ip)}/status`, body);
272
+ s.stop(`${task.ip} blocked`);
273
+ if (flags.json) ui.json(data);
274
+ else ui.success(`Handled: ${task.ip} blocked and recorded.`);
275
+ } catch (err) {
276
+ s.fail('Failed to block IP');
277
+ throw err;
278
+ }
279
+ }
280
+
281
+ async function fp(args, flags) {
282
+ requireAuth();
283
+ const ref = args[0];
284
+ const { task } = await resolveTask(ref, flags);
285
+ const scope = task.falsePositiveScope || {};
286
+ const reason = flags.reason || `Marked false positive from CLI. ${scope.scopeLabel || ''}`.trim();
287
+
288
+ if (!flags.yes && !flags.force) {
289
+ const ok = await ui.confirm(`Mark ${task.ip} as a scoped false positive?`);
290
+ if (!ok) return;
291
+ }
292
+
293
+ const s = ui.spinner(`Marking ${task.ip} false positive`);
294
+ try {
295
+ const body = {
296
+ reason,
297
+ conditions: flags.conditions ? JSON.parse(flags.conditions) : (scope.conditions?.length ? scope.conditions : null),
298
+ matchMode: flags['match-mode'] || scope.matchMode || 'all',
299
+ createExclusion: flags['create-exclusion'] != null ? flags['create-exclusion'] !== 'false' : Boolean(scope.createExclusion),
300
+ applyToExisting: flags['apply-existing'] != null ? flags['apply-existing'] !== 'false' : Boolean(scope.applyToExisting),
301
+ ruleScope: flags['rule-scope'] || scope.ruleScope || 'this_rule',
302
+ aiConfidence: task.aiDecision?.confidence ?? null,
303
+ };
304
+ const data = await api.post(`/notifications/${encodeURIComponent(task.notificationId)}/ips/${encodeURIComponent(task.ip)}/false-positive`, body);
305
+ s.stop(`${task.ip} marked false positive`);
306
+ if (flags.json) ui.json(data);
307
+ else ui.success(`Handled: ${task.ip} marked false positive with scoped evidence.`);
308
+ } catch (err) {
309
+ s.fail('Failed to mark false positive');
310
+ throw err;
311
+ }
312
+ }
313
+
314
+ function mcpPromptText(ref, flags = {}) {
315
+ const rowText = ref ? `Start with human action row/id: ${ref}.` : 'Work the human action queue from most urgent to least urgent.';
316
+ const limit = flags.limit || 10;
317
+ const mode = flags.mode || (ref ? 'single_row' : 'queue');
318
+ return [
319
+ 'Use the SecureNow MCP server to do my human Detect & Respond work deeply.',
320
+ '',
321
+ rowText,
322
+ `Mode: ${mode}. Review up to ${limit} task(s) unless I ask for more.`,
323
+ '',
324
+ 'Runbook:',
325
+ '1. Call securenow_human_actions_list with page=1 and the requested limit.',
326
+ ref
327
+ ? '2. Select the requested row number or task id, then call securenow_notifications_get and securenow_human_action_report for its notificationId and IP.'
328
+ : '2. For each task, call securenow_notifications_get and securenow_human_action_report for the notificationId and IP.',
329
+ '3. Read the AI report, finalDecision, DAG steps, findings, proofs, metadata paths/user agents/status codes, and trace IDs.',
330
+ '4. Open/inspect trace evidence with securenow_traces_show and securenow_logs_for_trace when trace IDs are available.',
331
+ '5. Decide one of only two outcomes: block the IP, or mark a scoped false positive. If evidence is ambiguous, report why and stop for that row.',
332
+ '6. For block decisions, call securenow_human_action_block with confirm:true and a precise reason.',
333
+ '7. For false positives, call securenow_human_action_false_positive with confirm:true, the narrowest available conditions, and a precise reason.',
334
+ '8. Summarize each row handled, skipped, and still waiting. Do not globally trust an IP by default.',
335
+ '',
336
+ 'Safety:',
337
+ '- Do not call write tools without confirm:true and a reason.',
338
+ '- Do not use broad false-positive scopes if the evidence only supports one alert rule/path/method/status/user-agent/body pattern.',
339
+ '- Prefer no action over a weak action.',
340
+ ].join('\n');
341
+ }
342
+
343
+ async function prompt(args, flags) {
344
+ const ref = args[0];
345
+ const text = mcpPromptText(ref, flags);
346
+ if (flags.json) ui.json({ prompt: text });
347
+ else console.log(`\n${text}\n`);
348
+ }
349
+
350
+ async function work(args, flags) {
351
+ requireAuth();
352
+ await list(args, { ...flags, limit: flags.limit || 10 });
353
+ console.log('');
354
+ ui.subheading('MCP prompt');
355
+ console.log(mcpPromptText(args[0], flags));
356
+ console.log('');
357
+ }
358
+
359
+ module.exports = {
360
+ list,
361
+ show,
362
+ block,
363
+ fp,
364
+ prompt,
365
+ work,
366
+ };
package/cli/init.js CHANGED
@@ -86,7 +86,7 @@ async function init(_args, flags) {
86
86
  }
87
87
 
88
88
  function initCredentials(flags) {
89
- config.ensureCredentialDefaults({ local: true });
89
+ config.ensureCredentialDefaults({ local: true, enableFirewall: true });
90
90
  config.ensureLocalGitignore();
91
91
  const creds = config.loadCredentials();
92
92
  creds.config = creds.config || {};
package/cli.js CHANGED
@@ -151,20 +151,48 @@ const COMMANDS = {
151
151
  },
152
152
  defaultSub: 'list',
153
153
  },
154
- notifications: {
155
- desc: 'Manage notifications',
156
- usage: 'securenow notifications <subcommand> [options]',
154
+ notifications: {
155
+ desc: 'Manage notifications',
156
+ usage: 'securenow notifications <subcommand> [options]',
157
157
  sub: {
158
158
  list: { desc: 'List notifications', flags: { limit: 'Max results', page: 'Page number' }, run: (a, f) => require('./cli/monitor').notificationsList(a, f) },
159
159
  read: { desc: 'Mark notification as read', usage: 'securenow notifications read <id>', run: (a, f) => require('./cli/monitor').notificationsRead(a, f) },
160
160
  'read-all': { desc: 'Mark all as read', run: () => require('./cli/monitor').notificationsReadAll() },
161
161
  unread: { desc: 'Show unread count', run: () => require('./cli/monitor').notificationsUnread() },
162
- },
163
- defaultSub: 'list',
164
- },
165
- alerts: {
166
- desc: 'Manage alerting',
167
- usage: 'securenow alerts <subcommand> [options]',
162
+ },
163
+ defaultSub: 'list',
164
+ },
165
+ human: {
166
+ desc: 'Work the human action queue prepared by SecureNow AI',
167
+ usage: 'securenow human <list|show|block|fp|prompt|work> [row|notificationId:ip] [options]',
168
+ flags: {
169
+ json: 'Output as JSON',
170
+ page: 'Queue page number',
171
+ limit: 'Queue page size',
172
+ search: 'Search IP, rule, path, or verdict',
173
+ reason: 'Reason for block/false-positive decisions',
174
+ yes: 'Confirm write actions without prompting',
175
+ force: 'Alias for --yes',
176
+ conditions: 'False-positive conditions JSON array',
177
+ 'match-mode': 'False-positive match mode: all or any',
178
+ 'rule-scope': 'False-positive scope: this_rule | specific_rules | all_existing | any_rule',
179
+ 'create-exclusion': 'Create a restrictive exclusion when marking false positive',
180
+ 'apply-existing': 'Apply false-positive decision to existing matching rows',
181
+ mode: 'Prompt mode label for MCP prompt output',
182
+ },
183
+ sub: {
184
+ list: { desc: 'List human decisions AI prepared', run: (a, f) => require('./cli/human').list(a, f) },
185
+ show: { desc: 'Show one row with AI report, proofs, DAG, and trace links', usage: 'securenow human show <row|notificationId:ip>', run: (a, f) => require('./cli/human').show(a, f) },
186
+ 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) },
187
+ 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) },
188
+ 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) },
189
+ 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) },
190
+ },
191
+ defaultSub: 'list',
192
+ },
193
+ alerts: {
194
+ desc: 'Manage alerting',
195
+ usage: 'securenow alerts <subcommand> [options]',
168
196
  sub: {
169
197
  rules: {
170
198
  desc: 'List, show, or update alert rules',
@@ -469,7 +497,7 @@ function showHelp(commandName) {
469
497
  'Authentication': ['login', 'logout', 'whoami', 'credentials'],
470
498
  'Applications': ['apps', 'init', 'status'],
471
499
  'Observe': ['traces', 'logs', 'analytics'],
472
- 'Detect & Respond': ['notifications', 'alerts', 'fp'],
500
+ 'Detect & Respond': ['human', 'notifications', 'alerts', 'fp'],
473
501
  'Investigate': ['ip', 'forensics'],
474
502
  'Firewall': ['firewall'],
475
503
  'Remediation': ['blocklist', 'allowlist', 'trusted'],
@@ -130,7 +130,7 @@ Legacy environment variables are fallback-only for existing deployments. New loc
130
130
  | `config.runtime.strict` | `false` | Exit clustered workers when no app identity resolves. |
131
131
  | `config.runtime.testSpan` | `false` | Prefer `npx securenow test-span` for manual checks. |
132
132
  | `config.runtime.hideBanner` | `false` | Hide free-trial response banner. |
133
- | `config.firewall.enabled` | `true` | Local kill-switch; dashboard app toggle also applies. |
133
+ | `config.firewall.enabled` | `true` | Local SDK firewall switch. Leave absent/true for protection; set false only when intentionally disabling in this credentials file. Dashboard app toggle also applies. |
134
134
  | `config.firewall.apiUrl` | `https://api.securenow.ai` | SecureNow API base URL. |
135
135
  | `config.firewall.versionCheckInterval` | `10` | Seconds between lightweight version checks. |
136
136
  | `config.firewall.syncInterval` | `300` | Seconds between full blocklist syncs. |
@@ -199,13 +199,12 @@ SECURENOW_FIREWALL_CLOUD_DRY_RUN=1
199
199
 
200
200
  ## Environment Variables Reference
201
201
 
202
- | Variable | Default | Description |
203
- |----------|---------|-------------|
204
- | `SECURENOW_API_KEY` | *(from creds file)* | API key with `firewall:read` scope. Since v7.1.0 the firewall also reads this from `.securenow/credentials.json` (written by `securenow login` or `securenow api-key set`), so this env var is optional. The env var only wins if it starts with `snk_live_`; otherwise the credentials file is used. |
205
- | `SECURENOW_API_URL` | `https://api.securenow.ai` | API base URL. Auto-fallback to `http://localhost:4000` on ECONNREFUSED. |
206
- | `SECURENOW_FIREWALL_ENABLED` | `1` | Master kill-switch (`0` to disable) |
207
- | `SECURENOW_FIREWALL_VERSION_INTERVAL` | `10` | Seconds between version checks (lightweight ETag-based) |
208
- | `SECURENOW_FIREWALL_SYNC_INTERVAL` | `300` | Full blocklist refresh interval in seconds (safety net) |
202
+ | Variable | Default | Description |
203
+ |----------|---------|-------------|
204
+ | `SECURENOW_API_KEY` | *(from creds file)* | API key with `firewall:read` scope. Since v7.1.0 the firewall also reads this from `.securenow/credentials.json` (written by `securenow login` or `securenow api-key set`), so this env var is optional. The env var only wins if it starts with `snk_live_`; otherwise the credentials file is used. |
205
+ | `SECURENOW_API_URL` | `https://api.securenow.ai` | API base URL. Auto-fallback to `http://localhost:4000` on ECONNREFUSED. |
206
+ | `SECURENOW_FIREWALL_VERSION_INTERVAL` | `10` | Seconds between version checks (lightweight ETag-based) |
207
+ | `SECURENOW_FIREWALL_SYNC_INTERVAL` | `300` | Full blocklist refresh interval in seconds (safety net) |
209
208
  | `SECURENOW_FIREWALL_FAIL_MODE` | `open` | `open` = allow when list unavailable; `closed` = block all |
210
209
  | `SECURENOW_FIREWALL_STATUS_CODE` | `403` | HTTP status code for blocked requests (Layer 1) |
211
210
  | `SECURENOW_FIREWALL_LOG` | `1` | Log blocked requests to console (`0` to silence) |
@@ -390,14 +389,16 @@ All layers clean up on process exit (SIGINT/SIGTERM):
390
389
  echo $SECURENOW_API_KEY
391
390
  ```
392
391
 
393
- **Check 2:** Is the firewall disabled?
394
-
395
- ```bash
396
- # Must NOT be set to 0
397
- echo $SECURENOW_FIREWALL_ENABLED
398
- ```
399
-
400
- **Check 3:** Check the startup log for sync errors:
392
+ **Check 2:** Is the firewall disabled in SecureNow config or dashboard?
393
+
394
+ ```bash
395
+ npx securenow env
396
+ npx securenow firewall apps
397
+ ```
398
+
399
+ The local SDK switch is `config.firewall.enabled` in `.securenow/credentials.json`. The runtime dashboard toggle is per app/environment.
400
+
401
+ **Check 3:** Check the startup log for sync errors:
401
402
 
402
403
  ```
403
404
  [securenow] Firewall: initial sync failed: API returned 401
package/firewall.js CHANGED
@@ -15,9 +15,11 @@ let _lastSyncEtag = null;
15
15
  let _initialized = false;
16
16
  let _consecutiveErrors = 0;
17
17
  let _layers = [];
18
- let _rawIps = [];
19
- let _stats = { syncs: 0, blocked: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
20
- let _localhostFallbackTried = false;
18
+ let _rawIps = [];
19
+ let _stats = { syncs: 0, blocked: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
20
+ let _localhostFallbackTried = false;
21
+ let _eventQueue = [];
22
+ let _eventTimer = null;
21
23
 
22
24
  // Remote toggle — set by /firewall/sync when an appKey is in scope. Default
23
25
  // true so a missing/unreachable backend fails open (matches pre-7.3 behavior).
@@ -45,7 +47,10 @@ let _retryAfterUntil = 0;
45
47
 
46
48
  // Keep-alive agents — reuse TCP connections across polls (TLS handshake once)
47
49
  const _httpAgent = new http.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
48
- const _httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
50
+ const _httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
51
+ const EVENT_FLUSH_INTERVAL_MS = 2_000;
52
+ const EVENT_BATCH_SIZE = 25;
53
+ const EVENT_QUEUE_MAX = 1_000;
49
54
 
50
55
  // Unified sync uses /firewall/sync (v2). Falls back to legacy on 404.
51
56
  let _useUnifiedSync = true;
@@ -118,7 +123,7 @@ function agentFor(url) {
118
123
  return url.startsWith('https') ? _httpsAgent : _httpAgent;
119
124
  }
120
125
 
121
- function httpGet(url, extraHeaders, timeout, callback) {
126
+ function httpGet(url, extraHeaders, timeout, callback) {
122
127
  const mod = url.startsWith('https') ? https : http;
123
128
  const parsed = new URL(url);
124
129
 
@@ -142,8 +147,89 @@ function httpGet(url, extraHeaders, timeout, callback) {
142
147
 
143
148
  req.on('error', (err) => callback(err));
144
149
  req.on('timeout', () => { req.destroy(); callback(new Error('Request timed out')); });
145
- req.end();
146
- }
150
+ req.end();
151
+ }
152
+
153
+ function httpPostJson(url, body, timeout, callback) {
154
+ const mod = url.startsWith('https') ? https : http;
155
+ const parsed = new URL(url);
156
+ const payload = JSON.stringify(body || {});
157
+
158
+ const req = mod.request({
159
+ hostname: parsed.hostname,
160
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
161
+ path: parsed.pathname + parsed.search,
162
+ method: 'POST',
163
+ headers: {
164
+ 'Authorization': `Bearer ${_options.apiKey}`,
165
+ 'User-Agent': 'securenow-firewall-sdk',
166
+ 'Content-Type': 'application/json',
167
+ 'Content-Length': Buffer.byteLength(payload),
168
+ },
169
+ timeout,
170
+ agent: agentFor(url),
171
+ }, (res) => {
172
+ let data = '';
173
+ res.on('data', (chunk) => { data += chunk; });
174
+ res.on('end', () => { callback(null, res, data); });
175
+ });
176
+
177
+ req.on('error', (err) => callback(err));
178
+ req.on('timeout', () => { req.destroy(); callback(new Error('Request timed out')); });
179
+ req.write(payload);
180
+ req.end();
181
+ }
182
+
183
+ function scheduleEventFlush() {
184
+ if (_eventTimer || _eventQueue.length === 0) return;
185
+ _eventTimer = setTimeout(() => {
186
+ _eventTimer = null;
187
+ flushFirewallEvents();
188
+ }, EVENT_FLUSH_INTERVAL_MS);
189
+ if (_eventTimer.unref) _eventTimer.unref();
190
+ }
191
+
192
+ function flushFirewallEvents() {
193
+ if (!_options || !_options.apiKey || !_options.apiUrl || _eventQueue.length === 0) return;
194
+ if (shouldSkipRequest()) {
195
+ _eventQueue = [];
196
+ return;
197
+ }
198
+
199
+ const batch = _eventQueue.splice(0, EVENT_BATCH_SIZE);
200
+ const url = buildFirewallUrl('/firewall/events');
201
+
202
+ httpPostJson(url, { events: batch }, 5000, (err, res) => {
203
+ if (err || !res || res.statusCode >= 400) {
204
+ if (_options.log) {
205
+ const msg = err ? err.message : `API returned ${res.statusCode}`;
206
+ console.warn('[securenow] Firewall: failed to report blocked-request ledger:', msg);
207
+ }
208
+ }
209
+ if (_eventQueue.length) scheduleEventFlush();
210
+ });
211
+ }
212
+
213
+ function reportFirewallEvent(event) {
214
+ if (!_options || !_options.apiKey || !_options.apiUrl) return;
215
+
216
+ const item = {
217
+ applicationKey: _options.appKey || null,
218
+ environment: _options.environment || 'production',
219
+ action: 'blocked',
220
+ statusCode: (_options && _options.statusCode) || 403,
221
+ occurredAt: new Date().toISOString(),
222
+ ...event,
223
+ };
224
+
225
+ _eventQueue.push(item);
226
+ if (_eventQueue.length > EVENT_QUEUE_MAX) {
227
+ _eventQueue.splice(0, _eventQueue.length - EVENT_QUEUE_MAX);
228
+ }
229
+
230
+ if (_eventQueue.length >= EVENT_BATCH_SIZE) flushFirewallEvents();
231
+ else scheduleEventFlush();
232
+ }
147
233
 
148
234
  // ────── Unified Sync (v2 — single request for everything) ──────
149
235
 
@@ -609,23 +695,39 @@ function firewallRequestHandler(req, res) {
609
695
  const ip = resolveClientIp(req);
610
696
 
611
697
  // Allowlist check: if active, only listed IPs are allowed through
612
- if (_allowlistMatcher && _allowlistMatcher.stats().total > 0) {
613
- if (!_allowlistMatcher.isBlocked(ip)) {
614
- _stats.blocked++;
615
- if (_options && _options.log) console.log('[securenow] Firewall: blocked %s via HTTP (not in allowlist)', ip);
616
- sendBlockResponse(req, res, ip);
617
- return true;
618
- }
619
- return false;
620
- }
698
+ if (_allowlistMatcher && _allowlistMatcher.stats().total > 0) {
699
+ if (!_allowlistMatcher.isBlocked(ip)) {
700
+ _stats.blocked++;
701
+ if (_options && _options.log) console.log('[securenow] Firewall: blocked %s via HTTP (not in allowlist)', ip);
702
+ reportFirewallEvent({
703
+ source: 'allowlist',
704
+ ip,
705
+ matchedEntry: '',
706
+ method: req.method || '',
707
+ path: req.url || '',
708
+ userAgent: req.headers['user-agent'] || '',
709
+ });
710
+ sendBlockResponse(req, res, ip);
711
+ return true;
712
+ }
713
+ return false;
714
+ }
621
715
 
622
716
  // Blocklist check
623
- if (_matcher && _matcher.isBlocked(ip)) {
624
- _stats.blocked++;
625
- if (_options && _options.log) console.log('[securenow] Firewall: blocked %s via HTTP', ip);
626
- sendBlockResponse(req, res, ip);
627
- return true;
628
- }
717
+ if (_matcher && _matcher.isBlocked(ip)) {
718
+ _stats.blocked++;
719
+ if (_options && _options.log) console.log('[securenow] Firewall: blocked %s via HTTP', ip);
720
+ reportFirewallEvent({
721
+ source: 'blocklist',
722
+ ip,
723
+ matchedEntry: ip,
724
+ method: req.method || '',
725
+ path: req.url || '',
726
+ userAgent: req.headers['user-agent'] || '',
727
+ });
728
+ sendBlockResponse(req, res, ip);
729
+ return true;
730
+ }
629
731
 
630
732
  return false;
631
733
  }
@@ -721,11 +823,13 @@ function init(options) {
721
823
  startSyncLoop();
722
824
  }
723
825
 
724
- function shutdown() {
725
- if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
726
- if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
727
-
728
- _circuitState = 'closed';
826
+ function shutdown() {
827
+ if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
828
+ if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
829
+ if (_eventTimer) { clearTimeout(_eventTimer); _eventTimer = null; }
830
+ flushFirewallEvents();
831
+
832
+ _circuitState = 'closed';
729
833
  _circuitOpenedAt = 0;
730
834
  _consecutiveErrors = 0;
731
835
  _pollInflight = false;
package/mcp/catalog.js CHANGED
@@ -408,6 +408,94 @@ const TOOLS = [
408
408
  ...confirmSchema,
409
409
  }, ['id', 'confirm', 'reason']),
410
410
  },
411
+ {
412
+ name: 'securenow_notifications_get',
413
+ title: 'Get Notification',
414
+ description: 'Get one notification/case with alert context, clusters, and IP investigations.',
415
+ scope: 'notifications:read',
416
+ readOnly: true,
417
+ method: 'GET',
418
+ endpoint: '/notifications/:id',
419
+ pathParams: ['id'],
420
+ inputSchema: objectSchema({
421
+ id: string('Notification id.'),
422
+ }, ['id']),
423
+ },
424
+ {
425
+ name: 'securenow_human_actions_list',
426
+ title: 'List Human Action Queue',
427
+ description: 'List approval-gated human decisions prepared by SecureNow AI, ordered by urgency.',
428
+ scope: 'notifications:read',
429
+ readOnly: true,
430
+ method: 'GET',
431
+ endpoint: '/notifications/approval-tasks',
432
+ queryFields: ['limit', 'page', 'search'],
433
+ inputSchema: objectSchema({
434
+ ...pagingInput,
435
+ search: string('Optional search over IP, rule, path, or verdict.'),
436
+ }),
437
+ },
438
+ {
439
+ name: 'securenow_human_action_report',
440
+ title: 'Get Human Action Report',
441
+ description: 'Fetch the full IP investigation report, DAG steps, proofs, metadata, and AI decision for one human action row.',
442
+ scope: 'notifications:read',
443
+ readOnly: true,
444
+ method: 'GET',
445
+ endpoint: '/notifications/:notificationId/ips/:ip',
446
+ pathParams: ['notificationId', 'ip'],
447
+ inputSchema: objectSchema({
448
+ notificationId: string('Notification id from the human action row.'),
449
+ ip: string('IP address from the human action row.'),
450
+ }, ['notificationId', 'ip']),
451
+ },
452
+ {
453
+ name: 'securenow_human_action_block',
454
+ title: 'Approve AI Block Recommendation',
455
+ description: 'Approve the AI-prepared block decision for an IP. Write action; requires confirmation.',
456
+ scope: 'notifications:write',
457
+ readOnly: false,
458
+ destructive: true,
459
+ confirm: true,
460
+ method: 'PUT',
461
+ endpoint: '/notifications/:notificationId/ips/:ip/status',
462
+ pathParams: ['notificationId', 'ip'],
463
+ fixedBody: { status: 'blocked', decisionSource: 'ai_dag' },
464
+ reasonAsNote: true,
465
+ bodyFields: ['note', 'verdict', 'riskScore'],
466
+ inputSchema: objectSchema({
467
+ notificationId: string('Notification id from the human action row.'),
468
+ ip: string('IP address to block.'),
469
+ note: string('Audit note explaining why the AI block was approved.'),
470
+ verdict: string('Optional final verdict text.'),
471
+ riskScore: number('Optional risk score.', { minimum: 0, maximum: 100 }),
472
+ ...confirmSchema,
473
+ }, ['notificationId', 'ip', 'confirm', 'reason']),
474
+ },
475
+ {
476
+ name: 'securenow_human_action_false_positive',
477
+ title: 'Mark Human Action False Positive',
478
+ description: 'Mark an AI-prepared human action as a scoped false positive. Write action; requires confirmation.',
479
+ scope: 'notifications:write',
480
+ readOnly: false,
481
+ confirm: true,
482
+ method: 'POST',
483
+ endpoint: '/notifications/:notificationId/ips/:ip/false-positive',
484
+ pathParams: ['notificationId', 'ip'],
485
+ bodyFields: ['reason', 'conditions', 'matchMode', 'createExclusion', 'applyToExisting', 'ruleScope', 'targetRuleIds', 'aiConfidence'],
486
+ inputSchema: objectSchema({
487
+ notificationId: string('Notification id from the human action row.'),
488
+ ip: string('IP address to mark as false positive.'),
489
+ conditions: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Restrictive false-positive conditions. Prefer app/rule/path/method/status/user-agent/body scope.' },
490
+ matchMode: string('Condition match mode: all or any.'),
491
+ createExclusion: boolean('Whether to create a restrictive exclusion rule.'),
492
+ applyToExisting: boolean('Whether to apply to existing matching IP investigations.'),
493
+ ruleScope: string('Scope: this_rule, specific_rules, all_existing, or any_rule.'),
494
+ targetRuleIds: arrayOfStrings('Specific rule ids when ruleScope is specific_rules.'),
495
+ aiConfidence: number('AI confidence for audit metadata.', { minimum: 0, maximum: 100 }),
496
+ ...confirmSchema,
497
+ }, ['notificationId', 'ip', 'confirm', 'reason']),
498
+ },
411
499
  {
412
500
  name: 'securenow_ip_lookup',
413
501
  title: 'IP Intelligence Lookup',
@@ -700,6 +788,27 @@ const PROMPTS = [
700
788
  { name: 'environment', description: 'Environment to investigate. Defaults to production; use all only when explicitly needed.', required: false },
701
789
  ],
702
790
  },
791
+ {
792
+ name: 'investigate_human_action_row',
793
+ title: 'Investigate Human Action Row',
794
+ description: 'Use MCP tools to deeply review one Requires Human row and either block the IP or mark a scoped false positive.',
795
+ arguments: [
796
+ { name: 'rowNumber', description: '1-based row number from the Requires Human queue.', required: true },
797
+ { name: 'page', description: 'Queue page number. Defaults to 1.', required: false },
798
+ { name: 'limit', description: 'Rows to fetch. Defaults to 20.', required: false },
799
+ { name: 'confirmWrites', description: 'Set true only when the user explicitly wants the MCP agent to execute decisions.', required: false },
800
+ ],
801
+ },
802
+ {
803
+ name: 'work_human_actions',
804
+ title: 'Work Human Actions',
805
+ description: 'Use MCP tools to work the approval queue from most urgent to least urgent, with trace verification and scoped decisions.',
806
+ arguments: [
807
+ { name: 'limit', description: 'Maximum rows to review this run. Defaults to 10.', required: false },
808
+ { name: 'search', description: 'Optional search filter for the queue.', required: false },
809
+ { name: 'confirmWrites', description: 'Set true only when the user explicitly wants the MCP agent to execute decisions.', required: false },
810
+ ],
811
+ },
703
812
  ];
704
813
 
705
814
  function promptMessages(name, args = {}) {
@@ -759,6 +868,56 @@ function promptMessages(name, args = {}) {
759
868
  ];
760
869
  }
761
870
 
871
+ if (name === 'investigate_human_action_row') {
872
+ const rowNumber = args.rowNumber || '<rowNumber>';
873
+ const page = args.page || 1;
874
+ const limit = args.limit || 20;
875
+ const confirmWrites = args.confirmWrites === true || args.confirmWrites === 'true';
876
+ return [
877
+ {
878
+ role: 'user',
879
+ content: {
880
+ type: 'text',
881
+ text: [
882
+ `Investigate Requires Human row ${rowNumber} using SecureNow MCP.`,
883
+ `Fetch page=${page}, limit=${limit} with securenow_human_actions_list, select row ${rowNumber}, then call securenow_notifications_get and securenow_human_action_report for that notificationId and IP.`,
884
+ 'Read the AI report, finalDecision, DAG steps, findings, proofs, metadata paths/user agents/status codes, and trace IDs.',
885
+ 'Open trace evidence with securenow_traces_show and correlated logs with securenow_logs_for_trace when trace IDs are available.',
886
+ 'Return one of only two outcomes: Block IP or False Positive. If evidence is ambiguous, stop and explain what is missing.',
887
+ confirmWrites
888
+ ? 'The user requested execution. If evidence supports the decision, call securenow_human_action_block or securenow_human_action_false_positive with confirm:true and a precise reason.'
889
+ : 'Do not execute write tools yet. Prepare the recommended decision and exact tool call the user can approve.',
890
+ 'False positives must be narrow: app + alert rule + path + method/status/user-agent/body evidence where possible. Never globally trust an IP by default.',
891
+ ].join('\n'),
892
+ },
893
+ },
894
+ ];
895
+ }
896
+
897
+ if (name === 'work_human_actions') {
898
+ const limit = args.limit || 10;
899
+ const confirmWrites = args.confirmWrites === true || args.confirmWrites === 'true';
900
+ return [
901
+ {
902
+ role: 'user',
903
+ content: {
904
+ type: 'text',
905
+ text: [
906
+ 'Work my SecureNow Requires Human queue like a senior security analyst using the MCP tools.',
907
+ `Review up to ${limit} row(s), most urgent first.${args.search ? ` Search filter: ${args.search}.` : ''}`,
908
+ 'Start with securenow_human_actions_list. For each row, call securenow_notifications_get and securenow_human_action_report, inspect the AI report/DAG/proofs/trace IDs, and fetch trace/log evidence where useful.',
909
+ 'For each row choose exactly one outcome: Block IP, False Positive, or Skip because evidence is insufficient. Explain skipped rows.',
910
+ confirmWrites
911
+ ? 'The user requested execution. For supported decisions, call the correct write tool with confirm:true and a precise reason, then continue.'
912
+ : 'Do not execute write tools yet. Produce a row-by-row action plan and exact MCP write calls for user approval.',
913
+ 'For block decisions, use securenow_human_action_block. For false positives, use securenow_human_action_false_positive with restrictive conditions. Avoid broad/global trust.',
914
+ 'End with counts: handled, proposed block, proposed false positive, skipped, still waiting.',
915
+ ].join('\n'),
916
+ },
917
+ },
918
+ ];
919
+ }
920
+
762
921
  throw new Error(`Unknown prompt: ${name}`);
763
922
  }
764
923
 
@@ -829,6 +988,10 @@ function buildApiRequest(tool, rawArgs = {}) {
829
988
  if (value != null && value !== '') body[key] = value;
830
989
  }
831
990
 
991
+ if (tool.reasonAsNote && !body.note && args.reason) {
992
+ body.note = args.reason;
993
+ }
994
+
832
995
  return {
833
996
  method: tool.method,
834
997
  endpoint,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "7.6.4",
3
+ "version": "7.6.6",
4
4
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
5
5
  "type": "commonjs",
6
6
  "main": "register.js",