@basein/runner 0.2.12 → 0.3.1
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 +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2787 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- 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
|