securenow 8.17.0 → 8.18.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/SKILL-CLI.md CHANGED
@@ -28,6 +28,22 @@ npx -p securenow securenow-mcp
28
28
 
29
29
  The MCP server reads admin/control-plane auth from `.securenow/admin.json` and SDK runtime app credentials from `.securenow/runtime.json`, with legacy `.securenow/credentials.json` still supported. Admin/global tools use admin auth; runtime-scoped read tools can use the runtime API key where its scopes allow it. Write tools require `confirm:true` plus a reason.
30
30
 
31
+ **For agents, prefer the hosted MCP** (`https://api.securenow.ai/mcp`, OAuth sign-in, no install and always the current tools). With the local stdio server, warm the package once so the first connection doesn't time out on the download (`npx -y securenow@latest --version`), and give the client a longer connect timeout (Claude Code: `MCP_TIMEOUT=120000`).
32
+
33
+ **Tool profiles** (stdio server): `securenow mcp --profile <name>` (or `SECURENOW_MCP_PROFILE=<name>`) serves only the tools one job needs, so the agent carries less context. Every profile keeps `securenow_auth_status`, `securenow_apps_list` and `securenow_apps_info`; calling a tool outside the profile returns an error naming the profile.
34
+
35
+ | Profile | Tools | What's in it |
36
+ |---|---|---|
37
+ | `all` (default) | 112 | everything |
38
+ | `investigator` | 58 | every read-only tool, plus forensics question/chat/confirm |
39
+ | `detection` | 47 | `securenow_alert_rule*` (draft, create, test, run, runs, explain, versions, exclusions, request promotion…), traces, logs, forensics, IP intelligence, notifications list/get |
40
+ | `responder` | 77 | notifications, human queue, alert reviews, blocklist, allowlist, trusted IPs, rate limits, challenges, automations, firewall, IP intelligence, plus rule get/runs/explain and traces/logs for context |
41
+
42
+ ```bash
43
+ claude mcp add securenow-investigator -- npx -y securenow@latest mcp --profile investigator
44
+ codex mcp add securenow-detection -- npx securenow mcp --profile detection
45
+ ```
46
+
31
47
  ### Authenticate
32
48
 
33
49
  ```bash
@@ -213,8 +229,11 @@ securenow traces analyze <traceId> --env production # AI-powered trace analysi
213
229
  securenow logs [--app <key>] [--env production] [--limit N] [--minutes M] [--level error|warn|info]
214
230
  securenow logs list --app my-app --env production --minutes 30 --level error
215
231
  securenow logs trace <traceId> --env production # logs correlated to a specific trace
232
+ securenow logs errors --app my-app --since 6h [--warn] # what is failing: error lines grouped by message (ids/numbers normalized), counts, first/last seen, sample traces
216
233
  ```
217
234
 
235
+ For "what's broken right now?", start with `logs errors` (MCP `securenow_logs_errors`): one call returns the distinct failures, not hundreds of raw lines.
236
+
218
237
  ### Notifications
219
238
 
220
239
  ```bash
@@ -295,6 +314,7 @@ securenow alerts rules delete <id> --yes # delete a custom rule (system rules
295
314
  securenow alerts rules update <id> --applications-all # all current & future apps
296
315
  securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
297
316
  securenow alerts rules test <id> --wait # dry-run an existing rule's query (default; --mode live inserts 6 synthetic attack requests)
317
+ securenow alerts rules change <id> "raise the threshold to 20" # rewrite a rule from English: checked, shown before/after, applied as a new version
298
318
  securenow alerts rules runs <id> [--since 1h] # what each run saw and why it did or didn't notify (+ next scheduled run)
299
319
  securenow alerts rules run <id> # run a periodic rule now and print the run (--no-wait to just queue it)
300
320
  securenow alerts rules why <id> --ip <ip> [--since 1h] # why the rule did or didn't alert on one address: verdict + findings
@@ -322,6 +342,8 @@ right tenant-scope column per table: logs (`signoz_logs.distributed_logs_v2`) us
322
342
 
323
343
  MCP parity for quiet rules: `securenow_alert_rule_runs` (run history), `securenow_alert_rule_run_now` (write, confirm) and `securenow_alert_rule_explain` (`ip`, `since`: verdict + findings).
324
344
 
345
+ **Agents don't promote rules; they ask.** When a test-mode rule has proved itself, an agent runs `securenow alerts rules request-promotion <id> --reason "..." --evidence "..."` (MCP `securenow_alert_rule_request_promotion`). A person sees it on the rules page or with `securenow human promotions` and runs `securenow human approve-promotion <request-id> --yes` (the rule goes to prod, recorded as a rule version naming the approver) or `deny-promotion <request-id> --reason "..."`. API keys and MCP tokens can ask but not decide.
346
+
325
347
  MCP parity for noisy alert-rule reviews:
326
348
 
327
349
  - `securenow_alert_rule_get`
package/cli/human.js CHANGED
@@ -569,6 +569,66 @@ async function work(args, flags) {
569
569
  console.log('');
570
570
  }
571
571
 
572
+ // ── Rule promotion requests ──
573
+ // Agents create rules in test mode and ask a person to promote them. A person approves (the rule
574
+ // goes to prod, recorded as a rule version) or denies here or on the dashboard's rules page.
575
+
576
+ async function promotions(args, flags) {
577
+ requireAuth();
578
+ const status = typeof flags.status === 'string' ? flags.status : 'pending';
579
+ const s = ui.spinner('Fetching promotion requests');
580
+ let data;
581
+ try {
582
+ data = await api.get('/alert-rules/promotion-requests', { query: { status, limit: flags.limit || 50 } });
583
+ s.stop(`Found ${(data.requests || []).length} ${status === 'all' ? '' : `${status} `}request${(data.requests || []).length === 1 ? '' : 's'}`);
584
+ } catch (err) {
585
+ s.fail('Failed to fetch promotion requests');
586
+ throw err;
587
+ }
588
+ if (flags.json) { ui.json(data); return; }
589
+ const requests = data.requests || [];
590
+ if (requests.length === 0) { ui.info('No promotion requests waiting.'); return; }
591
+ console.log('');
592
+ for (const r of requests) {
593
+ console.log(` ${ui.c.bold(r.ruleName || r.ruleId)} ${ui.c.dim(r.id)} ${r.status}`);
594
+ console.log(` asked by ${r.requestedBy?.name || r.requestedBy?.kind || 'unknown'} ${ui.timeAgo(r.createdAt)}: ${r.reason}`);
595
+ if (r.evidence) console.log(ui.c.dim(` evidence: ${ui.truncate(String(r.evidence).replace(/\s+/g, ' '), 160)}`));
596
+ if (r.status !== 'pending') console.log(ui.c.dim(` ${r.status} by ${r.decidedByName || 'someone'}${r.decisionNote ? `: ${r.decisionNote}` : ''}`));
597
+ }
598
+ console.log('');
599
+ if (requests.some((r) => r.status === 'pending')) {
600
+ console.log(ui.c.dim(' Approve: securenow human approve-promotion <id> --yes [--note "..."] Deny: securenow human deny-promotion <id> --reason "..."'));
601
+ console.log(ui.c.dim(' Before approving, check what it would arm: securenow alerts rules show <ruleId> and securenow alerts rules runs <ruleId>'));
602
+ console.log('');
603
+ }
604
+ }
605
+
606
+ async function decidePromotion(args, flags, decision) {
607
+ requireAuth();
608
+ const id = args[0];
609
+ const verb = decision === 'approve' ? 'approve-promotion' : 'deny-promotion';
610
+ if (!id) {
611
+ ui.error(`Usage: securenow human ${verb} <request-id> ${decision === 'approve' ? '--yes [--note "..."]' : '--reason "..."'}`);
612
+ process.exit(1);
613
+ }
614
+ if (decision === 'approve' && !flags.yes && !flags.force) {
615
+ ui.warn('Approving moves the rule to prod: its instant block, matching automations and the AI pipeline can act on its alerts.');
616
+ const ok = await ui.confirm('Approve this promotion?');
617
+ if (!ok) { ui.info('Cancelled'); return; }
618
+ }
619
+ const note = flags.note || flags.reason || '';
620
+ const s = ui.spinner(decision === 'approve' ? 'Approving' : 'Denying');
621
+ try {
622
+ const data = await api.post(`/alert-rules/promotion-requests/${id}/${decision}`, { note });
623
+ s.stop(decision === 'approve' ? `Approved: "${data.alertRule?.name || data.request?.ruleName}" is in prod` : 'Denied: the rule stays in test mode');
624
+ for (const warning of data.warnings || []) ui.warn(warning);
625
+ if (flags.json) ui.json(data);
626
+ } catch (err) {
627
+ s.fail(`Failed to ${decision} the promotion`);
628
+ throw err;
629
+ }
630
+ }
631
+
572
632
  module.exports = {
573
633
  list,
574
634
  show,
@@ -578,4 +638,7 @@ module.exports = {
578
638
  report,
579
639
  prompt,
580
640
  work,
641
+ promotions,
642
+ approvePromotion: (args, flags) => decidePromotion(args, flags, 'approve'),
643
+ denyPromotion: (args, flags) => decidePromotion(args, flags, 'deny'),
581
644
  };
package/cli/monitor.js CHANGED
@@ -294,6 +294,48 @@ async function logsList(args, flags) {
294
294
  }
295
295
  }
296
296
 
297
+ // Error lines grouped by message (ids and numbers normalized): what is failing, how often, since when.
298
+ async function logsErrors(args, flags) {
299
+ requireAuth();
300
+ const appKey = resolveApp(flags);
301
+ if (!appKey) {
302
+ ui.error('No app specified. Use --app <key> or set a default.');
303
+ process.exit(1);
304
+ }
305
+ const query = { appKeys: appKey, since: flags.since || '1h', limit: flags.limit || 20 };
306
+ if (flags.warn) query.includeWarn = 'true';
307
+ addEnvironment(query, flags);
308
+ const s = ui.spinner('Grouping error logs');
309
+ let data;
310
+ try {
311
+ data = await api.get('/logs/errors', { query });
312
+ const kind = data.includeWarn ? 'error/warning' : 'error';
313
+ s.stop(`${data.total} ${kind} line${data.total === 1 ? '' : 's'} in ${data.distinct} group${data.distinct === 1 ? '' : 's'}`);
314
+ } catch (err) {
315
+ s.fail('Failed to summarize error logs');
316
+ throw err;
317
+ }
318
+ if (flags.json) { ui.json(data); return; }
319
+ const groups = data.groups || [];
320
+ console.log('');
321
+ if (groups.length === 0) { ui.info(`No ${data.includeWarn ? 'warnings or errors' : 'errors'} since ${data.window?.from}.`); return; }
322
+ for (const g of groups) {
323
+ const last = g.lastSeen ? ui.timeAgo(g.lastSeen) : '';
324
+ console.log(` ${ui.c.red(String(g.occurrences).padStart(5))} ${ui.truncate(String(g.sample || g.signature).replace(/\s+/g, ' '), 110)}`);
325
+ const meta = [
326
+ (g.severities || []).join('/'),
327
+ (g.environments || []).join(', '),
328
+ g.exceptionType,
329
+ last && `last ${last}`,
330
+ g.sampleTraceIds?.[0] && `trace ${g.sampleTraceIds[0]}`,
331
+ ].filter(Boolean).join(' · ');
332
+ if (meta) console.log(ui.c.dim(` ${meta}`));
333
+ }
334
+ console.log('');
335
+ console.log(ui.c.dim(' A group\'s requests: securenow logs trace <traceId> Ask why: securenow forensics chat --app <key> "<question>"'));
336
+ console.log('');
337
+ }
338
+
297
339
  async function logsTrace(args, flags) {
298
340
  requireAuth();
299
341
  const traceId = args[0];
@@ -499,6 +541,7 @@ module.exports = {
499
541
  tracesAnalyze,
500
542
  logsList,
501
543
  logsTrace,
544
+ logsErrors,
502
545
  notificationsList,
503
546
  notificationsRead,
504
547
  notificationsReadAll,
package/cli/security.js CHANGED
@@ -55,6 +55,9 @@ async function alertRulesRoute(args, flags) {
55
55
  if (sub === 'from-text' || sub === 'draft') {
56
56
  return alertRuleFromText(args.slice(1), flags);
57
57
  }
58
+ if (sub === 'change') {
59
+ return alertRuleChange(args.slice(1), flags);
60
+ }
58
61
  if (sub === 'show') {
59
62
  return alertRuleShow(args.slice(1), flags);
60
63
  }
@@ -94,6 +97,9 @@ async function alertRulesRoute(args, flags) {
94
97
  if (sub === 'runs') {
95
98
  return alertRuleRuns(args.slice(1), flags);
96
99
  }
100
+ if (sub === 'request-promotion') {
101
+ return alertRuleRequestPromotion(args.slice(1), flags);
102
+ }
97
103
  if (sub === 'run' || sub === 'run-now') {
98
104
  return alertRuleRunNow(args.slice(1), flags);
99
105
  }
@@ -472,9 +478,14 @@ async function alertRuleUpdate(args, flags) {
472
478
 
473
479
  // Lifecycle mode promote/demote (also available as: alerts rules promote|demote <id>).
474
480
  if (flags.mode === 'test' || flags.mode === 'prod') body.mode = flags.mode;
481
+ if (flags.severity) {
482
+ const severity = String(flags.severity).toLowerCase();
483
+ if (!['critical', 'high', 'medium', 'low'].includes(severity)) { ui.error('--severity must be critical, high, medium or low'); process.exit(1); }
484
+ body.severity = severity;
485
+ }
475
486
 
476
487
  if (Object.keys(body).length === 0) {
477
- ui.error('Nothing to update. Use --applications-all/--apps, --status (or --enable/--disable/--pause), or --mode test|prod (or: alerts rules promote/demote <id>).');
488
+ ui.error('Nothing to update. Use --applications-all/--apps, --status (or --enable/--disable/--pause), --severity, or --mode test|prod (or: alerts rules promote/demote <id>).');
478
489
  process.exit(1);
479
490
  }
480
491
 
@@ -482,7 +493,7 @@ async function alertRuleUpdate(args, flags) {
482
493
  try {
483
494
  await api.put(`/alert-rules/${id}`, body);
484
495
  s.stop('Updated');
485
- const extras = [body.status && `status → ${body.status}`, body.mode && `mode → ${body.mode}`].filter(Boolean);
496
+ const extras = [body.status && `status → ${body.status}`, body.mode && `mode → ${body.mode}`, body.severity && `severity → ${body.severity}`].filter(Boolean);
486
497
  ui.success('Alert rule updated' + (extras.length ? ` (${extras.join(', ')})` : ''));
487
498
  } catch (err) {
488
499
  s.fail('Failed to update alert rule');
@@ -873,6 +884,87 @@ async function alertRuleFromText(args, flags) {
873
884
  console.log(ui.c.dim(` Runs: securenow alerts rules runs ${id} Why no alert: securenow alerts rules why ${id} --ip <ip>`));
874
885
  }
875
886
 
887
+ // Describe a change to an existing rule in English: the API rewrites the rule from its current
888
+ // version and the app's data, checks the result, and the change is applied as a new rule version.
889
+ async function alertRuleChange(args, flags) {
890
+ requireAuth();
891
+ const id = args[0];
892
+ const text = args.slice(1).join(' ').trim() || (typeof flags.text === 'string' ? flags.text.trim() : '');
893
+ if (!id || !text) {
894
+ ui.error('Usage: securenow alerts rules change <rule-id> "<what should change>" [--app <key>] [--yes] [--draft-only] [--json]');
895
+ console.log(ui.c.dim(' Example: securenow alerts rules change <id> "raise the threshold to 20 failed sign-ins"'));
896
+ process.exit(1);
897
+ }
898
+ const body = { text, ruleId: id };
899
+ if (flags.app) body.applicationKey = flags.app;
900
+
901
+ const s = ui.spinner('Rewriting the rule from your app\'s data');
902
+ let result;
903
+ try {
904
+ const started = await api.post('/alert-rules/draft', body);
905
+ result = await waitForDraft(started.draftId, s);
906
+ } catch (err) {
907
+ s.fail('Failed to draft the change');
908
+ throw err;
909
+ }
910
+ if (result.status !== 'complete') {
911
+ s.fail(result.error || 'Could not draft the change');
912
+ process.exit(1);
913
+ }
914
+ s.stop('Change drafted');
915
+
916
+ const matches = Number(result.check?.matches || 0);
917
+ const refuse = Boolean(flags.yes) && !flags.force && !flags['draft-only'] && matches > 0;
918
+ if (flags.json && (flags['draft-only'] || !flags.yes)) { ui.json(result); return; }
919
+ if (flags.json && refuse) {
920
+ ui.json({ ...result, applied: false, refused: 'check_matches' });
921
+ process.exit(1);
922
+ }
923
+ if (result.current?.kind === 'instant') {
924
+ console.log('');
925
+ console.log(ui.c.dim(` Before: ${result.current.matchMode === 'all' ? 'all of' : 'any of'} ${(result.current.conditions || []).map((c) => `${c.field} ${c.operator} ${c.value}`).join('; ')}`));
926
+ } else if (result.current?.sql) {
927
+ console.log('');
928
+ console.log(ui.c.dim(' Before'));
929
+ for (const line of String(result.current.sql).split('\n')) console.log(ui.c.dim(` ${line}`));
930
+ }
931
+ printDraft(result);
932
+ console.log('');
933
+ if (flags['draft-only']) { ui.info('Draft only: nothing was changed.'); return; }
934
+ if (refuse) {
935
+ ui.warn(`Not applied: the changed rule matches ${matches} row${matches === 1 ? '' : 's'} in your data right now, so it would alert at once. Apply anyway with --yes --force.`);
936
+ process.exit(1);
937
+ }
938
+ if (!flags.yes) {
939
+ if (matches > 0) ui.warn(`The changed rule matches ${matches} row${matches === 1 ? '' : 's'} right now (above): it alerts as soon as it is applied.`);
940
+ const ok = await ui.confirm('Apply this change? It is saved as a new rule version (roll back with alerts rules rollback)');
941
+ if (!ok) { ui.info('Nothing changed'); return; }
942
+ }
943
+
944
+ const d = result.draft || {};
945
+ const update = {
946
+ name: d.name,
947
+ description: d.summary || undefined,
948
+ severity: d.severity,
949
+ reviewNote: `Changed in English: ${text}`.slice(0, 1000),
950
+ // Instant: only what the draft decides, so the rule keeps its own instant-block settings.
951
+ ...(d.executionMode === 'instant'
952
+ ? { instant: { matchMode: d.instant?.matchMode, conditions: d.instant?.conditions || [], environments: d.instant?.environments || [] } }
953
+ : { sqlQuery: d.sqlQuery, schedule: d.schedule }),
954
+ };
955
+ const c = ui.spinner('Applying the change');
956
+ try {
957
+ const data = await api.put(`/alert-rules/${id}`, update);
958
+ c.stop(`Changed: version ${data.configVersion || '?'}`);
959
+ printWarnings(data);
960
+ if (flags.json) ui.json(data);
961
+ else console.log(ui.c.dim(` History: securenow alerts rules versions ${id} Runs: securenow alerts rules runs ${id}`));
962
+ } catch (err) {
963
+ c.fail('Failed to apply the change');
964
+ throw err;
965
+ }
966
+ }
967
+
876
968
  async function alertRuleTest(args, flags) {
877
969
  requireAuth();
878
970
  const id = args[0];
@@ -1230,6 +1322,33 @@ async function alertRuleWhy(args, flags) {
1230
1322
  console.log('');
1231
1323
  }
1232
1324
 
1325
+ // An agent hands the promotion of its test-mode rule to a person: the request waits on the rules
1326
+ // page and in `securenow human promotions` until someone approves or denies it.
1327
+ async function alertRuleRequestPromotion(args, flags) {
1328
+ requireAuth();
1329
+ const id = args[0];
1330
+ const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
1331
+ if (!id || !reason) {
1332
+ ui.error('Usage: securenow alerts rules request-promotion <rule-id> --reason "why it is ready for prod" [--evidence "<text>|@file"]');
1333
+ process.exit(1);
1334
+ }
1335
+ const body = { reason };
1336
+ if (typeof flags.evidence === 'string') {
1337
+ body.evidence = flags.evidence.startsWith('@') ? fs.readFileSync(flags.evidence.slice(1), 'utf8') : flags.evidence;
1338
+ }
1339
+ const s = ui.spinner('Asking for promotion');
1340
+ try {
1341
+ const data = await api.post(`/alert-rules/${id}/promotion-requests`, body);
1342
+ s.stop(data.existing ? 'A request for this rule is already waiting' : 'Promotion requested');
1343
+ if (flags.json) { ui.json(data); return; }
1344
+ ui.info(data.message || 'Waiting for a person to approve it.');
1345
+ if (data.request?.id) console.log(ui.c.dim(` Request ${data.request.id}. A person decides with: securenow human promotions`));
1346
+ } catch (err) {
1347
+ s.fail('Failed to request promotion');
1348
+ throw err;
1349
+ }
1350
+ }
1351
+
1233
1352
  // ── Alert Channels ──
1234
1353
 
1235
1354
  function channelDestination(ch) {
package/cli.js CHANGED
@@ -195,6 +195,7 @@ const COMMANDS = {
195
195
  sub: {
196
196
  list: { desc: 'List recent logs', flags: { app: 'App key', env: 'Environment (production, staging, preview, local, or all)', environment: 'Alias for --env', limit: 'Max results (default 200)', since: 'Time window (e.g. 30m, 6h, 2d)', minutes: 'Time window in minutes (alias for --since Nm)', level: 'Filter by level (error, warn, info, debug)', start: 'Start time (ISO 8601)', end: 'End time (ISO 8601)' }, run: (a, f) => require('./cli/monitor').logsList(a, f) },
197
197
  trace: { desc: 'Show logs for a trace', usage: 'securenow logs trace <traceId>', flags: { app: 'App key', env: 'Environment', environment: 'Alias for --env' }, run: (a, f) => require('./cli/monitor').logsTrace(a, f) },
198
+ errors: { desc: 'Error lines grouped by message (ids and numbers normalized), most frequent first', usage: 'securenow logs errors [--app <key>] [--env <env>] [--since 1h] [--warn] [--limit 20]', flags: { app: 'App key', env: 'Environment', environment: 'Alias for --env', since: 'Window, e.g. 30m, 6h, 2d (default 1h)', warn: 'Include warnings', limit: 'Max groups (default 20)', json: 'Output as JSON' }, run: (a, f) => require('./cli/monitor').logsErrors(a, f) },
198
199
  },
199
200
  defaultSub: 'list',
200
201
  },
@@ -249,9 +250,10 @@ const COMMANDS = {
249
250
  },
250
251
  human: {
251
252
  desc: 'Work the human action queue prepared by SecureNow AI',
252
- usage: 'securenow human <list|show|block|fp|report|action|prompt|work> [row|notificationId:ip] [options]',
253
+ usage: 'securenow human <list|show|block|fp|report|action|prompt|work|promotions|approve-promotion|deny-promotion> [row|notificationId:ip|request-id] [options]',
253
254
  flags: {
254
255
  json: 'Output as JSON',
256
+ note: 'With approve-promotion / deny-promotion: a note recorded with the decision',
255
257
  page: 'Queue page number',
256
258
  limit: 'Queue page size',
257
259
  search: 'Search IP, rule, path, or verdict',
@@ -286,6 +288,9 @@ const COMMANDS = {
286
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
289
  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) },
288
290
  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
+ 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) },
292
+ 'approve-promotion': { desc: 'Approve a promotion request: the rule goes to prod', usage: 'securenow human approve-promotion <request-id> --yes [--note "..."]', run: (a, f) => require('./cli/human').approvePromotion(a, f) },
293
+ 'deny-promotion': { desc: 'Deny a promotion request: the rule stays in test mode', usage: 'securenow human deny-promotion <request-id> --reason "..."', run: (a, f) => require('./cli/human').denyPromotion(a, f) },
289
294
  },
290
295
  defaultSub: 'list',
291
296
  },
@@ -314,9 +319,11 @@ const COMMANDS = {
314
319
  desc: 'Create (from English or SQL), list, show, update, duplicate, promote/demote, set-sql, validate, delete, test, run, explain, or tune alert rules',
315
320
  usage: 'securenow alerts rules <list|from-text|create|show|update|duplicate|promote|demote|set-sql|validate|delete|test|run|runs|why|dry-run-query|tune-query|exclusions|versions|version|rollback> [options]\n'
316
321
  + ' securenow alerts rules from-text "<what to catch, in English>" --app <key> [--env <environment>] [--instant|--periodic] [--yes|--draft-only]\n'
322
+ + ' securenow alerts rules change <id> "<what should change>" [--yes|--draft-only] rewrite a rule from English, checked, applied as a new version\n'
317
323
  + ' securenow alerts rules runs <id> [--limit 20] [--since 1h] what each run saw and why it did or didn\'t notify\n'
318
324
  + ' securenow alerts rules run <id> [--no-wait] run a periodic rule now\n'
319
- + ' securenow alerts rules why <id> --ip <ip> [--since 1h] why the rule did or didn\'t alert on one address',
325
+ + ' securenow alerts rules why <id> --ip <ip> [--since 1h] why the rule did or didn\'t alert on one address\n'
326
+ + ' securenow alerts rules request-promotion <id> --reason "..." ask a person to promote a test-mode rule (for agents)',
320
327
  flags: {
321
328
  json: 'Output as JSON',
322
329
  env: 'With from-text or create --conditions: only watch this environment (default: every environment)',
@@ -330,7 +337,7 @@ const COMMANDS = {
330
337
  nlp: 'With create: plain-English intent stored on the query',
331
338
  text: 'Alias for --nlp',
332
339
  category: 'With create: query category (default: custom)',
333
- severity: 'With create: critical | high | medium | low',
340
+ severity: 'With create/update: critical | high | medium | low',
334
341
  schedule: 'With create: cron expression (default */15 * * * *)',
335
342
  'throttle-minutes': 'With create: one alert per address every N minutes (default 15)',
336
343
  'no-throttle': 'With create: disable throttling',
@@ -359,7 +366,8 @@ const COMMANDS = {
359
366
  query: 'Alias for --sql',
360
367
  file: 'Read SQL from a file',
361
368
  'no-validate': 'With create/set-sql: skip the post-write dry-run + rollback',
362
- reason: 'Audit reason for a write',
369
+ reason: 'Audit reason for a write. With request-promotion: why the rule is ready for prod',
370
+ evidence: 'With request-promotion: evidence text or @file (runs, notifications, false-positive checks)',
363
371
  'apply-globally': 'Required for system query tuning',
364
372
  'reactivate-paused': 'Reactivate paused system copies after tuning',
365
373
  'expected-hash': 'Expected current SQL SHA-256 hash',
@@ -775,8 +783,14 @@ const COMMANDS = {
775
783
  },
776
784
  mcp: {
777
785
  desc: 'Start the SecureNow MCP server over stdio',
778
- usage: 'securenow mcp',
779
- run: () => require('./mcp/server'),
786
+ usage: 'securenow mcp [--profile all|investigator|detection|responder]',
787
+ flags: {
788
+ profile: 'Serve a subset of the tools: investigator (read-only investigation), detection (alert rules), responder (alerts, blocks, limits). Default: all',
789
+ },
790
+ run: (a, f) => {
791
+ if (typeof f.profile === 'string') process.env.SECURENOW_MCP_PROFILE = f.profile;
792
+ require('./mcp/server');
793
+ },
780
794
  },
781
795
  version: {
782
796
  desc: 'Show CLI version',
package/esm-detect.js ADDED
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Is this an ESM app, so the preload must register the ESM loader hook?
5
+ *
6
+ * Node decides a file's module type from the nearest package.json above THE FILE, not from
7
+ * the working directory. Process managers often start apps from another directory (pm2 `cwd`,
8
+ * systemd WorkingDirectory, a Docker WORKDIR, a supervisor that runs each environment from its
9
+ * own folder). Checking only process.cwd() then missed the hook, and every library the app
10
+ * imports (Express, pg, ...) went uninstrumented: no http.route on any request span.
11
+ *
12
+ * ESM when any of these says so: the entry script is .mjs; the nearest package.json above the
13
+ * entry script (process.argv[1], or pm2's pm_exec_path) has "type": "module"; or the working
14
+ * directory's package.json does (the original check, kept for launchers whose argv[1] is a wrapper).
15
+ */
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ function readType(pkgPath) {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8').replace(/^/, '')).type || 'commonjs';
22
+ } catch (_) {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function nearestPackageJson(startDir) {
28
+ let dir = startDir;
29
+ for (let i = 0; i < 64; i++) {
30
+ const candidate = path.join(dir, 'package.json');
31
+ if (fs.existsSync(candidate)) return candidate;
32
+ const parent = path.dirname(dir);
33
+ if (parent === dir) return null;
34
+ dir = parent;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /** @returns {{ esm: boolean, reason: string|null }} */
40
+ function detectEsmApp({ argv = process.argv, cwd = process.cwd(), env = process.env } = {}) {
41
+ const entries = [argv && argv[1], env && env.pm_exec_path]
42
+ .filter((p) => typeof p === 'string' && p && !p.startsWith('-'))
43
+ .map((p) => path.resolve(cwd, p));
44
+
45
+ for (const entry of entries) {
46
+ if (/\.mjs$/i.test(entry)) return { esm: true, reason: `${path.basename(entry)} is an .mjs file` };
47
+ if (/\.cjs$/i.test(entry)) continue;
48
+ const pkgPath = nearestPackageJson(path.dirname(entry));
49
+ if (pkgPath && readType(pkgPath) === 'module') return { esm: true, reason: `${pkgPath} has "type": "module"` };
50
+ }
51
+
52
+ const cwdPkg = path.resolve(cwd, 'package.json');
53
+ if (fs.existsSync(cwdPkg) && readType(cwdPkg) === 'module') return { esm: true, reason: `${cwdPkg} has "type": "module"` };
54
+ return { esm: false, reason: null };
55
+ }
56
+
57
+ module.exports = { detectEsmApp, nearestPackageJson };
package/mcp/catalog.js CHANGED
@@ -353,7 +353,7 @@ const TOOLS = [
353
353
  {
354
354
  name: 'securenow_traces_show',
355
355
  title: 'Show Trace',
356
- description: 'Show spans for a trace id scoped to one or more app keys.',
356
+ description: 'Show spans for a trace id scoped to one or more app keys. Fields identical on every span (service, environment and other resource attributes) come once under shared; empty fields are left out.',
357
357
  scope: 'traces:read',
358
358
  readOnly: true,
359
359
  method: 'GET',
@@ -385,6 +385,25 @@ const TOOLS = [
385
385
  limit: number('Maximum logs to return.', { minimum: 1, maximum: 500 }),
386
386
  }, ['appKeys']),
387
387
  },
388
+ {
389
+ name: 'securenow_logs_errors',
390
+ title: 'Summarize Error Logs',
391
+ description: 'What is failing: error (and fatal) log lines grouped by message, with ids, uuids, IPs and numbers normalized so repeats are one group. Each group: a sample line, how many times, first and last seen, environments, exception type and sample trace ids (pass one to securenow_logs_for_trace or securenow_traces_show). Far cheaper than listing raw logs. includeWarn adds warnings.',
392
+ scope: 'logs:read',
393
+ readOnly: true,
394
+ method: 'GET',
395
+ endpoint: '/logs/errors',
396
+ queryFields: ['appKeys', 'environment', 'since', 'from', 'to', 'includeWarn', 'limit'],
397
+ normalize: { appKeys: normalizeAppKeys },
398
+ inputSchema: objectSchema({
399
+ ...appKeysInput,
400
+ ...environmentInput,
401
+ since: string('Window: 30m, 1h (default), 6h, 2d; or pass from/to.'),
402
+ ...timeRangeInput,
403
+ includeWarn: boolean('Also group warnings.'),
404
+ limit: number('Maximum groups (default 20).', { minimum: 1, maximum: 100 }),
405
+ }, ['appKeys']),
406
+ },
388
407
  {
389
408
  name: 'securenow_logs_for_trace',
390
409
  title: 'Logs For Trace',
@@ -1381,13 +1400,13 @@ const TOOLS = [
1381
1400
  {
1382
1401
  name: 'securenow_alert_rule_draft',
1383
1402
  title: 'Draft an Alert Rule From English',
1384
- description: 'Turn a detection idea in plain English into an alert rule draft for one app, checked against that app\'s data. It reads the app\'s real event types, attribute keys and routes, then picks the kind: instant (conditions checked on every incoming request, alerts within seconds; one request is enough evidence) or periodic (SQL run on a schedule: counts, thresholds, distinct counts, ratios, sequences, baselines, security events, app log lines). Periodic SQL is run once against the data; instant conditions are replayed over the app\'s last 2000 requests, so check.matches shows what would alert today (false positives). Nothing is saved: create it with securenow_alert_rule_create, passing the draft fields. Waits up to 120 seconds.',
1403
+ description: 'Turn a detection idea in plain English into an alert rule draft for one app, checked against that app\'s data. It reads the app\'s real event types, attribute keys and routes, then picks the kind: instant (conditions checked on every incoming request, alerts within seconds; one request is enough evidence) or periodic (SQL run on a schedule: counts, thresholds, distinct counts, ratios, sequences, baselines, security events, app log lines). Periodic SQL is run once against the data; instant conditions are replayed over the app\'s last 2000 requests, so check.matches shows what would alert today (false positives). Nothing is saved: create it with securenow_alert_rule_create, passing the draft fields. To CHANGE an existing rule, pass ruleId and describe the change in text: the draft is the whole rule rewritten (same kind, the rest kept), with `current` showing the rule as it is; apply it with securenow_alert_rule_update (sqlQuery + schedule, or instant {matchMode, conditions, environments}). Waits up to 120 seconds.',
1385
1404
  scope: 'alerts:write',
1386
1405
  readOnly: false,
1387
1406
  confirm: true,
1388
1407
  method: 'POST',
1389
1408
  endpoint: '/alert-rules/draft',
1390
- bodyFields: ['text', 'applicationKey', 'environment', 'kind'],
1409
+ bodyFields: ['text', 'applicationKey', 'environment', 'kind', 'ruleId'],
1391
1410
  poll: {
1392
1411
  tool: 'securenow_alert_rule_draft_status',
1393
1412
  idField: 'draftId',
@@ -1400,8 +1419,9 @@ const TOOLS = [
1400
1419
  applicationKey: string('App key the rule watches; the draft is checked against its data.'),
1401
1420
  environment: string('Only watch this environment (e.g. production). Omit to watch every environment.'),
1402
1421
  kind: string('Optional: instant or periodic. Omit to let the drafter choose.'),
1422
+ ruleId: string('Optional: an existing custom rule to change; text then describes the change. applicationKey defaults to one the rule watches.'),
1403
1423
  ...confirmSchema,
1404
- }, ['text', 'applicationKey', 'confirm', 'reason']),
1424
+ }, ['text', 'confirm', 'reason']),
1405
1425
  },
1406
1426
  {
1407
1427
  name: 'securenow_alert_rule_draft_status',
@@ -1521,23 +1541,26 @@ const TOOLS = [
1521
1541
  {
1522
1542
  name: 'securenow_alert_rule_update',
1523
1543
  title: 'Update Alert Rule',
1524
- description: 'Update alert rule scope/status/schedule/throttle or record a review action. Write action; requires confirmation.',
1544
+ description: 'Update alert rule scope/status/severity/schedule/throttle, a custom rule\'s SQL (sqlQuery, checked like create: scoped, time-bounded, with an ip column) or instant conditions (instant: {matchMode, conditions, environments}; omitted fields such as the instant block stay as they are), or record a review action. reviewNote is kept as the version\'s reason. Write action; requires confirmation.',
1525
1545
  scope: 'alerts:write',
1526
1546
  readOnly: false,
1527
1547
  confirm: true,
1528
1548
  method: 'PUT',
1529
1549
  endpoint: '/alert-rules/:id',
1530
1550
  pathParams: ['id'],
1531
- bodyFields: ['name', 'description', 'status', 'applicationsAll', 'applications', 'schedule', 'throttle', 'alertChannelIds', 'reviewAction', 'reviewNote', 'reviewNotificationId'],
1551
+ bodyFields: ['name', 'description', 'severity', 'status', 'applicationsAll', 'applications', 'schedule', 'throttle', 'sqlQuery', 'instant', 'alertChannelIds', 'reviewAction', 'reviewNote', 'reviewNotificationId'],
1532
1552
  inputSchema: objectSchema({
1533
1553
  id: string('Alert rule id.'),
1534
1554
  name: string('Rule name for custom rules.'),
1535
1555
  description: string('Rule description for custom rules.'),
1556
+ severity: string('critical, high, medium or low (custom rules).'),
1536
1557
  status: string('Active, Disabled, or Paused.'),
1537
1558
  applicationsAll: boolean('Scope rule to all applications.'),
1538
1559
  applications: arrayOfStrings('Application keys when applicationsAll is false.'),
1539
1560
  schedule: { type: 'object', additionalProperties: true, description: 'Schedule patch.' },
1540
1561
  throttle: { type: 'object', additionalProperties: true, description: 'Throttle patch {enabled, minutes}: one alert per address every N minutes.' },
1562
+ sqlQuery: string('Custom periodic rules: the replacement detection SELECT (e.g. from a securenow_alert_rule_draft with ruleId).'),
1563
+ instant: { type: 'object', additionalProperties: true, description: 'Custom instant rules: {matchMode, conditions: [{field, operator, value}], environments}. Fields left out keep their current values.' },
1541
1564
  alertChannelIds: arrayOfStrings('Alert channel ids.'),
1542
1565
  reviewAction: string('Rule review action: keep_active (keep it running and stop auto-pausing it) or tuned_version (the rule was tuned; resume it).'),
1543
1566
  reviewNote: string('Review note.'),
@@ -1928,6 +1951,37 @@ const TOOLS = [
1928
1951
  since: string('Window start: 30m, 1h (default), 2d or an ISO date; at most 7 days.'),
1929
1952
  }, ['id', 'ip']),
1930
1953
  },
1954
+ {
1955
+ name: 'securenow_alert_rule_request_promotion',
1956
+ title: 'Ask a Person to Promote an Alert Rule',
1957
+ description: 'Ask a person to promote a test-mode rule to prod, with why it is ready and the evidence (runs, notifications it caught, false-positive checks). Agents create rules in test mode and cannot approve this themselves: a person approves on the rules page or with `securenow human approve-promotion`, which moves the rule to prod (its instant block, matching automations and the AI pipeline can then act on its alerts). One pending request per rule. Write action; requires confirmation.',
1958
+ scope: 'alerts:write',
1959
+ readOnly: false,
1960
+ confirm: true,
1961
+ method: 'POST',
1962
+ endpoint: '/alert-rules/:id/promotion-requests',
1963
+ pathParams: ['id'],
1964
+ bodyFields: ['reason', 'evidence'],
1965
+ inputSchema: objectSchema({
1966
+ id: string('Alert rule id (a custom rule in test mode).'),
1967
+ evidence: string('What shows it is ready: e.g. runs and their outcomes, notifications it raised on real attacks, how many false positives in how long.'),
1968
+ ...confirmSchema,
1969
+ }, ['id', 'confirm', 'reason']),
1970
+ },
1971
+ {
1972
+ name: 'securenow_alert_rule_promotion_requests',
1973
+ title: 'List Alert Rule Promotion Requests',
1974
+ description: 'Requests to promote test-mode rules to prod and their status (pending, approved, denied), who asked and why, and who decided.',
1975
+ scope: 'alerts:read',
1976
+ readOnly: true,
1977
+ method: 'GET',
1978
+ endpoint: '/alert-rules/promotion-requests',
1979
+ queryFields: ['status', 'limit'],
1980
+ inputSchema: objectSchema({
1981
+ status: string('pending (default), approved, denied or all.'),
1982
+ limit: number('How many (default 50, max 100).', { minimum: 1, maximum: 100 }),
1983
+ }),
1984
+ },
1931
1985
  {
1932
1986
  name: 'securenow_alert_rule_exclusions_list',
1933
1987
  title: 'List Alert Rule Exclusions',
@@ -2704,8 +2758,40 @@ function mcpToolDefinition(tool) {
2704
2758
  };
2705
2759
  }
2706
2760
 
2707
- function listTools() {
2708
- return TOOLS.map(mcpToolDefinition);
2761
+ // Tool profiles: a smaller tool list for an agent that does one job (fewer tools, less context).
2762
+ // Every profile keeps the auth and app tools. `securenow mcp --profile <name>` (or
2763
+ // SECURENOW_MCP_PROFILE) picks one; the default is every tool.
2764
+ const PROFILE_BASE_TOOLS = new Set(['securenow_auth_status', 'securenow_apps_list', 'securenow_apps_info']);
2765
+ const TOOL_PROFILES = {
2766
+ investigator: {
2767
+ description: 'Read-only investigation: notifications, traces, logs, IP intelligence, forensics questions and chat, rule run history and "why didn\'t this alert".',
2768
+ includes: (tool) => tool.readOnly || /^securenow_forensics_(query|chat|chat_confirm)$/.test(tool.name),
2769
+ },
2770
+ detection: {
2771
+ description: 'Write and tune detections: draft, create, test, run and explain alert rules, with the traces, logs, notifications and forensics to check them against.',
2772
+ includes: (tool) => /^securenow_alert_rules?_/.test(tool.name)
2773
+ || /^securenow_(traces|logs|forensics|ip)_/.test(tool.name)
2774
+ || /^securenow_notifications_(list|get|batch_get)$/.test(tool.name),
2775
+ },
2776
+ responder: {
2777
+ description: 'Respond to alerts: notifications, the human action queue and alert reviews, blocks, allowlist, trusted IPs, rate limits, challenges, automations, firewall and IP intelligence.',
2778
+ includes: (tool) => /^securenow_(notifications|human|alert_review|blocklist|allowlist|trusted|rate_limit|challenge|automation|ip|firewall)_/.test(tool.name)
2779
+ || ['securenow_alert_rule_get', 'securenow_alert_rule_runs', 'securenow_alert_rule_explain', 'securenow_traces_list', 'securenow_traces_show', 'securenow_logs_for_trace'].includes(tool.name),
2780
+ },
2781
+ };
2782
+
2783
+ function toolsForProfile(profile) {
2784
+ const name = String(profile || 'all').trim().toLowerCase();
2785
+ if (name === 'all') return TOOLS;
2786
+ const selected = TOOL_PROFILES[name];
2787
+ if (!selected) {
2788
+ throw new Error(`Unknown MCP profile "${profile}". Use one of: all, ${Object.keys(TOOL_PROFILES).join(', ')}`);
2789
+ }
2790
+ return TOOLS.filter((tool) => PROFILE_BASE_TOOLS.has(tool.name) || selected.includes(tool));
2791
+ }
2792
+
2793
+ function listTools(profile) {
2794
+ return toolsForProfile(profile).map(mcpToolDefinition);
2709
2795
  }
2710
2796
 
2711
2797
  function listResources() {
@@ -2945,7 +3031,29 @@ function summarizeRateLimit(r) {
2945
3031
  return { ...r, metadata: undefined, effectiveStatus: r.status === 'active' && expired ? 'expired' : r.status };
2946
3032
  }
2947
3033
 
3034
+ // One trace's spans repeat the same resource fields (service, environment, SDK...) on every span:
3035
+ // keep those once under `shared` and drop empty fields, so a trace costs far less context.
3036
+ function compactSpans(res) {
3037
+ if (!Array.isArray(res?.spans) || res.spans.length < 2) return res;
3038
+ const spans = res.spans;
3039
+ const isEmpty = (v) => v == null || v === '' || (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0)
3040
+ || (Array.isArray(v) && v.length === 0);
3041
+ const shared = {};
3042
+ for (const key of new Set(spans.flatMap((span) => Object.keys(span || {})))) {
3043
+ const first = JSON.stringify(spans[0]?.[key]);
3044
+ if (spans.every((span) => JSON.stringify(span?.[key]) === first)) shared[key] = spans[0]?.[key];
3045
+ }
3046
+ const keep = (span) => Object.fromEntries(Object.entries(span || {}).filter(([key, value]) => !(key in shared) && !isEmpty(value)));
3047
+ return {
3048
+ ...res,
3049
+ _compact: 'Fields identical on every span are listed once under shared; empty fields are left out.',
3050
+ shared: Object.fromEntries(Object.entries(shared).filter(([, value]) => !isEmpty(value))),
3051
+ spans: spans.map(keep),
3052
+ };
3053
+ }
3054
+
2948
3055
  const SUMMARIZERS = {
3056
+ securenow_traces_show: compactSpans,
2949
3057
  securenow_notifications_list: (res) => (Array.isArray(res?.notifications) ? { ...res, notifications: res.notifications.map(summarizeNotification) } : res),
2950
3058
  securenow_alert_rules_list: (res) => {
2951
3059
  if (!Array.isArray(res?.alertRules)) return res;
@@ -3000,6 +3108,8 @@ module.exports = {
3000
3108
  getTool,
3001
3109
  mcpToolDefinition,
3002
3110
  listTools,
3111
+ toolsForProfile,
3112
+ TOOL_PROFILES,
3003
3113
  listResources,
3004
3114
  listPrompts,
3005
3115
  promptMessages,
package/mcp/server.js CHANGED
@@ -9,6 +9,7 @@ const {
9
9
  RESOURCES,
10
10
  getTool,
11
11
  listTools,
12
+ toolsForProfile,
12
13
  listResources,
13
14
  listPrompts,
14
15
  promptMessages,
@@ -26,6 +27,17 @@ const {
26
27
  const SERVER_NAME = 'securenow';
27
28
  const PROTOCOL_VERSION = '2025-06-18';
28
29
 
30
+ // `securenow mcp --profile investigator|detection|responder` (or SECURENOW_MCP_PROFILE) serves a
31
+ // subset of the tools; the default is every tool.
32
+ const PROFILE = String(process.env.SECURENOW_MCP_PROFILE || 'all').trim().toLowerCase() || 'all';
33
+ let PROFILE_TOOL_NAMES;
34
+ try {
35
+ PROFILE_TOOL_NAMES = new Set(toolsForProfile(PROFILE).map((tool) => tool.name));
36
+ } catch (err) {
37
+ process.stderr.write(`[securenow mcp] ${err.message}\n`);
38
+ process.exit(2);
39
+ }
40
+
29
41
  function write(message) {
30
42
  process.stdout.write(`${JSON.stringify(message)}\n`);
31
43
  }
@@ -201,7 +213,7 @@ function readResource(uri) {
201
213
 
202
214
  const text = resourceContent(resource, {
203
215
  projectConfig: () => jsonText(localAuthStatus()),
204
- toolsCatalog: () => jsonText(listTools().map((tool) => ({
216
+ toolsCatalog: () => jsonText(listTools(PROFILE).map((tool) => ({
205
217
  name: tool.name,
206
218
  description: tool.description,
207
219
  readOnly: tool.annotations?.readOnlyHint === true,
@@ -240,7 +252,7 @@ async function handleRequest(message) {
240
252
  }
241
253
 
242
254
  if (method === 'ping') return ok(id, {});
243
- if (method === 'tools/list') return ok(id, { tools: listTools() });
255
+ if (method === 'tools/list') return ok(id, { tools: listTools(PROFILE) });
244
256
  if (method === 'resources/list') return ok(id, { resources: listResources() });
245
257
  if (method === 'prompts/list') return ok(id, { prompts: listPrompts() });
246
258
 
@@ -262,6 +274,9 @@ async function handleRequest(message) {
262
274
  const args = params.arguments || {};
263
275
  const tool = getTool(name);
264
276
  if (!tool) throw new Error(`Unknown tool: ${name}`);
277
+ if (!PROFILE_TOOL_NAMES.has(name)) {
278
+ throw new Error(`Tool ${name} is not in the "${PROFILE}" MCP profile. Restart the server without --profile (or with another profile) to use it.`);
279
+ }
265
280
 
266
281
  const result = await callApiTool(tool, args);
267
282
  return ok(id, asToolResult({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.17.0",
3
+ "version": "8.18.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",
@@ -11,7 +11,7 @@
11
11
  "securenow-mcp": "mcp/server.js"
12
12
  },
13
13
  "scripts": {
14
- "test": "node test-app-config.js && node test-resolve-ip.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-firewall-paths.js && node test-rate-limits.js && node test-rate-limit-redis.js && node test-log-format.js && node test-crash-capture.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-package-contents.js"
14
+ "test": "node test-app-config.js && node test-resolve-ip.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-firewall-paths.js && node test-rate-limits.js && node test-rate-limit-redis.js && node test-log-format.js && node test-crash-capture.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-esm-detect.js && node test-package-contents.js"
15
15
  },
16
16
  "keywords": [
17
17
  "opentelemetry",
@@ -160,6 +160,7 @@
160
160
  "challenge.js",
161
161
  "otel-defaults.js",
162
162
  "internal-thread.js",
163
+ "esm-detect.js",
163
164
  "legacy-semconv.js",
164
165
  "otp-redaction.js",
165
166
  "header-redaction.js",
package/register.js CHANGED
@@ -14,13 +14,9 @@ if (require('./sdk-disabled').sdkDisabled('register')) return;
14
14
  // 1. Auto-register the ESM loader hook so customers never need --import
15
15
  (() => {
16
16
  try {
17
- const fs = require('fs');
18
- const path = require('path');
19
- const pkgPath = path.resolve(process.cwd(), 'package.json');
20
- if (!fs.existsSync(pkgPath)) return;
21
-
22
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8').replace(/^\uFEFF/, ''));
23
- if (pkg.type !== 'module') return;
17
+ // ESM is decided by the entry script's package.json, not only the working directory's
18
+ // (process managers often start apps from another directory). See esm-detect.js.
19
+ if (!require('./esm-detect').detectEsmApp().esm) return;
24
20
 
25
21
  // Already registered via --import?
26
22
  const execArgv = process.execArgv.join(' ');
package/tracing.js CHANGED
@@ -338,19 +338,13 @@ function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFiel
338
338
  // (e.g. Node 18, or require('securenow/tracing') called directly without register.js).
339
339
  (() => {
340
340
  try {
341
- const fs = require('fs');
342
- const path = require('path');
343
- const pkgPath = path.resolve(process.cwd(), 'package.json');
344
- if (fs.existsSync(pkgPath)) {
345
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
346
- if (pkg.type === 'module') {
347
- const execArgv = process.execArgv.join(' ');
348
- const hasCliHook = execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle');
349
- const hasModuleRegister = typeof require('node:module').register === 'function';
350
- if (!hasCliHook && !hasModuleRegister) {
351
- console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
352
- console.warn('[securenow] Upgrade to Node >=20.6 (recommended) or add: --import @opentelemetry/instrumentation/hook.mjs');
353
- }
341
+ if (require('./esm-detect').detectEsmApp().esm) {
342
+ const execArgv = process.execArgv.join(' ');
343
+ const hasCliHook = execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle');
344
+ const hasModuleRegister = typeof require('node:module').register === 'function';
345
+ if (!hasCliHook && !hasModuleRegister) {
346
+ console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
347
+ console.warn('[securenow] Upgrade to Node >=20.6 (recommended) or add: --import @opentelemetry/instrumentation/hook.mjs');
354
348
  }
355
349
  }
356
350
  } catch (_) {}