dsh-server-connector 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/policy.js CHANGED
@@ -1,590 +1,590 @@
1
- // dsh-server-connector — built-in high-risk command policy engine.
2
- //
3
- // A single pure module: it never touches the network, the file system, the clock
4
- // or DSH. `srv_policy_check` and `srv_exec` both call `check()`, so the pre-flight
5
- // verdict and the execution verdict can never disagree.
6
- //
7
- // This is containment, not a security boundary. Regexes cannot see through
8
- // `python -c "shutil.rmtree('/')"` or `base64 -d | sh`; the real boundary is the
9
- // unprivileged remote user plus a sudo allowlist. The contract here is only that
10
- // known destructive patterns are blocked or require confirmation.
11
-
12
- /**
13
- * One decision level.
14
- * @typedef {'allow' | 'confirm' | 'block'} Decision
15
- */
16
-
17
- /**
18
- * One built-in rule. Either `pattern` or `wholePattern` is present; see
19
- * {@link RULES} for which one applies when.
20
- * @typedef {object} Rule
21
- * @property {string} id - stable rule id, the key of a server's policy map.
22
- * @property {string} label - short human-readable name, used in the approval reason.
23
- * @property {Decision} defaultDecision - `block` or `confirm`; never `allow`.
24
- * @property {string} description - what the rule is protecting against.
25
- * @property {RegExp} [pattern] - matched per subcommand and against the whole line.
26
- * @property {RegExp} [wholePattern] - matched against the whole line only.
27
- */
28
-
29
- /** A rule that matched, with the decision it resolved to for this server. */
30
- /**
31
- * @typedef {object} RuleMatch
32
- * @property {string} id
33
- * @property {string} label
34
- * @property {Decision} defaultDecision
35
- * @property {string} description
36
- * @property {Decision} decision
37
- */
38
-
39
- /** The three decision levels, ordered from most to least permissive. */
40
- export const DECISIONS = Object.freeze({
41
- ALLOW: /** @type {Decision} */ ('allow'),
42
- CONFIRM: /** @type {Decision} */ ('confirm'),
43
- BLOCK: /** @type {Decision} */ ('block'),
44
- })
45
-
46
- /** Severity rank: the strictest matching rule wins. `block > confirm > allow`. */
47
- const RANK = Object.freeze({ allow: 0, confirm: 1, block: 2 })
48
-
49
- /** Characters a single subcommand pattern may consume; separators are hard stops. */
50
- const SEG = '[^\\n;|&]*'
51
-
52
- /**
53
- * Verbs and redirections that modify the file they are told to modify: the
54
- * named path may appear anywhere after them. Used by the rules that block or
55
- * confirm a *write* to a system path while leaving a read (`cat /etc/passwd`)
56
- * alone.
57
- */
58
- const WRITE_VERBS =
59
- '(?:>>?\\s*|\\btee\\b|\\brm\\b|' +
60
- `\\bsed\\b${SEG}\\s-i|\\b(?:vi|vim|nano|emacs|ed)\\b|\\btruncate\\b|\\bshred\\b)`
61
-
62
- /**
63
- * Verbs whose *last* argument is the destination, so the protected path must be
64
- * preceded by at least one argument. Without that guard `cp /etc/passwd
65
- * /root/bak` — a backup, i.e. a read — would be reported as a write.
66
- */
67
- const COPY_VERBS = '(?:\\bcp\\b|\\bmv\\b|\\binstall\\b|\\bln\\b)'
68
-
69
- /**
70
- * A "writes this path" pattern: a write verb or redirection aimed at a path
71
- * matching `pathSource`. A copy verb counts only when the path is not its
72
- * first argument.
73
- * @param {string} pathSource - Regex source for the target path.
74
- * @returns {RegExp} compiled, case-insensitive pattern.
75
- */
76
- function writePattern(pathSource) {
77
- return new RegExp(
78
- `(?:${WRITE_VERBS}${SEG}${pathSource})|(?:${COPY_VERBS}[^\\n;|&]*\\S\\s+${pathSource})`,
79
- 'i',
80
- )
81
- }
82
-
83
- /**
84
- * The built-in rule catalog (doc §4.1). Exactly 20 rules; `defaultDecision` is
85
- * always `block` or `confirm`, never `allow`. Every rule is pure data: an id, a
86
- * label and description for the approval reason and panel, the default
87
- * decision, and the regexes it is matched with.
88
- *
89
- * `pattern` is tested against each subcommand (and the whole command line, which
90
- * is harmless because the patterns stop at separators). `wholePattern` is tested
91
- * against the whole command line only: the fork bomb and `curl … | sh` contain
92
- * separators themselves, so per-subcommand matching would never see them.
93
- *
94
- * The catalog is read-only: server policy can override a decision or disable a
95
- * rule, but never add to or edit the catalog.
96
- */
97
- export const RULES = /** @type {readonly Rule[]} */ (Object.freeze([
98
- Object.freeze({
99
- id: 'rm-recursive',
100
- label: '递归删除',
101
- defaultDecision: 'block',
102
- description: 'rm 的递归删除(-r / -R / --recursive)会不可逆地删除整个目录树。',
103
- // `git rm -r` is left out: it removes tracked paths and stays recoverable
104
- // from the object database, so it is a VCS operation rather than a wipe.
105
- pattern: /(?<!git )\brm\b[^\n;|&]*(?:\s-[a-zA-Z]*r[a-zA-Z]*\b|\s--recursive\b)/i,
106
- }),
107
- Object.freeze({
108
- id: 'dd-write',
109
- label: '裸设备写入 / 格式化',
110
- defaultDecision: 'block',
111
- description: 'dd 直接写块设备或 mkfs 格式化会毁掉整块磁盘上的数据。',
112
- pattern: /\bdd\b[^\n;|&]*\bof=\s*\/dev\/|\bmkfs(?:\.[a-z0-9]+)?\b/i,
113
- }),
114
- Object.freeze({
115
- id: 'db-drop',
116
- label: '删库 / 清表',
117
- defaultDecision: 'block',
118
- description: 'DROP DATABASE / DROP TABLE / TRUNCATE 会不可逆地删除数据库或表数据。',
119
- // `truncate` alone is the unix command that zeroes a FILE (see truncate-log),
120
- // so as SQL it needs `TABLE` or a client that runs it (`psql -c "truncate t"`).
121
- pattern:
122
- /\bdrop\s+(?:database|schema|table)\b|\btruncate\s+table\b|\b(?:mysql|mariadb|psql|sqlite3|mongosh?|clickhouse-client|redis-cli)\b[^\n;|&]*\btruncate\b/i,
123
- }),
124
- Object.freeze({
125
- id: 'fork-bomb',
126
- label: 'fork 炸弹',
127
- defaultDecision: 'block',
128
- description: '递归自复制的 fork 炸弹会耗尽远端进程表,导致机器失联。',
129
- wholePattern: /:\s*\(\s*\)\s*\{[^}]*:\s*\|\s*:[^}]*\}/i,
130
- }),
131
- Object.freeze({
132
- id: 'shutdown',
133
- label: '关机',
134
- defaultDecision: 'block',
135
- description: 'shutdown / poweroff 会直接关掉远端主机。',
136
- pattern: /\b(?:shutdown|poweroff)\b/i,
137
- }),
138
- Object.freeze({
139
- id: 'reboot',
140
- label: '重启',
141
- defaultDecision: 'block',
142
- description: 'reboot 会重启远端主机,中断其上所有服务。',
143
- pattern: /\breboot\b/i,
144
- }),
145
- Object.freeze({
146
- id: 'chmod-root',
147
- label: '递归改系统路径权限',
148
- defaultDecision: 'confirm',
149
- description: '对 /etc、/usr、/bin 等系统路径递归 chmod 会破坏系统权限。',
150
- // A system path is one the distribution owns. `/home` and `/srv` are left
151
- // out on purpose: they hold user data and the default work dir of an app
152
- // (`/srv/app` in doc §3.1), so recursive chmod there is routine.
153
- pattern:
154
- /\bchmod\b[^\n;|&]*(?:\s-[a-zA-Z]*R[a-zA-Z]*\b|\s--recursive\b)[^\n;|&]*\/(?:etc|usr|bin|sbin|lib|lib32|lib64|boot|var|opt|root|proc|sys|dev)\b/i,
155
- }),
156
- Object.freeze({
157
- id: 'chown-root',
158
- label: '递归改系统路径属主',
159
- defaultDecision: 'confirm',
160
- description: '对 /etc、/usr、/var 等系统路径递归 chown 会破坏系统文件属主。',
161
- pattern:
162
- /\bchown\b[^\n;|&]*(?:\s-[a-zA-Z]*R[a-zA-Z]*\b|\s--recursive\b)[^\n;|&]*\/(?:etc|usr|bin|sbin|lib|lib32|lib64|boot|var|opt|root|proc|sys|dev)\b/i,
163
- }),
164
- Object.freeze({
165
- id: 'service-stop',
166
- label: '停用服务',
167
- defaultDecision: 'confirm',
168
- description: 'systemctl stop / disable 会停掉或取消自启服务,可能造成业务中断。',
169
- pattern: /\bsystemctl\b[^\n;|&]*\b(?:stop|disable)\b/i,
170
- }),
171
- Object.freeze({
172
- id: 'service-restart',
173
- label: '重启服务',
174
- defaultDecision: 'confirm',
175
- description: 'systemctl restart / docker restart 会短暂中断服务。',
176
- pattern: /\bsystemctl\b[^\n;|&]*\brestart\b|\bdocker\s+(?:container\s+)?restart\b/i,
177
- }),
178
- Object.freeze({
179
- id: 'docker-prune',
180
- label: '清理 / 强删容器',
181
- defaultDecision: 'confirm',
182
- description: 'docker prune / docker rm -f 会清掉镜像缓存或强删容器,可能丢数据。',
183
- pattern:
184
- /\bdocker\s+(?:system\s+)?prune\b|\bdocker\s+(?:container\s+)?rm\b[^\n;|&]*(?:\s-[a-zA-Z]*f\b|\s--force\b)/i,
185
- }),
186
- Object.freeze({
187
- id: 'curl-pipe-sh',
188
- label: 'curl 管道执行',
189
- defaultDecision: 'confirm',
190
- description: 'curl … | sh 会把远端脚本直接交给 shell 执行,内容不可预审。',
191
- wholePattern: /\bcurl\b[^\n]*\|\s*(?:sudo\s+)?(?:ba|da|z|k)?sh\b/i,
192
- }),
193
- Object.freeze({
194
- id: 'kill-all',
195
- label: '杀全部进程',
196
- defaultDecision: 'confirm',
197
- description: 'kill -9 -1 / pkill -9 会杀掉该用户能杀的所有进程。',
198
- pattern:
199
- /\bkill\b[^\n;|&]*(?:\s-9\b|\s-KILL\b|\s-s\s*(?:9|KILL)\b)[^\n;|&]*\s-1\b|\bpkill\b[^\n;|&]*(?:\s-9\b|\s-KILL\b|\s-s\s*(?:9|KILL)\b)/i,
200
- }),
201
- Object.freeze({
202
- id: 'passwd-edit',
203
- label: '改账号数据库',
204
- defaultDecision: 'block',
205
- description: '写 /etc/passwd 或 /etc/shadow 容易把账号系统改坏甚至锁死 root。',
206
- pattern: writePattern('\\/etc\\/(?:passwd|shadow)\\b'),
207
- }),
208
- Object.freeze({
209
- id: 'crontab-edit',
210
- label: '删改计划任务',
211
- defaultDecision: 'confirm',
212
- description: 'crontab -r 或写 /etc/cron* 会删除或植入定时任务。',
213
- pattern: new RegExp(
214
- `\\bcrontab\\s+-r\\b|${WRITE_VERBS}${SEG}\\/etc\\/cron`,
215
- 'i',
216
- ),
217
- }),
218
- Object.freeze({
219
- id: 'iptables-flush',
220
- label: '清空防火墙',
221
- defaultDecision: 'confirm',
222
- description: 'iptables -F 会清空全部防火墙规则,可能把机器暴露出去。',
223
- pattern: /\biptables\b[^\n;|&]*\s(?:-F|--flush)\b/i,
224
- }),
225
- Object.freeze({
226
- id: 'git-force-push',
227
- label: '强制推送',
228
- defaultDecision: 'confirm',
229
- description: 'git push --force / -f 会覆盖远端历史(--force-with-lease 是安全变体,不在此列)。',
230
- // `--force-with-lease` is the guarded form: it only overwrites a branch the
231
- // pusher has already seen, so it is deliberately not matched.
232
- pattern: /\bgit\s+push\b[^\n;|&]*(?:\s--force(?!-with-lease)\b|\s-[a-zA-Z]*f[a-zA-Z]*\b)/i,
233
- }),
234
- Object.freeze({
235
- id: 'history-clear',
236
- label: '清空命令历史',
237
- defaultDecision: 'confirm',
238
- description: 'history -c 或清空 ~/.bash_history 会抹掉审计线索。',
239
- pattern:
240
- /\bhistory\s+-c\b|(?:>>?|\b(?:rm|shred|truncate|tee)\b)[^\n;|&]*\.[a-z_]*history\b/i,
241
- }),
242
- Object.freeze({
243
- id: 'truncate-log',
244
- label: '清空日志',
245
- defaultDecision: 'confirm',
246
- description: '重定向 /var/log/、把文件截断为 0 字节(truncate 无 -s 时即截断为 0)会清空日志文件。',
247
- pattern:
248
- /(?:>>?\s*\/var\/log\/)|\btruncate\b[^\n;|&]*(?:\s-s\s*0\b|\s--size\s*=?\s*0\b)|\btruncate\s+(?!--?[a-z])/i,
249
- }),
250
- Object.freeze({
251
- id: 'sudoers-edit',
252
- label: '改 sudo 配置',
253
- defaultDecision: 'block',
254
- description: '写 /etc/sudoers* 可能直接丢失 sudo 权限或开出无密码提权。',
255
- pattern: writePattern('\\/etc\\/sudoers'),
256
- }),
257
- ]))
258
-
259
- /** Catalog indexed by rule id. */
260
- export const RULES_BY_ID = new Map(RULES.map((rule) => [rule.id, rule]))
261
-
262
- /**
263
- * Whether `value` is one of the three decisions.
264
- * @param {unknown} value
265
- * @returns {value is Decision}
266
- */
267
- function isDecision(value) {
268
- return value === 'allow' || value === 'confirm' || value === 'block'
269
- }
270
-
271
- /**
272
- * Compare two decisions by severity.
273
- * @param {Decision} a
274
- * @param {Decision} b
275
- * @returns {number} positive when `a` is stricter than `b`, 0 when equal.
276
- */
277
- export function compareDecisions(a, b) {
278
- return RANK[a] - RANK[b]
279
- }
280
-
281
- // ------------------------------------------------- custom rules (doc §4.4) --
282
-
283
- /** Longest accepted `pattern` source. Past this the rule is almost certainly a paste accident. */
284
- export const MAX_CUSTOM_PATTERN_LENGTH = 512
285
-
286
- /** Most custom rules one server may carry. Bounds both the UI and the regex sweep. */
287
- export const MAX_CUSTOM_RULES = 50
288
-
289
- /**
290
- * Shapes that invite catastrophic backtracking: a quantified group whose body is
291
- * itself quantified (`(a+)+`, `(a*)*`, `(.*)+`). Conservative on purpose — it
292
- * rejects a few harmless forms rather than admitting a ReDoS.
293
- */
294
- const NESTED_QUANTIFIER = /\(\s*[^()]*[+*]\s*\)\s*[+*]/
295
-
296
- /** One custom rule as stored on a server record (doc §3.1). */
297
- /**
298
- * @typedef {object} CustomRule
299
- * @property {string} id
300
- * @property {string} label
301
- * @property {string} pattern - regex source, compiled on read
302
- * @property {Decision} defaultDecision - `confirm` or `block`; never `allow`
303
- * @property {string} description
304
- */
305
-
306
- /**
307
- * A custom rule that failed validation. The route layer turns this into a 400.
308
- */
309
- export class RuleError extends Error {
310
- /**
311
- * @param {string} code - machine-readable reason
312
- * @param {string} message - human-readable, shown in the panel
313
- */
314
- constructor(code, message) {
315
- super(message)
316
- this.name = 'RuleError'
317
- this.code = code
318
- }
319
- }
320
-
321
- /**
322
- * Validate and compile one custom rule (doc §4.4.2). Every check runs before the
323
- * rule is allowed to touch `servers.json`, so a rule that cannot be honoured is
324
- * never stored.
325
- *
326
- * `samples` is optional: when given, at least one must match, which is the
327
- * check that catches "I wrote a regex that never fires".
328
- * @param {unknown} value - the rule as it arrived (already id-assigned)
329
- * @param {{samples?: string[], takenIds?: Iterable<string>}} [options]
330
- * @returns {CustomRule}
331
- */
332
- export function validateCustomRule(value, options = {}) {
333
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
334
- throw new RuleError('INVALID_RULE', 'a custom rule must be an object.')
335
- }
336
- const rule = /** @type {Record<string, unknown>} */ (value)
337
- const label = typeof rule.label === 'string' ? rule.label.trim() : ''
338
- if (label === '') throw new RuleError('INVALID_RULE', 'label must be a non-empty string.')
339
- if (label.length > 80) throw new RuleError('INVALID_RULE', 'label must be at most 80 characters.')
340
- const description = typeof rule.description === 'string' ? rule.description.trim() : ''
341
- if (description.length > 400) throw new RuleError('INVALID_RULE', 'description must be at most 400 characters.')
342
- if (rule.defaultDecision !== 'confirm' && rule.defaultDecision !== 'block') {
343
- // `allow` is refused outright: a rule that permits what the engine already
344
- // permits buys nothing, and it is the only way to fake a loosening.
345
- throw new RuleError('INVALID_RULE', 'defaultDecision must be "confirm" or "block".')
346
- }
347
- if (typeof rule.pattern !== 'string' || rule.pattern.trim() === '') {
348
- throw new RuleError('INVALID_RULE', 'pattern must be a non-empty regex source.')
349
- }
350
- const pattern = rule.pattern.trim()
351
- if (pattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
352
- throw new RuleError('INVALID_RULE', `pattern must be at most ${MAX_CUSTOM_PATTERN_LENGTH} characters.`)
353
- }
354
- if (NESTED_QUANTIFIER.test(pattern)) {
355
- throw new RuleError(
356
- 'INVALID_RULE',
357
- 'pattern contains a nested quantifier like (a+)+, which can backtrack catastrophically. Rewrite it without the inner repetition.',
358
- )
359
- }
360
- /** @type {RegExp} */
361
- let compiled
362
- try {
363
- compiled = new RegExp(pattern, 'i')
364
- } catch (error) {
365
- throw new RuleError('INVALID_RULE', `pattern is not a valid regular expression: ${/** @type {Error} */ (error).message}`)
366
- }
367
- // A pattern that matches the empty string matches every command, which would
368
- // block or confirm everything on that server.
369
- if (compiled.test('')) {
370
- throw new RuleError('INVALID_RULE', 'pattern matches the empty string, so it would match every command.')
371
- }
372
- const id = typeof rule.id === 'string' ? rule.id.trim() : ''
373
- if (id === '') throw new RuleError('INVALID_RULE', 'id must be a non-empty string.')
374
- const taken = new Set(options.takenIds ?? [])
375
- if (RULES_BY_ID.has(id) || taken.has(id)) {
376
- throw new RuleError('DUPLICATE_RULE', `rule id "${id}" is already in use on this server.`)
377
- }
378
- const samples = Array.isArray(options.samples) ? options.samples.filter((s) => typeof s === 'string' && s.trim() !== '') : []
379
- if (samples.length > 0 && !samples.some((sample) => compiled.test(sample))) {
380
- throw new RuleError(
381
- 'RULE_NEVER_MATCHES',
382
- 'none of the sample commands matched this pattern; it would never fire.',
383
- )
384
- }
385
- return Object.freeze({
386
- id,
387
- label,
388
- pattern,
389
- defaultDecision: /** @type {Decision} */ (rule.defaultDecision),
390
- description,
391
- })
392
- }
393
-
394
- /**
395
- * Compile a stored custom rule for matching. A record that fails to compile is
396
- * skipped rather than thrown: `servers.json` may predate a stricter validator,
397
- * and one bad rule must not take down every command on that server.
398
- * @param {CustomRule} rule
399
- * @returns {Rule|null}
400
- */
401
- function compileCustomRule(rule) {
402
- /** @type {RegExp} */
403
- let pattern
404
- try {
405
- pattern = new RegExp(rule.pattern, 'i')
406
- } catch {
407
- return null
408
- }
409
- return Object.freeze({
410
- id: rule.id,
411
- label: rule.label,
412
- defaultDecision: rule.defaultDecision,
413
- description: rule.description,
414
- pattern,
415
- custom: true,
416
- })
417
- }
418
-
419
- /**
420
- * The rule set that applies to one server: the built-in catalog, then that
421
- * server's custom rules (doc §4.2.5). Custom rules enjoy no privilege — they run
422
- * through the same match and strictest-wins logic.
423
- * @param {ReadonlyArray<CustomRule>|undefined} customRules
424
- * @returns {ReadonlyArray<Rule>}
425
- */
426
- export function rulesFor(customRules) {
427
- if (!Array.isArray(customRules) || customRules.length === 0) return RULES
428
- const extra = []
429
- for (const rule of customRules) {
430
- if (rule === null || typeof rule !== 'object') continue
431
- const compiled = compileCustomRule(/** @type {CustomRule} */ (rule))
432
- if (compiled !== null) extra.push(compiled)
433
- }
434
- return extra.length === 0 ? RULES : [...RULES, ...extra]
435
- }
436
-
437
- /**
438
- * Split a command line into the subcommands every rule must be checked against.
439
- *
440
- * Splits on `&&`, `||`, `;`, `|` and newlines (doc §4.2.1). Without this,
441
- * `echo ok && rm -rf /` would only be matched as a whole and the destructive
442
- * half could slip past a rule anchored at the start of the line.
443
- *
444
- * @param {string} command - the raw command line.
445
- * @returns {string[]} trimmed, non-empty subcommands.
446
- * @throws {TypeError} when `command` is not a string.
447
- */
448
- export function splitSubcommands(command) {
449
- if (typeof command !== 'string') throw new TypeError('command must be a string')
450
- return command
451
- .split(/&&|\|\||;|\||\r?\n/)
452
- .map((segment) => segment.trim())
453
- .filter((segment) => segment !== '')
454
- }
455
-
456
- /**
457
- * Separate the two accepted second-argument shapes into overrides plus disabled
458
- * rule ids:
459
- * - a server record (`{ policy, disabledRules, customRules }`, doc §3.1), or
460
- * - a bare per-rule override map (`{ 'rm-recursive': 'confirm' }`).
461
- *
462
- * A third argument may carry `disabledRules` alongside a bare override map.
463
- * @param {Record<string, unknown>|undefined} serverPolicy
464
- * @param {string[]|undefined} disabledRules
465
- * @returns {{overrides: Record<string, unknown>, disabled: Set<string>, customRules: CustomRule[]}}
466
- */
467
- function normalizeServerPolicy(serverPolicy, disabledRules) {
468
- const disabled = new Set(Array.isArray(disabledRules) ? disabledRules : [])
469
- if (serverPolicy === undefined || serverPolicy === null) {
470
- return { overrides: {}, disabled, customRules: [] }
471
- }
472
- if (typeof serverPolicy !== 'object') throw new TypeError('serverPolicy must be an object')
473
- if (Array.isArray(serverPolicy.disabledRules)) {
474
- for (const id of serverPolicy.disabledRules) if (typeof id === 'string') disabled.add(id)
475
- }
476
- const fields = /** @type {Record<string, unknown>} */ (serverPolicy)
477
- const customRules = Array.isArray(fields.customRules)
478
- ? /** @type {CustomRule[]} */ (fields.customRules.filter((rule) => rule !== null && typeof rule === 'object'))
479
- : []
480
- const record = 'policy' in fields || 'disabledRules' in fields
481
- const policy = fields.policy
482
- /** @type {Record<string, unknown>} */
483
- const overrides = record
484
- ? policy !== null && typeof policy === 'object'
485
- ? /** @type {Record<string, unknown>} */ (policy)
486
- : {}
487
- : fields
488
- return { overrides, disabled, customRules }
489
- }
490
-
491
- /**
492
- * Resolve one rule's effective decision under a server's overrides (doc §4.2.3).
493
- *
494
- * A server may tighten any rule, loosen a `confirm` rule to `allow`, or lower a
495
- * `block` rule to `confirm`. It may **not** lower a `block` rule straight to
496
- * `allow`: that value is coerced to `confirm`, the loosest sanctioned setting.
497
- * Unknown ids and non-decision values fall back to the rule's default.
498
- *
499
- * @param {Rule} rule
500
- * @param {Record<string, unknown>} [overrides]
501
- * @returns {Decision}
502
- */
503
- export function effectiveDecision(rule, overrides = {}) {
504
- const configured = overrides?.[rule.id]
505
- if (!isDecision(configured)) return rule.defaultDecision
506
- if (rule.defaultDecision === 'block' && configured === 'allow') return 'confirm'
507
- return configured
508
- }
509
-
510
- /**
511
- * Whether one rule matches the command line. A rule carries exactly one of
512
- * `pattern` / `wholePattern`; the other is undefined.
513
- * @param {Rule} rule
514
- * @param {string} command
515
- * @param {string[]} segments
516
- * @returns {boolean}
517
- */
518
- function ruleMatches(rule, command, segments) {
519
- if (rule.wholePattern !== undefined) return rule.wholePattern.test(command)
520
- const pattern = /** @type {RegExp} */ (rule.pattern)
521
- return pattern.test(command) || segments.some((segment) => pattern.test(segment))
522
- }
523
-
524
- /**
525
- * The policy engine (doc §4.2): a pure function from
526
- * `(command, serverPolicy) → { decision, matchedRule, … }`.
527
- *
528
- * Semantics:
529
- * 1. the command line is split into subcommands and every subcommand is checked
530
- * against every rule;
531
- * 2. the strictest matching decision wins (`block > confirm > allow`);
532
- * 3. `disabledRules` removes a rule entirely, so `srv_policy_check` and
533
- * `srv_exec` agree on what exists;
534
- * 4. no match is `allow`.
535
- *
536
- * @param {string} command - the raw command line.
537
- * @param {{policy?: Record<string, unknown>, disabledRules?: string[]}|Record<string, unknown>} [serverPolicy]
538
- * a server record (`{policy, disabledRules}`) or a bare override map.
539
- * @param {string[]} [disabledRules] - extra disabled ids when the second argument
540
- * is a bare override map.
541
- * @returns {{
542
- * decision: Decision,
543
- * matchedRule: RuleMatch | null,
544
- * ruleId: string | null,
545
- * matches: RuleMatch[],
546
- * segments: string[],
547
- * }}
548
- * @throws {TypeError} for a non-string command or a non-object policy argument.
549
- */
550
- export function check(command, serverPolicy = {}, disabledRules) {
551
- const segments = splitSubcommands(command)
552
- const { overrides, disabled, customRules } = normalizeServerPolicy(serverPolicy, disabledRules)
553
-
554
- const matches = []
555
- // Built-ins first, then this server's custom rules (doc §4.2.5): the custom
556
- // set is part of the same sweep, so strictest-wins needs no special case.
557
- for (const rule of rulesFor(customRules)) {
558
- if (disabled.has(rule.id)) continue
559
- if (!ruleMatches(rule, command, segments)) continue
560
- matches.push({
561
- id: rule.id,
562
- label: rule.label,
563
- defaultDecision: rule.defaultDecision,
564
- description: rule.description,
565
- decision: effectiveDecision(rule, overrides),
566
- })
567
- }
568
-
569
- /** @type {Decision} */
570
- let decision = 'allow'
571
- /** @type {RuleMatch | null} */
572
- let matchedRule = null
573
- for (const match of matches) {
574
- if (RANK[match.decision] > RANK[decision]) {
575
- decision = match.decision
576
- matchedRule = match
577
- }
578
- }
579
-
580
- return {
581
- decision,
582
- matchedRule,
583
- ruleId: matchedRule === null ? null : matchedRule.id,
584
- matches,
585
- segments,
586
- }
587
- }
588
-
589
- /** Alias of {@link check} for callers that prefer an explicit verb. */
590
- export const checkCommand = check
1
+ // dsh-server-connector — built-in high-risk command policy engine.
2
+ //
3
+ // A single pure module: it never touches the network, the file system, the clock
4
+ // or DSH. `srv_policy_check` and `srv_exec` both call `check()`, so the pre-flight
5
+ // verdict and the execution verdict can never disagree.
6
+ //
7
+ // This is containment, not a security boundary. Regexes cannot see through
8
+ // `python -c "shutil.rmtree('/')"` or `base64 -d | sh`; the real boundary is the
9
+ // unprivileged remote user plus a sudo allowlist. The contract here is only that
10
+ // known destructive patterns are blocked or require confirmation.
11
+
12
+ /**
13
+ * One decision level.
14
+ * @typedef {'allow' | 'confirm' | 'block'} Decision
15
+ */
16
+
17
+ /**
18
+ * One built-in rule. Either `pattern` or `wholePattern` is present; see
19
+ * {@link RULES} for which one applies when.
20
+ * @typedef {object} Rule
21
+ * @property {string} id - stable rule id, the key of a server's policy map.
22
+ * @property {string} label - short human-readable name, used in the approval reason.
23
+ * @property {Decision} defaultDecision - `block` or `confirm`; never `allow`.
24
+ * @property {string} description - what the rule is protecting against.
25
+ * @property {RegExp} [pattern] - matched per subcommand and against the whole line.
26
+ * @property {RegExp} [wholePattern] - matched against the whole line only.
27
+ */
28
+
29
+ /** A rule that matched, with the decision it resolved to for this server. */
30
+ /**
31
+ * @typedef {object} RuleMatch
32
+ * @property {string} id
33
+ * @property {string} label
34
+ * @property {Decision} defaultDecision
35
+ * @property {string} description
36
+ * @property {Decision} decision
37
+ */
38
+
39
+ /** The three decision levels, ordered from most to least permissive. */
40
+ export const DECISIONS = Object.freeze({
41
+ ALLOW: /** @type {Decision} */ ('allow'),
42
+ CONFIRM: /** @type {Decision} */ ('confirm'),
43
+ BLOCK: /** @type {Decision} */ ('block'),
44
+ })
45
+
46
+ /** Severity rank: the strictest matching rule wins. `block > confirm > allow`. */
47
+ const RANK = Object.freeze({ allow: 0, confirm: 1, block: 2 })
48
+
49
+ /** Characters a single subcommand pattern may consume; separators are hard stops. */
50
+ const SEG = '[^\\n;|&]*'
51
+
52
+ /**
53
+ * Verbs and redirections that modify the file they are told to modify: the
54
+ * named path may appear anywhere after them. Used by the rules that block or
55
+ * confirm a *write* to a system path while leaving a read (`cat /etc/passwd`)
56
+ * alone.
57
+ */
58
+ const WRITE_VERBS =
59
+ '(?:>>?\\s*|\\btee\\b|\\brm\\b|' +
60
+ `\\bsed\\b${SEG}\\s-i|\\b(?:vi|vim|nano|emacs|ed)\\b|\\btruncate\\b|\\bshred\\b)`
61
+
62
+ /**
63
+ * Verbs whose *last* argument is the destination, so the protected path must be
64
+ * preceded by at least one argument. Without that guard `cp /etc/passwd
65
+ * /root/bak` — a backup, i.e. a read — would be reported as a write.
66
+ */
67
+ const COPY_VERBS = '(?:\\bcp\\b|\\bmv\\b|\\binstall\\b|\\bln\\b)'
68
+
69
+ /**
70
+ * A "writes this path" pattern: a write verb or redirection aimed at a path
71
+ * matching `pathSource`. A copy verb counts only when the path is not its
72
+ * first argument.
73
+ * @param {string} pathSource - Regex source for the target path.
74
+ * @returns {RegExp} compiled, case-insensitive pattern.
75
+ */
76
+ function writePattern(pathSource) {
77
+ return new RegExp(
78
+ `(?:${WRITE_VERBS}${SEG}${pathSource})|(?:${COPY_VERBS}[^\\n;|&]*\\S\\s+${pathSource})`,
79
+ 'i',
80
+ )
81
+ }
82
+
83
+ /**
84
+ * The built-in rule catalog (doc §4.1). Exactly 20 rules; `defaultDecision` is
85
+ * always `block` or `confirm`, never `allow`. Every rule is pure data: an id, a
86
+ * label and description for the approval reason and panel, the default
87
+ * decision, and the regexes it is matched with.
88
+ *
89
+ * `pattern` is tested against each subcommand (and the whole command line, which
90
+ * is harmless because the patterns stop at separators). `wholePattern` is tested
91
+ * against the whole command line only: the fork bomb and `curl … | sh` contain
92
+ * separators themselves, so per-subcommand matching would never see them.
93
+ *
94
+ * The catalog is read-only: server policy can override a decision or disable a
95
+ * rule, but never add to or edit the catalog.
96
+ */
97
+ export const RULES = /** @type {readonly Rule[]} */ (Object.freeze([
98
+ Object.freeze({
99
+ id: 'rm-recursive',
100
+ label: '递归删除',
101
+ defaultDecision: 'block',
102
+ description: 'rm 的递归删除(-r / -R / --recursive)会不可逆地删除整个目录树。',
103
+ // `git rm -r` is left out: it removes tracked paths and stays recoverable
104
+ // from the object database, so it is a VCS operation rather than a wipe.
105
+ pattern: /(?<!git )\brm\b[^\n;|&]*(?:\s-[a-zA-Z]*r[a-zA-Z]*\b|\s--recursive\b)/i,
106
+ }),
107
+ Object.freeze({
108
+ id: 'dd-write',
109
+ label: '裸设备写入 / 格式化',
110
+ defaultDecision: 'block',
111
+ description: 'dd 直接写块设备或 mkfs 格式化会毁掉整块磁盘上的数据。',
112
+ pattern: /\bdd\b[^\n;|&]*\bof=\s*\/dev\/|\bmkfs(?:\.[a-z0-9]+)?\b/i,
113
+ }),
114
+ Object.freeze({
115
+ id: 'db-drop',
116
+ label: '删库 / 清表',
117
+ defaultDecision: 'block',
118
+ description: 'DROP DATABASE / DROP TABLE / TRUNCATE 会不可逆地删除数据库或表数据。',
119
+ // `truncate` alone is the unix command that zeroes a FILE (see truncate-log),
120
+ // so as SQL it needs `TABLE` or a client that runs it (`psql -c "truncate t"`).
121
+ pattern:
122
+ /\bdrop\s+(?:database|schema|table)\b|\btruncate\s+table\b|\b(?:mysql|mariadb|psql|sqlite3|mongosh?|clickhouse-client|redis-cli)\b[^\n;|&]*\btruncate\b/i,
123
+ }),
124
+ Object.freeze({
125
+ id: 'fork-bomb',
126
+ label: 'fork 炸弹',
127
+ defaultDecision: 'block',
128
+ description: '递归自复制的 fork 炸弹会耗尽远端进程表,导致机器失联。',
129
+ wholePattern: /:\s*\(\s*\)\s*\{[^}]*:\s*\|\s*:[^}]*\}/i,
130
+ }),
131
+ Object.freeze({
132
+ id: 'shutdown',
133
+ label: '关机',
134
+ defaultDecision: 'block',
135
+ description: 'shutdown / poweroff 会直接关掉远端主机。',
136
+ pattern: /\b(?:shutdown|poweroff)\b/i,
137
+ }),
138
+ Object.freeze({
139
+ id: 'reboot',
140
+ label: '重启',
141
+ defaultDecision: 'block',
142
+ description: 'reboot 会重启远端主机,中断其上所有服务。',
143
+ pattern: /\breboot\b/i,
144
+ }),
145
+ Object.freeze({
146
+ id: 'chmod-root',
147
+ label: '递归改系统路径权限',
148
+ defaultDecision: 'confirm',
149
+ description: '对 /etc、/usr、/bin 等系统路径递归 chmod 会破坏系统权限。',
150
+ // A system path is one the distribution owns. `/home` and `/srv` are left
151
+ // out on purpose: they hold user data and the default work dir of an app
152
+ // (`/srv/app` in doc §3.1), so recursive chmod there is routine.
153
+ pattern:
154
+ /\bchmod\b[^\n;|&]*(?:\s-[a-zA-Z]*R[a-zA-Z]*\b|\s--recursive\b)[^\n;|&]*\/(?:etc|usr|bin|sbin|lib|lib32|lib64|boot|var|opt|root|proc|sys|dev)\b/i,
155
+ }),
156
+ Object.freeze({
157
+ id: 'chown-root',
158
+ label: '递归改系统路径属主',
159
+ defaultDecision: 'confirm',
160
+ description: '对 /etc、/usr、/var 等系统路径递归 chown 会破坏系统文件属主。',
161
+ pattern:
162
+ /\bchown\b[^\n;|&]*(?:\s-[a-zA-Z]*R[a-zA-Z]*\b|\s--recursive\b)[^\n;|&]*\/(?:etc|usr|bin|sbin|lib|lib32|lib64|boot|var|opt|root|proc|sys|dev)\b/i,
163
+ }),
164
+ Object.freeze({
165
+ id: 'service-stop',
166
+ label: '停用服务',
167
+ defaultDecision: 'confirm',
168
+ description: 'systemctl stop / disable 会停掉或取消自启服务,可能造成业务中断。',
169
+ pattern: /\bsystemctl\b[^\n;|&]*\b(?:stop|disable)\b/i,
170
+ }),
171
+ Object.freeze({
172
+ id: 'service-restart',
173
+ label: '重启服务',
174
+ defaultDecision: 'confirm',
175
+ description: 'systemctl restart / docker restart 会短暂中断服务。',
176
+ pattern: /\bsystemctl\b[^\n;|&]*\brestart\b|\bdocker\s+(?:container\s+)?restart\b/i,
177
+ }),
178
+ Object.freeze({
179
+ id: 'docker-prune',
180
+ label: '清理 / 强删容器',
181
+ defaultDecision: 'confirm',
182
+ description: 'docker prune / docker rm -f 会清掉镜像缓存或强删容器,可能丢数据。',
183
+ pattern:
184
+ /\bdocker\s+(?:system\s+)?prune\b|\bdocker\s+(?:container\s+)?rm\b[^\n;|&]*(?:\s-[a-zA-Z]*f\b|\s--force\b)/i,
185
+ }),
186
+ Object.freeze({
187
+ id: 'curl-pipe-sh',
188
+ label: 'curl 管道执行',
189
+ defaultDecision: 'confirm',
190
+ description: 'curl … | sh 会把远端脚本直接交给 shell 执行,内容不可预审。',
191
+ wholePattern: /\bcurl\b[^\n]*\|\s*(?:sudo\s+)?(?:ba|da|z|k)?sh\b/i,
192
+ }),
193
+ Object.freeze({
194
+ id: 'kill-all',
195
+ label: '杀全部进程',
196
+ defaultDecision: 'confirm',
197
+ description: 'kill -9 -1 / pkill -9 会杀掉该用户能杀的所有进程。',
198
+ pattern:
199
+ /\bkill\b[^\n;|&]*(?:\s-9\b|\s-KILL\b|\s-s\s*(?:9|KILL)\b)[^\n;|&]*\s-1\b|\bpkill\b[^\n;|&]*(?:\s-9\b|\s-KILL\b|\s-s\s*(?:9|KILL)\b)/i,
200
+ }),
201
+ Object.freeze({
202
+ id: 'passwd-edit',
203
+ label: '改账号数据库',
204
+ defaultDecision: 'block',
205
+ description: '写 /etc/passwd 或 /etc/shadow 容易把账号系统改坏甚至锁死 root。',
206
+ pattern: writePattern('\\/etc\\/(?:passwd|shadow)\\b'),
207
+ }),
208
+ Object.freeze({
209
+ id: 'crontab-edit',
210
+ label: '删改计划任务',
211
+ defaultDecision: 'confirm',
212
+ description: 'crontab -r 或写 /etc/cron* 会删除或植入定时任务。',
213
+ pattern: new RegExp(
214
+ `\\bcrontab\\s+-r\\b|${WRITE_VERBS}${SEG}\\/etc\\/cron`,
215
+ 'i',
216
+ ),
217
+ }),
218
+ Object.freeze({
219
+ id: 'iptables-flush',
220
+ label: '清空防火墙',
221
+ defaultDecision: 'confirm',
222
+ description: 'iptables -F 会清空全部防火墙规则,可能把机器暴露出去。',
223
+ pattern: /\biptables\b[^\n;|&]*\s(?:-F|--flush)\b/i,
224
+ }),
225
+ Object.freeze({
226
+ id: 'git-force-push',
227
+ label: '强制推送',
228
+ defaultDecision: 'confirm',
229
+ description: 'git push --force / -f 会覆盖远端历史(--force-with-lease 是安全变体,不在此列)。',
230
+ // `--force-with-lease` is the guarded form: it only overwrites a branch the
231
+ // pusher has already seen, so it is deliberately not matched.
232
+ pattern: /\bgit\s+push\b[^\n;|&]*(?:\s--force(?!-with-lease)\b|\s-[a-zA-Z]*f[a-zA-Z]*\b)/i,
233
+ }),
234
+ Object.freeze({
235
+ id: 'history-clear',
236
+ label: '清空命令历史',
237
+ defaultDecision: 'confirm',
238
+ description: 'history -c 或清空 ~/.bash_history 会抹掉审计线索。',
239
+ pattern:
240
+ /\bhistory\s+-c\b|(?:>>?|\b(?:rm|shred|truncate|tee)\b)[^\n;|&]*\.[a-z_]*history\b/i,
241
+ }),
242
+ Object.freeze({
243
+ id: 'truncate-log',
244
+ label: '清空日志',
245
+ defaultDecision: 'confirm',
246
+ description: '重定向 /var/log/、把文件截断为 0 字节(truncate 无 -s 时即截断为 0)会清空日志文件。',
247
+ pattern:
248
+ /(?:>>?\s*\/var\/log\/)|\btruncate\b[^\n;|&]*(?:\s-s\s*0\b|\s--size\s*=?\s*0\b)|\btruncate\s+(?!--?[a-z])/i,
249
+ }),
250
+ Object.freeze({
251
+ id: 'sudoers-edit',
252
+ label: '改 sudo 配置',
253
+ defaultDecision: 'block',
254
+ description: '写 /etc/sudoers* 可能直接丢失 sudo 权限或开出无密码提权。',
255
+ pattern: writePattern('\\/etc\\/sudoers'),
256
+ }),
257
+ ]))
258
+
259
+ /** Catalog indexed by rule id. */
260
+ export const RULES_BY_ID = new Map(RULES.map((rule) => [rule.id, rule]))
261
+
262
+ /**
263
+ * Whether `value` is one of the three decisions.
264
+ * @param {unknown} value
265
+ * @returns {value is Decision}
266
+ */
267
+ function isDecision(value) {
268
+ return value === 'allow' || value === 'confirm' || value === 'block'
269
+ }
270
+
271
+ /**
272
+ * Compare two decisions by severity.
273
+ * @param {Decision} a
274
+ * @param {Decision} b
275
+ * @returns {number} positive when `a` is stricter than `b`, 0 when equal.
276
+ */
277
+ export function compareDecisions(a, b) {
278
+ return RANK[a] - RANK[b]
279
+ }
280
+
281
+ // ------------------------------------------------- custom rules (doc §4.4) --
282
+
283
+ /** Longest accepted `pattern` source. Past this the rule is almost certainly a paste accident. */
284
+ export const MAX_CUSTOM_PATTERN_LENGTH = 512
285
+
286
+ /** Most custom rules one server may carry. Bounds both the UI and the regex sweep. */
287
+ export const MAX_CUSTOM_RULES = 50
288
+
289
+ /**
290
+ * Shapes that invite catastrophic backtracking: a quantified group whose body is
291
+ * itself quantified (`(a+)+`, `(a*)*`, `(.*)+`). Conservative on purpose — it
292
+ * rejects a few harmless forms rather than admitting a ReDoS.
293
+ */
294
+ const NESTED_QUANTIFIER = /\(\s*[^()]*[+*]\s*\)\s*[+*]/
295
+
296
+ /** One custom rule as stored on a server record (doc §3.1). */
297
+ /**
298
+ * @typedef {object} CustomRule
299
+ * @property {string} id
300
+ * @property {string} label
301
+ * @property {string} pattern - regex source, compiled on read
302
+ * @property {Decision} defaultDecision - `confirm` or `block`; never `allow`
303
+ * @property {string} description
304
+ */
305
+
306
+ /**
307
+ * A custom rule that failed validation. The route layer turns this into a 400.
308
+ */
309
+ export class RuleError extends Error {
310
+ /**
311
+ * @param {string} code - machine-readable reason
312
+ * @param {string} message - human-readable, shown in the panel
313
+ */
314
+ constructor(code, message) {
315
+ super(message)
316
+ this.name = 'RuleError'
317
+ this.code = code
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Validate and compile one custom rule (doc §4.4.2). Every check runs before the
323
+ * rule is allowed to touch `servers.json`, so a rule that cannot be honoured is
324
+ * never stored.
325
+ *
326
+ * `samples` is optional: when given, at least one must match, which is the
327
+ * check that catches "I wrote a regex that never fires".
328
+ * @param {unknown} value - the rule as it arrived (already id-assigned)
329
+ * @param {{samples?: string[], takenIds?: Iterable<string>}} [options]
330
+ * @returns {CustomRule}
331
+ */
332
+ export function validateCustomRule(value, options = {}) {
333
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
334
+ throw new RuleError('INVALID_RULE', 'a custom rule must be an object.')
335
+ }
336
+ const rule = /** @type {Record<string, unknown>} */ (value)
337
+ const label = typeof rule.label === 'string' ? rule.label.trim() : ''
338
+ if (label === '') throw new RuleError('INVALID_RULE', 'label must be a non-empty string.')
339
+ if (label.length > 80) throw new RuleError('INVALID_RULE', 'label must be at most 80 characters.')
340
+ const description = typeof rule.description === 'string' ? rule.description.trim() : ''
341
+ if (description.length > 400) throw new RuleError('INVALID_RULE', 'description must be at most 400 characters.')
342
+ if (rule.defaultDecision !== 'confirm' && rule.defaultDecision !== 'block') {
343
+ // `allow` is refused outright: a rule that permits what the engine already
344
+ // permits buys nothing, and it is the only way to fake a loosening.
345
+ throw new RuleError('INVALID_RULE', 'defaultDecision must be "confirm" or "block".')
346
+ }
347
+ if (typeof rule.pattern !== 'string' || rule.pattern.trim() === '') {
348
+ throw new RuleError('INVALID_RULE', 'pattern must be a non-empty regex source.')
349
+ }
350
+ const pattern = rule.pattern.trim()
351
+ if (pattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
352
+ throw new RuleError('INVALID_RULE', `pattern must be at most ${MAX_CUSTOM_PATTERN_LENGTH} characters.`)
353
+ }
354
+ if (NESTED_QUANTIFIER.test(pattern)) {
355
+ throw new RuleError(
356
+ 'INVALID_RULE',
357
+ 'pattern contains a nested quantifier like (a+)+, which can backtrack catastrophically. Rewrite it without the inner repetition.',
358
+ )
359
+ }
360
+ /** @type {RegExp} */
361
+ let compiled
362
+ try {
363
+ compiled = new RegExp(pattern, 'i')
364
+ } catch (error) {
365
+ throw new RuleError('INVALID_RULE', `pattern is not a valid regular expression: ${/** @type {Error} */ (error).message}`)
366
+ }
367
+ // A pattern that matches the empty string matches every command, which would
368
+ // block or confirm everything on that server.
369
+ if (compiled.test('')) {
370
+ throw new RuleError('INVALID_RULE', 'pattern matches the empty string, so it would match every command.')
371
+ }
372
+ const id = typeof rule.id === 'string' ? rule.id.trim() : ''
373
+ if (id === '') throw new RuleError('INVALID_RULE', 'id must be a non-empty string.')
374
+ const taken = new Set(options.takenIds ?? [])
375
+ if (RULES_BY_ID.has(id) || taken.has(id)) {
376
+ throw new RuleError('DUPLICATE_RULE', `rule id "${id}" is already in use on this server.`)
377
+ }
378
+ const samples = Array.isArray(options.samples) ? options.samples.filter((s) => typeof s === 'string' && s.trim() !== '') : []
379
+ if (samples.length > 0 && !samples.some((sample) => compiled.test(sample))) {
380
+ throw new RuleError(
381
+ 'RULE_NEVER_MATCHES',
382
+ 'none of the sample commands matched this pattern; it would never fire.',
383
+ )
384
+ }
385
+ return Object.freeze({
386
+ id,
387
+ label,
388
+ pattern,
389
+ defaultDecision: /** @type {Decision} */ (rule.defaultDecision),
390
+ description,
391
+ })
392
+ }
393
+
394
+ /**
395
+ * Compile a stored custom rule for matching. A record that fails to compile is
396
+ * skipped rather than thrown: `servers.json` may predate a stricter validator,
397
+ * and one bad rule must not take down every command on that server.
398
+ * @param {CustomRule} rule
399
+ * @returns {Rule|null}
400
+ */
401
+ function compileCustomRule(rule) {
402
+ /** @type {RegExp} */
403
+ let pattern
404
+ try {
405
+ pattern = new RegExp(rule.pattern, 'i')
406
+ } catch {
407
+ return null
408
+ }
409
+ return Object.freeze({
410
+ id: rule.id,
411
+ label: rule.label,
412
+ defaultDecision: rule.defaultDecision,
413
+ description: rule.description,
414
+ pattern,
415
+ custom: true,
416
+ })
417
+ }
418
+
419
+ /**
420
+ * The rule set that applies to one server: the built-in catalog, then that
421
+ * server's custom rules (doc §4.2.5). Custom rules enjoy no privilege — they run
422
+ * through the same match and strictest-wins logic.
423
+ * @param {ReadonlyArray<CustomRule>|undefined} customRules
424
+ * @returns {ReadonlyArray<Rule>}
425
+ */
426
+ export function rulesFor(customRules) {
427
+ if (!Array.isArray(customRules) || customRules.length === 0) return RULES
428
+ const extra = []
429
+ for (const rule of customRules) {
430
+ if (rule === null || typeof rule !== 'object') continue
431
+ const compiled = compileCustomRule(/** @type {CustomRule} */ (rule))
432
+ if (compiled !== null) extra.push(compiled)
433
+ }
434
+ return extra.length === 0 ? RULES : [...RULES, ...extra]
435
+ }
436
+
437
+ /**
438
+ * Split a command line into the subcommands every rule must be checked against.
439
+ *
440
+ * Splits on `&&`, `||`, `;`, `|` and newlines (doc §4.2.1). Without this,
441
+ * `echo ok && rm -rf /` would only be matched as a whole and the destructive
442
+ * half could slip past a rule anchored at the start of the line.
443
+ *
444
+ * @param {string} command - the raw command line.
445
+ * @returns {string[]} trimmed, non-empty subcommands.
446
+ * @throws {TypeError} when `command` is not a string.
447
+ */
448
+ export function splitSubcommands(command) {
449
+ if (typeof command !== 'string') throw new TypeError('command must be a string')
450
+ return command
451
+ .split(/&&|\|\||;|\||\r?\n/)
452
+ .map((segment) => segment.trim())
453
+ .filter((segment) => segment !== '')
454
+ }
455
+
456
+ /**
457
+ * Separate the two accepted second-argument shapes into overrides plus disabled
458
+ * rule ids:
459
+ * - a server record (`{ policy, disabledRules, customRules }`, doc §3.1), or
460
+ * - a bare per-rule override map (`{ 'rm-recursive': 'confirm' }`).
461
+ *
462
+ * A third argument may carry `disabledRules` alongside a bare override map.
463
+ * @param {Record<string, unknown>|undefined} serverPolicy
464
+ * @param {string[]|undefined} disabledRules
465
+ * @returns {{overrides: Record<string, unknown>, disabled: Set<string>, customRules: CustomRule[]}}
466
+ */
467
+ function normalizeServerPolicy(serverPolicy, disabledRules) {
468
+ const disabled = new Set(Array.isArray(disabledRules) ? disabledRules : [])
469
+ if (serverPolicy === undefined || serverPolicy === null) {
470
+ return { overrides: {}, disabled, customRules: [] }
471
+ }
472
+ if (typeof serverPolicy !== 'object') throw new TypeError('serverPolicy must be an object')
473
+ if (Array.isArray(serverPolicy.disabledRules)) {
474
+ for (const id of serverPolicy.disabledRules) if (typeof id === 'string') disabled.add(id)
475
+ }
476
+ const fields = /** @type {Record<string, unknown>} */ (serverPolicy)
477
+ const customRules = Array.isArray(fields.customRules)
478
+ ? /** @type {CustomRule[]} */ (fields.customRules.filter((rule) => rule !== null && typeof rule === 'object'))
479
+ : []
480
+ const record = 'policy' in fields || 'disabledRules' in fields
481
+ const policy = fields.policy
482
+ /** @type {Record<string, unknown>} */
483
+ const overrides = record
484
+ ? policy !== null && typeof policy === 'object'
485
+ ? /** @type {Record<string, unknown>} */ (policy)
486
+ : {}
487
+ : fields
488
+ return { overrides, disabled, customRules }
489
+ }
490
+
491
+ /**
492
+ * Resolve one rule's effective decision under a server's overrides (doc §4.2.3).
493
+ *
494
+ * A server may tighten any rule, loosen a `confirm` rule to `allow`, or lower a
495
+ * `block` rule to `confirm`. It may **not** lower a `block` rule straight to
496
+ * `allow`: that value is coerced to `confirm`, the loosest sanctioned setting.
497
+ * Unknown ids and non-decision values fall back to the rule's default.
498
+ *
499
+ * @param {Rule} rule
500
+ * @param {Record<string, unknown>} [overrides]
501
+ * @returns {Decision}
502
+ */
503
+ export function effectiveDecision(rule, overrides = {}) {
504
+ const configured = overrides?.[rule.id]
505
+ if (!isDecision(configured)) return rule.defaultDecision
506
+ if (rule.defaultDecision === 'block' && configured === 'allow') return 'confirm'
507
+ return configured
508
+ }
509
+
510
+ /**
511
+ * Whether one rule matches the command line. A rule carries exactly one of
512
+ * `pattern` / `wholePattern`; the other is undefined.
513
+ * @param {Rule} rule
514
+ * @param {string} command
515
+ * @param {string[]} segments
516
+ * @returns {boolean}
517
+ */
518
+ function ruleMatches(rule, command, segments) {
519
+ if (rule.wholePattern !== undefined) return rule.wholePattern.test(command)
520
+ const pattern = /** @type {RegExp} */ (rule.pattern)
521
+ return pattern.test(command) || segments.some((segment) => pattern.test(segment))
522
+ }
523
+
524
+ /**
525
+ * The policy engine (doc §4.2): a pure function from
526
+ * `(command, serverPolicy) → { decision, matchedRule, … }`.
527
+ *
528
+ * Semantics:
529
+ * 1. the command line is split into subcommands and every subcommand is checked
530
+ * against every rule;
531
+ * 2. the strictest matching decision wins (`block > confirm > allow`);
532
+ * 3. `disabledRules` removes a rule entirely, so `srv_policy_check` and
533
+ * `srv_exec` agree on what exists;
534
+ * 4. no match is `allow`.
535
+ *
536
+ * @param {string} command - the raw command line.
537
+ * @param {{policy?: Record<string, unknown>, disabledRules?: string[]}|Record<string, unknown>} [serverPolicy]
538
+ * a server record (`{policy, disabledRules}`) or a bare override map.
539
+ * @param {string[]} [disabledRules] - extra disabled ids when the second argument
540
+ * is a bare override map.
541
+ * @returns {{
542
+ * decision: Decision,
543
+ * matchedRule: RuleMatch | null,
544
+ * ruleId: string | null,
545
+ * matches: RuleMatch[],
546
+ * segments: string[],
547
+ * }}
548
+ * @throws {TypeError} for a non-string command or a non-object policy argument.
549
+ */
550
+ export function check(command, serverPolicy = {}, disabledRules) {
551
+ const segments = splitSubcommands(command)
552
+ const { overrides, disabled, customRules } = normalizeServerPolicy(serverPolicy, disabledRules)
553
+
554
+ const matches = []
555
+ // Built-ins first, then this server's custom rules (doc §4.2.5): the custom
556
+ // set is part of the same sweep, so strictest-wins needs no special case.
557
+ for (const rule of rulesFor(customRules)) {
558
+ if (disabled.has(rule.id)) continue
559
+ if (!ruleMatches(rule, command, segments)) continue
560
+ matches.push({
561
+ id: rule.id,
562
+ label: rule.label,
563
+ defaultDecision: rule.defaultDecision,
564
+ description: rule.description,
565
+ decision: effectiveDecision(rule, overrides),
566
+ })
567
+ }
568
+
569
+ /** @type {Decision} */
570
+ let decision = 'allow'
571
+ /** @type {RuleMatch | null} */
572
+ let matchedRule = null
573
+ for (const match of matches) {
574
+ if (RANK[match.decision] > RANK[decision]) {
575
+ decision = match.decision
576
+ matchedRule = match
577
+ }
578
+ }
579
+
580
+ return {
581
+ decision,
582
+ matchedRule,
583
+ ruleId: matchedRule === null ? null : matchedRule.id,
584
+ matches,
585
+ segments,
586
+ }
587
+ }
588
+
589
+ /** Alias of {@link check} for callers that prefer an explicit verb. */
590
+ export const checkCommand = check