@stroq/cli 0.14.1 → 0.15.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.
package/dist/index.js CHANGED
@@ -549,10 +549,10 @@ function decodeLayer(text, depth, maxDepth) {
549
549
  return found;
550
550
  }
551
551
  function expandVariants(text, maxDepth = 2) {
552
- const normalized = normalizeText(text);
552
+ const normalized2 = normalizeText(text);
553
553
  const base = [{ kind: "raw", depth: 0, text }];
554
- if (normalized !== text) base.push({ kind: "normalized", depth: 0, text: normalized });
555
- return [...base, ...decodeLayer(normalized, 1, maxDepth)];
554
+ if (normalized2 !== text) base.push({ kind: "normalized", depth: 0, text: normalized2 });
555
+ return [...base, ...decodeLayer(normalized2, 1, maxDepth)];
556
556
  }
557
557
 
558
558
  // ../core/src/rules/atr-types.ts
@@ -1255,7 +1255,14 @@ var SEVERITY_WEIGHT = {
1255
1255
  };
1256
1256
  var DEFAULT_THRESHOLD = 0.6;
1257
1257
  var DEFAULT_MAX_CHARS = 2e5;
1258
- var DEFAULT_BUDGET_MS = 500;
1258
+ var DEFAULT_BUDGET_MS = 4e3;
1259
+ var SCAN_BUDGET_ENV = "STROQ_SCAN_BUDGET_MS";
1260
+ function budgetFromEnv() {
1261
+ const raw = process.env[SCAN_BUDGET_ENV];
1262
+ if (raw === void 0) return void 0;
1263
+ const parsed = Number(raw);
1264
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1265
+ }
1259
1266
  var ENCODED_FLOOR = 0.7;
1260
1267
  var BUDGET_MATCH = {
1261
1268
  ruleId: "STROQ-SCAN-BUDGET",
@@ -1275,7 +1282,7 @@ function timedOutResult(matches) {
1275
1282
  function scanContent(rules, text, opts = {}, context = {}) {
1276
1283
  const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
1277
1284
  const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
1278
- const budgetMs = opts.budgetMs ?? DEFAULT_BUDGET_MS;
1285
+ const budgetMs = opts.budgetMs ?? budgetFromEnv() ?? DEFAULT_BUDGET_MS;
1279
1286
  const startedAt = performance.now();
1280
1287
  const input = text.length > maxChars ? text.slice(0, maxChars) : text;
1281
1288
  const seen = /* @__PURE__ */ new Set();
@@ -1311,13 +1318,13 @@ function isDangerousRmTarget(target, cwd) {
1311
1318
  if (t.startsWith("~") || t.startsWith("$") || t.startsWith("..")) return true;
1312
1319
  if (UNC_PATH.test(t)) return true;
1313
1320
  if (WINDOWS_DRIVE_PATH.test(t)) {
1314
- const normalized2 = foldPath(t).toLowerCase();
1321
+ const normalized3 = foldPath(t).toLowerCase();
1315
1322
  const root = foldPath(cwd).toLowerCase();
1316
- return normalized2 === "" || !normalized2.startsWith(`${root}/`);
1323
+ return normalized3 === "" || !normalized3.startsWith(`${root}/`);
1317
1324
  }
1318
1325
  if (!t.startsWith("/")) return false;
1319
- const normalized = t.replace(/\/+$/, "");
1320
- return !normalized.startsWith(`${cwd}/`);
1326
+ const normalized2 = t.replace(/\/+$/, "");
1327
+ return !normalized2.startsWith(`${cwd}/`);
1321
1328
  }
1322
1329
 
1323
1330
  // ../core/src/actions/classify-powershell.ts
@@ -1450,14 +1457,14 @@ function matchingParenEnd(text, start) {
1450
1457
  }
1451
1458
  function extractSubstitutions(command) {
1452
1459
  const results = [];
1453
- for (const open of OPEN_SUBSTITUTIONS) {
1454
- let idx = command.indexOf(open);
1460
+ for (const open3 of OPEN_SUBSTITUTIONS) {
1461
+ let idx = command.indexOf(open3);
1455
1462
  while (idx !== -1) {
1456
- const start = idx + open.length;
1463
+ const start = idx + open3.length;
1457
1464
  const end = matchingParenEnd(command, start);
1458
1465
  if (end === -1) break;
1459
1466
  results.push(command.slice(start, end));
1460
- idx = command.indexOf(open, end);
1467
+ idx = command.indexOf(open3, end);
1461
1468
  }
1462
1469
  }
1463
1470
  const backtickPairs = command.match(/`[^`]*`/g) ?? [];
@@ -1467,6 +1474,22 @@ function extractSubstitutions(command) {
1467
1474
  function splitTop(command) {
1468
1475
  return command.split(SEGMENT_SPLIT).map((s) => s.trim()).filter((s) => s.length > 0);
1469
1476
  }
1477
+ var SEQUENCE_SPLIT = /\|\||&&|;|\n/;
1478
+ function pipelinesOf(command) {
1479
+ return command.split(SEQUENCE_SPLIT).map(
1480
+ (run) => run.split("|").map((stage) => stage.trim()).filter((stage) => stage.length > 0)
1481
+ ).filter((stages) => stages.length > 0);
1482
+ }
1483
+ function splitPipelines(command) {
1484
+ return [
1485
+ ...pipelinesOf(command),
1486
+ ...extractSubstitutions(command).flatMap(pipelinesOf),
1487
+ ...extractShCStrings(command).flatMap(pipelinesOf),
1488
+ ...extractFindExecCommands(command).flatMap(pipelinesOf),
1489
+ ...extractEvalArguments(command).flatMap(pipelinesOf),
1490
+ ...extractGitForeachOrBisectRunCommands(command).flatMap(pipelinesOf)
1491
+ ];
1492
+ }
1470
1493
  var SH_C_QUOTE = /\b(?:sh|bash|zsh|dash|ksh)\s+-c\s+(["'])/g;
1471
1494
  var IEX_QUOTE = /\b(?:iex|Invoke-Expression)\s+(["'])/gi;
1472
1495
  function extractQuotedBodies(command, pattern) {
@@ -1963,19 +1986,28 @@ function isNetwork(seg) {
1963
1986
  if (/\/dev\/tcp\//.test(seg)) return true;
1964
1987
  return isUnknownWrapperNetworkCall(seg, word);
1965
1988
  }
1966
- function encodedExecSignals(segments) {
1967
- return segments.flatMap((seg, i) => {
1968
- const later = segments.slice(i + 1);
1969
- const signals = [];
1970
- if (DECODE.test(seg) && later.some(isShell)) signals.push("decode-pipe-shell");
1971
- if (isNetwork(seg) && later.some(isShell)) signals.push("remote-pipe-shell");
1972
- if (EVAL_DYNAMIC.test(seg)) signals.push("eval-dynamic");
1973
- if (INLINE_INTERP.test(seg) && INLINE_PAYLOAD.test(seg))
1974
- signals.push("inline-interpreter-payload");
1975
- if (SHELL_C_REMOTE.test(seg)) signals.push("shell-c-remote");
1976
- if (SHELL_PROC_SUB_REMOTE.test(seg)) signals.push("shell-proc-sub-remote");
1977
- return signals;
1978
- });
1989
+ function encodedExecSignals(segments, pipelines) {
1990
+ const piped = pipelines.flatMap(
1991
+ (stages) => stages.flatMap((stage, i) => {
1992
+ const downstream = stages.slice(i + 1);
1993
+ const signals = [];
1994
+ if (DECODE.test(stage) && downstream.some(isShell)) signals.push("decode-pipe-shell");
1995
+ if (isNetwork(stage) && downstream.some(isShell)) signals.push("remote-pipe-shell");
1996
+ return signals;
1997
+ })
1998
+ );
1999
+ return [
2000
+ ...piped,
2001
+ ...segments.flatMap((seg) => {
2002
+ const signals = [];
2003
+ if (EVAL_DYNAMIC.test(seg)) signals.push("eval-dynamic");
2004
+ if (INLINE_INTERP.test(seg) && INLINE_PAYLOAD.test(seg))
2005
+ signals.push("inline-interpreter-payload");
2006
+ if (SHELL_C_REMOTE.test(seg)) signals.push("shell-c-remote");
2007
+ if (SHELL_PROC_SUB_REMOTE.test(seg)) signals.push("shell-proc-sub-remote");
2008
+ return signals;
2009
+ })
2010
+ ];
1979
2011
  }
1980
2012
  function destructiveSignals(segments, cwd) {
1981
2013
  return segments.flatMap((seg) => {
@@ -2008,7 +2040,10 @@ function classifyCommand(command, cwd) {
2008
2040
  const selfConfig = selfTamperSignals(segments);
2009
2041
  const ps = powershellSignals(segments, cwd);
2010
2042
  const groups = [
2011
- ["shell.exec_encoded", [...encodedExecSignals(segments), ...ps.encoded]],
2043
+ [
2044
+ "shell.exec_encoded",
2045
+ [...encodedExecSignals(segments, splitPipelines(command)), ...ps.encoded]
2046
+ ],
2012
2047
  ["shell.network", [...segments.filter(isNetwork).map(() => "network-command"), ...ps.network]],
2013
2048
  ["shell.destructive", [...destructiveSignals(segments, cwd), ...ps.destructive]],
2014
2049
  ["fs.secrets", [...secretSignals(segments), ...ps.secrets]],
@@ -3807,6 +3842,25 @@ function collectStrings(value, depth = 0) {
3807
3842
  }
3808
3843
  return [];
3809
3844
  }
3845
+ function collectKeyedStrings(value, key2 = null, into = /* @__PURE__ */ new Map(), depth = 0) {
3846
+ if (depth > MAX_JSON_DEPTH) return into;
3847
+ if (typeof value === "string") {
3848
+ const keys = into.get(value) ?? /* @__PURE__ */ new Set();
3849
+ if (key2 !== null) keys.add(key2);
3850
+ into.set(value, keys);
3851
+ return into;
3852
+ }
3853
+ if (Array.isArray(value)) {
3854
+ for (const item of value) collectKeyedStrings(item, key2, into, depth + 1);
3855
+ return into;
3856
+ }
3857
+ if (value !== null && typeof value === "object") {
3858
+ for (const [childKey, item] of Object.entries(value)) {
3859
+ collectKeyedStrings(item, childKey, into, depth + 1);
3860
+ }
3861
+ }
3862
+ return into;
3863
+ }
3810
3864
  function mapStrings(value, fn, depth = 0) {
3811
3865
  if (depth > MAX_JSON_DEPTH) return value;
3812
3866
  if (typeof value === "string") return fn(value);
@@ -3822,19 +3876,182 @@ function mapStrings(value, fn, depth = 0) {
3822
3876
  return value;
3823
3877
  }
3824
3878
 
3879
+ // ../core/src/cloak/keyed.ts
3880
+ var normalizeKey = (key2) => key2.toLowerCase().replace(/[^a-z0-9]/g, "");
3881
+ var PERSON_KEYS = /* @__PURE__ */ new Set([
3882
+ "firstname",
3883
+ "lastname",
3884
+ "fullname",
3885
+ "middlename",
3886
+ "givenname",
3887
+ "familyname",
3888
+ "surname",
3889
+ "displayname",
3890
+ "legalname",
3891
+ "personname",
3892
+ "contactname",
3893
+ "customername",
3894
+ "clientname",
3895
+ "employeename",
3896
+ "patientname",
3897
+ "authorname",
3898
+ "ownername",
3899
+ "recipientname",
3900
+ "sendername",
3901
+ "attendee",
3902
+ "attendees",
3903
+ "attendeename"
3904
+ ]);
3905
+ var PLACE_KEYS = /* @__PURE__ */ new Set([
3906
+ "streetaddress",
3907
+ "street",
3908
+ "addressline1",
3909
+ "addressline2",
3910
+ "address1",
3911
+ "address2",
3912
+ "homeaddress",
3913
+ "billingaddress",
3914
+ "shippingaddress",
3915
+ "mailingaddress",
3916
+ "postaladdress",
3917
+ "deliveryaddress"
3918
+ ]);
3919
+ var MAX_NAME_CHARS = 200;
3920
+ var MAX_ADDRESS_CHARS = 500;
3921
+ function detectKeyedFields(text, keys) {
3922
+ if (text.trim() === "") return [];
3923
+ let kind = null;
3924
+ for (const key2 of keys) {
3925
+ const normalized2 = normalizeKey(key2);
3926
+ if (PERSON_KEYS.has(normalized2)) {
3927
+ kind = "name";
3928
+ break;
3929
+ }
3930
+ if (PLACE_KEYS.has(normalized2)) kind = "address";
3931
+ }
3932
+ if (kind === null) return [];
3933
+ if (text.length > (kind === "name" ? MAX_NAME_CHARS : MAX_ADDRESS_CHARS)) return [];
3934
+ return [{ kind, start: 0, end: text.length, value: text, restorable: true }];
3935
+ }
3936
+
3937
+ // ../core/src/cloak/prose-names.ts
3938
+ var MIN_PART_CHARS = 3;
3939
+ var MAX_NEEDLES = 64;
3940
+ var ALSO_ORDINARY = /* @__PURE__ */ new Set([
3941
+ "Amber",
3942
+ "Art",
3943
+ "Autumn",
3944
+ "Bill",
3945
+ "Bob",
3946
+ "Buck",
3947
+ "Carol",
3948
+ "Chase",
3949
+ "Chuck",
3950
+ "Daisy",
3951
+ "Dawn",
3952
+ "Don",
3953
+ "Drew",
3954
+ "Earl",
3955
+ "Faith",
3956
+ "Frank",
3957
+ "Grace",
3958
+ "Guy",
3959
+ "Hope",
3960
+ "Hunter",
3961
+ "Jack",
3962
+ "Joy",
3963
+ "June",
3964
+ "Major",
3965
+ "Mark",
3966
+ "Max",
3967
+ "May",
3968
+ "Mercy",
3969
+ "Nick",
3970
+ "Olive",
3971
+ "Page",
3972
+ "Pat",
3973
+ "Pearl",
3974
+ "Rose",
3975
+ "Ruby",
3976
+ "Sky",
3977
+ "Stone",
3978
+ "Sue",
3979
+ "Summer",
3980
+ "Wade",
3981
+ "Ward",
3982
+ "Will"
3983
+ ]);
3984
+ var NOT_WORD = "[^\\p{L}\\p{N}_]";
3985
+ var SENTENCE_END = /* @__PURE__ */ new Set([".", "!", "?", "\n", "\r", ";", ":", "\u2022", "-", "*"]);
3986
+ var escapeLiteral = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3987
+ function nameNeedles(names) {
3988
+ const out = /* @__PURE__ */ new Set();
3989
+ for (const name of names) {
3990
+ const trimmed = name.trim();
3991
+ if (trimmed === "") continue;
3992
+ out.add(trimmed);
3993
+ for (const part of trimmed.split(/[^\p{L}\p{N}]+/u)) {
3994
+ if (part.length >= MIN_PART_CHARS) out.add(part);
3995
+ }
3996
+ }
3997
+ return [...out].sort((a, b) => b.length - a.length || (a < b ? -1 : 1)).slice(0, MAX_NEEDLES);
3998
+ }
3999
+ function startsSentence(text, index) {
4000
+ for (let i = index - 1; i >= 0; i -= 1) {
4001
+ const char = text[i] ?? "";
4002
+ if (char === " " || char === " ") continue;
4003
+ return SENTENCE_END.has(char);
4004
+ }
4005
+ return true;
4006
+ }
4007
+ function compileNameMatcher(needles) {
4008
+ if (needles.length === 0) return null;
4009
+ const source = `(?<=^|${NOT_WORD})(?:${needles.map(escapeLiteral).join("|")})(?=$|${NOT_WORD})`;
4010
+ return (text) => {
4011
+ if (text === "") return [];
4012
+ const spans = [];
4013
+ for (const match of text.matchAll(new RegExp(source, "gu"))) {
4014
+ const value = match[0];
4015
+ const start = match.index;
4016
+ if (ALSO_ORDINARY.has(value) && startsSentence(text, start)) continue;
4017
+ spans.push({ kind: "name", start, end: start + value.length, value, restorable: true });
4018
+ }
4019
+ return spans;
4020
+ };
4021
+ }
4022
+
3825
4023
  // ../core/src/cloak/detector.ts
3826
4024
  var MAX_CLOAK_CHARS = MAX_SCAN_CHARS;
3827
4025
  function createCloakDetector(options) {
3828
4026
  return {
3829
- async detect(text) {
4027
+ async detect(text, keys) {
3830
4028
  if (text === "" || text.length > MAX_CLOAK_CHARS) return [];
3831
- const patterns = detectPatterns(text);
3832
- if (!options.secrets) return patterns;
4029
+ const shapes = [...detectPatterns(text), ...detectKeyedFields(text, keys ?? /* @__PURE__ */ new Set())];
4030
+ if (!options.secrets) return mergeSpans(shapes);
3833
4031
  const matches = await options.secrets.lookup(candidatesFromText(text), options.cwd);
3834
- return mergeSpans([...secretSpans(text, matches), ...patterns]);
4032
+ return mergeSpans([...secretSpans(text, matches), ...shapes]);
3835
4033
  }
3836
4034
  };
3837
4035
  }
4036
+ async function detectAcrossLeaves(leaves, detector) {
4037
+ const found = /* @__PURE__ */ new Map();
4038
+ const names = /* @__PURE__ */ new Set();
4039
+ for (const [text, keys] of leaves) {
4040
+ const spans = mergeSpans(await detector.detect(text, keys));
4041
+ if (spans.length > 0) found.set(text, spans);
4042
+ for (const span of spans) if (span.kind === "name") names.add(span.value);
4043
+ }
4044
+ if (names.size === 0) return found;
4045
+ const matcher = compileNameMatcher(nameNeedles(names));
4046
+ if (matcher === null) return found;
4047
+ for (const text of leaves.keys()) {
4048
+ const prose = matcher(text);
4049
+ if (prose.length === 0) continue;
4050
+ const merged = mergeSpans([...found.get(text) ?? [], ...prose]);
4051
+ found.set(text, merged);
4052
+ }
4053
+ return found;
4054
+ }
3838
4055
 
3839
4056
  // ../core/src/cloak/store.ts
3840
4057
  import { mkdir as mkdir6, readFile as readFile5, rename as rename4, writeFile as writeFile4 } from "fs/promises";
@@ -4556,7 +4773,7 @@ import { parseArgs as parseArgs2 } from "util";
4556
4773
 
4557
4774
  // src/bench/run.ts
4558
4775
  import { closeSync, existsSync as existsSync2, openSync, readSync, readdirSync as readdirSync4, statSync as statSync4 } from "fs";
4559
- import { join as join8 } from "path";
4776
+ import { basename as basename2, join as join8 } from "path";
4560
4777
  import { fileURLToPath } from "url";
4561
4778
  var TEXT = /\.(?:md|rst|txt|adoc)$/i;
4562
4779
  var BENCH_BUDGET_MS = 6e4;
@@ -4600,15 +4817,23 @@ function runBench(dir) {
4600
4817
  const flaggedFiles = [];
4601
4818
  let bytes = 0;
4602
4819
  let timedOut = 0;
4820
+ let slowestMs = 0;
4821
+ let slowestFile = "";
4603
4822
  for (const file of files) {
4604
4823
  const size = statSync4(file).size;
4605
4824
  bytes += size;
4825
+ const startedAt = performance.now();
4606
4826
  const result = scanContent(
4607
4827
  rules,
4608
4828
  readPrefix(file, MAX_READ_BYTES),
4609
4829
  { budgetMs: BENCH_BUDGET_MS },
4610
4830
  { target: "repo_content" }
4611
4831
  );
4832
+ const elapsed = performance.now() - startedAt;
4833
+ if (elapsed > slowestMs) {
4834
+ slowestMs = elapsed;
4835
+ slowestFile = file;
4836
+ }
4612
4837
  if (result.timedOut) timedOut += 1;
4613
4838
  if (result.verdict !== "suspect") continue;
4614
4839
  flaggedFiles.push(file);
@@ -4632,6 +4857,8 @@ function runBench(dir) {
4632
4857
  flagged: flaggedFiles.length,
4633
4858
  rate: files.length === 0 ? 0 : flaggedFiles.length / files.length,
4634
4859
  timedOut,
4860
+ slowestMs,
4861
+ slowestFile,
4635
4862
  byRule: [...perRule.values()].sort((a, b) => b.files - a.files),
4636
4863
  flaggedFiles
4637
4864
  };
@@ -4648,6 +4875,14 @@ function formatBench(report, opts = {}) {
4648
4875
  `timedOut: ${report.timedOut} ${noun} hit the bench's scan budget and were forced suspect by that alone, not by a rule match \u2014 see docs/BENCH.md's Method section.`
4649
4876
  );
4650
4877
  }
4878
+ if (opts.timings === true) {
4879
+ const headroom = DEFAULT_BUDGET_MS / Math.max(report.slowestMs, 1e-3);
4880
+ const verdict = headroom < 2 ? " ** raise DEFAULT_BUDGET_MS **" : "";
4881
+ const which = opts.verbose === true ? ` (${basename2(report.slowestFile)})` : "";
4882
+ lines.push(
4883
+ `slowest: ${report.slowestMs.toFixed(0)} ms${which} against production's ${DEFAULT_BUDGET_MS} ms budget \u2014 ${headroom.toFixed(1)}x headroom${verdict}`
4884
+ );
4885
+ }
4651
4886
  if (report.byRule.length === 0) {
4652
4887
  lines.push("", "Nothing in this corpus trips a rule.");
4653
4888
  } else {
@@ -4686,7 +4921,7 @@ async function runBenchCommand(args) {
4686
4921
  const report = runBench(dir);
4687
4922
  process.stdout.write(
4688
4923
  values.json ? `${JSON.stringify(report, null, 2)}
4689
- ` : formatBench(report, { verbose: values.verbose })
4924
+ ` : formatBench(report, { verbose: values.verbose, timings: true })
4690
4925
  );
4691
4926
  return 0;
4692
4927
  }
@@ -6032,7 +6267,7 @@ function runOpenClawInstall(bin, dir, run = spawnCommand) {
6032
6267
 
6033
6268
  // src/commands/init-mcp.ts
6034
6269
  import { existsSync as existsSync8, statSync as statSync6 } from "fs";
6035
- import { basename as basename2, resolve as resolve3 } from "path";
6270
+ import { basename as basename3, resolve as resolve3 } from "path";
6036
6271
 
6037
6272
  // src/commands/mcp-config.ts
6038
6273
  import { existsSync as existsSync7 } from "fs";
@@ -6198,7 +6433,7 @@ var MCP_NOTE = 'Restart the MCP client before this takes effect: it launches its
6198
6433
  function mcpTarget(client, configPath, scope) {
6199
6434
  if (client === void 0) {
6200
6435
  const file = resolve3(configPath ?? "");
6201
- return { file, label: basename2(file) };
6436
+ return { file, label: basename3(file) };
6202
6437
  }
6203
6438
  if (!isMcpClient(client)) return null;
6204
6439
  return { file: mcpConfigPath(client, scope), label: client };
@@ -7319,8 +7554,8 @@ var MAX_CONFIG_BYTES = 256 * 1024;
7319
7554
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".venv", "venv", "vendor", "target", "dist", "build"]);
7320
7555
  var readTextOr = (file, fallback = "") => {
7321
7556
  try {
7322
- const stat3 = statSync8(file);
7323
- if (!stat3.isFile() || stat3.size > MAX_CONFIG_BYTES) return fallback;
7557
+ const stat4 = statSync8(file);
7558
+ if (!stat4.isFile() || stat4.size > MAX_CONFIG_BYTES) return fallback;
7324
7559
  return readFileSync15(file, "utf8");
7325
7560
  } catch {
7326
7561
  return fallback;
@@ -7377,9 +7612,9 @@ function gitAttributesHits(root) {
7377
7612
  }
7378
7613
  function runsAsGitHook(file, plat) {
7379
7614
  try {
7380
- const stat3 = statSync8(file);
7381
- if (!stat3.isFile()) return false;
7382
- return plat === "win32" || (stat3.mode & 73) !== 0;
7615
+ const stat4 = statSync8(file);
7616
+ if (!stat4.isFile()) return false;
7617
+ return plat === "win32" || (stat4.mode & 73) !== 0;
7383
7618
  } catch {
7384
7619
  return false;
7385
7620
  }
@@ -8761,11 +8996,7 @@ var McpCloak = class {
8761
8996
  kind: "refused",
8762
8997
  reason: `the result serialises to ${serialised.length} characters, more than the ${MAX_CLOAK_CHARS} Stroq can scan whole`
8763
8998
  };
8764
- const spansByText = /* @__PURE__ */ new Map();
8765
- for (const text of new Set(collectStrings(result))) {
8766
- const spans = mergeSpans(await this.opts.detector.detect(text));
8767
- if (spans.length > 0) spansByText.set(text, spans);
8768
- }
8999
+ const spansByText = await detectAcrossLeaves(collectKeyedStrings(result), this.opts.detector);
8769
9000
  if (spansByText.size === 0) return { kind: "unchanged" };
8770
9001
  const requests = [];
8771
9002
  for (const spans of spansByText.values()) {
@@ -9753,7 +9984,9 @@ import { join as join23 } from "path";
9753
9984
  import { parseArgs as parseArgs8 } from "util";
9754
9985
 
9755
9986
  // src/replay/transcript.ts
9756
- import { readdir, readFile as readFile6, stat as stat2 } from "fs/promises";
9987
+ import { createReadStream } from "fs";
9988
+ import { readdir, stat as stat2 } from "fs/promises";
9989
+ import { createInterface } from "readline";
9757
9990
  import { homedir as homedir19 } from "os";
9758
9991
  import { join as join22 } from "path";
9759
9992
  var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -9772,24 +10005,24 @@ function blocksOf(record) {
9772
10005
  const content = message["content"];
9773
10006
  return Array.isArray(content) ? content.filter(isRecord2) : [];
9774
10007
  }
9775
- function parseTranscript(text) {
10008
+ function createTranscriptParser() {
9776
10009
  const events = [];
9777
10010
  const pending = /* @__PURE__ */ new Map();
9778
10011
  let sessionId = "transcript";
9779
10012
  let cwd = null;
9780
10013
  let skipped = 0;
9781
- for (const line3 of text.split("\n")) {
9782
- if (line3.trim().length === 0) continue;
10014
+ function push(line3) {
10015
+ if (line3.trim().length === 0) return;
9783
10016
  let record;
9784
10017
  try {
9785
10018
  record = JSON.parse(line3);
9786
10019
  } catch {
9787
10020
  skipped += 1;
9788
- continue;
10021
+ return;
9789
10022
  }
9790
10023
  if (!isRecord2(record)) {
9791
10024
  skipped += 1;
9792
- continue;
10025
+ return;
9793
10026
  }
9794
10027
  if (typeof record["sessionId"] === "string") sessionId = record["sessionId"];
9795
10028
  if (cwd === null && typeof record["cwd"] === "string") cwd = record["cwd"];
@@ -9816,7 +10049,19 @@ function parseTranscript(text) {
9816
10049
  }
9817
10050
  }
9818
10051
  }
9819
- return { sessionId, cwd, events, skipped };
10052
+ return { push, finish: () => ({ sessionId, cwd, events, skipped }) };
10053
+ }
10054
+ async function feedLines(path, parser) {
10055
+ const lines = createInterface({
10056
+ input: createReadStream(path, { encoding: "utf8" }),
10057
+ crlfDelay: Infinity
10058
+ });
10059
+ try {
10060
+ for await (const line3 of lines) parser.push(line3);
10061
+ } finally {
10062
+ lines.close();
10063
+ }
10064
+ return parser.finish();
9820
10065
  }
9821
10066
  function projectSlug(cwd) {
9822
10067
  return cwd.replace(/[/.]/g, "-");
@@ -9856,7 +10101,7 @@ async function listJsonl(dir) {
9856
10101
  return files;
9857
10102
  }
9858
10103
  async function readTranscript(path) {
9859
- return parseTranscript(await readFile6(path, "utf8"));
10104
+ return feedLines(path, createTranscriptParser());
9860
10105
  }
9861
10106
 
9862
10107
  // src/commands/replay.ts
@@ -10523,7 +10768,7 @@ stroq run \u2014 ${invocation.command}${agent === null ? "" : ` (${agent})`}`);
10523
10768
  }
10524
10769
 
10525
10770
  // src/commands/sent.ts
10526
- import { homedir as homedir21 } from "os";
10771
+ import { homedir as homedir24 } from "os";
10527
10772
  import { parseArgs as parseArgs9 } from "util";
10528
10773
 
10529
10774
  // src/sent/format.ts
@@ -10646,19 +10891,664 @@ function formatSent(report) {
10646
10891
  }
10647
10892
 
10648
10893
  // src/sent/readers.ts
10894
+ import { open as open2 } from "fs/promises";
10895
+ import { homedir as homedir23 } from "os";
10896
+
10897
+ // src/sent/codex.ts
10898
+ import { open, readdir as readdir2, stat as stat3 } from "fs/promises";
10899
+ import { homedir as homedir21 } from "os";
10900
+ import { join as join26 } from "path";
10901
+
10902
+ // src/sent/codex-literal.ts
10903
+ var MAX_DEPTH = 64;
10904
+ var Unreadable = class extends Error {
10905
+ };
10906
+ function fail() {
10907
+ throw new Unreadable();
10908
+ }
10909
+ function readObjectLiteral(src, start) {
10910
+ let i = start;
10911
+ let depth = 0;
10912
+ const skip = () => {
10913
+ while (i < src.length && (src[i] === "," || /\s/.test(src[i]))) i += 1;
10914
+ };
10915
+ const skipSpace = () => {
10916
+ while (i < src.length && /\s/.test(src[i])) i += 1;
10917
+ };
10918
+ const ESCAPES = {
10919
+ n: "\n",
10920
+ t: " ",
10921
+ r: "\r",
10922
+ b: "\b",
10923
+ f: "\f",
10924
+ v: "\v",
10925
+ "0": "\0"
10926
+ };
10927
+ function readString(quote) {
10928
+ i += 1;
10929
+ let out = "";
10930
+ while (i < src.length) {
10931
+ const c = src[i];
10932
+ if (c === "\\") {
10933
+ const next = src[i + 1];
10934
+ if (next === void 0) fail();
10935
+ if (next === "\n") {
10936
+ i += 2;
10937
+ continue;
10938
+ }
10939
+ if (next === "u") {
10940
+ if (src[i + 2] === "{") {
10941
+ const close = src.indexOf("}", i + 3);
10942
+ if (close < 0) fail();
10943
+ const code2 = Number.parseInt(src.slice(i + 3, close), 16);
10944
+ if (Number.isNaN(code2)) fail();
10945
+ out += String.fromCodePoint(code2);
10946
+ i = close + 1;
10947
+ continue;
10948
+ }
10949
+ const code = Number.parseInt(src.slice(i + 2, i + 6), 16);
10950
+ if (Number.isNaN(code)) fail();
10951
+ out += String.fromCharCode(code);
10952
+ i += 6;
10953
+ continue;
10954
+ }
10955
+ if (next === "x") {
10956
+ const code = Number.parseInt(src.slice(i + 2, i + 4), 16);
10957
+ if (Number.isNaN(code)) fail();
10958
+ out += String.fromCharCode(code);
10959
+ i += 4;
10960
+ continue;
10961
+ }
10962
+ out += ESCAPES[next] ?? next;
10963
+ i += 2;
10964
+ continue;
10965
+ }
10966
+ if (c === quote) {
10967
+ i += 1;
10968
+ return out;
10969
+ }
10970
+ if (quote === "`" && c === "$" && src[i + 1] === "{") {
10971
+ let braces = 0;
10972
+ const from = i;
10973
+ while (i < src.length) {
10974
+ const t = src[i];
10975
+ if (t === "{") braces += 1;
10976
+ else if (t === "}") {
10977
+ braces -= 1;
10978
+ if (braces === 0) {
10979
+ i += 1;
10980
+ break;
10981
+ }
10982
+ }
10983
+ i += 1;
10984
+ }
10985
+ if (braces !== 0) fail();
10986
+ out += src.slice(from, i);
10987
+ continue;
10988
+ }
10989
+ out += c;
10990
+ i += 1;
10991
+ }
10992
+ return fail();
10993
+ }
10994
+ function readKey() {
10995
+ skip();
10996
+ const c = src[i];
10997
+ if (c === '"' || c === "'" || c === "`") return readString(c);
10998
+ const name = /^[A-Za-z_$][\w$]*/.exec(src.slice(i));
10999
+ if (!name) fail();
11000
+ i += name[0].length;
11001
+ return name[0];
11002
+ }
11003
+ function readValue() {
11004
+ skip();
11005
+ if (i >= src.length) fail();
11006
+ const c = src[i];
11007
+ if (c === "{") return readObject();
11008
+ if (c === "[") return readArray();
11009
+ if (c === '"' || c === "'" || c === "`") return readString(c);
11010
+ const rest = src.slice(i);
11011
+ for (const [word, value] of [
11012
+ ["true", true],
11013
+ ["false", false],
11014
+ ["null", null],
11015
+ ["undefined", null]
11016
+ ]) {
11017
+ if (rest.startsWith(word) && !/[\w$]/.test(rest[word.length] ?? "")) {
11018
+ i += word.length;
11019
+ return value;
11020
+ }
11021
+ }
11022
+ const num = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(rest);
11023
+ if (num) {
11024
+ i += num[0].length;
11025
+ return Number(num[0]);
11026
+ }
11027
+ return fail();
11028
+ }
11029
+ function readObject() {
11030
+ depth += 1;
11031
+ if (depth > MAX_DEPTH) fail();
11032
+ i += 1;
11033
+ const out = {};
11034
+ for (; ; ) {
11035
+ skip();
11036
+ if (i >= src.length) fail();
11037
+ if (src[i] === "}") {
11038
+ i += 1;
11039
+ depth -= 1;
11040
+ return out;
11041
+ }
11042
+ if (src.startsWith("...", i)) {
11043
+ i += 3;
11044
+ const name = /^[A-Za-z_$][\w$.]*/.exec(src.slice(i));
11045
+ if (!name) fail();
11046
+ i += name[0].length;
11047
+ continue;
11048
+ }
11049
+ const key2 = readKey();
11050
+ skipSpace();
11051
+ if (src[i] === ":") {
11052
+ i += 1;
11053
+ out[key2] = readValue();
11054
+ continue;
11055
+ }
11056
+ if (src[i] === "}" || src[i] === ",") {
11057
+ out[key2] = null;
11058
+ continue;
11059
+ }
11060
+ fail();
11061
+ }
11062
+ }
11063
+ function readArray() {
11064
+ depth += 1;
11065
+ if (depth > MAX_DEPTH) fail();
11066
+ i += 1;
11067
+ const out = [];
11068
+ for (; ; ) {
11069
+ skip();
11070
+ if (i >= src.length) fail();
11071
+ if (src[i] === "]") {
11072
+ i += 1;
11073
+ depth -= 1;
11074
+ return out;
11075
+ }
11076
+ out.push(readValue());
11077
+ }
11078
+ }
11079
+ const opening = src[start];
11080
+ if (opening !== "{" && opening !== "[") return null;
11081
+ try {
11082
+ const value = readValue();
11083
+ return { value, end: i };
11084
+ } catch (err) {
11085
+ if (err instanceof Unreadable) return null;
11086
+ if (err instanceof RangeError) return null;
11087
+ throw err;
11088
+ }
11089
+ }
11090
+
11091
+ // src/sent/codex.ts
11092
+ var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
11093
+ function outputText(value) {
11094
+ if (typeof value === "string") return value;
11095
+ if (!Array.isArray(value)) return "";
11096
+ return value.map((block) => {
11097
+ if (typeof block === "string") return block;
11098
+ if (isRecord3(block) && typeof block["text"] === "string") return block["text"];
11099
+ return "";
11100
+ }).filter((t) => t.length > 0).join("");
11101
+ }
11102
+ var TOOL_CALL = /\btools\.([A-Za-z_$][\w$]*)\s*\(/g;
11103
+ function invocationsIn(script) {
11104
+ const found = [];
11105
+ TOOL_CALL.lastIndex = 0;
11106
+ for (let m = TOOL_CALL.exec(script); m !== null; m = TOOL_CALL.exec(script)) {
11107
+ const afterParen = m.index + m[0].length;
11108
+ const brace = script.indexOf("{", afterParen - 1);
11109
+ const literal = brace >= 0 && brace <= afterParen + 2 ? readObjectLiteral(script, brace) : null;
11110
+ const args = isRecord3(literal?.value) ? { ...literal.value } : { raw: script };
11111
+ found.push({ tool: m[1], input: args });
11112
+ }
11113
+ return found;
11114
+ }
11115
+ function normalized(mapped, input) {
11116
+ if (mapped !== "Bash" || typeof input["command"] === "string") return input;
11117
+ const command = commandOf(input);
11118
+ return command === "" ? input : { ...input, command };
11119
+ }
11120
+ function eventsFor(callId, rawTool, rawInput, at) {
11121
+ const invocations = isPatchTool(rawTool) ? [{ tool: rawTool, input: { input: rawInput } }] : invocationsIn(rawInput);
11122
+ const list = invocations.length > 0 ? invocations : [{ tool: rawTool, input: { raw: rawInput } }];
11123
+ return list.map((inv, n) => {
11124
+ const tool = codexToolName(inv.tool);
11125
+ return {
11126
+ kind: "pre",
11127
+ /* One script, several calls: the ids stay distinct so the output can attach to
11128
+ exactly one of them, and the first keeps the recorded `call_id` so the
11129
+ common single-call case reads as the rollout wrote it. */
11130
+ id: n === 0 ? callId : `${callId}#${n}`,
11131
+ tool,
11132
+ input: normalized(tool, inv.input),
11133
+ at
11134
+ };
11135
+ });
11136
+ }
11137
+ function createCodexParser() {
11138
+ const events = [];
11139
+ const pending = /* @__PURE__ */ new Map();
11140
+ let sessionId = "transcript";
11141
+ let cwd = null;
11142
+ let skipped = 0;
11143
+ function push(line3) {
11144
+ if (line3.trim().length === 0) return;
11145
+ let envelope2;
11146
+ try {
11147
+ envelope2 = JSON.parse(line3);
11148
+ } catch {
11149
+ skipped += 1;
11150
+ return;
11151
+ }
11152
+ if (!isRecord3(envelope2)) {
11153
+ skipped += 1;
11154
+ return;
11155
+ }
11156
+ const at = typeof envelope2["timestamp"] === "string" ? envelope2["timestamp"] : "";
11157
+ const payload = isRecord3(envelope2["payload"]) ? envelope2["payload"] : envelope2;
11158
+ if (envelope2["type"] === "session_meta") {
11159
+ if (typeof payload["session_id"] === "string") sessionId = payload["session_id"];
11160
+ if (typeof payload["cwd"] === "string") cwd = payload["cwd"];
11161
+ return;
11162
+ }
11163
+ if (cwd === null && typeof payload["cwd"] === "string") cwd = payload["cwd"];
11164
+ const kind = payload["type"];
11165
+ const callId = payload["call_id"];
11166
+ if (typeof callId !== "string") return;
11167
+ if (kind === "custom_tool_call" || kind === "function_call") {
11168
+ const rawTool = typeof payload["name"] === "string" ? payload["name"] : "unknown";
11169
+ if (kind === "function_call") {
11170
+ let parsed = null;
11171
+ if (typeof payload["arguments"] === "string") {
11172
+ try {
11173
+ parsed = JSON.parse(payload["arguments"]);
11174
+ } catch {
11175
+ parsed = { raw: payload["arguments"] };
11176
+ }
11177
+ }
11178
+ const tool = codexToolName(rawTool);
11179
+ const input = isRecord3(parsed) ? parsed : {};
11180
+ const event = {
11181
+ kind: "pre",
11182
+ id: callId,
11183
+ tool,
11184
+ input: normalized(tool, input),
11185
+ at
11186
+ };
11187
+ events.push(event);
11188
+ pending.set(callId, { id: event.id, tool: event.tool, input: event.input });
11189
+ return;
11190
+ }
11191
+ const script = typeof payload["input"] === "string" ? payload["input"] : "";
11192
+ const produced = eventsFor(callId, rawTool, script, at);
11193
+ events.push(...produced);
11194
+ const last = produced[produced.length - 1];
11195
+ if (last) pending.set(callId, { id: last.id, tool: last.tool, input: last.input });
11196
+ return;
11197
+ }
11198
+ if (kind === "custom_tool_call_output" || kind === "function_call_output") {
11199
+ const call = pending.get(callId);
11200
+ if (!call) return;
11201
+ events.push({
11202
+ kind: "post",
11203
+ id: call.id,
11204
+ tool: call.tool,
11205
+ input: call.input,
11206
+ resultText: outputText(payload["output"]),
11207
+ at
11208
+ });
11209
+ }
11210
+ }
11211
+ return { push, finish: () => ({ sessionId, cwd, events, skipped }) };
11212
+ }
11213
+ var codexRoot = (home = homedir21()) => join26(home, ".codex", "sessions");
11214
+ var META_BYTES = 64 * 1024;
11215
+ async function rolloutCwd(path) {
11216
+ let head;
11217
+ try {
11218
+ const handle = await open(path, "r");
11219
+ try {
11220
+ const buffer = Buffer.alloc(META_BYTES);
11221
+ const { bytesRead } = await handle.read(buffer, 0, META_BYTES, 0);
11222
+ head = buffer.subarray(0, bytesRead).toString("utf8");
11223
+ } finally {
11224
+ await handle.close();
11225
+ }
11226
+ } catch {
11227
+ return null;
11228
+ }
11229
+ const first = head.split("\n")[0];
11230
+ if (first === void 0 || first.trim() === "") return null;
11231
+ try {
11232
+ const envelope2 = JSON.parse(first);
11233
+ if (!isRecord3(envelope2)) return null;
11234
+ const payload = isRecord3(envelope2["payload"]) ? envelope2["payload"] : envelope2;
11235
+ return typeof payload["cwd"] === "string" ? payload["cwd"] : null;
11236
+ } catch {
11237
+ return null;
11238
+ }
11239
+ }
11240
+ async function everyRollout(root) {
11241
+ const entries = await readdir2(root, { recursive: true, withFileTypes: true }).catch(() => []);
11242
+ const files = [];
11243
+ for (const entry2 of entries) {
11244
+ if (!entry2.isFile()) continue;
11245
+ if (!entry2.name.startsWith("rollout-") || !entry2.name.endsWith(".jsonl")) continue;
11246
+ const path = join26(entry2.parentPath, entry2.name);
11247
+ try {
11248
+ files.push({ path, mtimeMs: (await stat3(path)).mtimeMs });
11249
+ } catch {
11250
+ }
11251
+ }
11252
+ return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
11253
+ }
11254
+ async function findCodexRollouts(cwd, home) {
11255
+ const all = await everyRollout(codexRoot(home));
11256
+ const here = [];
11257
+ for (const file of all) {
11258
+ if (await rolloutCwd(file.path) === cwd) here.push(file);
11259
+ }
11260
+ return here.length > 0 ? here : all;
11261
+ }
11262
+ async function readCodexRollout(path) {
11263
+ return feedLines(path, createCodexParser());
11264
+ }
11265
+
11266
+ // src/sent/cursor.ts
11267
+ import { homedir as homedir22 } from "os";
11268
+ import { join as join27 } from "path";
11269
+ var TOOL_NAMES = {
11270
+ run_terminal_command_v2: "Bash",
11271
+ run_terminal_command: "Bash",
11272
+ read_file_v2: "Read",
11273
+ read_file: "Read",
11274
+ edit_file_v2: "Write",
11275
+ edit_file: "Write",
11276
+ search_replace: "Write",
11277
+ create_file: "Write",
11278
+ delete_file: "Write"
11279
+ };
11280
+ var PATH_FIELDS2 = ["targetFile", "effectiveUri", "path", "targetDirectory", "absolutePath"];
11281
+ var RESULT_FIELDS = ["output", "contents", "result"];
11282
+ function parseJson(value) {
11283
+ if (typeof value !== "string" || value === "") return null;
11284
+ try {
11285
+ return JSON.parse(value);
11286
+ } catch {
11287
+ return null;
11288
+ }
11289
+ }
11290
+ function mcpPair(params) {
11291
+ if (!isRecord(params)) return null;
11292
+ const tools = params["tools"];
11293
+ if (!Array.isArray(tools)) return null;
11294
+ const first = tools[0];
11295
+ if (!isRecord(first)) return null;
11296
+ const tool = first["name"];
11297
+ const server = first["serverName"];
11298
+ if (typeof tool !== "string" || typeof server !== "string") return null;
11299
+ return { server, tool, args: first["parameters"] };
11300
+ }
11301
+ function callOf(name, params) {
11302
+ const mcp = mcpPair(params);
11303
+ if (mcp !== null) {
11304
+ return {
11305
+ tool: mcpToolName(mcp.server, mcp.tool),
11306
+ input: toolInputRecord(mcp.args),
11307
+ paths: []
11308
+ };
11309
+ }
11310
+ const record = isRecord(params) ? params : {};
11311
+ const paths = PATH_FIELDS2.map((field) => record[field]).filter(
11312
+ (value) => typeof value === "string" && value.startsWith("/")
11313
+ );
11314
+ const mapped = TOOL_NAMES[name] ?? name;
11315
+ if (mapped === "Bash") {
11316
+ const command = record["command"];
11317
+ return { tool: mapped, input: { command: typeof command === "string" ? command : "" }, paths };
11318
+ }
11319
+ if (mapped === "Read" || mapped === "Write") {
11320
+ const file = record["targetFile"] ?? record["relativeWorkspacePath"] ?? record["path"] ?? record["uri"];
11321
+ return {
11322
+ tool: mapped,
11323
+ input: { file_path: typeof file === "string" ? file : "" },
11324
+ paths
11325
+ };
11326
+ }
11327
+ return { tool: mapped, input: record, paths };
11328
+ }
11329
+ function resultTextOf(raw) {
11330
+ if (typeof raw !== "string" || raw === "") return null;
11331
+ const parsed = parseJson(raw);
11332
+ if (isRecord(parsed)) {
11333
+ for (const field of RESULT_FIELDS) {
11334
+ const value = parsed[field];
11335
+ if (typeof value === "string") return value;
11336
+ }
11337
+ }
11338
+ return raw;
11339
+ }
11340
+ function parseCursorStore(rows) {
11341
+ const bySession = /* @__PURE__ */ new Map();
11342
+ for (const row2 of rows) {
11343
+ const parts = row2.key.split(":");
11344
+ if (parts.length < 3 || parts[0] !== "bubbleId") continue;
11345
+ const id = parts[1] ?? "";
11346
+ if (id === "") continue;
11347
+ let session = bySession.get(id);
11348
+ if (session === void 0) {
11349
+ session = { events: [], roots: [], latest: 0, skipped: 0 };
11350
+ bySession.set(id, session);
11351
+ }
11352
+ const bubble = parseJson(row2.value);
11353
+ if (!isRecord(bubble)) {
11354
+ session.skipped += 1;
11355
+ continue;
11356
+ }
11357
+ const at = typeof bubble["createdAt"] === "string" ? bubble["createdAt"] : "";
11358
+ const moment = Date.parse(at);
11359
+ if (Number.isFinite(moment)) session.latest = Math.max(session.latest, moment);
11360
+ const former = bubble["toolFormerData"];
11361
+ if (!isRecord(former)) continue;
11362
+ const name = typeof former["name"] === "string" ? former["name"] : "";
11363
+ if (name === "") continue;
11364
+ const call = callOf(name, parseJson(former["params"]));
11365
+ for (const path of call.paths) if (!session.roots.includes(path)) session.roots.push(path);
11366
+ const callId = typeof former["toolCallId"] === "string" && former["toolCallId"] !== "" ? former["toolCallId"] : parts.slice(2).join(":") ?? "";
11367
+ session.events.push({ kind: "pre", id: callId, tool: call.tool, input: call.input, at });
11368
+ const text = resultTextOf(former["result"]);
11369
+ if (text === null) continue;
11370
+ session.events.push({
11371
+ kind: "post",
11372
+ id: callId,
11373
+ tool: call.tool,
11374
+ input: call.input,
11375
+ resultText: text,
11376
+ at
11377
+ });
11378
+ }
11379
+ return [...bySession.entries()].map(([id, s]) => ({
11380
+ id,
11381
+ mtimeMs: s.latest,
11382
+ roots: s.roots,
11383
+ transcript: {
11384
+ sessionId: id,
11385
+ cwd: s.roots[0] ?? null,
11386
+ events: s.events,
11387
+ skipped: s.skipped
11388
+ }
11389
+ })).sort((a, b) => b.mtimeMs - a.mtimeMs);
11390
+ }
11391
+ function cursorStateDb(plat = process.platform, env = process.env, home = homedir22()) {
11392
+ const base = plat === "darwin" ? join27(home, "Library", "Application Support", "Cursor") : plat === "win32" ? join27(env["APPDATA"] ?? join27(home, "AppData", "Roaming"), "Cursor") : join27(home, ".config", "Cursor");
11393
+ return join27(base, "User", "globalStorage", "state.vscdb");
11394
+ }
11395
+ var cursorSessionPath = (db, session) => `${db}#${session}`;
11396
+ function splitCursorSessionPath(path) {
11397
+ const hash = path.lastIndexOf("#");
11398
+ if (hash <= 0) return { db: path, session: null };
11399
+ return { db: path.slice(0, hash), session: path.slice(hash + 1) };
11400
+ }
11401
+ var sqlitePromise = null;
11402
+ async function sqlite() {
11403
+ sqlitePromise ??= (async () => {
11404
+ const emit2 = process.emitWarning;
11405
+ process.emitWarning = ((warning, ...rest) => {
11406
+ const name = typeof warning === "string" ? String(rest[0] ?? "") : warning.name;
11407
+ const text = typeof warning === "string" ? warning : warning.message;
11408
+ if (name === "ExperimentalWarning" && text.includes("SQLite")) return;
11409
+ emit2.call(process, warning, ...rest);
11410
+ });
11411
+ try {
11412
+ return await import("sqlite");
11413
+ } catch {
11414
+ return null;
11415
+ } finally {
11416
+ process.emitWarning = emit2;
11417
+ }
11418
+ })();
11419
+ return sqlitePromise;
11420
+ }
11421
+ async function cursorUnavailable() {
11422
+ if (await sqlite() !== null) return null;
11423
+ return `Cursor sessions need node:sqlite, which ${process.version} does not have \u2014 Node 22.23 or newer reads them`;
11424
+ }
11425
+ async function storeRows(db, session) {
11426
+ const mod = await sqlite();
11427
+ if (mod === null) return [];
11428
+ let handle;
11429
+ try {
11430
+ handle = new mod.DatabaseSync(db, { readOnly: true });
11431
+ } catch {
11432
+ return [];
11433
+ }
11434
+ try {
11435
+ const like = session === null ? "bubbleId:%" : `bubbleId:${session}:%`;
11436
+ const rows = handle.prepare("select key, value from cursorDiskKV where key like ?").all(like);
11437
+ return rows.flatMap((row2) => {
11438
+ const { key: key2, value } = row2;
11439
+ return typeof key2 === "string" && typeof value === "string" ? [{ key: key2, value }] : [];
11440
+ });
11441
+ } catch {
11442
+ return [];
11443
+ } finally {
11444
+ handle.close();
11445
+ }
11446
+ }
11447
+ function isUnder(path, dir) {
11448
+ if (dir === "") return false;
11449
+ const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
11450
+ return path === base || path.startsWith(`${base}/`);
11451
+ }
11452
+ async function findCursorSessions(cwd, db) {
11453
+ const store = db ?? cursorStateDb();
11454
+ const sessions = parseCursorStore(await storeRows(store, null));
11455
+ const files = (list) => list.map((s) => ({ path: cursorSessionPath(store, s.id), mtimeMs: s.mtimeMs }));
11456
+ const here = sessions.filter((s) => s.roots.some((root) => isUnder(root, cwd)));
11457
+ return here.length > 0 ? files(here) : files(sessions);
11458
+ }
11459
+ async function readCursorSession(path) {
11460
+ const { db, session } = splitCursorSessionPath(path);
11461
+ const sessions = parseCursorStore(await storeRows(db, session));
11462
+ const found = session === null ? sessions[0] : sessions.find((s) => s.id === session);
11463
+ return found?.transcript ?? { sessionId: session ?? "", cwd: null, events: [], skipped: 0 };
11464
+ }
11465
+
11466
+ // src/sent/readers.ts
11467
+ var HEAD_BYTES = 64 * 1024;
10649
11468
  var claudeCodeReader = {
10650
11469
  agent: "claude-code",
10651
11470
  find: findTranscripts,
10652
- read: readTranscript
11471
+ read: readTranscript,
11472
+ root: "~/.claude/projects",
11473
+ /* A Claude record is the message itself: `{type:'user'|'assistant', message:{…}}`,
11474
+ with `sessionId` on nearly every line. A Codex record is an envelope around a
11475
+ payload and has neither. */
11476
+ claims: (head) => {
11477
+ const record = tryParse(head);
11478
+ if (!isRecord4(record)) return false;
11479
+ return "message" in record || "sessionId" in record;
11480
+ }
11481
+ };
11482
+ var codexReader = {
11483
+ agent: "codex",
11484
+ find: (cwd) => findCodexRollouts(cwd),
11485
+ read: readCodexRollout,
11486
+ root: "~/.codex/sessions",
11487
+ /* Codex wraps every record: `{timestamp, ordinal, type, payload}`. The envelope
11488
+ is the format, so its presence is the test. */
11489
+ claims: (head) => {
11490
+ const record = tryParse(head);
11491
+ return isRecord4(record) && isRecord4(record["payload"]) && typeof record["type"] === "string";
11492
+ }
11493
+ };
11494
+ var cursorReader = {
11495
+ agent: "cursor",
11496
+ find: (cwd) => findCursorSessions(cwd),
11497
+ read: readCursorSession,
11498
+ root: tilde(cursorStateDb()),
11499
+ unavailable: cursorUnavailable,
11500
+ /* A SQLite file announces itself in its first 16 bytes, and that is all the
11501
+ dispatch needs: no other agent here writes one. */
11502
+ claims: (head) => head.startsWith("SQLite format 3")
10653
11503
  };
10654
- var READERS = [claudeCodeReader];
11504
+ var READERS = [claudeCodeReader, codexReader, cursorReader];
11505
+ async function readerNotices() {
11506
+ const notices = [];
11507
+ for (const reader of READERS) {
11508
+ const why = await reader.unavailable?.();
11509
+ if (why !== void 0 && why !== null) notices.push(why);
11510
+ }
11511
+ return notices;
11512
+ }
11513
+ function tilde(path) {
11514
+ const home = homedir23();
11515
+ return home !== "" && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
11516
+ }
11517
+ var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
11518
+ function tryParse(line3) {
11519
+ try {
11520
+ return JSON.parse(line3);
11521
+ } catch {
11522
+ return null;
11523
+ }
11524
+ }
11525
+ var READER_ROOTS = () => READERS.map((r) => r.root).join(" or ");
11526
+ async function readerForFile(path) {
11527
+ let head = "";
11528
+ try {
11529
+ const handle = await open2(splitCursorSessionPath(path).db, "r");
11530
+ try {
11531
+ const buffer = Buffer.alloc(HEAD_BYTES);
11532
+ const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
11533
+ head = buffer.subarray(0, bytesRead).toString("utf8").split("\n")[0] ?? "";
11534
+ } finally {
11535
+ await handle.close();
11536
+ }
11537
+ } catch {
11538
+ return claudeCodeReader;
11539
+ }
11540
+ return READERS.find((reader) => reader.claims(head)) ?? claudeCodeReader;
11541
+ }
10655
11542
  async function newestTranscript(cwd) {
11543
+ let best = null;
10656
11544
  for (const reader of READERS) {
10657
- const found = await reader.find(cwd);
10658
- const newest = found[0];
10659
- if (newest) return { reader, path: newest.path };
11545
+ const newest = (await reader.find(cwd))[0];
11546
+ if (!newest) continue;
11547
+ if (best === null || newest.mtimeMs > best.mtimeMs) {
11548
+ best = { reader, path: newest.path, mtimeMs: newest.mtimeMs };
11549
+ }
10660
11550
  }
10661
- return null;
11551
+ return best === null ? null : { reader: best.reader, path: best.path };
10662
11552
  }
10663
11553
 
10664
11554
  // src/sent/scan.ts
@@ -10882,7 +11772,7 @@ function emit(report, out) {
10882
11772
  var USAGE = `stroq sent \u2014 which of your credentials already reached a model provider
10883
11773
 
10884
11774
  stroq sent --last read the newest session in this directory
10885
- stroq sent --transcript <path> read a specific transcript file
11775
+ stroq sent --transcript <path> read a specific transcript or rollout
10886
11776
  stroq sent [<session-id>] read a session Stroq itself recorded
10887
11777
 
10888
11778
  Flags:
@@ -10890,9 +11780,10 @@ Flags:
10890
11780
  --fail-on-finding exit 1 when a credential is found (for a scheduled job)
10891
11781
  -h, --help show this
10892
11782
 
10893
- It reads this machine's credential files to know what to look for, and prints
10894
- names and sources only, never a value. A finding exits 0 by default: a session
10895
- that already happened cannot be un-sent by today's commit.
11783
+ Reads Claude Code transcripts and Codex CLI rollouts; --transcript works out
11784
+ which from the file itself. It reads this machine's credential files to know what
11785
+ to look for, and prints names and sources only, never a value. A finding exits 0
11786
+ by default: a session that already happened cannot be un-sent by today's commit.
10896
11787
  `;
10897
11788
  async function runSent(args) {
10898
11789
  let parsed;
@@ -10924,7 +11815,7 @@ ${USAGE}`);
10924
11815
  failOnFinding: values["fail-on-finding"] === true
10925
11816
  };
10926
11817
  const cwd = process.cwd();
10927
- const home = homedir21();
11818
+ const home = homedir24();
10928
11819
  const index = new FileSecretIndex(secretsFile(), home);
10929
11820
  const stats = await index.refresh(cwd);
10930
11821
  const scope = {
@@ -10935,14 +11826,18 @@ ${USAGE}`);
10935
11826
  };
10936
11827
  if (values.transcript !== void 0 || values.last === true) {
10937
11828
  const found = values.transcript === void 0 ? await newestTranscript(cwd) : (
10938
- // One reader is registered, so a transcript named on the command line is
10939
- // parsed by it. A second agent's format would need a way to tell them
10940
- // apart; writing that dispatch now would be guessing at a format nobody
10941
- // here can test against.
10942
- { reader: claudeCodeReader, path: values.transcript }
11829
+ // Chosen by what is in the file, not by where it is: a Codex rollout
11830
+ // parsed as a Claude transcript yields no events at all, which prints
11831
+ // as a clean session rather than as the mistake it is.
11832
+ { reader: await readerForFile(values.transcript), path: values.transcript }
10943
11833
  );
10944
11834
  if (found === null) {
10945
- process.stdout.write("no agent transcript found \u2014 looked under ~/.claude/projects\n");
11835
+ const notices = await readerNotices();
11836
+ process.stdout.write(
11837
+ `no agent transcript found \u2014 looked under ${READER_ROOTS()}
11838
+ ` + notices.map((why) => ` note: ${why}
11839
+ `).join("")
11840
+ );
10946
11841
  return 1;
10947
11842
  }
10948
11843
  const transcript = await found.reader.read(found.path);
@@ -11064,13 +11959,13 @@ function runTrust(argv, now = () => /* @__PURE__ */ new Date()) {
11064
11959
  // src/commands/untaint.ts
11065
11960
  import { existsSync as existsSync14, readdirSync as readdirSync7 } from "fs";
11066
11961
  import { rm as rm6 } from "fs/promises";
11067
- import { join as join26 } from "path";
11962
+ import { join as join28 } from "path";
11068
11963
  import { parseArgs as parseArgs11 } from "util";
11069
11964
  var USAGE2 = "usage: stroq untaint --session <id> | --all\n";
11070
11965
  async function clearAll() {
11071
11966
  const dir = sessionsDir();
11072
11967
  if (!existsSync14(dir)) return;
11073
- await Promise.all(readdirSync7(dir).map((f) => rm6(join26(dir, f), { force: true })));
11968
+ await Promise.all(readdirSync7(dir).map((f) => rm6(join28(dir, f), { force: true })));
11074
11969
  }
11075
11970
  async function runUntaint(args) {
11076
11971
  const { values } = parseArgs11({
@@ -2,7 +2,7 @@
2
2
  "id": "stroq",
3
3
  "name": "Stroq",
4
4
  "description": "Local action firewall for OpenClaw: scans what the agent reads, taints the session, blocks or asks before dangerous tool calls.",
5
- "version": "0.14.1",
5
+ "version": "0.15.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stroq-openclaw-plugin",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "private": true,
5
5
  "description": "Stroq's OpenClaw plugin entry: forwards before_tool_call and after_tool_call to the Stroq CLI.",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stroq/cli",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Local action firewall for AI agents: scans what the agent reads, taints the session, blocks dangerous follow-up actions",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",