dsh-ssh-workbench 0.0.0-stage → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +552 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +21 -0
- package/README.md +178 -2
- package/SECURITY.md +27 -0
- package/cordis.patch.yml +5 -0
- package/docs/architecture.md +146 -0
- package/docs/execution-model.md +83 -0
- package/docs/images/chat-at-resource.png +0 -0
- package/docs/images/plugin.png +0 -0
- package/docs/images/remote-workbench.png +0 -0
- package/docs/images/workbench.png +0 -0
- package/docs/quickstart.md +106 -0
- package/lib/client.js +4991 -0
- package/lib/core/activity.js +88 -0
- package/lib/core/background.js +69 -0
- package/lib/core/broker.js +802 -0
- package/lib/core/dialect.js +28 -0
- package/lib/core/errors.js +138 -0
- package/lib/core/execution-stats.js +88 -0
- package/lib/core/gate-pwsh.js +88 -0
- package/lib/core/gate.js +572 -0
- package/lib/core/guard.js +123 -0
- package/lib/core/known-hosts.js +74 -0
- package/lib/core/live-executions.js +101 -0
- package/lib/core/local-file-api.js +174 -0
- package/lib/core/localfs.js +381 -0
- package/lib/core/managed-context.js +276 -0
- package/lib/core/mentions.js +216 -0
- package/lib/core/paths.js +104 -0
- package/lib/core/posix-quote.js +4 -0
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +38 -0
- package/lib/core/remote-exec-tool.js +394 -0
- package/lib/core/remote-file-tools.js +559 -0
- package/lib/core/remote-shell-tool.js +310 -0
- package/lib/core/secrets.js +95 -0
- package/lib/core/server-ref.js +61 -0
- package/lib/core/shell-adapters.js +48 -0
- package/lib/core/ssh.js +761 -0
- package/lib/core/store.js +83 -0
- package/lib/core/terminals.js +299 -0
- package/lib/core/win32-adapter.js +141 -0
- package/lib/index.js +409 -0
- package/lib/routes.js +759 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +89 -4
- package/vendor/NOTICE.md +50 -0
- package/vendor/XTERM-LICENSE +21 -0
- package/vendor/node_modules/asn1/LICENSE +19 -0
- package/vendor/node_modules/asn1/README.md +50 -0
- package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
- package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
- package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
- package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
- package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
- package/vendor/node_modules/asn1/lib/index.js +20 -0
- package/vendor/node_modules/asn1/package.json +31 -0
- package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
- package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
- package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
- package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
- package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
- package/vendor/node_modules/safer-buffer/LICENSE +21 -0
- package/vendor/node_modules/safer-buffer/Readme.md +156 -0
- package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
- package/vendor/node_modules/safer-buffer/package.json +34 -0
- package/vendor/node_modules/safer-buffer/safer.js +77 -0
- package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
- package/vendor/node_modules/tweetnacl/LICENSE +24 -0
- package/vendor/node_modules/tweetnacl/README.md +459 -0
- package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
- package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
- package/vendor/node_modules/tweetnacl/package.json +58 -0
- package/vendor/ssh2/LICENSE +19 -0
- package/vendor/ssh2/lib/Channel.js +295 -0
- package/vendor/ssh2/lib/agent.js +1123 -0
- package/vendor/ssh2/lib/client.js +2176 -0
- package/vendor/ssh2/lib/http-agents.js +84 -0
- package/vendor/ssh2/lib/index.js +44 -0
- package/vendor/ssh2/lib/keygen.js +582 -0
- package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
- package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
- package/vendor/ssh2/lib/protocol/constants.js +356 -0
- package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
- package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
- package/vendor/ssh2/lib/protocol/handlers.js +16 -0
- package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
- package/vendor/ssh2/lib/protocol/kex.js +1908 -0
- package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
- package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
- package/vendor/ssh2/lib/protocol/utils.js +356 -0
- package/vendor/ssh2/lib/protocol/zlib.js +255 -0
- package/vendor/ssh2/lib/server.js +1380 -0
- package/vendor/ssh2/lib/utils.js +336 -0
- package/vendor/ssh2/package.json +34 -0
- package/vendor/xterm.js +53 -0
package/lib/core/gate.js
ADDED
|
@@ -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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback blacklist for remote commands.
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* There is no session-scoped target state: every remote tool names its own
|
|
9
|
+
* `server`, so the guard resolves that explicit reference. The native local
|
|
10
|
+
* `bash`/`pwsh` tools are never inspected — local work is outside this
|
|
11
|
+
* plugin's remote-command boundary.
|
|
12
|
+
*/
|
|
13
|
+
import { classifyCommand } from './gate.js'
|
|
14
|
+
import { classifyPowerShellCommand } from './gate-pwsh.js'
|
|
15
|
+
import { findServerRef } from './server-ref.js'
|
|
16
|
+
|
|
17
|
+
/** The plugin-owned POSIX shell tool. */
|
|
18
|
+
const REMOTE_BASH_TOOL_NAME = 'remote_bash'
|
|
19
|
+
|
|
20
|
+
/** The plugin-owned PowerShell tool (Windows targets). */
|
|
21
|
+
const REMOTE_PWSH_TOOL_NAME = 'remote_pwsh'
|
|
22
|
+
|
|
23
|
+
/** Explicit-target batch tool: every task names its own server. */
|
|
24
|
+
const REMOTE_EXEC_TOOL_NAME = 'remote_exec'
|
|
25
|
+
|
|
26
|
+
export const BLOCK_REASON_PREFIX = 'dsh-ssh-workbench blocked this command: '
|
|
27
|
+
|
|
28
|
+
/** Read the session key off one tool execution, tolerating test doubles. */
|
|
29
|
+
export function sessionKeyOf(exec) {
|
|
30
|
+
const agent = exec?.agent
|
|
31
|
+
const session = agent?.session
|
|
32
|
+
const id = session?.id ?? agent?.id
|
|
33
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pick the gate for one explicit server. A record's `platform` is only a hint:
|
|
38
|
+
* the executor probes the real dialect before running. When facts are not
|
|
39
|
+
* collected yet, run both blacklists so a stale hint cannot hide a
|
|
40
|
+
* Windows-only destructive command (and vice versa).
|
|
41
|
+
*/
|
|
42
|
+
function classifyForServer(server, command) {
|
|
43
|
+
const dialect = server?.facts?.dialect
|
|
44
|
+
if (dialect === 'windows') return classifyPowerShellCommand(command)
|
|
45
|
+
if (dialect === 'posix') return classifyCommand(command)
|
|
46
|
+
const posix = classifyCommand(command)
|
|
47
|
+
if (posix.decision === 'deny') return posix
|
|
48
|
+
return classifyPowerShellCommand(command)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function summaryOf(prefix, command) {
|
|
52
|
+
const text = `${prefix}${command}`.slice(0, 200)
|
|
53
|
+
return text
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} deps
|
|
58
|
+
* @param {(entry: object) => void} [deps.recordActivity]
|
|
59
|
+
* @param {() => object[]} [deps.loadInventory]
|
|
60
|
+
* @returns {(exec: object) => string|undefined} a `ctx.tools.guard()` handler
|
|
61
|
+
*/
|
|
62
|
+
export function createBlacklistGuard({ recordActivity, loadInventory } = {}) {
|
|
63
|
+
function inventory() {
|
|
64
|
+
if (typeof loadInventory !== 'function') return undefined
|
|
65
|
+
try {
|
|
66
|
+
const servers = loadInventory()
|
|
67
|
+
return Array.isArray(servers) ? servers : undefined
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function guardOne(exec, serverRef, command, label = '') {
|
|
74
|
+
const ref = typeof serverRef === 'string' ? serverRef.trim() : ''
|
|
75
|
+
if (!ref || ref.toLowerCase() === 'local') return undefined
|
|
76
|
+
if (!command.trim()) return undefined
|
|
77
|
+
const servers = inventory()
|
|
78
|
+
if (servers === undefined) return undefined
|
|
79
|
+
const server = findServerRef(servers, ref)
|
|
80
|
+
if (server === undefined) return undefined
|
|
81
|
+
const classification = classifyForServer(server, command)
|
|
82
|
+
if (classification.decision !== 'deny') return undefined
|
|
83
|
+
try {
|
|
84
|
+
recordActivity?.({
|
|
85
|
+
kind: 'exec',
|
|
86
|
+
server: server.name,
|
|
87
|
+
sessionId: sessionKeyOf(exec),
|
|
88
|
+
summary: summaryOf(label, command),
|
|
89
|
+
outcome: `blocked: ${classification.ruleId}`,
|
|
90
|
+
})
|
|
91
|
+
} catch {
|
|
92
|
+
// Activity recording is best effort and must never change the decision.
|
|
93
|
+
}
|
|
94
|
+
return `${BLOCK_REASON_PREFIX}${label}${classification.reason}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Check every task independently; one denied task denies the whole batch. */
|
|
98
|
+
function guardBatch(exec) {
|
|
99
|
+
const tasks = exec.arguments?.tasks
|
|
100
|
+
if (!Array.isArray(tasks) || tasks.length === 0) return undefined
|
|
101
|
+
for (let index = 0; index < tasks.length; index += 1) {
|
|
102
|
+
const task = tasks[index]
|
|
103
|
+
if (!task || typeof task !== 'object') continue
|
|
104
|
+
const command = typeof task.command === 'string' ? task.command : ''
|
|
105
|
+
const denied = guardOne(exec, task.server, command, `task ${index + 1}: `)
|
|
106
|
+
if (denied !== undefined) return denied
|
|
107
|
+
}
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return function blacklistGuard(exec) {
|
|
112
|
+
try {
|
|
113
|
+
if (!exec) return undefined
|
|
114
|
+
if (exec.name === REMOTE_EXEC_TOOL_NAME) return guardBatch(exec)
|
|
115
|
+
if (exec.name !== REMOTE_BASH_TOOL_NAME && exec.name !== REMOTE_PWSH_TOOL_NAME) return undefined
|
|
116
|
+
const command = typeof exec.arguments?.command === 'string' ? exec.arguments.command : ''
|
|
117
|
+
return guardOne(exec, exec.arguments?.server, command)
|
|
118
|
+
} catch {
|
|
119
|
+
// A guard must never turn its own failure into a denial of unrelated tools.
|
|
120
|
+
return undefined
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|