dsh-server-connector 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +574 -456
- package/lib/audit.js +14 -3
- package/lib/client.js +1716 -1376
- package/lib/policy.js +167 -6
- package/lib/registry.js +182 -7
- package/lib/routes.js +1527 -1272
- package/lib/tools.js +42 -10
- package/package.json +3 -2
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
|
-
|
|
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
|
|
@@ -314,13 +336,92 @@ function normalizeOptionalTimestamp(value, field, errorCode) {
|
|
|
314
336
|
return normalizeTimestamp(value, field, errorCode)
|
|
315
337
|
}
|
|
316
338
|
|
|
339
|
+
/**
|
|
340
|
+
* The grammar every credential ref must satisfy: a POSIX shell identifier.
|
|
341
|
+
*
|
|
342
|
+
* This is @deepseek-ai/dsh-credentials' own contract (`REF_PATTERN`), not an
|
|
343
|
+
* invention of this plugin. Keeping a local copy is deliberate: a ref this
|
|
344
|
+
* plugin persists is later parsed by that package, and a mismatch is not a
|
|
345
|
+
* local error — it prevents `credentials` from activating, which fails the whole
|
|
346
|
+
* Host startup.
|
|
347
|
+
*/
|
|
348
|
+
const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The ref this plugin derives for a server's password (doc §6.3).
|
|
352
|
+
*
|
|
353
|
+
* `server-connector:<id>:password` was the old shape and it was illegal: the
|
|
354
|
+
* colon and the dash fall outside the grammar above, so persisting it broke the
|
|
355
|
+
* next startup. The id is folded into a single identifier instead
|
|
356
|
+
* (`DSH_SERVER_CONNECTOR_SRV_01_PASSWORD`).
|
|
357
|
+
* @param {string} serverId - e.g. `srv-01`
|
|
358
|
+
* @returns {string}
|
|
359
|
+
*/
|
|
360
|
+
function deriveCredentialRef(serverId) {
|
|
361
|
+
const normalised = String(serverId)
|
|
362
|
+
.toUpperCase()
|
|
363
|
+
.replace(/[^A-Z0-9]+/g, '_')
|
|
364
|
+
.replace(/^_+|_+$/g, '')
|
|
365
|
+
const safe = normalised === '' ? 'SERVER' : normalised
|
|
366
|
+
return `DSH_SERVER_CONNECTOR_${safe}_PASSWORD`
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Validate a caller-supplied credential ref against the same grammar.
|
|
371
|
+
*
|
|
372
|
+
* A caller may pass its own ref (an environment-variable name, typically), but
|
|
373
|
+
* an illegal one must be refused here rather than stored: storing it would
|
|
374
|
+
* break the next startup instead of this request.
|
|
375
|
+
* @param {unknown} value
|
|
376
|
+
* @param {RegistryErrorCode} errorCode
|
|
377
|
+
* @returns {string}
|
|
378
|
+
*/
|
|
379
|
+
function assertCredentialRef(value, errorCode) {
|
|
380
|
+
const ref = requiredString(value, 'auth.credentialRef', errorCode)
|
|
381
|
+
if (!CREDENTIAL_REF_PATTERN.test(ref)) {
|
|
382
|
+
throw new RegistryError(
|
|
383
|
+
errorCode,
|
|
384
|
+
`auth.credentialRef ${JSON.stringify(ref)} must be a POSIX identifier ` +
|
|
385
|
+
`(letters, digits and underscores, not starting with a digit), because DSH credentials rejects ` +
|
|
386
|
+
`any other ref and the rejection fails the whole startup.`,
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
return ref
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Read-time recovery for a ref an older build persisted (doc §6.3).
|
|
394
|
+
*
|
|
395
|
+
* Before 0.2.1 this plugin derived `server-connector:<id>:password`, which is
|
|
396
|
+
* outside the grammar. Those records exist on disk in the wild, and refusing
|
|
397
|
+
* them at load would leave the plugin unable to start — trading a Host crash for
|
|
398
|
+
* a plugin that never loads. Instead the illegal legacy shape is mapped onto the
|
|
399
|
+
* derived one, so the record keeps working and the next write persists the fixed
|
|
400
|
+
* ref.
|
|
401
|
+
*
|
|
402
|
+
* Only the exact legacy shape is migrated. Any other illegal ref is still
|
|
403
|
+
* refused: silently rewriting a ref this plugin never wrote could point at a
|
|
404
|
+
* different secret.
|
|
405
|
+
* @param {unknown} value
|
|
406
|
+
* @param {string} serverId
|
|
407
|
+
* @param {RegistryErrorCode} errorCode
|
|
408
|
+
* @returns {string}
|
|
409
|
+
*/
|
|
410
|
+
function migrateCredentialRef(value, serverId, errorCode) {
|
|
411
|
+
const ref = requiredString(value, 'auth.credentialRef', errorCode)
|
|
412
|
+
if (CREDENTIAL_REF_PATTERN.test(ref)) return ref
|
|
413
|
+
if (ref === `server-connector:${serverId}:password`) return deriveCredentialRef(serverId)
|
|
414
|
+
return assertCredentialRef(ref, errorCode)
|
|
415
|
+
}
|
|
416
|
+
|
|
317
417
|
/**
|
|
318
418
|
* @param {unknown} value
|
|
319
419
|
* @param {string} serverId
|
|
320
420
|
* @param {RegistryErrorCode} errorCode
|
|
421
|
+
* @param {{migrate?: boolean}} [options] - `migrate` is set only by the load path.
|
|
321
422
|
* @returns {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}}
|
|
322
423
|
*/
|
|
323
|
-
function normalizeAuth(value, serverId, errorCode) {
|
|
424
|
+
function normalizeAuth(value, serverId, errorCode, options = {}) {
|
|
324
425
|
if (value === undefined) return { kind: 'agent' }
|
|
325
426
|
if (!isPlainObject(value)) throw new RegistryError(errorCode, 'auth must be an object.')
|
|
326
427
|
const kind = value.kind
|
|
@@ -330,12 +431,20 @@ function normalizeAuth(value, serverId, errorCode) {
|
|
|
330
431
|
}
|
|
331
432
|
if (kind === 'password') {
|
|
332
433
|
assertKnownFields(value, AUTH_FIELDS.password, errorCode, 'auth')
|
|
333
|
-
// Doc §6.3: the record keeps a reference, never the password itself.
|
|
334
|
-
//
|
|
434
|
+
// Doc §6.3: the record keeps a reference, never the password itself.
|
|
435
|
+
//
|
|
436
|
+
// The ref MUST be a POSIX shell identifier: @deepseek-ai/dsh-credentials
|
|
437
|
+
// validates every stored ref with /^[A-Za-z_][A-Za-z0-9_]*$/ while loading
|
|
438
|
+
// credentials.yaml, and a ref outside that grammar throws during
|
|
439
|
+
// `credentials` activation. Because `credentials` is a required plugin, that
|
|
440
|
+
// takes the entire Host down at startup — a persisted bad ref bricks DSH
|
|
441
|
+
// until the file is edited by hand. Derive and validate accordingly.
|
|
335
442
|
const credentialRef =
|
|
336
443
|
value.credentialRef === undefined
|
|
337
|
-
?
|
|
338
|
-
:
|
|
444
|
+
? deriveCredentialRef(serverId)
|
|
445
|
+
: options.migrate === true
|
|
446
|
+
? migrateCredentialRef(value.credentialRef, serverId, errorCode)
|
|
447
|
+
: assertCredentialRef(value.credentialRef, errorCode)
|
|
339
448
|
return { kind: 'password', credentialRef }
|
|
340
449
|
}
|
|
341
450
|
if (kind === 'agent') {
|
|
@@ -387,6 +496,41 @@ function normalizeDisabledRules(value, errorCode) {
|
|
|
387
496
|
return ids
|
|
388
497
|
}
|
|
389
498
|
|
|
499
|
+
/**
|
|
500
|
+
* Validate the custom-rule list of one record (doc §4.4.2).
|
|
501
|
+
*
|
|
502
|
+
* Ids are unique per server and must not collide with a built-in id. The
|
|
503
|
+
* decision may only be `confirm` or `block` — a rule that permits what the
|
|
504
|
+
* engine already permits buys nothing.
|
|
505
|
+
* @param {unknown} value
|
|
506
|
+
* @param {RegistryErrorCode} errorCode
|
|
507
|
+
* @returns {import('./policy.js').CustomRule[]}
|
|
508
|
+
*/
|
|
509
|
+
function normalizeCustomRules(value, errorCode) {
|
|
510
|
+
if (value === undefined) return []
|
|
511
|
+
if (!Array.isArray(value)) throw new RegistryError(errorCode, 'customRules must be an array of rules.')
|
|
512
|
+
if (value.length > MAX_CUSTOM_RULES) {
|
|
513
|
+
throw new RegistryError(errorCode, `customRules may hold at most ${MAX_CUSTOM_RULES} rules.`)
|
|
514
|
+
}
|
|
515
|
+
/** @type {import('./policy.js').CustomRule[]} */
|
|
516
|
+
const rules = []
|
|
517
|
+
/** @type {string[]} */
|
|
518
|
+
const seen = []
|
|
519
|
+
for (const item of value) {
|
|
520
|
+
/** @type {import('./policy.js').CustomRule} */
|
|
521
|
+
let rule
|
|
522
|
+
try {
|
|
523
|
+
rule = validateCustomRule(item, { takenIds: seen })
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (error instanceof RuleError) throw new RegistryError(errorCode, `customRules: ${error.message}`)
|
|
526
|
+
throw error
|
|
527
|
+
}
|
|
528
|
+
seen.push(rule.id)
|
|
529
|
+
rules.push(rule)
|
|
530
|
+
}
|
|
531
|
+
return rules
|
|
532
|
+
}
|
|
533
|
+
|
|
390
534
|
/**
|
|
391
535
|
* Validate and canonicalise one complete record. Create, update and load all
|
|
392
536
|
* go through this function, so a stored record and a freshly created one have
|
|
@@ -398,6 +542,7 @@ function normalizeDisabledRules(value, errorCode) {
|
|
|
398
542
|
* errorCode: RegistryErrorCode,
|
|
399
543
|
* label: string,
|
|
400
544
|
* createdAt?: string,
|
|
545
|
+
* migrate?: boolean,
|
|
401
546
|
* }} options
|
|
402
547
|
* @returns {ServerRecord}
|
|
403
548
|
*/
|
|
@@ -415,11 +560,13 @@ function normalizeRecord(source, options) {
|
|
|
415
560
|
host: normalizeHost(source.host, errorCode),
|
|
416
561
|
port: normalizePort(source.port, errorCode),
|
|
417
562
|
username: requiredString(source.username, 'username', errorCode),
|
|
418
|
-
auth: normalizeAuth(source.auth, id, errorCode),
|
|
563
|
+
auth: normalizeAuth(source.auth, id, errorCode, { migrate: options.migrate === true }),
|
|
419
564
|
hostKeyMode: normalizeHostKeyMode(source.hostKeyMode, errorCode),
|
|
420
565
|
defaultWorkDir: normalizeWorkDir(source.defaultWorkDir, errorCode),
|
|
566
|
+
enabled: normalizeEnabled(source.enabled, errorCode),
|
|
421
567
|
policy: normalizePolicy(source.policy, errorCode),
|
|
422
568
|
disabledRules: normalizeDisabledRules(source.disabledRules, errorCode),
|
|
569
|
+
customRules: normalizeCustomRules(source.customRules, errorCode),
|
|
423
570
|
createdAt,
|
|
424
571
|
lastConnectedAt: normalizeOptionalTimestamp(source.lastConnectedAt, 'lastConnectedAt', errorCode),
|
|
425
572
|
}
|
|
@@ -727,6 +874,9 @@ export class Registry {
|
|
|
727
874
|
id: recordId,
|
|
728
875
|
errorCode: 'INVALID_REGISTRY',
|
|
729
876
|
label: `servers[${index}]`,
|
|
877
|
+
// Loading is the one path allowed to repair a legacy credential ref;
|
|
878
|
+
// writes are strict so this plugin can never persist a bad one again.
|
|
879
|
+
migrate: true,
|
|
730
880
|
})
|
|
731
881
|
if (seenIds.has(record.id)) {
|
|
732
882
|
throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server id "${record.id}".`)
|
|
@@ -739,7 +889,32 @@ export class Registry {
|
|
|
739
889
|
seenNames.add(nameKey)
|
|
740
890
|
servers.push(record)
|
|
741
891
|
}
|
|
742
|
-
|
|
892
|
+
const document = { version: REGISTRY_VERSION, servers }
|
|
893
|
+
// Persist a repaired legacy ref immediately. Without this the migration
|
|
894
|
+
// lives only in memory: every later read re-runs it, and a copy of the file
|
|
895
|
+
// taken before the first write would still carry the illegal ref that breaks
|
|
896
|
+
// the next startup. The rewrite is conditional so a clean file is never
|
|
897
|
+
// touched (its mtime stays put).
|
|
898
|
+
if (this.#migrated(text, document)) await this.#save(document)
|
|
899
|
+
return document
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Whether the loaded document differs from what is on disk, i.e. whether the
|
|
904
|
+
* legacy-ref migration actually changed a record and the file must be rewritten.
|
|
905
|
+
* @param {string} text - the raw file text that was parsed.
|
|
906
|
+
* @param {RegistryDocument} document - the normalised document.
|
|
907
|
+
* @returns {boolean}
|
|
908
|
+
*/
|
|
909
|
+
#migrated(text, document) {
|
|
910
|
+
for (const record of document.servers) {
|
|
911
|
+
if (record.auth.kind !== 'password') continue
|
|
912
|
+
const ref = record.auth.credentialRef
|
|
913
|
+
// The normalised ref is legal by construction; if the raw text does not
|
|
914
|
+
// contain it, this record was migrated.
|
|
915
|
+
if (!text.includes(JSON.stringify(ref))) return true
|
|
916
|
+
}
|
|
917
|
+
return false
|
|
743
918
|
}
|
|
744
919
|
|
|
745
920
|
/**
|