@swestash/agent-lens 0.4.0 → 0.5.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.
@@ -591,7 +591,7 @@ var CAC = class extends EventEmitter {
591
591
  var cac = (name = "") => new CAC(name);
592
592
 
593
593
  // ../core/dist/schema.js
594
- var SCHEMA_VERSION = 11;
594
+ var SCHEMA_VERSION = 12;
595
595
  var SCHEMA_SQL = (
596
596
  /* sql */
597
597
  `
@@ -781,6 +781,34 @@ CREATE TABLE IF NOT EXISTS classifications (
781
781
  PRIMARY KEY (scope, target_id)
782
782
  );
783
783
 
784
+ -- Security findings (ADR-017), re-runnable. A deterministic rule engine (detect.ts) scans each
785
+ -- tool call's verbatim input/result for risky operations \u2014 destructive/data-loss, credential access,
786
+ -- data exfiltration, privilege/guardrail bypass \u2014 and records one row per (tool_call, rule) match.
787
+ -- Findings are 0..N per tool call (unlike classifications, which are 1-per-session), so an incremental
788
+ -- re-run DELETEs the touched sessions' rows and re-INSERTs (delete-then-insert) rather than upserting.
789
+ -- Every input/modifier is captured in signals_json so a finding is fully explainable; framework_ref
790
+ -- anchors it to OWASP Agentic / MITRE ATLAS. detector_version lets a future engine supersede rows.
791
+ CREATE TABLE IF NOT EXISTS findings (
792
+ id TEXT PRIMARY KEY, -- deterministic hash(tool_call_id, rule_id)
793
+ session_id TEXT NOT NULL REFERENCES sessions(id),
794
+ tool_call_id TEXT REFERENCES tool_calls(id),
795
+ event_uuid TEXT, -- the tool call's event, for jump-to-transcript
796
+ turn_id TEXT,
797
+ rule_id TEXT NOT NULL, -- 'destructive.rm_rf'
798
+ category TEXT NOT NULL, -- destructive | credential-access | exfiltration | privilege-bypass
799
+ framework_ref TEXT, -- 'OWASP ASI02' | 'MITRE ATLAS AML.T0086' | ...
800
+ severity TEXT NOT NULL, -- info | low | medium | high | critical
801
+ title TEXT,
802
+ evidence TEXT, -- matched snippet, truncated (no wholesale secret dump)
803
+ signals_json TEXT, -- why: rule, matched pattern, context modifiers
804
+ detector_version INTEGER NOT NULL DEFAULT 1
805
+ );
806
+ CREATE INDEX IF NOT EXISTS idx_findings_session ON findings(session_id);
807
+ CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity);
808
+ CREATE INDEX IF NOT EXISTS idx_findings_category ON findings(category);
809
+ CREATE INDEX IF NOT EXISTS idx_findings_rule ON findings(rule_id);
810
+ CREATE INDEX IF NOT EXISTS idx_findings_tool_call ON findings(tool_call_id);
811
+
784
812
  -- Workflow-tool run results, ingested from the sidecar JSON the runner writes next to the launching
785
813
  -- session (\u2026/projects/<enc>/<sessionId>/workflows/wf_<id>.json). The transcript only carries the
786
814
  -- "launched in background" ack for an async run; this sidecar is the authoritative record of how the
@@ -868,6 +896,55 @@ END;
868
896
  `
869
897
  );
870
898
 
899
+ // ../core/dist/security.js
900
+ var SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"];
901
+ function severityRank(s) {
902
+ return SEVERITY_ORDER.indexOf(s);
903
+ }
904
+ function bumpSeverity(s, delta) {
905
+ const i = SEVERITY_ORDER.indexOf(s);
906
+ const j = Math.max(0, Math.min(SEVERITY_ORDER.length - 1, i + delta));
907
+ return SEVERITY_ORDER[j];
908
+ }
909
+ var SECURITY_CATEGORIES = [
910
+ {
911
+ key: "destructive",
912
+ title: "Destructive & data-loss operations",
913
+ framework_ref: "OWASP ASI02",
914
+ framework_url: "https://genai.owasp.org/",
915
+ what: "Commands or edits that irreversibly delete, overwrite, or truncate data \u2014 rm -rf, git reset --hard, force-pushes, DROP/TRUNCATE, disk writes (dd/mkfs/shred), and overwrites of critical project files (lockfiles, CI config).",
916
+ why: "An agent acting on a bad plan or a poisoned instruction can wipe work, history, or a database with a single tool call. Because agent-lens sees sessions after the fact, this is how you notice the damage \u2014 and the blast radius \u2014 even when nobody was watching live.",
917
+ remediation: "Open the flagged session, confirm the target was intended, and check the tool result for whether it succeeded. Restore from git/backups if needed, and consider a PreToolUse hook to block the pattern going forward."
918
+ },
919
+ {
920
+ key: "credential-access",
921
+ title: "Secret & credential access",
922
+ framework_ref: "MITRE ATLAS: Credential Access",
923
+ framework_url: "https://atlas.mitre.org/",
924
+ what: "Reads of secret-bearing files (.env, ~/.ssh keys, ~/.aws/credentials, .npmrc, .pem) or secret-looking values (private keys, AWS keys, GitHub/Slack tokens) appearing in a command or a captured tool result.",
925
+ why: "Credentials the agent touched may have been exposed to the model context, logs, or a downstream tool \u2014 and could be leaked or reused. Retrospective visibility tells you which secrets to rotate.",
926
+ remediation: "Treat any accessed secret as potentially exposed: rotate it. Verify the access was purposeful, and keep secrets out of the working tree the agent operates on."
927
+ },
928
+ {
929
+ key: "exfiltration",
930
+ title: "Data exfiltration",
931
+ framework_ref: "MITRE ATLAS AML.T0086",
932
+ framework_url: "https://atlas.mitre.org/techniques/AML.T0086",
933
+ what: "Local data flowing outbound: curl/wget uploads to external hosts, piping files into a network command, netcat/reverse-shell patterns, and other ways an agent's write-capable tools become an exfiltration channel.",
934
+ why: "Exfiltration via the agent's own tool calls (AML.T0086) is a primary agentic attack path \u2014 a prompt-injected agent can POST your files or secrets to an attacker with one command. These are the highest-signal findings to review.",
935
+ remediation: "Inspect the destination host and the data referenced in the command. If it was not you, treat it as an incident: rotate anything referenced, review what left the host, and audit the session for the injection source."
936
+ },
937
+ {
938
+ key: "privilege-bypass",
939
+ title: "Privilege escalation & guardrail bypass",
940
+ framework_ref: "OWASP LLM06",
941
+ framework_url: "https://genai.owasp.org/",
942
+ what: "Actions that widen the agent's authority or sidestep safety: sudo, chmod 777, curl | sh, --dangerously-skip-permissions, writes outside the working project, and persistence mechanisms (cron, systemd, shell rc files).",
943
+ why: "Excessive agency (LLM06) is the root cause behind most agent incidents \u2014 every disabled guardrail or out-of-scope write removes a barrier the design relied on. Persistence in particular outlives the session that created it.",
944
+ remediation: "Confirm the escalation was necessary and scoped. Remove any persistence the agent added, re-tighten permissions, and avoid running agents with skipped permission prompts on a trusted host."
945
+ }
946
+ ];
947
+
871
948
  // ../core/dist/pricing.js
872
949
  var PRICE_TABLE = {
873
950
  // Opus 4.0 / 4.1 launched at $15/$75; Opus 4.5 cut the Opus tier to $5/$25 and 4.6/4.7/4.8 kept it.
@@ -1717,6 +1794,7 @@ function resetSchema(db) {
1717
1794
  DROP TRIGGER IF EXISTS events_ad;
1718
1795
  DROP TRIGGER IF EXISTS events_ai;
1719
1796
  DROP TABLE IF EXISTS events_fts;
1797
+ DROP TABLE IF EXISTS findings;
1720
1798
  DROP TABLE IF EXISTS classifications;
1721
1799
  DROP TABLE IF EXISTS token_usage;
1722
1800
  DROP TABLE IF EXISTS tool_calls;
@@ -2239,8 +2317,414 @@ function classify(db, dirty) {
2239
2317
  return { count: sessions.length, version: CLASSIFIER_VERSION };
2240
2318
  }
2241
2319
 
2242
- // ../ingest/dist/pipeline.js
2320
+ // ../ingest/dist/detect.js
2243
2321
  import { createHash } from "crypto";
2322
+ var DETECTOR_VERSION = 3;
2323
+ function evidence(s, max = 200) {
2324
+ const one = s.replace(/\s+/g, " ").trim();
2325
+ return one.length > max ? one.slice(0, max - 1) + "\u2026" : one;
2326
+ }
2327
+ function findingId(toolCallId, ruleId) {
2328
+ return createHash("sha1").update(toolCallId).update("\0").update(ruleId).digest("hex").slice(0, 16);
2329
+ }
2330
+ var SYSTEM_PATH = /(^|\s)(\/etc\/|\/usr\/|\/bin\/|\/sbin\/|\/boot\/|\/var\/|\/root\/|~\/\.ssh\/|~\/\.aws\/)/;
2331
+ var HOME_OR_ROOT_TARGET = /(\s|^)(~\/?|\/|\/\*|\$HOME\/?|\*)(\s|$)/;
2332
+ var SECRET_FILE = /(^|\/|\s)(\.env(\.[\w.-]+)?|id_rsa|id_ed25519|id_ecdsa|\.ssh\/[\w.-]+|\.aws\/credentials|\.gcp\/|gcloud\/|\.npmrc|\.pypirc|\.netrc|\.pem|\.p12|\.pfx|secrets?\.(ya?ml|json|txt)|credentials(\.json)?)\b/i;
2333
+ var EXAMPLE_FILE = /\.(example|sample|template|dist)($|\.)/i;
2334
+ var CONTENT_READ_CMD = /\b(cat|less|more|head|tail|bat|xxd|strings|base64|grep|egrep|rg|awk|sed|nl|tac|od|hexdump|cp|scp|sort)\b/i;
2335
+ var SECRET_VALUE_PATTERNS = [
2336
+ { re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/, label: "private-key", critical: true },
2337
+ { re: /\bAKIA[0-9A-Z]{16}\b/, label: "aws-access-key-id" },
2338
+ { re: /\bASIA[0-9A-Z]{16}\b/, label: "aws-temp-key-id" },
2339
+ { re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/, label: "github-token" },
2340
+ { re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, label: "slack-token" },
2341
+ { re: /\bsk-[A-Za-z0-9]{20,}\b/, label: "api-secret-key" }
2342
+ ];
2343
+ var isBash = (t) => t === "Bash";
2344
+ var AGENT_CONFIG_PATH = /(^|\/)\.claude\//;
2345
+ var TEMP_PATH = /^(\/tmp\/|\/var\/folders\/|\/private\/var\/folders\/)/;
2346
+ function isAgentOwnedPath(p) {
2347
+ if (AGENT_CONFIG_PATH.test(p) || TEMP_PATH.test(p))
2348
+ return true;
2349
+ const tmp = process.env.TMPDIR;
2350
+ return !!tmp && p.startsWith(tmp.replace(/\/$/, "") + "/");
2351
+ }
2352
+ function outsideProject(filePath, projectPath) {
2353
+ if (!filePath || !filePath.startsWith("/"))
2354
+ return false;
2355
+ if (isAgentOwnedPath(filePath))
2356
+ return false;
2357
+ if (!projectPath)
2358
+ return false;
2359
+ return filePath !== projectPath && !filePath.startsWith(projectPath.replace(/\/$/, "") + "/");
2360
+ }
2361
+ var RULES = [
2362
+ // --- Destructive / data-loss (OWASP ASI02 Tool Misuse) -------------------
2363
+ {
2364
+ id: "destructive.rm_rf",
2365
+ category: "destructive",
2366
+ framework_ref: "OWASP ASI02",
2367
+ title: "Recursive force delete (rm -rf)",
2368
+ tools: ["Bash"],
2369
+ baseSeverity: "high",
2370
+ test(ctx) {
2371
+ const c = ctx.command;
2372
+ if (!c)
2373
+ return null;
2374
+ const rf = /\brm\s+[^|;&\n]*(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|--recursive[^|;&\n]*--force|--force[^|;&\n]*--recursive)/i;
2375
+ if (!rf.test(c))
2376
+ return null;
2377
+ const dangerousTarget = HOME_OR_ROOT_TARGET.test(c) || /\s\/(?:\s|$)/.test(c) || /\brm\b[^|;&\n]*\*/.test(c);
2378
+ return { evidence: evidence(c), severity: dangerousTarget ? "critical" : "high", mods: { dangerous_target: dangerousTarget } };
2379
+ }
2380
+ },
2381
+ {
2382
+ id: "destructive.git_reset_hard",
2383
+ category: "destructive",
2384
+ framework_ref: "OWASP ASI02",
2385
+ title: "Discards local changes (git reset --hard)",
2386
+ tools: ["Bash"],
2387
+ baseSeverity: "medium",
2388
+ test(ctx) {
2389
+ const c = ctx.command;
2390
+ if (!c || !/\bgit\s+reset\s+--hard\b/i.test(c))
2391
+ return null;
2392
+ return { evidence: evidence(c) };
2393
+ }
2394
+ },
2395
+ {
2396
+ id: "destructive.git_force_push",
2397
+ category: "destructive",
2398
+ framework_ref: "OWASP ASI02",
2399
+ title: "Force-push rewrites remote history",
2400
+ tools: ["Bash"],
2401
+ baseSeverity: "medium",
2402
+ test(ctx) {
2403
+ const c = ctx.command;
2404
+ if (!c || !/\bgit\s+push\b[^|;&\n]*(--force\b|--force-with-lease\b|\s-f\b)/i.test(c))
2405
+ return null;
2406
+ const mainBranch = /\b(main|master|release)\b/.test(c);
2407
+ return { evidence: evidence(c), severity: mainBranch ? "high" : "medium", mods: { protected_branch: mainBranch } };
2408
+ }
2409
+ },
2410
+ {
2411
+ id: "destructive.sql_drop",
2412
+ category: "destructive",
2413
+ framework_ref: "OWASP ASI02",
2414
+ title: "Drops or truncates a database object",
2415
+ tools: "*",
2416
+ baseSeverity: "high",
2417
+ test(ctx) {
2418
+ const hay = ctx.command ?? (typeof ctx.input?.query === "string" ? ctx.input.query : null);
2419
+ if (!hay || !/\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE(\s+TABLE)?)\b/i.test(hay))
2420
+ return null;
2421
+ return { evidence: evidence(hay) };
2422
+ }
2423
+ },
2424
+ {
2425
+ id: "destructive.disk_wipe",
2426
+ category: "destructive",
2427
+ framework_ref: "OWASP ASI02",
2428
+ title: "Low-level disk write / wipe (dd, mkfs, shred)",
2429
+ tools: ["Bash"],
2430
+ baseSeverity: "high",
2431
+ test(ctx) {
2432
+ const c = ctx.command;
2433
+ if (!c || !/(\bdd\s+if=|\bmkfs(\.\w+)?\b|\bshred\b)/i.test(c))
2434
+ return null;
2435
+ return { evidence: evidence(c) };
2436
+ }
2437
+ },
2438
+ {
2439
+ id: "destructive.overwrite_critical",
2440
+ category: "destructive",
2441
+ framework_ref: "OWASP ASI02",
2442
+ title: "Overwrites a critical project file",
2443
+ tools: ["Write", "Edit", "MultiEdit"],
2444
+ baseSeverity: "medium",
2445
+ test(ctx) {
2446
+ const f = ctx.filePath;
2447
+ if (!f)
2448
+ return null;
2449
+ if (!/(^|\/)(package-lock\.json|pnpm-lock\.ya?ml|yarn\.lock|Cargo\.lock|go\.sum|\.github\/workflows\/|\.gitlab-ci\.ya?ml)\b/.test(f))
2450
+ return null;
2451
+ return { evidence: evidence(f) };
2452
+ }
2453
+ },
2454
+ // --- Credential & secret access ------------------------------------------
2455
+ {
2456
+ id: "credential.secret_file_access",
2457
+ category: "credential-access",
2458
+ framework_ref: "MITRE ATLAS: Credential Access",
2459
+ title: "Reads a secret/credential file",
2460
+ tools: ["Read", "Bash"],
2461
+ baseSeverity: "high",
2462
+ test(ctx) {
2463
+ const hay = ctx.toolName === "Read" ? ctx.filePath : ctx.command;
2464
+ if (!hay || !SECRET_FILE.test(hay) || EXAMPLE_FILE.test(hay))
2465
+ return null;
2466
+ if (isBash(ctx.toolName) && !CONTENT_READ_CMD.test(hay))
2467
+ return null;
2468
+ return { evidence: evidence(hay) };
2469
+ }
2470
+ },
2471
+ {
2472
+ id: "credential.secret_in_data",
2473
+ category: "credential-access",
2474
+ framework_ref: "MITRE ATLAS: Credential Access",
2475
+ title: "Secret-looking value in command or output",
2476
+ tools: "*",
2477
+ baseSeverity: "high",
2478
+ test(ctx) {
2479
+ const hay = [ctx.command, ctx.resultSummary].filter(Boolean).join("\n");
2480
+ if (!hay)
2481
+ return null;
2482
+ for (const p of SECRET_VALUE_PATTERNS) {
2483
+ const m = p.re.exec(hay);
2484
+ if (m)
2485
+ return { evidence: `${p.label}: ${evidence(m[0], 40)}`, severity: p.critical ? "critical" : "high", mods: { secret_kind: p.label } };
2486
+ }
2487
+ return null;
2488
+ }
2489
+ },
2490
+ // --- Data exfiltration (MITRE ATLAS AML.T0086) ---------------------------
2491
+ {
2492
+ id: "exfil.pipe_to_network",
2493
+ category: "exfiltration",
2494
+ framework_ref: "MITRE ATLAS AML.T0086",
2495
+ title: "Pipes local data into a network command",
2496
+ tools: ["Bash"],
2497
+ baseSeverity: "critical",
2498
+ test(ctx) {
2499
+ const c = ctx.command;
2500
+ if (!c)
2501
+ return null;
2502
+ if (!/(\||<)\s*(sudo\s+)?(curl|wget|nc|ncat|netcat|ssh|scp)\b/i.test(c))
2503
+ return null;
2504
+ if (!/\b(cat|tar|zip|gzip|base64|env|printenv|cat\s|<)\b/i.test(c))
2505
+ return null;
2506
+ return { evidence: evidence(c) };
2507
+ }
2508
+ },
2509
+ {
2510
+ id: "exfil.network_upload",
2511
+ category: "exfiltration",
2512
+ framework_ref: "MITRE ATLAS AML.T0086",
2513
+ title: "Uploads data to an external host",
2514
+ tools: ["Bash"],
2515
+ baseSeverity: "high",
2516
+ test(ctx) {
2517
+ const c = ctx.command;
2518
+ if (!c)
2519
+ return null;
2520
+ const upload = /\b(curl|wget)\b[^|;&\n]*(-X\s*POST|--data\b|--data-\w+\b|\s-d\b|-T\b|--upload-file\b|-F\b|--form\b)/i.test(c);
2521
+ if (!upload)
2522
+ return null;
2523
+ const external = /https?:\/\/(?!(localhost|127\.0\.0\.1|0\.0\.0\.0))/i.test(c);
2524
+ const withFile = /(@[^\s'"]+|--upload-file|\s-T\b)/.test(c);
2525
+ return {
2526
+ evidence: evidence(c),
2527
+ severity: withFile && external ? "critical" : "high",
2528
+ mods: { external, with_file: withFile }
2529
+ };
2530
+ }
2531
+ },
2532
+ {
2533
+ id: "exfil.reverse_shell",
2534
+ category: "exfiltration",
2535
+ framework_ref: "MITRE ATLAS AML.T0086",
2536
+ title: "Netcat / reverse-shell style connection",
2537
+ tools: ["Bash"],
2538
+ baseSeverity: "critical",
2539
+ test(ctx) {
2540
+ const c = ctx.command;
2541
+ if (!c)
2542
+ return null;
2543
+ if (!/\b(nc|ncat|netcat)\b[^|;&\n]*(-e\b|-c\b|\d{2,5})|\/dev\/tcp\/|bash\s+-i\b/i.test(c))
2544
+ return null;
2545
+ return { evidence: evidence(c) };
2546
+ }
2547
+ },
2548
+ // --- Privilege escalation / guardrail bypass (OWASP LLM06 Excessive Agency)
2549
+ {
2550
+ id: "privilege.skip_permissions",
2551
+ category: "privilege-bypass",
2552
+ framework_ref: "OWASP LLM06",
2553
+ title: "Bypasses the permission guardrail",
2554
+ tools: "*",
2555
+ baseSeverity: "high",
2556
+ test(ctx) {
2557
+ const c = ctx.command;
2558
+ if (!c || !/--dangerously-skip-permissions\b|--yolo\b|--dangerously-skip\b/i.test(c))
2559
+ return null;
2560
+ return { evidence: evidence(c) };
2561
+ }
2562
+ },
2563
+ {
2564
+ id: "privilege.curl_pipe_shell",
2565
+ category: "privilege-bypass",
2566
+ framework_ref: "OWASP LLM06",
2567
+ title: "Executes a remote script (curl | sh)",
2568
+ tools: ["Bash"],
2569
+ baseSeverity: "high",
2570
+ test(ctx) {
2571
+ const c = ctx.command;
2572
+ if (!c || !/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python\d?|node)\b/i.test(c))
2573
+ return null;
2574
+ return { evidence: evidence(c) };
2575
+ }
2576
+ },
2577
+ {
2578
+ id: "privilege.sudo",
2579
+ category: "privilege-bypass",
2580
+ framework_ref: "OWASP LLM06",
2581
+ title: "Runs a command as root (sudo)",
2582
+ tools: ["Bash"],
2583
+ baseSeverity: "high",
2584
+ // root escalation removes a real safety barrier — higher than an in-project op
2585
+ test(ctx) {
2586
+ const c = ctx.command;
2587
+ if (!c || !/(^|\s|\|)sudo\s+/i.test(c))
2588
+ return null;
2589
+ return { evidence: evidence(c) };
2590
+ }
2591
+ },
2592
+ {
2593
+ id: "privilege.chmod_777",
2594
+ category: "privilege-bypass",
2595
+ framework_ref: "OWASP LLM06",
2596
+ title: "Overly permissive permissions (chmod 777)",
2597
+ tools: ["Bash"],
2598
+ baseSeverity: "medium",
2599
+ test(ctx) {
2600
+ const c = ctx.command;
2601
+ if (!c || !/\bchmod\s+(-[a-zA-Z]+\s+)*0?777\b/i.test(c))
2602
+ return null;
2603
+ return { evidence: evidence(c) };
2604
+ }
2605
+ },
2606
+ {
2607
+ id: "privilege.persistence",
2608
+ category: "privilege-bypass",
2609
+ framework_ref: "OWASP LLM06",
2610
+ title: "Installs a persistence mechanism",
2611
+ tools: ["Bash"],
2612
+ baseSeverity: "high",
2613
+ test(ctx) {
2614
+ const c = ctx.command;
2615
+ if (!c)
2616
+ return null;
2617
+ if (!/\bcrontab\s+-|\bsystemctl\s+enable\b|\blaunchctl\s+load\b|>>\s*~\/\.(bashrc|zshrc|profile|bash_profile)\b|\/etc\/(cron|systemd|rc\.local)/i.test(c))
2618
+ return null;
2619
+ return { evidence: evidence(c) };
2620
+ }
2621
+ },
2622
+ {
2623
+ id: "privilege.write_outside_project",
2624
+ category: "privilege-bypass",
2625
+ framework_ref: "OWASP LLM06",
2626
+ title: "Writes a file outside the working project",
2627
+ tools: ["Write", "Edit", "MultiEdit"],
2628
+ baseSeverity: "medium",
2629
+ test(ctx) {
2630
+ const f = ctx.filePath;
2631
+ if (!outsideProject(f, ctx.projectPath))
2632
+ return null;
2633
+ const system = !!f && SYSTEM_PATH.test(f);
2634
+ return { evidence: evidence(f), severity: system ? "high" : "medium", mods: { project: ctx.projectPath, system_path: system } };
2635
+ }
2636
+ }
2637
+ ];
2638
+ function appliesTo(rule, toolName) {
2639
+ return rule.tools === "*" || rule.tools.includes(toolName);
2640
+ }
2641
+ function buildContext(row, projectPath) {
2642
+ let input = null;
2643
+ try {
2644
+ input = row.input_json ? JSON.parse(row.input_json) : null;
2645
+ } catch {
2646
+ input = null;
2647
+ }
2648
+ const command = typeof input?.command === "string" ? input.command : null;
2649
+ const filePath = typeof input?.file_path === "string" ? input.file_path : null;
2650
+ return { toolName: row.tool_name, command, filePath, input, resultSummary: row.result_summary, status: row.status, projectPath };
2651
+ }
2652
+ function detect(db, dirty) {
2653
+ const incremental = dirty != null;
2654
+ if (incremental) {
2655
+ db.exec("DROP TABLE IF EXISTS _dirty_sec");
2656
+ db.exec("CREATE TEMP TABLE _dirty_sec (id TEXT PRIMARY KEY)");
2657
+ const ins = db.prepare("INSERT OR IGNORE INTO _dirty_sec (id) VALUES (?)");
2658
+ db.transaction((ids) => {
2659
+ for (const id of ids)
2660
+ ins.run(id);
2661
+ })(dirty);
2662
+ }
2663
+ const scope = incremental ? " WHERE tc.session_id IN (SELECT id FROM _dirty_sec)" : "";
2664
+ const projPath = /* @__PURE__ */ new Map();
2665
+ for (const r of db.prepare(`SELECT s.id id, p.path path FROM sessions s JOIN projects p ON p.id = s.project_id${incremental ? " WHERE s.id IN (SELECT id FROM _dirty_sec)" : ""}`).all()) {
2666
+ projPath.set(r.id, r.path);
2667
+ }
2668
+ const rows = db.prepare(`SELECT tc.id, tc.session_id, tc.event_uuid, tc.turn_id, tc.tool_name, tc.input_json, tc.result_summary, tc.status
2669
+ FROM tool_calls tc${scope}`).all();
2670
+ const insert = db.prepare(`INSERT INTO findings (id, session_id, tool_call_id, event_uuid, turn_id, rule_id, category, framework_ref, severity, title, evidence, signals_json, detector_version)
2671
+ VALUES (@id, @session_id, @tool_call_id, @event_uuid, @turn_id, @rule_id, @category, @framework_ref, @severity, @title, @evidence, @signals_json, @detector_version)
2672
+ ON CONFLICT(id) DO NOTHING`);
2673
+ const delScope = incremental ? "DELETE FROM findings WHERE session_id IN (SELECT id FROM _dirty_sec)" : "DELETE FROM findings";
2674
+ const tx = db.transaction(() => {
2675
+ db.exec(delScope);
2676
+ for (const row of rows) {
2677
+ const ctx = buildContext(row, projPath.get(row.session_id) ?? null);
2678
+ for (const rule of RULES) {
2679
+ if (!appliesTo(rule, ctx.toolName))
2680
+ continue;
2681
+ const m = rule.test(ctx);
2682
+ if (!m)
2683
+ continue;
2684
+ let severity = m.severity ?? rule.baseSeverity;
2685
+ const mods = { ...m.mods ?? {} };
2686
+ if (row.status === "error") {
2687
+ severity = bumpSeverity(severity, -1);
2688
+ mods.attempted = true;
2689
+ }
2690
+ const signals = {
2691
+ rule: rule.id,
2692
+ category: rule.category,
2693
+ framework_ref: rule.framework_ref,
2694
+ tool_name: ctx.toolName,
2695
+ base_severity: rule.baseSeverity,
2696
+ severity,
2697
+ status: row.status,
2698
+ modifiers: mods,
2699
+ detector_version: DETECTOR_VERSION
2700
+ };
2701
+ insert.run({
2702
+ id: findingId(row.id, rule.id),
2703
+ session_id: row.session_id,
2704
+ tool_call_id: row.id,
2705
+ event_uuid: row.event_uuid,
2706
+ turn_id: row.turn_id,
2707
+ rule_id: rule.id,
2708
+ category: rule.category,
2709
+ framework_ref: rule.framework_ref,
2710
+ severity,
2711
+ title: rule.title,
2712
+ evidence: m.evidence,
2713
+ signals_json: JSON.stringify(signals),
2714
+ detector_version: DETECTOR_VERSION
2715
+ });
2716
+ }
2717
+ }
2718
+ });
2719
+ tx();
2720
+ if (incremental)
2721
+ db.exec("DROP TABLE IF EXISTS _dirty_sec");
2722
+ const count = db.prepare("SELECT COUNT(*) n FROM findings").get().n;
2723
+ return { count, version: DETECTOR_VERSION };
2724
+ }
2725
+
2726
+ // ../ingest/dist/pipeline.js
2727
+ import { createHash as createHash2 } from "crypto";
2244
2728
  function newStats() {
2245
2729
  return { files: 0, skipped: 0, malformed: 0, newEvents: 0 };
2246
2730
  }
@@ -2278,7 +2762,7 @@ function skillSummary(body) {
2278
2762
  return null;
2279
2763
  }
2280
2764
  function skillVersionId(name, body) {
2281
- return createHash("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
2765
+ return createHash2("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
2282
2766
  }
2283
2767
  function pruneExcluded(db, excludedPaths) {
2284
2768
  if (!excludedPaths.length)
@@ -2312,6 +2796,7 @@ function pruneExcluded(db, excludedPaths) {
2312
2796
  const files = db.prepare("SELECT DISTINCT source_file f FROM events WHERE session_id IN (SELECT id FROM _prune) AND source_file IS NOT NULL").all().map((r) => r.f);
2313
2797
  db.pragma("foreign_keys = OFF");
2314
2798
  db.transaction(() => {
2799
+ db.exec("DELETE FROM findings WHERE session_id IN (SELECT id FROM _prune)");
2315
2800
  for (const t of ["token_usage", "tool_calls", "turns", "events"])
2316
2801
  db.exec(`DELETE FROM ${t} WHERE session_id IN (SELECT id FROM _prune)`);
2317
2802
  db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id IN (SELECT id FROM _prune)");
@@ -2544,6 +3029,7 @@ function rebuildDerived(db, dirty) {
2544
3029
  `);
2545
3030
  db.exec(`DELETE FROM sessions WHERE event_count = 0${andId}`);
2546
3031
  db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id NOT IN (SELECT id FROM sessions)");
3032
+ db.exec("DELETE FROM findings WHERE session_id NOT IN (SELECT id FROM sessions)");
2547
3033
  if (!incremental) {
2548
3034
  db.exec("DELETE FROM skills WHERE id NOT IN (SELECT skill_id FROM tool_calls WHERE skill_id IS NOT NULL)");
2549
3035
  }
@@ -2609,7 +3095,7 @@ function prepareStatements(db) {
2609
3095
  workflow_run_id = COALESCE(excluded.workflow_run_id, workflow_run_id)`)
2610
3096
  };
2611
3097
  }
2612
- function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
3098
+ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
2613
3099
  const sessionMeta = {};
2614
3100
  let eventsInFile = 0;
2615
3101
  const tx = db.transaction(() => {
@@ -2658,13 +3144,13 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
2658
3144
  }
2659
3145
  let projectId = null;
2660
3146
  if (sessionMeta.cwd) {
2661
- projectId = createHash("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
3147
+ projectId = createHash2("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
2662
3148
  stmts.upsertProject.run({
2663
3149
  id: projectId,
2664
3150
  agent_id: adapter.agentId,
2665
3151
  path: sessionMeta.cwd,
2666
3152
  encoded_dir: file.encodedDir || null,
2667
- now
3153
+ now: now2
2668
3154
  });
2669
3155
  }
2670
3156
  stmts.upsertSession.run({
@@ -2686,7 +3172,7 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
2686
3172
  mtime_ms: Math.trunc(meta.mtimeMs),
2687
3173
  sha256: meta.hash,
2688
3174
  events_ingested: eventsInFile,
2689
- ingested_at: now
3175
+ ingested_at: now2
2690
3176
  });
2691
3177
  });
2692
3178
  tx();
@@ -2697,7 +3183,7 @@ import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as
2697
3183
  import { basename as basename3, join as join5 } from "path";
2698
3184
 
2699
3185
  // ../ingest/dist/redact.js
2700
- import { createHash as createHash2, randomBytes } from "crypto";
3186
+ import { createHash as createHash3, randomBytes } from "crypto";
2701
3187
  function parseExcludes(csv) {
2702
3188
  return (csv ?? "").split(",").map((s) => s.trim()).filter(Boolean).map(encodeProjectPath);
2703
3189
  }
@@ -2706,17 +3192,17 @@ function isExcludedArchivePath(filePath, excluded) {
2706
3192
  }
2707
3193
 
2708
3194
  // ../ingest/dist/fileread.js
2709
- import { createHash as createHash3 } from "crypto";
3195
+ import { createHash as createHash4 } from "crypto";
2710
3196
  import { openSync as openSync2, readSync as readSync2, closeSync as closeSync2 } from "fs";
2711
3197
  import { StringDecoder } from "string_decoder";
2712
3198
  var STREAM_THRESHOLD = 8 * 1024 * 1024;
2713
3199
  var CHUNK2 = 64 * 1024;
2714
3200
  function sha256(buf) {
2715
- return createHash3("sha256").update(buf).digest("hex");
3201
+ return createHash4("sha256").update(buf).digest("hex");
2716
3202
  }
2717
3203
  function sha256File(path) {
2718
3204
  const fd = openSync2(path, "r");
2719
- const h = createHash3("sha256");
3205
+ const h = createHash4("sha256");
2720
3206
  const buf = Buffer.allocUnsafe(CHUNK2);
2721
3207
  try {
2722
3208
  let n;
@@ -2803,7 +3289,7 @@ function cleanStr(v) {
2803
3289
  return null;
2804
3290
  return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\[[0-9;]+m\]?/g, "").trim() || null;
2805
3291
  }
2806
- function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now, stats, full) {
3292
+ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now2, stats, full) {
2807
3293
  let paths = discoverSidecars(sourceArchiveDir);
2808
3294
  if (excludedDirs.length)
2809
3295
  paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
@@ -2846,7 +3332,7 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
2846
3332
  }
2847
3333
  const hash = sha256(buf);
2848
3334
  if (prev && prev.sha256 === hash) {
2849
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3335
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
2850
3336
  stats.skipped++;
2851
3337
  continue;
2852
3338
  }
@@ -2884,9 +3370,9 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
2884
3370
  duration_ms: durationMs2,
2885
3371
  started_at: startedAt,
2886
3372
  ended_at: endedAt,
2887
- ingested_at: now
3373
+ ingested_at: now2
2888
3374
  });
2889
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3375
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
2890
3376
  stats.upserted++;
2891
3377
  }
2892
3378
  }
@@ -2925,7 +3411,7 @@ function discoverMeta(sourceArchiveDir) {
2925
3411
  }
2926
3412
  var intOrNull2 = (v) => typeof v === "number" && Number.isFinite(v) ? Math.trunc(v) : null;
2927
3413
  var strOrNull2 = (v) => typeof v === "string" && v ? v : null;
2928
- function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, stats, full) {
3414
+ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2, stats, full) {
2929
3415
  let paths = discoverMeta(sourceArchiveDir);
2930
3416
  if (excludedDirs.length)
2931
3417
  paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
@@ -2960,7 +3446,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, s
2960
3446
  }
2961
3447
  const hash = sha256(buf);
2962
3448
  if (prev && prev.sha256 === hash) {
2963
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3449
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
2964
3450
  stats.skipped++;
2965
3451
  continue;
2966
3452
  }
@@ -2983,9 +3469,9 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, s
2983
3469
  agent_description: strOrNull2(d.description),
2984
3470
  spawn_depth: intOrNull2(d.spawnDepth),
2985
3471
  tool_use_id: strOrNull2(d.toolUseId),
2986
- ingested_at: now
3472
+ ingested_at: now2
2987
3473
  });
2988
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3474
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
2989
3475
  stats.upserted++;
2990
3476
  }
2991
3477
  }
@@ -3027,7 +3513,7 @@ function sessionFromPath2(path) {
3027
3513
  const i = parts.indexOf("tool-results");
3028
3514
  return i > 0 ? parts[i - 1] : null;
3029
3515
  }
3030
- function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full) {
3516
+ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now2, stats, full) {
3031
3517
  let paths = discoverResults(sourceArchiveDir);
3032
3518
  if (excludedDirs.length)
3033
3519
  paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
@@ -3061,7 +3547,7 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full)
3061
3547
  }
3062
3548
  const hash = sha256(buf);
3063
3549
  if (prev && prev.sha256 === hash) {
3064
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3550
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
3065
3551
  stats.skipped++;
3066
3552
  continue;
3067
3553
  }
@@ -3071,9 +3557,9 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full)
3071
3557
  path,
3072
3558
  bytes: st.size,
3073
3559
  text: buf.toString("utf8"),
3074
- ingested_at: now
3560
+ ingested_at: now2
3075
3561
  });
3076
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3562
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
3077
3563
  stats.upserted++;
3078
3564
  }
3079
3565
  }
@@ -3115,7 +3601,7 @@ function runIngest(argv = process.argv.slice(2)) {
3115
3601
  const adapterList = [new ClaudeCodeAdapter()];
3116
3602
  const adapterById = new Map(adapterList.map((a) => [a.agentId, a]));
3117
3603
  const sources = loadSources();
3118
- const now = (/* @__PURE__ */ new Date()).toISOString();
3604
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
3119
3605
  const excludePaths = loadExcludes();
3120
3606
  const excludedDirs = parseExcludes(excludePaths.join(","));
3121
3607
  const pruned = pruneExcluded(db, excludePaths);
@@ -3156,21 +3642,22 @@ function runIngest(argv = process.argv.slice(2)) {
3156
3642
  mtime_ms: mtimeMs,
3157
3643
  sha256: hash,
3158
3644
  events_ingested: prev.events_ingested,
3159
- ingested_at: now
3645
+ ingested_at: now2
3160
3646
  });
3161
3647
  stats.skipped++;
3162
3648
  continue;
3163
3649
  }
3164
3650
  const lines = small ? buf.toString("utf8").split("\n") : streamLines(file.path);
3165
- ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash }, now, stats);
3651
+ ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash }, now2, stats);
3166
3652
  dirty.add(file.sessionId);
3167
3653
  }
3168
- ingestWorkflowResults(db, join8(archiveRoot, source.label), source.label, excludedDirs, now, wfStats, args.full);
3169
- ingestSubagentMeta(db, join8(archiveRoot, source.label), source.label, excludedDirs, now, metaStats, args.full);
3170
- ingestToolResults(db, join8(archiveRoot, source.label), excludedDirs, now, trStats, args.full);
3654
+ ingestWorkflowResults(db, join8(archiveRoot, source.label), source.label, excludedDirs, now2, wfStats, args.full);
3655
+ ingestSubagentMeta(db, join8(archiveRoot, source.label), source.label, excludedDirs, now2, metaStats, args.full);
3656
+ ingestToolResults(db, join8(archiveRoot, source.label), excludedDirs, now2, trStats, args.full);
3171
3657
  }
3172
3658
  const expanded = rebuildDerived(db, args.full ? null : dirty);
3173
3659
  const classified = classify(db, args.full ? null : expanded);
3660
+ const detected = detect(db, args.full ? null : expanded);
3174
3661
  const count = (sql) => db.prepare(sql).get().n;
3175
3662
  const sessions = count("SELECT COUNT(*) n FROM sessions");
3176
3663
  const turns = count("SELECT COUNT(*) n FROM turns");
@@ -3186,12 +3673,16 @@ function runIngest(argv = process.argv.slice(2)) {
3186
3673
  const wfRuns = count("SELECT COUNT(*) n FROM workflow_results");
3187
3674
  const metaRows = count("SELECT COUNT(*) n FROM session_meta");
3188
3675
  const trRows = count("SELECT COUNT(*) n FROM tool_results");
3676
+ const sevCounts = db.prepare("SELECT severity, COUNT(*) n FROM findings GROUP BY severity").all();
3677
+ const sevMap = Object.fromEntries(sevCounts.map((r) => [r.severity, r.n]));
3678
+ const sevLine = ["critical", "high", "medium", "low", "info"].map((s) => `${s}=${sevMap[s] ?? 0}`).join(" ");
3189
3679
  db.close();
3190
3680
  console.log(`agent-lens-ingest: files=${stats.files} skipped=${stats.skipped} new_events=${stats.newEvents} malformed=${stats.malformed}${pruned ? ` excluded_pruned=${pruned}` : ""}
3191
3681
  sessions=${sessions} turns=${turns} events=${events} tool_calls=${tools} classified=${classified.count}
3192
3682
  workflow_results=${wfRuns} (sidecar upserted=${wfStats.upserted} skipped=${wfStats.skipped} malformed=${wfStats.malformed})
3193
3683
  session_meta=${metaRows} (upserted=${metaStats.upserted} skipped=${metaStats.skipped} malformed=${metaStats.malformed})
3194
3684
  tool_results=${trRows} (upserted=${trStats.upserted} skipped=${trStats.skipped} malformed=${trStats.malformed})
3685
+ findings=${detected.count} (${sevLine})
3195
3686
  tokens=${totalTokens.toLocaleString()} est_cost=$${cost.toFixed(2)} db=${dbPath}`);
3196
3687
  }
3197
3688
  function runMetrics(argv = process.argv.slice(2)) {
@@ -3207,8 +3698,9 @@ function runMetrics(argv = process.argv.slice(2)) {
3207
3698
  }
3208
3699
  const db = openDb(dbPath);
3209
3700
  const r = classify(db);
3701
+ const d = detect(db);
3210
3702
  db.close();
3211
- console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} db=${dbPath}`);
3703
+ console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} findings=${d.count} detector_version=${DETECTOR_VERSION} db=${dbPath}`);
3212
3704
  }
3213
3705
 
3214
3706
  // ../server/dist/run.js
@@ -3281,9 +3773,9 @@ function listSessions(db, f) {
3281
3773
  if (f.project)
3282
3774
  where.push("s.project_id = ?"), params.push(f.project);
3283
3775
  if (f.from)
3284
- where.push("s.started_at >= ?"), params.push(f.from);
3776
+ where.push("date(s.started_at) >= date(?)"), params.push(f.from);
3285
3777
  if (f.to)
3286
- where.push("s.started_at <= ?"), params.push(f.to);
3778
+ where.push("date(s.started_at) <= date(?)"), params.push(f.to);
3287
3779
  if (f.kind === "main")
3288
3780
  where.push("s.is_sidechain = 0");
3289
3781
  if (f.kind === "subagent")
@@ -3390,6 +3882,33 @@ function getSession(db, id) {
3390
3882
  t.full_result = { text: full.text, bytes: full.bytes };
3391
3883
  }
3392
3884
  }
3885
+ const sessionFindings = [];
3886
+ if (tableExists(db, "findings")) {
3887
+ const findingRows = db.prepare(`SELECT id, tool_call_id, event_uuid, turn_id, rule_id, category, framework_ref, severity, title, evidence, signals_json
3888
+ FROM findings WHERE session_id = ?`).all(id);
3889
+ const byToolCall = /* @__PURE__ */ new Map();
3890
+ for (const fr of findingRows) {
3891
+ const f = {
3892
+ id: fr.id,
3893
+ tool_call_id: fr.tool_call_id,
3894
+ event_uuid: fr.event_uuid,
3895
+ turn_id: fr.turn_id,
3896
+ rule_id: fr.rule_id,
3897
+ category: fr.category,
3898
+ framework_ref: fr.framework_ref,
3899
+ severity: fr.severity,
3900
+ title: fr.title,
3901
+ evidence: fr.evidence,
3902
+ signals: safeJson(fr.signals_json)
3903
+ };
3904
+ sessionFindings.push(f);
3905
+ if (fr.tool_call_id)
3906
+ (byToolCall.get(fr.tool_call_id) ?? byToolCall.set(fr.tool_call_id, []).get(fr.tool_call_id)).push(f);
3907
+ }
3908
+ for (const t of toolRows)
3909
+ t.findings = byToolCall.get(t.id) ?? [];
3910
+ sessionFindings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
3911
+ }
3393
3912
  const toolsByEvent = /* @__PURE__ */ new Map();
3394
3913
  for (const t of toolRows) {
3395
3914
  if (!t.event_uuid)
@@ -3478,7 +3997,7 @@ function getSession(db, id) {
3478
3997
  FROM tool_calls tc LEFT JOIN turns t ON t.id = tc.turn_id
3479
3998
  WHERE tc.session_id = ? AND tc.workflow_run_id IS NOT NULL
3480
3999
  ORDER BY t.seq`).all(id);
3481
- return { session, turns, events, classification, parent, children, workflow_runs };
4000
+ return { session, turns, events, classification, parent, children, workflow_runs, findings: sessionFindings };
3482
4001
  }
3483
4002
  function xmlTag(text, tag) {
3484
4003
  return text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim() ?? null;
@@ -3634,6 +4153,101 @@ function getSkill(db, name) {
3634
4153
  const call_count = sessions.reduce((a, s) => a + s.fire_count, 0);
3635
4154
  return { name, versions, sessions, call_count };
3636
4155
  }
4156
+ var SEVERITY_RANK_SQL = "CASE f.severity WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END";
4157
+ var MUTED_SQL = `EXISTS (SELECT 1 FROM triage.muted_rules m WHERE m.rule_id = f.rule_id
4158
+ AND (m.scope='global' OR (m.scope='project' AND m.scope_id = s.project_id) OR (m.scope='source' AND m.scope_id = s.source_id)))`;
4159
+ function triageAttached(db) {
4160
+ return db.prepare("PRAGMA database_list").all().some((r) => r.name === "triage");
4161
+ }
4162
+ function findingWhere(f) {
4163
+ const sql = [];
4164
+ const params = [];
4165
+ if (f.severity)
4166
+ sql.push("f.severity = ?"), params.push(f.severity);
4167
+ if (f.category)
4168
+ sql.push("f.category = ?"), params.push(f.category);
4169
+ if (f.rule)
4170
+ sql.push("f.rule_id = ?"), params.push(f.rule);
4171
+ if (f.session)
4172
+ sql.push("f.session_id = ?"), params.push(f.session);
4173
+ if (f.source)
4174
+ sql.push("s.source_id = ?"), params.push(f.source);
4175
+ if (f.project)
4176
+ sql.push("s.project_id = ?"), params.push(f.project);
4177
+ if (f.from)
4178
+ sql.push("date(s.started_at) >= date(?)"), params.push(f.from);
4179
+ if (f.to)
4180
+ sql.push("date(s.started_at) <= date(?)"), params.push(f.to);
4181
+ return { sql, params };
4182
+ }
4183
+ function statusWhere(status, attached) {
4184
+ if (!attached)
4185
+ return status === "dismissed" || status === "muted" ? "1 = 0" : "";
4186
+ switch (status) {
4187
+ case "dismissed":
4188
+ return "d.finding_id IS NOT NULL";
4189
+ case "muted":
4190
+ return `d.finding_id IS NULL AND ${MUTED_SQL}`;
4191
+ case "all":
4192
+ return "";
4193
+ default:
4194
+ return `d.finding_id IS NULL AND NOT ${MUTED_SQL}`;
4195
+ }
4196
+ }
4197
+ function listFindings(db, f) {
4198
+ if (!tableExists(db, "findings"))
4199
+ return { total: 0, findings: [] };
4200
+ const attached = triageAttached(db);
4201
+ const w = findingWhere(f);
4202
+ const st = statusWhere(f.status ?? "open", attached);
4203
+ const where = [...w.sql, ...st ? [st] : []];
4204
+ const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
4205
+ const dismissJoin = attached ? "LEFT JOIN triage.dismissed_findings d ON d.finding_id = f.id" : "";
4206
+ const triageCols = attached ? `, (d.finding_id IS NOT NULL) AS dismissed, d.note AS dismiss_note, d.dismissed_at, ${MUTED_SQL} AS muted` : `, 0 AS dismissed, NULL AS dismiss_note, NULL AS dismissed_at, 0 AS muted`;
4207
+ const total = db.prepare(`SELECT COUNT(*) n FROM findings f JOIN sessions s ON s.id = f.session_id ${dismissJoin} ${whereSql}`).get(...w.params).n;
4208
+ const ORDER = {
4209
+ severity: SEVERITY_RANK_SQL,
4210
+ session: "f.session_id",
4211
+ rule: "f.rule_id",
4212
+ category: "f.category",
4213
+ time: "s.started_at"
4214
+ };
4215
+ const orderCol = ORDER[f.sort ?? "severity"] ?? SEVERITY_RANK_SQL;
4216
+ const dir = f.dir === "asc" ? "ASC" : "DESC";
4217
+ const findings = db.prepare(`SELECT f.id, f.session_id, f.tool_call_id, f.event_uuid, f.turn_id, f.rule_id, f.category,
4218
+ f.framework_ref, f.severity, f.title, f.evidence, tc.tool_name,
4219
+ COALESCE(s.ai_title, s.slug) AS session_title, s.source_id, s.is_sidechain,
4220
+ s.started_at, p.path AS project_path, s.project_id ${triageCols}
4221
+ FROM findings f
4222
+ JOIN sessions s ON s.id = f.session_id
4223
+ LEFT JOIN projects p ON p.id = s.project_id
4224
+ LEFT JOIN tool_calls tc ON tc.id = f.tool_call_id
4225
+ ${dismissJoin}
4226
+ ${whereSql}
4227
+ ORDER BY ${orderCol} ${dir}, ${SEVERITY_RANK_SQL} DESC, f.id ASC
4228
+ LIMIT ? OFFSET ?`).all(...w.params, f.limit, f.offset);
4229
+ return { total, findings };
4230
+ }
4231
+ function securitySummary(db) {
4232
+ const categories = SECURITY_CATEGORIES;
4233
+ if (!tableExists(db, "findings")) {
4234
+ return { total: 0, sessions_flagged: 0, dismissed: 0, muted: 0, by_severity: [], by_category: [], by_rule: [], categories };
4235
+ }
4236
+ const attached = triageAttached(db);
4237
+ const dismissJoin = attached ? "LEFT JOIN triage.dismissed_findings d ON d.finding_id = f.id" : "";
4238
+ const base = `FROM findings f JOIN sessions s ON s.id = f.session_id ${dismissJoin}`;
4239
+ const openWhere = attached ? `WHERE d.finding_id IS NULL AND NOT ${MUTED_SQL}` : "";
4240
+ const scalar = (sql) => db.prepare(sql).get().n;
4241
+ const total = scalar(`SELECT COUNT(*) n ${base} ${openWhere}`);
4242
+ const sessions_flagged = scalar(`SELECT COUNT(DISTINCT f.session_id) n ${base} ${openWhere}`);
4243
+ const dismissed = attached ? scalar(`SELECT COUNT(*) n FROM findings f LEFT JOIN triage.dismissed_findings d ON d.finding_id = f.id WHERE d.finding_id IS NOT NULL`) : 0;
4244
+ const muted = attached ? scalar(`SELECT COUNT(*) n ${base} WHERE d.finding_id IS NULL AND ${MUTED_SQL}`) : 0;
4245
+ const by_severity = db.prepare(`SELECT f.severity AS severity, COUNT(*) n ${base} ${openWhere} GROUP BY f.severity ORDER BY ${SEVERITY_RANK_SQL} DESC`).all();
4246
+ const by_category = db.prepare(`SELECT f.category AS category, COUNT(*) n ${base} ${openWhere} GROUP BY f.category ORDER BY n DESC`).all();
4247
+ const by_rule = db.prepare(`SELECT f.rule_id AS rule_id, f.category AS category, f.title AS title, COUNT(*) n, MAX(${SEVERITY_RANK_SQL}) AS rank
4248
+ ${base} ${openWhere} GROUP BY f.rule_id ORDER BY rank DESC, n DESC`).all();
4249
+ return { total, sessions_flagged, dismissed, muted, by_severity, by_category, by_rule, categories };
4250
+ }
3637
4251
 
3638
4252
  // ../server/dist/app.js
3639
4253
  import { existsSync as existsSync6 } from "fs";
@@ -3647,9 +4261,9 @@ function sessionWhere(f) {
3647
4261
  if (f.source)
3648
4262
  where.push("s.source_id = ?"), params.push(f.source);
3649
4263
  if (f.from)
3650
- where.push("s.started_at >= ?"), params.push(f.from);
4264
+ where.push("date(s.started_at) >= date(?)"), params.push(f.from);
3651
4265
  if (f.to)
3652
- where.push("s.started_at <= ?"), params.push(f.to);
4266
+ where.push("date(s.started_at) <= date(?)"), params.push(f.to);
3653
4267
  return { sql: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
3654
4268
  }
3655
4269
  var zeroSplit = () => ({ input: 0, output: 0, cache_creation: 0, cache_read: 0 });
@@ -3839,9 +4453,93 @@ function runRefresh() {
3839
4453
  }
3840
4454
  }
3841
4455
 
4456
+ // ../server/dist/triage.js
4457
+ import Database3 from "better-sqlite3";
4458
+ var TRIAGE_SCHEMA_SQL = (
4459
+ /* sql */
4460
+ `
4461
+ PRAGMA journal_mode = WAL;
4462
+
4463
+ -- Findings the user reviewed and marked safe. Keyed by the stable finding id so it survives re-ingest.
4464
+ CREATE TABLE IF NOT EXISTS dismissed_findings (
4465
+ finding_id TEXT PRIMARY KEY, -- findings.id = sha1(tool_call_id, rule_id)
4466
+ tool_call_id TEXT, -- reference only (may be null)
4467
+ rule_id TEXT, -- reference only (may be null)
4468
+ note TEXT,
4469
+ dismissed_at TEXT NOT NULL
4470
+ );
4471
+
4472
+ -- Rules the user muted (suppressed as noise), optionally scoped to a project or source. A muted rule
4473
+ -- hides its current AND future findings from the open view \u2014 the scalable lever for a noisy rule.
4474
+ CREATE TABLE IF NOT EXISTS muted_rules (
4475
+ rule_id TEXT NOT NULL,
4476
+ scope TEXT NOT NULL DEFAULT 'global', -- 'global' | 'project' | 'source'
4477
+ scope_id TEXT NOT NULL DEFAULT '', -- project_id / source_id; '' for global (NOT NULL keeps it in the PK)
4478
+ note TEXT,
4479
+ muted_at TEXT NOT NULL,
4480
+ PRIMARY KEY (rule_id, scope, scope_id)
4481
+ );
4482
+ `
4483
+ );
4484
+ function openTriage(file) {
4485
+ const db = new Database3(file);
4486
+ db.pragma("journal_mode = WAL");
4487
+ db.exec(TRIAGE_SCHEMA_SQL);
4488
+ return db;
4489
+ }
4490
+ var now = () => (/* @__PURE__ */ new Date()).toISOString();
4491
+ function dismiss(db, ids, note) {
4492
+ const stmt = db.prepare(`INSERT INTO dismissed_findings (finding_id, note, dismissed_at) VALUES (?, ?, ?)
4493
+ ON CONFLICT(finding_id) DO UPDATE SET note = excluded.note, dismissed_at = excluded.dismissed_at`);
4494
+ const ts = now();
4495
+ const tx = db.transaction((list) => {
4496
+ for (const id of list)
4497
+ stmt.run(id, note ?? null, ts);
4498
+ });
4499
+ tx(ids);
4500
+ return ids.length;
4501
+ }
4502
+ function reopen(db, ids) {
4503
+ const stmt = db.prepare("DELETE FROM dismissed_findings WHERE finding_id = ?");
4504
+ let n = 0;
4505
+ const tx = db.transaction((list) => {
4506
+ for (const id of list)
4507
+ n += stmt.run(id).changes;
4508
+ });
4509
+ tx(ids);
4510
+ return n;
4511
+ }
4512
+ function muteRule(db, ruleId, scope = "global", scopeId = "", note) {
4513
+ db.prepare(`INSERT INTO muted_rules (rule_id, scope, scope_id, note, muted_at) VALUES (?, ?, ?, ?, ?)
4514
+ ON CONFLICT(rule_id, scope, scope_id) DO UPDATE SET note = excluded.note, muted_at = excluded.muted_at`).run(ruleId, scope, scope === "global" ? "" : scopeId, note ?? null, now());
4515
+ }
4516
+ function unmute(db, ruleId, scope = "global", scopeId = "") {
4517
+ return db.prepare("DELETE FROM muted_rules WHERE rule_id = ? AND scope = ? AND scope_id = ?").run(ruleId, scope, scope === "global" ? "" : scopeId).changes;
4518
+ }
4519
+ function listMutes(db) {
4520
+ return db.prepare("SELECT rule_id, scope, scope_id, note, muted_at FROM muted_rules ORDER BY muted_at DESC").all();
4521
+ }
4522
+
3842
4523
  // ../server/dist/app.js
3843
4524
  async function createApp(db, opts = {}) {
3844
4525
  const app = Fastify({ logger: false });
4526
+ let triageDb = null;
4527
+ if (opts.triageDbPath) {
4528
+ triageDb = openTriage(opts.triageDbPath);
4529
+ db.exec(`ATTACH DATABASE '${opts.triageDbPath.replace(/'/g, "''")}' AS triage`);
4530
+ app.addHook("onClose", async () => triageDb?.close());
4531
+ }
4532
+ const guardWrite = (req, reply) => {
4533
+ if (!originAllowed(req.headers.origin)) {
4534
+ reply.code(403).send({ error: { code: "FORBIDDEN_ORIGIN", message: "cross-origin write blocked" } });
4535
+ return false;
4536
+ }
4537
+ if (!triageDb) {
4538
+ reply.code(503).send({ error: { code: "TRIAGE_UNAVAILABLE", message: "triage store not configured" } });
4539
+ return false;
4540
+ }
4541
+ return true;
4542
+ };
3845
4543
  app.get("/api/health", async () => {
3846
4544
  const s = schemaStatus(db);
3847
4545
  return { ok: true, last_ingested: lastIngested(db), schema_version: s.db_version, schema_stale: s.stale };
@@ -3904,6 +4602,72 @@ async function createApp(db, opts = {}) {
3904
4602
  return reply.code(404).send({ error: "not found" });
3905
4603
  return result;
3906
4604
  });
4605
+ app.get("/api/security/summary", async () => securitySummary(db));
4606
+ const findingFilters = (q) => ({
4607
+ severity: q.severity,
4608
+ category: q.category,
4609
+ rule: q.rule,
4610
+ session: q.session,
4611
+ source: q.source,
4612
+ project: q.project,
4613
+ from: q.from,
4614
+ to: q.to,
4615
+ status: ["open", "dismissed", "muted", "all"].find((s) => s === q.status)
4616
+ });
4617
+ app.get("/api/security/findings", async (req) => {
4618
+ const q = req.query;
4619
+ return listFindings(db, {
4620
+ ...findingFilters(q),
4621
+ sort: ["severity", "session", "rule", "category", "time"].find((s) => s === q.sort),
4622
+ dir: q.dir === "asc" ? "asc" : q.dir === "desc" ? "desc" : void 0,
4623
+ limit: Math.min(Number(q.limit) || 50, 5e3),
4624
+ offset: Number(q.offset) || 0
4625
+ });
4626
+ });
4627
+ app.get("/api/security/mutes", async () => triageDb ? listMutes(triageDb) : []);
4628
+ app.post("/api/security/dismiss", async (req, reply) => {
4629
+ if (!guardWrite(req, reply))
4630
+ return reply;
4631
+ const b = req.body ?? {};
4632
+ const n = dismiss(triageDb, Array.isArray(b.ids) ? b.ids : [], b.note ?? null);
4633
+ return { ok: true, dismissed: n };
4634
+ });
4635
+ app.post("/api/security/reopen", async (req, reply) => {
4636
+ if (!guardWrite(req, reply))
4637
+ return reply;
4638
+ const b = req.body ?? {};
4639
+ const n = reopen(triageDb, Array.isArray(b.ids) ? b.ids : []);
4640
+ return { ok: true, reopened: n };
4641
+ });
4642
+ app.post("/api/security/dismiss-matching", async (req, reply) => {
4643
+ if (!guardWrite(req, reply))
4644
+ return reply;
4645
+ const b = req.body ?? {};
4646
+ const page = listFindings(db, { ...findingFilters(b.filter ?? {}), status: "open", limit: 1e5, offset: 0 });
4647
+ const ids = page.findings.map((r) => r.id);
4648
+ const n = dismiss(triageDb, ids, b.note ?? null);
4649
+ return { ok: true, dismissed: n };
4650
+ });
4651
+ app.post("/api/security/mute", async (req, reply) => {
4652
+ if (!guardWrite(req, reply))
4653
+ return reply;
4654
+ const b = req.body ?? {};
4655
+ if (!b.rule_id)
4656
+ return reply.code(400).send({ error: { code: "BAD_REQUEST", message: "rule_id required" } });
4657
+ const scope = ["global", "project", "source"].find((s) => s === b.scope) ?? "global";
4658
+ muteRule(triageDb, b.rule_id, scope, b.scope_id ?? "", b.note ?? null);
4659
+ return { ok: true };
4660
+ });
4661
+ app.post("/api/security/unmute", async (req, reply) => {
4662
+ if (!guardWrite(req, reply))
4663
+ return reply;
4664
+ const b = req.body ?? {};
4665
+ if (!b.rule_id)
4666
+ return reply.code(400).send({ error: { code: "BAD_REQUEST", message: "rule_id required" } });
4667
+ const scope = ["global", "project", "source"].find((s) => s === b.scope) ?? "global";
4668
+ const n = unmute(triageDb, b.rule_id, scope, b.scope_id ?? "");
4669
+ return { ok: true, unmuted: n };
4670
+ });
3907
4671
  app.get("/api/skills", async (req) => {
3908
4672
  const q = req.query;
3909
4673
  return listSkills(db, { q: q.q, source: q.source, project: q.project });
@@ -3973,8 +4737,9 @@ async function startServer() {
3973
4737
  console.error(`agent-lens-server: db not found: ${dbPath} (run ingest first)`);
3974
4738
  process.exit(1);
3975
4739
  }
4740
+ const triageDbPath = process.env.AGENT_LENS_TRIAGE_DB || join10(dirname5(dbPath), "triage.db");
3976
4741
  const db = openReadonly(dbPath);
3977
- const app = await createApp(db, { webDist });
4742
+ const app = await createApp(db, { webDist, triageDbPath });
3978
4743
  try {
3979
4744
  await app.listen({ host, port });
3980
4745
  console.log(`agent-lens-server: http://${host}:${port} (db: ${dbPath})`);