dsh-vault 0.2.0 → 0.4.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/index.js CHANGED
@@ -53,6 +53,8 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
53
53
  import { defineTool } from '@deepseek-ai/dsh-tools';
54
54
  import Schema from '@deepseek-ai/schemastery';
55
55
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
56
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
57
+ import { dirname, join } from 'node:path';
56
58
  import { openVault, defaultVaultPath } from "./store.js";
57
59
  import { totp } from "./totp.js";
58
60
  import { generatePassword } from "./password.js";
@@ -65,28 +67,63 @@ export const Config = Schema.object({
65
67
  name: Schema.string(),
66
68
  accessMode: Schema.union([
67
69
  Schema.const('readonly'),
68
- Schema.const('readwrite'),
70
+ Schema.const('ask'),
71
+ Schema.const('auto'),
69
72
  ]),
70
73
  autoCapture: Schema.boolean(),
74
+ lockTimeoutSeconds: Schema.number(),
75
+ exportPasswordEnv: Schema.string(),
71
76
  });
72
- export function apply(ctx, config) {
77
+ export async function apply(ctx, config) {
73
78
  const masterPassword = resolveMasterPassword(config);
74
- const accessMode = config.accessMode ?? 'readwrite';
75
- const autoCapture = config.autoCapture ?? false;
79
+ const WRITE_TOOLS = new Set(['vault_add', 'vault_update', 'vault_delete']);
80
+ const lockTimeoutSeconds = config.lockTimeoutSeconds ?? 0;
81
+ /** Shared access policy; resolved once, mutated by the UI via setAccessMode. */
82
+ const policy = await sharedAccessPolicy(config);
76
83
  /** Reject mutations when the vault is in readonly mode. */
77
84
  function assertWritable(action) {
78
- if (accessMode === 'readonly') {
79
- throw new Error(`vault: ${action} is disabled in readonly mode (set accessMode: "readwrite" to enable)`);
85
+ if (policy.mode === 'readonly') {
86
+ throw new Error(`vault: ${action} is disabled in readonly mode (set accessMode to "ask" or "auto" to enable)`);
80
87
  }
81
88
  }
89
+ /**
90
+ * Route writes through the harness approval channel in `ask` mode: the user
91
+ * confirms every add/update/delete ("prompt before writing"). `auto` allows
92
+ * without a prompt; `readonly` denies in assertWritable. This listener must
93
+ * call `next()` (waterfall event).
94
+ */
95
+ ctx.on('tools/pre-execute', async (exec, next) => {
96
+ if (policy.mode === 'ask' && exec?.name !== undefined && WRITE_TOOLS.has(exec.name)) {
97
+ return { kind: 'ask', reason: `dsh-vault: ${exec.name} requires your confirmation in "ask" (prompt-before-write) mode` };
98
+ }
99
+ return next();
100
+ });
82
101
  /** Ensure the shared store is open (lazily on first use, so a missing
83
102
  * master password fails at the first tool call with a clear message). */
84
103
  async function ensureStore() {
85
- return sharedVaultStore(masterPassword, config);
104
+ const store = await sharedVaultStore(masterPassword, config);
105
+ // Install the auto-lock policy once per store instance.
106
+ if (lockTimeoutSeconds > 0)
107
+ store.setAutoLock(lockTimeoutSeconds * 1000);
108
+ return store;
86
109
  }
87
- /** Read a full entry (with secrets) by id. */
110
+ /** Guard every tool: enforce auto-lock (relock when idle, refuse when
111
+ * locked) and touch the activity timestamp. */
112
+ async function guardStore() {
113
+ const store = await ensureStore();
114
+ if (store.expired) {
115
+ store.lock();
116
+ throw new Error('vault is locked (idle timeout) — call vault_unlock to re-open it');
117
+ }
118
+ if (store.isLocked) {
119
+ throw new Error('vault is locked — call vault_unlock to re-open it');
120
+ }
121
+ store.touch();
122
+ return store;
123
+ }
124
+ /** Read a full entry (with secrets) by id (respects locking). */
88
125
  async function readEntry(id) {
89
- const s = await ensureStore();
126
+ const s = await guardStore();
90
127
  return s.get(id);
91
128
  }
92
129
  // System prompt guidance: tells the model how the vault works, what the
@@ -99,12 +136,16 @@ export function apply(ctx, config) {
99
136
  text: () => {
100
137
  const lines = [
101
138
  '## Encrypted credential vault (dsh-vault)',
102
- `Access mode: ${accessMode === 'readonly' ? 'READONLY — you may search/read/generate codes but MUST NOT add, update, or delete entries.' : 'readwrite — you may add, update, delete, search, and read entries.'}`,
139
+ policy.mode === 'readonly'
140
+ ? 'Access mode: READONLY — you may search/read/generate codes but MUST NOT add, update, or delete entries.'
141
+ : policy.mode === 'ask'
142
+ ? 'Access mode: ASK (prompt-before-write) — reads are free; every add/update/delete will ask the user for confirmation through the approval channel.'
143
+ : 'Access mode: AUTO (automatic read-write) — you may add, update, delete, search, and read entries without a per-call prompt.',
103
144
  'Credentials are encrypted at rest (AES-256-GCM) under a master password the user configured; never ask for that password.',
104
145
  'Use vault_search to find entries by title/username/host and vault_get (by id) to read full credentials when the task needs them.',
105
146
  'Do not repeat secrets in the conversation when a credential was obtained via vault_get.',
106
147
  ];
107
- if (autoCapture) {
148
+ if (policy.autoCapture) {
108
149
  lines.push('Auto-capture is ON: when the user shares an API key, token, password, or other credential in conversation', '(e.g. "my npm token is npm_…", "use this GitHub PAT"), offer to store it with vault_add under a clear title.', 'Capture user preference: if they agree (or have previously agreed to auto-save), call vault_add immediately;', 'if they decline or it is unclear, do NOT store it. Never auto-save credentials that were not explicitly shared.');
109
150
  }
110
151
  else {
@@ -166,7 +207,7 @@ export function apply(ctx, config) {
166
207
  assertWritable('vault_add');
167
208
  if (!args.title.trim())
168
209
  throw new Error('vault_add: title must not be empty');
169
- const s = await ensureStore();
210
+ const s = await guardStore();
170
211
  const entry = await s.add({
171
212
  title: args.title.trim(),
172
213
  ...(args.kind !== undefined ? { kind: args.kind } : {}),
@@ -247,7 +288,7 @@ export function apply(ctx, config) {
247
288
  }],
248
289
  },
249
290
  async execute(args) {
250
- const s = await ensureStore();
291
+ const s = await guardStore();
251
292
  const limit = validateLimit(args.limit, 'vault_search');
252
293
  const results = s.search(args.query, limit);
253
294
  return { results, total: results.length };
@@ -301,7 +342,7 @@ export function apply(ctx, config) {
301
342
  },
302
343
  async execute(args) {
303
344
  assertWritable('vault_update');
304
- const s = await ensureStore();
345
+ const s = await guardStore();
305
346
  const patch = {};
306
347
  for (const key of [
307
348
  'title', 'kind', 'username', 'email', 'phone', 'password', 'host', 'port', 'privateKey',
@@ -338,7 +379,7 @@ export function apply(ctx, config) {
338
379
  },
339
380
  async execute(args) {
340
381
  assertWritable('vault_delete');
341
- const s = await ensureStore();
382
+ const s = await guardStore();
342
383
  const deleted = await s.delete(args.id);
343
384
  return { deleted, message: deleted ? 'entry deleted' : 'entry not found' };
344
385
  },
@@ -428,6 +469,194 @@ export function apply(ctx, config) {
428
469
  return { password, length: password.length };
429
470
  },
430
471
  }));
472
+ // ── vault_lock / vault_unlock: explicit lock & unlock ──────────────────────
473
+ ctx.tools.register(defineTool({
474
+ name: 'vault_lock',
475
+ description: 'Lock the vault immediately: wipe the derived key from memory so every '
476
+ + 'subsequent read/write requires vault_unlock. Use when leaving the machine.',
477
+ parameters: {},
478
+ output: { schema: { type: 'object', additionalProperties: false, properties: { locked: { type: 'boolean', required: true } } }, render: (_a, v) => [{ type: 'text', text: v.locked ? 'vault locked' : 'vault not unlocked' }] },
479
+ async execute() {
480
+ const s = await ensureStore();
481
+ const was = s.isLocked;
482
+ s.lock();
483
+ return { locked: !was };
484
+ },
485
+ }));
486
+ ctx.tools.register(defineTool({
487
+ name: 'vault_unlock',
488
+ description: 'Unlock the vault with the master password (the deployment owns the password; '
489
+ + 'the model never supplies it). Needed after an explicit vault_lock or an auto-lock idle timeout.',
490
+ parameters: {},
491
+ output: { schema: { type: 'object', additionalProperties: false, properties: { unlocked: { type: 'boolean', required: true } } }, render: (_a, v) => [{ type: 'text', text: v.unlocked ? 'vault unlocked' : 'vault already unlocked' }] },
492
+ async execute() {
493
+ const s = await ensureStore();
494
+ if (!s.isLocked)
495
+ return { unlocked: false };
496
+ await s.unlock();
497
+ return { unlocked: true };
498
+ },
499
+ }));
500
+ // ── vault_rotation: expiry / rotation report ───────────────────────────────
501
+ ctx.tools.register(defineTool({
502
+ name: 'vault_rotation',
503
+ description: 'Report credentials that are expired, due for rotation (rotationDays elapsed), '
504
+ + 'or expiring within 7 days. Returns summaries with a due state — never secrets.',
505
+ parameters: {},
506
+ output: {
507
+ schema: { type: 'object', additionalProperties: false, properties: { entries: { type: 'array', required: true, items: { type: 'json' } } } },
508
+ render: (_a, v) => [{ type: 'text', text: v.entries.length === 0 ? 'no rotation items' : JSON.stringify(v.entries) }],
509
+ },
510
+ async execute() {
511
+ const s = await guardStore();
512
+ return { entries: s.rotationReport() };
513
+ },
514
+ }));
515
+ // ── vault_health: weak / reused credential scan ────────────────────────────
516
+ ctx.tools.register(defineTool({
517
+ name: 'vault_health',
518
+ description: 'Scan the vault for weak passwords (shorter than 12 chars) and credentials reused '
519
+ + 'across entries. Returns non-secret findings (entry summaries grouped by the reused value).',
520
+ parameters: {},
521
+ output: {
522
+ schema: { type: 'object', additionalProperties: false, properties: { weak: { type: 'array', required: true, items: { type: 'json' } }, reused: { type: 'array', required: true, items: { type: 'json' } } } },
523
+ render: (_a, v) => [{ type: 'text', text: `weak: ${v.weak.length}, reused groups: ${v.reused.length}` }],
524
+ },
525
+ async execute() {
526
+ const s = await guardStore();
527
+ return s.health();
528
+ },
529
+ }));
530
+ // ── vault_restore / vault_purge: trash management ──────────────────────────
531
+ ctx.tools.register(defineTool({
532
+ name: 'vault_restore',
533
+ description: 'Restore a soft-deleted entry from the vault trash back into the active set.',
534
+ parameters: { id: { type: 'string', required: true, description: 'The trashed entry id.' } },
535
+ output: { schema: { type: 'object', additionalProperties: false, properties: { restored: { type: 'boolean', required: true } } }, render: (_a, v) => [{ type: 'text', text: v.restored ? 'entry restored' : 'entry not found in trash' }] },
536
+ async execute(args) {
537
+ assertWritable('vault_restore');
538
+ const s = await guardStore();
539
+ return { restored: await s.restore(args.id) };
540
+ },
541
+ }));
542
+ ctx.tools.register(defineTool({
543
+ name: 'vault_purge',
544
+ description: 'Permanently delete an entry (active or trashed). Cannot be undone — prefer vault_delete '
545
+ + '(soft delete) unless the entry must be removed from disk.',
546
+ parameters: { id: { type: 'string', required: true, description: 'The entry id to purge.' } },
547
+ output: { schema: { type: 'object', additionalProperties: false, properties: { purged: { type: 'boolean', required: true } } }, render: (_a, v) => [{ type: 'text', text: v.purged ? 'entry purged' : 'entry not found' }] },
548
+ async execute(args) {
549
+ assertWritable('vault_purge');
550
+ const s = await guardStore();
551
+ return { purged: await s.purge(args.id) };
552
+ },
553
+ }));
554
+ // ── vault_export / vault_import: portable encrypted transfer ───────────────
555
+ ctx.tools.register(defineTool({
556
+ name: 'vault_export',
557
+ description: 'Export the entire vault (including trash) as a single encrypted document under a '
558
+ + 'separate export password (from the exportPasswordEnv config). Use for backup or migration; '
559
+ + 'the export can be re-imported with vault_import. Never pass the password as an argument.',
560
+ parameters: {},
561
+ output: { schema: { type: 'object', additionalProperties: false, properties: { exported: { type: 'boolean', required: true }, note: { type: 'string', required: true } } }, render: (_a, v) => [{ type: 'text', text: v.note }] },
562
+ async execute() {
563
+ const exportPassword = resolveExportPassword(config);
564
+ const s = await guardStore();
565
+ const blob = await s.exportEncrypted(exportPassword);
566
+ const file = join(dirname(resolveVaultPath(config)), `vault-export-${Date.now()}.json`);
567
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
568
+ await writeFile(file, blob, { mode: 0o600 });
569
+ return { exported: true, note: `vault exported to ${file}` };
570
+ },
571
+ }));
572
+ ctx.tools.register(defineTool({
573
+ name: 'vault_import',
574
+ description: 'Import a previously exported vault document (see vault_export), merging entries by '
575
+ + 'id. Pass the document path; the export password comes from the exportPasswordEnv config.',
576
+ parameters: { path: { type: 'string', required: true, description: 'Absolute path of the exported vault JSON file.' } },
577
+ output: { schema: { type: 'object', additionalProperties: false, properties: { imported: { type: 'integer', required: true } } }, render: (_a, v) => [{ type: 'text', text: `imported ${v.imported} entries` }] },
578
+ async execute(args) {
579
+ assertWritable('vault_import');
580
+ const exportPassword = resolveExportPassword(config);
581
+ const s = await guardStore();
582
+ const blob = await readFile(args.path, 'utf8');
583
+ const count = await s.importEncrypted(blob, exportPassword);
584
+ return { imported: count };
585
+ },
586
+ }));
587
+ // ── vault_fill: find the credential that fits a target ─────────────────────
588
+ ctx.tools.register(defineTool({
589
+ name: 'vault_fill',
590
+ description: 'Find the vault entry that fits a target (host/URL/username/title) and return the '
591
+ + 'ready-to-use credentials (secrets included, as the caller needs them for the actual login). '
592
+ + 'Use instead of vault_search+vault_get when you know what you are connecting to.',
593
+ parameters: {
594
+ target: { type: 'string', required: true, description: 'Host, URL, username, or title to match.' },
595
+ },
596
+ output: {
597
+ schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean', required: true }, entry: { type: 'json' } } },
598
+ render: (_a, v) => [{ type: 'text', text: v.found ? `matched: ${v.entry?.title ?? 'entry'}` : 'no matching entry' }],
599
+ },
600
+ async execute(args) {
601
+ const s = await guardStore();
602
+ const needle = args.target.trim().toLowerCase();
603
+ for (const entry of s.list()) {
604
+ const haystack = [entry.title, entry.host, entry.url, entry.username, entry.email].filter(Boolean).join(' ').toLowerCase();
605
+ if (needle.length > 0 && haystack.includes(needle)) {
606
+ return { found: true, entry: stripTimestamps(entry) };
607
+ }
608
+ }
609
+ return { found: false };
610
+ },
611
+ }));
612
+ // ── vault_env: environment-variable export ─────────────────────────────────
613
+ ctx.tools.register(defineTool({
614
+ name: 'vault_env',
615
+ description: 'Render entries flagged for environment export (tags contain "env") as KEY=VALUE lines '
616
+ + 'suitable for .env or export statements. Keys derive from the title + field name; values are the '
617
+ + 'secrets. Returns the lines so the caller can write them to a file (user-authorized).',
618
+ parameters: {},
619
+ output: { schema: { type: 'object', additionalProperties: false, properties: { lines: { type: 'array', required: true, items: { type: 'string' } } } }, render: (_a, v) => [{ type: 'text', text: v.lines.join('\n') }] },
620
+ async execute() {
621
+ const s = await guardStore();
622
+ const lines = [];
623
+ const keyOf = (title, field) => title.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_' + field.toUpperCase();
624
+ for (const entry of s.list()) {
625
+ if (!(entry.tags ?? []).includes('env'))
626
+ continue;
627
+ for (const [field, value] of Object.entries(entry)) {
628
+ if (typeof value !== 'string' || value.length === 0)
629
+ continue;
630
+ if (['id', 'title', 'kind', 'sensitivity', 'host', 'url', 'notes', 'createdAt', 'updatedAt', 'deletedAt'].includes(field))
631
+ continue;
632
+ if (['username', 'email', 'phone', 'port', 'tags'].includes(field))
633
+ continue;
634
+ lines.push(`${keyOf(entry.title, field)}=${value}`);
635
+ }
636
+ }
637
+ return { lines };
638
+ },
639
+ }));
640
+ // ── vault_templates: field templates by kind ───────────────────────────────
641
+ const TEMPLATES = {
642
+ login: { username: 'account username', email: 'account email', password: 'account password' },
643
+ ssh: { host: 'server host', port: 'port (e.g. 22)', username: 'login user', password: 'password or passphrase', privateKey: 'PEM private key' },
644
+ 'api-key': { apiKey: 'the API key', url: 'API base URL', username: 'owner/account (optional)' },
645
+ oauth: { accessToken: 'access token', refreshToken: 'refresh token', expiresAt: 'expiry epoch millis', clientId: 'client id (via fields)' },
646
+ secret: { secret: 'the shared secret', notes: 'what it is for' },
647
+ custom: { fields: 'arbitrary key/value pairs' },
648
+ };
649
+ ctx.tools.register(defineTool({
650
+ name: 'vault_templates',
651
+ description: 'Return the recommended fields for a credential kind, so vault_add can be called with '
652
+ + 'the right field names (e.g. kind ssh → host/port/username/password/privateKey).',
653
+ parameters: { kind: { type: 'string', description: 'Entry kind; default login.', enum: ['login', 'ssh', 'api-key', 'secret', 'oauth', 'custom'] } },
654
+ output: { schema: { type: 'object', additionalProperties: false, properties: { kind: { type: 'string', required: true }, fields: { type: 'json', required: true } } }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v.fields) }] },
655
+ async execute(args) {
656
+ const kind = args.kind ?? 'login';
657
+ return { kind, fields: TEMPLATES[kind] ?? TEMPLATES.login };
658
+ },
659
+ }));
431
660
  // UI-facing Remote gateway: the browser Settings Vault page talks to these
432
661
  // methods through the /api RPC channel (loopback-trusted), bypassing the
433
662
  // model-tool layer entirely. Secrets are returned because the UI is the
@@ -437,8 +666,7 @@ export function apply(ctx, config) {
437
666
  masterPassword,
438
667
  ...(config.path !== undefined ? { path: config.path } : {}),
439
668
  ...(config.name !== undefined ? { name: config.name } : {}),
440
- accessMode,
441
- autoCapture,
669
+ accessPolicy: policy,
442
670
  });
443
671
  }
444
672
  /**
@@ -451,7 +679,12 @@ let VaultGateway = (() => {
451
679
  let _classSuper = TypertRemoteService;
452
680
  let _instanceExtraInitializers = [];
453
681
  let _config_decorators;
682
+ let _setAccessMode_decorators;
454
683
  let _list_decorators;
684
+ let _trash_decorators;
685
+ let _restore_decorators;
686
+ let _rotation_decorators;
687
+ let _health_decorators;
455
688
  let _get_decorators;
456
689
  let _search_decorators;
457
690
  let _add_decorators;
@@ -462,7 +695,12 @@ let VaultGateway = (() => {
462
695
  static {
463
696
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
464
697
  _config_decorators = [Remote('config')];
698
+ _setAccessMode_decorators = [Remote('setAccessMode')];
465
699
  _list_decorators = [Remote('list')];
700
+ _trash_decorators = [Remote('trash')];
701
+ _restore_decorators = [Remote('restore')];
702
+ _rotation_decorators = [Remote('rotation')];
703
+ _health_decorators = [Remote('health')];
466
704
  _get_decorators = [Remote('get')];
467
705
  _search_decorators = [Remote('search')];
468
706
  _add_decorators = [Remote('add')];
@@ -470,7 +708,12 @@ let VaultGateway = (() => {
470
708
  _delete_decorators = [Remote('delete')];
471
709
  _totp_decorators = [Remote('totp')];
472
710
  __esDecorate(this, null, _config_decorators, { kind: "method", name: "config", static: false, private: false, access: { has: obj => "config" in obj, get: obj => obj.config }, metadata: _metadata }, null, _instanceExtraInitializers);
711
+ __esDecorate(this, null, _setAccessMode_decorators, { kind: "method", name: "setAccessMode", static: false, private: false, access: { has: obj => "setAccessMode" in obj, get: obj => obj.setAccessMode }, metadata: _metadata }, null, _instanceExtraInitializers);
473
712
  __esDecorate(this, null, _list_decorators, { kind: "method", name: "list", static: false, private: false, access: { has: obj => "list" in obj, get: obj => obj.list }, metadata: _metadata }, null, _instanceExtraInitializers);
713
+ __esDecorate(this, null, _trash_decorators, { kind: "method", name: "trash", static: false, private: false, access: { has: obj => "trash" in obj, get: obj => obj.trash }, metadata: _metadata }, null, _instanceExtraInitializers);
714
+ __esDecorate(this, null, _restore_decorators, { kind: "method", name: "restore", static: false, private: false, access: { has: obj => "restore" in obj, get: obj => obj.restore }, metadata: _metadata }, null, _instanceExtraInitializers);
715
+ __esDecorate(this, null, _rotation_decorators, { kind: "method", name: "rotation", static: false, private: false, access: { has: obj => "rotation" in obj, get: obj => obj.rotation }, metadata: _metadata }, null, _instanceExtraInitializers);
716
+ __esDecorate(this, null, _health_decorators, { kind: "method", name: "health", static: false, private: false, access: { has: obj => "health" in obj, get: obj => obj.health }, metadata: _metadata }, null, _instanceExtraInitializers);
474
717
  __esDecorate(this, null, _get_decorators, { kind: "method", name: "get", static: false, private: false, access: { has: obj => "get" in obj, get: obj => obj.get }, metadata: _metadata }, null, _instanceExtraInitializers);
475
718
  __esDecorate(this, null, _search_decorators, { kind: "method", name: "search", static: false, private: false, access: { has: obj => "search" in obj, get: obj => obj.search }, metadata: _metadata }, null, _instanceExtraInitializers);
476
719
  __esDecorate(this, null, _add_decorators, { kind: "method", name: "add", static: false, private: false, access: { has: obj => "add" in obj, get: obj => obj.add }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -483,15 +726,13 @@ let VaultGateway = (() => {
483
726
  masterPassword = __runInitializers(this, _instanceExtraInitializers);
484
727
  vaultPath;
485
728
  vaultName;
486
- accessMode;
487
- autoCapture;
729
+ accessPolicy;
488
730
  constructor(ctx, config) {
489
731
  super(ctx, 'vault');
490
732
  this.masterPassword = config.masterPassword ?? resolveMasterPassword(config);
491
733
  this.vaultPath = config.path;
492
734
  this.vaultName = config.name;
493
- this.accessMode = config.accessMode ?? 'readwrite';
494
- this.autoCapture = config.autoCapture ?? false;
735
+ this.accessPolicy = config.accessPolicy ?? { mode: config.accessMode ?? 'ask', autoCapture: config.autoCapture ?? false };
495
736
  }
496
737
  async ensureStore() {
497
738
  return sharedVaultStore(this.masterPassword, {
@@ -501,19 +742,58 @@ let VaultGateway = (() => {
501
742
  }
502
743
  /** Reject mutations when the vault is in readonly mode (UI surface). */
503
744
  assertWritable(action) {
504
- if (this.accessMode === 'readonly') {
505
- throw new Error(`vault: ${action} is disabled in readonly mode (set accessMode: "readwrite" to enable)`);
745
+ if (this.accessPolicy.mode === 'readonly') {
746
+ throw new Error(`vault: ${action} is disabled in readonly mode (set accessMode to "ask" or "auto" to enable)`);
506
747
  }
507
748
  }
508
749
  /** Current access policy and capture preference, for the Settings UI. */
509
750
  async config() {
510
- return { accessMode: this.accessMode, autoCapture: this.autoCapture };
751
+ return { accessMode: this.accessPolicy.mode, autoCapture: this.accessPolicy.autoCapture };
752
+ }
753
+ /** Switch the runtime access mode from the Settings UI and persist it. */
754
+ async setAccessMode(mode) {
755
+ if (mode !== 'readonly' && mode !== 'ask' && mode !== 'auto') {
756
+ throw new Error(`vault: invalid accessMode "${String(mode)}" (expected readonly, ask, or auto)`);
757
+ }
758
+ this.accessPolicy.mode = mode;
759
+ await this.persistPolicy();
760
+ return { accessMode: this.accessPolicy.mode, autoCapture: this.accessPolicy.autoCapture };
761
+ }
762
+ /** Persist the current policy to `<vault dir>/access.json`. */
763
+ async persistPolicy() {
764
+ const file = accessPolicyFile({
765
+ ...(this.vaultPath !== undefined ? { path: this.vaultPath } : {}),
766
+ ...(this.vaultName !== undefined ? { name: this.vaultName } : {}),
767
+ });
768
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
769
+ await writeFile(file, JSON.stringify(this.accessPolicy, null, 2), { mode: 0o600 });
511
770
  }
512
771
  /** List every entry as a non-secret summary. */
513
772
  async list() {
514
773
  const store = await this.ensureStore();
515
774
  return { entries: store.list().map(toSummary) };
516
775
  }
776
+ /** List trashed (soft-deleted) entries as non-secret summaries. */
777
+ async trash() {
778
+ const store = await this.ensureStore();
779
+ return { entries: store.listTrash().map(toSummary) };
780
+ }
781
+ /** Restore a trashed entry (non-secret summary returned). */
782
+ async restore(id) {
783
+ this.assertWritable('restore');
784
+ const store = await this.ensureStore();
785
+ return { restored: await store.restore(id) };
786
+ }
787
+ /** Rotation/expiry report (no secrets). */
788
+ async rotation() {
789
+ const store = await this.ensureStore();
790
+ return { entries: store.rotationReport() };
791
+ }
792
+ /** Health scan findings (no secrets). */
793
+ async health() {
794
+ const store = await this.ensureStore();
795
+ return store.health();
796
+ }
517
797
  /** Read one full entry (including secrets) by id. */
518
798
  async get(id) {
519
799
  const store = await this.ensureStore();
@@ -613,6 +893,18 @@ function resolveMasterPassword(config) {
613
893
  }
614
894
  throw new Error('dsh-vault: configure masterPassword or masterPasswordEnv to unlock the vault');
615
895
  }
896
+ /** Resolve the export/import password from the configured environment
897
+ * variable, or fail loudly (the model must never pass it as an argument). */
898
+ function resolveExportPassword(config) {
899
+ if (config.exportPasswordEnv !== undefined) {
900
+ const fromEnv = process.env[config.exportPasswordEnv];
901
+ if (fromEnv === undefined || fromEnv.length === 0) {
902
+ throw new Error(`dsh-vault: environment variable ${config.exportPasswordEnv} is not set (needed for vault_export/import)`);
903
+ }
904
+ return fromEnv;
905
+ }
906
+ throw new Error('dsh-vault: configure exportPasswordEnv to use vault_export / vault_import');
907
+ }
616
908
  /**
617
909
  * Shared vault-store instances keyed by resolved path + master password. The
618
910
  * model tools and the UI-facing VaultGateway must observe ONE store so writes
@@ -623,12 +915,77 @@ function resolveMasterPassword(config) {
623
915
  * (and silently "unlocking") each other's store.
624
916
  */
625
917
  const sharedVaultStores = new Map();
918
+ const sharedAccessPolicies = new Map();
626
919
  /** Resolve the canonical vault file path for a config (path override or name). */
627
920
  function resolveVaultPath(config) {
628
921
  if (config.path !== undefined)
629
922
  return config.path;
630
923
  return defaultVaultPath(config.name);
631
924
  }
925
+ /** The `<vault dir>/access.json` path holding the persisted access policy. */
926
+ function accessPolicyFile(config) {
927
+ return join(dirname(resolveVaultPath(config)), 'access.json');
928
+ }
929
+ /** Read a persisted access policy, defaulting to the configured values. */
930
+ async function loadAccessPolicy(config) {
931
+ const fallback = { mode: config.accessMode ?? 'ask', autoCapture: config.autoCapture ?? false };
932
+ const file = accessPolicyFile(config);
933
+ try {
934
+ const raw = await readFile(file, 'utf8');
935
+ const parsed = JSON.parse(raw);
936
+ return {
937
+ mode: parsed.mode === 'readonly' || parsed.mode === 'ask' || parsed.mode === 'auto' ? parsed.mode : fallback.mode,
938
+ autoCapture: typeof parsed.autoCapture === 'boolean' ? parsed.autoCapture : fallback.autoCapture,
939
+ };
940
+ }
941
+ catch {
942
+ return fallback;
943
+ }
944
+ }
945
+ /** Open (or reuse) the shared access policy for one vault path. */
946
+ async function sharedAccessPolicy(config) {
947
+ const path = resolveVaultPath(config);
948
+ const existing = sharedAccessPolicies.get(path);
949
+ if (existing !== undefined)
950
+ return existing;
951
+ const policy = await loadAccessPolicy(config);
952
+ sharedAccessPolicies.set(path, policy);
953
+ return policy;
954
+ }
955
+ const MAX_ATTEMPTS = 5;
956
+ const LOCKOUT_MS = 5 * 60 * 1000;
957
+ /** `<vault dir>/meta.json` — failed-attempt counter and lockout window. */
958
+ function metaFile(config) {
959
+ return join(dirname(resolveVaultPath(config)), 'meta.json');
960
+ }
961
+ async function readMeta(config) {
962
+ try {
963
+ const raw = await readFile(metaFile(config), 'utf8');
964
+ return JSON.parse(raw);
965
+ }
966
+ catch {
967
+ return { failedAttempts: 0 };
968
+ }
969
+ }
970
+ async function writeMeta(config, meta) {
971
+ const file = metaFile(config);
972
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
973
+ await writeFile(file, JSON.stringify(meta), { mode: 0o600 });
974
+ }
975
+ /** Record one failed unlock: increment the counter, start a lockout when the
976
+ * threshold is crossed. */
977
+ async function recordFailedAttempt(config) {
978
+ const meta = await readMeta(config);
979
+ meta.failedAttempts = (meta.failedAttempts ?? 0) + 1;
980
+ if (meta.failedAttempts >= MAX_ATTEMPTS) {
981
+ meta.lockedUntil = Date.now() + LOCKOUT_MS;
982
+ }
983
+ await writeMeta(config, meta);
984
+ }
985
+ /** Clear the counter after a successful unlock. */
986
+ async function clearFailedAttempts(config) {
987
+ await writeMeta(config, { failedAttempts: 0 });
988
+ }
632
989
  /**
633
990
  * Open (or reuse) the vault store for one deployment configuration. All
634
991
  * callers within the process share the same instance for the same path and
@@ -637,22 +994,34 @@ function resolveVaultPath(config) {
637
994
  */
638
995
  async function sharedVaultStore(masterPassword, config) {
639
996
  const path = resolveVaultPath(config);
997
+ // Brute-force guard: refuse to even attempt while a lockout window is open.
998
+ const meta = await readMeta(config);
999
+ if (meta.lockedUntil !== undefined && Date.now() < meta.lockedUntil) {
1000
+ const minutes = Math.ceil((meta.lockedUntil - Date.now()) / 60000);
1001
+ throw new Error(`vault is temporarily locked after ${MAX_ATTEMPTS} failed password attempts — retry in ~${minutes} min`);
1002
+ }
640
1003
  // The master password is part of the identity: a different password must
641
1004
  // open its own store (and fail authentication) rather than reuse a store
642
1005
  // unlocked with another password.
643
1006
  const cacheKey = `${path}\0${masterPassword}`;
644
1007
  const existing = sharedVaultStores.get(cacheKey);
645
- if (existing !== undefined)
1008
+ if (existing !== undefined) {
1009
+ // A successful re-open of a cached store clears the failure counter.
1010
+ await clearFailedAttempts(config);
646
1011
  return existing;
1012
+ }
647
1013
  const opening = openVault({
648
1014
  masterPassword,
649
1015
  path,
650
- }).catch((error) => {
651
- // A failed open must not poison the cache for later retries.
1016
+ }).catch(async (error) => {
1017
+ // A failed open must not poison the cache for later retries, and counts
1018
+ // toward the brute-force lockout.
652
1019
  sharedVaultStores.delete(cacheKey);
1020
+ await recordFailedAttempt(config);
653
1021
  throw error;
654
1022
  });
655
1023
  sharedVaultStores.set(cacheKey, opening);
1024
+ await clearFailedAttempts(config);
656
1025
  return opening;
657
1026
  }
658
1027
  /** Validate a model-supplied result limit: a positive integer capped at 100. */