@icoretech/warden-mcp 0.1.8 → 0.1.10

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/README.md CHANGED
@@ -367,12 +367,14 @@ Credential resolution:
367
367
  Mutation control:
368
368
 
369
369
  - Set `READONLY=true` to block all write operations (create/edit/delete/move/restore/attachments).
370
+ - Set `NOREVEAL=true` to force all `reveal` parameters to `false` server-side. Clients can still request `reveal: true`, but the server will silently downgrade to redacted output. This prevents prompt injection from tricking an LLM agent into exfiltrating secrets.
370
371
  - Session guardrails:
371
372
  - `KEYCHAIN_SESSION_MAX_COUNT` (default `32`)
372
373
  - `KEYCHAIN_SESSION_TTL_MS` (default `900000`)
373
374
  - `KEYCHAIN_SESSION_SWEEP_INTERVAL_MS` (default `60000`)
374
375
  - `KEYCHAIN_MAX_HEAP_USED_MB` (default `1536`, set `0` to disable memory fuse)
375
376
  - `KEYCHAIN_METRICS_LOG_INTERVAL_MS` (default `0`, disabled)
377
+ - `NOREVEAL` / `KEYCHAIN_NOREVEAL` (default `false`; force all reveals to false)
376
378
  - `KEYCHAIN_ALLOW_ENV_FALLBACK` (default `false`; HTTP env-var credential fallback)
377
379
 
378
380
  Redaction defaults (item reads):
@@ -390,6 +392,26 @@ Reveal rules:
390
392
  - Secret helper tools (`get_password`, `get_totp`, `get_notes`, `generate`, `get_password_history`) return `structuredContent.result = { kind, value, revealed }`.
391
393
  - When `reveal` is omitted/false, `value` is `null` (or historic passwords are `null`) and `revealed: false`.
392
394
 
395
+ ## Production Deployment Checklist
396
+
397
+ If you run `warden-mcp` beyond local development, review these items:
398
+
399
+ 1. **TLS everywhere.** Always terminate TLS in front of the HTTP endpoint. `X-BW-*` headers carry master passwords in cleartext — without TLS they are visible to anyone on the network.
400
+
401
+ 2. **Network isolation.** Bind the server to `127.0.0.1` or place it behind an authenticated reverse proxy. The service has no built-in authentication; anyone who can reach `/sse` can issue vault operations.
402
+
403
+ 3. **Do not enable `KEYCHAIN_ALLOW_ENV_FALLBACK` on shared networks.** This flag makes the server's own vault credentials available to any HTTP client that omits headers. Only use it in single-tenant setups where the network is fully trusted.
404
+
405
+ 4. **Enable `READONLY=true` when writes are not needed.** This blocks all mutating tools at the MCP layer, limiting blast radius if an agent or client is compromised.
406
+
407
+ 5. **Restrict filesystem access to `/data/bw-profiles`.** The `bw` CLI stores decrypted state under its HOME directory. Ensure the profile directory is not world-readable and is mounted with appropriate permissions (the Docker image runs as non-root by default).
408
+
409
+ 6. **Disable debug logging in production.** `KEYCHAIN_DEBUG_BW` and `KEYCHAIN_DEBUG_HTTP` emit request details and CLI invocations to stdout. Debug logs may include session metadata and request structure. Keep them off unless actively troubleshooting.
410
+
411
+ 7. **Set `NOREVEAL=true` when secrets should never leave the server.** This forces all `reveal` parameters to `false` server-side, regardless of what the client requests. Use this when the MCP host is an LLM agent that could be influenced by prompt injection — it prevents tricked agents from exfiltrating passwords or TOTP codes.
412
+
413
+ 8. **Monitor `/metricsz`.** The endpoint is intentionally unauthenticated (for scraper compatibility) but exposes session counts, heap usage, and rejection counters. If this data is sensitive in your environment, restrict access at the network level.
414
+
393
415
  ## Quick Start
394
416
 
395
417
  ### Minimal local run
package/dist/bw/bwCli.js CHANGED
@@ -16,9 +16,10 @@ export async function runBw(args, opts = {}) {
16
16
  const bwBin = process.env.BW_BIN ?? 'bw';
17
17
  // Ensure the CLI never blocks waiting for a prompt (e.g. master password).
18
18
  // This is critical for running as an MCP server / in test automation.
19
- const finalArgs = args.includes('--nointeraction')
20
- ? args
21
- : ['--nointeraction', ...args];
19
+ const injectNoInteraction = opts.noInteraction ?? true;
20
+ const finalArgs = injectNoInteraction && !args.includes('--nointeraction')
21
+ ? ['--nointeraction', ...args]
22
+ : args;
22
23
  const env = { ...process.env, ...(opts.env ?? {}) };
23
24
  const debug = (process.env.KEYCHAIN_DEBUG_BW ?? 'false').toLowerCase() === 'true';
24
25
  const startedAt = Date.now();
@@ -169,7 +169,7 @@ export class BwSessionManager {
169
169
  });
170
170
  const tryUnlock = async () => {
171
171
  try {
172
- const { stdout } = await runBw(['unlock', '--passwordenv', 'BW_PASSWORD', '--raw'], { env: unlockEnv, timeoutMs: 60_000 });
172
+ const { stdout } = await runBw(['unlock', '--passwordenv', 'BW_PASSWORD', '--raw'], { env: unlockEnv, timeoutMs: 60_000, noInteraction: false });
173
173
  return stdout.trim();
174
174
  }
175
175
  catch {
@@ -186,6 +186,7 @@ export class BwSessionManager {
186
186
  BW_HOST: this.env.host,
187
187
  }),
188
188
  timeoutMs: 60_000,
189
+ noInteraction: false,
189
190
  });
190
191
  return stdout.trim();
191
192
  }
@@ -195,7 +196,7 @@ export class BwSessionManager {
195
196
  '--passwordenv',
196
197
  'BW_PASSWORD',
197
198
  '--raw',
198
- ], { env: unlockEnv, timeoutMs: 60_000 });
199
+ ], { env: unlockEnv, timeoutMs: 60_000, noInteraction: false });
199
200
  return stdout.trim();
200
201
  }
201
202
  catch {
@@ -418,6 +418,10 @@ export class KeychainSdk {
418
418
  });
419
419
  }
420
420
  async receive(input) {
421
+ const parsed = new URL(input.url);
422
+ if (parsed.protocol !== 'https:') {
423
+ throw new Error('receive URL must use HTTPS');
424
+ }
421
425
  return this.bw.withSession(async (session) => {
422
426
  const opts = ['receive'];
423
427
  if (typeof input.password === 'string')
@@ -63,35 +63,34 @@ const CODAS = [
63
63
  'ng',
64
64
  ];
65
65
  function titleCase(s) {
66
- if (!s)
67
- return s;
68
- const first = s.charAt(0);
69
- return first.toUpperCase() + s.slice(1);
66
+ return s.charAt(0).toUpperCase() + s.slice(1);
70
67
  }
71
68
  function randomWord(opts, deps) {
72
69
  // "Word-like" usernames without pulling a large word list dependency.
73
70
  // Produces a pronounceable-ish token such as "cravon" or "Plenast7".
74
- for (let i = 0; i < 12; i++) {
75
- const syllables = 2 + deps.randInt(2); // 2-3
76
- let s = '';
77
- for (let j = 0; j < syllables; j++) {
78
- s += ONSETS[deps.randInt(ONSETS.length)] ?? 'k';
79
- s += VOWELS[deps.randInt(VOWELS.length)] ?? 'a';
80
- const coda = CODAS[deps.randInt(CODAS.length)] ?? '';
81
- // Avoid overly long tokens by preferring empty coda on earlier syllables.
82
- s += j === syllables - 1 ? coda : coda.length > 1 ? '' : coda;
71
+ // With the current arrays, minimum output is 4 chars ("baba") and maximum
72
+ // is ~14 chars, so the length check always passes on the first iteration.
73
+ const syllables = 2 + deps.randInt(2); // 2-3
74
+ let s = '';
75
+ for (let j = 0; j < syllables; j++) {
76
+ const onset = ONSETS[deps.randInt(ONSETS.length)];
77
+ const vowel = VOWELS[deps.randInt(VOWELS.length)];
78
+ const coda = CODAS[deps.randInt(CODAS.length)];
79
+ s += onset;
80
+ s += vowel;
81
+ // Avoid overly long tokens by preferring empty coda on earlier syllables.
82
+ if (j === syllables - 1) {
83
+ s += coda;
84
+ }
85
+ else if (coda.length <= 1) {
86
+ s += coda;
83
87
  }
84
- if (s.length < 4 || s.length > 18)
85
- continue;
86
- if (opts.capitalize)
87
- s = titleCase(s);
88
- if (opts.includeNumber)
89
- s += String(deps.randInt(10));
90
- return s;
91
88
  }
92
- // Fallback: still deterministic with deps.randInt in tests.
93
- const fallback = `user${deps.randInt(1_000_000)}`;
94
- return opts.capitalize ? titleCase(fallback) : fallback;
89
+ if (opts.capitalize)
90
+ s = titleCase(s);
91
+ if (opts.includeNumber)
92
+ s += String(deps.randInt(10));
93
+ return s;
95
94
  }
96
95
  function parseEmail(email) {
97
96
  const trimmed = email.trim();
@@ -126,7 +125,7 @@ export function generateUsername(input = {}, deps) {
126
125
  throw new Error('email is required for plus_addressed_email');
127
126
  }
128
127
  const { local, domain } = parseEmail(input.email);
129
- const baseLocal = local.split('+')[0] ?? local;
128
+ const baseLocal = local.split('+')[0];
130
129
  return `${baseLocal}+${word}@${domain}`;
131
130
  }
132
131
  if (type === 'catch_all_email') {
@@ -136,7 +135,6 @@ export function generateUsername(input = {}, deps) {
136
135
  const domain = normalizeDomain(input.domain);
137
136
  return `${word}@${domain}`;
138
137
  }
139
- // Exhaustive check.
140
- const _never = type;
141
- return _never;
138
+ // Exhaustive check — TypeScript errors here if a new type is added without a handler.
139
+ throw new Error(`Unsupported username generator type: ${type}`);
142
140
  }
@@ -2,12 +2,22 @@
2
2
  import { z } from 'zod';
3
3
  export function registerTools(server, deps) {
4
4
  const toolMeta = {};
5
- const isReadOnly = (() => {
6
- const v = (process.env.READONLY ?? process.env.KEYCHAIN_READONLY ?? 'false')
7
- .trim()
8
- .toLowerCase();
9
- return v === '1' || v === 'true' || v === 'yes' || v === 'on';
10
- })();
5
+ function parseBoolEnv(...names) {
6
+ for (const name of names) {
7
+ const v = process.env[name];
8
+ if (v !== undefined) {
9
+ const lower = v.trim().toLowerCase();
10
+ if (lower === '1' ||
11
+ lower === 'true' ||
12
+ lower === 'yes' ||
13
+ lower === 'on')
14
+ return true;
15
+ }
16
+ }
17
+ return false;
18
+ }
19
+ const isReadOnly = parseBoolEnv('READONLY', 'KEYCHAIN_READONLY');
20
+ const isNoReveal = parseBoolEnv('NOREVEAL', 'KEYCHAIN_NOREVEAL');
11
21
  function readonlyBlocked() {
12
22
  return {
13
23
  structuredContent: { ok: false, error: 'READONLY' },
@@ -15,6 +25,16 @@ export function registerTools(server, deps) {
15
25
  isError: true,
16
26
  };
17
27
  }
28
+ /** Strip reveal from input when NOREVEAL is set. */
29
+ function clampReveal(input) {
30
+ if (isNoReveal && input.reveal) {
31
+ return { ...input, reveal: false };
32
+ }
33
+ return input;
34
+ }
35
+ function effectiveReveal(input) {
36
+ return isNoReveal ? false : (input.reveal ?? false);
37
+ }
18
38
  function toolResult(kind, value, revealed) {
19
39
  return { result: { kind, value, revealed } };
20
40
  }
@@ -120,7 +140,7 @@ export function registerTools(server, deps) {
120
140
  _meta: toolMeta,
121
141
  }, async (input, extra) => {
122
142
  const sdk = await deps.getSdk(extra.authInfo);
123
- const result = await sdk.generate(input);
143
+ const result = await sdk.generate(clampReveal(input));
124
144
  return {
125
145
  structuredContent: toolResult('generated', result.value, result.revealed),
126
146
  content: [{ type: 'text', text: 'OK' }],
@@ -148,7 +168,7 @@ export function registerTools(server, deps) {
148
168
  _meta: toolMeta,
149
169
  }, async (input, extra) => {
150
170
  const sdk = await deps.getSdk(extra.authInfo);
151
- const result = await sdk.generateUsername(input);
171
+ const result = await sdk.generateUsername(clampReveal(input));
152
172
  return {
153
173
  structuredContent: toolResult('generated', result.value, result.revealed),
154
174
  content: [{ type: 'text', text: 'OK' }],
@@ -427,7 +447,9 @@ export function registerTools(server, deps) {
427
447
  _meta: toolMeta,
428
448
  }, async (input, extra) => {
429
449
  const sdk = await deps.getSdk(extra.authInfo);
430
- const item = await sdk.getItem(input.id, { reveal: input.reveal });
450
+ const item = await sdk.getItem(input.id, {
451
+ reveal: effectiveReveal(input),
452
+ });
431
453
  return {
432
454
  structuredContent: { item },
433
455
  content: [{ type: 'text', text: 'OK' }],
@@ -460,7 +482,7 @@ export function registerTools(server, deps) {
460
482
  _meta: toolMeta,
461
483
  }, async (input, extra) => {
462
484
  const sdk = await deps.getSdk(extra.authInfo);
463
- const notes = await sdk.getNotes({ term: input.term }, { reveal: input.reveal });
485
+ const notes = await sdk.getNotes({ term: input.term }, { reveal: effectiveReveal(input) });
464
486
  return {
465
487
  structuredContent: toolResult('notes', notes.value, notes.revealed),
466
488
  content: [{ type: 'text', text: 'OK' }],
@@ -618,7 +640,7 @@ export function registerTools(server, deps) {
618
640
  if (isReadOnly)
619
641
  return readonlyBlocked();
620
642
  const sdk = await deps.getSdk(extra.authInfo);
621
- const item = await sdk.createAttachment(input);
643
+ const item = await sdk.createAttachment(clampReveal(input));
622
644
  return {
623
645
  structuredContent: { item },
624
646
  content: [{ type: 'text', text: 'Attached.' }],
@@ -637,7 +659,7 @@ export function registerTools(server, deps) {
637
659
  if (isReadOnly)
638
660
  return readonlyBlocked();
639
661
  const sdk = await deps.getSdk(extra.authInfo);
640
- const item = await sdk.deleteAttachment(input);
662
+ const item = await sdk.deleteAttachment(clampReveal(input));
641
663
  return {
642
664
  structuredContent: { item },
643
665
  content: [{ type: 'text', text: 'Deleted.' }],
@@ -885,7 +907,7 @@ export function registerTools(server, deps) {
885
907
  _meta: toolMeta,
886
908
  }, async (input, extra) => {
887
909
  const sdk = await deps.getSdk(extra.authInfo);
888
- const password = await sdk.getPassword({ term: input.term }, { reveal: input.reveal });
910
+ const password = await sdk.getPassword({ term: input.term }, { reveal: effectiveReveal(input) });
889
911
  return {
890
912
  structuredContent: toolResult('password', password.value, password.revealed),
891
913
  content: [{ type: 'text', text: 'OK' }],
@@ -902,7 +924,7 @@ export function registerTools(server, deps) {
902
924
  _meta: toolMeta,
903
925
  }, async (input, extra) => {
904
926
  const sdk = await deps.getSdk(extra.authInfo);
905
- const totp = await sdk.getTotp({ term: input.term }, { reveal: input.reveal });
927
+ const totp = await sdk.getTotp({ term: input.term }, { reveal: effectiveReveal(input) });
906
928
  return {
907
929
  structuredContent: toolResult('totp', totp.value, totp.revealed),
908
930
  content: [{ type: 'text', text: 'OK' }],
@@ -920,7 +942,7 @@ export function registerTools(server, deps) {
920
942
  }, async (input, extra) => {
921
943
  const sdk = await deps.getSdk(extra.authInfo);
922
944
  const history = await sdk.getPasswordHistory(input.id, {
923
- reveal: input.reveal,
945
+ reveal: effectiveReveal(input),
924
946
  });
925
947
  return {
926
948
  structuredContent: toolResult('password_history', history.value, history.revealed),
@@ -1050,7 +1072,7 @@ export function registerTools(server, deps) {
1050
1072
  id: input.id,
1051
1073
  mode: input.mode,
1052
1074
  uris: normalizeUrisInput(input.uris) ?? [],
1053
- reveal: input.reveal,
1075
+ reveal: effectiveReveal(input),
1054
1076
  });
1055
1077
  return {
1056
1078
  structuredContent: { item: updated },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "private": false,
3
3
  "name": "@icoretech/warden-mcp",
4
- "version": "0.1.8",
4
+ "version": "0.1.10",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "description": "Vaultwarden/Bitwarden MCP server backed by Bitwarden CLI (bw).",
@@ -37,6 +37,7 @@
37
37
  "build": "tsc -p .",
38
38
  "start": "node dist/server.js",
39
39
  "test": "npm run build && node --test \"dist/**/*.test.js\"",
40
+ "test:coverage": "npm run build && node --test --experimental-test-coverage \"dist/**/*.test.js\"",
40
41
  "test:integration": "npm run build && node --test --test-timeout=45000 \"dist/integration/**/*.test.js\"",
41
42
  "test:session-regression": "node scripts/session-flood-regression.mjs",
42
43
  "lint": "biome check --write --assist-enabled=true . && tsc --noEmit"