@twinsearth/w2m-dsh-plugin 0.5.8 → 0.6.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/CHANGELOG.md +384 -7
- package/CONTRIBUTING.md +14 -0
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +180 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +100 -53
- package/bin/w2m-rabbit.mjs +32 -5
- package/deploy/networking/LESSONS.md +18 -1
- package/deploy/networking/README.md +32 -7
- package/deploy/windows/README.md +20 -1
- package/deploy/windows/install-service.ps1 +98 -19
- package/deploy/windows/verify-supervisor.mjs +794 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +867 -40
- package/docs/AUDIT.md +10 -1
- package/docs/DEPLOY.md +16 -3
- package/docs/P2P-CRYPTO.md +105 -4
- package/docs/PIPELINE-DESIGN.md +35 -10
- package/docs/TROUBLESHOOTING.md +22 -3
- package/package.json +6 -6
- package/src/agent/agent.mjs +152 -5
- package/src/agent/allowed-commands.mjs +539 -15
- package/src/agent/crypto.mjs +238 -40
- package/src/agent/exec.mjs +60 -4
- package/src/agent/p2p-node.mjs +40 -0
- package/src/agent/p2p.mjs +323 -30
- package/src/agent/resolve.mjs +68 -3
- package/src/plugin/fleet-target.mjs +160 -15
- package/src/plugin/tools.mjs +319 -56
- package/src/relay/server.mjs +74 -11
|
@@ -8,15 +8,58 @@
|
|
|
8
8
|
* ends drift apart, and a pre-flight that is *stricter* than the gate is the worse direction: the
|
|
9
9
|
* agent would have run the command, and the dispatch never happened.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* ## What an entry means (read this before trusting an allow-list)
|
|
12
|
+
*
|
|
13
|
+
* An entry is a **prefix sequence**, and it grants *everything the tool can be told to do with the
|
|
14
|
+
* arguments that follow it*:
|
|
15
|
+
*
|
|
16
|
+
* * `"node --test"` means `['node','--test']`, which allows `['node','--test','--test-name-pattern=x']`
|
|
17
|
+
* and refuses `['node','-e','...']` and a bare `['node']`.
|
|
18
|
+
* * It also allows `['node','--test','some/file.mjs']` -- and the test runner *loads and executes*
|
|
19
|
+
* whatever file it is handed. So `node --test` is a grant of arbitrary code execution from any
|
|
20
|
+
* file this machine can read. **A prefix entry is not a sandbox.** It says which tool may run,
|
|
21
|
+
* not what that tool will do; the only way to get "this exact command line" semantics is an entry
|
|
22
|
+
* that names the whole line, and even then the named file's contents are the caller's to choose.
|
|
23
|
+
*
|
|
24
|
+
* Two things narrow that grant, and neither closes it:
|
|
25
|
+
*
|
|
26
|
+
* 1. {@link REPARSING_ARGUMENTS} refuses a named set of *trailing* arguments that make a tool load
|
|
27
|
+
* code the entry never mentioned (`--test-reporter=file:///…evil.mjs`, `--import`, `python -c`,
|
|
28
|
+
* `git --exec-path`, …). The set is per-tool, explicit, and deliberately incomplete: PowerShell
|
|
29
|
+
* abbreviates its own parameter names, `npx`/`xargs`/`sh -c`/`make` exist to run *another*
|
|
30
|
+
* program, and a tool nobody listed here gets no argument control at all. When an entry does
|
|
31
|
+
* need one of these arguments, the remedy is to name it **in the entry** -- the entry is the
|
|
32
|
+
* explicit grant, and this matcher only reads entries.
|
|
33
|
+
* 2. The executable is compared by **identity**, not by spelling: see `executableIdentity` below.
|
|
34
|
+
*
|
|
35
|
+
* ## The executable
|
|
36
|
+
*
|
|
37
|
+
* `argv[0]` is admitted in exactly three shapes:
|
|
38
|
+
*
|
|
39
|
+
* * a **bare name** (`node`, `node.exe`, `git`) matching the entry's own bare name, extension and
|
|
40
|
+
* (on Windows) case aside. PATH decides which file that is, and the executor resolves it through
|
|
41
|
+
* the same search order the capability probe uses (PATH, then DSH's bundled runtime). What this
|
|
42
|
+
* leaves open is PATH itself: an earlier directory, or a writable entry in the bundled runtime,
|
|
43
|
+
* can put a different `node` in front of the intended one. That is a machine-configuration
|
|
44
|
+
* question, and it is why the honest reading of a bare-name entry is "whatever this machine
|
|
45
|
+
* currently calls `node`".
|
|
46
|
+
* * an **absolute path** that resolves (through `realpath`) to the *same file* the entry's
|
|
47
|
+
* executable resolves to, so `C:\Program Files\nodejs\node.exe` is admitted for the entry `node`
|
|
48
|
+
* only when that is the file `node` resolves to, and `E:\Temp\evil\node.exe` -- a different file
|
|
49
|
+
* that merely shares the name -- is refused with `COMMAND_EXECUTABLE_PATH_MISMATCH`.
|
|
50
|
+
* * nothing else. A **relative** path (`./node.exe`, `bin\node.exe`) is refused outright with
|
|
51
|
+
* `COMMAND_EXECUTABLE_RELATIVE_PATH`: it names a different file in every working directory, and
|
|
52
|
+
* the working directory is chosen by the offer, not by this machine.
|
|
53
|
+
*
|
|
54
|
+
* Names are compared case-insensitively on Windows and case-sensitively elsewhere, and the
|
|
55
|
+
* extension is ignored on **both** sides, so the entry `Node.exe` and the argv `node` agree on
|
|
56
|
+
* Windows but not on a POSIX machine, where those are two different files.
|
|
16
57
|
*/
|
|
17
58
|
|
|
18
59
|
import { basename, extname } from 'node:path';
|
|
19
60
|
|
|
61
|
+
import { hasPathSeparator, isRelativePath, resolveCommand, sameFile } from './resolve.mjs';
|
|
62
|
+
|
|
20
63
|
/**
|
|
21
64
|
* Parse `--allowed-commands`.
|
|
22
65
|
*
|
|
@@ -87,22 +130,472 @@ function sameName(a, b) {
|
|
|
87
130
|
}
|
|
88
131
|
|
|
89
132
|
/**
|
|
90
|
-
*
|
|
133
|
+
* The trailing arguments a matched prefix does **not** cover, and why each one is refused.
|
|
134
|
+
*
|
|
135
|
+
* Every entry here is an argument that makes its tool run code the allow-list entry never named --
|
|
136
|
+
* either by loading a module or file, by taking source text directly, or by delegating to another
|
|
137
|
+
* program. They are refused because the alternative is worse in a way that is invisible: the matcher
|
|
138
|
+
* would say `allowed: true` for a command line whose *effect* the entry does not describe, and the
|
|
139
|
+
* operator would read that as "the list covers this".
|
|
140
|
+
*
|
|
141
|
+
* Three honest limits, because a list like this invites more trust than it can carry:
|
|
142
|
+
*
|
|
143
|
+
* * It is keyed by executable **family** ({@link EXECUTABLE_FAMILY}), so an executable nobody
|
|
144
|
+
* listed gets no argument control at all -- and listing every executable that can run another
|
|
145
|
+
* program is not a finite job (`npx`, `xargs`, `make`, `docker run`, `sh -c`, …). Those are
|
|
146
|
+
* grants of *another* program by construction; an entry naming one is a grant of it.
|
|
147
|
+
* * A tool that abbreviates its own options cannot be covered by tokens: PowerShell accepts any
|
|
148
|
+
* unambiguous prefix (`-com`, `-comm`, `-Command`), so the list is a floor for `pwsh`, not a
|
|
149
|
+
* guarantee.
|
|
150
|
+
* * It matches the arguments *as written*. Nothing here re-parses a command line, because
|
|
151
|
+
* re-parsing is the technique being refused; `--test-reporter` is caught, `--test-report`+`er`
|
|
152
|
+
* split across two arguments is not a thing a tool accepts either.
|
|
153
|
+
*
|
|
154
|
+
* The escape hatch is deliberate and is the point: an entry that names the argument is an explicit
|
|
155
|
+
* grant, so `["node --test --test-reporter=tap"]` allows exactly that and nothing more.
|
|
156
|
+
*/
|
|
157
|
+
const REPARSING_ARGUMENTS = Object.freeze({
|
|
158
|
+
node: Object.freeze([
|
|
159
|
+
{ token: '--test-reporter', why: 'its value is a module specifier or URL, and node imports and runs it as the test reporter' },
|
|
160
|
+
{ token: '--import', why: 'it loads an ES module before the program runs' },
|
|
161
|
+
{ token: '--require', why: 'it loads a CommonJS module before the program runs' },
|
|
162
|
+
{ token: '-r', why: 'it is the short spelling of --require, which loads a CommonJS module before the program runs' },
|
|
163
|
+
{ token: '--experimental-loader', why: 'it installs a module loader hook: code that decides what every later import resolves to' },
|
|
164
|
+
{ token: '--loader', why: 'it is the older spelling of --experimental-loader' },
|
|
165
|
+
{ token: '--eval', why: 'it runs its value as source instead of a file' },
|
|
166
|
+
{ token: '-e', why: 'it is the short spelling of --eval, which runs its value as source' },
|
|
167
|
+
{ token: '--print', why: 'it runs its value as source and prints the result' },
|
|
168
|
+
{ token: '-p', why: 'it is the short spelling of --print, which runs its value as source' },
|
|
169
|
+
// `-c` / `--check` is deliberately absent: it parses a file without executing it.
|
|
170
|
+
]),
|
|
171
|
+
python: Object.freeze([
|
|
172
|
+
{ token: '-c', why: 'it runs its value as Python source' },
|
|
173
|
+
{ token: '-m', why: 'it runs a module as __main__, imported from sys.path - which includes the working directory the offer chose' },
|
|
174
|
+
]),
|
|
175
|
+
perl: Object.freeze([
|
|
176
|
+
{ token: '-e', why: 'it runs its value as source' },
|
|
177
|
+
{ token: '-E', why: 'it runs its value as source, with optional features enabled' },
|
|
178
|
+
{ token: '-M', why: 'it loads a module at startup, and -MModule=args calls into it' },
|
|
179
|
+
{ token: '-m', why: 'it loads a module at startup, as -M does' },
|
|
180
|
+
{ token: '-d', why: 'it runs under a debugger whose value can name a module to load (-d:Module)' },
|
|
181
|
+
{ token: '-S', why: 'it runs a script looked up on PATH, so PATH chooses the file that executes' },
|
|
182
|
+
]),
|
|
183
|
+
ruby: Object.freeze([
|
|
184
|
+
{ token: '-e', why: 'it runs its value as source' },
|
|
185
|
+
{ token: '-r', why: 'it requires a library before the program runs' },
|
|
186
|
+
{ token: '-S', why: 'it runs a script looked up on PATH, so PATH chooses the file that executes' },
|
|
187
|
+
]),
|
|
188
|
+
php: Object.freeze([
|
|
189
|
+
{ token: '-r', why: 'it runs its value as PHP source' },
|
|
190
|
+
{ token: '-B', why: 'it runs its value as source before the input loop' },
|
|
191
|
+
{ token: '-R', why: 'it runs its value as source for every input line' },
|
|
192
|
+
{ token: '-E', why: 'it runs its value as source after the input loop' },
|
|
193
|
+
{ token: '-F', why: 'it runs a file for every input line' },
|
|
194
|
+
{ token: '-d', why: 'it overrides any php.ini directive, including auto_prepend_file/auto_append_file, which include a file before the script runs' },
|
|
195
|
+
]),
|
|
196
|
+
deno: Object.freeze([
|
|
197
|
+
{ token: '--import-map', why: 'it remaps module specifiers, so an import can be pointed at a different file' },
|
|
198
|
+
{ token: '--config', why: 'it names a config file whose imports/scopes remap module specifiers, as --import-map does' },
|
|
199
|
+
]),
|
|
200
|
+
bun: Object.freeze([
|
|
201
|
+
{ token: '-e', why: 'it runs its value as source' },
|
|
202
|
+
{ token: '--eval', why: 'it is the long spelling of bun -e, which runs its value as source' },
|
|
203
|
+
{ token: '-r', why: 'it imports a module before the program runs' },
|
|
204
|
+
{ token: '--preload', why: 'it is the long spelling of bun -r, which imports a module before the program runs' },
|
|
205
|
+
{ token: '--require', why: 'it imports a CommonJS module before the program runs' },
|
|
206
|
+
]),
|
|
207
|
+
shell: Object.freeze([
|
|
208
|
+
{ token: '-c', why: 'it runs its value as a command line' },
|
|
209
|
+
{ token: '--command', why: 'it is the long spelling of -c' },
|
|
210
|
+
{ token: '-Command', why: "it is PowerShell's -Command, which runs its value as a script block" },
|
|
211
|
+
{ token: '-EncodedCommand', why: "it is PowerShell's -EncodedCommand, which runs its base64 value as a script block" },
|
|
212
|
+
{ token: '--init-file', why: "it is bash's --init-file, which runs the named file instead of the usual startup file" },
|
|
213
|
+
{ token: '--rcfile', why: "it is bash's --rcfile, which runs the named file instead of ~/.bashrc" },
|
|
214
|
+
{ token: '/c', why: "it is cmd.exe's /c, which runs the rest of the command line as a command" },
|
|
215
|
+
{ token: '/k', why: "it is cmd.exe's /k, which runs the rest of the command line and then keeps the shell open" },
|
|
216
|
+
]),
|
|
217
|
+
java: Object.freeze([
|
|
218
|
+
{ token: '-javaagent', why: 'it names a jar whose premain runs inside the JVM before main' },
|
|
219
|
+
{ token: '-agentlib', why: 'it loads a native agent library whose Agent_OnLoad runs before main' },
|
|
220
|
+
{ token: '-agentpath', why: 'it loads a native agent library by path, as -agentlib does' },
|
|
221
|
+
]),
|
|
222
|
+
git: Object.freeze([
|
|
223
|
+
{ token: '-c', why: 'it sets any config for this one run: core.pager, core.sshCommand, core.editor and alias.* = !<shell command> each make git run a program the entry never named' },
|
|
224
|
+
{ token: '--config-env', why: 'it is -c with the value taken from an environment variable' },
|
|
225
|
+
{ token: '--exec-path', why: 'it moves the directory git takes its own subprograms (git-remote-*, git-upload-pack) from' },
|
|
226
|
+
{ token: '--upload-pack', why: 'it names the program the remote side runs to serve the pack' },
|
|
227
|
+
{ token: '--receive-pack', why: 'it is the push-side spelling of --upload-pack' },
|
|
228
|
+
]),
|
|
229
|
+
make: Object.freeze([
|
|
230
|
+
{ token: '-f', why: 'it reads a different makefile, whose recipes are commands make runs' },
|
|
231
|
+
{ token: '--file', why: 'it is a long spelling of make -f' },
|
|
232
|
+
{ token: '--makefile', why: 'it is another long spelling of make -f' },
|
|
233
|
+
{ token: '--eval', why: 'it evaluates its value as makefile text before any makefile is read' },
|
|
234
|
+
]),
|
|
235
|
+
tar: Object.freeze([
|
|
236
|
+
{ token: '-I', why: 'it names the compression program tar executes' },
|
|
237
|
+
{ token: '--use-compress-program', why: 'it is the long spelling of tar -I' },
|
|
238
|
+
{ token: '--to-command', why: 'it pipes every extracted member through a command of the caller\'s choosing' },
|
|
239
|
+
{ token: '--checkpoint-action', why: 'it runs a command at a checkpoint (--checkpoint-action=exec=...)' },
|
|
240
|
+
{ token: '-F', why: 'it runs its value as a script at every checkpoint' },
|
|
241
|
+
{ token: '--info-script', why: 'it is the long spelling of tar -F' },
|
|
242
|
+
]),
|
|
243
|
+
ssh: Object.freeze([
|
|
244
|
+
{ token: '-o', why: 'it sets any ssh_config option for this run, including ProxyCommand and LocalCommand, which are shell commands' },
|
|
245
|
+
{ token: '-F', why: 'it reads an alternate config file, which can itself set ProxyCommand' },
|
|
246
|
+
]),
|
|
247
|
+
scp: Object.freeze([
|
|
248
|
+
{ token: '-S', why: 'it names the program scp uses for the encrypted connection' },
|
|
249
|
+
{ token: '-o', why: 'it sets any ssh_config option for this run, including ProxyCommand, which is a shell command' },
|
|
250
|
+
{ token: '-F', why: 'it reads an alternate config file, which can itself set ProxyCommand' },
|
|
251
|
+
]),
|
|
252
|
+
rsync: Object.freeze([
|
|
253
|
+
{ token: '-e', why: 'it names the remote shell rsync runs' },
|
|
254
|
+
{ token: '--rsh', why: 'it is the long spelling of rsync -e' },
|
|
255
|
+
{ token: '--rsync-path', why: 'it names the rsync program the remote side runs' },
|
|
256
|
+
]),
|
|
257
|
+
find: Object.freeze([
|
|
258
|
+
{ token: '-exec', why: 'it runs a command for every match' },
|
|
259
|
+
{ token: '-execdir', why: "it runs a command in each match's own directory" },
|
|
260
|
+
{ token: '-ok', why: 'it runs a command for every match, asking first' },
|
|
261
|
+
{ token: '-okdir', why: "it runs a command in each match's own directory, asking first" },
|
|
262
|
+
]),
|
|
263
|
+
env: Object.freeze([
|
|
264
|
+
{ token: '-S', why: 'it splits its value into a new command line, so the arguments this matcher just read would be re-parsed by env' },
|
|
265
|
+
{ token: '--split-string', why: 'it is the long spelling of env -S' },
|
|
266
|
+
]),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Which argument list applies to an executable name.
|
|
271
|
+
*
|
|
272
|
+
* Family names, not exact names: `python3.12` and `python` must land on one list, and so must
|
|
273
|
+
* `node.exe` and `node`. The trailing-version strip is how `python3.12`/`perl5`/`node20` are
|
|
274
|
+
* recognised without enumerating every release ever installed.
|
|
275
|
+
*/
|
|
276
|
+
const EXECUTABLE_FAMILY = Object.freeze({
|
|
277
|
+
node: 'node', nodejs: 'node',
|
|
278
|
+
python: 'python', pythonw: 'python', py: 'python',
|
|
279
|
+
perl: 'perl',
|
|
280
|
+
ruby: 'ruby',
|
|
281
|
+
php: 'php',
|
|
282
|
+
deno: 'deno',
|
|
283
|
+
bun: 'bun',
|
|
284
|
+
sh: 'shell', bash: 'shell', dash: 'shell', zsh: 'shell', ksh: 'shell', fish: 'shell',
|
|
285
|
+
pwsh: 'shell', powershell: 'shell', cmd: 'shell',
|
|
286
|
+
java: 'java', javaw: 'java',
|
|
287
|
+
git: 'git',
|
|
288
|
+
make: 'make',
|
|
289
|
+
tar: 'tar',
|
|
290
|
+
ssh: 'ssh', sftp: 'ssh',
|
|
291
|
+
scp: 'scp',
|
|
292
|
+
rsync: 'rsync',
|
|
293
|
+
find: 'find',
|
|
294
|
+
env: 'env',
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The deny-list entries that apply to `executable`, or `[]` when its family is not covered.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} executable
|
|
301
|
+
* @returns {ReadonlyArray<{token: string, why: string}>}
|
|
302
|
+
*/
|
|
303
|
+
function reparsingArgumentsFor(executable) {
|
|
304
|
+
const name = execName(String(executable)).toLowerCase();
|
|
305
|
+
const versionless = name.replace(/[0-9.]+$/, '');
|
|
306
|
+
for (const candidate of [name, versionless]) {
|
|
307
|
+
if (candidate !== '' && Object.hasOwn(EXECUTABLE_FAMILY, candidate)) {
|
|
308
|
+
return REPARSING_ARGUMENTS[EXECUTABLE_FAMILY[candidate]] ?? [];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Does one trailing argument name a denied token?
|
|
316
|
+
*
|
|
317
|
+
* Three shapes, because tools accept three:
|
|
318
|
+
*
|
|
319
|
+
* * **exact** (`-e`, `--import`), and -- for tokens that take a value -- the value attached with
|
|
320
|
+
* `=` or `:` (`--import=file:///x`, `--test-reporter=spec`, `-javaagent:C:\evil.jar`) or written
|
|
321
|
+
* straight after a single-letter token;
|
|
322
|
+
* * a **bundle** of short options, where a denied single letter is one of them: `node -pe '…'` is
|
|
323
|
+
* `--print --eval`, `python -uc '…'` is `-u -c`, `bash -lc '…'` is `-l -c`. Only single-letter
|
|
324
|
+
* tokens take part, which is the conservative direction -- `find . -name x` must not read as
|
|
325
|
+
* `-n -a -m -e`;
|
|
326
|
+
* * the `/c` and `/k` forms cmd.exe uses, exact only.
|
|
327
|
+
*
|
|
328
|
+
* Comparison is case-insensitive: the family lists are small, the flags they name are the ones that
|
|
329
|
+
* load code, and refusing `-C` for `python` (a different spelling of the same thing on the platform
|
|
330
|
+
* where the entry was probably written) is the direction that keeps the two ends agreeing.
|
|
331
|
+
*
|
|
332
|
+
* @param {string} argument
|
|
333
|
+
* @param {ReadonlyArray<{token: string, why: string}>} denied
|
|
334
|
+
* @returns {{token: string, why: string, argument: string}|null}
|
|
335
|
+
*/
|
|
336
|
+
function deniedArgument(argument, denied) {
|
|
337
|
+
const token = String(argument);
|
|
338
|
+
const lower = token.toLowerCase();
|
|
339
|
+
// The *given* token decides which shapes are possible. Without this, `...\not-a-test-name.mjs`
|
|
340
|
+
// would be read as a bundle of short options and refused for containing an `e`.
|
|
341
|
+
const isLong = lower.startsWith('--');
|
|
342
|
+
const isShort = !isLong && lower.startsWith('-') && lower.length > 1;
|
|
343
|
+
const isSlash = lower.startsWith('/') && lower.length > 1;
|
|
344
|
+
for (const entry of denied) {
|
|
345
|
+
const deniedToken = entry.token.toLowerCase();
|
|
346
|
+
if (deniedToken.startsWith('--')) {
|
|
347
|
+
if (!isLong) continue;
|
|
348
|
+
if (lower === deniedToken || lower.startsWith(`${deniedToken}=`) || lower.startsWith(`${deniedToken}:`)) {
|
|
349
|
+
return { ...entry, argument: token };
|
|
350
|
+
}
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (deniedToken.startsWith('/')) {
|
|
354
|
+
// cmd.exe's `/c` and `/k`: exact, or with the command attached (`cmd /cecho hi`).
|
|
355
|
+
if (isSlash && (lower === deniedToken || lower.startsWith(deniedToken))) {
|
|
356
|
+
return { ...entry, argument: token };
|
|
357
|
+
}
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (!isShort) continue;
|
|
361
|
+
// Exact, or with the value attached (`-javaagent:C:\evil.jar`, `-MModule=args`).
|
|
362
|
+
if (lower.startsWith(deniedToken)) return { ...entry, argument: token };
|
|
363
|
+
const flag = deniedToken.slice(1);
|
|
364
|
+
if (flag.length === 1 && token.length > 2 && lower.slice(1).includes(flag)) {
|
|
365
|
+
return { ...entry, argument: token };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The stable codes this matcher refuses with.
|
|
373
|
+
*
|
|
374
|
+
* `COMMAND_NOT_ALLOWED` is deliberately the **same literal** the agent writes into a result
|
|
375
|
+
* envelope's `refusal_reason` (`REFUSAL.COMMAND_NOT_ALLOWED` in `src/agent/agent.mjs`) and the same
|
|
376
|
+
* one the plugin's dispatch pre-flight reports: the whole reason this matcher is shared is that the
|
|
377
|
+
* two ends must not be able to disagree about *why* a command was refused, and two spellings of one
|
|
378
|
+
* refusal would be a disagreement of exactly that kind. The three codes below it travel the same
|
|
379
|
+
* way -- the agent writes whichever code the matcher returned into its envelope, and the plugin puts
|
|
380
|
+
* the same one in its refusal value -- so a refusal code has exactly one definition, here.
|
|
381
|
+
*
|
|
382
|
+
* `COMMAND_ARGV_EMPTY` is the matcher's own precondition: no argv at all, or an argv carrying an
|
|
383
|
+
* entry that is not a string. It is a separate code because it is a separate fault -- a caller that
|
|
384
|
+
* passed something incomparable has a bug, not a policy problem -- and collapsing it into
|
|
385
|
+
* `COMMAND_NOT_ALLOWED` would send the reader to the allow-list to fix a caller. (A non-string entry
|
|
386
|
+
* used to reach `path.basename` and throw out of this function; the agent's catch-all turned that
|
|
387
|
+
* into a `crashed` envelope, which is a lot of noise for a caller bug and no refusal at all.)
|
|
388
|
+
*
|
|
389
|
+
* The three v0.5.10 codes are separate for the same reason, one remedy each:
|
|
390
|
+
*
|
|
391
|
+
* * `COMMAND_REPARSING_ARGUMENT` -- the entry matched, and one of its *trailing* arguments is on
|
|
392
|
+
* {@link REPARSING_ARGUMENTS}. The remedy is to name that argument in the entry (or drop it).
|
|
393
|
+
* * `COMMAND_EXECUTABLE_PATH_MISMATCH` -- `argv[0]` is a path, and it is not the file the entry's
|
|
394
|
+
* executable resolves to. The remedy is the other path, or a bare name.
|
|
395
|
+
* * `COMMAND_EXECUTABLE_RELATIVE_PATH` -- `argv[0]` is a relative path, whose meaning is chosen by
|
|
396
|
+
* the offer's working directory. The remedy is a bare name or an absolute path.
|
|
397
|
+
*/
|
|
398
|
+
export const MATCH_REFUSAL = Object.freeze({
|
|
399
|
+
ARGV_EMPTY: 'COMMAND_ARGV_EMPTY',
|
|
400
|
+
NOT_ALLOWED: 'COMMAND_NOT_ALLOWED',
|
|
401
|
+
REPARSING_ARGUMENT: 'COMMAND_REPARSING_ARGUMENT',
|
|
402
|
+
EXECUTABLE_PATH_MISMATCH: 'COMMAND_EXECUTABLE_PATH_MISMATCH',
|
|
403
|
+
EXECUTABLE_RELATIVE_PATH: 'COMMAND_EXECUTABLE_RELATIVE_PATH',
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Why this argv is not covered by this list, and what would change it.
|
|
408
|
+
*
|
|
409
|
+
* The entries are echoed because they are the *only* thing that would make the same call succeed,
|
|
410
|
+
* and a caller reading this deserves them without a second call. They are configuration, never
|
|
411
|
+
* credentials: the parse path refuses a secret-looking key before a list ever reaches here.
|
|
412
|
+
*
|
|
413
|
+
* @param {string[]} argv
|
|
414
|
+
* @param {string[][]} prefixes
|
|
415
|
+
* @returns {string}
|
|
416
|
+
*/
|
|
417
|
+
function allowListMissDetail(argv, prefixes) {
|
|
418
|
+
if (prefixes.length === 0) {
|
|
419
|
+
return (
|
|
420
|
+
'the configured allow-list is empty, so every command is refused; add an entry naming what ' +
|
|
421
|
+
`this machine may run - an entry of \`${argv[0]}\` would allow \`${argv.join(' ')}\``
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const entries = prefixes.map((prefix) => prefix.join(' ')).join(', ');
|
|
425
|
+
return (
|
|
426
|
+
`no configured entry is a prefix of this argv; the allow-list entries are ${entries}. Add one ` +
|
|
427
|
+
`that covers \`${argv.join(' ')}\`, or run a command the list already covers`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Can `argv[0]` be the executable this entry names?
|
|
433
|
+
*
|
|
434
|
+
* The three admissible shapes are described in the file header. The comparison is on the **file**,
|
|
435
|
+
* never on the spelling: `realpath` equality for paths (through {@link sameFile}), and name equality
|
|
436
|
+
* for bare names, where PATH is what turns a name into a file.
|
|
437
|
+
*
|
|
438
|
+
* @param {string} argv0
|
|
439
|
+
* @param {string} prefix0
|
|
440
|
+
* @param {object} options Forwarded to `resolveCommand` (`env`, `extraDirs`).
|
|
441
|
+
* @returns {{kind: 'match'|'no-match'|'path-mismatch', detail: string|null}}
|
|
442
|
+
*/
|
|
443
|
+
function executableIdentity(argv0, prefix0, options) {
|
|
444
|
+
const argvIsPath = hasPathSeparator(argv0);
|
|
445
|
+
const prefixIsPath = hasPathSeparator(prefix0);
|
|
446
|
+
|
|
447
|
+
// Both bare names: the name decides, and PATH decides which file the name means. This is the
|
|
448
|
+
// behaviour every existing configuration was written against, and it is the shape the header
|
|
449
|
+
// calls out as trusting PATH.
|
|
450
|
+
if (!argvIsPath && !prefixIsPath) {
|
|
451
|
+
return sameName(execName(argv0), execName(prefix0))
|
|
452
|
+
? { kind: 'match', detail: null }
|
|
453
|
+
: { kind: 'no-match', detail: null };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// One side is a path, so a file has to be identified: the entry's executable is resolved the same
|
|
457
|
+
// way the executor resolves it (PATH, then DSH's bundled runtime), and argv[0] -- a path the
|
|
458
|
+
// caller wrote -- is compared to it by realpath.
|
|
459
|
+
const resolvedPrefix = prefixIsPath ? prefix0 : resolveCommand(prefix0, options);
|
|
460
|
+
if (!prefixIsPath && !hasPathSeparator(resolvedPrefix)) {
|
|
461
|
+
return {
|
|
462
|
+
kind: 'path-mismatch',
|
|
463
|
+
detail:
|
|
464
|
+
`\`${argv0}\` is a path, but the entry's executable \`${prefix0}\` could not be found on ` +
|
|
465
|
+
'this machine\'s PATH (or in DSH\'s bundled runtime), so there is no file for a path to be ' +
|
|
466
|
+
`the same as; install it, or use the bare name \`${prefix0}\``,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const resolvedArgv = argvIsPath ? argv0 : resolveCommand(argv0, options);
|
|
471
|
+
if (!argvIsPath && !hasPathSeparator(resolvedArgv)) {
|
|
472
|
+
// A bare argv[0] against a path entry, and the bare name resolves to nothing: that is an
|
|
473
|
+
// ordinary "the entry does not cover this name" miss, not a claim about a file.
|
|
474
|
+
return { kind: 'no-match', detail: null };
|
|
475
|
+
}
|
|
476
|
+
if (sameFile(resolvedArgv, resolvedPrefix)) return { kind: 'match', detail: null };
|
|
477
|
+
return {
|
|
478
|
+
kind: 'path-mismatch',
|
|
479
|
+
detail:
|
|
480
|
+
`\`${argv0}\` is a path, and it is not the executable this entry names: \`${prefix0}\` resolves ` +
|
|
481
|
+
`to \`${resolvedPrefix}\`, a different file. An absolute path is admitted only when it *is* the ` +
|
|
482
|
+
`file the entry's executable resolves to, because a copy of a program under an allowed name is ` +
|
|
483
|
+
`a different program; use \`${resolvedPrefix}\`, or the bare name \`${execName(prefix0)}\``,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The first trailing argument that makes this match mean more than the entry says, or `null`.
|
|
489
|
+
*
|
|
490
|
+
* Only arguments **after** the matched prefix are examined: an entry that names a code-loading
|
|
491
|
+
* argument is the operator's explicit grant (`["python -m pytest"]` runs pytest), and re-refusing it
|
|
492
|
+
* here would make such an entry unusable rather than safer.
|
|
493
|
+
*
|
|
494
|
+
* @param {string[]} argv
|
|
495
|
+
* @param {string[]} prefix
|
|
496
|
+
* @returns {{token: string, why: string, argument: string}|null}
|
|
497
|
+
*/
|
|
498
|
+
function trailingReparsingArgument(argv, prefix) {
|
|
499
|
+
const denied = reparsingArgumentsFor(prefix[0]);
|
|
500
|
+
if (denied.length === 0) return null;
|
|
501
|
+
for (let i = prefix.length; i < argv.length; i += 1) {
|
|
502
|
+
const hit = deniedArgument(argv[i], denied);
|
|
503
|
+
if (hit) return hit;
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Why this trailing argument was refused, in terms of the entry that let the command this far.
|
|
510
|
+
*
|
|
511
|
+
* @param {{token: string, why: string, argument: string}} hit
|
|
512
|
+
* @param {string[]} prefix
|
|
513
|
+
* @returns {string}
|
|
514
|
+
*/
|
|
515
|
+
function reparsingDetail(hit, prefix) {
|
|
516
|
+
const entry = prefix.join(' ');
|
|
517
|
+
return (
|
|
518
|
+
`\`${hit.argument}\` is a trailing argument of the entry \`${entry}\`, and it is refused because ` +
|
|
519
|
+
`${hit.why}. A prefix entry grants the command, not the arguments after it, so this control ` +
|
|
520
|
+
`cannot tell \`${hit.argument}\` from the one that loads someone else's code. Name the argument ` +
|
|
521
|
+
`in an entry of its own if this machine is meant to run it (for example \`${entry} ${hit.token}=...\`), ` +
|
|
522
|
+
'or run the command without it'
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Default-deny prefix match, executable identity check, and trailing-argument deny-list.
|
|
91
528
|
*
|
|
92
529
|
* `['node','--test']` is allowed by the prefix `node --test`, and so is
|
|
93
|
-
* `['node','--test','--
|
|
94
|
-
* `['node']`
|
|
530
|
+
* `['node','--test','--test-name-pattern=x']`; `['node','-e','...']`, a bare `['node']`,
|
|
531
|
+
* `['node','--test','--test-reporter=file:///evil.mjs']` and a copy of some other program at
|
|
532
|
+
* `E:\Temp\evil\node.exe` are all refused.
|
|
533
|
+
*
|
|
534
|
+
* A refusal is a **named** one: `reason_code` is a stable, greppable code (see
|
|
535
|
+
* {@link MATCH_REFUSAL}) and `detail` says what would change it. `allowed` and `prefix` keep the
|
|
536
|
+
* shape this function has always returned, because both callers read exactly those two fields and
|
|
537
|
+
* the codes are additive.
|
|
95
538
|
*
|
|
96
539
|
* @param {string[]} argv
|
|
97
540
|
* @param {string[][]} prefixes
|
|
98
|
-
* @
|
|
541
|
+
* @param {object} [options] `env`/`extraDirs` for executable resolution (defaults: this process).
|
|
542
|
+
* @returns {{allowed: boolean, prefix: string[]|null, reason_code: string|null, detail: string|null}}
|
|
99
543
|
*/
|
|
100
|
-
export function matchAllowedCommand(argv, prefixes) {
|
|
101
|
-
|
|
102
|
-
|
|
544
|
+
export function matchAllowedCommand(argv, prefixes, options = {}) {
|
|
545
|
+
const entries = Array.isArray(prefixes) ? prefixes : [];
|
|
546
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
547
|
+
return {
|
|
548
|
+
allowed: false,
|
|
549
|
+
prefix: null,
|
|
550
|
+
reason_code: MATCH_REFUSAL.ARGV_EMPTY,
|
|
551
|
+
detail:
|
|
552
|
+
'there is no argv to compare, and this matcher is default-deny; pass a non-empty array of ' +
|
|
553
|
+
'strings whose first element is the executable',
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
for (const part of argv) {
|
|
557
|
+
if (typeof part !== 'string') {
|
|
558
|
+
return {
|
|
559
|
+
allowed: false,
|
|
560
|
+
prefix: null,
|
|
561
|
+
reason_code: MATCH_REFUSAL.ARGV_EMPTY,
|
|
562
|
+
detail:
|
|
563
|
+
'every argv entry must be a string; a non-string entry cannot be compared without ' +
|
|
564
|
+
'coercing it, and coercion is a re-parse',
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// A relative executable is refused before any entry is consulted, because no entry can pin it:
|
|
570
|
+
// `./node.exe` is a different file in every working directory, and the working directory comes
|
|
571
|
+
// from the offer.
|
|
572
|
+
if (isRelativePath(argv[0])) {
|
|
573
|
+
return {
|
|
574
|
+
allowed: false,
|
|
575
|
+
prefix: null,
|
|
576
|
+
reason_code: MATCH_REFUSAL.EXECUTABLE_RELATIVE_PATH,
|
|
577
|
+
detail:
|
|
578
|
+
`\`${argv[0]}\` is a relative path, and the working directory it would be resolved against ` +
|
|
579
|
+
'is chosen by the offer, not by this machine - so this names a different file in every ' +
|
|
580
|
+
'directory and the allow-list cannot say which program would run. Use the bare command name ' +
|
|
581
|
+
`(\`${execName(argv[0])}\`) or an absolute path to the executable the entry names`,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** The first refused trailing argument, kept while other entries are still tried on their merits. */
|
|
586
|
+
let reparsing = null;
|
|
587
|
+
/** The first path-identity mismatch, reported only if no entry matches at all. */
|
|
588
|
+
let mismatch = null;
|
|
589
|
+
|
|
590
|
+
for (const prefix of entries) {
|
|
103
591
|
if (!Array.isArray(prefix) || prefix.length === 0) continue;
|
|
104
592
|
if (argv.length < prefix.length) continue;
|
|
105
|
-
|
|
593
|
+
const identity = executableIdentity(argv[0], prefix[0], options);
|
|
594
|
+
if (identity.kind === 'no-match') continue;
|
|
595
|
+
if (identity.kind === 'path-mismatch') {
|
|
596
|
+
if (mismatch === null) mismatch = identity.detail;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
106
599
|
let ok = true;
|
|
107
600
|
for (let i = 1; i < prefix.length; i += 1) {
|
|
108
601
|
if (argv[i] !== prefix[i]) {
|
|
@@ -110,7 +603,38 @@ export function matchAllowedCommand(argv, prefixes) {
|
|
|
110
603
|
break;
|
|
111
604
|
}
|
|
112
605
|
}
|
|
113
|
-
if (ok)
|
|
606
|
+
if (!ok) continue;
|
|
607
|
+
// The prefix matched. A trailing argument on the deny-list is *not* an immediate refusal: an
|
|
608
|
+
// entry may name it explicitly, and entries are tried in the order they were configured, so
|
|
609
|
+
// `["node --test","node --test --test-reporter=tap"]` must still admit the second one.
|
|
610
|
+
const hit = trailingReparsingArgument(argv, prefix);
|
|
611
|
+
if (hit) {
|
|
612
|
+
if (reparsing === null) reparsing = { hit, prefix };
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
return { allowed: true, prefix, reason_code: null, detail: null };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (reparsing !== null) {
|
|
619
|
+
return {
|
|
620
|
+
allowed: false,
|
|
621
|
+
prefix: null,
|
|
622
|
+
reason_code: MATCH_REFUSAL.REPARSING_ARGUMENT,
|
|
623
|
+
detail: reparsingDetail(reparsing.hit, reparsing.prefix),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (mismatch !== null) {
|
|
627
|
+
return {
|
|
628
|
+
allowed: false,
|
|
629
|
+
prefix: null,
|
|
630
|
+
reason_code: MATCH_REFUSAL.EXECUTABLE_PATH_MISMATCH,
|
|
631
|
+
detail: mismatch,
|
|
632
|
+
};
|
|
114
633
|
}
|
|
115
|
-
return {
|
|
634
|
+
return {
|
|
635
|
+
allowed: false,
|
|
636
|
+
prefix: null,
|
|
637
|
+
reason_code: MATCH_REFUSAL.NOT_ALLOWED,
|
|
638
|
+
detail: allowListMissDetail(argv, entries.filter((prefix) => Array.isArray(prefix) && prefix.length > 0)),
|
|
639
|
+
};
|
|
116
640
|
}
|