@stroq/cli 0.19.2 → 0.20.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/README.md CHANGED
@@ -51,6 +51,7 @@ MCP proxy note: for clients with no hook API, `--agent mcp` rewrites the client'
51
51
  | Command | What it does |
52
52
  | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
53
53
  | `stroq init [--agent <name>] [--user] [--dry-run]` | Install hooks for `claude-code`, `cursor`, `codex`, `copilot`, `openclaw`, `windsurf` or `antigravity`, or wrap an MCP client's stdio servers with `--agent mcp --client <name>` or `--config <path>` (`--user` for the home-directory copy) |
54
+ | `stroq uninstall [--agent <name>] [--user] [--dry-run]` | Take Stroq's hooks out of an agent's config, leaving everything else as it was |
54
55
  | `stroq hook <agent>` | Hook entrypoint (reads the event on stdin; `copilot` and `openclaw` take a `pre`/`post` argument, the others do not) |
55
56
  | `stroq mcp --server <name> -- <cmd>` | MCP stdio proxy: judges every `tools/call` and scans every result for one wrapped server |
56
57
  | `stroq doctor` | Check Node version, rules, hooks for every agent, self-test |
package/dist/index.js CHANGED
@@ -1488,8 +1488,27 @@ function isSelfConfigReadOnly(segment2, word) {
1488
1488
  var OPENCLAW_PLUGIN_OFF = /\bopenclaw\s+plugins\s+(?:disable|remove|uninstall)\b/;
1489
1489
  var OPENCLAW_PLUGIN_CONFIG_WRITE = /\bopenclaw\s+config\s+set\s+plugins\./;
1490
1490
  var disablesAgentPlugin = (segment2) => OPENCLAW_PLUGIN_OFF.test(segment2) || OPENCLAW_PLUGIN_CONFIG_WRITE.test(segment2);
1491
+ var STROQ_RUNNERS = /* @__PURE__ */ new Set(["npx", "pnpm", "bunx", "yarn", "npm", "node"]);
1492
+ var STROQ_PACKAGE = /^@stroq\/cli(?:@\S*)?$/;
1493
+ var STROQ_ENTRY = /[\\/]@stroq[\\/]cli[\\/]dist[\\/]index\.js$/;
1494
+ function changesStroqState(segment2) {
1495
+ const word = commandWord(segment2);
1496
+ if (word !== "stroq" && !STROQ_RUNNERS.has(word)) return false;
1497
+ const tokens = tokenize(segment2).map((token) => token.replace(/["']/g, ""));
1498
+ const at = tokens.findIndex(
1499
+ (token) => word === "stroq" ? token.replace(/^.*[\\/]/, "") === "stroq" : STROQ_PACKAGE.test(token) || STROQ_ENTRY.test(token)
1500
+ );
1501
+ if (at === -1) return false;
1502
+ const args = tokens.slice(at + 1).filter((token) => token !== "--");
1503
+ if (args.includes("--dry-run")) return false;
1504
+ const sub = args.find((token) => !token.startsWith("-"));
1505
+ if (sub === "untaint" || sub === "init" || sub === "uninstall") return true;
1506
+ if (sub !== "trust") return false;
1507
+ const after = args.slice(args.indexOf("trust") + 1);
1508
+ return after.includes("--remove") || after.some((token) => !token.startsWith("-"));
1509
+ }
1491
1510
  function classifySelfConfigSegment(segment2) {
1492
- if (disablesAgentPlugin(segment2)) return "deny";
1511
+ if (disablesAgentPlugin(segment2) || changesStroqState(segment2)) return "deny";
1493
1512
  const word = commandWord(segment2);
1494
1513
  if (!touchesSelfConfig(segment2, word)) return null;
1495
1514
  if (isSelfConfigWriteIntent(segment2, word)) return "deny";
@@ -1534,7 +1553,9 @@ function selfTamperSignals(segments) {
1534
1553
  for (const segment2 of segments) {
1535
1554
  const verdict = classifySelfConfigSegment(segment2);
1536
1555
  if (verdict === "deny" || verdict === "ask" && interpreterInlineElsewhere) {
1537
- deny.push(disablesAgentPlugin(segment2) ? "self-config-disable" : "self-config-write");
1556
+ deny.push(
1557
+ changesStroqState(segment2) ? "stroq-state-change" : disablesAgentPlugin(segment2) ? "self-config-disable" : "self-config-write"
1558
+ );
1538
1559
  } else if (verdict === "ask") {
1539
1560
  ask.push("self-config-touch");
1540
1561
  }
@@ -1963,10 +1984,9 @@ var FileTrustStore = class {
1963
1984
  this.cache = parseTrustList(raw);
1964
1985
  return this.cache;
1965
1986
  }
1966
- trusts(source, text) {
1967
- if (source === "") return false;
1987
+ trusts(text) {
1968
1988
  const digest = trustDigest(text);
1969
- return this.load().entries.some((e) => e.source === source && e.sha256 === digest);
1989
+ return this.load().entries.some((e) => e.sha256 === digest);
1970
1990
  }
1971
1991
  list() {
1972
1992
  return this.load().entries;
@@ -2019,7 +2039,7 @@ var DEFAULT_POLICY = {
2019
2039
  {
2020
2040
  id: "deny-self-tamper",
2021
2041
  effect: "deny",
2022
- reason: "Modifying agent security configuration is blocked",
2042
+ reason: "Modifying agent security configuration is blocked; that change is yours to make, outside the agent (to take Stroq out: stroq uninstall)",
2023
2043
  when: { classes: ["config.self"], taint: "any" }
2024
2044
  },
2025
2045
  {
@@ -2041,7 +2061,7 @@ var DEFAULT_POLICY = {
2041
2061
  {
2042
2062
  id: "deny-origin-suspect",
2043
2063
  effect: "deny",
2044
- reason: "Action was dictated by content Stroq flagged as suspicious; blocked (a false positive can be cleared with: stroq untaint --session <id>)",
2064
+ reason: "Action was dictated by content Stroq flagged as suspicious; blocked",
2045
2065
  when: { classes: ["origin.suspect"], taint: "any" }
2046
2066
  },
2047
2067
  {
@@ -2636,6 +2656,53 @@ function addCanaryFile(file, absolutePath) {
2636
2656
  renameSync(temp, file);
2637
2657
  }
2638
2658
 
2659
+ // ../core/src/util/controls.ts
2660
+ var UNSAFE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f‪-‮⁦-⁩]/g;
2661
+ function neutralizeControls(text) {
2662
+ return text.replace(UNSAFE, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
2663
+ }
2664
+
2665
+ // ../core/src/provenance/describe.ts
2666
+ function ageLabel(fromIso, now) {
2667
+ const from = Date.parse(fromIso);
2668
+ if (Number.isNaN(from)) return "unknown time";
2669
+ const seconds = Math.max(0, Math.round((now.getTime() - from) / 1e3));
2670
+ if (seconds < 90) return `${seconds} s`;
2671
+ if (seconds < 5400) return `${Math.round(seconds / 60)} min`;
2672
+ return `${Math.round(seconds / 3600)} h`;
2673
+ }
2674
+ function toEvidence(hit) {
2675
+ const { kind, excerpt, tool, source, at, suspect } = hit.record;
2676
+ return { kind, excerpt, tool, source, at, suspect };
2677
+ }
2678
+ function describeEvidence(evidence, now) {
2679
+ const flagged = evidence.suspect ? "Stroq flagged that content as suspicious." : "that content was not flagged, but tool output is data, not instructions.";
2680
+ const quoted = neutralizeControls(
2681
+ `"${evidence.excerpt}" appeared in the output of ${evidence.tool} (${evidence.source})`
2682
+ );
2683
+ return `${quoted} ${ageLabel(evidence.at, now)} ago; ${flagged}`;
2684
+ }
2685
+
2686
+ // ../core/src/policy/way-out.ts
2687
+ var SAFE_WORD = /^[\w@%+=:,./-]+$/;
2688
+ var shellQuote = (word) => SAFE_WORD.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
2689
+ function withWayOut(decision, policy, taint, sessionId, now) {
2690
+ if (decision.effect === "allow" || decision.ruleId === null) return decision;
2691
+ const rule = policy.rules.find((r) => r.id === decision.ruleId);
2692
+ if (rule === void 0) return decision;
2693
+ const byTaint = rule.when.taint === "suspect" && taint !== null;
2694
+ const byOrigin = rule.when.classes.includes("origin.suspect");
2695
+ if (!byTaint && !byOrigin) return decision;
2696
+ const source = byTaint ? taint.sources.at(-1) : void 0;
2697
+ const path = source?.source === void 0 ? "" : neutralizeControls(source.source);
2698
+ const what = source === void 0 ? "" : ` Tainted by ${source.tool}${path === "" ? "" : ` ${path}`} ${ageLabel(source.at, now)} ago.`;
2699
+ const trust = source?.tool === "Read" && path !== "" ? `; to stop that file tainting it again: stroq trust ${shellQuote(path)}` : "";
2700
+ return {
2701
+ ...decision,
2702
+ reason: `${decision.reason.replace(/\.$/, "")}.${what} If that was a false positive, clear it yourself, outside the agent: stroq untaint --session ${shellQuote(sessionId)}${trust}`
2703
+ };
2704
+ }
2705
+
2639
2706
  // ../core/src/provenance/action-atoms.ts
2640
2707
  import { readdirSync } from "node:fs";
2641
2708
  import { join as join3 } from "node:path";
@@ -2944,33 +3011,6 @@ function originClasses(hits, classes) {
2944
3011
  return { classes: out, counted };
2945
3012
  }
2946
3013
 
2947
- // ../core/src/util/controls.ts
2948
- var UNSAFE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f‪-‮⁦-⁩]/g;
2949
- function neutralizeControls(text) {
2950
- return text.replace(UNSAFE, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
2951
- }
2952
-
2953
- // ../core/src/provenance/describe.ts
2954
- function ageLabel(fromIso, now) {
2955
- const from = Date.parse(fromIso);
2956
- if (Number.isNaN(from)) return "unknown time";
2957
- const seconds = Math.max(0, Math.round((now.getTime() - from) / 1e3));
2958
- if (seconds < 90) return `${seconds} s`;
2959
- if (seconds < 5400) return `${Math.round(seconds / 60)} min`;
2960
- return `${Math.round(seconds / 3600)} h`;
2961
- }
2962
- function toEvidence(hit) {
2963
- const { kind, excerpt, tool, source, at, suspect } = hit.record;
2964
- return { kind, excerpt, tool, source, at, suspect };
2965
- }
2966
- function describeEvidence(evidence, now) {
2967
- const flagged = evidence.suspect ? "Stroq flagged that content as suspicious." : "that content was not flagged, but tool output is data, not instructions.";
2968
- const quoted = neutralizeControls(
2969
- `"${evidence.excerpt}" appeared in the output of ${evidence.tool} (${evidence.source})`
2970
- );
2971
- return `${quoted} ${ageLabel(evidence.at, now)} ago; ${flagged}`;
2972
- }
2973
-
2974
3014
  // ../core/src/secrets/extract.ts
2975
3015
  var MIN_SECRET_LENGTH = 12;
2976
3016
  var MAX_TEXT_CHARS = 262144;
@@ -3355,7 +3395,13 @@ var StroqEngine = class {
3355
3395
  ...secrets.length > 0 ? ["secret.egress"] : [],
3356
3396
  ...unscannable ? ["secret.unscannable"] : []
3357
3397
  ];
3358
- const decision = evaluatePolicy(this.opts.policy, classes, state.taint?.level ?? null);
3398
+ const decision = withWayOut(
3399
+ evaluatePolicy(this.opts.policy, classes, state.taint?.level ?? null),
3400
+ this.opts.policy,
3401
+ state.taint,
3402
+ event.sessionId,
3403
+ new Date(this.now())
3404
+ );
3359
3405
  const provenance = origin2.counted.map(toEvidence);
3360
3406
  const summary = await this.safeSummary(
3361
3407
  summarizeInput(event.toolName, event.auditInput ?? event.toolInput),
@@ -3407,7 +3453,7 @@ var StroqEngine = class {
3407
3453
  );
3408
3454
  const ruleIds = [...new Set(scan.matches.map((m) => m.ruleId))];
3409
3455
  const source = redact(summary).slice(0, MAX_STORED_CHARS);
3410
- const trusted = scan.verdict === "suspect" && this.opts.trust?.trusts(source, event.toolResultText) === true;
3456
+ const trusted = scan.verdict === "suspect" && this.opts.trust?.trusts(event.toolResultText) === true;
3411
3457
  await this.opts.audit.append({
3412
3458
  sessionId: event.sessionId,
3413
3459
  phase: "post",
@@ -6942,9 +6988,9 @@ var openclawInstallArgv = (dir) => [
6942
6988
  ["plugins", "install", "--link", dir],
6943
6989
  ["plugins", "enable", OPENCLAW_PLUGIN_ID]
6944
6990
  ];
6945
- var SAFE_WORD = /^[\w@%+=:,./-]+$/;
6946
- var shellQuote = (arg) => SAFE_WORD.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
6947
- var openclawInstallCommands = (dir) => openclawInstallArgv(dir).map((argv) => [OPENCLAW_BIN, ...argv].map(shellQuote).join(" "));
6991
+ var SAFE_WORD2 = /^[\w@%+=:,./-]+$/;
6992
+ var shellQuote2 = (arg) => SAFE_WORD2.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
6993
+ var openclawInstallCommands = (dir) => openclawInstallArgv(dir).map((argv) => [OPENCLAW_BIN, ...argv].map(shellQuote2).join(" "));
6948
6994
  function openclawOnPath(env = process.env, plat = process.platform) {
6949
6995
  const names = plat === "win32" ? (env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => ext !== "").map((ext) => `${OPENCLAW_BIN}${ext.toLowerCase()}`) : [OPENCLAW_BIN];
6950
6996
  for (const entry2 of (env["PATH"] ?? "").split(delimiter)) {
@@ -7016,12 +7062,12 @@ function mcpConfigPath(client, scope, cwd = process.cwd()) {
7016
7062
  }
7017
7063
  var readMcpConfig = (file) => readJsonObject(file);
7018
7064
  var hasValidMcpServers = (config) => config.mcpServers === void 0 || isPlainObject(config.mcpServers);
7019
- var STROQ_ENTRY = /(^|[\\/])(index\.(?:js|ts|mjs|cjs)|stroq(?:\.js|\.cmd)?)$/;
7065
+ var STROQ_ENTRY2 = /(^|[\\/])(index\.(?:js|ts|mjs|cjs)|stroq(?:\.js|\.cmd)?)$/;
7020
7066
  function wrapperIndex(args) {
7021
7067
  for (let i = 1; i < args.length; i += 1) {
7022
7068
  if (args[i] !== "mcp" || args[i + 1] !== "--server" || args[i + 3] !== "--client") continue;
7023
7069
  const entry2 = args[i - 1];
7024
- if (typeof entry2 !== "string" || !STROQ_ENTRY.test(entry2)) continue;
7070
+ if (typeof entry2 !== "string" || !STROQ_ENTRY2.test(entry2)) continue;
7025
7071
  if (args.indexOf("--", i) === -1) continue;
7026
7072
  return i;
7027
7073
  }
@@ -7511,6 +7557,9 @@ async function runInit(args) {
7511
7557
  const code = install[agent](scope, command, dryRun);
7512
7558
  if (code === 0 && !dryRun) {
7513
7559
  recordInstall(agent, scope, agent === "openclaw" ? hookArgv(node, entry2).join(" ") : command);
7560
+ const flags = `${agent === "claude-code" ? "" : ` --agent ${agent}`}${scope === "user" ? " --user" : ""}`;
7561
+ process.stdout.write(`To remove them: stroq uninstall${flags}
7562
+ `);
7514
7563
  }
7515
7564
  return code;
7516
7565
  }
@@ -8241,7 +8290,7 @@ function flaggedTools(tools) {
8241
8290
  ).map((t) => typeof t.name === "string" ? t.name : "(unnamed)");
8242
8291
  }
8243
8292
  async function probeOne(spec, timeoutMs) {
8244
- return new Promise((resolve14) => {
8293
+ return new Promise((resolve15) => {
8245
8294
  const empty = { server: spec.name, tools: 0, flagged: [] };
8246
8295
  let child;
8247
8296
  try {
@@ -8251,7 +8300,7 @@ async function probeOne(spec, timeoutMs) {
8251
8300
  stdio: ["pipe", "pipe", "ignore"]
8252
8301
  });
8253
8302
  } catch (err) {
8254
- resolve14({ ...empty, error: err.message });
8303
+ resolve15({ ...empty, error: err.message });
8255
8304
  return;
8256
8305
  }
8257
8306
  let buf = "";
@@ -8261,7 +8310,7 @@ async function probeOne(spec, timeoutMs) {
8261
8310
  done = true;
8262
8311
  clearTimeout(timer);
8263
8312
  killChildTree(child, "SIGTERM");
8264
- resolve14(result);
8313
+ resolve15(result);
8265
8314
  };
8266
8315
  const timer = setTimeout(
8267
8316
  () => finish({ ...empty, error: `no tools/list response within ${timeoutMs} ms` }),
@@ -9709,8 +9758,8 @@ async function withDeadline(work, ms, onTimeout) {
9709
9758
  try {
9710
9759
  return await Promise.race([
9711
9760
  work,
9712
- new Promise((resolve14) => {
9713
- timer = setTimeout(() => resolve14(onTimeout()), ms);
9761
+ new Promise((resolve15) => {
9762
+ timer = setTimeout(() => resolve15(onTimeout()), ms);
9714
9763
  })
9715
9764
  ]);
9716
9765
  } finally {
@@ -10307,12 +10356,12 @@ async function writeBackpressured(sink, text, source) {
10307
10356
  return;
10308
10357
  }
10309
10358
  source.pause();
10310
- await new Promise((resolve14) => {
10359
+ await new Promise((resolve15) => {
10311
10360
  const done = () => {
10312
10361
  sink.off("drain", done);
10313
10362
  sink.off("error", done);
10314
10363
  sink.off("close", done);
10315
- resolve14();
10364
+ resolve15();
10316
10365
  };
10317
10366
  sink.once("drain", done);
10318
10367
  sink.once("error", done);
@@ -10877,14 +10926,14 @@ async function runMcpProxy(options) {
10877
10926
  const onSigterm = relay("SIGTERM");
10878
10927
  process.on("SIGINT", onSigint);
10879
10928
  process.on("SIGTERM", onSigterm);
10880
- const code = await new Promise((resolve14) => {
10929
+ const code = await new Promise((resolve15) => {
10881
10930
  child.on("error", (err) => {
10882
10931
  options.stderr.write(`stroq mcp: cannot start the MCP server: ${err.message}
10883
10932
  `);
10884
- resolve14(1);
10933
+ resolve15(1);
10885
10934
  });
10886
10935
  child.on("close", (exitCode, signal) => {
10887
- resolve14(signal === null ? exitCode ?? 1 : 1);
10936
+ resolve15(signal === null ? exitCode ?? 1 : 1);
10888
10937
  });
10889
10938
  });
10890
10939
  shuttingDown = true;
@@ -11088,7 +11137,7 @@ async function feedLines(path, parser) {
11088
11137
  return parser.finish();
11089
11138
  }
11090
11139
  function projectSlug(cwd) {
11091
- return cwd.replace(/[/.]/g, "-");
11140
+ return cwd.replace(/[^a-zA-Z0-9]/g, "-");
11092
11141
  }
11093
11142
  var transcriptRoot = () => join22(homedir20(), ".claude", "projects");
11094
11143
  async function findTranscripts(cwd) {
@@ -11217,12 +11266,18 @@ function buildReplay(allEntries, sessionId) {
11217
11266
  };
11218
11267
  }
11219
11268
  var RULE_PREVIEW = 3;
11220
- function verdictTag(entry2) {
11269
+ function verdictTag(entry2, voice) {
11221
11270
  const d = entry2.decision;
11222
11271
  if (!d) return "";
11223
- if (d.effect === "deny") return `DENIED ${d.ruleId ?? "default"}`;
11224
- if (d.effect === "ask") return `ASKED ${d.ruleId ?? "default"}`;
11225
- return `allowed ${d.ruleId ?? "default"}`;
11272
+ const rule = d.ruleId ?? "default";
11273
+ if (voice === "replayed") {
11274
+ if (d.effect === "deny") return `WOULD DENY ${rule}`;
11275
+ if (d.effect === "ask") return `WOULD ASK ${rule}`;
11276
+ return `would allow ${rule}`;
11277
+ }
11278
+ if (d.effect === "deny") return `DENIED ${rule}`;
11279
+ if (d.effect === "ask") return `ASKED ${rule}`;
11280
+ return `allowed ${rule}`;
11226
11281
  }
11227
11282
  function readLine(src) {
11228
11283
  const scan = src.read?.scan;
@@ -11238,24 +11293,25 @@ function readLine(src) {
11238
11293
  return `SUSPECT ${scan.score.toFixed(2)} \u2014 ${scan.ruleIds.length} rules: ${ids}${more}${waived}`;
11239
11294
  }
11240
11295
  var secretLine = (s) => `${s.name} from ${s.source}${s.canary ? " (canary)" : ""}`;
11241
- function consequenceBlock(c, last) {
11296
+ function consequenceBlock(c, last, voice) {
11242
11297
  const elbow = last ? " \u2514\u2500\u25BA" : " \u251C\u2500\u25BA";
11243
11298
  const rail = last ? " " : " \u2502 ";
11244
11299
  const gap = ageLabel(c.evidence.at, new Date(c.action.ts));
11300
+ const tag = verdictTag(c.action, voice);
11245
11301
  return [
11246
11302
  `${elbow} #${c.action.seq} ${c.action.tool} ${c.action.summary}`,
11247
- `${rail} ${verdictTag(c.action)}${" ".repeat(Math.max(1, 34 - verdictTag(c.action).length))}${gap} later`,
11303
+ `${rail} ${tag}${" ".repeat(Math.max(1, 34 - tag.length))}${gap} later`,
11248
11304
  `${rail} carried over: "${c.evidence.excerpt}" (${c.evidence.kind})` + (c.alsoCarried > 0 ? ` and ${c.alsoCarried} more` : "")
11249
11305
  ];
11250
11306
  }
11251
- function sourceBlock(src) {
11307
+ function sourceBlock(src, voice) {
11252
11308
  const seq = src.read ? `#${src.read.seq} ` : "";
11253
11309
  const head = [` \u25A0 ${seq}${src.tool} ${src.source}`, ` ${readLine(src)}`];
11254
11310
  if (src.consequences.length === 0) {
11255
11311
  return [...head, " nothing traced back to it", ""];
11256
11312
  }
11257
11313
  const body = src.consequences.flatMap(
11258
- (c, i) => consequenceBlock(c, i === src.consequences.length - 1)
11314
+ (c, i) => consequenceBlock(c, i === src.consequences.length - 1, voice)
11259
11315
  );
11260
11316
  return [...head, " \u2502", ...body, ""];
11261
11317
  }
@@ -11263,7 +11319,7 @@ function duration(model) {
11263
11319
  if (!model.first || !model.last) return "";
11264
11320
  return ` \xB7 ${ageLabel(model.first, new Date(model.last))} long`;
11265
11321
  }
11266
- function formatReplay(model) {
11322
+ function formatReplay(model, voice = "recorded") {
11267
11323
  if (model.total === 0) {
11268
11324
  return `no audit entries for session ${model.sessionId}
11269
11325
  `;
@@ -11272,18 +11328,18 @@ function formatReplay(model) {
11272
11328
  const lines = [
11273
11329
  "stroq replay \u2014 what the agent did, and what told it to do it",
11274
11330
  "",
11275
- ` session ${model.sessionId} \xB7 ${model.total} events${duration(model)} \xB7 ${model.denied} denied \xB7 ${model.asked} asked`,
11331
+ ` session ${model.sessionId} \xB7 ${model.total} events${duration(model)} \xB7 ` + (voice === "replayed" ? `${model.denied} would be denied \xB7 ${model.asked} would ask (today's policy)` : `${model.denied} denied \xB7 ${model.asked} asked`),
11276
11332
  ""
11277
11333
  ];
11278
11334
  if (model.sources.length > 0) {
11279
11335
  lines.push("CONTENT THE AGENT READ, AND WHAT CAME OUT OF IT", "");
11280
- for (const src of model.sources) lines.push(...sourceBlock(src));
11336
+ for (const src of model.sources) lines.push(...sourceBlock(src, voice));
11281
11337
  }
11282
11338
  if (model.secretActions.length > 0) {
11283
11339
  lines.push("ACTIONS CARRYING A KNOWN SECRET VALUE", "");
11284
11340
  for (const a of model.secretActions) {
11285
11341
  lines.push(` \u25CF #${a.seq} ${a.tool} ${a.summary}`);
11286
- lines.push(` ${verdictTag(a)}`);
11342
+ lines.push(` ${verdictTag(a, voice)}`);
11287
11343
  for (const s of a.secrets ?? []) lines.push(` ${secretLine(s)}`);
11288
11344
  lines.push("");
11289
11345
  }
@@ -11291,7 +11347,7 @@ function formatReplay(model) {
11291
11347
  if (model.unlinked.length > 0) {
11292
11348
  lines.push(`ACTIONS WITH NO UNTRUSTED ORIGIN (${model.unlinked.length})`, "");
11293
11349
  for (const a of model.unlinked) {
11294
- const tag = a.decision ? a.decision.effect : "-";
11350
+ const tag = a.decision ? verdictTag(a, voice) : "-";
11295
11351
  lines.push(` \u25CB #${a.seq} ${a.tool} ${a.summary}`);
11296
11352
  lines.push(` ${tag}`);
11297
11353
  }
@@ -11301,6 +11357,12 @@ function formatReplay(model) {
11301
11357
  lines.push(
11302
11358
  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.`
11303
11359
  );
11360
+ if (voice === "replayed")
11361
+ lines.push(
11362
+ "",
11363
+ "These are the verdicts today's policy gives the recording \u2014",
11364
+ "not a record of what was blocked at the time. Guard the next session: stroq init"
11365
+ );
11304
11366
  return `${lines.join("\n")}
11305
11367
  `;
11306
11368
  }
@@ -11371,7 +11433,7 @@ async function runReplay(args) {
11371
11433
  `);
11372
11434
  return 0;
11373
11435
  }
11374
- process.stdout.write(`${formatReplay(model2)}
11436
+ process.stdout.write(`${formatReplay(model2, "replayed")}
11375
11437
  replayed from ${path}
11376
11438
  `);
11377
11439
  return 0;
@@ -11466,7 +11528,7 @@ async function launch(options) {
11466
11528
  const relays = RELAYED.map((signal) => ({ signal, listener: () => kill(child, signal) }));
11467
11529
  for (const { signal, listener } of relays) signals.on(signal, listener);
11468
11530
  try {
11469
- return await new Promise((resolve14) => {
11531
+ return await new Promise((resolve15) => {
11470
11532
  child.on("error", (err) => {
11471
11533
  const code = err.code;
11472
11534
  stderr.write(
@@ -11474,9 +11536,9 @@ async function launch(options) {
11474
11536
  ` : `stroq run: cannot start "${options.command}": ${err.message}
11475
11537
  `
11476
11538
  );
11477
- resolve14(code === "ENOENT" ? NOT_FOUND : 1);
11539
+ resolve15(code === "ENOENT" ? NOT_FOUND : 1);
11478
11540
  });
11479
- child.on("close", (code, signal) => resolve14(exitCodeFor(code, signal, plat)));
11541
+ child.on("close", (code, signal) => resolve15(exitCodeFor(code, signal, plat)));
11480
11542
  });
11481
11543
  } finally {
11482
11544
  for (const { signal, listener } of relays) signals.off(signal, listener);
@@ -11793,6 +11855,7 @@ stroq run \u2014 ${invocation.command}${agent === null ? "" : ` (${agent})`}`);
11793
11855
 
11794
11856
  // src/commands/sent.ts
11795
11857
  import { homedir as homedir25 } from "node:os";
11858
+ import { isAbsolute as isAbsolute6, relative as relative6, resolve as resolve13 } from "node:path";
11796
11859
  import { parseArgs as parseArgs9 } from "node:util";
11797
11860
 
11798
11861
  // src/sent/format.ts
@@ -11908,10 +11971,18 @@ function formatSent(report2) {
11908
11971
  } else {
11909
11972
  lines.push(...MEANING, "");
11910
11973
  }
11911
- lines.push(...coverageLines(report2));
11974
+ lines.push(...coverageLines(report2), "", ...nextLines(report2.credentials.length > 0));
11912
11975
  return `${lines.join("\n")}
11913
11976
  `;
11914
11977
  }
11978
+ function nextLines(found) {
11979
+ return [
11980
+ "NEXT",
11981
+ ...found ? [" Rotate each credential named above that is still live."] : [],
11982
+ " stroq replay --last what the session read, and which later actions matched it",
11983
+ " stroq init guard the next session (--agent <name> for another agent)"
11984
+ ];
11985
+ }
11915
11986
 
11916
11987
  // src/sent/readers.ts
11917
11988
  import { open as open3 } from "node:fs/promises";
@@ -12865,6 +12936,10 @@ know what to look for, and prints names and sources only, never a value. A
12865
12936
  finding exits 0 by default: a session that already happened cannot be
12866
12937
  changed by today's commit. A transcript match does not confirm provider delivery.
12867
12938
  `;
12939
+ function isWithin(dir, root) {
12940
+ const rel = relative6(resolve13(root), resolve13(dir));
12941
+ return rel === "" || !rel.startsWith("..") && !isAbsolute6(rel);
12942
+ }
12868
12943
  async function runSent(args) {
12869
12944
  let parsed;
12870
12945
  try {
@@ -12921,6 +12996,15 @@ ${USAGE}`);
12921
12996
  return 1;
12922
12997
  }
12923
12998
  const transcript = await found.reader.read(found.path);
12999
+ if (values.transcript === void 0 && transcript.cwd !== null && !isWithin(cwd, transcript.cwd)) {
13000
+ process.stdout.write(
13001
+ `no agent session recorded in ${cwd}.
13002
+ The newest one on this machine ran in ${transcript.cwd}: run \`stroq sent --last\` there,
13003
+ or name a session with --transcript <path>.
13004
+ `
13005
+ );
13006
+ return 1;
13007
+ }
12924
13008
  if (transcript.events.length === 0) {
12925
13009
  process.stdout.write(`no tool calls recorded in ${found.path}
12926
13010
  `);
@@ -12947,7 +13031,7 @@ ${USAGE}`);
12947
13031
 
12948
13032
  // src/commands/trust.ts
12949
13033
  import { chmodSync as chmodSync4, mkdirSync as mkdirSync9, readFileSync as readFileSync13, writeFileSync as writeFileSync7 } from "node:fs";
12950
- import { dirname as dirname11, resolve as resolve13 } from "node:path";
13034
+ import { dirname as dirname11, resolve as resolve14 } from "node:path";
12951
13035
  import { parseArgs as parseArgs10 } from "node:util";
12952
13036
  function read2(file) {
12953
13037
  try {
@@ -13010,7 +13094,7 @@ function runTrust(argv, now = () => /* @__PURE__ */ new Date()) {
13010
13094
  });
13011
13095
  const file = trustFile();
13012
13096
  if (values.remove !== void 0) {
13013
- const path = resolve13(values.remove);
13097
+ const path = resolve14(values.remove);
13014
13098
  const current = read2(file);
13015
13099
  const entries = current.entries.filter((e) => e.source !== path);
13016
13100
  if (entries.length === current.entries.length) {
@@ -13031,24 +13115,154 @@ function runTrust(argv, now = () => /* @__PURE__ */ new Date()) {
13031
13115
  );
13032
13116
  return 0;
13033
13117
  }
13034
- const { code, message } = add(file, resolve13(positionals[0]), now);
13118
+ const { code, message } = add(file, resolve14(positionals[0]), now);
13035
13119
  process.stdout.write(message);
13036
13120
  return code;
13037
13121
  }
13038
13122
 
13123
+ // src/commands/uninstall.ts
13124
+ import { existsSync as existsSync15, rmSync as rmSync3 } from "node:fs";
13125
+ import { parseArgs as parseArgs11 } from "node:util";
13126
+ var ownedBy = (agent) => (handler2) => isPlainObject(handler2) && typeof handler2["command"] === "string" && new RegExp(` hook ${agent}(?: \\S+)?$`).test(handler2["command"]);
13127
+ function stripEvent(entries, ours) {
13128
+ return entries.flatMap((entry2) => {
13129
+ if (ours(entry2)) return [];
13130
+ if (!isPlainObject(entry2) || !Array.isArray(entry2["hooks"])) return [entry2];
13131
+ const hooks = entry2["hooks"];
13132
+ const kept = hooks.filter((handler2) => !ours(handler2));
13133
+ if (kept.length === hooks.length) return [entry2];
13134
+ return kept.length > 0 ? [{ ...entry2, hooks: kept }] : [];
13135
+ });
13136
+ }
13137
+ function stripEvents(events, ours) {
13138
+ const out = {};
13139
+ for (const [event, value] of Object.entries(events)) {
13140
+ if (!Array.isArray(value)) {
13141
+ out[event] = value;
13142
+ continue;
13143
+ }
13144
+ const kept = stripEvent(value, ours);
13145
+ if (kept.length > 0 || value.length === 0) out[event] = kept;
13146
+ }
13147
+ return out;
13148
+ }
13149
+ function stripHooksKey(settings, ours) {
13150
+ const { hooks, ...rest } = settings;
13151
+ if (!isPlainObject(hooks)) return settings;
13152
+ const kept = stripEvents(hooks, ours);
13153
+ return Object.keys(kept).length > 0 ? { ...rest, hooks: kept } : rest;
13154
+ }
13155
+ var JSON_TARGETS = {
13156
+ "claude-code": {
13157
+ path: settingsPath,
13158
+ strip: (s) => stripHooksKey(s, ownedBy("claude-code"))
13159
+ },
13160
+ cursor: { path: cursorHooksPath, strip: (s) => stripHooksKey(s, ownedBy("cursor")) },
13161
+ windsurf: { path: windsurfHooksPath, strip: (s) => stripHooksKey(s, ownedBy("windsurf")) },
13162
+ // Codex also reads events kept at the file's root, so both levels are stripped.
13163
+ codex: {
13164
+ path: codexHooksPath,
13165
+ strip: (s) => stripEvents(stripHooksKey(s, ownedBy("codex")), ownedBy("codex"))
13166
+ },
13167
+ antigravity: {
13168
+ path: antigravityHooksPath,
13169
+ strip: ({ [ANTIGRAVITY_HOOK_NAME]: _ours, ...rest }) => rest
13170
+ }
13171
+ };
13172
+ var nothing = (file) => ({
13173
+ removed: false,
13174
+ message: `No Stroq hooks in ${file}; nothing to remove.
13175
+ `
13176
+ });
13177
+ function uninstallJson(target, file, dryRun) {
13178
+ if (!existsSync15(file)) return nothing(file);
13179
+ const before = readJsonObject(file);
13180
+ const after = target.strip(before);
13181
+ if (JSON.stringify(after) === JSON.stringify(before)) return nothing(file);
13182
+ if (dryRun) return { removed: true, message: `${JSON.stringify(after, null, 2)}
13183
+ ` };
13184
+ writeJsonObject(file, after);
13185
+ return {
13186
+ removed: true,
13187
+ message: `Removed Stroq's hooks from ${file}. Anything else in it is unchanged.
13188
+ Run "stroq doctor" to check.
13189
+ `
13190
+ };
13191
+ }
13192
+ function uninstallCopilot(file, dryRun) {
13193
+ if (!existsSync15(file)) return nothing(file);
13194
+ const text = JSON.stringify(readJsonObject(file));
13195
+ if (!/ hook copilot (?:pre|post)\b/.test(text)) return nothing(file);
13196
+ if (dryRun) return { removed: true, message: `would delete ${file}
13197
+ ` };
13198
+ rmSync3(file);
13199
+ return {
13200
+ removed: true,
13201
+ message: `Deleted ${file}, which held only Stroq's hooks.
13202
+ Run "stroq doctor" to check.
13203
+ `
13204
+ };
13205
+ }
13206
+ function uninstallAgent(agent, scope, cwd, dryRun) {
13207
+ if (agent === "openclaw")
13208
+ return {
13209
+ removed: false,
13210
+ message: "The OpenClaw plugin is registered with your Gateway. Switch it off with:\n openclaw plugins disable stroq\n"
13211
+ };
13212
+ if (agent === "copilot") return uninstallCopilot(copilotHooksPath(scope, cwd), dryRun);
13213
+ const target = JSON_TARGETS[agent];
13214
+ return uninstallJson(target, target.path(scope, cwd), dryRun);
13215
+ }
13216
+ async function runUninstall(args) {
13217
+ const { values } = parseArgs11({
13218
+ args: [...args],
13219
+ options: {
13220
+ agent: { type: "string" },
13221
+ user: { type: "boolean" },
13222
+ "dry-run": { type: "boolean" },
13223
+ client: { type: "string" },
13224
+ config: { type: "string" }
13225
+ }
13226
+ });
13227
+ const agent = values.agent ?? "claude-code";
13228
+ if (!isInitAgent(agent)) {
13229
+ process.stderr.write(`stroq uninstall: unknown agent "${agent}"
13230
+ `);
13231
+ return 2;
13232
+ }
13233
+ if (agent === "mcp")
13234
+ return runInit([
13235
+ "--agent",
13236
+ "mcp",
13237
+ "--unwrap",
13238
+ ...values.user === true ? ["--user"] : [],
13239
+ ...values["dry-run"] === true ? ["--dry-run"] : [],
13240
+ ...values.client === void 0 ? [] : ["--client", values.client],
13241
+ ...values.config === void 0 ? [] : ["--config", values.config]
13242
+ ]);
13243
+ const result = uninstallAgent(
13244
+ agent,
13245
+ values.user === true ? "user" : "project",
13246
+ process.cwd(),
13247
+ values["dry-run"] === true
13248
+ );
13249
+ process.stdout.write(result.message);
13250
+ return 0;
13251
+ }
13252
+
13039
13253
  // src/commands/untaint.ts
13040
- import { existsSync as existsSync15, readdirSync as readdirSync6 } from "node:fs";
13254
+ import { existsSync as existsSync16, readdirSync as readdirSync6 } from "node:fs";
13041
13255
  import { rm as rm6 } from "node:fs/promises";
13042
13256
  import { join as join28 } from "node:path";
13043
- import { parseArgs as parseArgs11 } from "node:util";
13257
+ import { parseArgs as parseArgs12 } from "node:util";
13044
13258
  var USAGE2 = "usage: stroq untaint --session <id> | --all\n";
13045
13259
  async function clearAll() {
13046
13260
  const dir = sessionsDir();
13047
- if (!existsSync15(dir)) return;
13261
+ if (!existsSync16(dir)) return;
13048
13262
  await Promise.all(readdirSync6(dir).map((f) => rm6(join28(dir, f), { force: true })));
13049
13263
  }
13050
13264
  async function runUntaint(args) {
13051
- const { values } = parseArgs11({
13265
+ const { values } = parseArgs12({
13052
13266
  args: [...args],
13053
13267
  options: {
13054
13268
  session: { type: "string" },
@@ -13085,7 +13299,7 @@ async function runVerify() {
13085
13299
  }
13086
13300
 
13087
13301
  // src/commands/why.ts
13088
- import { parseArgs as parseArgs12 } from "node:util";
13302
+ import { parseArgs as parseArgs13 } from "node:util";
13089
13303
  function verdictLine(entry2) {
13090
13304
  if (entry2.decision) {
13091
13305
  return `${entry2.decision.effect} by ${entry2.decision.ruleId ?? "default"}: ${entry2.decision.reason}`;
@@ -13118,7 +13332,7 @@ function formatWhy(entry2, state, now) {
13118
13332
  `;
13119
13333
  }
13120
13334
  async function runWhy(args) {
13121
- const { values } = parseArgs12({ args: [...args], options: { seq: { type: "string" } } });
13335
+ const { values } = parseArgs13({ args: [...args], options: { seq: { type: "string" } } });
13122
13336
  const entries = await new AuditLog(auditFile()).readAll();
13123
13337
  const seq = values.seq === void 0 ? null : Number.parseInt(values.seq, 10);
13124
13338
  const target = seq === null ? [...entries].reverse().find((e) => e.decision !== void 0 && e.decision.effect !== "allow") : entries.find((e) => e.seq === seq);
@@ -13134,76 +13348,369 @@ async function runWhy(args) {
13134
13348
  return 0;
13135
13349
  }
13136
13350
 
13137
- // src/index.ts
13138
- var USAGE3 = `stroq <command>
13139
-
13140
- Commands:
13141
- init [--agent <name>] [--user] [--dry-run]
13142
- install hooks (--agent claude-code | cursor | codex | copilot | openclaw | windsurf | antigravity; project config by default)
13143
- or wrap a client's MCP servers (--agent mcp --client <name>)
13144
- hook <claude-code|cursor|codex> hook entrypoint: reads the event JSON on stdin, prints a decision
13145
- hook windsurf Windsurf entrypoint: its events name themselves, and a block is exit 2 with the reason on stderr
13146
- hook copilot <pre|post> Copilot entrypoint: its events carry no name, so the phase is an argument
13147
- hook openclaw <pre|post> OpenClaw plugin entrypoint: same, answered in Stroq's own JSON
13148
- hook antigravity <pre|post|preinvocation>
13149
- Antigravity entrypoint: same, plus PreInvocation, where a tainted
13150
- session's status is stated to the model before it is called
13151
- run [--sandbox] -- <agent> \u2026 start an agent already confined: exports the git settings that
13152
- stop a repository running a command during the startup
13153
- "git status", refuses to launch into a repository that runs
13154
- something before you could approve it, and checks that Stroq's
13155
- hooks are installed for that agent. --sandbox additionally wraps
13156
- the launch in Anthropic's srt, when srt is installed, with a
13157
- read-deny list built from this machine's real credential files
13158
- mcp --server <n> -- <cmd> \u2026 stdio MCP proxy: judges every tools/call, scans every result
13159
- doctor [--all] check the installation (--all lists every agent and scope)
13160
- log [--count 20] [--json] show recent audit entries (--json: one JSON line each)
13161
- verify verify the audit hash chain
13162
- untaint [--session <id>] [--all] clear a false-positive session's taint, or every session's
13163
- why [--seq <n>] explain the most recent denied/asked action: rule, provenance, taint
13164
- replay [<session>] [--last] [--transcript <path>] [--json] [--list]
13165
- rebuild the recorded sequence: which content the agent read,
13166
- and which later actions matched it. --last reads the agent's own
13167
- transcript, so it works on sessions that ran before you installed
13168
- sent [<session>] [--last] [--transcript <path>] [--json] [--fail-on-finding]
13169
- which credentials appear in a recorded agent session, and in
13170
- which tool result or call. This local evidence does not confirm
13171
- a model request or provider delivery. --last reads
13172
- the agent's own transcript, so it covers sessions from before
13173
- you installed and can see what tools RETURNED, not just what
13174
- they called. To match values it reads this machine's credential
13175
- files (~/.aws/credentials, ~/.npmrc, ~/.netrc,
13176
- ~/.docker/config.json, ./.env*); it reports names and sources
13177
- only, never a value. Exits 0 even when it finds something \u2014
13178
- the past cannot be fixed by this build; use --fail-on-finding
13179
- to gate on it anyway
13180
- canary [--name <NAME>] [--file <path>]
13181
- print a canary secret to plant, or plant it as a decoy file; its
13182
- outbound use, or any call naming the file, is denied and taints
13183
- attack [--json] [--only <id>] [--fuzz]
13184
- replay recorded incidents against your policy; exit 1 if any gets
13185
- through. --fuzz crosses every scenario with every mutation and
13186
- prints the ones that escape
13187
- exposure [--probe] [--share] [--json] [--verbose]
13188
- map this machine's agent surface and report what reaches you;
13189
- --probe starts your MCP servers to read their tool descriptions
13190
- inspect [<dir>] [--json|--sarif] [--env]
13191
- read what a repository runs before you open it with an agent;
13192
- --sarif for code scanning, --env prints the git settings that
13193
- neutralise it
13194
- trust [<file>] [--list] [--remove <file>] [--json]
13195
- waive a false positive on a file's exact content; without
13196
- arguments, list what is trusted
13197
- bench [--corpus <dir>] [--json] [--verbose]
13198
- measure how much benign developer text the rule set flags
13199
- coverage [--format table|navigator] [--json]
13200
- control mapping against MITRE ATLAS and OWASP ASI
13201
- --version print the CLI version
13351
+ // src/help.ts
13352
+ var COMMANDS = [
13353
+ {
13354
+ name: "init",
13355
+ synopsis: "init [--agent <name>] [--user] [--dry-run]",
13356
+ about: [
13357
+ "install hooks for an agent, in this project by default,",
13358
+ "or wrap an MCP client's stdio servers in Stroq's proxy (--agent mcp)"
13359
+ ],
13360
+ flags: [
13361
+ [
13362
+ "--agent <name>",
13363
+ "claude-code (default), cursor, codex, copilot, openclaw, windsurf, antigravity or mcp"
13364
+ ],
13365
+ ["--user", "install into the user's config instead of this project's"],
13366
+ ["--dry-run", "print the change and write nothing"],
13367
+ ["--client <name>", "with --agent mcp: claude-desktop, windsurf, cursor or claude-code"],
13368
+ ["--config <path>", 'with --agent mcp: any file with an "mcpServers" object'],
13369
+ ["--unwrap", "with --agent mcp: put every wrapped server back the way it was"],
13370
+ [
13371
+ "--cloak",
13372
+ "with --agent mcp: replace detected values in results before the model reads them"
13373
+ ]
13374
+ ]
13375
+ },
13376
+ {
13377
+ name: "uninstall",
13378
+ synopsis: "uninstall [--agent <name>] [--user] [--dry-run]",
13379
+ about: [
13380
+ "take Stroq's hooks out of an agent's config, leaving everything else in it as it",
13381
+ "was; with --agent mcp, put every wrapped MCP server back"
13382
+ ],
13383
+ flags: [
13384
+ ["--agent <name>", "the agent to remove Stroq from, as for init (default claude-code)"],
13385
+ ["--user", "remove from the user's config instead of this project's"],
13386
+ ["--dry-run", "print the result and change nothing"],
13387
+ ["--client <name>", "with --agent mcp: the client whose servers to unwrap"],
13388
+ ["--config <path>", "with --agent mcp: the config file to unwrap"]
13389
+ ]
13390
+ },
13391
+ {
13392
+ name: "hook",
13393
+ synopsis: "hook <agent> [<phase>]",
13394
+ about: [
13395
+ "the entrypoint an agent runs on every tool call: reads the event JSON on stdin and",
13396
+ "prints a decision. Agents: claude-code, cursor, codex, windsurf, copilot <pre|post>,",
13397
+ "openclaw <pre|post>, antigravity <pre|post|preinvocation>. `stroq init` writes it"
13398
+ ],
13399
+ flags: []
13400
+ },
13401
+ {
13402
+ name: "run",
13403
+ synopsis: "run [--sandbox] [--agent <id>] -- <agent> \u2026",
13404
+ about: [
13405
+ "start an agent already confined: sets the git settings that stop a repository",
13406
+ 'running a command during the startup "git status", refuses to launch into a',
13407
+ "repository that runs something before you could approve it, and checks that",
13408
+ "Stroq's hooks are installed for that agent"
13409
+ ],
13410
+ flags: [
13411
+ ["--agent <id>", "the agent whose hooks to check, when the command does not say"],
13412
+ [
13413
+ "--sandbox",
13414
+ "also wrap the launch in Anthropic's srt, with this machine's credential files unreadable"
13415
+ ],
13416
+ ["--allow-domain <host>", "with --sandbox: a host the agent may reach; repeat for more"],
13417
+ ["--no-inspect", "skip reading what the repository runs"],
13418
+ ["--force", "launch even after printing a refusal"],
13419
+ ["--dry-run", "print what would run and start nothing"]
13420
+ ],
13421
+ passThrough: true
13422
+ },
13423
+ {
13424
+ name: "mcp",
13425
+ synopsis: "mcp --server <name> [--cloak] -- <command> \u2026",
13426
+ about: [
13427
+ "the stdio MCP proxy `init --agent mcp` writes into a client config: judges every",
13428
+ "tools/call against your policy and scans every result on its way back"
13429
+ ],
13430
+ flags: [
13431
+ ["--server <name>", "the server name from the client config"],
13432
+ ["--client <name>", "the client, for the session id and the audit"],
13433
+ ["--cwd <dir>", "the project directory decisions are made for"],
13434
+ ["--session <id>", "the session id; default mcp:<client>"],
13435
+ ["--pass-env <names>", "comma-separated variables the server is started with"],
13436
+ ["--cloak", "replace detected values in results before the model reads them"]
13437
+ ],
13438
+ passThrough: true
13439
+ },
13440
+ {
13441
+ name: "doctor",
13442
+ synopsis: "doctor [--all]",
13443
+ about: ["check the installation: Node, rules, the hooks of every agent, a self-test"],
13444
+ flags: [["--all", "list every agent and scope, installed or not"]]
13445
+ },
13446
+ {
13447
+ name: "log",
13448
+ synopsis: "log [--count 20] [--json]",
13449
+ about: ["show recent audit entries"],
13450
+ flags: [
13451
+ ["--count <n>", "how many entries, newest last (default 20)"],
13452
+ ["--json", "one JSON object per line"]
13453
+ ]
13454
+ },
13455
+ {
13456
+ name: "verify",
13457
+ synopsis: "verify",
13458
+ about: ["verify the hash chain of the audit log"],
13459
+ flags: []
13460
+ },
13461
+ {
13462
+ name: "untaint",
13463
+ synopsis: "untaint [--session <id>] [--all]",
13464
+ about: [
13465
+ "clear a false-positive session's taint and provenance, or every session's.",
13466
+ "`stroq why` and `stroq log` show the session id"
13467
+ ],
13468
+ flags: [
13469
+ ["--session <id>", "the session to clear"],
13470
+ ["--all", "clear every session"]
13471
+ ]
13472
+ },
13473
+ {
13474
+ name: "why",
13475
+ synopsis: "why [--seq <n>]",
13476
+ about: ["explain the most recent denied or asked action: rule, provenance, taint"],
13477
+ flags: [["--seq <n>", "explain the audit entry with this sequence number instead"]]
13478
+ },
13479
+ {
13480
+ name: "replay",
13481
+ synopsis: "replay [<session>] [--last] [--transcript <path>] [--json] [--list]",
13482
+ about: [
13483
+ "rebuild the recorded sequence: which content the agent read, and which later",
13484
+ "actions matched it. --last reads the agent's own transcript, so it works on",
13485
+ "sessions that ran before you installed"
13486
+ ],
13487
+ flags: [
13488
+ ["--last", "the newest session recorded for this directory"],
13489
+ ["--transcript <path>", "a specific transcript"],
13490
+ ["--json", "machine-readable output"],
13491
+ ["--list", "list the sessions in the audit log"]
13492
+ ]
13493
+ },
13494
+ {
13495
+ name: "sent",
13496
+ synopsis: "sent [<session>] [--last] [--transcript <path>] [--json] [--fail-on-finding]",
13497
+ about: [
13498
+ "which credentials appear in a recorded agent session, and in which tool result",
13499
+ "or call. Reads names and sources only, never a value"
13500
+ ],
13501
+ flags: [
13502
+ ["--last", "the newest session recorded for this directory"],
13503
+ ["--transcript <path>", "a specific transcript or rollout"],
13504
+ ["--json", "machine-readable output"],
13505
+ ["--fail-on-finding", "exit 1 when a credential is found"]
13506
+ ]
13507
+ },
13508
+ {
13509
+ name: "canary",
13510
+ synopsis: "canary [--name <NAME>] [--file <path>]",
13511
+ about: [
13512
+ "print a canary secret to plant, or plant it as a decoy file. Its outbound use, or",
13513
+ "any call naming the file, is denied and taints the session"
13514
+ ],
13515
+ flags: [
13516
+ ["--name <NAME>", "the variable name to print it under (default STROQ_CANARY_KEY)"],
13517
+ ["--file <path>", "create a credentials-shaped decoy file holding it"]
13518
+ ]
13519
+ },
13520
+ {
13521
+ name: "attack",
13522
+ synopsis: "attack [--json] [--only <id>] [--fuzz]",
13523
+ about: ["replay recorded attacks against your policy; exit 1 if any gets through"],
13524
+ flags: [
13525
+ ["--json", "machine-readable output"],
13526
+ ["--only <id>", "one scenario, e.g. 05"],
13527
+ ["--fuzz", "cross every scenario with every mutation and print the ones that escape"]
13528
+ ]
13529
+ },
13530
+ {
13531
+ name: "exposure",
13532
+ synopsis: "exposure [--probe] [--share] [--json] [--verbose]",
13533
+ about: ["map this machine's agent surface and report what reaches you; exit 1 on a finding"],
13534
+ flags: [
13535
+ ["--probe", "start your MCP servers once to read their tool descriptions"],
13536
+ ["--share", "print a redacted summary you can paste"],
13537
+ ["--json", "machine-readable output"],
13538
+ ["--verbose", "list every file behind a count"]
13539
+ ]
13540
+ },
13541
+ {
13542
+ name: "inspect",
13543
+ synopsis: "inspect [<dir>] [--json|--sarif] [--env]",
13544
+ about: ["read what a repository runs before you open it with an agent"],
13545
+ flags: [
13546
+ ["--json", "machine-readable output"],
13547
+ ["--sarif", "SARIF 2.1.0, for code scanning"],
13548
+ ["--env", "print the git settings that neutralise it"]
13549
+ ]
13550
+ },
13551
+ {
13552
+ name: "trust",
13553
+ synopsis: "trust [<file>] [--list] [--remove <file>] [--json]",
13554
+ about: [
13555
+ "waive a false positive on a file's exact content; any change to the file taints",
13556
+ "again. Without arguments, list what is trusted"
13557
+ ],
13558
+ flags: [
13559
+ ["--list", "list trusted files"],
13560
+ ["--remove <file>", "stop trusting a file"],
13561
+ ["--json", "machine-readable output"]
13562
+ ]
13563
+ },
13564
+ {
13565
+ name: "bench",
13566
+ synopsis: "bench [--corpus <dir>] [--json] [--verbose]",
13567
+ about: ["measure how much benign developer text the rule set flags"],
13568
+ flags: [
13569
+ ["--corpus <dir>", "measure your own files instead of the shipped corpus"],
13570
+ ["--json", "machine-readable output"],
13571
+ ["--verbose", "list the flagged files"]
13572
+ ]
13573
+ },
13574
+ {
13575
+ name: "coverage",
13576
+ synopsis: "coverage [--format table|navigator] [--json]",
13577
+ about: ["the control mapping against MITRE ATLAS and OWASP ASI"],
13578
+ flags: [
13579
+ ["--format <table|navigator>", "navigator emits an ATT&CK Navigator layer"],
13580
+ ["--json", "machine-readable output"]
13581
+ ]
13582
+ }
13583
+ ];
13584
+ var byName = new Map(COMMANDS.map((c) => [c.name, c]));
13585
+ var INDENT = 37;
13586
+ function usage() {
13587
+ const blocks = COMMANDS.map((c) => {
13588
+ const head = ` ${c.synopsis}`;
13589
+ const [first = "", ...more] = c.about;
13590
+ const lines = head.length < INDENT - 1 ? [`${head.padEnd(INDENT)}${first}`, ...more.map((l) => `${" ".repeat(INDENT)}${l}`)] : [head, ...c.about.map((l) => `${" ".repeat(INDENT)}${l}`)];
13591
+ return lines.join("\n");
13592
+ });
13593
+ return [
13594
+ "stroq <command>",
13595
+ "",
13596
+ "Commands:",
13597
+ ...blocks,
13598
+ `${" help [<command>]".padEnd(INDENT)}this list, or one command's options`,
13599
+ `${" --version".padEnd(INDENT)}print the CLI version`,
13600
+ "",
13601
+ 'Run "stroq <command> --help" for its options.',
13602
+ ""
13603
+ ].join("\n");
13604
+ }
13605
+ function commandHelp(name) {
13606
+ const c = byName.get(name);
13607
+ if (c === void 0) return null;
13608
+ const width = Math.max(0, ...c.flags.map(([spelling]) => spelling.length)) + 4;
13609
+ return [
13610
+ `stroq ${c.synopsis}`,
13611
+ "",
13612
+ ...c.about.map((line3) => ` ${line3}`),
13613
+ ...c.flags.length === 0 ? [] : [
13614
+ "",
13615
+ "Options:",
13616
+ ...c.flags.map(([spelling, meaning]) => ` ${spelling.padEnd(width)}${meaning}`)
13617
+ ],
13618
+ ""
13619
+ ].join("\n");
13620
+ }
13621
+ function ownArgs(name, args) {
13622
+ if (byName.get(name)?.passThrough !== true) return args;
13623
+ const end = args.indexOf("--");
13624
+ return end === -1 ? args : args.slice(0, end);
13625
+ }
13626
+ function wantsHelp(name, args) {
13627
+ return ownArgs(name, args).some((arg) => arg === "--help" || arg === "-h");
13628
+ }
13629
+ function unknownOption(name, args) {
13630
+ const c = byName.get(name);
13631
+ if (c === void 0) return null;
13632
+ const known = /* @__PURE__ */ new Set([
13633
+ "--help",
13634
+ "-h",
13635
+ ...c.flags.map(([spelling]) => spelling.split(/[\s|<]/)[0])
13636
+ ]);
13637
+ for (const arg of ownArgs(name, args)) {
13638
+ if (!arg.startsWith("-") || arg === "-" || arg === "--") continue;
13639
+ const option = arg.split("=")[0] ?? arg;
13640
+ if (!known.has(option)) return option;
13641
+ }
13642
+ return null;
13643
+ }
13644
+ function usageError(name, problem) {
13645
+ return `stroq ${name}: ${problem}
13646
+ Run "stroq ${name} --help" to see its options.
13202
13647
  `;
13648
+ }
13649
+ function parseArgsProblem(err) {
13650
+ const code = err?.code;
13651
+ if (typeof code !== "string" || !code.startsWith("ERR_PARSE_ARGS_")) return null;
13652
+ const message = err instanceof Error ? err.message : String(err);
13653
+ const first = message.split(/(?<=\.)\s/)[0] ?? message;
13654
+ return first.replace(/\.$/, "");
13655
+ }
13656
+ function suggestCommand(typed) {
13657
+ let best = null;
13658
+ let bestDistance = 3;
13659
+ for (const { name } of COMMANDS) {
13660
+ const d = distance(typed.toLowerCase(), name);
13661
+ if (d < bestDistance) {
13662
+ best = name;
13663
+ bestDistance = d;
13664
+ }
13665
+ }
13666
+ return best;
13667
+ }
13668
+ function distance(a, b) {
13669
+ const rows = Array.from(
13670
+ { length: a.length + 1 },
13671
+ (_, i) => Array.from({ length: b.length + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0)
13672
+ );
13673
+ for (let i = 1; i <= a.length; i += 1) {
13674
+ for (let j = 1; j <= b.length; j += 1) {
13675
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
13676
+ const row2 = rows[i];
13677
+ const above = rows[i - 1];
13678
+ row2[j] = Math.min(above[j] + 1, row2[j - 1] + 1, above[j - 1] + cost);
13679
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
13680
+ row2[j] = Math.min(row2[j], rows[i - 2][j - 2] + 1);
13681
+ }
13682
+ }
13683
+ return rows[a.length][b.length];
13684
+ }
13685
+
13686
+ // src/index.ts
13687
+ var COMMANDS2 = {
13688
+ inspect: runInspect,
13689
+ init: runInit,
13690
+ uninstall: runUninstall,
13691
+ run: runRun,
13692
+ doctor: runDoctor,
13693
+ log: runLog,
13694
+ verify: () => runVerify(),
13695
+ trust: runTrust,
13696
+ untaint: runUntaint,
13697
+ replay: runReplay,
13698
+ why: runWhy,
13699
+ sent: runSent,
13700
+ canary: runCanary,
13701
+ attack: runAttackCommand,
13702
+ exposure: runExposure,
13703
+ bench: runBenchCommand,
13704
+ coverage: runCoverageCommand
13705
+ };
13203
13706
  async function main(argv) {
13204
13707
  const [command, ...rest] = argv;
13205
13708
  switch (command) {
13206
13709
  case "hook": {
13710
+ if (rest[0] === void 0 || wantsHelp("hook", rest)) {
13711
+ process.stdout.write(commandHelp("hook") ?? "");
13712
+ return rest[0] === void 0 ? 2 : 0;
13713
+ }
13207
13714
  const out = await runHookCommand(rest[0] ?? "", rest[1] ?? "");
13208
13715
  if (out.stdout) process.stdout.write(out.stdout);
13209
13716
  if (out.stderr) process.stderr.write(out.stderr);
@@ -13211,66 +13718,74 @@ async function main(argv) {
13211
13718
  return out.exitCode;
13212
13719
  }
13213
13720
  case "mcp":
13721
+ if (wantsHelp("mcp", rest)) {
13722
+ process.stdout.write(commandHelp("mcp") ?? "");
13723
+ return 0;
13724
+ }
13214
13725
  return runMcp(rest);
13215
13726
  default:
13216
13727
  return withSafeOutput(() => report(command, rest));
13217
13728
  }
13218
13729
  }
13219
13730
  async function report(command, rest) {
13220
- switch (command) {
13221
- case "inspect":
13222
- return runInspect(rest);
13223
- case "init":
13224
- return runInit(rest);
13225
- case "run":
13226
- return runRun(rest);
13227
- case "doctor":
13228
- return runDoctor(rest);
13229
- case "log":
13230
- return runLog(rest);
13231
- case "verify":
13232
- return runVerify();
13233
- case "trust":
13234
- return runTrust(rest);
13235
- case "untaint":
13236
- return runUntaint(rest);
13237
- case "replay":
13238
- return runReplay(rest);
13239
- case "why":
13240
- return runWhy(rest);
13241
- case "sent":
13242
- return runSent(rest);
13243
- case "canary":
13244
- return runCanary(rest);
13245
- case "attack":
13246
- return runAttackCommand(rest);
13247
- case "exposure":
13248
- return runExposure(rest);
13249
- case "bench":
13250
- return runBenchCommand(rest);
13251
- case "coverage":
13252
- return runCoverageCommand(rest);
13253
- case "--version":
13254
- case "-v":
13255
- case "version":
13256
- process.stdout.write(`${stroqVersion()}
13731
+ if (command === void 0 || command === "--help" || command === "-h") {
13732
+ process.stdout.write(usage());
13733
+ return 0;
13734
+ }
13735
+ if (command === "--version" || command === "-v" || command === "version") {
13736
+ process.stdout.write(`${stroqVersion()}
13257
13737
  `);
13258
- return 0;
13259
- default:
13260
- process.stdout.write(USAGE3);
13261
- return command === void 0 || command === "--help" || command === "-h" ? 0 : 1;
13738
+ return 0;
13739
+ }
13740
+ if (command === "help") {
13741
+ const help = rest[0] === void 0 ? usage() : commandHelp(rest[0]);
13742
+ if (help === null) return unknownCommand(rest[0] ?? "");
13743
+ process.stdout.write(help);
13744
+ return 0;
13745
+ }
13746
+ const runner = COMMANDS2[command];
13747
+ if (runner === void 0) return unknownCommand(command);
13748
+ if (command !== "sent" && wantsHelp(command, rest)) {
13749
+ process.stdout.write(commandHelp(command) ?? "");
13750
+ return 0;
13751
+ }
13752
+ const unknown = unknownOption(command, rest);
13753
+ if (unknown !== null) {
13754
+ process.stderr.write(usageError(command, `unknown option ${unknown}`));
13755
+ return 2;
13756
+ }
13757
+ try {
13758
+ return await runner(rest);
13759
+ } catch (err) {
13760
+ const problem = parseArgsProblem(err);
13761
+ if (problem === null) throw err;
13762
+ process.stderr.write(usageError(command, problem));
13763
+ return 2;
13262
13764
  }
13263
13765
  }
13766
+ function unknownCommand(typed) {
13767
+ const suggestion = suggestCommand(typed);
13768
+ process.stderr.write(
13769
+ `stroq: unknown command "${typed}".${suggestion === null ? "" : ` Did you mean "${suggestion}"?`}
13770
+ Run "stroq --help" for the list of commands.
13771
+ `
13772
+ );
13773
+ return 1;
13774
+ }
13264
13775
  async function exitNow(code) {
13265
13776
  await Promise.all(
13266
13777
  [process.stdout, process.stderr].map(
13267
- (stream) => new Promise((resolve14) => {
13268
- stream.write("", () => resolve14());
13778
+ (stream) => new Promise((resolve15) => {
13779
+ stream.write("", () => resolve15());
13269
13780
  })
13270
13781
  )
13271
13782
  );
13272
13783
  process.exit(code);
13273
13784
  }
13785
+ process.stdout.on("error", (err) => {
13786
+ if (err.code === "EPIPE") process.exit(process.exitCode ?? 0);
13787
+ throw err;
13788
+ });
13274
13789
  main(process.argv.slice(2)).then(
13275
13790
  (code) => {
13276
13791
  process.exitCode = code;
@@ -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.19.2",
5
+ "version": "0.20.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.19.2",
3
+ "version": "0.20.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.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "Find known secrets in local Claude Code, Codex and Cursor sessions; trace risky AI agent actions and guard future tool calls",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",