@swestash/agent-lens 0.7.0 → 0.8.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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync8 } from "fs";
4
+ import { existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
5
5
  import { dirname as dirname6, join as join12 } from "path";
6
6
  import { fileURLToPath as fileURLToPath3 } from "url";
7
7
 
@@ -947,6 +947,245 @@ var SECURITY_CATEGORIES = [
947
947
  }
948
948
  ];
949
949
 
950
+ // ../core/dist/secrets.js
951
+ var LEAK_PATTERNS = [
952
+ { name: "email", re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
953
+ { name: "home-path-with-user", re: /\/(?:home|Users)\/(?!user\b)[A-Za-z0-9._-]+/ },
954
+ { name: "ipv4", re: /\b\d{1,3}(?:\.\d{1,3}){3}\b/ },
955
+ { name: "url", re: /https?:\/\/[^\s"']+/ },
956
+ { name: "aws-key", re: /AKIA[0-9A-Z]{16}/ },
957
+ { name: "bearer/token", re: /\b(?:sk|pk|ghp|gho|xox[abp])[-_][A-Za-z0-9]{8,}/ }
958
+ ];
959
+ var SECRET_PATTERNS = [
960
+ { re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g, label: "PRIVATE-KEY" },
961
+ { re: /\bAKIA[0-9A-Z]{16}\b/g, label: "AWS-KEY" },
962
+ { re: /\bASIA[0-9A-Z]{16}\b/g, label: "AWS-KEY" },
963
+ { re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g, label: "GITHUB-TOKEN" },
964
+ { re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g, label: "GITHUB-TOKEN" },
965
+ { re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, label: "SLACK-TOKEN" },
966
+ { re: /\bxapp-[0-9A-Za-z-]{10,}/g, label: "SLACK-TOKEN" },
967
+ { re: /\bnpm_[A-Za-z0-9]{30,}/g, label: "NPM-TOKEN" },
968
+ { re: /\bAIza[0-9A-Za-z_-]{35}\b/g, label: "GOOGLE-API-KEY" },
969
+ { re: /\b[rsp]k_(?:live|test)_[A-Za-z0-9]{10,}\b/g, label: "STRIPE-KEY" },
970
+ { re: /\bsk-(?:proj|ant|or|svcacct)-[A-Za-z0-9_-]{20,}/g, label: "API-KEY" },
971
+ { re: /\bsk-[A-Za-z0-9]{20,}\b/g, label: "API-KEY" },
972
+ { re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/g, label: "JWT" }
973
+ ];
974
+ function maskUrlCredentials(text) {
975
+ return text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/\s:@"'`]+:[^/\s@"'`]+@/gi, "$1[REDACTED-AUTH]@").replace(/([?&](?:access_?token|api[_-]?key|apikey|token|secret|password|pwd|client[_-]?secret|auth|signature|sig)=)[^&\s"'`<>]+/gi, "$1[REDACTED]");
976
+ }
977
+ var ENV_ASSIGN = /\b([A-Za-z][A-Za-z0-9_]*_(?:TOKEN|SECRET|KEY|PASSWORD|PASSWD|PWD|PW|CREDENTIALS?)|PASSWORD|PASSWD|SECRET|TOKEN)=(["']?)(?!\$)[^\s"'`&;|<>()]+/gi;
978
+ function maskEnvAssignments(text) {
979
+ return text.replace(ENV_ASSIGN, (_m, name, q) => `${name}=${q}[REDACTED]`);
980
+ }
981
+ var PII_PATTERNS = [
982
+ { re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, label: "EMAIL" },
983
+ { re: /\b\d{1,3}(?:\.\d{1,3}){3}\b/g, label: "IP" }
984
+ ];
985
+ var HOME_USER = /\/(home|Users)\/[^/\s"'`]+/g;
986
+ var ENCODED_HOME_USER = /(?<![A-Za-z0-9])(-(?:home|Users)-)[A-Za-z0-9._]+(?=-)/g;
987
+ var WINDOWS_USER = /([A-Za-z]:\\Users\\)[^\\/\s"'`]+/gi;
988
+ function maskSecrets(text) {
989
+ let out = maskUrlCredentials(text);
990
+ out = maskEnvAssignments(out);
991
+ for (const { re, label } of SECRET_PATTERNS)
992
+ out = out.replace(re, `[${label}]`);
993
+ for (const { re, label } of PII_PATTERNS)
994
+ out = out.replace(re, `[${label}]`);
995
+ out = out.replace(HOME_USER, (_m, root) => `/${root}/user`);
996
+ out = out.replace(ENCODED_HOME_USER, (_m, root) => `${root}user`);
997
+ out = out.replace(WINDOWS_USER, (_m, root) => `${root}user`);
998
+ return out;
999
+ }
1000
+ var SHARE_LEAK = [
1001
+ ...SECRET_PATTERNS.map(({ re, label }) => ({ name: label, re: new RegExp(re.source) })),
1002
+ { name: "email", re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
1003
+ { name: "home-path-with-user", re: /\/(?:home|Users)\/(?!user\b)[A-Za-z0-9._-]+/ },
1004
+ { name: "encoded-home-with-user", re: /(?<![A-Za-z0-9])-(?:home|Users)-(?!user-)[A-Za-z0-9._]+-/ },
1005
+ { name: "url-credentials", re: /:\/\/[^/\s:@"'`]+:[^/\s@"'`]+@/ }
1006
+ ];
1007
+ var USER_STOPLIST = /* @__PURE__ */ new Set([
1008
+ "user",
1009
+ "users",
1010
+ "home",
1011
+ "root",
1012
+ "admin",
1013
+ "ubuntu",
1014
+ "runner",
1015
+ "node",
1016
+ "app",
1017
+ "apps",
1018
+ "data",
1019
+ "tmp",
1020
+ "shared",
1021
+ "public",
1022
+ "guest",
1023
+ "vagrant",
1024
+ "ec2-user",
1025
+ "docker"
1026
+ ]);
1027
+ var HOME_OWNER = /(?:\/(?:home|Users)\/|-(?:home|Users)-|[A-Za-z]:\\Users\\)([A-Za-z0-9._]{3,})/g;
1028
+ function deriveHomeUsers(texts) {
1029
+ const found = /* @__PURE__ */ new Set();
1030
+ for (const t of texts) {
1031
+ if (!t)
1032
+ continue;
1033
+ const re = new RegExp(HOME_OWNER.source, "g");
1034
+ let m;
1035
+ while (m = re.exec(t)) {
1036
+ const u = m[1];
1037
+ if (u.length >= 3 && !USER_STOPLIST.has(u.toLowerCase()))
1038
+ found.add(u);
1039
+ }
1040
+ }
1041
+ return [...found];
1042
+ }
1043
+ function scrubUsernames(text, users) {
1044
+ let out = text;
1045
+ for (const u of users) {
1046
+ const esc = u.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
1047
+ out = out.replace(new RegExp(`(?<![A-Za-z0-9])${esc}(?![A-Za-z0-9])`, "g"), "[USER]");
1048
+ }
1049
+ return out;
1050
+ }
1051
+
1052
+ // ../core/dist/redact-export.js
1053
+ import { createHash } from "crypto";
1054
+
1055
+ // ../core/dist/markdown.js
1056
+ function fence(s) {
1057
+ return s.length > 2e3 ? s.slice(0, 2e3) + "\n\u2026 (truncated)" : s;
1058
+ }
1059
+ function sessionToMarkdown(s, events) {
1060
+ const out = [];
1061
+ out.push(`# ${s.title || "Session " + s.id.slice(0, 8)}`);
1062
+ out.push("");
1063
+ const meta = [
1064
+ ["Session", s.id],
1065
+ ["Source", s.source],
1066
+ ["Project", s.project],
1067
+ ["Model", s.model],
1068
+ ["Started", s.started_at],
1069
+ ["Ended", s.ended_at]
1070
+ ].filter(([, v]) => v);
1071
+ for (const [k, v] of meta)
1072
+ out.push(`- **${k}:** ${v}`);
1073
+ out.push("");
1074
+ out.push("---");
1075
+ out.push("");
1076
+ for (const e of events) {
1077
+ const who = e.role || e.type;
1078
+ const ts = e.timestamp ? ` _(${e.timestamp})_` : "";
1079
+ if (e.thinking) {
1080
+ out.push(`### \u{1F9E0} thinking${ts}`);
1081
+ out.push("");
1082
+ out.push(fence(e.thinking));
1083
+ out.push("");
1084
+ }
1085
+ if (e.text) {
1086
+ const icon = who === "user" ? "\u{1F464}" : who === "assistant" ? "\u{1F916}" : "\u2699\uFE0F";
1087
+ out.push(`### ${icon} ${who}${ts}`);
1088
+ out.push("");
1089
+ out.push(fence(e.text));
1090
+ out.push("");
1091
+ }
1092
+ for (const tc of e.toolCalls) {
1093
+ const label = tc.skill_name ? `Skill: ${tc.skill_name}` : tc.agent_type ? `${tc.tool_name} \u2192 ${tc.agent_type}` : tc.tool_name;
1094
+ const status = tc.status ? ` _(${tc.status})_` : "";
1095
+ out.push(`> \u{1F527} **${label}**${status}`);
1096
+ if (tc.input_json && tc.input_json !== "{}") {
1097
+ out.push("```json");
1098
+ out.push(fence(tc.input_json));
1099
+ out.push("```");
1100
+ }
1101
+ out.push("");
1102
+ }
1103
+ }
1104
+ return out.join("\n");
1105
+ }
1106
+
1107
+ // ../core/dist/redact-export.js
1108
+ function hash(s) {
1109
+ return createHash("sha256").update(s).digest("hex").slice(0, 4);
1110
+ }
1111
+ function structurePath(p) {
1112
+ if (!p)
1113
+ return p;
1114
+ const abs = p.startsWith("/");
1115
+ const segs = p.split("/").filter(Boolean);
1116
+ const out = segs.map((seg, i) => {
1117
+ if (segs[i - 1] === "home" || segs[i - 1] === "Users")
1118
+ return "user";
1119
+ if (seg === "home" || seg === "Users")
1120
+ return seg;
1121
+ const last = i === segs.length - 1;
1122
+ const dot = last ? seg.lastIndexOf(".") : -1;
1123
+ return dot > 0 ? `file-${hash(seg)}${seg.slice(dot)}` : `dir-${hash(seg)}`;
1124
+ });
1125
+ return (abs ? "/" : "") + out.join("/");
1126
+ }
1127
+ function scrub(s) {
1128
+ return s && s.length > 0 ? "[redacted]" : s;
1129
+ }
1130
+ function redactSession(session, events, level) {
1131
+ const users = deriveHomeUsers([
1132
+ session.title,
1133
+ session.project,
1134
+ session.source,
1135
+ ...events.flatMap((e) => [e.text, e.thinking, ...e.toolCalls.map((t) => t.input_json)])
1136
+ ]);
1137
+ const unuser = (s) => s == null ? s : scrubUsernames(s, users);
1138
+ if (level === "structure") {
1139
+ return {
1140
+ session: { ...session, title: session.title ? "Redacted session" : session.title, project: structurePath(session.project), source: unuser(session.source) },
1141
+ events: events.map((e) => ({
1142
+ ...e,
1143
+ text: scrub(e.text),
1144
+ thinking: scrub(e.thinking),
1145
+ toolCalls: e.toolCalls.map((t) => ({ ...t, input_json: t.input_json ? "{}" : t.input_json }))
1146
+ }))
1147
+ };
1148
+ }
1149
+ const mask = (s) => s == null ? s : scrubUsernames(maskSecrets(s), users);
1150
+ return {
1151
+ session: { ...session, title: mask(session.title), project: mask(session.project), source: mask(session.source) },
1152
+ events: events.map((e) => ({
1153
+ ...e,
1154
+ text: mask(e.text),
1155
+ thinking: mask(e.thinking),
1156
+ toolCalls: e.toolCalls.map((t) => ({ ...t, input_json: mask(t.input_json) }))
1157
+ }))
1158
+ };
1159
+ }
1160
+ function sweep(md, patterns) {
1161
+ let out = md;
1162
+ let caught = false;
1163
+ for (const { re } of patterns) {
1164
+ const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
1165
+ if (g.test(out)) {
1166
+ caught = true;
1167
+ out = out.replace(g, "[REDACTED]");
1168
+ }
1169
+ }
1170
+ return { md: out, caught };
1171
+ }
1172
+ function disclaimer(level) {
1173
+ return `> \u{1F512} **Redacted export** (level: ${level}) \u2014 best-effort automatic masking of secrets, tokens, emails, IPs, and usernames. Not a guarantee; review before sharing. Generated locally by Agent Lens \u2014 never uploaded.`;
1174
+ }
1175
+ function exportMarkdown(session, events, opts = {}) {
1176
+ const level = opts.level ?? "secrets";
1177
+ if (level === "off") {
1178
+ return { markdown: sessionToMarkdown(session, events), residualLeak: false, level: "off" };
1179
+ }
1180
+ const redacted = redactSession(session, events, level);
1181
+ const rendered = sessionToMarkdown(redacted.session, redacted.events);
1182
+ const scanSet = level === "structure" ? LEAK_PATTERNS : SHARE_LEAK;
1183
+ const { md, caught } = sweep(rendered, scanSet);
1184
+ return { markdown: `${disclaimer(level)}
1185
+
1186
+ ${md}`, residualLeak: caught, level };
1187
+ }
1188
+
950
1189
  // ../core/dist/errors.js
951
1190
  var ERROR_CLASSIFIER_VERSION = 1;
952
1191
  var REJECTION_TYPES = /* @__PURE__ */ new Set(["user-rejected", "guardrail-blocked"]);
@@ -1010,58 +1249,6 @@ function costForUsage(model, u) {
1010
1249
  return (u.input_tokens * r.input + u.output_tokens * r.output + u.cache_creation_input_tokens * r.cacheWrite + u.cache_read_input_tokens * r.cacheRead) / M;
1011
1250
  }
1012
1251
 
1013
- // ../core/dist/markdown.js
1014
- function fence(s) {
1015
- return s.length > 2e3 ? s.slice(0, 2e3) + "\n\u2026 (truncated)" : s;
1016
- }
1017
- function sessionToMarkdown(s, events) {
1018
- const out = [];
1019
- out.push(`# ${s.title || "Session " + s.id.slice(0, 8)}`);
1020
- out.push("");
1021
- const meta = [
1022
- ["Session", s.id],
1023
- ["Source", s.source],
1024
- ["Project", s.project],
1025
- ["Model", s.model],
1026
- ["Started", s.started_at],
1027
- ["Ended", s.ended_at]
1028
- ].filter(([, v]) => v);
1029
- for (const [k, v] of meta)
1030
- out.push(`- **${k}:** ${v}`);
1031
- out.push("");
1032
- out.push("---");
1033
- out.push("");
1034
- for (const e of events) {
1035
- const who = e.role || e.type;
1036
- const ts = e.timestamp ? ` _(${e.timestamp})_` : "";
1037
- if (e.thinking) {
1038
- out.push(`### \u{1F9E0} thinking${ts}`);
1039
- out.push("");
1040
- out.push(fence(e.thinking));
1041
- out.push("");
1042
- }
1043
- if (e.text) {
1044
- const icon = who === "user" ? "\u{1F464}" : who === "assistant" ? "\u{1F916}" : "\u2699\uFE0F";
1045
- out.push(`### ${icon} ${who}${ts}`);
1046
- out.push("");
1047
- out.push(fence(e.text));
1048
- out.push("");
1049
- }
1050
- for (const tc of e.toolCalls) {
1051
- const label = tc.skill_name ? `Skill: ${tc.skill_name}` : tc.agent_type ? `${tc.tool_name} \u2192 ${tc.agent_type}` : tc.tool_name;
1052
- const status = tc.status ? ` _(${tc.status})_` : "";
1053
- out.push(`> \u{1F527} **${label}**${status}`);
1054
- if (tc.input_json && tc.input_json !== "{}") {
1055
- out.push("```json");
1056
- out.push(fence(tc.input_json));
1057
- out.push("```");
1058
- }
1059
- out.push("");
1060
- }
1061
- }
1062
- return out.join("\n");
1063
- }
1064
-
1065
1252
  // ../core/dist/rawjson.js
1066
1253
  import { gzipSync, gunzipSync } from "zlib";
1067
1254
  function packRaw(json) {
@@ -1550,8 +1737,11 @@ After=network.target
1550
1737
  Type=simple
1551
1738
  # Absolute node + CLI paths: systemd user services don't inherit the interactive PATH.
1552
1739
  ExecStart=${node} ${cli2} serve
1553
- ${envLines}# Long-running: restart if it crashes.
1554
- Restart=on-failure
1740
+ ${envLines}# Long-running: always come back, including after a bare SIGTERM. systemd counts SIGTERM as a
1741
+ # clean exit, so Restart=on-failure would leave the server dead after a stray "pkill -f ... serve"
1742
+ # aimed at an ad-hoc test server \u2014 the unit's ExecStart matches that pattern too. Matches the
1743
+ # launchd KeepAlive=true behaviour. An explicit "systemctl --user stop" still stops it for good.
1744
+ Restart=always
1555
1745
  RestartSec=5
1556
1746
  Nice=10
1557
1747
 
@@ -1913,13 +2103,13 @@ function asString(v) {
1913
2103
  function asInt(v) {
1914
2104
  return typeof v === "number" && Number.isFinite(v) ? Math.trunc(v) : 0;
1915
2105
  }
1916
- function parentSessionFromPath(path) {
1917
- const parts = path.split("/");
2106
+ function parentSessionFromPath(path2) {
2107
+ const parts = path2.split("/");
1918
2108
  const i = parts.indexOf("subagents");
1919
2109
  return i > 0 ? parts[i - 1] : null;
1920
2110
  }
1921
- function workflowRunFromPath(path) {
1922
- const parts = path.split("/");
2111
+ function workflowRunFromPath(path2) {
2112
+ const parts = path2.split("/");
1923
2113
  const i = parts.indexOf("workflows");
1924
2114
  return i >= 0 && i + 1 < parts.length ? parts[i + 1] : null;
1925
2115
  }
@@ -1947,18 +2137,18 @@ var ClaudeCodeAdapter = class {
1947
2137
  const addFrom = (projectsDir, isVersion) => {
1948
2138
  const found = [];
1949
2139
  recurseJsonl(projectsDir, found);
1950
- for (const path of found) {
1951
- const parts = path.split("/");
1952
- const sessionId = basename2(path, ".jsonl");
2140
+ for (const path2 of found) {
2141
+ const parts = path2.split("/");
2142
+ const sessionId = basename2(path2, ".jsonl");
1953
2143
  const encodedDir = parts[parts.length - 2] ?? "";
1954
2144
  files.push({
1955
- path,
2145
+ path: path2,
1956
2146
  sessionId,
1957
2147
  encodedDir,
1958
2148
  isVersion,
1959
2149
  sourceId,
1960
- parentSessionId: parentSessionFromPath(path),
1961
- workflowRunId: workflowRunFromPath(path)
2150
+ parentSessionId: parentSessionFromPath(path2),
2151
+ workflowRunId: workflowRunFromPath(path2)
1962
2152
  });
1963
2153
  }
1964
2154
  };
@@ -2354,14 +2544,15 @@ function classify(db, dirty) {
2354
2544
  }
2355
2545
 
2356
2546
  // ../ingest/dist/detect.js
2357
- import { createHash } from "crypto";
2358
- var DETECTOR_VERSION = 7;
2547
+ import { createHash as createHash2 } from "crypto";
2548
+ import { posix as path } from "path";
2549
+ var DETECTOR_VERSION = 8;
2359
2550
  function evidence(s, max = 200) {
2360
2551
  const one = s.replace(/\s+/g, " ").trim();
2361
2552
  return one.length > max ? one.slice(0, max - 1) + "\u2026" : one;
2362
2553
  }
2363
2554
  function findingId(toolCallId, ruleId) {
2364
- return createHash("sha1").update(toolCallId).update("\0").update(ruleId).digest("hex").slice(0, 16);
2555
+ return createHash2("sha1").update(toolCallId).update("\0").update(ruleId).digest("hex").slice(0, 16);
2365
2556
  }
2366
2557
  var SYSTEM_PATH = /(^|\s)(\/etc\/|\/usr\/|\/bin\/|\/sbin\/|\/boot\/|\/var\/|\/root\/|~\/\.ssh\/|~\/\.aws\/)/;
2367
2558
  var HOME_OR_ROOT_TARGET = /(\s|^)(~\/?|\/|\/\*|\$HOME\/?|\*)(\s|$)/;
@@ -2395,8 +2586,8 @@ function execGeneratedScript(cmd) {
2395
2586
  const e = t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2396
2587
  const interp = new RegExp(`\\b(?:sh|bash|zsh|ksh|dash|source)\\s+["']?${e}\\b|(?:^|[\\n;|&(])\\s*\\.\\s+["']?${e}\\b`);
2397
2588
  const hasPath = /^(\.\/|\/|~\/)/.test(t);
2398
- const path = hasPath ? new RegExp(`(?:^|[\\n;|&(])\\s*${e}(?:\\s|$|[;&|<>])`) : new RegExp(`(?:^|[\\n;|&(])\\s*\\.\\/${e}\\b`);
2399
- if (interp.test(cmd) || path.test(cmd))
2589
+ const path2 = hasPath ? new RegExp(`(?:^|[\\n;|&(])\\s*${e}(?:\\s|$|[;&|<>])`) : new RegExp(`(?:^|[\\n;|&(])\\s*\\.\\/${e}\\b`);
2590
+ if (interp.test(cmd) || path2.test(cmd))
2400
2591
  return t;
2401
2592
  }
2402
2593
  return null;
@@ -2410,14 +2601,21 @@ function isAgentOwnedPath(p, configDirs) {
2410
2601
  const tmp = process.env.TMPDIR;
2411
2602
  return !!tmp && p.startsWith(tmp.replace(/\/$/, "") + "/");
2412
2603
  }
2413
- function outsideProject(filePath, projectPath, configDirs) {
2414
- if (!filePath || !filePath.startsWith("/"))
2415
- return false;
2416
- if (isAgentOwnedPath(filePath, configDirs))
2417
- return false;
2604
+ function outsideProjectPath(filePath, projectPath, configDirs) {
2605
+ if (!filePath)
2606
+ return null;
2607
+ let abs = filePath;
2608
+ if (!filePath.startsWith("/")) {
2609
+ if (!projectPath || !filePath.includes("../"))
2610
+ return null;
2611
+ abs = path.normalize(projectPath.replace(/\/$/, "") + "/" + filePath);
2612
+ }
2613
+ if (isAgentOwnedPath(abs, configDirs))
2614
+ return null;
2418
2615
  if (!projectPath)
2419
- return false;
2420
- return filePath !== projectPath && !filePath.startsWith(projectPath.replace(/\/$/, "") + "/");
2616
+ return null;
2617
+ const root = projectPath.replace(/\/$/, "");
2618
+ return abs !== root && !abs.startsWith(root + "/") ? abs : null;
2421
2619
  }
2422
2620
  var SCOPE_RANK = { unknown: 0, loopback: 1, internal: 2, external: 3 };
2423
2621
  function netTargetScope(rawCommand) {
@@ -2697,6 +2895,34 @@ var RULES = [
2697
2895
  return { evidence: evidence(ctx.command) };
2698
2896
  }
2699
2897
  },
2898
+ {
2899
+ id: "obfuscated.decode_exec",
2900
+ category: "privilege-bypass",
2901
+ framework_ref: "OWASP LLM06",
2902
+ title: "Decodes and executes obfuscated content (base64 -d | sh)",
2903
+ tools: ["Bash"],
2904
+ baseSeverity: "high",
2905
+ test(ctx) {
2906
+ const c = ctx.commandBare;
2907
+ if (!c)
2908
+ return null;
2909
+ const decoder = /\b(base64\s+(?:-d|-D|--decode)|xxd\s+-r|openssl\s+(?:base64\s+-d|enc\b[^|]*\s-d\b)|uudecode)\b/i.exec(c);
2910
+ if (!decoder)
2911
+ return null;
2912
+ const downstream = c.slice(decoder.index + decoder[0].length);
2913
+ const piped = /\|\s*(?:sudo\s+)?(sh|bash|zsh|dash|ksh|python\d?|node|perl|ruby)\b([^|;&\n]*)/i.exec(downstream);
2914
+ if (!piped)
2915
+ return null;
2916
+ const interp = piped[1].toLowerCase();
2917
+ const args = piped[2] || "";
2918
+ if (/^(?:node|python|perl|ruby)/.test(interp) && /(?:^|\s)-(?:e|c|m|p)\b|(?:^|\s)--(?:eval|command|module|print)\b/.test(args))
2919
+ return null;
2920
+ const sudo = /\|\s*sudo\b/i.test(downstream);
2921
+ const d = decoder[0].toLowerCase();
2922
+ const decoderName = d.startsWith("base64") ? "base64" : d.startsWith("xxd") ? "xxd" : d.startsWith("openssl") ? "openssl" : "uudecode";
2923
+ return { evidence: evidence(ctx.command), severity: sudo ? "critical" : "high", mods: { decoder: decoderName, interpreter: interp } };
2924
+ }
2925
+ },
2700
2926
  {
2701
2927
  id: "privilege.sudo",
2702
2928
  category: "privilege-bypass",
@@ -2770,10 +2996,16 @@ var RULES = [
2770
2996
  // writing to /tmp, scratch dirs, adjacent repos is routine; only a system path (below) is high
2771
2997
  test(ctx) {
2772
2998
  const f = ctx.filePath;
2773
- if (!outsideProject(f, ctx.projectPath, ctx.ownedConfigDirs))
2999
+ const abs = outsideProjectPath(f, ctx.projectPath, ctx.ownedConfigDirs);
3000
+ if (!abs)
2774
3001
  return null;
2775
- const system = !!f && SYSTEM_PATH.test(f);
2776
- return { evidence: evidence(f), severity: system ? "high" : "low", mods: { project: ctx.projectPath, system_path: system } };
3002
+ const system = SYSTEM_PATH.test(abs);
3003
+ const traversal = !!f && !f.startsWith("/") && f.includes("../");
3004
+ return {
3005
+ evidence: evidence(f),
3006
+ severity: system ? "high" : "low",
3007
+ mods: { project: ctx.projectPath, system_path: system, ...traversal ? { traversal: true } : {} }
3008
+ };
2777
3009
  }
2778
3010
  }
2779
3011
  ];
@@ -2896,7 +3128,7 @@ function classifyErrors(db, dirty) {
2896
3128
  }
2897
3129
 
2898
3130
  // ../ingest/dist/pipeline.js
2899
- import { createHash as createHash2 } from "crypto";
3131
+ import { createHash as createHash3 } from "crypto";
2900
3132
  function newStats() {
2901
3133
  return { files: 0, skipped: 0, malformed: 0, newEvents: 0 };
2902
3134
  }
@@ -2934,7 +3166,7 @@ function skillSummary(body) {
2934
3166
  return null;
2935
3167
  }
2936
3168
  function skillVersionId(name, body) {
2937
- return createHash2("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
3169
+ return createHash3("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
2938
3170
  }
2939
3171
  function pruneExcluded(db, excludedPaths) {
2940
3172
  if (!excludedPaths.length)
@@ -3316,7 +3548,7 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
3316
3548
  }
3317
3549
  let projectId = null;
3318
3550
  if (sessionMeta.cwd) {
3319
- projectId = createHash2("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
3551
+ projectId = createHash3("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
3320
3552
  stmts.upsertProject.run({
3321
3553
  id: projectId,
3322
3554
  agent_id: adapter.agentId,
@@ -3355,7 +3587,7 @@ import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as
3355
3587
  import { basename as basename3, join as join5 } from "path";
3356
3588
 
3357
3589
  // ../ingest/dist/redact.js
3358
- import { createHash as createHash3, randomBytes } from "crypto";
3590
+ import { createHash as createHash4, randomBytes } from "crypto";
3359
3591
  function parseExcludes(csv) {
3360
3592
  return (csv ?? "").split(",").map((s) => s.trim()).filter(Boolean).map(encodeProjectPath);
3361
3593
  }
@@ -3364,17 +3596,17 @@ function isExcludedArchivePath(filePath, excluded) {
3364
3596
  }
3365
3597
 
3366
3598
  // ../ingest/dist/fileread.js
3367
- import { createHash as createHash4 } from "crypto";
3599
+ import { createHash as createHash5 } from "crypto";
3368
3600
  import { openSync as openSync2, readSync as readSync2, closeSync as closeSync2 } from "fs";
3369
3601
  import { StringDecoder } from "string_decoder";
3370
3602
  var STREAM_THRESHOLD = 8 * 1024 * 1024;
3371
3603
  var CHUNK2 = 64 * 1024;
3372
3604
  function sha256(buf) {
3373
- return createHash4("sha256").update(buf).digest("hex");
3605
+ return createHash5("sha256").update(buf).digest("hex");
3374
3606
  }
3375
- function sha256File(path) {
3376
- const fd = openSync2(path, "r");
3377
- const h = createHash4("sha256");
3607
+ function sha256File(path2) {
3608
+ const fd = openSync2(path2, "r");
3609
+ const h = createHash5("sha256");
3378
3610
  const buf = Buffer.allocUnsafe(CHUNK2);
3379
3611
  try {
3380
3612
  let n;
@@ -3385,8 +3617,8 @@ function sha256File(path) {
3385
3617
  }
3386
3618
  return h.digest("hex");
3387
3619
  }
3388
- function* streamLines(path) {
3389
- const fd = openSync2(path, "r");
3620
+ function* streamLines(path2) {
3621
+ const fd = openSync2(path2, "r");
3390
3622
  const decoder = new StringDecoder("utf8");
3391
3623
  const buf = Buffer.allocUnsafe(CHUNK2);
3392
3624
  let leftover = "";
@@ -3438,8 +3670,8 @@ function discoverSidecars(sourceArchiveDir) {
3438
3670
  }
3439
3671
  return out;
3440
3672
  }
3441
- function sessionFromPath(path) {
3442
- const parts = path.split("/");
3673
+ function sessionFromPath(path2) {
3674
+ const parts = path2.split("/");
3443
3675
  const i = parts.indexOf("workflows");
3444
3676
  return i > 0 ? parts[i - 1] : null;
3445
3677
  }
@@ -3486,25 +3718,25 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3486
3718
  total_tokens=excluded.total_tokens, total_tool_calls=excluded.total_tool_calls,
3487
3719
  duration_ms=excluded.duration_ms, started_at=excluded.started_at, ended_at=excluded.ended_at,
3488
3720
  ingested_at=excluded.ingested_at`);
3489
- for (const path of paths) {
3721
+ for (const path2 of paths) {
3490
3722
  stats.files++;
3491
- const st = statSync2(path);
3723
+ const st = statSync2(path2);
3492
3724
  const mtimeMs = Math.trunc(st.mtimeMs);
3493
- const prev = full ? void 0 : getState.get(path);
3725
+ const prev = full ? void 0 : getState.get(path2);
3494
3726
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
3495
3727
  stats.skipped++;
3496
3728
  continue;
3497
3729
  }
3498
3730
  let buf;
3499
3731
  try {
3500
- buf = readFileSync4(path);
3732
+ buf = readFileSync4(path2);
3501
3733
  } catch {
3502
3734
  stats.malformed++;
3503
3735
  continue;
3504
3736
  }
3505
- const hash = sha256(buf);
3506
- if (prev && prev.sha256 === hash) {
3507
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
3737
+ const hash2 = sha256(buf);
3738
+ if (prev && prev.sha256 === hash2) {
3739
+ setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3508
3740
  stats.skipped++;
3509
3741
  continue;
3510
3742
  }
@@ -3519,14 +3751,14 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3519
3751
  stats.malformed++;
3520
3752
  continue;
3521
3753
  }
3522
- const runId = strOrNull(d.runId) ?? basename3(path, ".json");
3754
+ const runId = strOrNull(d.runId) ?? basename3(path2, ".json");
3523
3755
  const startedAt = toIso(d.startTime) ?? toIso(d.timestamp);
3524
3756
  const durationMs2 = intOrNull(d.durationMs);
3525
3757
  const endedAt = startedAt && durationMs2 != null ? new Date(Date.parse(startedAt) + durationMs2).toISOString() : null;
3526
3758
  upsert.run({
3527
3759
  run_id: runId,
3528
3760
  source_id: sourceId,
3529
- session_id: sessionFromPath(path),
3761
+ session_id: sessionFromPath(path2),
3530
3762
  task_id: strOrNull(d.taskId),
3531
3763
  workflow_name: strOrNull(d.workflowName),
3532
3764
  status: strOrNull(d.status),
@@ -3544,7 +3776,7 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3544
3776
  ended_at: endedAt,
3545
3777
  ingested_at: now2
3546
3778
  });
3547
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
3779
+ setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3548
3780
  stats.upserted++;
3549
3781
  }
3550
3782
  }
@@ -3600,25 +3832,25 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3600
3832
  source_id=excluded.source_id, agent_type=excluded.agent_type,
3601
3833
  agent_description=excluded.agent_description, spawn_depth=excluded.spawn_depth,
3602
3834
  tool_use_id=excluded.tool_use_id, ingested_at=excluded.ingested_at`);
3603
- for (const path of paths) {
3835
+ for (const path2 of paths) {
3604
3836
  stats.files++;
3605
- const st = statSync3(path);
3837
+ const st = statSync3(path2);
3606
3838
  const mtimeMs = Math.trunc(st.mtimeMs);
3607
- const prev = full ? void 0 : getState.get(path);
3839
+ const prev = full ? void 0 : getState.get(path2);
3608
3840
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
3609
3841
  stats.skipped++;
3610
3842
  continue;
3611
3843
  }
3612
3844
  let buf;
3613
3845
  try {
3614
- buf = readFileSync5(path);
3846
+ buf = readFileSync5(path2);
3615
3847
  } catch {
3616
3848
  stats.malformed++;
3617
3849
  continue;
3618
3850
  }
3619
- const hash = sha256(buf);
3620
- if (prev && prev.sha256 === hash) {
3621
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
3851
+ const hash2 = sha256(buf);
3852
+ if (prev && prev.sha256 === hash2) {
3853
+ setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3622
3854
  stats.skipped++;
3623
3855
  continue;
3624
3856
  }
@@ -3633,7 +3865,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3633
3865
  stats.malformed++;
3634
3866
  continue;
3635
3867
  }
3636
- const sessionId = basename4(path, ".meta.json");
3868
+ const sessionId = basename4(path2, ".meta.json");
3637
3869
  upsert.run({
3638
3870
  session_id: sessionId,
3639
3871
  source_id: sourceId,
@@ -3643,7 +3875,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3643
3875
  tool_use_id: strOrNull2(d.toolUseId),
3644
3876
  ingested_at: now2
3645
3877
  });
3646
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
3878
+ setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3647
3879
  stats.upserted++;
3648
3880
  }
3649
3881
  }
@@ -3680,8 +3912,8 @@ function discoverResults(sourceArchiveDir) {
3680
3912
  }
3681
3913
  return out;
3682
3914
  }
3683
- function sessionFromPath2(path) {
3684
- const parts = path.split("/");
3915
+ function sessionFromPath2(path2) {
3916
+ const parts = path2.split("/");
3685
3917
  const i = parts.indexOf("tool-results");
3686
3918
  return i > 0 ? parts[i - 1] : null;
3687
3919
  }
@@ -3698,40 +3930,40 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now2, stats, full
3698
3930
  VALUES (@session_id, @name, @path, @bytes, @text, @ingested_at)
3699
3931
  ON CONFLICT(session_id, name) DO UPDATE SET
3700
3932
  path=excluded.path, bytes=excluded.bytes, text=excluded.text, ingested_at=excluded.ingested_at`);
3701
- for (const path of paths) {
3702
- const sessionId = sessionFromPath2(path);
3933
+ for (const path2 of paths) {
3934
+ const sessionId = sessionFromPath2(path2);
3703
3935
  if (!sessionId)
3704
3936
  continue;
3705
3937
  stats.files++;
3706
- const st = statSync4(path);
3938
+ const st = statSync4(path2);
3707
3939
  const mtimeMs = Math.trunc(st.mtimeMs);
3708
- const prev = full ? void 0 : getState.get(path);
3940
+ const prev = full ? void 0 : getState.get(path2);
3709
3941
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
3710
3942
  stats.skipped++;
3711
3943
  continue;
3712
3944
  }
3713
3945
  let buf;
3714
3946
  try {
3715
- buf = readFileSync6(path);
3947
+ buf = readFileSync6(path2);
3716
3948
  } catch {
3717
3949
  stats.malformed++;
3718
3950
  continue;
3719
3951
  }
3720
- const hash = sha256(buf);
3721
- if (prev && prev.sha256 === hash) {
3722
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
3952
+ const hash2 = sha256(buf);
3953
+ if (prev && prev.sha256 === hash2) {
3954
+ setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3723
3955
  stats.skipped++;
3724
3956
  continue;
3725
3957
  }
3726
3958
  upsert.run({
3727
3959
  session_id: sessionId,
3728
- name: basename5(path, ".txt"),
3729
- path,
3960
+ name: basename5(path2, ".txt"),
3961
+ path: path2,
3730
3962
  bytes: st.size,
3731
3963
  text: buf.toString("utf8"),
3732
3964
  ingested_at: now2
3733
3965
  });
3734
- setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
3966
+ setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3735
3967
  stats.upserted++;
3736
3968
  }
3737
3969
  }
@@ -3806,13 +4038,13 @@ function runIngest(argv = process.argv.slice(2)) {
3806
4038
  }
3807
4039
  const small = st.size <= STREAM_THRESHOLD;
3808
4040
  const buf = small ? readFileSync7(file.path) : null;
3809
- const hash = small ? sha256(buf) : sha256File(file.path);
3810
- if (prev && prev.sha256 === hash) {
4041
+ const hash2 = small ? sha256(buf) : sha256File(file.path);
4042
+ if (prev && prev.sha256 === hash2) {
3811
4043
  stmts.setState.run({
3812
4044
  file_path: file.path,
3813
4045
  size: st.size,
3814
4046
  mtime_ms: mtimeMs,
3815
- sha256: hash,
4047
+ sha256: hash2,
3816
4048
  events_ingested: prev.events_ingested,
3817
4049
  ingested_at: now2
3818
4050
  });
@@ -3820,7 +4052,7 @@ function runIngest(argv = process.argv.slice(2)) {
3820
4052
  continue;
3821
4053
  }
3822
4054
  const lines = small ? buf.toString("utf8").split("\n") : streamLines(file.path);
3823
- ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash }, now2, stats);
4055
+ ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash: hash2 }, now2, stats);
3824
4056
  dirty.add(file.sessionId);
3825
4057
  }
3826
4058
  ingestWorkflowResults(db, join8(archiveRoot, source.label), source.label, excludedDirs, now2, wfStats, args.full);
@@ -4462,6 +4694,34 @@ import { existsSync as existsSync6 } from "fs";
4462
4694
  import Fastify from "fastify";
4463
4695
  import fastifyStatic from "@fastify/static";
4464
4696
 
4697
+ // ../server/dist/export.js
4698
+ function parseRedactionLevel(raw) {
4699
+ return raw === "off" ? "off" : raw === "structure" ? "structure" : "secrets";
4700
+ }
4701
+ function renderSessionExport(db, id, level) {
4702
+ const result = getSession(db, id);
4703
+ if (!result)
4704
+ return null;
4705
+ const s = result.session;
4706
+ const events = result.events.map((e) => ({
4707
+ type: e.type,
4708
+ role: e.role,
4709
+ timestamp: e.timestamp,
4710
+ text: e.text,
4711
+ thinking: e.thinking,
4712
+ toolCalls: e.toolCalls.map((t) => ({
4713
+ tool_name: t.tool_name,
4714
+ skill_name: t.skill_name,
4715
+ agent_type: t.agent_type,
4716
+ input_json: t.input_json,
4717
+ status: t.status
4718
+ }))
4719
+ }));
4720
+ const { markdown } = exportMarkdown({ id: s.id, title: s.title, source: s.source_id, project: s.project_path, model: null, started_at: s.started_at, ended_at: s.ended_at }, events, { level });
4721
+ const suffix = level === "off" ? "" : ".redacted";
4722
+ return { markdown, filename: `session-${id.slice(0, 8)}${suffix}.md` };
4723
+ }
4724
+
4465
4725
  // ../server/dist/dashboard.js
4466
4726
  function sessionWhere(f) {
4467
4727
  const where = [];
@@ -4718,6 +4978,14 @@ function originAllowed(origin) {
4718
4978
  return false;
4719
4979
  }
4720
4980
  }
4981
+ function writeBlocked(headers) {
4982
+ const site = headers["sec-fetch-site"];
4983
+ const s = Array.isArray(site) ? site[0] : site;
4984
+ if (typeof s === "string" && s !== "same-origin" && s !== "none")
4985
+ return true;
4986
+ const origin = headers["origin"];
4987
+ return !originAllowed(Array.isArray(origin) ? origin[0] : origin);
4988
+ }
4721
4989
  function runRefresh() {
4722
4990
  const dataDir = resolveDataDir(findRepoRoot());
4723
4991
  const lock = acquireLock(join9(dataDir, ".agent-lens.lock"));
@@ -4823,8 +5091,24 @@ function setPref(db, key, value) {
4823
5091
  }
4824
5092
 
4825
5093
  // ../server/dist/app.js
5094
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
4826
5095
  async function createApp(db, opts = {}) {
4827
5096
  const app = Fastify({ logger: false });
5097
+ if (opts.enforceLoopbackHost !== false) {
5098
+ app.addHook("onRequest", async (req, reply) => {
5099
+ const host = (req.headers.host ?? "").replace(/:\d+$/, "");
5100
+ if (!LOOPBACK_HOSTS.has(host)) {
5101
+ return reply.code(403).send({ error: { code: "FORBIDDEN_HOST", message: "non-loopback Host rejected" } });
5102
+ }
5103
+ });
5104
+ }
5105
+ app.addHook("onSend", async (_req, reply, payload) => {
5106
+ reply.header("X-Frame-Options", "DENY");
5107
+ reply.header("X-Content-Type-Options", "nosniff");
5108
+ reply.header("Referrer-Policy", "no-referrer");
5109
+ reply.header("Content-Security-Policy", "frame-ancestors 'none'");
5110
+ return payload;
5111
+ });
4828
5112
  let triageDb = null;
4829
5113
  if (opts.triageDbPath) {
4830
5114
  triageDb = openTriage(opts.triageDbPath);
@@ -4833,7 +5117,7 @@ async function createApp(db, opts = {}) {
4833
5117
  app.addHook("onClose", async () => triageDb?.close());
4834
5118
  }
4835
5119
  const guardWrite = (req, reply) => {
4836
- if (!originAllowed(req.headers.origin)) {
5120
+ if (writeBlocked(req.headers)) {
4837
5121
  reply.code(403).send({ error: { code: "FORBIDDEN_ORIGIN", message: "cross-origin write blocked" } });
4838
5122
  return false;
4839
5123
  }
@@ -4848,14 +5132,15 @@ async function createApp(db, opts = {}) {
4848
5132
  return { ok: true, last_ingested: lastIngested(db), schema_version: s.db_version, schema_stale: s.stale };
4849
5133
  });
4850
5134
  app.post("/api/refresh", async (req, reply) => {
4851
- if (!originAllowed(req.headers.origin)) {
5135
+ if (writeBlocked(req.headers)) {
4852
5136
  return reply.code(403).send({ error: { code: "FORBIDDEN_ORIGIN", message: "cross-origin refresh blocked" } });
4853
5137
  }
4854
5138
  let result;
4855
5139
  try {
4856
5140
  result = runRefresh();
4857
5141
  } catch (e) {
4858
- return reply.code(500).send({ error: { code: "REFRESH_FAILED", message: String(e?.message ?? e) } });
5142
+ console.error("agent-lens-server: refresh failed:", e);
5143
+ return reply.code(500).send({ error: { code: "REFRESH_FAILED", message: "refresh failed; see server logs" } });
4859
5144
  }
4860
5145
  if (!result) {
4861
5146
  return reply.code(409).send({ error: { code: "REFRESH_IN_PROGRESS", message: "a collect/ingest run is already in progress" } });
@@ -5006,34 +5291,11 @@ async function createApp(db, opts = {}) {
5006
5291
  });
5007
5292
  app.get("/api/sessions/:id/export.md", async (req, reply) => {
5008
5293
  const { id } = req.params;
5009
- const result = getSession(db, id);
5010
- if (!result)
5294
+ const level = parseRedactionLevel(req.query.redact);
5295
+ const out = renderSessionExport(db, id, level);
5296
+ if (!out)
5011
5297
  return reply.code(404).send({ error: "not found" });
5012
- const s = result.session;
5013
- const events = result.events.map((e) => ({
5014
- type: e.type,
5015
- role: e.role,
5016
- timestamp: e.timestamp,
5017
- text: e.text,
5018
- thinking: e.thinking,
5019
- toolCalls: e.toolCalls.map((t) => ({
5020
- tool_name: t.tool_name,
5021
- skill_name: t.skill_name,
5022
- agent_type: t.agent_type,
5023
- input_json: t.input_json,
5024
- status: t.status
5025
- }))
5026
- }));
5027
- const md = sessionToMarkdown({
5028
- id: s.id,
5029
- title: s.title,
5030
- source: s.source_id,
5031
- project: s.project_path,
5032
- model: null,
5033
- started_at: s.started_at,
5034
- ended_at: s.ended_at
5035
- }, events);
5036
- reply.header("content-type", "text/markdown; charset=utf-8").header("content-disposition", `attachment; filename="session-${id.slice(0, 8)}.md"`).send(md);
5298
+ reply.header("content-type", "text/markdown; charset=utf-8").header("content-disposition", `attachment; filename="${out.filename}"`).send(out.markdown);
5037
5299
  });
5038
5300
  if (opts.webDist && existsSync6(opts.webDist)) {
5039
5301
  await app.register(fastifyStatic, { root: opts.webDist });
@@ -5064,7 +5326,8 @@ async function startServer() {
5064
5326
  }
5065
5327
  const triageDbPath = process.env.AGENT_LENS_TRIAGE_DB || join10(dirname5(dbPath), "triage.db");
5066
5328
  const db = openReadonly(dbPath);
5067
- const app = await createApp(db, { webDist, triageDbPath });
5329
+ const enforceLoopbackHost = host === "127.0.0.1" || host === "localhost";
5330
+ const app = await createApp(db, { webDist, triageDbPath, enforceLoopbackHost });
5068
5331
  try {
5069
5332
  await app.listen({ host, port });
5070
5333
  console.log(`agent-lens-server: http://${host}:${port} (db: ${dbPath})`);
@@ -5188,6 +5451,25 @@ cli.command("watch", "Watch sources and collect + ingest on change (resident pro
5188
5451
  cli.command("metrics", "Re-run classification over an already-ingested DB").option("--db <path>", "SQLite DB path").action((opts) => {
5189
5452
  runMetrics(opts.db ? ["--db", opts.db] : []);
5190
5453
  });
5454
+ cli.command("export <sessionId>", "Write a session to a shareable Markdown file (redacted by default)").option("--out <file>", "Write to this file (default: stdout)").option("--level <level>", "Redaction level: secrets (default) | structure").option("--no-redact", "Verbatim, UNREDACTED output (opt-out)").option("--db <path>", "SQLite DB path").action((sessionId, opts) => {
5455
+ const dbPath = opts.db || process.env.AGENT_LENS_DB || join12(resolveDataDir(findRepoRoot()), "agent-lens.db");
5456
+ if (!existsSync9(dbPath)) {
5457
+ console.error(`agent-lens: db not found: ${dbPath} (run ingest first)`);
5458
+ process.exit(1);
5459
+ }
5460
+ const level = opts.redact === false ? "off" : parseRedactionLevel(opts.level);
5461
+ const out = renderSessionExport(openReadonly(dbPath), sessionId, level);
5462
+ if (!out) {
5463
+ console.error(`agent-lens: session not found: ${sessionId}`);
5464
+ process.exit(1);
5465
+ }
5466
+ if (opts.out) {
5467
+ writeFileSync3(opts.out, out.markdown);
5468
+ console.error(`agent-lens: wrote ${out.filename} \u2192 ${opts.out} (redaction: ${level})`);
5469
+ } else {
5470
+ process.stdout.write(out.markdown);
5471
+ }
5472
+ });
5191
5473
  cli.command(
5192
5474
  "service <action> [target]",
5193
5475
  "Install/uninstall/status OS services \u2014 action: install|uninstall|status; target: collector|server|all (default all)"