@basein/runner 0.2.11 → 0.3.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.
Files changed (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -0,0 +1,534 @@
1
+ /**
2
+ * pin-guard — whether a live call is plausibly the step a steering plan
3
+ * expects, before the control server rewrites it to the step's input.
4
+ *
5
+ * WHY THIS EXISTS. A steer plan pins "the next call to the expected tool", and
6
+ * the pin replaces the model's arguments with the step's and answers `allow`.
7
+ * Matching on the tool name alone meant that a model checking its work with
8
+ * `Bash ls` while a `Bash git push --force` step was due had Claude Code show
9
+ * `ls` and run the push, unasked. A tool name says which *kind* of call this
10
+ * is; it says nothing about whether the call is the step.
11
+ *
12
+ * The rule is deliberately conservative, because the two mistakes are not the
13
+ * same size. Not pinning a call that was the step costs a replay: the call
14
+ * runs as the model wrote it, and the plan stays armed for the call that is
15
+ * the step (the control server retires it, running nothing, if the model moves
16
+ * on to another tool instead). Pinning one that was not the step runs
17
+ * something the user never saw.
18
+ *
19
+ * - `Bash` / `PowerShell`: the same command, or the same *commands*: as
20
+ * many of them, chained the same way (`&&`, `||`, `;`, `|`, `&`, a new
21
+ * line), each with the same head (the program, plus its subcommand when
22
+ * it has one: `git push`, `npm test`; plus what it runs when it only runs
23
+ * something else: `npm run lint`, `python deploy.py`). Comparing only the
24
+ * first command's head pinned a model's `git status` to a step's `git
25
+ * status && git push --force`. A step that runs a command substitution
26
+ * (`$(…)`, backticks) is pinned only to an exact copy.
27
+ * - Tools that write a file: the same file.
28
+ * - MCP tools whose input is run — a query, a statement, a shell command
29
+ * (replay/safety.ts `sinkOf`): every statement the step runs, supplied by
30
+ * the model with the same verb (`SELECT` for `SELECT`, never for
31
+ * `DELETE`), or no arguments at all. One shared key such as `sql` said
32
+ * nothing about what the call does. An empty statement argument of the
33
+ * step (`input: ""` beside the query) runs nothing and need not be
34
+ * supplied — but a call that runs something there is not the step.
35
+ * - Everything else: at least one argument in common, and no argument that
36
+ * names *where* the call acts (a path, a URL) pointing somewhere else. A
37
+ * call with no arguments at all is the model deferring to the directive
38
+ * ("the system supplies the exact arguments") and is pinned.
39
+ *
40
+ * Read-only file tools are not held to the same file: pinning a `Read` of the
41
+ * wrong path to the step's path is exactly what the directive asks for, and it
42
+ * changes nothing on disk.
43
+ */
44
+ import { stableStringify } from "./correlation.js";
45
+ import { sinkOf } from "../replay/safety.js";
46
+ /** Tools whose target file is the call's whole effect: another file is another call. */
47
+ const FILE_WRITERS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
48
+ /** Argument keys that name where a call acts. Two different values are two different calls. */
49
+ const TARGET_KEYS = ["file_path", "notebook_path", "path", "url", "uri"];
50
+ /** Read-only built-ins whose target may be steered freely. */
51
+ const READERS = new Set(["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch", "NotebookRead"]);
52
+ function record(value) {
53
+ return value && typeof value === "object" && !Array.isArray(value)
54
+ ? value
55
+ : {};
56
+ }
57
+ /** Paths compare without caring about separators, trailing slashes or (on Windows) case. */
58
+ function normalTarget(value) {
59
+ let out = value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
60
+ if (process.platform === "win32")
61
+ out = out.toLowerCase();
62
+ return out;
63
+ }
64
+ /** Split a shell command into words, honouring simple quotes. Good enough to find its head. */
65
+ function words(command) {
66
+ const out = [];
67
+ let word = "";
68
+ let quote;
69
+ let inWord = false;
70
+ for (const ch of command) {
71
+ if (quote) {
72
+ if (ch === quote)
73
+ quote = undefined;
74
+ else
75
+ word += ch;
76
+ continue;
77
+ }
78
+ if (ch === "'" || ch === '"') {
79
+ quote = ch;
80
+ inWord = true;
81
+ continue;
82
+ }
83
+ if (/\s/.test(ch)) {
84
+ if (inWord)
85
+ out.push(word);
86
+ word = "";
87
+ inWord = false;
88
+ continue;
89
+ }
90
+ word += ch;
91
+ inWord = true;
92
+ }
93
+ if (inWord)
94
+ out.push(word);
95
+ return out;
96
+ }
97
+ const SEPARATORS = new Set(["&&", "||", ";", "|", "&"]);
98
+ /**
99
+ * Programs whose own name says nothing about what runs: the script, module or
100
+ * inline command they are handed does. `python a.py` and `python deploy.py`
101
+ * are two different calls, and so are `bash -c "ls"` and `bash -c "rm -rf x"`.
102
+ */
103
+ const INTERPRETERS = new Set([
104
+ "python", "python3", "py", "pypy", "pypy3", "node", "deno", "bun", "tsx", "ts-node",
105
+ "ruby", "perl", "php", "bash", "sh", "zsh", "dash", "ksh", "fish", "pwsh", "powershell", "cmd",
106
+ ]);
107
+ /** The flags that hand an interpreter its code inline. What follows is what runs. */
108
+ const INLINE_FLAGS = new Set(["-c", "-e", "-command", "/c", "/k"]);
109
+ /** Package runners: `npx eslint` runs eslint. */
110
+ const PACKAGE_RUNNERS = new Set(["npx", "pnpx", "bunx", "uvx"]);
111
+ /** `<program> <subcommand> <what>`: the subcommand runs its first operand. */
112
+ const RUN_SUBCOMMANDS = {
113
+ npm: new Set(["run", "run-script", "exec", "x"]),
114
+ pnpm: new Set(["run", "exec", "dlx"]),
115
+ yarn: new Set(["run", "exec", "dlx"]),
116
+ bun: new Set(["run", "x"]),
117
+ deno: new Set(["run", "task"]),
118
+ uv: new Set(["run"]),
119
+ poetry: new Set(["run"]),
120
+ pipenv: new Set(["run"]),
121
+ pipx: new Set(["run"]),
122
+ cargo: new Set(["run"]),
123
+ go: new Set(["run"]),
124
+ };
125
+ /** How deep `uv run python …` or `bash -c "sh -c '…'"` is followed. */
126
+ const MAX_HEAD_DEPTH = 3;
127
+ /** A word that reads like a subcommand: not a path, a flag or a file name. */
128
+ const SUBCOMMAND = /^[A-Za-z][A-Za-z0-9_:-]*$/;
129
+ /** A program's bare name: no directory, no Windows extension, lower case. */
130
+ function programName(word) {
131
+ return word
132
+ .replace(/\\/g, "/")
133
+ .split("/")
134
+ .pop()
135
+ .toLowerCase()
136
+ .replace(/\.(exe|cmd|bat|ps1)$/, "");
137
+ }
138
+ /** A script operand compares by its file name: `./scripts/deploy.py` is `deploy.py`. */
139
+ function operandName(word) {
140
+ return word.replace(/\\/g, "/").split("/").pop() ?? word;
141
+ }
142
+ /** The first word of `args` that is not a flag, or -1. */
143
+ function operandIndex(args) {
144
+ return args.findIndex((a) => a !== "" && !a.startsWith("-"));
145
+ }
146
+ /**
147
+ * What a runner is told to run, named: a program that itself runs something
148
+ * else is followed (`uv run python deploy.py`), anything else is its name.
149
+ */
150
+ function operandHead(args, depth) {
151
+ const i = operandIndex(args);
152
+ if (i < 0)
153
+ return undefined;
154
+ const what = args[i];
155
+ const name = programName(what);
156
+ const runsSomething = INTERPRETERS.has(name) || PACKAGE_RUNNERS.has(name) || RUN_SUBCOMMANDS[name];
157
+ if (runsSomething && depth < MAX_HEAD_DEPTH)
158
+ return headOf(args.slice(i), depth + 1);
159
+ return operandName(what);
160
+ }
161
+ /**
162
+ * The part of a command that says what it does: the program and, when the
163
+ * next word reads like a subcommand, that too — `git push`, `npm test`,
164
+ * `docker compose`. Leading environment assignments and a leading `cd <dir> &&`
165
+ * are skipped; a path, a flag or a file name is not a subcommand.
166
+ *
167
+ * A program that only runs something else is named with what it runs —
168
+ * `npm run lint` (never just `npm run`, which would pin a lint to a deploy),
169
+ * `npx eslint`, `python deploy.py`, `python -m pytest`, `uv run pytest` —
170
+ * and a shell's inline command is that command: `bash -c "git push"` is
171
+ * `git push`.
172
+ */
173
+ export function commandHead(command, depth = 0) {
174
+ let parts = words(command.replace(/(&&|\|\||;|\|)/g, " $1 "));
175
+ // `cd somewhere && real-command` is the real command's call.
176
+ while (parts.length > 0) {
177
+ let i = 0;
178
+ while (i < parts.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(parts[i]))
179
+ i += 1;
180
+ parts = parts.slice(i);
181
+ const first = (parts[0] ?? "").toLowerCase();
182
+ if (first === "cd" || first === "pushd" || first === "set-location" || first === "sl") {
183
+ const sep = parts.findIndex((p) => SEPARATORS.has(p));
184
+ if (sep < 0)
185
+ break;
186
+ parts = parts.slice(sep + 1);
187
+ continue;
188
+ }
189
+ break;
190
+ }
191
+ return headOf(parts, depth);
192
+ }
193
+ /** {@link commandHead} of a command already split into words, program first. */
194
+ function headOf(parts, depth) {
195
+ const program = programName(parts[0] ?? "");
196
+ // This command's own words, up to the next `&&`, `|`, `;`.
197
+ const end = parts.findIndex((p, i) => i > 0 && SEPARATORS.has(p));
198
+ const args = parts.slice(1, end < 0 ? undefined : end);
199
+ const sub = args[0]?.toLowerCase();
200
+ if (sub !== undefined && RUN_SUBCOMMANDS[program]?.has(sub)) {
201
+ const what = operandHead(args.slice(1), depth);
202
+ return what === undefined ? `${program} ${sub}` : `${program} ${sub} ${what}`;
203
+ }
204
+ if (PACKAGE_RUNNERS.has(program)) {
205
+ const what = operandHead(args, depth);
206
+ return what === undefined ? program : `${program} ${what}`;
207
+ }
208
+ if (INTERPRETERS.has(program)) {
209
+ const inline = args.findIndex((a) => INLINE_FLAGS.has(a.toLowerCase()));
210
+ if (inline >= 0) {
211
+ // The inline code is what runs. A shell's is a command with a head of
212
+ // its own; anything else (`python -c`, `node -e`) is compared whole.
213
+ const inner = args.slice(inline + 1).join(" ").trim();
214
+ if (!inner)
215
+ return program;
216
+ if (depth >= MAX_HEAD_DEPTH)
217
+ return `${program} ${inner}`;
218
+ return commandHead(inner, depth + 1) || program;
219
+ }
220
+ const module = args.indexOf("-m");
221
+ if (module >= 0 && args[module + 1])
222
+ return `${program} -m ${args[module + 1]}`;
223
+ const i = operandIndex(args);
224
+ if (i < 0)
225
+ return program;
226
+ const what = args[i];
227
+ // A bare word is a subcommand (`bun install`, `deno fmt`) or a script with
228
+ // no extension; a word with a dot or a slash is a script, named by its file.
229
+ return SUBCOMMAND.test(what) ? `${program} ${what.toLowerCase()}` : `${program} ${operandName(what)}`;
230
+ }
231
+ const next = args[0];
232
+ return next !== undefined && SUBCOMMAND.test(next) ? `${program} ${next.toLowerCase()}` : program;
233
+ }
234
+ /**
235
+ * A command line split into the commands it chains, at the separators that
236
+ * are not quoted or escaped: `&&`, `||`, `;`, `|` (and `|&`), a background
237
+ * `&`, a new line. A redirection's `&` (`2>&1`, `&>file`, `>&2`) is not one.
238
+ * `escape` is the shell's own escape character: a backslash in a POSIX shell,
239
+ * a backtick in PowerShell (where `C:\;` ends a command), a caret in cmd.
240
+ * Good enough to count and name the commands; not a shell parser.
241
+ */
242
+ export function commandSegments(command, escape = "\\") {
243
+ const out = [];
244
+ let current = "";
245
+ let quote;
246
+ const push = () => {
247
+ const trimmed = current.trim();
248
+ if (trimmed)
249
+ out.push(trimmed);
250
+ current = "";
251
+ };
252
+ for (let i = 0; i < command.length; i += 1) {
253
+ const ch = command[i];
254
+ if (quote) {
255
+ current += ch;
256
+ if (ch === quote)
257
+ quote = undefined;
258
+ else if (ch === escape && quote === '"' && i + 1 < command.length)
259
+ current += command[++i];
260
+ continue;
261
+ }
262
+ if (ch === escape && i + 1 < command.length) {
263
+ current += ch + command[++i];
264
+ continue;
265
+ }
266
+ if (ch === "'" || ch === '"') {
267
+ quote = ch;
268
+ current += ch;
269
+ continue;
270
+ }
271
+ if (ch === "\n" || ch === "\r" || ch === ";") {
272
+ push();
273
+ continue;
274
+ }
275
+ if (ch === "|") {
276
+ push();
277
+ if (command[i + 1] === "|" || command[i + 1] === "&")
278
+ i += 1;
279
+ continue;
280
+ }
281
+ if (ch === "&") {
282
+ if (command[i + 1] === "&") {
283
+ push();
284
+ i += 1;
285
+ continue;
286
+ }
287
+ const prev = command[i - 1];
288
+ if (prev === ">" || prev === "<" || command[i + 1] === ">") {
289
+ current += ch;
290
+ continue;
291
+ }
292
+ push();
293
+ continue;
294
+ }
295
+ current += ch;
296
+ }
297
+ push();
298
+ return out;
299
+ }
300
+ /** Shells whose inline command (`-c`, `/c`, `-Command`) is itself a command line. */
301
+ const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh", "fish", "pwsh", "powershell", "cmd"]);
302
+ /** The escape character of the shell `program` names (see {@link commandSegments}). */
303
+ function escapeOf(program) {
304
+ if (program === "pwsh" || program === "powershell")
305
+ return "`";
306
+ if (program === "cmd")
307
+ return "^";
308
+ return "\\";
309
+ }
310
+ /** The words of one command, leading `NAME=value` assignments dropped. */
311
+ function commandWords(segment) {
312
+ const parts = words(segment);
313
+ let i = 0;
314
+ while (i < parts.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(parts[i]))
315
+ i += 1;
316
+ return parts.slice(i);
317
+ }
318
+ /**
319
+ * What a command line does, command by command: the {@link commandHead} of
320
+ * each chained command, a shell's inline command expanded into its own, and a
321
+ * leading `cd <dir>` (which only says where) left out. Two command lines run
322
+ * the same things when these are equal.
323
+ */
324
+ export function commandShape(command, depth = 0, escape = "\\") {
325
+ const segments = commandSegments(command, escape);
326
+ while (segments.length > 1) {
327
+ const first = (commandWords(segments[0])[0] ?? "").toLowerCase();
328
+ if (first !== "cd" && first !== "pushd" && first !== "set-location" && first !== "sl")
329
+ break;
330
+ segments.shift();
331
+ }
332
+ const out = [];
333
+ for (const segment of segments) {
334
+ const parts = commandWords(segment);
335
+ const program = programName(parts[0] ?? "");
336
+ const inline = SHELLS.has(program) ? parts.findIndex((a, i) => i > 0 && INLINE_FLAGS.has(a.toLowerCase())) : -1;
337
+ const inner = inline >= 0 ? parts.slice(inline + 1).join(" ").trim() : "";
338
+ if (inner && depth < MAX_HEAD_DEPTH) {
339
+ out.push(...commandShape(inner, depth + 1, escapeOf(program)));
340
+ continue;
341
+ }
342
+ out.push(commandHead(segment, depth));
343
+ }
344
+ return out;
345
+ }
346
+ /** Runs a command inside the command: `$(…)`, backticks, `<(…)`, `>(…)`. */
347
+ const SUBSTITUTION = /\$\(|`|<\(|>\(/;
348
+ /** PowerShell's: a subexpression, `$(…)` or `@(…)`, or a script block. */
349
+ const PS_SUBSTITUTION = /\$\(|@\(|\{/;
350
+ /** A shell command's verdict, for `Bash` or `PowerShell`. See the file comment. */
351
+ function commandVerdict(mine, theirs, powershell = false) {
352
+ // No command at all: the model left it to the directive.
353
+ if (!mine)
354
+ return { ok: true };
355
+ if (mine.replace(/\s+/g, " ") === theirs.replace(/\s+/g, " "))
356
+ return { ok: true };
357
+ // What a substitution runs is not in any head: only an exact copy is the step.
358
+ if ((powershell ? PS_SUBSTITUTION : SUBSTITUTION).test(theirs)) {
359
+ return { ok: false, why: "the step runs a command substitution, and the call is not that command" };
360
+ }
361
+ const escape = powershell ? "`" : "\\";
362
+ const a = commandShape(mine, 0, escape);
363
+ const b = commandShape(theirs, 0, escape);
364
+ if (a.length === 0 || b.length === 0 || a.some((h) => !h) || b.some((h) => !h)) {
365
+ return { ok: false, why: `the call runs \`${a.join(" ; ") || "?"}\`, the step runs \`${b.join(" ; ") || "?"}\`` };
366
+ }
367
+ if (a.length !== b.length) {
368
+ return {
369
+ ok: false,
370
+ why: `the call runs ${a.length} command${a.length === 1 ? "" : "s"} (\`${a.join(" ; ")}\`), the step ${b.length} (\`${b.join(" ; ")}\`)`,
371
+ };
372
+ }
373
+ for (let i = 0; i < a.length; i += 1) {
374
+ if (a[i] !== b[i])
375
+ return { ok: false, why: `the call runs \`${a[i]}\`, the step runs \`${b[i]}\`` };
376
+ }
377
+ return { ok: true };
378
+ }
379
+ /** Statement verbs of SQL and its dialects: what a query does to the data. */
380
+ const SQL_VERBS = new Set([
381
+ "SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE", "WITH", "VALUES", "TABLE",
382
+ "CREATE", "ALTER", "DROP", "TRUNCATE", "RENAME", "COMMENT", "GRANT", "REVOKE", "SET", "RESET",
383
+ "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "ANALYZE", "VACUUM", "COPY", "CALL", "EXEC", "EXECUTE",
384
+ "DO", "BEGIN", "START", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE", "LOCK", "UNLOCK", "USE",
385
+ "PRAGMA", "ATTACH", "DETACH", "REINDEX", "CLUSTER", "REFRESH", "LOAD", "UNLOAD", "PUT", "GET",
386
+ "REMOVE", "LIST", "OPTIMIZE", "KILL", "HANDLER", "LISTEN", "NOTIFY", "PREPARE", "DEALLOCATE",
387
+ "DECLARE", "FETCH", "MOVE", "CLOSE", "CHECKPOINT", "DISCARD", "SECURITY", "IMPORT", "EXPORT",
388
+ ]);
389
+ /** The verbs that make a string a statement, whatever its argument is called. */
390
+ const SQL_DATA_VERBS = new Set([
391
+ "SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE", "WITH", "CREATE", "ALTER",
392
+ "DROP", "TRUNCATE", "GRANT", "REVOKE", "EXEC", "EXECUTE", "CALL", "COPY",
393
+ ]);
394
+ /** Writes a `WITH` statement may end in: `WITH … DELETE` is a delete, not a read. */
395
+ const SQL_WRITES = /\b(INSERT|UPDATE|DELETE|MERGE|UPSERT|REPLACE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/gi;
396
+ /** Words of an argument key that say its value is what a command-running tool runs. */
397
+ const STATEMENT_WORDS = new Set([
398
+ "sql", "query", "statement", "statements", "stmt", "command", "cmd", "commands", "script", "code",
399
+ "q", "expression", "eval", "input", "shell", "exec", "execute",
400
+ ]);
401
+ /** Whether an argument key names a statement: `sql`, `query`, `sqlQuery`, `shell_command`. */
402
+ function isStatementKey(key) {
403
+ return key
404
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
405
+ .toLowerCase()
406
+ .split(/[^a-z0-9]+/)
407
+ .some((word) => STATEMENT_WORDS.has(word));
408
+ }
409
+ /** SQL text with its comments blanked out. */
410
+ function withoutSqlComments(text) {
411
+ return text.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ");
412
+ }
413
+ /**
414
+ * What a string argument of a command-running tool does: the verb of each SQL
415
+ * statement it holds (`SELECT ; DELETE`), or — not SQL — the commands it runs
416
+ * ({@link commandShape}). Undefined for something that is neither.
417
+ */
418
+ function statementShape(value) {
419
+ const text = withoutSqlComments(value).trim();
420
+ if (!text)
421
+ return undefined;
422
+ const firstWord = /^\(*\s*([A-Za-z]+)/.exec(text)?.[1]?.toUpperCase();
423
+ if (firstWord && SQL_VERBS.has(firstWord)) {
424
+ // Statements end at `;`; `||` and `&` are operators in SQL, not chains.
425
+ const verbs = commandSegments(text.replace(/[\n\r|&]/g, " "))
426
+ .map((statement) => {
427
+ const body = statement.trim();
428
+ const verb = /^\(*\s*([A-Za-z]+)/.exec(body)?.[1]?.toUpperCase() ?? "?";
429
+ if (verb !== "WITH")
430
+ return verb;
431
+ const writes = [...new Set((body.match(SQL_WRITES) ?? []).map((w) => w.toUpperCase()))].sort();
432
+ return writes.length > 0 ? `WITH ${writes.join(" ")}` : "WITH";
433
+ })
434
+ .filter((v) => v !== "?");
435
+ return verbs.length > 0 ? verbs.join(" ; ") : undefined;
436
+ }
437
+ const shape = commandShape(value);
438
+ return shape.length > 0 && shape.every((h) => h) ? shape.join(" ; ") : undefined;
439
+ }
440
+ /**
441
+ * An MCP tool whose input is run (a query, a statement, a command): every
442
+ * statement-bearing argument of the step must be supplied by the model and do
443
+ * the same thing. See the file comment. Undefined when the step carries no
444
+ * such argument, and the ordinary rule decides.
445
+ */
446
+ function statementVerdict(model, step) {
447
+ let checked = false;
448
+ for (const [key, theirs] of Object.entries(step)) {
449
+ if (typeof theirs !== "string")
450
+ continue;
451
+ const stepShape = statementShape(theirs);
452
+ // An empty `input` or `code` beside the query runs nothing: the model need
453
+ // not supply it. Unless the model's own runs something there — then its
454
+ // call is not the step (a pin would swap that for nothing).
455
+ if (!theirs.trim() && stepShape === undefined) {
456
+ const mine = model[key];
457
+ if (typeof mine === "string" && mine.trim() && isStatementKey(key)) {
458
+ return { ok: false, why: `its ${key} runs \`${statementShape(mine) ?? "?"}\`, the step's is empty` };
459
+ }
460
+ continue;
461
+ }
462
+ // Named a statement, or reading as a statement that reads or changes data
463
+ // whatever its name.
464
+ const bearing = isStatementKey(key) ||
465
+ (stepShape !== undefined && stepShape.split(" ; ").some((v) => SQL_DATA_VERBS.has(v.split(" ")[0])));
466
+ if (!bearing)
467
+ continue;
468
+ checked = true;
469
+ const mine = model[key];
470
+ if (typeof mine !== "string")
471
+ return { ok: false, why: `it does not supply the step's ${key}` };
472
+ if (mine.replace(/\s+/g, " ").trim() === theirs.replace(/\s+/g, " ").trim())
473
+ continue;
474
+ // A shell command's substitution runs what no head shows (backticks are
475
+ // only an identifier quote in SQL, so SQL is not held to this).
476
+ const sql = stepShape !== undefined && /^[A-Z]/.test(stepShape);
477
+ if (!sql && SUBSTITUTION.test(theirs)) {
478
+ return { ok: false, why: `the step's ${key} runs a command substitution, and the call's is not that command` };
479
+ }
480
+ const myShape = statementShape(mine);
481
+ if (!stepShape || !myShape || myShape !== stepShape) {
482
+ return { ok: false, why: `its ${key} runs \`${myShape ?? "?"}\`, the step's runs \`${stepShape ?? "?"}\`` };
483
+ }
484
+ }
485
+ return checked ? { ok: true } : undefined;
486
+ }
487
+ /**
488
+ * Whether the model's call `modelArgs` to `toolName` may be pinned to the
489
+ * step's `pinnedArgs`. See the file comment for the rule.
490
+ */
491
+ export function pinCompatible(toolName, modelArgs, pinnedArgs) {
492
+ const model = record(modelArgs);
493
+ const step = record(pinnedArgs);
494
+ if (stableStringify(model) === stableStringify(step))
495
+ return { ok: true };
496
+ if (toolName === "Bash" || toolName === "PowerShell") {
497
+ const mine = typeof model.command === "string" ? model.command.trim() : "";
498
+ const theirs = typeof step.command === "string" ? step.command.trim() : "";
499
+ return commandVerdict(mine, theirs, toolName === "PowerShell");
500
+ }
501
+ const modelKeys = Object.keys(model);
502
+ if (modelKeys.length === 0)
503
+ return { ok: true };
504
+ if (sinkOf(toolName) === "command") {
505
+ const verdict = statementVerdict(model, step);
506
+ if (verdict && !verdict.ok)
507
+ return verdict;
508
+ }
509
+ for (const key of TARGET_KEYS) {
510
+ const mine = model[key];
511
+ const theirs = step[key];
512
+ if (typeof mine !== "string" || typeof theirs !== "string")
513
+ continue;
514
+ if (normalTarget(mine) === normalTarget(theirs))
515
+ continue;
516
+ if (READERS.has(toolName))
517
+ continue;
518
+ return { ok: false, why: `its ${key} is not the step's` };
519
+ }
520
+ if (FILE_WRITERS.has(toolName)) {
521
+ const mine = model.file_path ?? model.notebook_path;
522
+ const theirs = step.file_path ?? step.notebook_path;
523
+ if (typeof mine === "string" && typeof theirs !== "string") {
524
+ return { ok: false, why: "it writes a file the step does not name" };
525
+ }
526
+ }
527
+ const stepKeys = Object.keys(step);
528
+ if (stepKeys.length === 0)
529
+ return { ok: true };
530
+ return modelKeys.some((k) => stepKeys.includes(k))
531
+ ? { ok: true }
532
+ : { ok: false, why: "it shares no argument with the step" };
533
+ }
534
+ //# sourceMappingURL=pin-guard.js.map