@stroq/cli 0.12.1 → 0.13.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
@@ -1526,6 +1526,7 @@ function firstArgAfter(segment2) {
1526
1526
  // ../core/src/actions/self-config.ts
1527
1527
  var SELF_CONFIG_FILE = /(\.claude\/settings(\.local)?\.json|\.cursor\/hooks\.json|\.codex\/(hooks\.json|config\.toml)|\.github\/(hooks(?![\w.-])|copilot\/settings(\.local)?\.json)|\.copilot\/(hooks(?![\w.-])|settings\.json|config\.json)|\.openclaw\/(openclaw\.json|plugins(?![\w.-])|extensions(?![\w.-]))|(\.windsurf|\.codeium(\/windsurf)?)\/hooks\.json|(?<![\w.-])\/etc\/windsurf\/hooks\.json|Application(?:\\ | )Support\/Windsurf\/hooks\.json|(?<![\w.-])claude_desktop_config\.json|(?<![\w.-])mcp_config\.json|\.stroq(\/|\b))/;
1528
1528
  var PROTECTED_DIRS = /\.(claude|cursor|codex|copilot|openclaw|stroq|windsurf|codeium|github\/(hooks|copilot))(\/|$|\s)/;
1529
+ var PROTECTED_DIR_BARE = /(^|[\s"'=(])(?:[\w.~/-]*\/)?(?:\.(claude|cursor|codex|copilot|openclaw|stroq|windsurf|codeium)|\.github\/(hooks|copilot))(?:\/\*?|\*)?(?=$|[\s"';|&)])/i;
1529
1530
  var SELF_CONFIG_READ_COMMANDS = /* @__PURE__ */ new Set([
1530
1531
  "cat",
1531
1532
  "less",
@@ -1608,7 +1609,8 @@ function isFindWriteIntent(segment2) {
1608
1609
  }
1609
1610
  function touchesSelfConfig(segment2, word) {
1610
1611
  if (SELF_CONFIG_FILE.test(segment2)) return true;
1611
- return word === "find" && PROTECTED_DIRS.test(segment2);
1612
+ if (word === "find" && PROTECTED_DIRS.test(segment2)) return true;
1613
+ return SELF_CONFIG_WRITE_COMMANDS.has(word) && PROTECTED_DIR_BARE.test(segment2);
1612
1614
  }
1613
1615
  function isSelfConfigWriteIntent(segment2, word) {
1614
1616
  if (SELF_CONFIG_WRITE_COMMANDS.has(word)) return true;
@@ -1924,7 +1926,7 @@ function classifyCommand(command, cwd) {
1924
1926
  }
1925
1927
 
1926
1928
  // ../core/src/actions/classify-tool.ts
1927
- var SECRET_PATH = /(\/\.ssh\/|\bid_(rsa|ed25519|ecdsa|dsa)\b|\/\.aws\/|(^|\/)\.env(\.[\w-]+)?$|\.(pem|p12|pfx|key)$|\/\.(npmrc|netrc|pgpass|git-credentials)$|\/\.kube\/config$|\/\.config\/gcloud\/|\/etc\/(shadow|passwd)$)/;
1929
+ var SECRET_PATH = /(\/\.ssh(\/|$)|\bid_(rsa|ed25519|ecdsa|dsa)\b|\/\.aws(\/|$)|(^|\/)\.env(\.[\w-]+)?$|\.(pem|p12|pfx|key)$|\/\.(npmrc|netrc|pgpass|git-credentials)$|\/\.kube(\/config$|$)|\/\.config\/gcloud(\/|$)|\/etc\/(shadow|passwd)$)/;
1928
1930
  var SIDE_EFFECT_TOOL = /(send|post|publish|upload|email|mail|message|notify|pay|transfer|purchase|delete|remove|drop|deploy|execute|exec|run|shell|write|update|create|comment|merge|push)/i;
1929
1931
  var WRITE_SHAPED_TOOL = /(write|edit|delete|remove|move|rename|append|put|save|update|create_file|mkdir)/i;
1930
1932
  var PATH_LIKE_KEY = /^(path|filepath|file_path|file|target|dest|destination|uri|url)s?$/i;
@@ -1941,7 +1943,20 @@ function pathOf(toolInput) {
1941
1943
  const candidate = toolInput["file_path"] ?? toolInput["notebook_path"] ?? toolInput["path"] ?? "";
1942
1944
  return typeof candidate === "string" ? candidate : "";
1943
1945
  }
1944
- function classifyPath(path, write2) {
1946
+ function normalizePathForMatch(path) {
1947
+ const collapsed = path.replace(/(^|\/)\.(?=\/)/g, "$1").replace(/\/{2,}/g, "/");
1948
+ const segments = [];
1949
+ for (const segment2 of collapsed.split("/")) {
1950
+ if (segment2 === ".." && segments.length > 0 && segments[segments.length - 1] !== "..") {
1951
+ segments.pop();
1952
+ } else {
1953
+ segments.push(segment2);
1954
+ }
1955
+ }
1956
+ return segments.join("/").toLowerCase();
1957
+ }
1958
+ function classifyPath(rawPath, write2) {
1959
+ const path = normalizePathForMatch(rawPath);
1945
1960
  const classes = [];
1946
1961
  const signals = [];
1947
1962
  if (write2 && SELF_CONFIG_FILE.test(path)) {
@@ -2593,8 +2608,14 @@ function pipeShellAtoms(text) {
2593
2608
  atom: { kind: "pipe_shell", value: collapse(m[0]) }
2594
2609
  }));
2595
2610
  }
2611
+ var PATH_SEGMENT_MAX = 16;
2612
+ function looksLikePath(body) {
2613
+ const parts = body.split("/");
2614
+ return parts.length >= 3 && parts.every((part) => part.length <= PATH_SEGMENT_MAX);
2615
+ }
2596
2616
  function looksBase64(token) {
2597
2617
  const body = token.replace(/=+$/, "");
2618
+ if (looksLikePath(body)) return false;
2598
2619
  return !HEX.test(body) && /[a-z]/.test(body) && /[A-Z]/.test(body) && /[0-9+/]/.test(body);
2599
2620
  }
2600
2621
  function encodedAtoms(text) {
@@ -6602,8 +6623,8 @@ var MAX_CONFIG_BYTES = 256 * 1024;
6602
6623
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".venv", "venv", "vendor", "target", "dist", "build"]);
6603
6624
  var readTextOr = (file, fallback = "") => {
6604
6625
  try {
6605
- const stat2 = statSync8(file);
6606
- if (!stat2.isFile() || stat2.size > MAX_CONFIG_BYTES) return fallback;
6626
+ const stat3 = statSync8(file);
6627
+ if (!stat3.isFile() || stat3.size > MAX_CONFIG_BYTES) return fallback;
6607
6628
  return readFileSync15(file, "utf8");
6608
6629
  } catch {
6609
6630
  return fallback;
@@ -6664,8 +6685,8 @@ function gitHookHits(root) {
6664
6685
  try {
6665
6686
  return readdirSync6(dir).filter((name) => !name.endsWith(".sample")).filter((name) => {
6666
6687
  try {
6667
- const stat2 = statSync8(join19(dir, name));
6668
- return stat2.isFile() && (stat2.mode & 73) !== 0;
6688
+ const stat3 = statSync8(join19(dir, name));
6689
+ return stat3.isFile() && (stat3.mode & 73) !== 0;
6669
6690
  } catch {
6670
6691
  return false;
6671
6692
  }
@@ -8426,10 +8447,397 @@ ${MCP_USAGE}`);
8426
8447
  });
8427
8448
  }
8428
8449
 
8450
+ // src/commands/replay.ts
8451
+ import { mkdtemp as mkdtemp2, rm as rm5 } from "fs/promises";
8452
+ import { tmpdir as tmpdir2 } from "os";
8453
+ import { join as join21 } from "path";
8454
+ import { parseArgs as parseArgs8 } from "util";
8455
+
8456
+ // src/replay/transcript.ts
8457
+ import { readdir, readFile as readFile5, stat as stat2 } from "fs/promises";
8458
+ import { homedir as homedir17 } from "os";
8459
+ import { join as join20 } from "path";
8460
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
8461
+ function resultText(content) {
8462
+ if (typeof content === "string") return content;
8463
+ if (!Array.isArray(content)) return "";
8464
+ return content.map((b) => {
8465
+ if (typeof b === "string") return b;
8466
+ if (isRecord2(b) && typeof b["text"] === "string") return b["text"];
8467
+ return "";
8468
+ }).filter((t) => t.length > 0).join("\n");
8469
+ }
8470
+ function blocksOf(record) {
8471
+ const message = record["message"];
8472
+ if (!isRecord2(message)) return [];
8473
+ const content = message["content"];
8474
+ return Array.isArray(content) ? content.filter(isRecord2) : [];
8475
+ }
8476
+ function parseTranscript(text) {
8477
+ const events = [];
8478
+ const pending = /* @__PURE__ */ new Map();
8479
+ let sessionId = "transcript";
8480
+ let cwd = null;
8481
+ let skipped = 0;
8482
+ for (const line2 of text.split("\n")) {
8483
+ if (line2.trim().length === 0) continue;
8484
+ let record;
8485
+ try {
8486
+ record = JSON.parse(line2);
8487
+ } catch {
8488
+ skipped += 1;
8489
+ continue;
8490
+ }
8491
+ if (!isRecord2(record)) {
8492
+ skipped += 1;
8493
+ continue;
8494
+ }
8495
+ if (typeof record["sessionId"] === "string") sessionId = record["sessionId"];
8496
+ if (cwd === null && typeof record["cwd"] === "string") cwd = record["cwd"];
8497
+ const at = typeof record["timestamp"] === "string" ? record["timestamp"] : "";
8498
+ for (const block of blocksOf(record)) {
8499
+ if (block.type === "tool_use" && typeof block.id === "string") {
8500
+ const tool = typeof block.name === "string" ? block.name : "unknown";
8501
+ const input = isRecord2(block.input) ? block.input : {};
8502
+ pending.set(block.id, { tool, input });
8503
+ events.push({ kind: "pre", id: block.id, tool, input, at });
8504
+ continue;
8505
+ }
8506
+ if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
8507
+ const call = pending.get(block.tool_use_id);
8508
+ if (!call) continue;
8509
+ events.push({
8510
+ kind: "post",
8511
+ id: block.tool_use_id,
8512
+ tool: call.tool,
8513
+ input: call.input,
8514
+ resultText: resultText(block.content),
8515
+ at
8516
+ });
8517
+ }
8518
+ }
8519
+ }
8520
+ return { sessionId, cwd, events, skipped };
8521
+ }
8522
+ function projectSlug(cwd) {
8523
+ return cwd.replace(/[/.]/g, "-");
8524
+ }
8525
+ var transcriptRoot = () => join20(homedir17(), ".claude", "projects");
8526
+ async function findTranscripts(cwd) {
8527
+ const root = transcriptRoot();
8528
+ const scoped = join20(root, projectSlug(cwd));
8529
+ const found = await listJsonl(scoped) ?? [];
8530
+ if (found.length > 0) return found.sort((a, b) => b.mtimeMs - a.mtimeMs);
8531
+ let dirs;
8532
+ try {
8533
+ dirs = (await readdir(root, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => join20(root, d.name));
8534
+ } catch {
8535
+ return [];
8536
+ }
8537
+ const all = [];
8538
+ for (const dir of dirs) all.push(...await listJsonl(dir) ?? []);
8539
+ return all.sort((a, b) => b.mtimeMs - a.mtimeMs);
8540
+ }
8541
+ async function listJsonl(dir) {
8542
+ let names;
8543
+ try {
8544
+ names = await readdir(dir);
8545
+ } catch {
8546
+ return null;
8547
+ }
8548
+ const files = [];
8549
+ for (const name of names) {
8550
+ if (!name.endsWith(".jsonl")) continue;
8551
+ const path = join20(dir, name);
8552
+ try {
8553
+ files.push({ path, mtimeMs: (await stat2(path)).mtimeMs });
8554
+ } catch {
8555
+ }
8556
+ }
8557
+ return files;
8558
+ }
8559
+ async function readTranscript(path) {
8560
+ return parseTranscript(await readFile5(path, "utf8"));
8561
+ }
8562
+
8563
+ // src/commands/replay.ts
8564
+ var CLIP = /…+$/;
8565
+ var sourceKey = (tool, source) => JSON.stringify([tool, source]);
8566
+ var ATOM_RANK = {
8567
+ pipe_shell: 4,
8568
+ encoded: 3,
8569
+ pkg: 2,
8570
+ url: 1,
8571
+ host: 0
8572
+ };
8573
+ var atomRank = (ev) => ATOM_RANK[ev.kind] ?? 0;
8574
+ function sameSource(summary, source) {
8575
+ const a = summary.replace(CLIP, "");
8576
+ const b = source.replace(CLIP, "");
8577
+ if (a.length === 0 || b.length === 0) return false;
8578
+ return a.startsWith(b) || b.startsWith(a);
8579
+ }
8580
+ function findRead(entries, ev) {
8581
+ const candidates = entries.filter(
8582
+ (e) => e.phase === "post" && e.tool === ev.tool && sameSource(e.summary, ev.source)
8583
+ );
8584
+ if (candidates.length === 0) return null;
8585
+ const at = Date.parse(ev.at);
8586
+ const before = candidates.filter((e) => Date.parse(e.ts) <= at + 1e3);
8587
+ return (before.length > 0 ? before : candidates)[(before.length > 0 ? before : candidates).length - 1];
8588
+ }
8589
+ function taintingReads(entries) {
8590
+ return entries.filter((e) => e.phase === "post" && e.scan?.verdict === "suspect");
8591
+ }
8592
+ function buildReplay(allEntries, sessionId) {
8593
+ const entries = allEntries.filter((e) => e.sessionId === sessionId);
8594
+ const actions = entries.filter((e) => e.phase === "pre");
8595
+ const bySource = /* @__PURE__ */ new Map();
8596
+ const ensure = (tool, source, at, suspect) => {
8597
+ const key = sourceKey(tool, source);
8598
+ const existing = bySource.get(key);
8599
+ if (existing) return existing.list;
8600
+ const list = [];
8601
+ bySource.set(key, {
8602
+ source: {
8603
+ tool,
8604
+ source,
8605
+ at,
8606
+ suspect,
8607
+ read: findRead(entries, { tool, source, at, suspect, kind: "url", excerpt: "" }),
8608
+ consequences: list
8609
+ },
8610
+ list
8611
+ });
8612
+ return list;
8613
+ };
8614
+ for (const read3 of taintingReads(entries)) {
8615
+ ensure(read3.tool, read3.summary, read3.ts, true);
8616
+ }
8617
+ for (const action of actions) {
8618
+ for (const ev of action.provenance ?? []) {
8619
+ ensure(ev.tool, ev.source, ev.at, ev.suspect).push({ action, evidence: ev, alsoCarried: 0 });
8620
+ }
8621
+ }
8622
+ for (const group of bySource.values()) {
8623
+ const byAction = /* @__PURE__ */ new Map();
8624
+ for (const c of group.list) {
8625
+ byAction.set(c.action.seq, [...byAction.get(c.action.seq) ?? [], c]);
8626
+ }
8627
+ const merged = [...byAction.values()].map((carried) => {
8628
+ const best = carried.reduce((a, b) => atomRank(b.evidence) > atomRank(a.evidence) ? b : a);
8629
+ return { ...best, alsoCarried: carried.length - 1 };
8630
+ });
8631
+ group.list.length = 0;
8632
+ group.list.push(...merged.sort((a, b) => a.action.seq - b.action.seq));
8633
+ }
8634
+ const linked = new Set([...bySource.values()].flatMap((g) => g.list.map((c) => c.action.seq)));
8635
+ const secretActions = actions.filter((a) => (a.secrets ?? []).length > 0 && !linked.has(a.seq));
8636
+ const secretSeqs = new Set(secretActions.map((a) => a.seq));
8637
+ const unlinked = actions.filter((a) => !linked.has(a.seq) && !secretSeqs.has(a.seq));
8638
+ const sources = [...bySource.values()].map((g) => ({ ...g.source, consequences: g.list })).sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
8639
+ return {
8640
+ sessionId,
8641
+ total: entries.length,
8642
+ first: entries[0]?.ts ?? null,
8643
+ last: entries[entries.length - 1]?.ts ?? null,
8644
+ sources,
8645
+ secretActions,
8646
+ unlinked,
8647
+ denied: actions.filter((a) => a.decision?.effect === "deny").length,
8648
+ asked: actions.filter((a) => a.decision?.effect === "ask").length
8649
+ };
8650
+ }
8651
+ var RULE_PREVIEW = 3;
8652
+ function verdictTag(entry2) {
8653
+ const d = entry2.decision;
8654
+ if (!d) return "";
8655
+ if (d.effect === "deny") return `DENIED ${d.ruleId ?? "default"}`;
8656
+ if (d.effect === "ask") return `ASKED ${d.ruleId ?? "default"}`;
8657
+ return `allowed ${d.ruleId ?? "default"}`;
8658
+ }
8659
+ function readLine(src) {
8660
+ const scan = src.read?.scan;
8661
+ if (!scan) {
8662
+ return src.suspect ? "flagged suspicious" : "no rule matched it, but tool output is data, not instructions";
8663
+ }
8664
+ if (scan.verdict !== "suspect") {
8665
+ return `clean \u2014 no rule matched it, but tool output is data, not instructions`;
8666
+ }
8667
+ const ids = scan.ruleIds.slice(0, RULE_PREVIEW).join(", ");
8668
+ const more = scan.ruleIds.length > RULE_PREVIEW ? `, +${scan.ruleIds.length - RULE_PREVIEW}` : "";
8669
+ const waived = scan.trusted === true ? " \u2014 waived by stroq trust" : "";
8670
+ return `SUSPECT ${scan.score.toFixed(2)} \u2014 ${scan.ruleIds.length} rules: ${ids}${more}${waived}`;
8671
+ }
8672
+ var secretLine = (s) => `${s.name} from ${s.source}${s.canary ? " (canary)" : ""}`;
8673
+ function consequenceBlock(c, last) {
8674
+ const elbow = last ? " \u2514\u2500\u25BA" : " \u251C\u2500\u25BA";
8675
+ const rail = last ? " " : " \u2502 ";
8676
+ const gap = ageLabel(c.evidence.at, new Date(c.action.ts));
8677
+ return [
8678
+ `${elbow} #${c.action.seq} ${c.action.tool} ${c.action.summary}`,
8679
+ `${rail} ${verdictTag(c.action)}${" ".repeat(Math.max(1, 34 - verdictTag(c.action).length))}${gap} later`,
8680
+ `${rail} carried over: "${c.evidence.excerpt}" (${c.evidence.kind})` + (c.alsoCarried > 0 ? ` and ${c.alsoCarried} more` : "")
8681
+ ];
8682
+ }
8683
+ function sourceBlock(src) {
8684
+ const seq = src.read ? `#${src.read.seq} ` : "";
8685
+ const head = [` \u25A0 ${seq}${src.tool} ${src.source}`, ` ${readLine(src)}`];
8686
+ if (src.consequences.length === 0) {
8687
+ return [...head, " nothing traced back to it", ""];
8688
+ }
8689
+ const body = src.consequences.flatMap(
8690
+ (c, i) => consequenceBlock(c, i === src.consequences.length - 1)
8691
+ );
8692
+ return [...head, " \u2502", ...body, ""];
8693
+ }
8694
+ function duration(model) {
8695
+ if (!model.first || !model.last) return "";
8696
+ return ` \xB7 ${ageLabel(model.first, new Date(model.last))} long`;
8697
+ }
8698
+ function formatReplay(model) {
8699
+ if (model.total === 0) {
8700
+ return `no audit entries for session ${model.sessionId}
8701
+ `;
8702
+ }
8703
+ const traced = new Set(model.sources.flatMap((s) => s.consequences.map((c) => c.action.seq))).size;
8704
+ const lines = [
8705
+ "stroq replay \u2014 what the agent did, and what told it to do it",
8706
+ "",
8707
+ ` session ${model.sessionId} \xB7 ${model.total} events${duration(model)} \xB7 ${model.denied} denied \xB7 ${model.asked} asked`,
8708
+ ""
8709
+ ];
8710
+ if (model.sources.length > 0) {
8711
+ lines.push("CONTENT THE AGENT READ, AND WHAT CAME OUT OF IT", "");
8712
+ for (const src of model.sources) lines.push(...sourceBlock(src));
8713
+ }
8714
+ if (model.secretActions.length > 0) {
8715
+ lines.push("ACTIONS CARRYING A KNOWN SECRET VALUE", "");
8716
+ for (const a of model.secretActions) {
8717
+ lines.push(` \u25CF #${a.seq} ${a.tool} ${a.summary}`);
8718
+ lines.push(` ${verdictTag(a)}`);
8719
+ for (const s of a.secrets ?? []) lines.push(` ${secretLine(s)}`);
8720
+ lines.push("");
8721
+ }
8722
+ }
8723
+ if (model.unlinked.length > 0) {
8724
+ lines.push(`ACTIONS WITH NO UNTRUSTED ORIGIN (${model.unlinked.length})`, "");
8725
+ for (const a of model.unlinked) {
8726
+ const tag = a.decision ? a.decision.effect : "-";
8727
+ lines.push(` \u25CB #${a.seq} ${a.tool} ${a.summary}`);
8728
+ lines.push(` ${tag}`);
8729
+ }
8730
+ lines.push("");
8731
+ }
8732
+ const judged = traced + model.secretActions.length + model.unlinked.length;
8733
+ lines.push(
8734
+ traced === 0 ? "No action in this session traced back to content the agent read." : `${traced} of ${judged} judged actions traced back to content the agent read.`
8735
+ );
8736
+ return `${lines.join("\n")}
8737
+ `;
8738
+ }
8739
+ function sessionsIn(entries) {
8740
+ const lastSeen = /* @__PURE__ */ new Map();
8741
+ for (const e of entries) lastSeen.set(e.sessionId, e.seq);
8742
+ return [...lastSeen.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
8743
+ }
8744
+ async function replayTranscript(transcript) {
8745
+ const root = await mkdtemp2(join21(tmpdir2(), "stroq-replay-"));
8746
+ try {
8747
+ const home = join21(root, "home");
8748
+ const engine = createEngineAt({
8749
+ home,
8750
+ userHome: join21(root, "user"),
8751
+ policy: loadPolicy(),
8752
+ env: {}
8753
+ });
8754
+ const cwd = transcript.cwd ?? process.cwd();
8755
+ for (const ev of transcript.events) {
8756
+ const base = {
8757
+ sessionId: transcript.sessionId,
8758
+ toolName: ev.tool,
8759
+ toolInput: ev.input,
8760
+ cwd
8761
+ };
8762
+ try {
8763
+ if (ev.kind === "pre") await engine.pre(base);
8764
+ else await engine.post({ ...base, toolResultText: ev.resultText });
8765
+ } catch {
8766
+ }
8767
+ }
8768
+ return await new AuditLog(auditFileIn(home)).readAll();
8769
+ } finally {
8770
+ await rm5(root, { recursive: true, force: true });
8771
+ }
8772
+ }
8773
+ async function runReplay(args) {
8774
+ const { values, positionals } = parseArgs8({
8775
+ args: [...args],
8776
+ options: {
8777
+ json: { type: "boolean" },
8778
+ list: { type: "boolean" },
8779
+ transcript: { type: "string" },
8780
+ last: { type: "boolean" }
8781
+ },
8782
+ allowPositionals: true
8783
+ });
8784
+ if (values.transcript !== void 0 || values.last === true) {
8785
+ const path = values.transcript ?? (await findTranscripts(process.cwd()))[0]?.path;
8786
+ if (path === void 0) {
8787
+ process.stdout.write("no agent transcript found \u2014 looked under ~/.claude/projects\n");
8788
+ return 1;
8789
+ }
8790
+ const transcript = await readTranscript(path);
8791
+ if (transcript.events.length === 0) {
8792
+ process.stdout.write(`no tool calls recorded in ${path}
8793
+ `);
8794
+ return 1;
8795
+ }
8796
+ const replayed = await replayTranscript(transcript);
8797
+ const model2 = buildReplay(replayed, transcript.sessionId);
8798
+ if (values.json === true) {
8799
+ process.stdout.write(`${JSON.stringify(model2, null, 2)}
8800
+ `);
8801
+ return 0;
8802
+ }
8803
+ process.stdout.write(`${formatReplay(model2)}
8804
+ replayed from ${path}
8805
+ `);
8806
+ return 0;
8807
+ }
8808
+ const entries = await new AuditLog(auditFile()).readAll();
8809
+ const sessions = sessionsIn(entries);
8810
+ if (values.list === true) {
8811
+ if (sessions.length === 0) {
8812
+ process.stdout.write("no audit entries yet\n");
8813
+ return 0;
8814
+ }
8815
+ for (const id of sessions) {
8816
+ const n = entries.filter((e) => e.sessionId === id).length;
8817
+ process.stdout.write(`${id} ${n} events
8818
+ `);
8819
+ }
8820
+ return 0;
8821
+ }
8822
+ const sessionId = positionals[0] ?? sessions[0];
8823
+ if (sessionId === void 0) {
8824
+ process.stdout.write("no audit entries yet\n");
8825
+ return 1;
8826
+ }
8827
+ const model = buildReplay(entries, sessionId);
8828
+ if (values.json === true) {
8829
+ process.stdout.write(`${JSON.stringify(model, null, 2)}
8830
+ `);
8831
+ return 0;
8832
+ }
8833
+ process.stdout.write(formatReplay(model));
8834
+ return model.total === 0 ? 1 : 0;
8835
+ }
8836
+
8429
8837
  // src/commands/trust.ts
8430
8838
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync16, writeFileSync as writeFileSync3 } from "fs";
8431
8839
  import { dirname as dirname7, resolve as resolve7 } from "path";
8432
- import { parseArgs as parseArgs8 } from "util";
8840
+ import { parseArgs as parseArgs9 } from "util";
8433
8841
  function read2(file) {
8434
8842
  try {
8435
8843
  return parseTrustList(readFileSync16(file, "utf8"));
@@ -8484,7 +8892,7 @@ function formatList(entries) {
8484
8892
  `;
8485
8893
  }
8486
8894
  function runTrust(argv, now = () => /* @__PURE__ */ new Date()) {
8487
- const { values, positionals } = parseArgs8({
8895
+ const { values, positionals } = parseArgs9({
8488
8896
  args: [...argv],
8489
8897
  options: { list: { type: "boolean" }, remove: { type: "string" }, json: { type: "boolean" } },
8490
8898
  allowPositionals: true
@@ -8519,17 +8927,17 @@ function runTrust(argv, now = () => /* @__PURE__ */ new Date()) {
8519
8927
 
8520
8928
  // src/commands/untaint.ts
8521
8929
  import { existsSync as existsSync14, readdirSync as readdirSync7 } from "fs";
8522
- import { rm as rm5 } from "fs/promises";
8523
- import { join as join20 } from "path";
8524
- import { parseArgs as parseArgs9 } from "util";
8930
+ import { rm as rm6 } from "fs/promises";
8931
+ import { join as join22 } from "path";
8932
+ import { parseArgs as parseArgs10 } from "util";
8525
8933
  var USAGE = "usage: stroq untaint --session <id> | --all\n";
8526
8934
  async function clearAll() {
8527
8935
  const dir = sessionsDir();
8528
8936
  if (!existsSync14(dir)) return;
8529
- await Promise.all(readdirSync7(dir).map((f) => rm5(join20(dir, f), { force: true })));
8937
+ await Promise.all(readdirSync7(dir).map((f) => rm6(join22(dir, f), { force: true })));
8530
8938
  }
8531
8939
  async function runUntaint(args) {
8532
- const { values } = parseArgs9({
8940
+ const { values } = parseArgs10({
8533
8941
  args: [...args],
8534
8942
  options: {
8535
8943
  session: { type: "string" },
@@ -8566,7 +8974,7 @@ async function runVerify() {
8566
8974
  }
8567
8975
 
8568
8976
  // src/commands/why.ts
8569
- import { parseArgs as parseArgs10 } from "util";
8977
+ import { parseArgs as parseArgs11 } from "util";
8570
8978
  function verdictLine(entry2) {
8571
8979
  if (entry2.decision) {
8572
8980
  return `${entry2.decision.effect} by ${entry2.decision.ruleId ?? "default"}: ${entry2.decision.reason}`;
@@ -8599,7 +9007,7 @@ function formatWhy(entry2, state, now) {
8599
9007
  `;
8600
9008
  }
8601
9009
  async function runWhy(args) {
8602
- const { values } = parseArgs10({ args: [...args], options: { seq: { type: "string" } } });
9010
+ const { values } = parseArgs11({ args: [...args], options: { seq: { type: "string" } } });
8603
9011
  const entries = await new AuditLog(auditFile()).readAll();
8604
9012
  const seq = values.seq === void 0 ? null : Number.parseInt(values.seq, 10);
8605
9013
  const target = seq === null ? [...entries].reverse().find((e) => e.decision !== void 0 && e.decision.effect !== "allow") : entries.find((e) => e.seq === seq);
@@ -8632,6 +9040,10 @@ Commands:
8632
9040
  verify verify the audit hash chain
8633
9041
  untaint [--session <id>] [--all] clear a false-positive session's taint, or every session's
8634
9042
  why [--seq <n>] explain the most recent denied/asked action: rule, provenance, taint
9043
+ replay [<session>] [--last] [--transcript <path>] [--json] [--list]
9044
+ rebuild a session's causal history: which content the agent read,
9045
+ and which actions came out of it. --last reads the agent's own
9046
+ transcript, so it works on sessions that ran before you installed
8635
9047
  canary [--name <NAME>] print a canary secret to plant; its outbound use is denied and taints the session
8636
9048
  attack [--json] [--only <id>] [--fuzz]
8637
9049
  replay recorded incidents against your policy; exit 1 if any gets
@@ -8677,6 +9089,8 @@ async function main(argv) {
8677
9089
  return runTrust(rest);
8678
9090
  case "untaint":
8679
9091
  return runUntaint(rest);
9092
+ case "replay":
9093
+ return runReplay(rest);
8680
9094
  case "why":
8681
9095
  return runWhy(rest);
8682
9096
  case "canary":
@@ -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.12.1",
5
+ "version": "0.13.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.12.1",
3
+ "version": "0.13.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.12.1",
3
+ "version": "0.13.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",