@swestash/agent-lens 0.3.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;
@@ -1760,6 +1838,8 @@ function flattenText(content) {
1760
1838
  const text = parts.join("\n").trim();
1761
1839
  return text || null;
1762
1840
  }
1841
+ var RESULT_SUMMARY_MAX_LINES = 40;
1842
+ var RESULT_SUMMARY_MAX_CHARS = 2e3;
1763
1843
  function summarizeResult(content) {
1764
1844
  let s = null;
1765
1845
  if (typeof content === "string")
@@ -1777,8 +1857,24 @@ function summarizeResult(content) {
1777
1857
  }
1778
1858
  if (!s)
1779
1859
  return null;
1780
- s = s.replace(/\s+/g, " ").trim();
1781
- return s.slice(0, 280) || null;
1860
+ s = s.replace(/\r\n/g, "\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
1861
+ if (!s)
1862
+ return null;
1863
+ let truncated = false;
1864
+ const lines = s.split("\n");
1865
+ if (lines.length > RESULT_SUMMARY_MAX_LINES) {
1866
+ s = lines.slice(0, RESULT_SUMMARY_MAX_LINES).join("\n");
1867
+ truncated = true;
1868
+ }
1869
+ if (s.length > RESULT_SUMMARY_MAX_CHARS) {
1870
+ s = s.slice(0, RESULT_SUMMARY_MAX_CHARS);
1871
+ const nl = s.lastIndexOf("\n");
1872
+ if (nl > RESULT_SUMMARY_MAX_CHARS - 200)
1873
+ s = s.slice(0, nl);
1874
+ truncated = true;
1875
+ }
1876
+ s = s.trimEnd();
1877
+ return (truncated ? s + "\n\u2026 (truncated)" : s) || null;
1782
1878
  }
1783
1879
  function asString(v) {
1784
1880
  return typeof v === "string" && v.length > 0 ? v : null;
@@ -2221,8 +2317,414 @@ function classify(db, dirty) {
2221
2317
  return { count: sessions.length, version: CLASSIFIER_VERSION };
2222
2318
  }
2223
2319
 
2224
- // ../ingest/dist/pipeline.js
2320
+ // ../ingest/dist/detect.js
2225
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";
2226
2728
  function newStats() {
2227
2729
  return { files: 0, skipped: 0, malformed: 0, newEvents: 0 };
2228
2730
  }
@@ -2260,7 +2762,7 @@ function skillSummary(body) {
2260
2762
  return null;
2261
2763
  }
2262
2764
  function skillVersionId(name, body) {
2263
- 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);
2264
2766
  }
2265
2767
  function pruneExcluded(db, excludedPaths) {
2266
2768
  if (!excludedPaths.length)
@@ -2294,6 +2796,7 @@ function pruneExcluded(db, excludedPaths) {
2294
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);
2295
2797
  db.pragma("foreign_keys = OFF");
2296
2798
  db.transaction(() => {
2799
+ db.exec("DELETE FROM findings WHERE session_id IN (SELECT id FROM _prune)");
2297
2800
  for (const t of ["token_usage", "tool_calls", "turns", "events"])
2298
2801
  db.exec(`DELETE FROM ${t} WHERE session_id IN (SELECT id FROM _prune)`);
2299
2802
  db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id IN (SELECT id FROM _prune)");
@@ -2526,6 +3029,7 @@ function rebuildDerived(db, dirty) {
2526
3029
  `);
2527
3030
  db.exec(`DELETE FROM sessions WHERE event_count = 0${andId}`);
2528
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)");
2529
3033
  if (!incremental) {
2530
3034
  db.exec("DELETE FROM skills WHERE id NOT IN (SELECT skill_id FROM tool_calls WHERE skill_id IS NOT NULL)");
2531
3035
  }
@@ -2591,7 +3095,7 @@ function prepareStatements(db) {
2591
3095
  workflow_run_id = COALESCE(excluded.workflow_run_id, workflow_run_id)`)
2592
3096
  };
2593
3097
  }
2594
- function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
3098
+ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
2595
3099
  const sessionMeta = {};
2596
3100
  let eventsInFile = 0;
2597
3101
  const tx = db.transaction(() => {
@@ -2640,13 +3144,13 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
2640
3144
  }
2641
3145
  let projectId = null;
2642
3146
  if (sessionMeta.cwd) {
2643
- 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);
2644
3148
  stmts.upsertProject.run({
2645
3149
  id: projectId,
2646
3150
  agent_id: adapter.agentId,
2647
3151
  path: sessionMeta.cwd,
2648
3152
  encoded_dir: file.encodedDir || null,
2649
- now
3153
+ now: now2
2650
3154
  });
2651
3155
  }
2652
3156
  stmts.upsertSession.run({
@@ -2668,7 +3172,7 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
2668
3172
  mtime_ms: Math.trunc(meta.mtimeMs),
2669
3173
  sha256: meta.hash,
2670
3174
  events_ingested: eventsInFile,
2671
- ingested_at: now
3175
+ ingested_at: now2
2672
3176
  });
2673
3177
  });
2674
3178
  tx();
@@ -2679,7 +3183,7 @@ import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as
2679
3183
  import { basename as basename3, join as join5 } from "path";
2680
3184
 
2681
3185
  // ../ingest/dist/redact.js
2682
- import { createHash as createHash2, randomBytes } from "crypto";
3186
+ import { createHash as createHash3, randomBytes } from "crypto";
2683
3187
  function parseExcludes(csv) {
2684
3188
  return (csv ?? "").split(",").map((s) => s.trim()).filter(Boolean).map(encodeProjectPath);
2685
3189
  }
@@ -2688,17 +3192,17 @@ function isExcludedArchivePath(filePath, excluded) {
2688
3192
  }
2689
3193
 
2690
3194
  // ../ingest/dist/fileread.js
2691
- import { createHash as createHash3 } from "crypto";
3195
+ import { createHash as createHash4 } from "crypto";
2692
3196
  import { openSync as openSync2, readSync as readSync2, closeSync as closeSync2 } from "fs";
2693
3197
  import { StringDecoder } from "string_decoder";
2694
3198
  var STREAM_THRESHOLD = 8 * 1024 * 1024;
2695
3199
  var CHUNK2 = 64 * 1024;
2696
3200
  function sha256(buf) {
2697
- return createHash3("sha256").update(buf).digest("hex");
3201
+ return createHash4("sha256").update(buf).digest("hex");
2698
3202
  }
2699
3203
  function sha256File(path) {
2700
3204
  const fd = openSync2(path, "r");
2701
- const h = createHash3("sha256");
3205
+ const h = createHash4("sha256");
2702
3206
  const buf = Buffer.allocUnsafe(CHUNK2);
2703
3207
  try {
2704
3208
  let n;
@@ -2785,7 +3289,7 @@ function cleanStr(v) {
2785
3289
  return null;
2786
3290
  return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\[[0-9;]+m\]?/g, "").trim() || null;
2787
3291
  }
2788
- function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now, stats, full) {
3292
+ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now2, stats, full) {
2789
3293
  let paths = discoverSidecars(sourceArchiveDir);
2790
3294
  if (excludedDirs.length)
2791
3295
  paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
@@ -2828,7 +3332,7 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
2828
3332
  }
2829
3333
  const hash = sha256(buf);
2830
3334
  if (prev && prev.sha256 === hash) {
2831
- 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 });
2832
3336
  stats.skipped++;
2833
3337
  continue;
2834
3338
  }
@@ -2866,9 +3370,9 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
2866
3370
  duration_ms: durationMs2,
2867
3371
  started_at: startedAt,
2868
3372
  ended_at: endedAt,
2869
- ingested_at: now
3373
+ ingested_at: now2
2870
3374
  });
2871
- 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 });
2872
3376
  stats.upserted++;
2873
3377
  }
2874
3378
  }
@@ -2907,7 +3411,7 @@ function discoverMeta(sourceArchiveDir) {
2907
3411
  }
2908
3412
  var intOrNull2 = (v) => typeof v === "number" && Number.isFinite(v) ? Math.trunc(v) : null;
2909
3413
  var strOrNull2 = (v) => typeof v === "string" && v ? v : null;
2910
- function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, stats, full) {
3414
+ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2, stats, full) {
2911
3415
  let paths = discoverMeta(sourceArchiveDir);
2912
3416
  if (excludedDirs.length)
2913
3417
  paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
@@ -2942,7 +3446,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, s
2942
3446
  }
2943
3447
  const hash = sha256(buf);
2944
3448
  if (prev && prev.sha256 === hash) {
2945
- 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 });
2946
3450
  stats.skipped++;
2947
3451
  continue;
2948
3452
  }
@@ -2965,9 +3469,9 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, s
2965
3469
  agent_description: strOrNull2(d.description),
2966
3470
  spawn_depth: intOrNull2(d.spawnDepth),
2967
3471
  tool_use_id: strOrNull2(d.toolUseId),
2968
- ingested_at: now
3472
+ ingested_at: now2
2969
3473
  });
2970
- 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 });
2971
3475
  stats.upserted++;
2972
3476
  }
2973
3477
  }
@@ -3009,7 +3513,7 @@ function sessionFromPath2(path) {
3009
3513
  const i = parts.indexOf("tool-results");
3010
3514
  return i > 0 ? parts[i - 1] : null;
3011
3515
  }
3012
- function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full) {
3516
+ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now2, stats, full) {
3013
3517
  let paths = discoverResults(sourceArchiveDir);
3014
3518
  if (excludedDirs.length)
3015
3519
  paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
@@ -3043,7 +3547,7 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full)
3043
3547
  }
3044
3548
  const hash = sha256(buf);
3045
3549
  if (prev && prev.sha256 === hash) {
3046
- 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 });
3047
3551
  stats.skipped++;
3048
3552
  continue;
3049
3553
  }
@@ -3053,9 +3557,9 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full)
3053
3557
  path,
3054
3558
  bytes: st.size,
3055
3559
  text: buf.toString("utf8"),
3056
- ingested_at: now
3560
+ ingested_at: now2
3057
3561
  });
3058
- 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 });
3059
3563
  stats.upserted++;
3060
3564
  }
3061
3565
  }
@@ -3097,7 +3601,7 @@ function runIngest(argv = process.argv.slice(2)) {
3097
3601
  const adapterList = [new ClaudeCodeAdapter()];
3098
3602
  const adapterById = new Map(adapterList.map((a) => [a.agentId, a]));
3099
3603
  const sources = loadSources();
3100
- const now = (/* @__PURE__ */ new Date()).toISOString();
3604
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
3101
3605
  const excludePaths = loadExcludes();
3102
3606
  const excludedDirs = parseExcludes(excludePaths.join(","));
3103
3607
  const pruned = pruneExcluded(db, excludePaths);
@@ -3138,21 +3642,22 @@ function runIngest(argv = process.argv.slice(2)) {
3138
3642
  mtime_ms: mtimeMs,
3139
3643
  sha256: hash,
3140
3644
  events_ingested: prev.events_ingested,
3141
- ingested_at: now
3645
+ ingested_at: now2
3142
3646
  });
3143
3647
  stats.skipped++;
3144
3648
  continue;
3145
3649
  }
3146
3650
  const lines = small ? buf.toString("utf8").split("\n") : streamLines(file.path);
3147
- 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);
3148
3652
  dirty.add(file.sessionId);
3149
3653
  }
3150
- ingestWorkflowResults(db, join8(archiveRoot, source.label), source.label, excludedDirs, now, wfStats, args.full);
3151
- ingestSubagentMeta(db, join8(archiveRoot, source.label), source.label, excludedDirs, now, metaStats, args.full);
3152
- 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);
3153
3657
  }
3154
3658
  const expanded = rebuildDerived(db, args.full ? null : dirty);
3155
3659
  const classified = classify(db, args.full ? null : expanded);
3660
+ const detected = detect(db, args.full ? null : expanded);
3156
3661
  const count = (sql) => db.prepare(sql).get().n;
3157
3662
  const sessions = count("SELECT COUNT(*) n FROM sessions");
3158
3663
  const turns = count("SELECT COUNT(*) n FROM turns");
@@ -3168,12 +3673,16 @@ function runIngest(argv = process.argv.slice(2)) {
3168
3673
  const wfRuns = count("SELECT COUNT(*) n FROM workflow_results");
3169
3674
  const metaRows = count("SELECT COUNT(*) n FROM session_meta");
3170
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(" ");
3171
3679
  db.close();
3172
3680
  console.log(`agent-lens-ingest: files=${stats.files} skipped=${stats.skipped} new_events=${stats.newEvents} malformed=${stats.malformed}${pruned ? ` excluded_pruned=${pruned}` : ""}
3173
3681
  sessions=${sessions} turns=${turns} events=${events} tool_calls=${tools} classified=${classified.count}
3174
3682
  workflow_results=${wfRuns} (sidecar upserted=${wfStats.upserted} skipped=${wfStats.skipped} malformed=${wfStats.malformed})
3175
3683
  session_meta=${metaRows} (upserted=${metaStats.upserted} skipped=${metaStats.skipped} malformed=${metaStats.malformed})
3176
3684
  tool_results=${trRows} (upserted=${trStats.upserted} skipped=${trStats.skipped} malformed=${trStats.malformed})
3685
+ findings=${detected.count} (${sevLine})
3177
3686
  tokens=${totalTokens.toLocaleString()} est_cost=$${cost.toFixed(2)} db=${dbPath}`);
3178
3687
  }
3179
3688
  function runMetrics(argv = process.argv.slice(2)) {
@@ -3189,8 +3698,9 @@ function runMetrics(argv = process.argv.slice(2)) {
3189
3698
  }
3190
3699
  const db = openDb(dbPath);
3191
3700
  const r = classify(db);
3701
+ const d = detect(db);
3192
3702
  db.close();
3193
- 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}`);
3194
3704
  }
3195
3705
 
3196
3706
  // ../server/dist/run.js
@@ -3263,9 +3773,9 @@ function listSessions(db, f) {
3263
3773
  if (f.project)
3264
3774
  where.push("s.project_id = ?"), params.push(f.project);
3265
3775
  if (f.from)
3266
- where.push("s.started_at >= ?"), params.push(f.from);
3776
+ where.push("date(s.started_at) >= date(?)"), params.push(f.from);
3267
3777
  if (f.to)
3268
- where.push("s.started_at <= ?"), params.push(f.to);
3778
+ where.push("date(s.started_at) <= date(?)"), params.push(f.to);
3269
3779
  if (f.kind === "main")
3270
3780
  where.push("s.is_sidechain = 0");
3271
3781
  if (f.kind === "subagent")
@@ -3372,6 +3882,33 @@ function getSession(db, id) {
3372
3882
  t.full_result = { text: full.text, bytes: full.bytes };
3373
3883
  }
3374
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
+ }
3375
3912
  const toolsByEvent = /* @__PURE__ */ new Map();
3376
3913
  for (const t of toolRows) {
3377
3914
  if (!t.event_uuid)
@@ -3460,7 +3997,7 @@ function getSession(db, id) {
3460
3997
  FROM tool_calls tc LEFT JOIN turns t ON t.id = tc.turn_id
3461
3998
  WHERE tc.session_id = ? AND tc.workflow_run_id IS NOT NULL
3462
3999
  ORDER BY t.seq`).all(id);
3463
- return { session, turns, events, classification, parent, children, workflow_runs };
4000
+ return { session, turns, events, classification, parent, children, workflow_runs, findings: sessionFindings };
3464
4001
  }
3465
4002
  function xmlTag(text, tag) {
3466
4003
  return text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim() ?? null;
@@ -3616,6 +4153,101 @@ function getSkill(db, name) {
3616
4153
  const call_count = sessions.reduce((a, s) => a + s.fire_count, 0);
3617
4154
  return { name, versions, sessions, call_count };
3618
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
+ }
3619
4251
 
3620
4252
  // ../server/dist/app.js
3621
4253
  import { existsSync as existsSync6 } from "fs";
@@ -3629,9 +4261,9 @@ function sessionWhere(f) {
3629
4261
  if (f.source)
3630
4262
  where.push("s.source_id = ?"), params.push(f.source);
3631
4263
  if (f.from)
3632
- where.push("s.started_at >= ?"), params.push(f.from);
4264
+ where.push("date(s.started_at) >= date(?)"), params.push(f.from);
3633
4265
  if (f.to)
3634
- where.push("s.started_at <= ?"), params.push(f.to);
4266
+ where.push("date(s.started_at) <= date(?)"), params.push(f.to);
3635
4267
  return { sql: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
3636
4268
  }
3637
4269
  var zeroSplit = () => ({ input: 0, output: 0, cache_creation: 0, cache_read: 0 });
@@ -3821,9 +4453,93 @@ function runRefresh() {
3821
4453
  }
3822
4454
  }
3823
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
+
3824
4523
  // ../server/dist/app.js
3825
4524
  async function createApp(db, opts = {}) {
3826
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
+ };
3827
4543
  app.get("/api/health", async () => {
3828
4544
  const s = schemaStatus(db);
3829
4545
  return { ok: true, last_ingested: lastIngested(db), schema_version: s.db_version, schema_stale: s.stale };
@@ -3886,6 +4602,72 @@ async function createApp(db, opts = {}) {
3886
4602
  return reply.code(404).send({ error: "not found" });
3887
4603
  return result;
3888
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
+ });
3889
4671
  app.get("/api/skills", async (req) => {
3890
4672
  const q = req.query;
3891
4673
  return listSkills(db, { q: q.q, source: q.source, project: q.project });
@@ -3955,8 +4737,9 @@ async function startServer() {
3955
4737
  console.error(`agent-lens-server: db not found: ${dbPath} (run ingest first)`);
3956
4738
  process.exit(1);
3957
4739
  }
4740
+ const triageDbPath = process.env.AGENT_LENS_TRIAGE_DB || join10(dirname5(dbPath), "triage.db");
3958
4741
  const db = openReadonly(dbPath);
3959
- const app = await createApp(db, { webDist });
4742
+ const app = await createApp(db, { webDist, triageDbPath });
3960
4743
  try {
3961
4744
  await app.listen({ host, port });
3962
4745
  console.log(`agent-lens-server: http://${host}:${port} (db: ${dbPath})`);