dsh-server-connector 0.1.0 → 0.2.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
@@ -278,6 +278,162 @@ export function compareDecisions(a, b) {
278
278
  return RANK[a] - RANK[b]
279
279
  }
280
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
+
281
437
  /**
282
438
  * Split a command line into the subcommands every rule must be checked against.
283
439
  *
@@ -300,24 +456,27 @@ export function splitSubcommands(command) {
300
456
  /**
301
457
  * Separate the two accepted second-argument shapes into overrides plus disabled
302
458
  * rule ids:
303
- * - a server record (`{ policy, disabledRules }`, doc §3.1), or
459
+ * - a server record (`{ policy, disabledRules, customRules }`, doc §3.1), or
304
460
  * - a bare per-rule override map (`{ 'rm-recursive': 'confirm' }`).
305
461
  *
306
462
  * A third argument may carry `disabledRules` alongside a bare override map.
307
463
  * @param {Record<string, unknown>|undefined} serverPolicy
308
464
  * @param {string[]|undefined} disabledRules
309
- * @returns {{overrides: Record<string, unknown>, disabled: Set<string>}}
465
+ * @returns {{overrides: Record<string, unknown>, disabled: Set<string>, customRules: CustomRule[]}}
310
466
  */
311
467
  function normalizeServerPolicy(serverPolicy, disabledRules) {
312
468
  const disabled = new Set(Array.isArray(disabledRules) ? disabledRules : [])
313
469
  if (serverPolicy === undefined || serverPolicy === null) {
314
- return { overrides: {}, disabled }
470
+ return { overrides: {}, disabled, customRules: [] }
315
471
  }
316
472
  if (typeof serverPolicy !== 'object') throw new TypeError('serverPolicy must be an object')
317
473
  if (Array.isArray(serverPolicy.disabledRules)) {
318
474
  for (const id of serverPolicy.disabledRules) if (typeof id === 'string') disabled.add(id)
319
475
  }
320
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
+ : []
321
480
  const record = 'policy' in fields || 'disabledRules' in fields
322
481
  const policy = fields.policy
323
482
  /** @type {Record<string, unknown>} */
@@ -326,7 +485,7 @@ function normalizeServerPolicy(serverPolicy, disabledRules) {
326
485
  ? /** @type {Record<string, unknown>} */ (policy)
327
486
  : {}
328
487
  : fields
329
- return { overrides, disabled }
488
+ return { overrides, disabled, customRules }
330
489
  }
331
490
 
332
491
  /**
@@ -390,10 +549,12 @@ function ruleMatches(rule, command, segments) {
390
549
  */
391
550
  export function check(command, serverPolicy = {}, disabledRules) {
392
551
  const segments = splitSubcommands(command)
393
- const { overrides, disabled } = normalizeServerPolicy(serverPolicy, disabledRules)
552
+ const { overrides, disabled, customRules } = normalizeServerPolicy(serverPolicy, disabledRules)
394
553
 
395
554
  const matches = []
396
- for (const rule of RULES) {
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)) {
397
558
  if (disabled.has(rule.id)) continue
398
559
  if (!ruleMatches(rule, command, segments)) continue
399
560
  matches.push({
package/lib/registry.js CHANGED
@@ -21,6 +21,8 @@ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
21
21
  import { homedir } from 'node:os'
22
22
  import { dirname, join, resolve } from 'node:path'
23
23
 
24
+ import { MAX_CUSTOM_RULES, RuleError, validateCustomRule } from './policy.js'
25
+
24
26
  /** Schema version written to, and required from, `servers.json`. */
25
27
  export const REGISTRY_VERSION = 1
26
28
 
@@ -57,8 +59,10 @@ export const DECISION_VALUES = Object.freeze(['allow', 'confirm', 'block'])
57
59
  * @property {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}} auth
58
60
  * @property {'accept-new'|'verify'|'off'} hostKeyMode
59
61
  * @property {string|null} defaultWorkDir
62
+ * @property {boolean} enabled
60
63
  * @property {Record<string, 'allow'|'confirm'|'block'>} policy
61
64
  * @property {string[]} disabledRules
65
+ * @property {import('./policy.js').CustomRule[]} customRules
62
66
  * @property {string} createdAt
63
67
  * @property {string|null} lastConnectedAt
64
68
  */
@@ -133,9 +137,11 @@ const CREATE_FIELDS = new Set([
133
137
  'username',
134
138
  'auth',
135
139
  'hostKeyMode',
140
+ 'enabled',
136
141
  'defaultWorkDir',
137
142
  'policy',
138
143
  'disabledRules',
144
+ 'customRules',
139
145
  ])
140
146
 
141
147
  /** Fields an update() call accepts; `id` and `createdAt` are immutable. */
@@ -286,6 +292,22 @@ function normalizeWorkDir(value, errorCode) {
286
292
  return trimmed === '' ? null : trimmed
287
293
  }
288
294
 
295
+ /**
296
+ * The enable switch (doc §3.1, enable-switch spec).
297
+ *
298
+ * Absent means enabled: files written before the switch existed carry no
299
+ * `enabled` key, and silently disabling every server on load would be a
300
+ * destructive surprise.
301
+ * @param {unknown} value
302
+ * @param {RegistryErrorCode} errorCode
303
+ * @returns {boolean}
304
+ */
305
+ function normalizeEnabled(value, errorCode) {
306
+ if (value === undefined || value === null) return true
307
+ if (typeof value !== 'boolean') throw new RegistryError(errorCode, 'enabled must be a boolean.')
308
+ return value
309
+ }
310
+
289
311
  /**
290
312
  * @param {unknown} value
291
313
  * @param {string} field
@@ -387,6 +409,41 @@ function normalizeDisabledRules(value, errorCode) {
387
409
  return ids
388
410
  }
389
411
 
412
+ /**
413
+ * Validate the custom-rule list of one record (doc §4.4.2).
414
+ *
415
+ * Ids are unique per server and must not collide with a built-in id. The
416
+ * decision may only be `confirm` or `block` — a rule that permits what the
417
+ * engine already permits buys nothing.
418
+ * @param {unknown} value
419
+ * @param {RegistryErrorCode} errorCode
420
+ * @returns {import('./policy.js').CustomRule[]}
421
+ */
422
+ function normalizeCustomRules(value, errorCode) {
423
+ if (value === undefined) return []
424
+ if (!Array.isArray(value)) throw new RegistryError(errorCode, 'customRules must be an array of rules.')
425
+ if (value.length > MAX_CUSTOM_RULES) {
426
+ throw new RegistryError(errorCode, `customRules may hold at most ${MAX_CUSTOM_RULES} rules.`)
427
+ }
428
+ /** @type {import('./policy.js').CustomRule[]} */
429
+ const rules = []
430
+ /** @type {string[]} */
431
+ const seen = []
432
+ for (const item of value) {
433
+ /** @type {import('./policy.js').CustomRule} */
434
+ let rule
435
+ try {
436
+ rule = validateCustomRule(item, { takenIds: seen })
437
+ } catch (error) {
438
+ if (error instanceof RuleError) throw new RegistryError(errorCode, `customRules: ${error.message}`)
439
+ throw error
440
+ }
441
+ seen.push(rule.id)
442
+ rules.push(rule)
443
+ }
444
+ return rules
445
+ }
446
+
390
447
  /**
391
448
  * Validate and canonicalise one complete record. Create, update and load all
392
449
  * go through this function, so a stored record and a freshly created one have
@@ -418,8 +475,10 @@ function normalizeRecord(source, options) {
418
475
  auth: normalizeAuth(source.auth, id, errorCode),
419
476
  hostKeyMode: normalizeHostKeyMode(source.hostKeyMode, errorCode),
420
477
  defaultWorkDir: normalizeWorkDir(source.defaultWorkDir, errorCode),
478
+ enabled: normalizeEnabled(source.enabled, errorCode),
421
479
  policy: normalizePolicy(source.policy, errorCode),
422
480
  disabledRules: normalizeDisabledRules(source.disabledRules, errorCode),
481
+ customRules: normalizeCustomRules(source.customRules, errorCode),
423
482
  createdAt,
424
483
  lastConnectedAt: normalizeOptionalTimestamp(source.lastConnectedAt, 'lastConnectedAt', errorCode),
425
484
  }