zeuslock-dlp-cli 0.2.0 → 0.2.2

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/LICENSE ADDED
@@ -0,0 +1,72 @@
1
+ ZeusLock CLI — End User Licence Agreement
2
+ Copyright (c) 2026 ZeusLock. All rights reserved.
3
+
4
+ This software (the "Software") is proprietary to ZeusLock and is licensed, not
5
+ sold. By downloading, installing or using the Software you agree to the terms
6
+ below. If you do not agree, do not install or use it.
7
+
8
+ 1. Grant of licence
9
+
10
+ ZeusLock grants you a non-exclusive, non-transferable, revocable, worldwide
11
+ licence to install and use the Software, in object and source form as
12
+ distributed, solely to access and administer a ZeusLock service for which you
13
+ hold a valid subscription or evaluation entitlement.
14
+
15
+ You may install the Software on any number of machines you or your
16
+ organisation control, and use it in automated systems such as CI pipelines,
17
+ for that purpose.
18
+
19
+ 2. Restrictions
20
+
21
+ Except where the applicable law of your jurisdiction grants a right that
22
+ cannot lawfully be waived, you may not:
23
+
24
+ a. distribute, sublicense, sell, rent, lease or host the Software, or make it
25
+ available to any third party as part of a service offering;
26
+ b. modify or create derivative works of the Software, other than local
27
+ configuration and scripting that invokes it as published;
28
+ c. remove or obscure any copyright, trademark or other proprietary notice;
29
+ d. use the Software to access a ZeusLock service you are not authorised to
30
+ access, or to circumvent any licensing, quota or entitlement control.
31
+
32
+ 3. Third-party components
33
+
34
+ The Software depends on separately licensed open-source packages, which are
35
+ installed from their own registries and remain governed by their own licence
36
+ terms. Nothing in this Agreement limits any right you have under those terms.
37
+
38
+ 4. Ownership
39
+
40
+ ZeusLock and its licensors retain all right, title and interest in and to the
41
+ Software, including all intellectual property rights. No rights are granted
42
+ except as expressly stated in this Agreement.
43
+
44
+ 5. Term and termination
45
+
46
+ This licence takes effect on first use and continues until terminated. It
47
+ terminates automatically if you breach any term, or when your entitlement to
48
+ the corresponding ZeusLock service ends. On termination you must stop using
49
+ the Software and remove all copies under your control. Sections 4, 6, 7 and 8
50
+ survive termination.
51
+
52
+ 6. No warranty
53
+
54
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
55
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
56
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
57
+
58
+ 7. Limitation of liability
59
+
60
+ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL ZEUSLOCK
61
+ OR ITS LICENSORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
62
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
63
+ CONNECTION WITH THE SOFTWARE OR ITS USE. Nothing in this Agreement excludes
64
+ or limits liability that cannot lawfully be excluded or limited.
65
+
66
+ 8. Precedence
67
+
68
+ If you or your organisation have a signed written agreement with ZeusLock
69
+ covering the Software, that agreement prevails over this one to the extent of
70
+ any conflict.
71
+
72
+ Contact: https://zeuslock.ai
package/README.md CHANGED
@@ -55,7 +55,7 @@ npm start -- auth api-key set zl_your_org_api_key
55
55
  npm start -- scan ./README.md --json
56
56
  npm start -- anonymize ./dataset.txt --output dataset.safe.txt
57
57
  npm start -- hook install --force
58
- npm start -- deploy gpo-config --api-key zl_your_key_here --output zeuslock-deploy
58
+ npm start -- deploy generate --api-key zl_your_key_here --output zeuslock-deploy
59
59
  npm start -- deploy agent-verify --hostname CLIENT01
60
60
  npm start -- agents list
61
61
  npm start -- agents list --status offline --search linux
@@ -96,7 +96,7 @@ zeuslock scan ./README.md
96
96
  zeuslock scan --stdin
97
97
  zeuslock anonymize ./dataset.txt --output dataset.safe.txt
98
98
  zeuslock hook install
99
- zeuslock deploy gpo-config --api-key zl_your_key_here --output zeuslock-deploy
99
+ zeuslock deploy generate --api-key zl_your_key_here --output zeuslock-deploy
100
100
  zeuslock deploy agent-verify --hostname CLIENT01
101
101
  zeuslock agents list --status online
102
102
  zeuslock agents revoke <agent_id> --yes
@@ -252,20 +252,20 @@ Deployment commands mirror only the dashboard Extension page and Agents deployme
252
252
 
253
253
  | Command | Purpose |
254
254
  | --- | --- |
255
- | `zeuslock deploy gpo-config` | Generate dashboard-supported extension GPO policy files and desktop-agent config files. |
255
+ | `zeuslock deploy generate` | Generate enrollment files: extension managed-configuration policies and desktop-agent config. Alias: `gpo-config`. |
256
256
  | `zeuslock deploy agent-verify` | Verify installed/enrolled/reachable state from `GET /api/agents`. |
257
257
 
258
258
  Supported filters and values:
259
259
 
260
260
  | Command | Option | Values |
261
261
  | --- | --- | --- |
262
- | `deploy gpo-config` | `--artifact` | `all`, `extension`, `agent`; default `all` |
263
- | `deploy gpo-config` | `--browser` | `all`, `chrome`, `edge`; default `all`; applies to extension artifacts |
264
- | `deploy gpo-config` | `--output` | Output directory; default `zeuslock-deploy` |
265
- | `deploy gpo-config` | `--api-key` | Full organization API key to embed in generated files |
266
- | `deploy gpo-config` | `--key-id` | Existing key id from `zeuslock keys list --show-secret`; requires a decryptable key from `GET /api/keys` |
267
- | `deploy gpo-config` | `--create-key-name` | Creates a new key with `POST /api/keys` and embeds the returned full key |
268
- | `deploy gpo-config` | `--json` | Prints generated artifact metadata without secret values |
262
+ | `deploy generate` | `--artifact` | `all`, `extension`, `agent`; default `all` |
263
+ | `deploy generate` | `--browser` | `all`, `chrome`, `edge`; default `all`; applies to extension artifacts |
264
+ | `deploy generate` | `--output` | Output directory; default `zeuslock-deploy` |
265
+ | `deploy generate` | `--api-key` | Full organization API key to embed in generated files |
266
+ | `deploy generate` | `--key-id` | Existing key id from `zeuslock keys list --show-secret`; requires a decryptable key from `GET /api/keys` |
267
+ | `deploy generate` | `--create-key-name` | Creates a new key with `POST /api/keys` and embeds the returned full key |
268
+ | `deploy generate` | `--json` | Prints generated artifact metadata without secret values |
269
269
  | `deploy agent-verify` | `--agent-id` | Exact agent id from `zeuslock agents list` |
270
270
  | `deploy agent-verify` | `--hostname` | Exact hostname, case-insensitive |
271
271
  | `deploy agent-verify` | `--platform` | `windows`, `macos`, `linux` |
@@ -411,12 +411,12 @@ Supported filters and values:
411
411
  | Command | Option | Values |
412
412
  | --- | --- | --- |
413
413
  | `incidents list` | `--days` | `7`, `30`, `90`; `90` requires Business or Enterprise |
414
- | `incidents list` | `--severity` | `critical`, `warning`; `warning` includes high, medium, and low |
414
+ | `incidents list` | `--severity` | `critical`, `warning`; policy outcome — `critical` = blocked, `warning` = everything else |
415
415
  | `incidents list` | `--search` | Any text; searches user email, URL, and finding type |
416
416
  | `incidents list` | `--jailbreak` | Only incidents with a `jailbreak_attempt` finding |
417
417
  | `incidents stats` | `--days` | `1`, `7`, `30`, `365`; `365` requires Business or Enterprise |
418
418
  | `incidents export` | `--days` | `7`, `30`, `90`; default `30`; export requires Business or Enterprise |
419
- | `incidents export` | `--severity` | `critical`, `warning`; `warning` includes high, medium, and low |
419
+ | `incidents export` | `--severity` | `critical`, `warning`; policy outcome — `critical` = blocked, `warning` = everything else |
420
420
  | `incidents export` | `--search` | Any text; searches user email, URL, and finding type |
421
421
  | `incidents export` | `--jailbreak` | Only incidents with a `jailbreak_attempt` finding |
422
422
  | `incidents export` | `--format` | `csv`, `json`; default `csv` |
@@ -561,7 +561,26 @@ Rules export scopes:
561
561
 
562
562
  This package is configured as `zeuslock-dlp-cli` and exposes the executable command `zeuslock`.
563
563
 
564
- Before publishing, confirm that the npm account is the intended package owner, choose the final license, and verify that `package.json` is already at the intended release version. Then run:
564
+ Before publishing, confirm that the npm account is the intended package owner, choose the final license, and verify that `package.json` is already at the intended release version.
565
+
566
+ ### GitHub Actions
567
+
568
+ Create a GitHub repository secret named `NPM_PUBLISH_TOKEN`. The token must belong to an npm account that can publish `zeuslock-dlp-cli`.
569
+
570
+ Release a new version:
571
+
572
+ ```bash
573
+ npm version patch
574
+ git push origin main --follow-tags
575
+ ```
576
+
577
+ The `vX.Y.Z` tag created by `npm version` triggers `.github/workflows/npm-publish.yml`. The workflow verifies the tag matches `package.json`, runs tests, checks publish contents, confirms the version is not already on npm, and publishes to the npm registry.
578
+
579
+ You can also run the workflow manually from the `main` branch in GitHub Actions. Manual runs publish the version already present in `package.json`.
580
+
581
+ ### Manual Fallback
582
+
583
+ If you need to publish from a local machine, run:
565
584
 
566
585
  ```bash
567
586
  npm publish --access public
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zeuslock-dlp-cli",
3
- "version": "0.2.0",
4
- "description": "Command line tools for ZeusLock.",
3
+ "version": "0.2.2",
4
+ "description": "Terminal client for ZeusLock DLP — scan files and prompts for sensitive data, block risky commits, and manage policies, agents, incidents and SIEM events.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "zeuslock": "bin/zeuslock.js"
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "src",
12
- "README.md"
12
+ "README.md",
13
+ "LICENSE"
13
14
  ],
14
15
  "scripts": {
15
16
  "start": "node ./bin/zeuslock.js",
@@ -22,10 +23,26 @@
22
23
  },
23
24
  "keywords": [
24
25
  "zeuslock",
26
+ "dlp",
27
+ "data-loss-prevention",
25
28
  "cli",
26
- "security"
29
+ "security",
30
+ "ai-security",
31
+ "shadow-ai",
32
+ "siem",
33
+ "ocsf",
34
+ "pre-commit",
35
+ "compliance"
27
36
  ],
28
- "license": "UNLICENSED",
37
+ "author": "ZeusLock (https://zeuslock.ai)",
38
+ "homepage": "https://zeuslock.ai/en/docs/getting-started/zeuslock-cli/",
39
+ "bugs": {
40
+ "url": "https://zeuslock.ai/en/docs/getting-started/zeuslock-cli/"
41
+ },
42
+ "license": "SEE LICENSE IN LICENSE",
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
29
46
  "dependencies": {
30
47
  "commander": "^15.0.0",
31
48
  "yaml": "^2.9.0"
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { Command } from "commander";
4
4
 
@@ -18,12 +18,27 @@ const EXTENSION_ID = "hgooghpcnalhpjbemnnmdoabfjhchoip";
18
18
  const EXTENSION_UPDATE_URL = "https://clients2.google.com/service/update2/crx";
19
19
  const AGENT_ONLINE_WINDOW_MS = 5 * 60 * 1000;
20
20
 
21
+ // Chromium reads an extension's managed configuration from a policy tree that is
22
+ // SEPARATE from ExtensionSettings — ExtensionSettings only controls install and
23
+ // host permissions, and silently drops any key it does not recognise. Managed
24
+ // values live under `<browser policy root>\3rdparty\extensions\<id>\policy`.
25
+ const EXTENSION_MANAGED_POLICY_ROOT = {
26
+ chrome: "HKEY_LOCAL_MACHINE\\SOFTWARE\\Policies\\Google\\Chrome",
27
+ edge: "HKEY_LOCAL_MACHINE\\SOFTWARE\\Policies\\Microsoft\\Edge"
28
+ };
29
+
30
+ // Property names the extension actually reads. These must match
31
+ // `managed_schema.json` in zeuslock-extension exactly: Chromium validates
32
+ // managed storage against that schema and discards anything not declared, and
33
+ // background.js gates on `managed.apiUrl && managed.apiKey` (camelCase).
34
+ const EXTENSION_MANAGED_KEYS = ["apiUrl", "apiKey", "orgName", "enabled", "showNotifications"];
35
+
21
36
  export function createDeployCommand({
22
37
  stdout = process.stdout,
23
38
  env = process.env
24
39
  } = {}) {
25
40
  const command = new Command("deploy")
26
- .description("Generate and verify ZeusLock deployment artifacts");
41
+ .description("Generate and verify ZeusLock enrollment files");
27
42
 
28
43
  command.addCommand(createGpoConfigCommand({ stdout, env }));
29
44
  command.addCommand(createAgentVerifyCommand({ stdout, env }));
@@ -32,8 +47,13 @@ export function createDeployCommand({
32
47
  }
33
48
 
34
49
  function createGpoConfigCommand({ stdout, env }) {
35
- return new Command("gpo-config")
36
- .description("Generate dashboard-supported GPO and agent deployment artifacts")
50
+ // `generate` is the documented name: this command writes local enrollment
51
+ // files for any rollout channel (GPO, Intune, Jamf, MDM), not GPO alone, and
52
+ // it never touches Active Directory. `gpo-config` stays as an alias so
53
+ // existing scripts and the published docs keep working.
54
+ return new Command("generate")
55
+ .alias("gpo-config")
56
+ .description("Generate managed-configuration files for mass deployment (GPO, Intune, Jamf, MDM)")
37
57
  .option("--artifact <set>", "artifact set: all, extension, or agent", "all")
38
58
  .option("--browser <browser>", "extension browser policy: chrome, edge, or all", "all")
39
59
  .option("--output <dir>", "output directory", "zeuslock-deploy")
@@ -65,7 +85,8 @@ function createGpoConfigCommand({ stdout, env }) {
65
85
  outputDir,
66
86
  browser,
67
87
  apiUrl,
68
- apiKey: selectedKey.api_key
88
+ apiKey: selectedKey.api_key,
89
+ orgName: org?.name || ""
69
90
  }));
70
91
  }
71
92
 
@@ -312,7 +333,7 @@ function formatMaskedPrefix(prefix) {
312
333
  return prefix ? `${prefix}***...****` : "";
313
334
  }
314
335
 
315
- async function writeExtensionArtifacts({ outputDir, browser, apiUrl, apiKey }) {
336
+ async function writeExtensionArtifacts({ outputDir, browser, apiUrl, apiKey, orgName }) {
316
337
  const artifacts = [];
317
338
  const browsers = browser === "all" ? ["chrome", "edge"] : [browser];
318
339
  for (const target of browsers) {
@@ -325,44 +346,98 @@ async function writeExtensionArtifacts({ outputDir, browser, apiUrl, apiKey }) {
325
346
  artifacts.push(await writeArtifact(
326
347
  outputDir,
327
348
  `extension/${target}-extension-settings.json`,
328
- `${JSON.stringify(buildExtensionSettings({ apiUrl, apiKey }), null, 2)}\n`,
349
+ `${JSON.stringify(buildExtensionSettings({ apiUrl }), null, 2)}\n`,
329
350
  "extension"
330
351
  ));
352
+ artifacts.push(await writeArtifact(
353
+ outputDir,
354
+ `extension/${target}-extension-managed-policy.reg`,
355
+ buildExtensionManagedRegistry({ browser: target, apiUrl, apiKey, orgName }),
356
+ "extension",
357
+ { secret: true }
358
+ ));
359
+ artifacts.push(await writeArtifact(
360
+ outputDir,
361
+ `extension/${target}-extension-managed-config.json`,
362
+ `${JSON.stringify(buildExtensionManagedConfig({ apiUrl, apiKey, orgName }), null, 2)}\n`,
363
+ "extension",
364
+ { secret: true }
365
+ ));
331
366
  }
332
367
  return artifacts;
333
368
  }
334
369
 
335
- function buildExtensionSettings({ apiUrl, apiKey }) {
370
+ /**
371
+ * Chromium `ExtensionSettings` policy value — install and host permissions only.
372
+ *
373
+ * This deliberately carries NO credentials. An earlier version nested a
374
+ * `configuration` block here; `configuration` is not a recognised
375
+ * ExtensionSettings key, so Chromium dropped it without warning and the
376
+ * extension fell back to per-user setup — the exact thing this command exists to
377
+ * avoid. Managed values go through buildExtensionManagedConfig() instead.
378
+ */
379
+ function buildExtensionSettings({ apiUrl }) {
336
380
  const apiHost = new URL(apiUrl).host;
337
381
  return {
338
382
  [EXTENSION_ID]: {
339
383
  installation_mode: "force_installed",
384
+ update_url: EXTENSION_UPDATE_URL,
340
385
  runtime_allowed_hosts: [`*://${apiHost}`],
341
- runtime_blocked_hosts: [],
342
- configuration: {
343
- api_url: apiUrl,
344
- api_key: apiKey,
345
- monitor_clipboard: true,
346
- monitor_ai_apps: true,
347
- show_notifications: true
348
- }
386
+ runtime_blocked_hosts: []
349
387
  }
350
388
  };
351
389
  }
352
390
 
391
+ /** Managed-storage values the extension reads via `chrome.storage.managed`. */
392
+ function buildExtensionManagedConfig({ apiUrl, apiKey, orgName }) {
393
+ return {
394
+ apiUrl,
395
+ apiKey,
396
+ orgName: orgName || "",
397
+ enabled: true,
398
+ showNotifications: true
399
+ };
400
+ }
401
+
402
+ /**
403
+ * The same managed configuration as a Windows `.reg` file, for GPO rollout.
404
+ * Booleans must be REG_DWORD; Chromium rejects "true"/"false" strings.
405
+ */
406
+ function buildExtensionManagedRegistry({ browser, apiUrl, apiKey, orgName }) {
407
+ const config = buildExtensionManagedConfig({ apiUrl, apiKey, orgName });
408
+ const lines = [
409
+ "Windows Registry Editor Version 5.00",
410
+ "",
411
+ `; ZeusLock extension managed configuration (${browser}).`,
412
+ "; Read by the extension through chrome.storage.managed.",
413
+ "",
414
+ `[${EXTENSION_MANAGED_POLICY_ROOT[browser]}\\3rdparty\\extensions\\${EXTENSION_ID}\\policy]`
415
+ ];
416
+ for (const key of EXTENSION_MANAGED_KEYS) {
417
+ const value = config[key];
418
+ lines.push(typeof value === "boolean"
419
+ ? `"${key}"=dword:${value ? "00000001" : "00000000"}`
420
+ : `"${key}"="${escapeRegString(String(value ?? ""))}"`);
421
+ }
422
+ lines.push("");
423
+ return lines.join("\r\n");
424
+ }
425
+
353
426
  async function writeAgentArtifacts({ outputDir, apiUrl, apiKey }) {
354
427
  return [
355
428
  await writeArtifact(
356
429
  outputDir,
357
430
  "agent/windows-agent-policy.reg",
358
431
  buildWindowsAgentRegistry({ apiUrl, apiKey }),
359
- "agent"
432
+ "agent",
433
+ { secret: true }
360
434
  ),
361
435
  await writeArtifact(
362
436
  outputDir,
363
437
  "agent/agent-config.json",
364
438
  `${JSON.stringify(buildAgentConfig({ apiUrl, apiKey }), null, 2)}\n`,
365
- "agent"
439
+ "agent",
440
+ { secret: true }
366
441
  )
367
442
  ];
368
443
  }
@@ -391,13 +466,23 @@ function escapeRegString(value) {
391
466
  return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
392
467
  }
393
468
 
394
- async function writeArtifact(outputDir, relativePath, content, type) {
469
+ async function writeArtifact(outputDir, relativePath, content, type, { secret = false } = {}) {
395
470
  const fullPath = path.join(outputDir, relativePath);
396
471
  await mkdir(path.dirname(fullPath), { recursive: true });
397
- await writeFile(fullPath, content, "utf8");
472
+ // Artifacts carrying the organization API key get the same 0600 treatment as
473
+ // the CLI's own api-key.json — writing a plaintext fleet credential
474
+ // world-readable would undo that hardening. `mode` is a no-op on Windows.
475
+ await writeFile(fullPath, content, { encoding: "utf8", mode: secret ? 0o600 : 0o644 });
476
+ if (secret) {
477
+ // writeFile's `mode` only applies when the file is created, and this command
478
+ // overwrites on re-run — chmod explicitly so a file left over from an older
479
+ // build does not keep 0644. Best-effort: unsupported on Windows.
480
+ await chmod(fullPath, 0o600).catch(() => {});
481
+ }
398
482
  return {
399
483
  type,
400
- path: fullPath
484
+ path: fullPath,
485
+ ...(secret ? { contains_secret: true } : {})
401
486
  };
402
487
  }
403
488
 
@@ -209,13 +209,7 @@ function applyIncidentFilters(incidents, { severity = null, search = "", jailbre
209
209
  }
210
210
 
211
211
  if (severity) {
212
- filtered = filtered.filter((incident) => {
213
- const riskLevel = String(incident.risk_level || incident.severity || "").toLowerCase();
214
- if (severity === "warning") {
215
- return ["high", "medium", "low"].includes(riskLevel);
216
- }
217
- return riskLevel === severity;
218
- });
212
+ filtered = filtered.filter((incident) => outcomeSeverity(incident) === severity);
219
213
  }
220
214
 
221
215
  const query = String(search || "").trim().toLowerCase();
@@ -230,6 +224,24 @@ function applyIncidentFilters(incidents, { severity = null, search = "", jailbre
230
224
  return filtered;
231
225
  }
232
226
 
227
+ /**
228
+ * Severity as the dashboard shows it: the POLICY OUTCOME decides, not the
229
+ * detector's risk score. A credit-card hit under an `alert` policy is a
230
+ * warning; the same hit under a `block` policy is critical.
231
+ *
232
+ * Kept byte-for-byte equivalent to `outcomeSeverity()` in the dashboard's
233
+ * IncidentsPage.tsx. The CLI used to read `risk_level` here, which disagreed
234
+ * with the dashboard on 44% of rows (alert-mode findings still carry
235
+ * risk_level "critical"). Change both or neither.
236
+ */
237
+ function outcomeSeverity(incident) {
238
+ const action = String(incident?.action || "").toLowerCase();
239
+ if (action) {
240
+ return action === "blocked" || action === "block" ? "critical" : "warning";
241
+ }
242
+ return incident?.blocked === true ? "critical" : "warning";
243
+ }
244
+
233
245
  function isDashboardVisibleIncident(incident) {
234
246
  const hasFindings = Array.isArray(incident.findings) && incident.findings.length > 0;
235
247
  const isBlocked = incident.blocked === true;
@@ -250,7 +262,7 @@ function getFindingTypes(incident) {
250
262
  function formatIncidentTable(incidents) {
251
263
  const rows = incidents.map((incident) => ({
252
264
  date: formatDate(incident.created_at),
253
- severity: incident.risk_level || incident.severity || "-",
265
+ severity: outcomeSeverity(incident),
254
266
  type: formatFindingTypes(incident),
255
267
  user: incident.user_email || "-",
256
268
  platform: formatPlatform(incident),
@@ -365,6 +377,10 @@ function formatIncidentsCsv(incidents) {
365
377
  const columns = [
366
378
  ["incident_id", (incident) => incident.incident_id || incident.id || ""],
367
379
  ["created_at", (incident) => incident.created_at || ""],
380
+ // `severity` is the dashboard-visible value (policy outcome); `risk_level`
381
+ // is kept alongside it as the raw detector score so nothing is lost for
382
+ // anyone already parsing that column.
383
+ ["severity", (incident) => outcomeSeverity(incident)],
368
384
  ["risk_level", (incident) => incident.risk_level || incident.severity || ""],
369
385
  ["type", (incident) => formatFindingTypes(incident)],
370
386
  ["user_email", (incident) => incident.user_email || ""],
@@ -292,9 +292,9 @@ async function fetchJson(url, request) {
292
292
 
293
293
  if (!response.ok) {
294
294
  const message =
295
- data?.error ||
296
- data?.detail ||
297
- data?.message ||
295
+ errorText(data?.error) ||
296
+ errorText(data?.detail) ||
297
+ errorText(data?.message) ||
298
298
  responseText ||
299
299
  `Request failed with status ${response.status}`;
300
300
  throw new ApiError(message, { status: response.status, data, responseText });
@@ -303,6 +303,36 @@ async function fetchJson(url, request) {
303
303
  return data ?? {};
304
304
  }
305
305
 
306
+ /**
307
+ * Coerce one backend error field into displayable text.
308
+ *
309
+ * Most endpoints return `detail` as a plain string, but the policy, group and
310
+ * principal routes wrap it (`{"detail": {"error": "Policy not found"}}` — see
311
+ * `policies.py` `_translate`, which passes `detail=exc.to_dict()`). Handing that
312
+ * object straight to `new Error()` printed the literal `[object Object]` and
313
+ * threw away a perfectly good message, so unwrap the common shapes first.
314
+ */
315
+ function errorText(value) {
316
+ if (typeof value === "string") {
317
+ return value.trim() || null;
318
+ }
319
+ if (Array.isArray(value)) {
320
+ // FastAPI request-validation errors arrive as a list of {loc, msg, type}.
321
+ const parts = value.map((entry) => errorText(entry)).filter(Boolean);
322
+ return parts.length ? parts.join("; ") : null;
323
+ }
324
+ if (value && typeof value === "object") {
325
+ return (
326
+ errorText(value.error) ||
327
+ errorText(value.message) ||
328
+ errorText(value.msg) ||
329
+ errorText(value.detail) ||
330
+ null
331
+ );
332
+ }
333
+ return null;
334
+ }
335
+
306
336
  function ensureTrailingSlash(apiUrl) {
307
337
  return apiUrl.endsWith("/") ? apiUrl : `${apiUrl}/`;
308
338
  }