@swestash/agent-lens 0.4.0 → 0.5.1

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,465 @@ 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 = 5;
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)(?![\w-])/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 stripComments = (c) => c.replace(/(^|\s)#[^\n]*/g, "$1");
2345
+ var neutralizeEcho = (c) => c.replace(/((?:^|[;&|\n(])\s*)(?:echo|printf)\b(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^;&|\n])*/gi, "$1echo");
2346
+ var blankHeredoc = (c) => c.replace(/<<-?\s*(['"]?)(\w+)\1[\s\S]*?\n\s*\2\b/g, "<<HEREDOC");
2347
+ var codeOf = (c) => neutralizeEcho(stripComments(blankHeredoc(c)));
2348
+ var blankQuoted = (c) => c.replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/'[^']*'/g, "''");
2349
+ var bareOf = (c) => blankQuoted(codeOf(c));
2350
+ function execGeneratedScript(cmd) {
2351
+ const targets = /* @__PURE__ */ new Set();
2352
+ for (const m of cmd.matchAll(/(?:>>?|\btee\b\s+(?:-a\s+)?)\s*["']?([A-Za-z0-9._/~-]+)/g)) {
2353
+ if (m[1] && !m[1].startsWith("/dev/"))
2354
+ targets.add(m[1]);
2355
+ }
2356
+ for (const t of targets) {
2357
+ const e = t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2358
+ const interp = new RegExp(`\\b(?:sh|bash|zsh|ksh|dash|source)\\s+["']?${e}\\b|(?:^|[\\n;|&(])\\s*\\.\\s+["']?${e}\\b`);
2359
+ const hasPath = /^(\.\/|\/|~\/)/.test(t);
2360
+ const path = hasPath ? new RegExp(`(?:^|[\\n;|&(])\\s*${e}(?:\\s|$|[;&|<>])`) : new RegExp(`(?:^|[\\n;|&(])\\s*\\.\\/${e}\\b`);
2361
+ if (interp.test(cmd) || path.test(cmd))
2362
+ return t;
2363
+ }
2364
+ return null;
2365
+ }
2366
+ var TEMP_PATH = /^(\/tmp\/|\/var\/folders\/|\/private\/var\/folders\/)/;
2367
+ function isAgentOwnedPath(p, configDirs) {
2368
+ if (TEMP_PATH.test(p))
2369
+ return true;
2370
+ if (configDirs.some((d) => p === d || p.startsWith(d + "/")))
2371
+ return true;
2372
+ const tmp = process.env.TMPDIR;
2373
+ return !!tmp && p.startsWith(tmp.replace(/\/$/, "") + "/");
2374
+ }
2375
+ function outsideProject(filePath, projectPath, configDirs) {
2376
+ if (!filePath || !filePath.startsWith("/"))
2377
+ return false;
2378
+ if (isAgentOwnedPath(filePath, configDirs))
2379
+ return false;
2380
+ if (!projectPath)
2381
+ return false;
2382
+ return filePath !== projectPath && !filePath.startsWith(projectPath.replace(/\/$/, "") + "/");
2383
+ }
2384
+ var RULES = [
2385
+ // --- Destructive / data-loss (OWASP ASI02 Tool Misuse) -------------------
2386
+ {
2387
+ id: "destructive.rm_rf",
2388
+ category: "destructive",
2389
+ framework_ref: "OWASP ASI02",
2390
+ title: "Recursive force delete (rm -rf)",
2391
+ tools: ["Bash"],
2392
+ baseSeverity: "high",
2393
+ test(ctx) {
2394
+ const c = ctx.commandBare;
2395
+ if (!c)
2396
+ return null;
2397
+ const rf = /\brm\s+[^|;&\n]*(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|--recursive[^|;&\n]*--force|--force[^|;&\n]*--recursive)/i;
2398
+ if (!rf.test(c))
2399
+ return null;
2400
+ const dangerousTarget = HOME_OR_ROOT_TARGET.test(c) || /\s\/(?:\s|$)/.test(c) || /\brm\b[^|;&\n]*\*/.test(c);
2401
+ return { evidence: evidence(ctx.command), severity: dangerousTarget ? "critical" : "high", mods: { dangerous_target: dangerousTarget } };
2402
+ }
2403
+ },
2404
+ {
2405
+ id: "destructive.git_reset_hard",
2406
+ category: "destructive",
2407
+ framework_ref: "OWASP ASI02",
2408
+ title: "Discards local changes (git reset --hard)",
2409
+ tools: ["Bash"],
2410
+ baseSeverity: "medium",
2411
+ test(ctx) {
2412
+ const c = ctx.commandBare;
2413
+ if (!c || !/\bgit\s+reset\s+--hard\b/i.test(c))
2414
+ return null;
2415
+ return { evidence: evidence(ctx.command) };
2416
+ }
2417
+ },
2418
+ {
2419
+ id: "destructive.git_force_push",
2420
+ category: "destructive",
2421
+ framework_ref: "OWASP ASI02",
2422
+ title: "Force-push rewrites remote history",
2423
+ tools: ["Bash"],
2424
+ baseSeverity: "medium",
2425
+ test(ctx) {
2426
+ const c = ctx.commandBare;
2427
+ if (!c || !/\bgit\s+push\b[^|;&\n]*(--force\b|--force-with-lease\b|\s-f\b)/i.test(c))
2428
+ return null;
2429
+ const mainBranch = /\b(main|master|release)\b/.test(c);
2430
+ return { evidence: evidence(ctx.command), severity: mainBranch ? "high" : "medium", mods: { protected_branch: mainBranch } };
2431
+ }
2432
+ },
2433
+ {
2434
+ id: "destructive.sql_drop",
2435
+ category: "destructive",
2436
+ framework_ref: "OWASP ASI02",
2437
+ title: "Drops or truncates a database object",
2438
+ tools: "*",
2439
+ baseSeverity: "high",
2440
+ test(ctx) {
2441
+ const query = typeof ctx.input?.query === "string" ? ctx.input.query : null;
2442
+ const hay = ctx.commandCode ?? query;
2443
+ if (!hay || !/\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE(\s+TABLE)?)\b/i.test(hay))
2444
+ return null;
2445
+ return { evidence: evidence(ctx.command ?? query) };
2446
+ }
2447
+ },
2448
+ {
2449
+ id: "destructive.disk_wipe",
2450
+ category: "destructive",
2451
+ framework_ref: "OWASP ASI02",
2452
+ title: "Low-level disk write / wipe (dd, mkfs, shred)",
2453
+ tools: ["Bash"],
2454
+ baseSeverity: "high",
2455
+ test(ctx) {
2456
+ const c = ctx.commandBare;
2457
+ if (!c || !/(\bdd\s+if=|\bmkfs(\.\w+)?\b|\bshred\b)/i.test(c))
2458
+ return null;
2459
+ return { evidence: evidence(ctx.command) };
2460
+ }
2461
+ },
2462
+ {
2463
+ id: "destructive.overwrite_critical",
2464
+ category: "destructive",
2465
+ framework_ref: "OWASP ASI02",
2466
+ title: "Overwrites a critical project file",
2467
+ tools: ["Write", "Edit", "MultiEdit"],
2468
+ baseSeverity: "medium",
2469
+ test(ctx) {
2470
+ const f = ctx.filePath;
2471
+ if (!f)
2472
+ return null;
2473
+ if (!/(^|\/)(package-lock\.json|pnpm-lock\.ya?ml|yarn\.lock|Cargo\.lock|go\.sum|\.github\/workflows\/|\.gitlab-ci\.ya?ml)\b/.test(f))
2474
+ return null;
2475
+ return { evidence: evidence(f) };
2476
+ }
2477
+ },
2478
+ // --- Credential & secret access ------------------------------------------
2479
+ {
2480
+ id: "credential.secret_file_access",
2481
+ category: "credential-access",
2482
+ framework_ref: "MITRE ATLAS: Credential Access",
2483
+ title: "Reads a secret/credential file",
2484
+ tools: ["Read", "Bash"],
2485
+ baseSeverity: "high",
2486
+ test(ctx) {
2487
+ if (isBash(ctx.toolName)) {
2488
+ const cmd = ctx.commandBare;
2489
+ if (!cmd)
2490
+ return null;
2491
+ const segments = cmd.split(/\|\||&&|[|;\n]/);
2492
+ const reads = segments.some((seg) => SECRET_FILE.test(seg) && !EXAMPLE_FILE.test(seg) && CONTENT_READ_CMD.test(seg));
2493
+ return reads ? { evidence: evidence(ctx.command) } : null;
2494
+ }
2495
+ const f = ctx.filePath;
2496
+ if (!f || !SECRET_FILE.test(f) || EXAMPLE_FILE.test(f))
2497
+ return null;
2498
+ return { evidence: evidence(f) };
2499
+ }
2500
+ },
2501
+ {
2502
+ id: "credential.secret_in_data",
2503
+ category: "credential-access",
2504
+ framework_ref: "MITRE ATLAS: Credential Access",
2505
+ title: "Secret-looking value in command or output",
2506
+ tools: "*",
2507
+ baseSeverity: "high",
2508
+ test(ctx) {
2509
+ const hay = [ctx.command, ctx.resultSummary].filter(Boolean).join("\n");
2510
+ if (!hay)
2511
+ return null;
2512
+ for (const p of SECRET_VALUE_PATTERNS) {
2513
+ const m = p.re.exec(hay);
2514
+ if (m)
2515
+ return { evidence: `${p.label}: ${evidence(m[0], 40)}`, severity: p.critical ? "critical" : "high", mods: { secret_kind: p.label } };
2516
+ }
2517
+ return null;
2518
+ }
2519
+ },
2520
+ // --- Data exfiltration (MITRE ATLAS AML.T0086) ---------------------------
2521
+ {
2522
+ id: "exfil.pipe_to_network",
2523
+ category: "exfiltration",
2524
+ framework_ref: "MITRE ATLAS AML.T0086",
2525
+ title: "Pipes local data into a network command",
2526
+ tools: ["Bash"],
2527
+ baseSeverity: "critical",
2528
+ test(ctx) {
2529
+ const c = ctx.commandBare;
2530
+ if (!c)
2531
+ return null;
2532
+ if (!/(\||<)\s*(sudo\s+)?(curl|wget|nc|ncat|netcat|ssh|scp)\b/i.test(c))
2533
+ return null;
2534
+ if (!/\b(cat|tar|zip|gzip|base64|env|printenv|cat\s|<)\b/i.test(c))
2535
+ return null;
2536
+ return { evidence: evidence(ctx.command) };
2537
+ }
2538
+ },
2539
+ {
2540
+ id: "exfil.network_upload",
2541
+ category: "exfiltration",
2542
+ framework_ref: "MITRE ATLAS AML.T0086",
2543
+ title: "Uploads data to an external host",
2544
+ tools: ["Bash"],
2545
+ baseSeverity: "high",
2546
+ test(ctx) {
2547
+ const c = ctx.commandBare;
2548
+ if (!c)
2549
+ return null;
2550
+ 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);
2551
+ if (!upload)
2552
+ return null;
2553
+ const external = /https?:\/\/(?!(localhost|127\.0\.0\.1|0\.0\.0\.0))/i.test(c);
2554
+ const withFile = /(@[^\s'"]+|--upload-file|\s-T\b)/.test(c);
2555
+ return {
2556
+ evidence: evidence(ctx.command),
2557
+ severity: withFile && external ? "critical" : "high",
2558
+ mods: { external, with_file: withFile }
2559
+ };
2560
+ }
2561
+ },
2562
+ {
2563
+ id: "exfil.reverse_shell",
2564
+ category: "exfiltration",
2565
+ framework_ref: "MITRE ATLAS AML.T0086",
2566
+ title: "Netcat / reverse-shell style connection",
2567
+ tools: ["Bash"],
2568
+ baseSeverity: "critical",
2569
+ test(ctx) {
2570
+ const c = ctx.commandBare;
2571
+ if (!c)
2572
+ return null;
2573
+ if (!/\b(nc|ncat|netcat)\b[^|;&\n]*(-e\b|-c\b|\d{2,5})|\/dev\/tcp\/|bash\s+-i\b/i.test(c))
2574
+ return null;
2575
+ return { evidence: evidence(ctx.command) };
2576
+ }
2577
+ },
2578
+ // --- Privilege escalation / guardrail bypass (OWASP LLM06 Excessive Agency)
2579
+ {
2580
+ id: "privilege.skip_permissions",
2581
+ category: "privilege-bypass",
2582
+ framework_ref: "OWASP LLM06",
2583
+ title: "Bypasses the permission guardrail",
2584
+ tools: "*",
2585
+ baseSeverity: "high",
2586
+ test(ctx) {
2587
+ const c = ctx.commandBare;
2588
+ if (!c || !/--dangerously-skip-permissions\b|--yolo\b|--dangerously-skip\b/i.test(c))
2589
+ return null;
2590
+ return { evidence: evidence(ctx.command) };
2591
+ }
2592
+ },
2593
+ {
2594
+ id: "privilege.curl_pipe_shell",
2595
+ category: "privilege-bypass",
2596
+ framework_ref: "OWASP LLM06",
2597
+ title: "Executes a remote script (curl | sh)",
2598
+ tools: ["Bash"],
2599
+ baseSeverity: "high",
2600
+ test(ctx) {
2601
+ const c = ctx.commandBare;
2602
+ if (!c || !/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python\d?|node)\b/i.test(c))
2603
+ return null;
2604
+ return { evidence: evidence(ctx.command) };
2605
+ }
2606
+ },
2607
+ {
2608
+ id: "privilege.sudo",
2609
+ category: "privilege-bypass",
2610
+ framework_ref: "OWASP LLM06",
2611
+ title: "Runs a command as root (sudo)",
2612
+ tools: ["Bash"],
2613
+ baseSeverity: "high",
2614
+ // root escalation removes a real safety barrier — higher than an in-project op
2615
+ test(ctx) {
2616
+ const c = ctx.commandBare;
2617
+ if (!c || !/(?:^|[\n;|&(])\s*sudo\b/i.test(c))
2618
+ return null;
2619
+ return { evidence: evidence(ctx.command) };
2620
+ }
2621
+ },
2622
+ {
2623
+ id: "privilege.exec_generated_script",
2624
+ category: "privilege-bypass",
2625
+ framework_ref: "OWASP LLM06",
2626
+ title: "Writes a shell script from inline output and executes it",
2627
+ tools: ["Bash"],
2628
+ baseSeverity: "high",
2629
+ test(ctx) {
2630
+ const raw = ctx.command;
2631
+ if (!raw)
2632
+ return null;
2633
+ const file = execGeneratedScript(stripComments(raw));
2634
+ if (!file)
2635
+ return null;
2636
+ const critical = /(?:^|["'\s;|&(])sudo\b|\brm\s+[^|;&\n]*-[a-z]*r[a-z]*f|\bchmod\s+0?777\b|\bmkfs|\bdd\s+if=/i.test(raw);
2637
+ return { evidence: evidence(raw), severity: critical ? "critical" : "high", mods: { script_file: file } };
2638
+ }
2639
+ },
2640
+ {
2641
+ id: "privilege.chmod_777",
2642
+ category: "privilege-bypass",
2643
+ framework_ref: "OWASP LLM06",
2644
+ title: "Overly permissive permissions (chmod 777)",
2645
+ tools: ["Bash"],
2646
+ baseSeverity: "medium",
2647
+ test(ctx) {
2648
+ const c = ctx.commandBare;
2649
+ if (!c || !/\bchmod\s+(-[a-zA-Z]+\s+)*0?777\b/i.test(c))
2650
+ return null;
2651
+ return { evidence: evidence(ctx.command) };
2652
+ }
2653
+ },
2654
+ {
2655
+ id: "privilege.persistence",
2656
+ category: "privilege-bypass",
2657
+ framework_ref: "OWASP LLM06",
2658
+ title: "Installs a persistence mechanism",
2659
+ tools: ["Bash"],
2660
+ baseSeverity: "high",
2661
+ test(ctx) {
2662
+ const c = ctx.commandBare;
2663
+ if (!c)
2664
+ return null;
2665
+ 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))
2666
+ return null;
2667
+ return { evidence: evidence(ctx.command) };
2668
+ }
2669
+ },
2670
+ {
2671
+ id: "privilege.write_outside_project",
2672
+ category: "privilege-bypass",
2673
+ framework_ref: "OWASP LLM06",
2674
+ title: "Writes a file outside the working project",
2675
+ tools: ["Write", "Edit", "MultiEdit"],
2676
+ baseSeverity: "medium",
2677
+ test(ctx) {
2678
+ const f = ctx.filePath;
2679
+ if (!outsideProject(f, ctx.projectPath, ctx.ownedConfigDirs))
2680
+ return null;
2681
+ const system = !!f && SYSTEM_PATH.test(f);
2682
+ return { evidence: evidence(f), severity: system ? "high" : "medium", mods: { project: ctx.projectPath, system_path: system } };
2683
+ }
2684
+ }
2685
+ ];
2686
+ function appliesTo(rule, toolName) {
2687
+ return rule.tools === "*" || rule.tools.includes(toolName);
2688
+ }
2689
+ function buildContext(row, projectPath, ownedConfigDirs) {
2690
+ let input = null;
2691
+ try {
2692
+ input = row.input_json ? JSON.parse(row.input_json) : null;
2693
+ } catch {
2694
+ input = null;
2695
+ }
2696
+ const command = typeof input?.command === "string" ? input.command : null;
2697
+ const commandCode = command != null ? codeOf(command) : null;
2698
+ const commandBare = command != null ? bareOf(command) : null;
2699
+ const filePath = typeof input?.file_path === "string" ? input.file_path : null;
2700
+ return { toolName: row.tool_name, command, commandCode, commandBare, filePath, input, resultSummary: row.result_summary, status: row.status, projectPath, ownedConfigDirs };
2701
+ }
2702
+ function detect(db, dirty) {
2703
+ const incremental = dirty != null;
2704
+ if (incremental) {
2705
+ db.exec("DROP TABLE IF EXISTS _dirty_sec");
2706
+ db.exec("CREATE TEMP TABLE _dirty_sec (id TEXT PRIMARY KEY)");
2707
+ const ins = db.prepare("INSERT OR IGNORE INTO _dirty_sec (id) VALUES (?)");
2708
+ db.transaction((ids) => {
2709
+ for (const id of ids)
2710
+ ins.run(id);
2711
+ })(dirty);
2712
+ }
2713
+ const scope = incremental ? " WHERE tc.session_id IN (SELECT id FROM _dirty_sec)" : "";
2714
+ const ownedConfigDirs = db.prepare("SELECT config_dir FROM sources WHERE config_dir IS NOT NULL").all().map((r) => r.config_dir.replace(/\/$/, "")).filter(Boolean);
2715
+ const projPath = /* @__PURE__ */ new Map();
2716
+ 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()) {
2717
+ projPath.set(r.id, r.path);
2718
+ }
2719
+ 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
2720
+ FROM tool_calls tc${scope}`).all();
2721
+ 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)
2722
+ VALUES (@id, @session_id, @tool_call_id, @event_uuid, @turn_id, @rule_id, @category, @framework_ref, @severity, @title, @evidence, @signals_json, @detector_version)
2723
+ ON CONFLICT(id) DO NOTHING`);
2724
+ const delScope = incremental ? "DELETE FROM findings WHERE session_id IN (SELECT id FROM _dirty_sec)" : "DELETE FROM findings";
2725
+ const tx = db.transaction(() => {
2726
+ db.exec(delScope);
2727
+ for (const row of rows) {
2728
+ const ctx = buildContext(row, projPath.get(row.session_id) ?? null, ownedConfigDirs);
2729
+ for (const rule of RULES) {
2730
+ if (!appliesTo(rule, ctx.toolName))
2731
+ continue;
2732
+ const m = rule.test(ctx);
2733
+ if (!m)
2734
+ continue;
2735
+ let severity = m.severity ?? rule.baseSeverity;
2736
+ const mods = { ...m.mods ?? {} };
2737
+ if (row.status === "error") {
2738
+ severity = bumpSeverity(severity, -1);
2739
+ mods.attempted = true;
2740
+ }
2741
+ const signals = {
2742
+ rule: rule.id,
2743
+ category: rule.category,
2744
+ framework_ref: rule.framework_ref,
2745
+ tool_name: ctx.toolName,
2746
+ base_severity: rule.baseSeverity,
2747
+ severity,
2748
+ status: row.status,
2749
+ modifiers: mods,
2750
+ detector_version: DETECTOR_VERSION
2751
+ };
2752
+ insert.run({
2753
+ id: findingId(row.id, rule.id),
2754
+ session_id: row.session_id,
2755
+ tool_call_id: row.id,
2756
+ event_uuid: row.event_uuid,
2757
+ turn_id: row.turn_id,
2758
+ rule_id: rule.id,
2759
+ category: rule.category,
2760
+ framework_ref: rule.framework_ref,
2761
+ severity,
2762
+ title: rule.title,
2763
+ evidence: m.evidence,
2764
+ signals_json: JSON.stringify(signals),
2765
+ detector_version: DETECTOR_VERSION
2766
+ });
2767
+ }
2768
+ }
2769
+ });
2770
+ tx();
2771
+ if (incremental)
2772
+ db.exec("DROP TABLE IF EXISTS _dirty_sec");
2773
+ const count = db.prepare("SELECT COUNT(*) n FROM findings").get().n;
2774
+ return { count, version: DETECTOR_VERSION };
2775
+ }
2776
+
2777
+ // ../ingest/dist/pipeline.js
2778
+ import { createHash as createHash2 } from "crypto";
2244
2779
  function newStats() {
2245
2780
  return { files: 0, skipped: 0, malformed: 0, newEvents: 0 };
2246
2781
  }
@@ -2278,7 +2813,7 @@ function skillSummary(body) {
2278
2813
  return null;
2279
2814
  }
2280
2815
  function skillVersionId(name, body) {
2281
- return createHash("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
2816
+ return createHash2("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
2282
2817
  }
2283
2818
  function pruneExcluded(db, excludedPaths) {
2284
2819
  if (!excludedPaths.length)
@@ -2312,6 +2847,7 @@ function pruneExcluded(db, excludedPaths) {
2312
2847
  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
2848
  db.pragma("foreign_keys = OFF");
2314
2849
  db.transaction(() => {
2850
+ db.exec("DELETE FROM findings WHERE session_id IN (SELECT id FROM _prune)");
2315
2851
  for (const t of ["token_usage", "tool_calls", "turns", "events"])
2316
2852
  db.exec(`DELETE FROM ${t} WHERE session_id IN (SELECT id FROM _prune)`);
2317
2853
  db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id IN (SELECT id FROM _prune)");
@@ -2544,6 +3080,7 @@ function rebuildDerived(db, dirty) {
2544
3080
  `);
2545
3081
  db.exec(`DELETE FROM sessions WHERE event_count = 0${andId}`);
2546
3082
  db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id NOT IN (SELECT id FROM sessions)");
3083
+ db.exec("DELETE FROM findings WHERE session_id NOT IN (SELECT id FROM sessions)");
2547
3084
  if (!incremental) {
2548
3085
  db.exec("DELETE FROM skills WHERE id NOT IN (SELECT skill_id FROM tool_calls WHERE skill_id IS NOT NULL)");
2549
3086
  }
@@ -2609,7 +3146,7 @@ function prepareStatements(db) {
2609
3146
  workflow_run_id = COALESCE(excluded.workflow_run_id, workflow_run_id)`)
2610
3147
  };
2611
3148
  }
2612
- function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
3149
+ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
2613
3150
  const sessionMeta = {};
2614
3151
  let eventsInFile = 0;
2615
3152
  const tx = db.transaction(() => {
@@ -2658,13 +3195,13 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
2658
3195
  }
2659
3196
  let projectId = null;
2660
3197
  if (sessionMeta.cwd) {
2661
- projectId = createHash("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
3198
+ projectId = createHash2("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
2662
3199
  stmts.upsertProject.run({
2663
3200
  id: projectId,
2664
3201
  agent_id: adapter.agentId,
2665
3202
  path: sessionMeta.cwd,
2666
3203
  encoded_dir: file.encodedDir || null,
2667
- now
3204
+ now: now2
2668
3205
  });
2669
3206
  }
2670
3207
  stmts.upsertSession.run({
@@ -2686,7 +3223,7 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
2686
3223
  mtime_ms: Math.trunc(meta.mtimeMs),
2687
3224
  sha256: meta.hash,
2688
3225
  events_ingested: eventsInFile,
2689
- ingested_at: now
3226
+ ingested_at: now2
2690
3227
  });
2691
3228
  });
2692
3229
  tx();
@@ -2697,7 +3234,7 @@ import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as
2697
3234
  import { basename as basename3, join as join5 } from "path";
2698
3235
 
2699
3236
  // ../ingest/dist/redact.js
2700
- import { createHash as createHash2, randomBytes } from "crypto";
3237
+ import { createHash as createHash3, randomBytes } from "crypto";
2701
3238
  function parseExcludes(csv) {
2702
3239
  return (csv ?? "").split(",").map((s) => s.trim()).filter(Boolean).map(encodeProjectPath);
2703
3240
  }
@@ -2706,17 +3243,17 @@ function isExcludedArchivePath(filePath, excluded) {
2706
3243
  }
2707
3244
 
2708
3245
  // ../ingest/dist/fileread.js
2709
- import { createHash as createHash3 } from "crypto";
3246
+ import { createHash as createHash4 } from "crypto";
2710
3247
  import { openSync as openSync2, readSync as readSync2, closeSync as closeSync2 } from "fs";
2711
3248
  import { StringDecoder } from "string_decoder";
2712
3249
  var STREAM_THRESHOLD = 8 * 1024 * 1024;
2713
3250
  var CHUNK2 = 64 * 1024;
2714
3251
  function sha256(buf) {
2715
- return createHash3("sha256").update(buf).digest("hex");
3252
+ return createHash4("sha256").update(buf).digest("hex");
2716
3253
  }
2717
3254
  function sha256File(path) {
2718
3255
  const fd = openSync2(path, "r");
2719
- const h = createHash3("sha256");
3256
+ const h = createHash4("sha256");
2720
3257
  const buf = Buffer.allocUnsafe(CHUNK2);
2721
3258
  try {
2722
3259
  let n;
@@ -2803,7 +3340,7 @@ function cleanStr(v) {
2803
3340
  return null;
2804
3341
  return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\[[0-9;]+m\]?/g, "").trim() || null;
2805
3342
  }
2806
- function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now, stats, full) {
3343
+ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now2, stats, full) {
2807
3344
  let paths = discoverSidecars(sourceArchiveDir);
2808
3345
  if (excludedDirs.length)
2809
3346
  paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
@@ -2846,7 +3383,7 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
2846
3383
  }
2847
3384
  const hash = sha256(buf);
2848
3385
  if (prev && prev.sha256 === hash) {
2849
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3386
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
2850
3387
  stats.skipped++;
2851
3388
  continue;
2852
3389
  }
@@ -2884,9 +3421,9 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
2884
3421
  duration_ms: durationMs2,
2885
3422
  started_at: startedAt,
2886
3423
  ended_at: endedAt,
2887
- ingested_at: now
3424
+ ingested_at: now2
2888
3425
  });
2889
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3426
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
2890
3427
  stats.upserted++;
2891
3428
  }
2892
3429
  }
@@ -2925,7 +3462,7 @@ function discoverMeta(sourceArchiveDir) {
2925
3462
  }
2926
3463
  var intOrNull2 = (v) => typeof v === "number" && Number.isFinite(v) ? Math.trunc(v) : null;
2927
3464
  var strOrNull2 = (v) => typeof v === "string" && v ? v : null;
2928
- function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, stats, full) {
3465
+ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2, stats, full) {
2929
3466
  let paths = discoverMeta(sourceArchiveDir);
2930
3467
  if (excludedDirs.length)
2931
3468
  paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
@@ -2960,7 +3497,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, s
2960
3497
  }
2961
3498
  const hash = sha256(buf);
2962
3499
  if (prev && prev.sha256 === hash) {
2963
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3500
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
2964
3501
  stats.skipped++;
2965
3502
  continue;
2966
3503
  }
@@ -2983,9 +3520,9 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, s
2983
3520
  agent_description: strOrNull2(d.description),
2984
3521
  spawn_depth: intOrNull2(d.spawnDepth),
2985
3522
  tool_use_id: strOrNull2(d.toolUseId),
2986
- ingested_at: now
3523
+ ingested_at: now2
2987
3524
  });
2988
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3525
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
2989
3526
  stats.upserted++;
2990
3527
  }
2991
3528
  }
@@ -3027,7 +3564,7 @@ function sessionFromPath2(path) {
3027
3564
  const i = parts.indexOf("tool-results");
3028
3565
  return i > 0 ? parts[i - 1] : null;
3029
3566
  }
3030
- function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full) {
3567
+ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now2, stats, full) {
3031
3568
  let paths = discoverResults(sourceArchiveDir);
3032
3569
  if (excludedDirs.length)
3033
3570
  paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
@@ -3061,7 +3598,7 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full)
3061
3598
  }
3062
3599
  const hash = sha256(buf);
3063
3600
  if (prev && prev.sha256 === hash) {
3064
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3601
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
3065
3602
  stats.skipped++;
3066
3603
  continue;
3067
3604
  }
@@ -3071,9 +3608,9 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full)
3071
3608
  path,
3072
3609
  bytes: st.size,
3073
3610
  text: buf.toString("utf8"),
3074
- ingested_at: now
3611
+ ingested_at: now2
3075
3612
  });
3076
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3613
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
3077
3614
  stats.upserted++;
3078
3615
  }
3079
3616
  }
@@ -3115,7 +3652,7 @@ function runIngest(argv = process.argv.slice(2)) {
3115
3652
  const adapterList = [new ClaudeCodeAdapter()];
3116
3653
  const adapterById = new Map(adapterList.map((a) => [a.agentId, a]));
3117
3654
  const sources = loadSources();
3118
- const now = (/* @__PURE__ */ new Date()).toISOString();
3655
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
3119
3656
  const excludePaths = loadExcludes();
3120
3657
  const excludedDirs = parseExcludes(excludePaths.join(","));
3121
3658
  const pruned = pruneExcluded(db, excludePaths);
@@ -3156,21 +3693,22 @@ function runIngest(argv = process.argv.slice(2)) {
3156
3693
  mtime_ms: mtimeMs,
3157
3694
  sha256: hash,
3158
3695
  events_ingested: prev.events_ingested,
3159
- ingested_at: now
3696
+ ingested_at: now2
3160
3697
  });
3161
3698
  stats.skipped++;
3162
3699
  continue;
3163
3700
  }
3164
3701
  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);
3702
+ ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash }, now2, stats);
3166
3703
  dirty.add(file.sessionId);
3167
3704
  }
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);
3705
+ ingestWorkflowResults(db, join8(archiveRoot, source.label), source.label, excludedDirs, now2, wfStats, args.full);
3706
+ ingestSubagentMeta(db, join8(archiveRoot, source.label), source.label, excludedDirs, now2, metaStats, args.full);
3707
+ ingestToolResults(db, join8(archiveRoot, source.label), excludedDirs, now2, trStats, args.full);
3171
3708
  }
3172
3709
  const expanded = rebuildDerived(db, args.full ? null : dirty);
3173
3710
  const classified = classify(db, args.full ? null : expanded);
3711
+ const detected = detect(db, args.full ? null : expanded);
3174
3712
  const count = (sql) => db.prepare(sql).get().n;
3175
3713
  const sessions = count("SELECT COUNT(*) n FROM sessions");
3176
3714
  const turns = count("SELECT COUNT(*) n FROM turns");
@@ -3186,12 +3724,16 @@ function runIngest(argv = process.argv.slice(2)) {
3186
3724
  const wfRuns = count("SELECT COUNT(*) n FROM workflow_results");
3187
3725
  const metaRows = count("SELECT COUNT(*) n FROM session_meta");
3188
3726
  const trRows = count("SELECT COUNT(*) n FROM tool_results");
3727
+ const sevCounts = db.prepare("SELECT severity, COUNT(*) n FROM findings GROUP BY severity").all();
3728
+ const sevMap = Object.fromEntries(sevCounts.map((r) => [r.severity, r.n]));
3729
+ const sevLine = ["critical", "high", "medium", "low", "info"].map((s) => `${s}=${sevMap[s] ?? 0}`).join(" ");
3189
3730
  db.close();
3190
3731
  console.log(`agent-lens-ingest: files=${stats.files} skipped=${stats.skipped} new_events=${stats.newEvents} malformed=${stats.malformed}${pruned ? ` excluded_pruned=${pruned}` : ""}
3191
3732
  sessions=${sessions} turns=${turns} events=${events} tool_calls=${tools} classified=${classified.count}
3192
3733
  workflow_results=${wfRuns} (sidecar upserted=${wfStats.upserted} skipped=${wfStats.skipped} malformed=${wfStats.malformed})
3193
3734
  session_meta=${metaRows} (upserted=${metaStats.upserted} skipped=${metaStats.skipped} malformed=${metaStats.malformed})
3194
3735
  tool_results=${trRows} (upserted=${trStats.upserted} skipped=${trStats.skipped} malformed=${trStats.malformed})
3736
+ findings=${detected.count} (${sevLine})
3195
3737
  tokens=${totalTokens.toLocaleString()} est_cost=$${cost.toFixed(2)} db=${dbPath}`);
3196
3738
  }
3197
3739
  function runMetrics(argv = process.argv.slice(2)) {
@@ -3207,8 +3749,9 @@ function runMetrics(argv = process.argv.slice(2)) {
3207
3749
  }
3208
3750
  const db = openDb(dbPath);
3209
3751
  const r = classify(db);
3752
+ const d = detect(db);
3210
3753
  db.close();
3211
- console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} db=${dbPath}`);
3754
+ console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} findings=${d.count} detector_version=${DETECTOR_VERSION} db=${dbPath}`);
3212
3755
  }
3213
3756
 
3214
3757
  // ../server/dist/run.js
@@ -3281,9 +3824,9 @@ function listSessions(db, f) {
3281
3824
  if (f.project)
3282
3825
  where.push("s.project_id = ?"), params.push(f.project);
3283
3826
  if (f.from)
3284
- where.push("s.started_at >= ?"), params.push(f.from);
3827
+ where.push("date(s.started_at) >= date(?)"), params.push(f.from);
3285
3828
  if (f.to)
3286
- where.push("s.started_at <= ?"), params.push(f.to);
3829
+ where.push("date(s.started_at) <= date(?)"), params.push(f.to);
3287
3830
  if (f.kind === "main")
3288
3831
  where.push("s.is_sidechain = 0");
3289
3832
  if (f.kind === "subagent")
@@ -3390,6 +3933,33 @@ function getSession(db, id) {
3390
3933
  t.full_result = { text: full.text, bytes: full.bytes };
3391
3934
  }
3392
3935
  }
3936
+ const sessionFindings = [];
3937
+ if (tableExists(db, "findings")) {
3938
+ const findingRows = db.prepare(`SELECT id, tool_call_id, event_uuid, turn_id, rule_id, category, framework_ref, severity, title, evidence, signals_json
3939
+ FROM findings WHERE session_id = ?`).all(id);
3940
+ const byToolCall = /* @__PURE__ */ new Map();
3941
+ for (const fr of findingRows) {
3942
+ const f = {
3943
+ id: fr.id,
3944
+ tool_call_id: fr.tool_call_id,
3945
+ event_uuid: fr.event_uuid,
3946
+ turn_id: fr.turn_id,
3947
+ rule_id: fr.rule_id,
3948
+ category: fr.category,
3949
+ framework_ref: fr.framework_ref,
3950
+ severity: fr.severity,
3951
+ title: fr.title,
3952
+ evidence: fr.evidence,
3953
+ signals: safeJson(fr.signals_json)
3954
+ };
3955
+ sessionFindings.push(f);
3956
+ if (fr.tool_call_id)
3957
+ (byToolCall.get(fr.tool_call_id) ?? byToolCall.set(fr.tool_call_id, []).get(fr.tool_call_id)).push(f);
3958
+ }
3959
+ for (const t of toolRows)
3960
+ t.findings = byToolCall.get(t.id) ?? [];
3961
+ sessionFindings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
3962
+ }
3393
3963
  const toolsByEvent = /* @__PURE__ */ new Map();
3394
3964
  for (const t of toolRows) {
3395
3965
  if (!t.event_uuid)
@@ -3478,7 +4048,7 @@ function getSession(db, id) {
3478
4048
  FROM tool_calls tc LEFT JOIN turns t ON t.id = tc.turn_id
3479
4049
  WHERE tc.session_id = ? AND tc.workflow_run_id IS NOT NULL
3480
4050
  ORDER BY t.seq`).all(id);
3481
- return { session, turns, events, classification, parent, children, workflow_runs };
4051
+ return { session, turns, events, classification, parent, children, workflow_runs, findings: sessionFindings };
3482
4052
  }
3483
4053
  function xmlTag(text, tag) {
3484
4054
  return text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim() ?? null;
@@ -3634,6 +4204,101 @@ function getSkill(db, name) {
3634
4204
  const call_count = sessions.reduce((a, s) => a + s.fire_count, 0);
3635
4205
  return { name, versions, sessions, call_count };
3636
4206
  }
4207
+ 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";
4208
+ var MUTED_SQL = `EXISTS (SELECT 1 FROM triage.muted_rules m WHERE m.rule_id = f.rule_id
4209
+ 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)))`;
4210
+ function triageAttached(db) {
4211
+ return db.prepare("PRAGMA database_list").all().some((r) => r.name === "triage");
4212
+ }
4213
+ function findingWhere(f) {
4214
+ const sql = [];
4215
+ const params = [];
4216
+ if (f.severity)
4217
+ sql.push("f.severity = ?"), params.push(f.severity);
4218
+ if (f.category)
4219
+ sql.push("f.category = ?"), params.push(f.category);
4220
+ if (f.rule)
4221
+ sql.push("f.rule_id = ?"), params.push(f.rule);
4222
+ if (f.session)
4223
+ sql.push("f.session_id = ?"), params.push(f.session);
4224
+ if (f.source)
4225
+ sql.push("s.source_id = ?"), params.push(f.source);
4226
+ if (f.project)
4227
+ sql.push("s.project_id = ?"), params.push(f.project);
4228
+ if (f.from)
4229
+ sql.push("date(s.started_at) >= date(?)"), params.push(f.from);
4230
+ if (f.to)
4231
+ sql.push("date(s.started_at) <= date(?)"), params.push(f.to);
4232
+ return { sql, params };
4233
+ }
4234
+ function statusWhere(status, attached) {
4235
+ if (!attached)
4236
+ return status === "dismissed" || status === "muted" ? "1 = 0" : "";
4237
+ switch (status) {
4238
+ case "dismissed":
4239
+ return "d.finding_id IS NOT NULL";
4240
+ case "muted":
4241
+ return `d.finding_id IS NULL AND ${MUTED_SQL}`;
4242
+ case "all":
4243
+ return "";
4244
+ default:
4245
+ return `d.finding_id IS NULL AND NOT ${MUTED_SQL}`;
4246
+ }
4247
+ }
4248
+ function listFindings(db, f) {
4249
+ if (!tableExists(db, "findings"))
4250
+ return { total: 0, findings: [] };
4251
+ const attached = triageAttached(db);
4252
+ const w = findingWhere(f);
4253
+ const st = statusWhere(f.status ?? "open", attached);
4254
+ const where = [...w.sql, ...st ? [st] : []];
4255
+ const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
4256
+ const dismissJoin = attached ? "LEFT JOIN triage.dismissed_findings d ON d.finding_id = f.id" : "";
4257
+ 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`;
4258
+ 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;
4259
+ const ORDER = {
4260
+ severity: SEVERITY_RANK_SQL,
4261
+ session: "f.session_id",
4262
+ rule: "f.rule_id",
4263
+ category: "f.category",
4264
+ time: "s.started_at"
4265
+ };
4266
+ const orderCol = ORDER[f.sort ?? "severity"] ?? SEVERITY_RANK_SQL;
4267
+ const dir = f.dir === "asc" ? "ASC" : "DESC";
4268
+ 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,
4269
+ f.framework_ref, f.severity, f.title, f.evidence, tc.tool_name,
4270
+ COALESCE(s.ai_title, s.slug) AS session_title, s.source_id, s.is_sidechain,
4271
+ s.started_at, p.path AS project_path, s.project_id ${triageCols}
4272
+ FROM findings f
4273
+ JOIN sessions s ON s.id = f.session_id
4274
+ LEFT JOIN projects p ON p.id = s.project_id
4275
+ LEFT JOIN tool_calls tc ON tc.id = f.tool_call_id
4276
+ ${dismissJoin}
4277
+ ${whereSql}
4278
+ ORDER BY ${orderCol} ${dir}, ${SEVERITY_RANK_SQL} DESC, f.id ASC
4279
+ LIMIT ? OFFSET ?`).all(...w.params, f.limit, f.offset);
4280
+ return { total, findings };
4281
+ }
4282
+ function securitySummary(db) {
4283
+ const categories = SECURITY_CATEGORIES;
4284
+ if (!tableExists(db, "findings")) {
4285
+ return { total: 0, sessions_flagged: 0, dismissed: 0, muted: 0, by_severity: [], by_category: [], by_rule: [], categories };
4286
+ }
4287
+ const attached = triageAttached(db);
4288
+ const dismissJoin = attached ? "LEFT JOIN triage.dismissed_findings d ON d.finding_id = f.id" : "";
4289
+ const base = `FROM findings f JOIN sessions s ON s.id = f.session_id ${dismissJoin}`;
4290
+ const openWhere = attached ? `WHERE d.finding_id IS NULL AND NOT ${MUTED_SQL}` : "";
4291
+ const scalar = (sql) => db.prepare(sql).get().n;
4292
+ const total = scalar(`SELECT COUNT(*) n ${base} ${openWhere}`);
4293
+ const sessions_flagged = scalar(`SELECT COUNT(DISTINCT f.session_id) n ${base} ${openWhere}`);
4294
+ 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;
4295
+ const muted = attached ? scalar(`SELECT COUNT(*) n ${base} WHERE d.finding_id IS NULL AND ${MUTED_SQL}`) : 0;
4296
+ 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();
4297
+ const by_category = db.prepare(`SELECT f.category AS category, COUNT(*) n ${base} ${openWhere} GROUP BY f.category ORDER BY n DESC`).all();
4298
+ 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
4299
+ ${base} ${openWhere} GROUP BY f.rule_id ORDER BY rank DESC, n DESC`).all();
4300
+ return { total, sessions_flagged, dismissed, muted, by_severity, by_category, by_rule, categories };
4301
+ }
3637
4302
 
3638
4303
  // ../server/dist/app.js
3639
4304
  import { existsSync as existsSync6 } from "fs";
@@ -3647,9 +4312,9 @@ function sessionWhere(f) {
3647
4312
  if (f.source)
3648
4313
  where.push("s.source_id = ?"), params.push(f.source);
3649
4314
  if (f.from)
3650
- where.push("s.started_at >= ?"), params.push(f.from);
4315
+ where.push("date(s.started_at) >= date(?)"), params.push(f.from);
3651
4316
  if (f.to)
3652
- where.push("s.started_at <= ?"), params.push(f.to);
4317
+ where.push("date(s.started_at) <= date(?)"), params.push(f.to);
3653
4318
  return { sql: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
3654
4319
  }
3655
4320
  var zeroSplit = () => ({ input: 0, output: 0, cache_creation: 0, cache_read: 0 });
@@ -3839,9 +4504,93 @@ function runRefresh() {
3839
4504
  }
3840
4505
  }
3841
4506
 
4507
+ // ../server/dist/triage.js
4508
+ import Database3 from "better-sqlite3";
4509
+ var TRIAGE_SCHEMA_SQL = (
4510
+ /* sql */
4511
+ `
4512
+ PRAGMA journal_mode = WAL;
4513
+
4514
+ -- Findings the user reviewed and marked safe. Keyed by the stable finding id so it survives re-ingest.
4515
+ CREATE TABLE IF NOT EXISTS dismissed_findings (
4516
+ finding_id TEXT PRIMARY KEY, -- findings.id = sha1(tool_call_id, rule_id)
4517
+ tool_call_id TEXT, -- reference only (may be null)
4518
+ rule_id TEXT, -- reference only (may be null)
4519
+ note TEXT,
4520
+ dismissed_at TEXT NOT NULL
4521
+ );
4522
+
4523
+ -- Rules the user muted (suppressed as noise), optionally scoped to a project or source. A muted rule
4524
+ -- hides its current AND future findings from the open view \u2014 the scalable lever for a noisy rule.
4525
+ CREATE TABLE IF NOT EXISTS muted_rules (
4526
+ rule_id TEXT NOT NULL,
4527
+ scope TEXT NOT NULL DEFAULT 'global', -- 'global' | 'project' | 'source'
4528
+ scope_id TEXT NOT NULL DEFAULT '', -- project_id / source_id; '' for global (NOT NULL keeps it in the PK)
4529
+ note TEXT,
4530
+ muted_at TEXT NOT NULL,
4531
+ PRIMARY KEY (rule_id, scope, scope_id)
4532
+ );
4533
+ `
4534
+ );
4535
+ function openTriage(file) {
4536
+ const db = new Database3(file);
4537
+ db.pragma("journal_mode = WAL");
4538
+ db.exec(TRIAGE_SCHEMA_SQL);
4539
+ return db;
4540
+ }
4541
+ var now = () => (/* @__PURE__ */ new Date()).toISOString();
4542
+ function dismiss(db, ids, note) {
4543
+ const stmt = db.prepare(`INSERT INTO dismissed_findings (finding_id, note, dismissed_at) VALUES (?, ?, ?)
4544
+ ON CONFLICT(finding_id) DO UPDATE SET note = excluded.note, dismissed_at = excluded.dismissed_at`);
4545
+ const ts = now();
4546
+ const tx = db.transaction((list) => {
4547
+ for (const id of list)
4548
+ stmt.run(id, note ?? null, ts);
4549
+ });
4550
+ tx(ids);
4551
+ return ids.length;
4552
+ }
4553
+ function reopen(db, ids) {
4554
+ const stmt = db.prepare("DELETE FROM dismissed_findings WHERE finding_id = ?");
4555
+ let n = 0;
4556
+ const tx = db.transaction((list) => {
4557
+ for (const id of list)
4558
+ n += stmt.run(id).changes;
4559
+ });
4560
+ tx(ids);
4561
+ return n;
4562
+ }
4563
+ function muteRule(db, ruleId, scope = "global", scopeId = "", note) {
4564
+ db.prepare(`INSERT INTO muted_rules (rule_id, scope, scope_id, note, muted_at) VALUES (?, ?, ?, ?, ?)
4565
+ 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());
4566
+ }
4567
+ function unmute(db, ruleId, scope = "global", scopeId = "") {
4568
+ return db.prepare("DELETE FROM muted_rules WHERE rule_id = ? AND scope = ? AND scope_id = ?").run(ruleId, scope, scope === "global" ? "" : scopeId).changes;
4569
+ }
4570
+ function listMutes(db) {
4571
+ return db.prepare("SELECT rule_id, scope, scope_id, note, muted_at FROM muted_rules ORDER BY muted_at DESC").all();
4572
+ }
4573
+
3842
4574
  // ../server/dist/app.js
3843
4575
  async function createApp(db, opts = {}) {
3844
4576
  const app = Fastify({ logger: false });
4577
+ let triageDb = null;
4578
+ if (opts.triageDbPath) {
4579
+ triageDb = openTriage(opts.triageDbPath);
4580
+ db.exec(`ATTACH DATABASE '${opts.triageDbPath.replace(/'/g, "''")}' AS triage`);
4581
+ app.addHook("onClose", async () => triageDb?.close());
4582
+ }
4583
+ const guardWrite = (req, reply) => {
4584
+ if (!originAllowed(req.headers.origin)) {
4585
+ reply.code(403).send({ error: { code: "FORBIDDEN_ORIGIN", message: "cross-origin write blocked" } });
4586
+ return false;
4587
+ }
4588
+ if (!triageDb) {
4589
+ reply.code(503).send({ error: { code: "TRIAGE_UNAVAILABLE", message: "triage store not configured" } });
4590
+ return false;
4591
+ }
4592
+ return true;
4593
+ };
3845
4594
  app.get("/api/health", async () => {
3846
4595
  const s = schemaStatus(db);
3847
4596
  return { ok: true, last_ingested: lastIngested(db), schema_version: s.db_version, schema_stale: s.stale };
@@ -3904,6 +4653,72 @@ async function createApp(db, opts = {}) {
3904
4653
  return reply.code(404).send({ error: "not found" });
3905
4654
  return result;
3906
4655
  });
4656
+ app.get("/api/security/summary", async () => securitySummary(db));
4657
+ const findingFilters = (q) => ({
4658
+ severity: q.severity,
4659
+ category: q.category,
4660
+ rule: q.rule,
4661
+ session: q.session,
4662
+ source: q.source,
4663
+ project: q.project,
4664
+ from: q.from,
4665
+ to: q.to,
4666
+ status: ["open", "dismissed", "muted", "all"].find((s) => s === q.status)
4667
+ });
4668
+ app.get("/api/security/findings", async (req) => {
4669
+ const q = req.query;
4670
+ return listFindings(db, {
4671
+ ...findingFilters(q),
4672
+ sort: ["severity", "session", "rule", "category", "time"].find((s) => s === q.sort),
4673
+ dir: q.dir === "asc" ? "asc" : q.dir === "desc" ? "desc" : void 0,
4674
+ limit: Math.min(Number(q.limit) || 50, 5e3),
4675
+ offset: Number(q.offset) || 0
4676
+ });
4677
+ });
4678
+ app.get("/api/security/mutes", async () => triageDb ? listMutes(triageDb) : []);
4679
+ app.post("/api/security/dismiss", async (req, reply) => {
4680
+ if (!guardWrite(req, reply))
4681
+ return reply;
4682
+ const b = req.body ?? {};
4683
+ const n = dismiss(triageDb, Array.isArray(b.ids) ? b.ids : [], b.note ?? null);
4684
+ return { ok: true, dismissed: n };
4685
+ });
4686
+ app.post("/api/security/reopen", async (req, reply) => {
4687
+ if (!guardWrite(req, reply))
4688
+ return reply;
4689
+ const b = req.body ?? {};
4690
+ const n = reopen(triageDb, Array.isArray(b.ids) ? b.ids : []);
4691
+ return { ok: true, reopened: n };
4692
+ });
4693
+ app.post("/api/security/dismiss-matching", async (req, reply) => {
4694
+ if (!guardWrite(req, reply))
4695
+ return reply;
4696
+ const b = req.body ?? {};
4697
+ const page = listFindings(db, { ...findingFilters(b.filter ?? {}), status: "open", limit: 1e5, offset: 0 });
4698
+ const ids = page.findings.map((r) => r.id);
4699
+ const n = dismiss(triageDb, ids, b.note ?? null);
4700
+ return { ok: true, dismissed: n };
4701
+ });
4702
+ app.post("/api/security/mute", async (req, reply) => {
4703
+ if (!guardWrite(req, reply))
4704
+ return reply;
4705
+ const b = req.body ?? {};
4706
+ if (!b.rule_id)
4707
+ return reply.code(400).send({ error: { code: "BAD_REQUEST", message: "rule_id required" } });
4708
+ const scope = ["global", "project", "source"].find((s) => s === b.scope) ?? "global";
4709
+ muteRule(triageDb, b.rule_id, scope, b.scope_id ?? "", b.note ?? null);
4710
+ return { ok: true };
4711
+ });
4712
+ app.post("/api/security/unmute", async (req, reply) => {
4713
+ if (!guardWrite(req, reply))
4714
+ return reply;
4715
+ const b = req.body ?? {};
4716
+ if (!b.rule_id)
4717
+ return reply.code(400).send({ error: { code: "BAD_REQUEST", message: "rule_id required" } });
4718
+ const scope = ["global", "project", "source"].find((s) => s === b.scope) ?? "global";
4719
+ const n = unmute(triageDb, b.rule_id, scope, b.scope_id ?? "");
4720
+ return { ok: true, unmuted: n };
4721
+ });
3907
4722
  app.get("/api/skills", async (req) => {
3908
4723
  const q = req.query;
3909
4724
  return listSkills(db, { q: q.q, source: q.source, project: q.project });
@@ -3973,8 +4788,9 @@ async function startServer() {
3973
4788
  console.error(`agent-lens-server: db not found: ${dbPath} (run ingest first)`);
3974
4789
  process.exit(1);
3975
4790
  }
4791
+ const triageDbPath = process.env.AGENT_LENS_TRIAGE_DB || join10(dirname5(dbPath), "triage.db");
3976
4792
  const db = openReadonly(dbPath);
3977
- const app = await createApp(db, { webDist });
4793
+ const app = await createApp(db, { webDist, triageDbPath });
3978
4794
  try {
3979
4795
  await app.listen({ host, port });
3980
4796
  console.log(`agent-lens-server: http://${host}:${port} (db: ${dbPath})`);