securenow 8.14.0 → 8.15.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
@@ -163,6 +163,8 @@ Everything the MCP server can do is also a command. Run `npx securenow help` for
163
163
  | `securenow ip <address>` | IP intelligence (geo, abuse, verdict) |
164
164
  | `securenow ip traces <address>` | Traces from an IP |
165
165
  | `securenow forensics "<query>"` | Natural-language forensic query |
166
+ | `securenow forensics chat --app <key> ["<question>"]` | Forensics chat with memory; `--conversation <id>` continues one |
167
+ | `securenow forensics conversations [<id>]` | List forensics conversations, or show one |
166
168
  | `securenow traces` / `traces show <traceId>` | Recent traces / trace spans |
167
169
  | `securenow traces analyze <traceId>` | AI security analysis of a trace |
168
170
  | `securenow logs [--since 6h] [--level error]` | View logs |
package/SKILL-CLI.md CHANGED
@@ -334,10 +334,20 @@ securenow ip traces <ip-address> --env production # traces originating from this
334
334
  ```bash
335
335
  securenow forensics "show me all SQL injection attempts in the last 24h" --env production
336
336
  securenow forensics query "top 10 IPs by blocked requests" --app my-app --env production
337
- securenow forensics chat --app my-app --env production # interactive forensics chat session
337
+ securenow forensics chat --app my-app --env production # interactive forensics chat session (with memory)
338
+ securenow forensics chat --app my-app "which accounts had failed logins then a success?" # one question, prints the conversation id
339
+ securenow forensics chat --conversation <id> "what else did those IPs do?" # follow up: the chat remembers the earlier answers
340
+ securenow forensics conversations # list conversations (dashboard, CLI and agent ones)
341
+ securenow forensics conversations <id> # show one: questions, SQL, findings, rows
338
342
  securenow forensics library # view saved/template queries
339
343
  ```
340
344
 
345
+ The chat runs discovery queries before answering, shows its SQL, and ends each answer with findings: what the result rows
346
+ show. A follow-up sees the earlier questions, their findings, and the IPs and trace IDs in their results. MCP:
347
+ `securenow_forensics_chat` (pass `conversationId` to continue), `securenow_forensics_conversations_list`,
348
+ `securenow_forensics_conversation_get`. `securenow forensics "<q>"` and `securenow_forensics_query` answer one question
349
+ with no memory.
350
+
341
351
  ---
342
352
 
343
353
  ### Firewall
package/cli/automation.js CHANGED
@@ -82,7 +82,7 @@ async function list(args, flags) {
82
82
 
83
83
  console.log('');
84
84
  const rows = rules.map((rule) => [
85
- ui.c.dim(ui.truncate(rule._id || rule.id, 12)),
85
+ ui.c.dim(rule.id || rule._id || ''),
86
86
  rule.name || '-',
87
87
  ui.statusBadge(rule.status || 'active'),
88
88
  formatApplications(rule),
package/cli/challenges.js CHANGED
@@ -64,7 +64,7 @@ async function list(args, flags) {
64
64
 
65
65
  console.log('');
66
66
  const rows = rules.map((r) => [
67
- ui.c.dim(ui.truncate(r.id || r._id, 12)),
67
+ ui.c.dim(r.id || r._id || ''),
68
68
  r.status === 'active' ? ui.statusBadge('active') : ui.statusBadge(r.status || 'unknown'),
69
69
  describeTarget(r),
70
70
  `pow:${r.difficulty}`,
package/cli/fp.js CHANGED
@@ -76,7 +76,7 @@ async function list(args, flags) {
76
76
  const rows = exclusions.map(e => {
77
77
  const scope = e.type === 'global' ? ui.c.cyan('All Rules') : `Rule: ${ui.truncate(e.ruleName || '?', 18)}`;
78
78
  return [
79
- ui.c.dim(ui.truncate(e._id, 12)),
79
+ ui.c.dim(e.id || e._id || ''),
80
80
  scope,
81
81
  ui.truncate(conditionsSummary(e.conditions, e.matchMode), 40),
82
82
  (e.matchMode || 'all').toUpperCase(),
package/cli/monitor.js CHANGED
@@ -351,7 +351,7 @@ async function notificationsList(args, flags) {
351
351
 
352
352
  console.log('');
353
353
  const rows = notifications.map(n => [
354
- ui.c.dim(ui.truncate(n._id, 12)),
354
+ ui.c.dim(n.id || n._id || ''),
355
355
  ui.statusBadge(n.read ? 'read' : 'unread'),
356
356
  ui.truncate(n.title || n.message || n.type || '', 50),
357
357
  n.ip || '—',
package/cli/rateLimits.js CHANGED
@@ -47,6 +47,16 @@ function describeTarget(rule) {
47
47
  return parts.join(' ') || 'all traffic';
48
48
  }
49
49
 
50
+ // The bucket each request counts against: "ip", "global", "header x-api-key", "cookie session".
51
+ function describeKey(rule) {
52
+ const keyBy = rule.keyBy || 'ip';
53
+ return rule.keyName && (keyBy === 'header' || keyBy === 'cookie') ? `${keyBy} ${rule.keyName}` : keyBy;
54
+ }
55
+
56
+ function isExpired(rule) {
57
+ return Boolean(rule.expiresAt) && new Date(rule.expiresAt).getTime() <= Date.now();
58
+ }
59
+
50
60
  function naturalText(args, flags) {
51
61
  return String(flags.text || flags.prompt || flags.query || args.join(' ') || '').trim();
52
62
  }
@@ -62,6 +72,11 @@ function draftFromFlags(flags) {
62
72
  if (flags.mode || flags['path-mode']) draft.pathMatchMode = flags.mode || flags['path-mode'];
63
73
  if (flags.method) draft.method = flags.method;
64
74
  if (flags['key-by'] || flags.keyBy) draft.keyBy = flags['key-by'] || flags.keyBy;
75
+ else if (flags.header) draft.keyBy = 'header';
76
+ else if (flags.cookie) draft.keyBy = 'cookie';
77
+ if (flags['key-name'] || flags.keyName || flags.header || flags.cookie) {
78
+ draft.keyName = flags['key-name'] || flags.keyName || flags.header || flags.cookie;
79
+ }
65
80
  if (flags.limit) draft.limit = Number(flags.limit);
66
81
  if (flags.window || flags['window-seconds']) draft.windowSeconds = parseWindow(flags.window || flags['window-seconds']);
67
82
  if (flags.duration) draft.duration = flags.duration;
@@ -76,7 +91,7 @@ function printParsedDraft(data) {
76
91
  ['Name', draft.name || '-'],
77
92
  ['Target', describeTarget(draft)],
78
93
  ['Limit', `${draft.limit || '-'} / ${draft.windowSeconds || '-'}s`],
79
- ['Key by', draft.keyBy || 'ip'],
94
+ ['Key by', describeKey(draft)],
80
95
  ['App', draft.appKey || 'all'],
81
96
  ['Environment', draft.environment || 'all'],
82
97
  ['Duration', draft.duration || 'none'],
@@ -107,16 +122,17 @@ async function list(args, flags) {
107
122
 
108
123
  console.log('');
109
124
  const rows = rules.map((r) => [
110
- ui.c.dim(ui.truncate(r.id || r._id, 12)),
111
- r.status === 'active' ? ui.statusBadge('active') : ui.statusBadge(r.status || 'unknown'),
125
+ ui.c.dim(r.id || r._id || ''),
126
+ ui.statusBadge(r.status === 'active' && isExpired(r) ? 'expired' : (r.status || 'unknown')),
112
127
  describeTarget(r),
113
128
  `${r.limit}/${r.windowSeconds}s`,
129
+ describeKey(r),
114
130
  r.applicationKey || ui.c.dim('all apps'),
115
131
  r.environment || ui.c.dim('all envs'),
116
132
  r.expiresAt ? new Date(r.expiresAt).toLocaleString() : ui.c.dim('permanent'),
117
133
  ui.timeAgo(r.createdAt),
118
134
  ]);
119
- ui.table(['ID', 'Status', 'Target', 'Limit', 'App', 'Env', 'Expires', 'Created'], rows);
135
+ ui.table(['ID', 'Status', 'Target', 'Limit', 'Per', 'App', 'Env', 'Expires', 'Created'], rows);
120
136
  console.log('');
121
137
  } catch (err) {
122
138
  s.fail('Failed to fetch rate limits');
@@ -167,7 +183,7 @@ async function fromText(args, flags) {
167
183
  if (flags.json) { ui.json(data); return; }
168
184
  const r = data.rateLimit;
169
185
  console.log('');
170
- console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s`);
186
+ console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s per ${describeKey(r)}`);
171
187
  console.log(` ${ui.c.dim('ID:')} ${r.id || r._id}`);
172
188
  console.log('');
173
189
  } catch (err) {
@@ -197,7 +213,7 @@ async function show(args, flags) {
197
213
  ['Status', r.status || '-'],
198
214
  ['Target', describeTarget(r)],
199
215
  ['Limit', `${r.limit}/${r.windowSeconds}s`],
200
- ['Key by', r.keyName ? `${r.keyBy} ${r.keyName}` : (r.keyBy || 'ip')],
216
+ ['Key by', describeKey(r)],
201
217
  ['App', r.applicationKey || 'all apps'],
202
218
  ['Environment', r.environment || 'all envs'],
203
219
  ['Reason', r.reason || '-'],
@@ -251,7 +267,7 @@ async function add(args, flags) {
251
267
  if (flags.json) { ui.json(data); return; }
252
268
  const r = data.rateLimit;
253
269
  console.log('');
254
- console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s`);
270
+ console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s per ${describeKey(r)}`);
255
271
  console.log(` ${ui.c.dim('ID:')} ${r.id || r._id}`);
256
272
  console.log('');
257
273
  } catch (err) {
@@ -330,7 +346,7 @@ async function test(args, flags) {
330
346
  if (data.limited) {
331
347
  console.log(` ${ui.c.bold(ui.c.yellow('RATE LIMITED'))} - ${data.matches.length} matching rule${data.matches.length !== 1 ? 's' : ''}`);
332
348
  for (const r of data.matches.slice(0, 5)) {
333
- console.log(` ${ui.c.dim(r.id || r._id)} ${describeTarget(r)} ${r.limit}/${r.windowSeconds}s`);
349
+ console.log(` ${ui.c.dim(r.id || r._id)} ${describeTarget(r)} ${r.limit}/${r.windowSeconds}s per ${describeKey(r)}`);
334
350
  }
335
351
  } else {
336
352
  console.log(` ${ui.c.bold(ui.c.green('NOT LIMITED'))} - no matching rate-limit rules`);
package/cli/security.js CHANGED
@@ -190,7 +190,7 @@ async function alertRulesList(args, flags) {
190
190
 
191
191
  console.log('');
192
192
  const rows = rules.map((r) => [
193
- ui.c.dim(ui.truncate(r._id || r.id, 12)),
193
+ ui.c.dim(r.id || r._id || ''),
194
194
  r.name || '—',
195
195
  ruleStatusBadge(r),
196
196
  r.mode === 'test' ? ui.c.yellow('test') : ui.c.dim('prod'),
@@ -836,7 +836,7 @@ async function alertRuleExclusions(args, flags) {
836
836
  if (flags.json) { ui.json(data); return; }
837
837
  console.log('');
838
838
  const rows = exclusions.map((e) => [
839
- ui.c.dim(ui.truncate(e._id, 12)),
839
+ ui.c.dim(e.id || e._id || ''),
840
840
  e.isActive === false ? ui.statusBadge('disabled') : ui.statusBadge('active'),
841
841
  e.matchMode || 'all',
842
842
  ui.truncate(e.reason || e.pathPattern || JSON.stringify(e.conditions || []), 72),
@@ -862,7 +862,7 @@ async function alertChannelsList(args, flags) {
862
862
 
863
863
  console.log('');
864
864
  const rows = channels.map(ch => [
865
- ui.c.dim(ui.truncate(ch._id, 12)),
865
+ ui.c.dim(ch.id || ch._id || ''),
866
866
  ch.name || '—',
867
867
  ch.type || '—',
868
868
  ui.statusBadge(ch.enabled !== false ? 'enabled' : 'disabled'),
@@ -891,7 +891,7 @@ async function alertHistoryList(args, flags) {
891
891
 
892
892
  console.log('');
893
893
  const rows = history.map(h => [
894
- ui.c.dim(ui.truncate(h._id, 12)),
894
+ ui.c.dim(h.id || h._id || ''),
895
895
  h.ruleName || h.type || '—',
896
896
  h.severity ? ui.statusBadge(h.severity) : '—',
897
897
  ui.truncate(h.message || h.description || '', 40),
@@ -938,7 +938,7 @@ async function blocklistList(args, flags) {
938
938
 
939
939
  console.log('');
940
940
  const rows = items.map(b => [
941
- ui.c.dim(ui.truncate(b._id, 12)),
941
+ ui.c.dim(b.id || b._id || ''),
942
942
  ui.c.red(describeBlockTarget(b)),
943
943
  ui.truncate(b.reason || '', 40),
944
944
  b.source || '—',
@@ -1011,7 +1011,7 @@ async function revokeList(args, flags) {
1011
1011
  if (flags.json) { ui.json(entries); return; }
1012
1012
  console.log('');
1013
1013
  ui.table(['ID', 'Type', 'Value', 'Scope', 'Expires'], entries.map((r) => [
1014
- ui.c.dim(ui.truncate(r._id || r.id, 12)),
1014
+ ui.c.dim(r.id || r._id || ''),
1015
1015
  r.type,
1016
1016
  ui.truncate(r.value, 28),
1017
1017
  r.applicationKey || ui.c.dim('all'),
@@ -1191,7 +1191,7 @@ async function allowlistList(args, flags) {
1191
1191
 
1192
1192
  console.log('');
1193
1193
  const rows = items.map(a => [
1194
- ui.c.dim(ui.truncate(a._id, 12)),
1194
+ ui.c.dim(a.id || a._id || ''),
1195
1195
  ui.c.green(a.ip || '—'),
1196
1196
  a.label || ui.c.dim('—'),
1197
1197
  ui.truncate(a.reason || '', 40),
@@ -1307,7 +1307,7 @@ async function trustedList(args, flags) {
1307
1307
 
1308
1308
  console.log('');
1309
1309
  const rows = items.map(t => [
1310
- ui.c.dim(ui.truncate(t._id, 12)),
1310
+ ui.c.dim(t.id || t._id || ''),
1311
1311
  ui.c.green(t.ip || t.cidr || '—'),
1312
1312
  t.label || t.description || ui.c.dim('—'),
1313
1313
  t.applicationKey || ui.c.dim('all apps'),
@@ -1494,7 +1494,7 @@ async function forensicsLibrary(args, flags) {
1494
1494
 
1495
1495
  console.log('');
1496
1496
  const rows = queries.map(q => [
1497
- ui.c.dim(ui.truncate(q._id, 12)),
1497
+ ui.c.dim(q.id || q._id || ''),
1498
1498
  q.name || q.title || '—',
1499
1499
  ui.truncate(q.description || q.query || '', 50),
1500
1500
  ui.timeAgo(q.createdAt),
@@ -1509,24 +1509,124 @@ async function forensicsLibrary(args, flags) {
1509
1509
 
1510
1510
  // ── Forensics Chat ──
1511
1511
 
1512
- async function forensicsChat(args, flags) {
1513
- requireAuth();
1512
+ const CHAT_PRINT_ROWS = 25;
1513
+
1514
+ // Log timestamps come back as nanosecond (or microsecond) epochs when a query returns
1515
+ // min(timestamp) and the like from the logs table; print them as times.
1516
+ function chatCell(value) {
1517
+ const s = String(value ?? '');
1518
+ if (/^\d{16,19}$/.test(s)) {
1519
+ const n = Number(s);
1520
+ const ms = n >= 1e18 && n < 4e18 ? n / 1e6 : n >= 1e15 && n < 4e15 ? n / 1e3 : null;
1521
+ if (ms != null) return new Date(ms).toISOString().replace('T', ' ').replace('Z', '');
1522
+ }
1523
+ return typeof value === 'object' && value !== null ? JSON.stringify(value) : s;
1524
+ }
1525
+
1526
+ // The API answers a chat message with 202 and works in the background; poll until
1527
+ // the reply settles (complete or awaiting_confirmation) or about five minutes pass.
1528
+ async function waitForChatReply(conversationId, environment, s) {
1529
+ for (let i = 0; i < 150; i++) {
1530
+ await new Promise(r => setTimeout(r, 2000));
1531
+ const result = await api.get(`/forensics/chat/status/${conversationId}`, { query: { environment } });
1532
+ if (result.status !== 'processing') return { ...result, conversationId };
1533
+ if (result._progress?.action && s) s.update(result._progress.action);
1534
+ }
1535
+ return { conversationId, status: 'processing' };
1536
+ }
1537
+
1538
+ async function sendChatMessage(body, environment, s) {
1539
+ const chatRes = await api.post('/forensics/chat', body);
1540
+ if (chatRes.status !== 'processing') {
1541
+ return { conversationId: chatRes.conversationId, status: 'complete', message: chatRes.message };
1542
+ }
1543
+ return waitForChatReply(chatRes.conversationId, environment, s);
1544
+ }
1545
+
1546
+ async function confirmChatQuery(conversationId, environment, s) {
1547
+ await api.post(`/forensics/chat/confirm/${conversationId}`, { environment });
1548
+ return waitForChatReply(conversationId, environment, s);
1549
+ }
1550
+
1551
+ // Resolve what a chat talks to: an existing conversation (--conversation <id>,
1552
+ // which carries its own app and environment) or a new one scoped to --app.
1553
+ async function resolveChatTarget(flags) {
1554
+ if (typeof flags.conversation === 'string' && flags.conversation) {
1555
+ const data = await api.get(`/forensics/conversations/${flags.conversation}`);
1556
+ const conv = data.conversation;
1557
+ return {
1558
+ conversationId: conv.id,
1559
+ title: conv.title,
1560
+ label: conv.applicationName || conv.applicationKey || conv.applicationId,
1561
+ appKey: conv.applicationKey,
1562
+ environment: conv.environment || 'production',
1563
+ messageCount: (conv.messages || []).length,
1564
+ };
1565
+ }
1514
1566
  const resolved = await resolveAppId(flags);
1515
1567
  if (!resolved) {
1516
- ui.error('App required. Usage: securenow forensics chat --app <key>');
1568
+ ui.error('App required. Usage: securenow forensics chat --app <key>, or --conversation <id> to continue one.');
1517
1569
  ui.info('Set a default with: securenow apps default <key>');
1518
1570
  process.exit(1);
1519
1571
  }
1572
+ return {
1573
+ conversationId: null,
1574
+ label: resolved.name || resolved.key,
1575
+ appKey: resolved.key,
1576
+ environment: resolveEnvironment(flags, 'production'),
1577
+ };
1578
+ }
1520
1579
 
1521
- console.log('');
1522
- ui.heading(`Forensics Chat — ${resolved.name || resolved.key}`);
1523
- console.log(ui.c.dim(` App: ${resolved.key} (${resolved.id})`));
1524
- console.log(ui.c.dim(' Type your question, or "exit" to quit.\n'));
1580
+ async function forensicsChat(args, flags) {
1581
+ requireAuth();
1582
+ // `--json "<question>"` parses the question as the flag's value; give it back.
1583
+ const words = [...args];
1584
+ for (const key of ['json', 'yes', 'force']) {
1585
+ if (typeof flags[key] === 'string') { words.push(flags[key]); flags[key] = true; }
1586
+ }
1587
+ const target = await resolveChatTarget(flags);
1588
+ const { environment } = target;
1589
+ let conversationId = target.conversationId;
1590
+ const question = words.join(' ').trim();
1591
+
1592
+ // One question, one reply, then exit: scriptable, and what an agent without MCP uses.
1593
+ if (question) {
1594
+ const s = flags.json ? null : ui.spinner('Thinking');
1595
+ const body = { message: question, environment };
1596
+ if (conversationId) body.conversationId = conversationId;
1597
+ else body.applicationKey = target.appKey;
1598
+ let reply = await sendChatMessage(body, environment, s);
1599
+ if (reply.status === 'awaiting_confirmation' && (flags.yes || flags.force)) {
1600
+ if (s) s.update('Running the large query');
1601
+ reply = await confirmChatQuery(reply.conversationId, environment, s);
1602
+ }
1603
+ if (flags.json) { ui.json(reply); return; }
1604
+ if (reply.status === 'processing') {
1605
+ s.fail('Still working');
1606
+ ui.info(`See the reply later: securenow forensics conversations ${reply.conversationId}`);
1607
+ return;
1608
+ }
1609
+ s.stop('Done');
1610
+ printChatMessage(reply.message);
1611
+ console.log('');
1612
+ if (reply.status === 'awaiting_confirmation') {
1613
+ ui.info('Large query held back. Add --yes to run it, or ask a narrower question.');
1614
+ }
1615
+ ui.info(`Conversation ${reply.conversationId}`);
1616
+ console.log(ui.c.dim(` Follow up: securenow forensics chat --conversation ${reply.conversationId} "<question>"`));
1617
+ console.log('');
1618
+ return;
1619
+ }
1525
1620
 
1526
- const environment = resolveEnvironment(flags, 'production');
1621
+ console.log('');
1622
+ ui.heading(`Forensics Chat — ${target.label}`);
1623
+ if (conversationId) {
1624
+ console.log(ui.c.dim(` Continuing: ${target.title} (${target.messageCount} messages)`));
1625
+ } else {
1626
+ console.log(ui.c.dim(` App: ${target.appKey}`));
1627
+ }
1527
1628
  console.log(ui.c.dim(` Env: ${environment}`));
1528
-
1529
- let conversationId = null;
1629
+ console.log(ui.c.dim(' Type your question, or "exit" to quit.\n'));
1530
1630
 
1531
1631
  const readline = require('readline');
1532
1632
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
@@ -1541,57 +1641,37 @@ async function forensicsChat(args, flags) {
1541
1641
  if (!message || message.toLowerCase() === 'exit' || message.toLowerCase() === 'quit') {
1542
1642
  console.log('');
1543
1643
  ui.info('Chat ended.');
1644
+ if (conversationId) console.log(ui.c.dim(` Continue later: securenow forensics chat --conversation ${conversationId}`));
1544
1645
  break;
1545
1646
  }
1546
1647
 
1547
1648
  const s = ui.spinner('Thinking');
1548
1649
  try {
1549
- const body = { message, applicationKey: resolved.key, environment };
1650
+ const body = { message, environment };
1550
1651
  if (conversationId) body.conversationId = conversationId;
1652
+ else body.applicationKey = target.appKey;
1551
1653
 
1552
- const chatRes = await api.post('/forensics/chat', body);
1553
- conversationId = chatRes.conversationId;
1554
-
1555
- if (chatRes.status === 'processing') {
1556
- let result;
1557
- for (let i = 0; i < 150; i++) {
1558
- await new Promise(r => setTimeout(r, 2000));
1559
- result = await api.get(`/forensics/chat/status/${conversationId}`, { query: { environment } });
1560
- if (result.status === 'complete' || result.status === 'failed' || result.status === 'awaiting_confirmation') break;
1561
- const progress = result._progress;
1562
- if (progress?.action) s.update(progress.action);
1563
- }
1654
+ const reply = await sendChatMessage(body, environment, s);
1655
+ conversationId = reply.conversationId;
1564
1656
 
1565
- if (!result || result.status === 'processing') {
1566
- s.fail('Response timed out');
1567
- continue;
1568
- }
1657
+ if (reply.status === 'processing') {
1658
+ s.fail('Response timed out');
1659
+ continue;
1660
+ }
1569
1661
 
1570
- s.stop('Done');
1571
- printChatMessage(result.message);
1572
-
1573
- if (result.status === 'awaiting_confirmation') {
1574
- const proceed = await ui.confirm('Proceed with this query?');
1575
- if (proceed) {
1576
- const cs = ui.spinner('Executing query');
1577
- await api.post(`/forensics/chat/confirm/${conversationId}`, { environment });
1578
- let confirmResult;
1579
- for (let i = 0; i < 90; i++) {
1580
- await new Promise(r => setTimeout(r, 2000));
1581
- confirmResult = await api.get(`/forensics/chat/status/${conversationId}`, { query: { environment } });
1582
- if (confirmResult.status !== 'processing') break;
1583
- }
1584
- cs.stop('Done');
1585
- if (confirmResult?.message) printChatMessage(confirmResult.message);
1586
- } else {
1587
- ui.info('Query skipped. Ask a different question or refine.');
1588
- }
1662
+ s.stop('Done');
1663
+ printChatMessage(reply.message);
1664
+
1665
+ if (reply.status === 'awaiting_confirmation') {
1666
+ const proceed = await ui.confirm('Proceed with this query?');
1667
+ if (proceed) {
1668
+ const cs = ui.spinner('Executing query');
1669
+ const confirmed = await confirmChatQuery(conversationId, environment, cs);
1670
+ cs.stop('Done');
1671
+ if (confirmed?.message) printChatMessage(confirmed.message);
1672
+ } else {
1673
+ ui.info('Query skipped. Ask a different question or refine.');
1589
1674
  }
1590
- } else if (chatRes.message) {
1591
- s.stop('Done');
1592
- printChatMessage(chatRes.message);
1593
- } else {
1594
- s.stop('Done');
1595
1675
  }
1596
1676
  } catch (err) {
1597
1677
  s.fail('Error');
@@ -1604,9 +1684,61 @@ async function forensicsChat(args, flags) {
1604
1684
  }
1605
1685
  }
1606
1686
 
1607
- function printChatMessage(msg) {
1687
+ async function forensicsConversations(args, flags) {
1688
+ requireAuth();
1689
+ const id = args[0];
1690
+
1691
+ if (id) {
1692
+ const s = flags.json ? null : ui.spinner('Fetching conversation');
1693
+ const data = await api.get(`/forensics/conversations/${id}`);
1694
+ const conv = data.conversation;
1695
+ if (flags.json) { ui.json(conv); return; }
1696
+ s.stop(conv.title);
1697
+ console.log(ui.c.dim(` ${conv.applicationName || conv.applicationKey} · ${conv.environment} · ${(conv.messages || []).length} messages · ${conv.id}`));
1698
+ for (const msg of conv.messages || []) {
1699
+ if (msg.role === 'user') {
1700
+ console.log(`\n ${ui.c.cyan('you >')} ${msg.content}`);
1701
+ } else {
1702
+ printChatMessage(msg, { maxRows: 10 });
1703
+ }
1704
+ }
1705
+ console.log('');
1706
+ console.log(ui.c.dim(` Continue: securenow forensics chat --conversation ${conv.id} "<question>"`));
1707
+ console.log('');
1708
+ return;
1709
+ }
1710
+
1711
+ const s = flags.json ? null : ui.spinner('Fetching conversations');
1712
+ const query = {};
1713
+ if (flags.app) {
1714
+ const resolved = await resolveAppId(flags);
1715
+ if (resolved) query.applicationId = resolved.id;
1716
+ }
1717
+ const data = await api.get('/forensics/conversations', { query });
1718
+ const list = data.conversations || [];
1719
+ if (flags.json) { ui.json(list); return; }
1720
+ s.stop(`Found ${list.length} conversation${list.length !== 1 ? 's' : ''}`);
1721
+ console.log('');
1722
+ ui.table(['ID', 'Title', 'App', 'Env', 'Msgs', 'Updated'], list.map(c => [
1723
+ ui.c.dim(c.id),
1724
+ ui.truncate(c.title || '', 48),
1725
+ c.applicationName || '—',
1726
+ c.environment || '—',
1727
+ String(c.messageCount ?? 0),
1728
+ ui.timeAgo(c.updatedAt),
1729
+ ]));
1730
+ console.log('');
1731
+ }
1732
+
1733
+ function printChatMessage(msg, { maxRows = CHAT_PRINT_ROWS } = {}) {
1608
1734
  if (!msg) return;
1609
1735
  console.log('');
1736
+ if (msg.reasoning) {
1737
+ console.log(ui.c.dim(` plan: ${msg.reasoning}`));
1738
+ }
1739
+ if (Array.isArray(msg.discoverySteps) && msg.discoverySteps.length > 0) {
1740
+ console.log(ui.c.dim(` ${msg.discoverySteps.length} discovery quer${msg.discoverySteps.length !== 1 ? 'ies' : 'y'} run first`));
1741
+ }
1610
1742
  if (msg.content) {
1611
1743
  console.log(` ${ui.c.green('ai >')} ${msg.content}`);
1612
1744
  }
@@ -1616,11 +1748,18 @@ function printChatMessage(msg) {
1616
1748
  console.log(`\n ${ui.c.dim(msg.sqlQuery)}\n`);
1617
1749
  }
1618
1750
  if (msg.results && Array.isArray(msg.results) && msg.results.length > 0) {
1619
- ui.subheading(`Results (${msg.resultCount ?? msg.results.length} rows)`);
1751
+ const total = msg.resultCount ?? msg.results.length;
1752
+ ui.subheading(`Results (${total} rows)`);
1620
1753
  console.log('');
1621
1754
  const headers = Object.keys(msg.results[0]);
1622
- const rows = msg.results.map(row => headers.map(h => String(row[h] ?? '')));
1755
+ const rows = msg.results.slice(0, maxRows).map(row => headers.map(h => chatCell(row[h])));
1623
1756
  ui.table(headers, rows);
1757
+ if (msg.results.length > maxRows) {
1758
+ console.log(ui.c.dim(` … ${msg.results.length - maxRows} more rows (--json for all)`));
1759
+ }
1760
+ }
1761
+ if (msg.findings) {
1762
+ console.log(`\n ${ui.c.cyan('findings >')} ${msg.findings}`);
1624
1763
  }
1625
1764
  if (msg.estimation) {
1626
1765
  const est = msg.estimation;
@@ -1756,7 +1895,7 @@ async function instancesList(args, flags) {
1756
1895
 
1757
1896
  console.log('');
1758
1897
  const rows = instances.map(inst => [
1759
- ui.c.dim(ui.truncate(inst._id, 12)),
1898
+ ui.c.dim(inst.id || inst._id || ''),
1760
1899
  inst.name || inst.host || '—',
1761
1900
  inst.host || '—',
1762
1901
  inst.port != null ? String(inst.port) : '—',
@@ -1857,6 +1996,7 @@ module.exports = {
1857
1996
  trustedRemove,
1858
1997
  forensicsQuery,
1859
1998
  forensicsChat,
1999
+ forensicsConversations,
1860
2000
  forensicsLibrary,
1861
2001
  ipLookup,
1862
2002
  ipTraces,
package/cli/ui.js CHANGED
@@ -354,6 +354,7 @@ function statusBadge(status) {
354
354
  trusted: c.green('■ trusted'),
355
355
  enabled: c.green('● enabled'),
356
356
  disabled: c.dim('○ disabled'),
357
+ expired: c.dim('○ expired'),
357
358
  paused: c.yellow('◆ paused'),
358
359
  critical: c.red('▲ critical'),
359
360
  high: c.red('▲ high'),
package/cli.js CHANGED
@@ -585,7 +585,8 @@ const COMMANDS = {
585
585
  usage: 'securenow forensics <query> [--app <key>]',
586
586
  sub: {
587
587
  query: { desc: 'Run a forensic query', flags: { app: 'App key to scope query', env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/security').forensicsQuery(a, f) },
588
- chat: { desc: 'Interactive forensics chat (scoped to an app)', usage: 'securenow forensics chat --app <key> [--env production]', flags: { app: 'App key to chat with', env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/security').forensicsChat(a, f) },
588
+ chat: { desc: 'Forensics chat with memory: interactive, or one question with "<question>"', usage: 'securenow forensics chat [--app <key> | --conversation <id>] ["<question>"] [--json]', flags: { app: 'App key for a new conversation', conversation: 'Continue this conversation (from the dashboard, the CLI or an agent)', env: 'Environment of a new conversation (default: production)', environment: 'Alias for --env', yes: 'Run a query the chat held back as large', json: 'Print the raw reply (one-question mode)' }, run: (a, f) => require('./cli/security').forensicsChat(a, f) },
589
+ conversations: { desc: 'List forensics chat conversations, or show one', usage: 'securenow forensics conversations [<id>] [--app <key>] [--json]', flags: { app: 'Only conversations about this app', json: 'Output as JSON' }, run: (a, f) => require('./cli/security').forensicsConversations(a, f) },
589
590
  library: { desc: 'View saved queries', run: (a, f) => require('./cli/security').forensicsLibrary(a, f) },
590
591
  },
591
592
  defaultAction: (a, f) => require('./cli/security').forensicsQuery(a, f),
package/firewall.js CHANGED
@@ -1478,7 +1478,7 @@ function init(options) {
1478
1478
  _rateLimitStore = require('./rate-limit-store').createRateLimitStore(storeSpec, {
1479
1479
  log: (...args) => { if (_options.log) fwLog(...args); },
1480
1480
  });
1481
- if (_options.log && _rateLimitStore) fwLog('[securenow] Firewall: shared rate-limit counters → %s', _rateLimitStore.describe());
1481
+ if (_options.log && _rateLimitStore) fwLog('[securenow] Firewall: shared rate-limit counters: connecting to %s', _rateLimitStore.describe());
1482
1482
  } catch (e) {
1483
1483
  _rateLimitStore = null;
1484
1484
  if (_options.log) fwWarn('[securenow] Firewall: rate-limit store disabled (%s); counting in-process', e.message);
package/mcp/catalog.js CHANGED
@@ -845,7 +845,7 @@ const TOOLS = [
845
845
  {
846
846
  name: 'securenow_forensics_query',
847
847
  title: 'Run Forensics Query',
848
- description: 'Run a natural-language forensic query over your traffic and return the result rows. Waits up to 30 seconds; a query still running after that returns its jobId with status processing. Write scope because it creates an async query job.',
848
+ description: 'Run one natural-language forensic query over your traffic and return the result rows. One question, no memory of earlier questions; for an investigation with follow-ups use securenow_forensics_chat. Waits up to 30 seconds; a query still running after that returns its jobId with status processing. Write scope because it creates an async query job.',
849
849
  scope: 'forensics:write',
850
850
  readOnly: false,
851
851
  confirm: true,
@@ -890,6 +890,93 @@ const TOOLS = [
890
890
  endpoint: '/forensics/query-library',
891
891
  inputSchema: objectSchema({}),
892
892
  },
893
+ {
894
+ name: 'securenow_forensics_chat',
895
+ title: 'Ask the Forensics Chat',
896
+ description: 'Ask the SecureNow forensics chat a question about one app\'s traces, logs, errors and custom events, in plain English. Without conversationId it starts a new conversation (applicationKey or applicationId required); with conversationId it continues that conversation, including ones started in the dashboard or the CLI, and the chat sees the earlier questions, their SQL, findings, and the IPs and trace IDs in their results. The reply has the chat\'s reasoning, any discovery queries, the SQL, up to 100 result rows and findings: what the rows show. Read-only over telemetry. Waits up to 90 seconds; a reply still running after that comes back with status processing (use securenow_forensics_chat_status). Status awaiting_confirmation means the query is large: run it with securenow_forensics_chat_confirm or ask a narrower question.',
897
+ scope: 'forensics:write',
898
+ readOnly: false,
899
+ confirm: true,
900
+ method: 'POST',
901
+ endpoint: '/forensics/chat',
902
+ bodyFields: ['message', 'conversationId', 'applicationKey', 'applicationId', 'environment'],
903
+ poll: {
904
+ tool: 'securenow_forensics_chat_status',
905
+ idField: 'conversationId',
906
+ pending: ['processing'],
907
+ timeoutMs: 90000,
908
+ intervalMs: 2000,
909
+ },
910
+ inputSchema: objectSchema({
911
+ message: string('The question, in plain English. Follow-ups can refer to earlier answers ("those IPs", "that session").'),
912
+ conversationId: string('Continue this conversation. Omit to start a new one.'),
913
+ applicationKey: string('App key the new conversation is scoped to. Not needed with conversationId.'),
914
+ applicationId: string('App database id, instead of applicationKey.'),
915
+ environment: string('Environment of a new conversation: production (default), staging, preview, local or test. Omit it when continuing: a conversation keeps its environment.'),
916
+ ...confirmSchema,
917
+ }, ['message', 'confirm', 'reason']),
918
+ },
919
+ {
920
+ name: 'securenow_forensics_chat_status',
921
+ title: 'Forensics Chat Reply',
922
+ description: 'Get the latest reply of a forensics chat conversation: status (processing, complete or awaiting_confirmation), progress while processing, then the reasoning, SQL, result rows and findings.',
923
+ scope: 'forensics:read',
924
+ readOnly: true,
925
+ method: 'GET',
926
+ endpoint: '/forensics/chat/status/:conversationId',
927
+ pathParams: ['conversationId'],
928
+ inputSchema: objectSchema({
929
+ conversationId: string('Forensics chat conversation id.'),
930
+ }, ['conversationId']),
931
+ },
932
+ {
933
+ name: 'securenow_forensics_chat_confirm',
934
+ title: 'Run a Large Forensics Query',
935
+ description: 'Run the query a forensics chat held back as large (status awaiting_confirmation), after the user agrees. Returns the rows and findings like securenow_forensics_chat.',
936
+ scope: 'forensics:write',
937
+ readOnly: false,
938
+ confirm: true,
939
+ method: 'POST',
940
+ endpoint: '/forensics/chat/confirm/:conversationId',
941
+ pathParams: ['conversationId'],
942
+ poll: {
943
+ tool: 'securenow_forensics_chat_status',
944
+ idField: 'conversationId',
945
+ pending: ['processing'],
946
+ timeoutMs: 90000,
947
+ intervalMs: 2000,
948
+ },
949
+ inputSchema: objectSchema({
950
+ conversationId: string('Forensics chat conversation id.'),
951
+ ...confirmSchema,
952
+ }, ['conversationId', 'confirm', 'reason']),
953
+ },
954
+ {
955
+ name: 'securenow_forensics_conversations_list',
956
+ title: 'List Forensics Conversations',
957
+ description: 'List your forensics chat conversations, newest first (up to 100): id, title, app, environment, message count and last update. Includes conversations started in the dashboard and the CLI.',
958
+ scope: 'forensics:read',
959
+ readOnly: true,
960
+ method: 'GET',
961
+ endpoint: '/forensics/conversations',
962
+ queryFields: ['applicationId'],
963
+ inputSchema: objectSchema({
964
+ applicationId: string('Only conversations about this app database id.'),
965
+ }),
966
+ },
967
+ {
968
+ name: 'securenow_forensics_conversation_get',
969
+ title: 'Get Forensics Conversation',
970
+ description: 'Get a whole forensics chat conversation: every question and reply with its reasoning, SQL, findings and up to 100 result rows. Use it to read an investigation someone started in the dashboard before continuing it with securenow_forensics_chat.',
971
+ scope: 'forensics:read',
972
+ readOnly: true,
973
+ method: 'GET',
974
+ endpoint: '/forensics/conversations/:id',
975
+ pathParams: ['id'],
976
+ inputSchema: objectSchema({
977
+ id: string('Forensics chat conversation id.'),
978
+ }, ['id']),
979
+ },
893
980
  {
894
981
  name: 'securenow_analytics_summary',
895
982
  title: 'Analytics Summary',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.14.0",
3
+ "version": "8.15.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",