dsh-ssh-workbench 0.0.0-stage → 1.6.4

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 (88) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/CONTRIBUTING.md +62 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -2
  5. package/SECURITY.md +25 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +124 -0
  8. package/docs/quickstart.md +94 -0
  9. package/lib/client.js +4977 -0
  10. package/lib/core/activity.js +83 -0
  11. package/lib/core/background.js +69 -0
  12. package/lib/core/broker.js +799 -0
  13. package/lib/core/dialect.js +28 -0
  14. package/lib/core/errors.js +138 -0
  15. package/lib/core/gate-pwsh.js +88 -0
  16. package/lib/core/gate.js +572 -0
  17. package/lib/core/guard.js +71 -0
  18. package/lib/core/known-hosts.js +74 -0
  19. package/lib/core/local-file-api.js +174 -0
  20. package/lib/core/localfs.js +381 -0
  21. package/lib/core/paths.js +104 -0
  22. package/lib/core/posix-quote.js +4 -0
  23. package/lib/core/prefs.js +38 -0
  24. package/lib/core/remote-bash-tool.js +321 -0
  25. package/lib/core/remote-bash.js +337 -0
  26. package/lib/core/secrets.js +96 -0
  27. package/lib/core/server-reference.js +299 -0
  28. package/lib/core/shell-adapters.js +48 -0
  29. package/lib/core/ssh.js +761 -0
  30. package/lib/core/store.js +83 -0
  31. package/lib/core/targets.js +250 -0
  32. package/lib/core/terminals.js +299 -0
  33. package/lib/core/win32-adapter.js +141 -0
  34. package/lib/index.js +450 -0
  35. package/lib/routes.js +733 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +88 -4
  39. package/vendor/NOTICE.md +50 -0
  40. package/vendor/XTERM-LICENSE +21 -0
  41. package/vendor/node_modules/asn1/LICENSE +19 -0
  42. package/vendor/node_modules/asn1/README.md +50 -0
  43. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  44. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  45. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  46. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  47. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  48. package/vendor/node_modules/asn1/lib/index.js +20 -0
  49. package/vendor/node_modules/asn1/package.json +31 -0
  50. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  51. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  52. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  53. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  54. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  55. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  56. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  57. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  58. package/vendor/node_modules/safer-buffer/package.json +34 -0
  59. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  60. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  61. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  62. package/vendor/node_modules/tweetnacl/README.md +459 -0
  63. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  64. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  65. package/vendor/node_modules/tweetnacl/package.json +58 -0
  66. package/vendor/ssh2/LICENSE +19 -0
  67. package/vendor/ssh2/lib/Channel.js +295 -0
  68. package/vendor/ssh2/lib/agent.js +1123 -0
  69. package/vendor/ssh2/lib/client.js +2176 -0
  70. package/vendor/ssh2/lib/http-agents.js +84 -0
  71. package/vendor/ssh2/lib/index.js +44 -0
  72. package/vendor/ssh2/lib/keygen.js +582 -0
  73. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  74. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  75. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  76. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  77. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  78. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  79. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  80. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  81. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  82. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  83. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  84. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  85. package/vendor/ssh2/lib/server.js +1380 -0
  86. package/vendor/ssh2/lib/utils.js +336 -0
  87. package/vendor/ssh2/package.json +34 -0
  88. package/vendor/xterm.js +53 -0
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Remote-command safety net for the DSH approval pipeline.
3
+ *
4
+ * The plugin has full SSH credentials for every managed server, so it is not a
5
+ * permission boundary and does not pretend to be one. Its only deterministic
6
+ * job is to stop a small set of machine-destroying accidents. Everything else
7
+ * is allowed: whether a command is reviewed is decided by the DSH session
8
+ * approval preset (`ask` / Auto review ask every command, `never` lets them
9
+ * run), never by a plugin-side allowlist or ask list.
10
+ *
11
+ * Design notes (v4, v1.0.2):
12
+ * - Shell syntax is normalized before matching. Each segment is tokenized into
13
+ * words with quoting and backslash escapes removed, wrappers (`sudo`,
14
+ * `timeout`, `env`, `!`, assignments, ...) are skipped, and rules inspect the
15
+ * resulting argv. So `"mkfs.ext4"`, `\rm`, `sudo -u root mkfs...` and
16
+ * `timeout 5 mkfs...` are classified like their plain forms.
17
+ * - Redirects are detected as tokens, not by substring: `cat > "/dev/sda"` is
18
+ * a device write, while `grep "> /dev/sda" file` is just text.
19
+ * - Paths are lexically canonicalized (trailing slashes, `//`, `.`/`..`,
20
+ * `~`/`${HOME}`) and glob/brace patterns are reduced to their literal
21
+ * prefix, so `rm -rf /etc/`, `rm -rf /etc/../`, `rm -rf //` and
22
+ * `rm -rf /etc/*` hit the same rule as `rm -rf /etc`.
23
+ * - Command substitutions (`$(...)`, backticks) and `eval` bodies are
24
+ * classified recursively, so `echo "$(mkfs.ext4 /dev/sdb1)"` is denied.
25
+ * - The blacklist remains best-effort: `bash -c`, base64, script files and
26
+ * heredoc bodies passed to an interpreter are deliberately left to the
27
+ * caller. The real boundary is SSH least privilege + host-key pinning.
28
+ */
29
+
30
+ const DEVICE_PATTERN =
31
+ '(?:sd[a-z]|hd[a-z]|vd[a-z]|xvd[a-z]|nvme\\d|mmcblk\\d|md\\d|dm-\\d|' +
32
+ 'mapper|disk|loop\\d|nbd\\d|ram\\d|zram\\d|ubi\\d|mtdblock\\d|sr\\d|fd\\d|dasd[a-z]+|root)'
33
+ const DEVICE_RE = new RegExp(`^/dev/${DEVICE_PATTERN}`)
34
+
35
+ const SYSTEM_DIRS = new Set([
36
+ '/etc', '/root', '/usr', '/var', '/bin', '/sbin', '/lib', '/lib64', '/boot',
37
+ '/home', '/opt', '/srv', '/sys', '/proc', '/dev',
38
+ ])
39
+
40
+ /** Home/root spellings that point at a whole user (or root) home directory. */
41
+ const HOME_ROOT_TOKENS = new Set([
42
+ '~', '~/', '~root', '~root/', '$HOME', '$HOME/', '${HOME}', '${HOME}/',
43
+ ])
44
+
45
+ /** Relative spellings that mean "the whole current or parent directory". */
46
+ const RELATIVE_ROOT_TOKENS = new Set(['.', './', '..', '../'])
47
+
48
+ const PARTITION_TOOLS = new Set(['fdisk', 'parted', 'wipefs', 'sfdisk', 'gdisk', 'sgdisk', 'cfdisk', 'cgdisk'])
49
+ const ERASE_TOOLS = new Set(['blkdiscard'])
50
+ const COPY_TOOLS = new Set(['cp', 'mv', 'install'])
51
+ const REDIRECT_OPS = new Set(['>', '>>', '&>', '&>>', '>&'])
52
+
53
+ const SUDO_ARG_FLAGS = new Set([
54
+ '-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-r', '--role',
55
+ '-t', '--type', '-U', '--other-user', '-C', '--close-from',
56
+ ])
57
+ const WRAPPERS = new Set(['sudo', 'doas', 'env', 'command', 'nice', 'time', 'nohup', 'exec', 'setsid', 'builtin'])
58
+ const WRAPPER_VALUE_FLAGS = {
59
+ sudo: SUDO_ARG_FLAGS,
60
+ doas: new Set(['-u']),
61
+ env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']),
62
+ nice: new Set(['-n', '--adjustment']),
63
+ time: new Set(['-f', '--format', '-o', '--output']),
64
+ exec: new Set(['-a']),
65
+ }
66
+ const TIMEOUT_ARG_FLAGS = new Set(['-s', '--signal', '-k', '--kill-after'])
67
+ const DURATION_RE = /^\d+(?:\.\d+)?[smhd]?$/
68
+
69
+ /**
70
+ * Split on top-level shell separators, respecting quotes:
71
+ * `;` `|` `||` `&&`, a single `&` (command backgrounding), and newlines —
72
+ * models routinely emit multi-line shell scripts, and a newline is as much a
73
+ * command boundary as `;`. Redirection operators that contain `&` (`2>&1`,
74
+ * `&>file`, `&>>file`) are kept in the segment instead of being split.
75
+ */
76
+ function splitSegments(command) {
77
+ const segments = []
78
+ let current = ''
79
+ let quote = null
80
+ for (let index = 0; index < command.length; index += 1) {
81
+ const char = command[index]
82
+ if (quote) {
83
+ current += char
84
+ if (quote === '"' && char === '\\' && index + 1 < command.length) {
85
+ current += command[index + 1]
86
+ index += 1
87
+ } else if (char === quote) {
88
+ quote = null
89
+ }
90
+ continue
91
+ }
92
+ if (char === '"' || char === "'") {
93
+ quote = char
94
+ current += char
95
+ continue
96
+ }
97
+ if (char === '\\') {
98
+ current += char
99
+ if (index + 1 < command.length) {
100
+ current += command[index + 1]
101
+ index += 1
102
+ }
103
+ continue
104
+ }
105
+ if (char === ';' || char === '|' || char === '\n' || char === '\r') {
106
+ segments.push(current)
107
+ current = ''
108
+ continue
109
+ }
110
+ if (char === '&') {
111
+ const next = command[index + 1]
112
+ const previous = command[index - 1]
113
+ if (next === '&') {
114
+ segments.push(current)
115
+ current = ''
116
+ index += 1
117
+ continue
118
+ }
119
+ if (next !== '>' && previous !== '>') {
120
+ segments.push(current)
121
+ current = ''
122
+ continue
123
+ }
124
+ }
125
+ current += char
126
+ }
127
+ segments.push(current)
128
+ return segments
129
+ }
130
+
131
+ /**
132
+ * Tokenize one shell segment into words and operators, removing quoting and
133
+ * backslash escapes: `cat > "/dev/sda"` → `['cat', '>', '/dev/sda']`.
134
+ * Inside quotes operators stay part of the word, so quoted text keeps its
135
+ * literal meaning.
136
+ */
137
+ function tokenize(segment) {
138
+ const tokens = []
139
+ let word = ''
140
+ let index = 0
141
+ const pushWord = () => {
142
+ if (word.length > 0) {
143
+ tokens.push(word)
144
+ word = ''
145
+ }
146
+ }
147
+ while (index < segment.length) {
148
+ const char = segment[index]
149
+ if (char === "'") {
150
+ index += 1
151
+ while (index < segment.length && segment[index] !== "'") {
152
+ word += segment[index]
153
+ index += 1
154
+ }
155
+ index += 1
156
+ continue
157
+ }
158
+ if (char === '"') {
159
+ index += 1
160
+ while (index < segment.length && segment[index] !== '"') {
161
+ const inner = segment[index]
162
+ if (inner === '\\' && index + 1 < segment.length && '$`"\\\n'.includes(segment[index + 1])) {
163
+ word += segment[index + 1]
164
+ index += 2
165
+ continue
166
+ }
167
+ word += inner
168
+ index += 1
169
+ }
170
+ index += 1
171
+ continue
172
+ }
173
+ if (char === '\\') {
174
+ if (index + 1 < segment.length) {
175
+ word += segment[index + 1]
176
+ index += 2
177
+ } else {
178
+ index += 1
179
+ }
180
+ continue
181
+ }
182
+ if (char === ' ' || char === '\t' || char === '\n' || char === '\r') {
183
+ pushWord()
184
+ index += 1
185
+ continue
186
+ }
187
+ if (char === '&') {
188
+ pushWord()
189
+ if (segment[index + 1] === '>') {
190
+ if (segment[index + 2] === '>') {
191
+ tokens.push('&>>')
192
+ index += 3
193
+ } else {
194
+ tokens.push('&>')
195
+ index += 2
196
+ }
197
+ continue
198
+ }
199
+ tokens.push('&')
200
+ index += 1
201
+ continue
202
+ }
203
+ if (char === '>' || char === '<') {
204
+ pushWord()
205
+ if (segment[index + 1] === '&') {
206
+ tokens.push(`${char}&`)
207
+ index += 2
208
+ continue
209
+ }
210
+ if (segment[index + 1] === char) {
211
+ if (char === '<' && segment[index + 2] === '<') {
212
+ tokens.push('<<<')
213
+ index += 3
214
+ continue
215
+ }
216
+ tokens.push(char + char)
217
+ index += 2
218
+ continue
219
+ }
220
+ tokens.push(char)
221
+ index += 1
222
+ continue
223
+ }
224
+ if (char === '|' || char === ';') {
225
+ pushWord()
226
+ tokens.push(char)
227
+ index += 1
228
+ continue
229
+ }
230
+ word += char
231
+ index += 1
232
+ }
233
+ pushWord()
234
+ return tokens
235
+ }
236
+
237
+ /**
238
+ * Skip leading assignments, shell wrappers and their arguments so rules see the
239
+ * actual command word. Works on unquoted tokens, so `sudo -u root "mkfs.ext4"`
240
+ * reduces to `['mkfs.ext4']`.
241
+ */
242
+ function stripWrappers(tokens) {
243
+ let index = 0
244
+ for (let guard = 0; guard < 24 && index < tokens.length; guard += 1) {
245
+ const head = tokens[index]
246
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) {
247
+ index += 1
248
+ continue
249
+ }
250
+ if (head === '!') {
251
+ index += 1
252
+ continue
253
+ }
254
+ if (head === 'timeout') {
255
+ index += 1
256
+ while (index < tokens.length && tokens[index].startsWith('-') && tokens[index] !== '-') {
257
+ const inline = tokens[index].includes('=')
258
+ const flag = tokens[index].split('=')[0]
259
+ index += 1
260
+ if (!inline && TIMEOUT_ARG_FLAGS.has(flag) && index < tokens.length) index += 1
261
+ }
262
+ if (index < tokens.length && DURATION_RE.test(tokens[index])) index += 1
263
+ continue
264
+ }
265
+ if (WRAPPERS.has(head)) {
266
+ index += 1
267
+ const valueFlags = WRAPPER_VALUE_FLAGS[head] ?? new Set()
268
+ while (index < tokens.length && tokens[index].startsWith('-') && tokens[index] !== '-') {
269
+ const inline = tokens[index].includes('=')
270
+ const flag = tokens[index].split('=')[0]
271
+ index += 1
272
+ if (!inline && valueFlags.has(flag) && index < tokens.length) index += 1
273
+ }
274
+ continue
275
+ }
276
+ break
277
+ }
278
+ return tokens.slice(index)
279
+ }
280
+
281
+ /** Last path component of a command word (`/bin/rm` → `rm`). */
282
+ function basenameOf(word) {
283
+ const trimmed = String(word).replace(/\/+$/, '')
284
+ const index = trimmed.lastIndexOf('/')
285
+ return index === -1 ? trimmed : trimmed.slice(index + 1)
286
+ }
287
+
288
+ /** POSIX lexical path normalization: `/etc//../` → `/`. */
289
+ function canonicalizePath(value) {
290
+ if (value === '' || !value.startsWith('/')) return { absolute: false, value }
291
+ const stack = []
292
+ for (const part of value.split('/')) {
293
+ if (part === '' || part === '.') continue
294
+ if (part === '..') {
295
+ stack.pop()
296
+ continue
297
+ }
298
+ stack.push(part)
299
+ }
300
+ return { absolute: true, value: `/${stack.join('/')}` }
301
+ }
302
+
303
+ /** Expand simple `{a,b}` alternatives (no nesting, bounded). */
304
+ function expandBraces(pattern) {
305
+ const match = pattern.match(/\{([^{}]*)\}/)
306
+ if (!match) return [pattern]
307
+ const results = []
308
+ for (const alternative of match[1].split(',')) {
309
+ results.push(...expandBraces(pattern.slice(0, match.index) + alternative + pattern.slice(match.index + match[0].length)))
310
+ }
311
+ return results.slice(0, 16)
312
+ }
313
+
314
+ /** Literal prefix of a glob/brace pattern (`/etc/*` → `/etc`). */
315
+ function literalPrefix(pattern) {
316
+ const meta = pattern.search(/[*?\[]/)
317
+ return meta === -1 ? pattern : pattern.slice(0, meta)
318
+ }
319
+
320
+ function isProtectedPath(target) {
321
+ if (typeof target !== 'string' || target.length === 0) return false
322
+ if (HOME_ROOT_TOKENS.has(target) || RELATIVE_ROOT_TOKENS.has(target)) return true
323
+ for (const pattern of expandBraces(target)) {
324
+ const prefix = literalPrefix(pattern)
325
+ if (prefix === '') continue
326
+ const normalized = canonicalizePath(prefix)
327
+ if (!normalized.absolute) continue
328
+ if (normalized.value === '/' || SYSTEM_DIRS.has(normalized.value)) return true
329
+ }
330
+ return false
331
+ }
332
+
333
+ function isRootFilesystemPath(target) {
334
+ if (typeof target !== 'string' || target.length === 0) return false
335
+ for (const pattern of expandBraces(target)) {
336
+ const prefix = literalPrefix(pattern)
337
+ if (prefix === '') continue
338
+ const normalized = canonicalizePath(prefix)
339
+ if (normalized.absolute && (normalized.value === '/' || SYSTEM_DIRS.has(normalized.value))) return true
340
+ }
341
+ return false
342
+ }
343
+
344
+ function isDevicePath(word) {
345
+ if (typeof word !== 'string' || word.length === 0) return false
346
+ const value = word.includes('=') ? word.slice(word.indexOf('=') + 1) : word
347
+ return DEVICE_RE.test(value)
348
+ }
349
+
350
+ /** Deny rule for `dd ... of=/dev/<device>`. */
351
+ function ddRule(words) {
352
+ if (basenameOf(words[0]) !== 'dd') return undefined
353
+ for (const word of words.slice(1)) {
354
+ if (word.startsWith('of=') && isDevicePath(word)) {
355
+ return { reason: 'raw disk write (dd of=/dev/...)', ruleId: 'disk-write' }
356
+ }
357
+ }
358
+ return undefined
359
+ }
360
+
361
+ /** Deny rule for recursive delete of a root/system path. */
362
+ function rmRule(words) {
363
+ if (basenameOf(words[0]) !== 'rm') return undefined
364
+ const rest = words.slice(1)
365
+ const flags = rest.filter((word) => word.startsWith('-')).join('')
366
+ if (!/[rR]/.test(flags)) return undefined
367
+ const targets = rest.filter((word) => !word.startsWith('-'))
368
+ if (targets.some(isProtectedPath)) {
369
+ return { reason: 'recursive delete of a root/system path', ruleId: 'rm-root' }
370
+ }
371
+ return undefined
372
+ }
373
+
374
+ function chmodRule(words) {
375
+ if (basenameOf(words[0]) !== 'chmod') return undefined
376
+ const positional = words.slice(1).filter((word) => !word.startsWith('-'))
377
+ if (positional.length < 2) return undefined
378
+ const mode = positional[0]
379
+ const numeric = /^[0-7]{3,4}$/.test(mode)
380
+ const symbolic = /^[ugoa]*[-+=][rwxXst]*([,][ugoa]*[-+=][rwxXst]*)*$/.test(mode)
381
+ if (!numeric && !symbolic) return undefined
382
+ if (positional.slice(1).some(isRootFilesystemPath)) {
383
+ return { reason: 'permission change on the root filesystem', ruleId: 'chmod-root' }
384
+ }
385
+ return undefined
386
+ }
387
+
388
+ function chownRule(words) {
389
+ if (basenameOf(words[0]) !== 'chown') return undefined
390
+ const positional = words.slice(1).filter((word) => !word.startsWith('-'))
391
+ if (positional.length < 2) return undefined
392
+ if (positional.slice(1).some(isRootFilesystemPath)) {
393
+ return { reason: 'ownership change on the root filesystem', ruleId: 'chown-root' }
394
+ }
395
+ return undefined
396
+ }
397
+
398
+ function deviceWriteRule(words) {
399
+ const head = basenameOf(words[0])
400
+ const rest = words.slice(1)
401
+ if (PARTITION_TOOLS.has(head)) {
402
+ return { reason: 'partition table editing', ruleId: 'partition' }
403
+ }
404
+ if (ERASE_TOOLS.has(head)) {
405
+ return { reason: 'raw disk erase (blkdiscard)', ruleId: 'device-write' }
406
+ }
407
+ if (head === 'shred') {
408
+ if (rest.filter((word) => !word.startsWith('-')).some(isDevicePath)) {
409
+ return { reason: 'raw disk wipe (shred)', ruleId: 'device-write' }
410
+ }
411
+ return undefined
412
+ }
413
+ if (head === 'badblocks') {
414
+ const flags = rest.filter((word) => word.startsWith('-')).join('')
415
+ if (/[wn]/.test(flags) && rest.filter((word) => !word.startsWith('-')).some(isDevicePath)) {
416
+ return { reason: 'destructive disk test (badblocks -w/-n)', ruleId: 'device-write' }
417
+ }
418
+ return undefined
419
+ }
420
+ if (COPY_TOOLS.has(head)) {
421
+ const positional = rest.filter((word) => !word.startsWith('-'))
422
+ if (positional.length > 0 && isDevicePath(positional[positional.length - 1])) {
423
+ return { reason: 'writing a file onto a block device', ruleId: 'device-write' }
424
+ }
425
+ return undefined
426
+ }
427
+ if (head === 'tee') {
428
+ if (rest.filter((word) => !word.startsWith('-')).some(isDevicePath)) {
429
+ return { reason: 'writing output onto a block device', ruleId: 'device-write' }
430
+ }
431
+ }
432
+ return undefined
433
+ }
434
+
435
+ /** Deny when a redirect operator is followed by a block device. */
436
+ function redirectRule(tokens) {
437
+ for (let index = 0; index < tokens.length - 1; index += 1) {
438
+ if (REDIRECT_OPS.has(tokens[index]) && isDevicePath(tokens[index + 1])) {
439
+ return { reason: 'redirecting output onto a block device', ruleId: 'device-redirect' }
440
+ }
441
+ }
442
+ return undefined
443
+ }
444
+
445
+ /** Extract `$(...)` and backtick bodies that the shell would execute. */
446
+ function collectSubstitutions(command) {
447
+ const bodies = []
448
+ let quote = null
449
+ let index = 0
450
+ const readParen = (start) => {
451
+ let depth = 0
452
+ for (let cursor = start; cursor < command.length; cursor += 1) {
453
+ const char = command[cursor]
454
+ if (char === '(') depth += 1
455
+ else if (char === ')') {
456
+ depth -= 1
457
+ if (depth === 0) return { body: command.slice(start + 1, cursor), end: cursor }
458
+ }
459
+ }
460
+ return undefined
461
+ }
462
+ while (index < command.length) {
463
+ const char = command[index]
464
+ if (quote === "'") {
465
+ if (char === "'") quote = null
466
+ index += 1
467
+ continue
468
+ }
469
+ if (char === '\\') {
470
+ index += 2
471
+ continue
472
+ }
473
+ if (quote === '"') {
474
+ if (char === '"') {
475
+ quote = null
476
+ index += 1
477
+ continue
478
+ }
479
+ if (char === '$' && command[index + 1] === '(') {
480
+ const found = readParen(index + 1)
481
+ if (found && found.body.trim() && bodies.length < 16) bodies.push(found.body)
482
+ index = found ? found.end + 1 : index + 2
483
+ continue
484
+ }
485
+ if (char === '`') {
486
+ const end = command.indexOf('`', index + 1)
487
+ if (end > index) {
488
+ const body = command.slice(index + 1, end)
489
+ if (body.trim() && bodies.length < 16) bodies.push(body)
490
+ index = end + 1
491
+ continue
492
+ }
493
+ }
494
+ index += 1
495
+ continue
496
+ }
497
+ if (char === "'") {
498
+ quote = "'"
499
+ index += 1
500
+ continue
501
+ }
502
+ if (char === '"') {
503
+ quote = '"'
504
+ index += 1
505
+ continue
506
+ }
507
+ if (char === '$' && command[index + 1] === '(') {
508
+ const found = readParen(index + 1)
509
+ if (found && found.body.trim() && bodies.length < 16) bodies.push(found.body)
510
+ index = found ? found.end + 1 : index + 2
511
+ continue
512
+ }
513
+ if (char === '`') {
514
+ const end = command.indexOf('`', index + 1)
515
+ if (end > index) {
516
+ const body = command.slice(index + 1, end)
517
+ if (body.trim() && bodies.length < 16) bodies.push(body)
518
+ index = end + 1
519
+ continue
520
+ }
521
+ }
522
+ index += 1
523
+ }
524
+ return bodies
525
+ }
526
+
527
+ /** Classify the words of one segment. Returns a deny decision or undefined. */
528
+ function classifySegmentWords(segment, depth) {
529
+ if (/^:\s*\(\s*\)\s*\{/.test(segment.trim())) {
530
+ return { reason: 'fork bomb pattern', ruleId: 'fork-bomb' }
531
+ }
532
+ const tokens = tokenize(segment)
533
+ const redirect = redirectRule(tokens)
534
+ if (redirect) return redirect
535
+ const words = stripWrappers(tokens)
536
+ if (words.length === 0) return undefined
537
+ const head = basenameOf(words[0])
538
+ // `eval` re-parses its argument, so classify it like a nested command.
539
+ if (head === 'eval' && depth < 2) {
540
+ const nested = classifyCommand(words.slice(1).join(' '), depth + 1)
541
+ if (nested.decision === 'deny') {
542
+ return { reason: `inside eval: ${nested.reason}`, ruleId: nested.ruleId }
543
+ }
544
+ return undefined
545
+ }
546
+ if (/^mkfs(\.\w+)?$/.test(head)) {
547
+ return { reason: 'filesystem formatting (mkfs)', ruleId: 'mkfs' }
548
+ }
549
+ return ddRule(words) ?? deviceWriteRule(words) ?? rmRule(words) ?? chmodRule(words) ?? chownRule(words)
550
+ }
551
+
552
+ /**
553
+ * Classify one raw command string.
554
+ * @returns {{decision: 'deny'|'allow', reason: string, ruleId: string}}
555
+ */
556
+ export function classifyCommand(raw, depth = 0) {
557
+ const command = String(raw ?? '').trim()
558
+ if (!command) return { decision: 'deny', reason: 'empty command', ruleId: 'empty' }
559
+ if (depth < 2) {
560
+ for (const body of collectSubstitutions(command)) {
561
+ const nested = classifyCommand(body, depth + 1)
562
+ if (nested.decision === 'deny') {
563
+ return { decision: 'deny', reason: `inside command substitution: ${nested.reason}`, ruleId: nested.ruleId }
564
+ }
565
+ }
566
+ }
567
+ for (const segment of splitSegments(command)) {
568
+ const denied = classifySegmentWords(segment, depth)
569
+ if (denied) return { decision: 'deny', ...denied }
570
+ }
571
+ return { decision: 'allow', reason: 'not on the machine-destruction blacklist', ruleId: 'allow' }
572
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Fallback blacklist for remote bash.
3
+ *
4
+ * Registered through `ctx.tools.guard()`: synchronous, monotonic, and checked
5
+ * after DSH's own pre-execute policy and approval, so no later listener can
6
+ * turn a denial back into a permission. It only inspects commands that will
7
+ * actually run on a managed server (the session has an active target and the
8
+ * tool is a POSIX remote shell: the native `bash` tool on POSIX Hosts, the
9
+ * plugin-owned `remote_bash` tool on Windows Hosts); local bash, file tools and
10
+ * every other tool are untouched.
11
+ */
12
+ import { classifyCommand } from './gate.js'
13
+ import { classifyPowerShellCommand } from './gate-pwsh.js'
14
+
15
+ /** The plugin-owned POSIX shell tool (Windows Hosts have no native bash). */
16
+ export const REMOTE_BASH_TOOL_NAME = 'remote_bash'
17
+
18
+ /** The plugin-owned PowerShell tool (Windows targets). */
19
+ export const REMOTE_PWSH_TOOL_NAME = 'remote_pwsh'
20
+
21
+ /** Tools whose command argument runs in a POSIX shell on a managed target. */
22
+ const POSIX_REMOTE_TOOL_NAMES = new Set(['bash', REMOTE_BASH_TOOL_NAME])
23
+
24
+ export const BLOCK_REASON_PREFIX = 'dsh-ssh-workbench blocked this command: '
25
+
26
+ /** Read the session key off one tool execution, tolerating test doubles. */
27
+ export function sessionKeyOf(exec) {
28
+ const agent = exec?.agent
29
+ const session = agent?.session
30
+ const id = session?.id ?? agent?.id
31
+ return typeof id === 'string' && id.length > 0 ? id : undefined
32
+ }
33
+
34
+ /**
35
+ * @param {object} deps
36
+ * @param {object} deps.targets per-session target store
37
+ * @param {(entry: object) => void} [deps.recordActivity]
38
+ * @returns {(exec: object) => string|undefined} a `ctx.tools.guard()` handler
39
+ */
40
+ export function createBlacklistGuard({ targets, recordActivity } = {}) {
41
+ return function blacklistGuard(exec) {
42
+ try {
43
+ if (!exec) return undefined
44
+ const isPosix = POSIX_REMOTE_TOOL_NAMES.has(exec.name)
45
+ const isPowerShell = exec.name === REMOTE_PWSH_TOOL_NAME
46
+ if (!isPosix && !isPowerShell) return undefined
47
+ const key = sessionKeyOf(exec)
48
+ if (key === undefined || !targets) return undefined
49
+ const binding = targets.get(key)
50
+ if (!binding || !binding.active) return undefined
51
+ const command = typeof exec.arguments?.command === 'string' ? exec.arguments.command : ''
52
+ if (!command.trim()) return undefined
53
+ const classification = isPowerShell ? classifyPowerShellCommand(command) : classifyCommand(command)
54
+ if (classification.decision !== 'deny') return undefined
55
+ try {
56
+ recordActivity?.({
57
+ kind: 'exec',
58
+ server: binding.active,
59
+ summary: command.slice(0, 200),
60
+ outcome: `blocked: ${classification.ruleId}`,
61
+ })
62
+ } catch {
63
+ // Activity recording is best effort and must never change the decision.
64
+ }
65
+ return `${BLOCK_REASON_PREFIX}${classification.reason}`
66
+ } catch {
67
+ // A guard must never turn its own failure into a denial of unrelated tools.
68
+ return undefined
69
+ }
70
+ }
71
+ }