quoxcli 0.5.0 → 0.6.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/lib/api/endpoints/governance.js +7 -0
  3. package/lib/api/endpoints/inboxq.js +39 -0
  4. package/lib/api/endpoints/index.js +2 -0
  5. package/lib/api/endpoints/lucid.js +17 -0
  6. package/lib/api/endpoints/mcp.js +35 -0
  7. package/lib/api/endpoints/notesq.js +2 -0
  8. package/lib/api/endpoints/quoxplan.js +51 -0
  9. package/lib/commands/actions.js +51 -13
  10. package/lib/commands/contacts.js +2 -0
  11. package/lib/commands/lucid.js +6 -0
  12. package/lib/commands/lucidGoals.js +389 -0
  13. package/lib/commands/mail.js +266 -5
  14. package/lib/commands/mcp.js +190 -0
  15. package/lib/commands/mon.js +14 -0
  16. package/lib/commands/monitoring.js +68 -0
  17. package/lib/commands/notesSeal.js +380 -22
  18. package/lib/commands/offload.js +51 -10
  19. package/lib/commands/plan.js +471 -53
  20. package/lib/commands/volt.js +6 -1
  21. package/lib/commands/wazuh.js +65 -9
  22. package/lib/commands/wazuhIntake.js +46 -5
  23. package/lib/core/commandRegistrars.js +1 -0
  24. package/lib/core/config.js +14 -1
  25. package/lib/core/offload.js +83 -0
  26. package/lib/core/output.js +29 -4
  27. package/lib/core/selfUpdate.js +2 -0
  28. package/lib/core/version.js +1 -1
  29. package/lib/quoxon/api.js +4 -0
  30. package/lib/quoxon/app.js +257 -48
  31. package/lib/quoxon/approvalsView.js +3 -1
  32. package/lib/quoxon/ask.js +4 -2
  33. package/lib/quoxon/bang.js +7 -1
  34. package/lib/quoxon/commandShort.js +1 -1
  35. package/lib/quoxon/commands.js +4 -4
  36. package/lib/quoxon/delegationDragon.js +49 -0
  37. package/lib/quoxon/dnd.js +47 -0
  38. package/lib/quoxon/events.js +12 -1
  39. package/lib/quoxon/extensions.js +2 -0
  40. package/lib/quoxon/foreignText.js +49 -0
  41. package/lib/quoxon/inboxView.js +5 -5
  42. package/lib/quoxon/launcher.js +2 -1
  43. package/lib/quoxon/loopCard.js +2 -1
  44. package/lib/quoxon/main.js +8 -2
  45. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  46. package/lib/quoxon/onboarding/view.js +1 -1
  47. package/lib/quoxon/render.js +5 -2
  48. package/lib/quoxon/resend.js +14 -0
  49. package/lib/quoxon/rows.js +3 -1
  50. package/lib/quoxon/secretShape.js +2 -0
  51. package/lib/quoxon/slash/broadcast.js +30 -6
  52. package/lib/quoxon/slash/broadcastRun.js +12 -6
  53. package/lib/quoxon/slash/cliLeaves.json +35 -0
  54. package/lib/quoxon/slash/dashboard.js +2 -0
  55. package/lib/quoxon/slash/listView.js +2 -1
  56. package/lib/quoxon/slash/palette.js +41 -0
  57. package/lib/quoxon/slash/paletteController.js +6 -2
  58. package/lib/quoxon/slash/restart.js +2 -0
  59. package/lib/quoxon/slotChat.js +7 -3
  60. package/lib/quoxon/slotRun.js +2 -0
  61. package/lib/quoxon/webPty.js +15 -0
  62. package/lib/quoxon/whatsnew.jsonl +6 -0
  63. package/lib/render/planBrief.js +614 -0
  64. package/lib/toolOutputShape.js +6 -3
  65. package/package.json +1 -1
@@ -511,7 +511,12 @@ export function registerVoltCommands(program, ctx = {}) {
511
511
  console.log(` Agents: ${(m.agents_involved || []).join(', ') || 'none'}`);
512
512
  console.log(` Policies: ${(m.policy_ids_evaluated || []).length} evaluated`);
513
513
  console.log(` HITL: ${m.hitl_decisions || 0} decisions`);
514
- console.log(` Signed: ${m.signed ? 'yes' : 'no'}`);
514
+ // Signatures live beside the manifest (fetched bundles: `signatures[]`;
515
+ // stored rows: `signature`); collector manifests never set m.signed.
516
+ const sigCount = Array.isArray(parsed.signatures) && parsed.signatures.length > 0
517
+ ? parsed.signatures.length
518
+ : (parsed.signature ? 1 : (m.signed ? 1 : 0));
519
+ console.log(` Signed: ${sigCount > 0 ? `yes (${sigCount} signature${sigCount === 1 ? '' : 's'}, not checked here; run quox volt verify)` : 'no'}`);
515
520
  console.log(` Redacted: ${m.redactions_present ? 'yes' : 'no'}`);
516
521
  console.log(` Created: ${m.created_ts || 'N/A'}`);
517
522
  console.log(` Started: ${m.started_at || 'N/A'}`);
@@ -28,6 +28,7 @@
28
28
  * the verb, endpoint and status. It is never rendered as "0 agents". `status` exits 3 when the manager is unhealthy.
29
29
  */
30
30
 
31
+ import fs from 'fs';
31
32
  import { resolveEndpoint } from '../api/endpoints.js';
32
33
  import { line as glyphLine } from '../core/quoxglyph.js';
33
34
  import { resolveOrgId } from './helpers.js';
@@ -209,8 +210,10 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
209
210
  const a = await allAgents('status');
210
211
  const counts = countStates(a.items);
211
212
  if (a.total > a.items.length) counts.total = a.total;
213
+ // WAZ-11: agent 000 is the manager's own agent; coverage leaves it out, so status says when its count includes it.
214
+ const includesManager = a.items.some((x) => String(x && x.id) === '000');
212
215
  if (jsonOut()) {
213
- console.log(JSON.stringify({ healthy, daemons, stopped_core: brokenCore, agents: counts }, null, 2));
216
+ console.log(JSON.stringify({ healthy, daemons, stopped_core: brokenCore, agents: { ...counts, includes_manager: includesManager, excluding_manager: includesManager ? counts.total - 1 : counts.total } }, null, 2));
214
217
  } else {
215
218
  head('status', `manager ${healthy ? 'healthy' : 'unhealthy'} · ${plural(counts.total, 'agent')}`, healthy ? 'ok' : 'fail');
216
219
  console.log(`Manager ${healthy ? 'healthy' : 'unhealthy'}: ${running.length} running${stopped.length ? `, ${stopped.length} stopped` : ''}`);
@@ -218,6 +221,7 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
218
221
  console.log(pad(stopped.map((n) => [` ${n}`, OPTIONAL_DAEMONS.has(n) ? `${daemons[n]} (optional)` : String(daemons[n])])).join('\n'));
219
222
  }
220
223
  console.log(`Agents: ${countText(counts)}${a.total > a.items.length ? ` (counted the first ${a.items.length} of ${a.total})` : ''}`);
224
+ if (includesManager) console.log(`${plural(counts.total, 'agent')} (incl. the manager, agent 000); ${plural(counts.total - 1, 'agent')} excluding it, as quox wazuh coverage counts.`);
221
225
  if (!healthy) console.log(`Not running: ${brokenCore.join(', ')}. Check the manager host's service logs.`);
222
226
  }
223
227
  if (!healthy) process.exitCode = 3;
@@ -432,11 +436,13 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
432
436
  const { ep, data } = await getRoute('vulns', 'mon.wazuh.vulnerabilities.list', query);
433
437
  if (!data || data.success === false || !Array.isArray(data.data)) throw new WazFail(`quox wazuh vulns: ${ep.method} ${ep.path} returned an error body: ${(data && (data.message || data.error)) || 'no vulnerabilities array'}`);
434
438
  const list = data.data;
435
- if (jsonOut()) { console.log(JSON.stringify({ vulnerabilities: list, total: data.total ?? list.length }, null, 2)); return; }
439
+ if (jsonOut()) { console.log(JSON.stringify({ vulnerabilities: list, total: data.total ?? list.length, total_is_lower_bound: data.total_is_lower_bound === true }, null, 2)); return; }
436
440
  const bySev = {};
437
441
  for (const v of list) { const s = (v.vulnerability && v.vulnerability.severity) || 'Unknown'; bySev[s] = (bySev[s] || 0) + 1; }
438
442
  const sevText = ['Critical', 'High', 'Medium', 'Low', 'Unknown'].filter((s) => bySev[s]).map((s) => `${bySev[s]} ${s}`).join(' · ');
439
- head('vulns', `${list.length === 0 ? 'none' : sevText}${(data.total ?? 0) > list.length ? ` (showing ${list.length} of ${data.total})` : ''}`, bySev.Critical ? 'warn' : 'ok');
443
+ // WAZ-11: an indexer-capped total is a lower bound ("10,000+"), never shown as an exact count.
444
+ const totalText = data.total_is_lower_bound === true ? `${Number(data.total).toLocaleString('en-GB')}+` : data.total;
445
+ head('vulns', `${list.length === 0 ? 'none' : sevText}${(data.total ?? 0) > list.length ? ` (showing ${list.length} of ${totalText})` : ''}`, bySev.Critical ? 'warn' : 'ok');
440
446
  if (!list.length) { console.log('No vulnerabilities.'); return; }
441
447
  const rows = [['SEVERITY', 'SCORE', 'CVE', 'AGENT', 'PACKAGE']];
442
448
  for (const v of list) rows.push([(v.vulnerability && v.vulnerability.severity) || '-', (v.vulnerability && v.vulnerability.score && v.vulnerability.score.base) ?? '-', (v.vulnerability && v.vulnerability.id) || '-', (v.agent && v.agent.name) || '-', v.package ? `${v.package.name || ''} ${v.package.version || ''}`.trim() : '-']);
@@ -728,15 +734,44 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
728
734
  // ---------------------------------------------------------------------------------------------------------------
729
735
 
730
736
  async function hiddenPrompt(message) {
731
- if (!process.stdin.isTTY) throw new WazFail(`quox wazuh connect: ${message} needs a terminal (the password is typed hidden, never passed as a flag or piped)`);
737
+ if (!process.stdin.isTTY) throw new WazFail(`quox wazuh connect: ${message} needs a terminal (the password is typed hidden, never passed as a flag); without a terminal pipe it in with --password-stdin / --indexer-password-stdin`);
732
738
  const { default: inquirer } = await import('inquirer');
733
739
  const ans = await inquirer.prompt([{ type: 'password', name: 'v', message, mask: '*', validate: (v) => (v && v.length > 0) || 'required' }]);
734
740
  return ans.v;
735
741
  }
736
742
 
743
+ /** All of stdin as text (quox#1588 --password-stdin). Never echoed. */
744
+ async function readAllStdin() {
745
+ const chunks = [];
746
+ for await (const chunk of process.stdin) chunks.push(chunk);
747
+ return Buffer.concat(chunks).toString('utf8');
748
+ }
749
+
750
+ const CA_MAX_BYTES = 64 * 1024;
751
+ /**
752
+ * quox#1588: read the indexer CA for --indexer-ca. A public certificate, but the file is still never printed: errors name
753
+ * the path only. Must hold at least one PEM CERTIFICATE block and NO private key (a bundle with a key is refused, so a key
754
+ * never lands in the vault entry by mistake).
755
+ */
756
+ function readIndexerCa(file) {
757
+ let text;
758
+ try {
759
+ const st = fs.statSync(file);
760
+ if (!st.isFile()) throw new Error('not a file');
761
+ if (st.size > CA_MAX_BYTES) throw new Error(`larger than ${CA_MAX_BYTES} bytes`);
762
+ text = fs.readFileSync(file, 'utf8');
763
+ } catch (err) {
764
+ throw new WazFail(`quox wazuh connect: --indexer-ca ${file} could not be read (${err && err.code ? err.code : (err && err.message) || 'error'})`);
765
+ }
766
+ if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(text)) throw new WazFail(`quox wazuh connect: --indexer-ca ${file} contains a private key; pass the CA certificate only (nothing was saved)`);
767
+ if (!/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/.test(text)) throw new WazFail(`quox wazuh connect: --indexer-ca ${file} is not a PEM certificate (expected -----BEGIN CERTIFICATE-----)`);
768
+ return text;
769
+ }
770
+
737
771
  export function attachWazuhConnect(wazuh, program, ctx) {
738
772
  const { client, auth } = ctx;
739
773
  const promptSecret = ctx.promptSecret || hiddenPrompt;
774
+ const readStdin = ctx.readStdin || readAllStdin;
740
775
  async function importInstanceEnv() {
741
776
  const ep = resolveEndpoint('mon.wazuh.credential.import-instance-env');
742
777
  let res;
@@ -765,19 +800,22 @@ export function attachWazuhConnect(wazuh, program, ctx) {
765
800
  }
766
801
 
767
802
  wazuh.command('connect')
768
- .description('Connect this organisation to its Wazuh: stores the API (and optional indexer) credential in the vault, then runs status. Admin only. Passwords are prompted, never flags.')
803
+ .description('Connect this organisation to its Wazuh: stores the API (and optional indexer) credential in the vault, then runs status. Admin only. Passwords are prompted (or piped with --password-stdin), never flags.')
769
804
  .option('--from-instance-env', 'import the collector\'s own WAZUH_* environment into this org\'s vault (instance superadmin who is also an org admin; no prompts, no other flags)')
770
805
  .option('--url <url>', 'Wazuh manager API URL, e.g. https://wazuh-host:55000')
771
806
  .option('--user <user>', 'Wazuh API username, e.g. wazuh-wui')
772
807
  .option('--indexer-url <url>', 'Wazuh indexer URL, e.g. https://wazuh-host:9200')
773
808
  .option('--indexer-user <user>', 'Wazuh indexer username (a password is then prompted)')
774
809
  .option('--transport <mode>', 'indexer transport: auto|direct|host', 'auto')
775
- .addHelpText('after', '\nExample: quox wazuh connect --url https://wazuh.example:55000 --user wazuh-wui --indexer-user admin --transport host')
810
+ .option('--indexer-ca <file>', 'PEM CA certificate file for a self-signed indexer; stored as indexer_ca in the vault entry')
811
+ .option('--password-stdin', 'read the Wazuh API password from stdin (line 1) instead of the hidden prompt; never argv, never echoed')
812
+ .option('--indexer-password-stdin', 'read the indexer password from stdin (the next line after the API password, if both) instead of the prompt; needs --indexer-user')
813
+ .addHelpText('after', '\nExample: quox wazuh connect --url https://wazuh.example:55000 --user wazuh-wui --indexer-user admin --transport host\nNo terminal: printf \'%s\\n%s\\n\' "$API_PW" "$IDX_PW" | quox wazuh connect --url https://wazuh.example:55000 --user wazuh-wui --indexer-user admin --indexer-ca root-ca.pem --password-stdin --indexer-password-stdin')
776
814
  .action(async (options) => {
777
815
  try {
778
816
  auth.requireAuth();
779
817
  if (options.fromInstanceEnv) {
780
- if (options.url || options.user || options.indexerUrl || options.indexerUser) throw new WazFail('quox wazuh connect: --from-instance-env cannot be combined with --url, --user, --indexer-url or --indexer-user');
818
+ if (options.url || options.user || options.indexerUrl || options.indexerUser || options.indexerCa || options.passwordStdin || options.indexerPasswordStdin) throw new WazFail('quox wazuh connect: --from-instance-env cannot be combined with --url, --user, --indexer-url, --indexer-user, --indexer-ca, --password-stdin or --indexer-password-stdin');
781
819
  await importInstanceEnv();
782
820
  return;
783
821
  }
@@ -786,6 +824,23 @@ export function attachWazuhConnect(wazuh, program, ctx) {
786
824
  if (!/^https:\/\/[^\s/]+/.test(options.url)) throw new WazFail('quox wazuh connect: --url must be an https:// URL (the credential is sent to it)');
787
825
  if (options.indexerUrl && !/^https:\/\/[^\s/]+/.test(options.indexerUrl)) throw new WazFail('quox wazuh connect: --indexer-url must be an https:// URL');
788
826
  if (!['auto', 'direct', 'host'].includes(options.transport)) throw new WazFail('quox wazuh connect: --transport must be auto, direct or host');
827
+ if (options.indexerPasswordStdin && !options.indexerUser) throw new WazFail('quox wazuh connect: --indexer-password-stdin needs --indexer-user <user>');
828
+ // quox#1588: the CA file is read and checked BEFORE anything is prompted or saved.
829
+ const indexerCa = options.indexerCa ? readIndexerCa(String(options.indexerCa)) : null;
830
+ // quox#1588: stdin is read once; line 1 = API password (--password-stdin), the next = indexer password.
831
+ let stdinLines = null;
832
+ if (options.passwordStdin || options.indexerPasswordStdin) {
833
+ const text = String((await readStdin()) || '');
834
+ stdinLines = text.split(/\r?\n/).map((l) => l.trim());
835
+ if (!stdinLines.some(Boolean)) throw new WazFail('quox wazuh connect: --password-stdin / --indexer-password-stdin given but stdin was empty');
836
+ }
837
+ const fromStdin = (what) => {
838
+ const v = stdinLines.shift();
839
+ if (!v) throw new WazFail(`quox wazuh connect: stdin had no ${what} line (send the API password on line 1 and the indexer password on line 2 when both stdin flags are set)`);
840
+ return v;
841
+ };
842
+ const apiPassword = options.passwordStdin ? fromStdin('API password') : null;
843
+ const indexerPassword = options.indexerPasswordStdin ? fromStdin('indexer password') : null;
789
844
  const orgId = resolveOrgId(auth, program);
790
845
  const listEp = resolveEndpoint('vault.list', { orgId });
791
846
  let existing;
@@ -795,12 +850,13 @@ export function attachWazuhConnect(wazuh, program, ctx) {
795
850
  } catch (err) { throw failFrom('connect', listEp.method, listEp.path, err); }
796
851
 
797
852
  const credentials = { base_url: options.url, username: options.user };
798
- credentials.password = await promptSecret(`Wazuh API password for ${options.user}`);
853
+ credentials.password = apiPassword || await promptSecret(`Wazuh API password for ${options.user}`);
799
854
  if (options.indexerUser) {
800
855
  credentials.indexer_username = options.indexerUser;
801
- credentials.indexer_password = await promptSecret(`Wazuh indexer password for ${options.indexerUser}`);
856
+ credentials.indexer_password = indexerPassword || await promptSecret(`Wazuh indexer password for ${options.indexerUser}`);
802
857
  }
803
858
  if (options.indexerUrl) credentials.indexer_url = options.indexerUrl;
859
+ if (indexerCa) credentials.indexer_ca = indexerCa;
804
860
  credentials.indexer_transport = options.transport;
805
861
 
806
862
  const verb = existing ? 'update' : 'create';
@@ -25,11 +25,19 @@ import { relDuration } from './kuma.js';
25
25
 
26
26
  const RUNBOOK = 'docs/wazuh/custom-quox-integration.md';
27
27
 
28
+ // WAZ-11: ARGUS's routing proposal from the verdict: what it proposes and the command a person runs to accept it.
29
+ function proposalCell(p) {
30
+ if (!p || typeof p !== 'object' || !p.proposed_action) return 'none';
31
+ return `${p.proposed_action}${p.verdict ? ` (ARGUS verdict ${p.verdict})` : ''}${p.command ? `: ${p.command}` : ''}`;
32
+ }
33
+
28
34
  // WAZ P7: compact context flags. A source that could not answer shows as SRC?, never as "nothing".
35
+ // WAZ-11: a source the org never set up (context.not_configured, e.g. no Uptime Kuma) is left out, not SRC? on every row.
29
36
  const UNKNOWN_FLAG = { maintenance: 'MAINT?', kuma: 'KUMA?', deploys: 'DEPLOY?' };
30
37
  export function contextFlags(ctx) {
31
38
  if (!ctx || typeof ctx !== 'object') return '-';
32
- const parts = [...(ctx.flags || []), ...(ctx.unavailable || []).map((s) => UNKNOWN_FLAG[s] || `${String(s).toUpperCase()}?`)];
39
+ const skip = new Set(Array.isArray(ctx.not_configured) ? ctx.not_configured : []);
40
+ const parts = [...(ctx.flags || []), ...(ctx.unavailable || []).filter((s) => !skip.has(s)).map((s) => UNKNOWN_FLAG[s] || `${String(s).toUpperCase()}?`)];
33
41
  return parts.length ? parts.join(' ') : '-';
34
42
  }
35
43
 
@@ -210,6 +218,10 @@ export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFa
210
218
  }
211
219
 
212
220
  // ---- incidents ----
221
+ // WAZ P11: the governance incident a level >= notify-level Wazuh incident opened (shared lifecycle: status, assignee, tickets).
222
+ const governanceCell = (i) => (i.governance_incident_id
223
+ ? `${i.governance_incident_id} (${i.governance_incident && i.governance_incident.status ? i.governance_incident.status : 'status unavailable'})`
224
+ : '-');
213
225
  const ago = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? relDuration(Date.now() - t).replace(/ /g, '') : (iso || '-'); };
214
226
 
215
227
  const incidents = example(wazuh.command('incidents')
@@ -223,16 +235,41 @@ export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFa
223
235
  if (bad(data) || !Array.isArray(data.incidents)) throw errBody('incidents', ep, data, 'response had no incidents array');
224
236
  if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
225
237
  const openN = data.incidents.filter((i) => i.open).length;
226
- head('incidents', `${data.incidents.length} shown · ${openN} open`, openN ? 'warn' : 'ok');
227
- if (!data.incidents.length) return;
238
+ // WAZ-11: governance incidents still open/investigating after their Wazuh side went quiet (additive; the same
239
+ // section ARGUS's wazuh_incidents returns, so the CLI and chat count the same).
240
+ const q = data.quiet_still_open && typeof data.quiet_still_open === 'object' ? data.quiet_still_open : null;
241
+ const qRows = q && Array.isArray(q.incidents) ? q.incidents : [];
242
+ const qUnknown = !!(q && q.count === null);
243
+ const qN = qRows.length;
244
+ const qHead = qUnknown ? ' · gov-open: unknown' : (qN ? ` · ${qN} gov-open (quiet)` : '');
245
+ head('incidents', `${data.incidents.length} shown · ${openN} open${qHead}`, openN || qN || qUnknown ? 'warn' : 'ok');
246
+ const printQuiet = () => {
247
+ if (qUnknown) { console.log(`\nstill open in governance: unknown (${q.error || 'the collector could not read it'})`); return; }
248
+ if (!qN) return;
249
+ const qr = [['GOVERNANCE', 'STATUS', 'SEVERITY', 'ASSIGNED', 'WAZUH', 'CLOSED AS', 'CLOSED', 'AGENT', 'RULE']];
250
+ for (const g of qRows) {
251
+ qr.push([g.id, g.status, g.severity, g.assigned_to || '-', g.wazuh_incident_id || '-', g.wazuh_close_reason || '-', ago(g.wazuh_closed_at),
252
+ g.agent_name || g.agent_id || '-', g.rule_id || '-']);
253
+ }
254
+ console.log(`\n${q.label || 'Wazuh went quiet; still open in governance'} (${qN}):`);
255
+ console.log(pad(qr).join('\n'));
256
+ console.log('Resolve or claim: quox monitoring incidents claim <id> | quox monitoring incidents status <id> resolved --resolution "<what was done>"');
257
+ };
258
+ if (!data.incidents.length) { printQuiet(); return; }
228
259
  // WAZ P7: CONTEXT (MAINT / KUMA-DOWN / DEPLOYED, SRC? when a source could not answer) when the collector sent it.
229
260
  const withCtx = data.incidents.some((i) => i.context);
230
- const rows = [['ID', 'STATE', 'LEVEL', 'AGENT', 'RULE', 'DESCRIPTION', 'COUNT', 'LAST SEEN', 'MITRE', ...(withCtx ? ['CONTEXT'] : [])]];
261
+ // WAZ P11: GOVERNANCE (the linked governance incident id + its status) when any incident is linked.
262
+ const withGov = data.incidents.some((i) => i.governance_incident_id);
263
+ // WAZ P11 1c: VERDICT (ARGUS's rule-based first pass: noise / fix_needed / security_event) when any incident has one.
264
+ const withVerdict = data.incidents.some((i) => i.triage && i.triage.verdict);
265
+ const rows = [['ID', 'STATE', 'LEVEL', 'AGENT', 'RULE', 'DESCRIPTION', 'COUNT', 'LAST SEEN', 'MITRE', ...(withCtx ? ['CONTEXT'] : []), ...(withGov ? ['GOVERNANCE'] : []), ...(withVerdict ? ['VERDICT'] : [])]];
231
266
  for (const i of data.incidents) {
232
267
  rows.push([i.id, i.open ? 'open' : `closed${i.close_reason === 'quiet' ? ' (quiet)' : ''}`, i.level, i.agent_name || i.agent_id, i.rule_id,
233
- String(i.rule_description || '-').slice(0, 60), i.count ?? '-', ago(i.last_seen), (i.mitre_ids || []).join(',') || '-', ...(withCtx ? [contextFlags(i.context)] : [])]);
268
+ String(i.rule_description || '-').slice(0, 60), i.count ?? '-', ago(i.last_seen), (i.mitre_ids || []).join(',') || '-', ...(withCtx ? [contextFlags(i.context)] : []),
269
+ ...(withGov ? [governanceCell(i)] : []), ...(withVerdict ? [(i.triage && i.triage.verdict) || '-'] : [])]);
234
270
  }
235
271
  console.log(pad(rows).join('\n'));
272
+ printQuiet();
236
273
  })), 'quox wazuh incidents --open --since 24h');
237
274
 
238
275
  example(incidents.command('show')
@@ -256,6 +293,10 @@ export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFa
256
293
  ? `no: suppressed, host was in a Kuma maintenance window (${(i.suppressed.monitors || []).join(', ') || 'monitor'})`
257
294
  : 'no (digest only)')],
258
295
  ['Log', i.log_snippet || '-'],
296
+ ['Governance', governanceCell(i)],
297
+ ['Verdict', i.triage && i.triage.verdict ? `${i.triage.verdict} (ARGUS rule-based first pass, no model)` : 'not triaged (set when a governance incident is opened)'],
298
+ ...(i.triage && Array.isArray(i.triage.reasons) && i.triage.reasons.length ? [['Why', i.triage.reasons.join('; ')]] : []),
299
+ ['Proposal', proposalCell(i.proposal)],
259
300
  ...contextRows(i.context),
260
301
  ];
261
302
  const dg = i.diagnosis;
@@ -56,6 +56,7 @@ export const COMMAND_REGISTRARS = [
56
56
  ['../commands/file.js', 'registerFileCommands'],
57
57
  ['../commands/evidence.js', 'registerEvidenceCommands'],
58
58
  ['../commands/admin.js', 'registerAdminCommands'],
59
+ ['../commands/mcp.js', 'registerMcpCommands'],
59
60
  ['../commands/aee.js', 'registerAeeCommands'],
60
61
  ['../commands/integration.js', 'registerIntegrationCommands'],
61
62
  ['../commands/consent.js', 'registerConsentCommands'],
@@ -41,6 +41,15 @@ function acquireLock(lockFile) {
41
41
  function releaseLock(lockFile) { try { unlinkSync(lockFile); } catch { /* already gone */ } }
42
42
  const J = (v) => JSON.stringify(v === undefined ? null : v);
43
43
 
44
+ function envTokenExpiryMs(token) {
45
+ try {
46
+ const payload = JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8'));
47
+ return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
44
53
  const DEFAULT_CONFIG = {
45
54
  version: 1,
46
55
  activeProfile: 'local',
@@ -417,7 +426,11 @@ export function loadConfig(profileOverride) {
417
426
  getAuth() {
418
427
  // Env var override
419
428
  if (process.env.QUOX_TOKEN) {
420
- return { access_token: process.env.QUOX_TOKEN };
429
+ // The JWT's own exp is the env token's expiry. Without it auth.isExpired() read every env
430
+ // token as expired and tried a refresh there is no refresh token for (the dashboard /cli
431
+ // browser terminal then opened the conversation app signed out). Not verification: the server verifies.
432
+ const exp = envTokenExpiryMs(process.env.QUOX_TOKEN);
433
+ return exp ? { access_token: process.env.QUOX_TOKEN, expires_at: exp } : { access_token: process.env.QUOX_TOKEN };
421
434
  }
422
435
  return this.getActiveProfile()?.auth || null;
423
436
  },
@@ -501,6 +501,89 @@ export async function workerStatus(deps) {
501
501
  };
502
502
  }
503
503
 
504
+ // 2026-11-07: three worker leases expired overnight with no warning and the nightly suite died on the human-lease
505
+ // error. Every offload command now warns inside this window, and status/leases list every configured worker.
506
+ export const LEASE_WARN_H = 12;
507
+ const STATE_FILE_RE = /^offload(?:-([a-z0-9][a-z0-9-]{0,31}))?\.json$/;
508
+
509
+ /** Configured workers from the state files: '' is the shared default, then named ones sorted. */
510
+ export function listWorkerNames(deps) {
511
+ let files = [];
512
+ try { files = deps.fs.readdirSync(configDir(deps.home)); } catch { return []; }
513
+ const names = [];
514
+ for (const f of files) {
515
+ const m = STATE_FILE_RE.exec(f);
516
+ if (m) names.push(m[1] || '');
517
+ }
518
+ return names.sort((a, b) => (a === '' ? -1 : b === '' ? 1 : a.localeCompare(b)));
519
+ }
520
+
521
+ /** Lease state for an expiry timestamp: EXPIRED, EXPIRING (within LEASE_WARN_H), OK, or UNKNOWN. */
522
+ export function leaseInfo(expiresAt, now) {
523
+ const t = expiresAt ? Date.parse(expiresAt) : NaN;
524
+ if (!Number.isFinite(t)) return { lease: 'UNKNOWN', hours_left: null };
525
+ const hours = Math.round(((t - now) / 3600000) * 10) / 10;
526
+ if (t <= now) return { lease: 'EXPIRED', hours_left: hours };
527
+ return { lease: hours <= LEASE_WARN_H ? 'EXPIRING' : 'OK', hours_left: hours };
528
+ }
529
+
530
+ /** What a HUMAN runs to renew: there is no lease-extend verb, so lease a fresh worker (the old one is released after). */
531
+ export function renewCommand(name) {
532
+ return `quox -p <human-profile> offload --fresh${name ? ` --worker ${name}` : ''} -- true`;
533
+ }
534
+
535
+ const fmtLeft = (h) => (Math.abs(h) < 1 ? `${Math.round(Math.abs(h) * 60)}m` : `${Math.round(Math.abs(h))}h`);
536
+ export function describeLease(l) {
537
+ if (l.lease === 'EXPIRED') return l.hours_left == null ? 'EXPIRED (the pool no longer holds it)' : `EXPIRED ${fmtLeft(l.hours_left)} ago`;
538
+ if (l.lease === 'EXPIRING' || l.lease === 'OK') return `${l.lease}, expires in ${fmtLeft(l.hours_left)}`;
539
+ return l.lease;
540
+ }
541
+
542
+ function leaseEntry(name, st, live, now) {
543
+ const base = { worker: name || 'default', vmid: null, ip: null, lessee: null, pool_state: null, lease_expires_at: null, lease: 'NONE', hours_left: null, renew: null };
544
+ if (!st.worker) return base;
545
+ const expires = live?.lease_expires_at || st.worker.expires || null;
546
+ let info = leaseInfo(expires, now);
547
+ const pool = live === undefined ? 'unknown' : live ? live.state : 'missing';
548
+ // The pool is the truth when it answered: a VM it no longer holds (or holds for someone else) is gone.
549
+ if (live === null || (live && (live.state !== 'leased' || (st.worker.lessee && live.lessee && live.lessee !== st.worker.lessee)))) {
550
+ info = { lease: 'EXPIRED', hours_left: info.lease === 'EXPIRED' ? info.hours_left : null };
551
+ }
552
+ return {
553
+ ...base, vmid: st.worker.vmid, ip: live?.ip || st.worker.ip || null, lessee: st.worker.lessee || null,
554
+ pool_state: pool, lease_expires_at: expires, ...info,
555
+ renew: info.lease === 'EXPIRED' || info.lease === 'EXPIRING' ? renewCommand(name) : null,
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Every configured worker's lease. `live: true` asks the pool once (a failure is reported, the cached expiry is
561
+ * used); `live: false` reads only the local state files (the pre-run warning must not add a pool call).
562
+ * Read-only: never leases, renews or releases.
563
+ */
564
+ export async function allLeases(deps, { live = true } = {}) {
565
+ const now = deps.now();
566
+ let pool = null; let poolErr = null;
567
+ if (live) {
568
+ try { pool = await poolStatus(deps.rest); } catch (e) { poolErr = String(e?.message || e); }
569
+ }
570
+ const leases = listWorkerNames(deps).map((name) => {
571
+ const st = loadState({ ...deps, worker: name || undefined });
572
+ const v = pool && st.worker ? vmOf(pool, st.worker.vmid) : undefined;
573
+ return leaseEntry(name, st, v, now);
574
+ });
575
+ return {
576
+ checked_at: new Date(now).toISOString(), warn_within_h: LEASE_WARN_H, source: pool ? 'pool' : 'cache',
577
+ ...(poolErr ? { pool_error: poolErr } : {}),
578
+ any_attention: leases.some((l) => l.renew), leases,
579
+ };
580
+ }
581
+
582
+ /** One line per expired or expiring lease, naming the command a human runs. */
583
+ export function leaseWarnings(report) {
584
+ return report.leases.filter((l) => l.renew).map((l) => `warning: worker ${l.worker} (vm ${l.vmid}) lease ${l.lease === 'EXPIRED' ? describeLease(l) : `expires in ${fmtLeft(l.hours_left)} (${l.lease_expires_at})`}; leasing needs a human, who renews it with: ${l.renew}`);
585
+ }
586
+
504
587
  /** Real side effects. */
505
588
  export function realDeps({ rest, env = process.env, log = (m) => process.stderr.write(`offload: ${m}\n`) }) {
506
589
  return {
@@ -12,7 +12,8 @@ import Table from 'cli-table3';
12
12
  * Format and print tabular data
13
13
  *
14
14
  * @param {Object} result - { columns, headers, rows } or array of objects
15
- * @param {Object} options - { format, title, emptyMessage, full } (full: no 60-char cell truncation in table mode)
15
+ * @param {Object} options - { format, title, emptyMessage, full, fit } (full: no 60-char cell truncation in table mode;
16
+ * fit: { width, flex } opt-in, shortens the `flex` column with an ellipsis so each table line fits `width`)
16
17
  */
17
18
  export function formatOutput(result, options = {}) {
18
19
  const format = options.format || getOutputFormat();
@@ -167,9 +168,9 @@ function formatTable(rows, columns, headers, options) {
167
168
  },
168
169
  });
169
170
 
170
- for (const row of rows) {
171
- table.push(columns.map(col => formatValue(row[col], options.full === true)));
172
- }
171
+ const cells = rows.map(row => columns.map(col => formatValue(row[col], options.full === true)));
172
+ if (options.fit) fitCells(cells, columns, headers, options.fit);
173
+ for (const r of cells) table.push(r);
173
174
 
174
175
  console.log(table.toString());
175
176
 
@@ -179,6 +180,30 @@ function formatTable(rows, columns, headers, options) {
179
180
  }
180
181
  }
181
182
 
183
+ // eslint-disable-next-line no-control-regex
184
+ const ANSI = /\x1b\[[0-9;]*m/g;
185
+ const visibleLength = (s) => [...String(s).replace(ANSI, '')].length;
186
+
187
+ /**
188
+ * Opt-in width fitting (fit: { width, flex }). cli-table3 here pads each cell by 1 on both sides and joins
189
+ * cells with a 2-space middle, so a line is sum(w + 2) + 2 * (n - 1). When that passes `width`, the `flex`
190
+ * column is cut to the remaining room with an ellipsis instead of letting the terminal wrap the row
191
+ * (owner 2026-11-07: wrapped `quox actions list` rows in the 73-column TUI).
192
+ */
193
+ function fitCells(cells, columns, headers, { width, flex } = {}) {
194
+ const w = Number(width);
195
+ const fi = columns.indexOf(flex);
196
+ if (!(w > 0) || fi < 0) return;
197
+ const widths = columns.map((_, i) => Math.max(visibleLength(headers[i] ?? ''), ...cells.map(r => visibleLength(r[i]))));
198
+ const total = widths.reduce((a, b) => a + b + 2, 0) + 2 * (columns.length - 1);
199
+ if (total <= w) return;
200
+ const room = Math.max(visibleLength(headers[fi] ?? ''), 8, widths[fi] - (total - w));
201
+ for (const r of cells) {
202
+ const plain = String(r[fi]).replace(ANSI, '');
203
+ if ([...plain].length > room) r[fi] = [...plain].slice(0, room - 1).join('').trimEnd() + '…';
204
+ }
205
+ }
206
+
182
207
  function formatCsv(rows, columns, headers) {
183
208
  console.log(headers.map(csvEscape).join(','));
184
209
  for (const row of rows) {
@@ -20,6 +20,7 @@ import os from 'node:os';
20
20
  import { RUNNING_ROOT, findOnPath } from '../quoxon/slash/restart.js';
21
21
  import { cmpSemver } from '../server/install.js';
22
22
  import { askText } from '../quoxon/ask.js';
23
+ import { isWebPty, SHELL_REFUSED } from '../quoxon/webPty.js';
23
24
 
24
25
  export const NPM_PACKAGE = 'quoxcli';
25
26
  export const NOTES_MAX = 5;
@@ -146,6 +147,7 @@ export async function checkUpdate(info, { deps = {} } = {}) {
146
147
 
147
148
  /** Run the update with the terminal handed over (prompts, sudo). -> { ok, code } or { ok:false, error } */
148
149
  export function runUpdate(plan, { spawn = spawnSync, env = process.env } = {}) {
150
+ if (isWebPty(env)) return { ok: false, error: SHELL_REFUSED }; // QUOXON web PTY: no npm/sudo on the server
149
151
  const r = spawn(plan.command.cmd, plan.command.args, { stdio: 'inherit', env });
150
152
  if (r.error) return { ok: false, error: `${r.error.code || 'failed'}: ${r.error.message}` };
151
153
  const code = r.status ?? (r.signal ? 128 + (os.constants.signals[r.signal] || 0) : 1);
@@ -18,7 +18,7 @@ import { execFileSync } from 'node:child_process';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { dirname, resolve } from 'node:path';
20
20
 
21
- export const BASE_VERSION = '0.5.0';
21
+ export const BASE_VERSION = '0.6.0';
22
22
 
23
23
  // The CLI source root (…/cli). version.js lives at cli/lib/core/, so two
24
24
  // levels up is the tree whose HEAD identifies the running build.
package/lib/quoxon/api.js CHANGED
@@ -459,6 +459,10 @@ const HUMAN_TUI = { 'X-Quox-Human-Action': 'tui' }; // DND contract: only a huma
459
459
  await client.post(ep.path, { service: ep.service, body: grantBody(cur, provider), retries: 0 });
460
460
  return true;
461
461
  },
462
+ /** Record a contact opt-in as THIS person (POST /api/v1/commanderq/contactability/policy/grant; human-only, needs a lawful basis). */
463
+ async grantContactability(body) {
464
+ return client.post('/api/v1/commanderq/contactability/policy/grant', { service: 'collector', body, retries: 0 });
465
+ },
462
466
  /** The dashboard address to print (never localhost when a better one is known). */
463
467
  async dashboardBase() {
464
468
  const profileUrl = config.getActiveProfile?.()?.url || config.getBaseUrl();