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.
- package/CHANGELOG.md +50 -1
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inboxq.js +39 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/lucid.js +6 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +266 -5
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/mon.js +14 -0
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notesSeal.js +380 -22
- package/lib/commands/offload.js +51 -10
- package/lib/commands/plan.js +471 -53
- package/lib/commands/volt.js +6 -1
- package/lib/commands/wazuh.js +65 -9
- package/lib/commands/wazuhIntake.js +46 -5
- package/lib/core/commandRegistrars.js +1 -0
- package/lib/core/config.js +14 -1
- package/lib/core/offload.js +83 -0
- package/lib/core/output.js +29 -4
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/version.js +1 -1
- package/lib/quoxon/api.js +4 -0
- package/lib/quoxon/app.js +257 -48
- package/lib/quoxon/approvalsView.js +3 -1
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/bang.js +7 -1
- package/lib/quoxon/commandShort.js +1 -1
- package/lib/quoxon/commands.js +4 -4
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/dnd.js +47 -0
- package/lib/quoxon/events.js +12 -1
- package/lib/quoxon/extensions.js +2 -0
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/inboxView.js +5 -5
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +2 -1
- package/lib/quoxon/main.js +8 -2
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/render.js +5 -2
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/secretShape.js +2 -0
- package/lib/quoxon/slash/broadcast.js +30 -6
- package/lib/quoxon/slash/broadcastRun.js +12 -6
- package/lib/quoxon/slash/cliLeaves.json +35 -0
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +41 -0
- package/lib/quoxon/slash/paletteController.js +6 -2
- package/lib/quoxon/slash/restart.js +2 -0
- package/lib/quoxon/slotChat.js +7 -3
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/whatsnew.jsonl +6 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/toolOutputShape.js +6 -3
- package/package.json +1 -1
package/lib/commands/volt.js
CHANGED
|
@@ -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
|
-
|
|
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'}`);
|
package/lib/commands/wazuh.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
.
|
|
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-
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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'],
|
package/lib/core/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|
package/lib/core/offload.js
CHANGED
|
@@ -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 {
|
package/lib/core/output.js
CHANGED
|
@@ -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
|
-
|
|
171
|
-
|
|
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) {
|
package/lib/core/selfUpdate.js
CHANGED
|
@@ -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);
|
package/lib/core/version.js
CHANGED
|
@@ -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.
|
|
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();
|