@safekb/mcp 0.13.0 → 0.14.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/README.md CHANGED
@@ -377,6 +377,10 @@ custom fields and the key name of every custom field (sealed ones too) stay read
377
377
  sealed credentials start with the tenant default (Settings → Sealed credentials; `use_only` until
378
378
  set). Only you make anything more visible, in the SafeKB app; `set_sealed_credential_llm_visibility`
379
379
  only tightens. The client enforces this: a client older than 0.11.0 reveals every sealed credential, and 0.11.0 ignores field overrides.
380
+ - Entering a secret without the agent seeing it: `insert_sealed_credential` and
381
+ `update_sealed_credential` take `typed_at_sidecar`, e.g. `["custom:number", "custom:cvc", "password"]`.
382
+ The call waits while you type each field at the sidecar (`safekb-mcp --sidecar`), hidden and twice;
383
+ the agent passes only the visible fields. Or enter it in the SafeKB web app.
380
384
  - Using a secret without showing it: SafeKB only provides the value. `secret_exec` runs your own
381
385
  program with it in env, stdin or a temp file (`files`, mode 0600, deleted afterwards), never argv;
382
386
  every integration (HTTP API, browser form, `adb shell input text`, ssh, wallet, `cryptsetup`, …) is
@@ -2581,7 +2581,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
2581
2581
  // package.json
2582
2582
  var package_default = {
2583
2583
  name: "@safekb/mcp",
2584
- version: "0.13.0",
2584
+ version: "0.14.1",
2585
2585
  description: "SafeKB local MCP client: proxies the hosted SafeKB tools and adds zero-knowledge sealed documents.",
2586
2586
  license: "SEE LICENSE IN LICENSE",
2587
2587
  author: "Mustafa Öztürk",
@@ -9895,6 +9895,47 @@ class CredentialMigration {
9895
9895
  }
9896
9896
  }
9897
9897
 
9898
+ // src/sealed/typedFields.ts
9899
+ var NAMED = ["password", "totp", "sealed_notes"];
9900
+ var CUSTOM_PREFIX3 = customFieldName("");
9901
+ var NOTHING_TYPED = { named: {}, custom: [] };
9902
+ function checked(fields2, passed) {
9903
+ if (new Set(fields2).size !== fields2.length) {
9904
+ throw new ClientError("typed_at_sidecar names a field twice");
9905
+ }
9906
+ for (const field of fields2) {
9907
+ if (NAMED.includes(field)) {
9908
+ if (passed[field] !== undefined) {
9909
+ throw new ClientError(`${field} is both passed and typed_at_sidecar: pass it one way only`);
9910
+ }
9911
+ } else if (field.startsWith(CUSTOM_PREFIX3) && field.length > CUSTOM_PREFIX3.length) {
9912
+ const key = field.slice(CUSTOM_PREFIX3.length);
9913
+ if (passed.custom?.some((item) => item.key === key)) {
9914
+ throw new ClientError(`custom field '${key}' is both in custom and typed_at_sidecar: pass it one way only`);
9915
+ }
9916
+ } else {
9917
+ throw new ClientError(`typed_at_sidecar: '${field}' is not a sealed field. Use password, totp, sealed_notes or custom:{key}.`);
9918
+ }
9919
+ }
9920
+ }
9921
+ async function typeAtSidecar(entry, credential2, fields2, passed) {
9922
+ checked(fields2, passed);
9923
+ const typed = { named: {}, custom: [] };
9924
+ for (const field of fields2) {
9925
+ const value = await entry.secret(credential2, field);
9926
+ if (field.startsWith(CUSTOM_PREFIX3)) {
9927
+ typed.custom.push({ key: field.slice(CUSTOM_PREFIX3.length), value });
9928
+ } else {
9929
+ typed.named[field] = value;
9930
+ }
9931
+ }
9932
+ return typed;
9933
+ }
9934
+ function withTypedCustom(sealed, typed) {
9935
+ const keys = new Set(typed.custom.map(({ key }) => key));
9936
+ return [...sealed.filter(({ key }) => !keys.has(key)), ...typed.custom];
9937
+ }
9938
+
9898
9939
  // src/sealed/credentialService.ts
9899
9940
  var NOT_FOUND2 = 404;
9900
9941
  var VISIBLE_NAMES = [
@@ -10006,6 +10047,7 @@ class CredentialService {
10006
10047
  const id = newObjectId();
10007
10048
  const custom = splitCustom(args.custom ?? []);
10008
10049
  const { dek, encDek } = await this.access.newDek(args.root_id, id);
10050
+ const typed = await this.typed(args.title, args);
10009
10051
  const save = await this.access.seal(id, dek, {
10010
10052
  title: args.title,
10011
10053
  username: textOrNull(args.username, null),
@@ -10014,10 +10056,10 @@ class CredentialService {
10014
10056
  notes: textOrNull(args.notes, null),
10015
10057
  custom: custom.visible
10016
10058
  }, {
10017
- password: textOrNull(args.password, null),
10018
- totp: this.checkedTotp(textOrNull(args.totp, null)),
10019
- sealed_notes: textOrNull(args.sealed_notes, null),
10020
- custom: custom.sealed
10059
+ password: typed.named.password ?? textOrNull(args.password, null),
10060
+ totp: this.checkedTotp(typed.named.totp ?? textOrNull(args.totp, null)),
10061
+ sealed_notes: typed.named.sealed_notes ?? textOrNull(args.sealed_notes, null),
10062
+ custom: withTypedCustom(custom.sealed, typed)
10021
10063
  });
10022
10064
  const sigs = await this.access.sign({
10023
10065
  op: "credential_create",
@@ -10051,6 +10093,7 @@ class CredentialService {
10051
10093
  throw new ClientError(`${attestation.warning} ${describeChanges(attestation.changed_visible_fields, current)} ` + "Show this to the user. Only after they have reviewed it, repeat with accept_visible_changes: true (and pass the right values for any field to correct).");
10052
10094
  }
10053
10095
  const custom = args.custom === undefined ? { visible: current.custom, sealed: values.custom } : splitCustom(args.custom);
10096
+ const typed = await this.typed(args.title ?? current.title, args);
10054
10097
  const save = await this.access.seal(credential2.id, dek, {
10055
10098
  title: args.title ?? current.title,
10056
10099
  username: textOrNull(args.username, current.username),
@@ -10059,10 +10102,10 @@ class CredentialService {
10059
10102
  notes: textOrNull(args.notes, current.notes),
10060
10103
  custom: custom.visible
10061
10104
  }, {
10062
- password: textOrNull(args.password, values.password),
10063
- totp: this.checkedTotp(textOrNull(args.totp, values.totp)),
10064
- sealed_notes: textOrNull(args.sealed_notes, values.sealed_notes),
10065
- custom: custom.sealed
10105
+ password: typed.named.password ?? textOrNull(args.password, values.password),
10106
+ totp: this.checkedTotp(typed.named.totp ?? textOrNull(args.totp, values.totp)),
10107
+ sealed_notes: typed.named.sealed_notes ?? textOrNull(args.sealed_notes, values.sealed_notes),
10108
+ custom: withTypedCustom(custom.sealed, typed)
10066
10109
  });
10067
10110
  const sigs = await this.access.sign({
10068
10111
  op: "credential_update",
@@ -10120,6 +10163,10 @@ class CredentialService {
10120
10163
  });
10121
10164
  return { id: credentialId, deleted: true };
10122
10165
  }
10166
+ typed(title, args) {
10167
+ const atSidecar = args.typed_at_sidecar;
10168
+ return atSidecar === undefined || atSidecar.fields.length === 0 ? Promise.resolve(NOTHING_TYPED) : typeAtSidecar(atSidecar.entry, title, atSidecar.fields, args);
10169
+ }
10123
10170
  checkedTotp(value) {
10124
10171
  if (value !== null)
10125
10172
  parseTotp(value);
@@ -31313,7 +31360,7 @@ function decodeDirective(html) {
31313
31360
 
31314
31361
  // ../shared/js/canvas-markdown/src/html.ts
31315
31362
  var ENTITY = /&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);/g;
31316
- var NAMED = {
31363
+ var NAMED2 = {
31317
31364
  amp: "&",
31318
31365
  lt: "<",
31319
31366
  gt: ">",
@@ -31329,7 +31376,7 @@ function unescapeHtmlText(text2) {
31329
31376
  return String.fromCodePoint(Number.parseInt(body.slice(2), 16));
31330
31377
  if (body.startsWith("#"))
31331
31378
  return String.fromCodePoint(Number(body.slice(1)));
31332
- return NAMED[body];
31379
+ return NAMED2[body];
31333
31380
  });
31334
31381
  }
31335
31382
 
@@ -31660,12 +31707,12 @@ class BlockParser {
31660
31707
  }
31661
31708
  item(taskable) {
31662
31709
  this.pos++;
31663
- const checked = taskable ? this.takeTaskMarker() : null;
31710
+ const checked2 = taskable ? this.takeTaskMarker() : null;
31664
31711
  const children = this.blocks((token) => token.type === "list_item_close");
31665
31712
  this.pos++;
31666
31713
  return {
31667
- task: checked !== null,
31668
- checked: checked === true,
31714
+ task: checked2 !== null,
31715
+ checked: checked2 === true,
31669
31716
  children: children.length > 0 ? children : [node("paragraph")]
31670
31717
  };
31671
31718
  }
@@ -34474,7 +34521,7 @@ import { connect } from "node:net";
34474
34521
 
34475
34522
  // src/sidecar/protocol.ts
34476
34523
  import { dirname as dirname4, join as join6 } from "node:path";
34477
- var SIDECAR_PROTOCOL_VERSION = 2;
34524
+ var SIDECAR_PROTOCOL_VERSION = 3;
34478
34525
  var SIDECAR_SOCKET_NAME = "sidecar.sock";
34479
34526
  var SIDECAR_SUPPORTED = process.platform !== "win32";
34480
34527
  function defaultSidecarSocket(configPath) {
@@ -34484,12 +34531,16 @@ function defaultSidecarSocket(configPath) {
34484
34531
  // src/sidecar/client.ts
34485
34532
  var SIDECAR_PROMPT_TIMEOUT_MS = 5 * 60 * 1000;
34486
34533
  var SIDECAR_QUICK_TIMEOUT_MS = 3000;
34487
- var PROMPTING_OPS = new Set([
34488
- "lookup",
34489
- "new",
34490
- "passphrase",
34491
- "confirm"
34492
- ]);
34534
+ var WAITS_FOR_USER = {
34535
+ holds: false,
34536
+ lookup: true,
34537
+ new: true,
34538
+ discard: false,
34539
+ settle: false,
34540
+ passphrase: true,
34541
+ secret: true,
34542
+ confirm: true
34543
+ };
34493
34544
  var NOT_RUNNING_CODES = new Set(["ENOENT", "ECONNREFUSED"]);
34494
34545
 
34495
34546
  class SidecarNotRunningError extends ClientError {
@@ -34529,7 +34580,7 @@ class SidecarClient {
34529
34580
  this.quickTimeoutMs = quickTimeoutMs;
34530
34581
  }
34531
34582
  async request(request) {
34532
- const text2 = await send(this.socketPath, request, PROMPTING_OPS.has(request.op) ? this.promptTimeoutMs : this.quickTimeoutMs);
34583
+ const text2 = await send(this.socketPath, request, WAITS_FOR_USER[request.op] ? this.promptTimeoutMs : this.quickTimeoutMs);
34533
34584
  let response;
34534
34585
  try {
34535
34586
  response = JSON.parse(text2);
@@ -34619,6 +34670,10 @@ class SidecarQuestions {
34619
34670
  const { passphrase } = await this.asking("The passphrase", () => this.client.request({ op: "passphrase", of }));
34620
34671
  return passphrase;
34621
34672
  }
34673
+ async secret(credential2, field2) {
34674
+ const { value } = await this.asking(`The value of ${field2}`, () => this.client.request({ op: "secret", credential: credential2, field: field2 }));
34675
+ return value;
34676
+ }
34622
34677
  async ask(notice, question) {
34623
34678
  const { typed: typed2 } = await this.asking("The confirmation", () => this.client.request({ op: "confirm", notice, question }));
34624
34679
  return typed2;
@@ -34648,6 +34703,9 @@ class NoSidecarQuestions {
34648
34703
  ask() {
34649
34704
  return this.refuse();
34650
34705
  }
34706
+ secret() {
34707
+ return Promise.reject(new ClientError("Typing a secret at the sidecar is not available on this platform. Ask the user to enter it in the SafeKB web app (Credentials)."));
34708
+ }
34651
34709
  refuse() {
34652
34710
  return Promise.reject(new ClientError(`This needs the user to type at the sidecar, which is not available on this platform. Ask them to run it at a terminal: ${this.commandLine}`));
34653
34711
  }
@@ -34721,6 +34779,15 @@ The MCP client asks for the passphrase of ${printable2(of)}. It is used for this
34721
34779
  return typed2.length === 0 ? null : typed2;
34722
34780
  });
34723
34781
  }
34782
+ secret({ credential: credential2, field: field2 }, signal) {
34783
+ return this.inTurn(async () => {
34784
+ signal.throwIfAborted();
34785
+ this.reader.write(`
34786
+ The MCP client asks for ${printable2(field2)} of credential "${printable2(credential2)}". It is sealed on this machine; the model never sees it.
34787
+ `);
34788
+ return this.readTwice(`${printable2(field2)} (not shown; empty to decline): `, signal);
34789
+ });
34790
+ }
34724
34791
  typed(notice, question, signal) {
34725
34792
  return this.inTurn(async () => {
34726
34793
  signal.throwIfAborted();
@@ -34852,9 +34919,12 @@ ${printableLines(notice)}`);
34852
34919
  ` : `Forgotten.
34853
34920
  `);
34854
34921
  }
34855
- async readNewPassphrase(signal) {
34922
+ readNewPassphrase(signal) {
34923
+ return this.readTwice("New passphrase (not shown; empty to decline): ", signal);
34924
+ }
34925
+ async readTwice(prompt, signal) {
34856
34926
  for (;; ) {
34857
- const first = await this.reader.read("New passphrase (not shown; empty to decline): ", { hidden: true, signal });
34927
+ const first = await this.reader.read(prompt, { hidden: true, signal });
34858
34928
  if (first.length === 0)
34859
34929
  return null;
34860
34930
  const second = await this.reader.read("Repeat it: ", {
@@ -35118,6 +35188,8 @@ class SidecarServer {
35118
35188
  return {};
35119
35189
  case "passphrase":
35120
35190
  return { passphrase: await this.passphrase(request, clientGone) };
35191
+ case "secret":
35192
+ return { value: await this.secret(request, clientGone) };
35121
35193
  case "confirm":
35122
35194
  return {
35123
35195
  typed: await this.prompter.typed(requireText(request.notice, "notice"), requireText(request.question, "question"), clientGone)
@@ -35144,6 +35216,16 @@ class SidecarServer {
35144
35216
  }
35145
35217
  return typed2;
35146
35218
  }
35219
+ async secret(request, clientGone) {
35220
+ const credential2 = requireText(request.credential, "credential");
35221
+ const field2 = requireText(request.field, "field");
35222
+ const typed2 = await this.prompter.secret({ credential: credential2, field: field2 }, clientGone);
35223
+ clientGone.throwIfAborted();
35224
+ if (typed2 === null) {
35225
+ throw new ClientError(`The sidecar's user entered no ${field2} for credential "${credential2}"`);
35226
+ }
35227
+ return typed2;
35228
+ }
35147
35229
  async create(rootId, label2, clientGone) {
35148
35230
  const chosen = await this.prompter.newSecret(rootId, label2, clientGone);
35149
35231
  clientGone.throwIfAborted();
@@ -36097,13 +36179,15 @@ var sealedFields = {
36097
36179
  password: exports_external.string().describe(`Sealed. ${CLEARABLE}`).optional(),
36098
36180
  totp: exports_external.string().describe(`Sealed TOTP seed (base32) or otpauth://totp URI. ${CLEARABLE}`).optional(),
36099
36181
  sealed_notes: exports_external.string().describe(`Sealed notes. ${CLEARABLE}`).optional(),
36182
+ typed_at_sidecar: exports_external.array(exports_external.string().min(1)).describe(`Sealed fields the user types at the sidecar instead of you passing them: password, totp, sealed_notes or custom:{key} (a new or replaced sealed custom field). The call waits while they type each one, hidden and twice. ${SIDECAR_TYPING} Use this for any secret the user has not already given you, e.g. a card number, CVC or PIN; set the visible fields (title, holder name, last 4 digits) yourself.`).optional(),
36100
36183
  custom: exports_external.array(exports_external.strictObject({
36101
36184
  key: exports_external.string().min(1).describe("Field name. Always stored in clear, also for a sealed field: never put a secret in it."),
36102
36185
  value: exports_external.string(),
36103
36186
  sealed: exports_external.boolean().describe("true: the value is encrypted on this machine (the key name is not). false: visible to the server.")
36104
36187
  })).describe("The complete list of custom fields; it replaces the stored list.").optional()
36105
36188
  };
36106
- function credentialTools(credentials, migration, names) {
36189
+ function credentialTools(credentials, migration, names, entry) {
36190
+ const atSidecar = (fields2) => fields2 === undefined ? undefined : { fields: fields2, entry };
36107
36191
  return [
36108
36192
  defineTool("reveal_sealed_credential", `Decrypt one sealed field of a sealed credential on this machine. The server only sees that the ciphertext was fetched. Find credentials and their visible fields with the standard credential tools (search_credentials, get_credential). Refused unless its llm_visibility is visible. ${EXPOSURE_NOTE}`, {
36109
36193
  credential_id: credentialId,
@@ -36123,7 +36207,8 @@ function credentialTools(credentials, migration, names) {
36123
36207
  ...sealedFields
36124
36208
  }, async (args) => credentials.insert({
36125
36209
  ...args,
36126
- root_id: await names.resolve(args.root_id)
36210
+ root_id: await names.resolve(args.root_id),
36211
+ typed_at_sidecar: atSidecar(args.typed_at_sidecar)
36127
36212
  })),
36128
36213
  defineTool("update_sealed_credential", `Change a sealed credential with its seal root's signature. Pass only the fields to change; the save also attests the visible fields as they are now. If visible fields were changed without the key since the last sealed save, the call fails and names them until accept_visible_changes is true. ${SECRET_ARGUMENT_NOTE}`, {
36129
36214
  credential_id: credentialId,
@@ -36132,7 +36217,10 @@ function credentialTools(credentials, migration, names) {
36132
36217
  title: exports_external.string().min(1).describe("Visible to the server.").optional(),
36133
36218
  ...visibleFields,
36134
36219
  ...sealedFields
36135
- }, (args) => credentials.update(args)),
36220
+ }, (args) => credentials.update({
36221
+ ...args,
36222
+ typed_at_sidecar: atSidecar(args.typed_at_sidecar)
36223
+ })),
36136
36224
  defineTool("move_sealed_credential", "Move a sealed credential to another seal root of its space. Both roots must be openable here: its key is re-wrapped under the new root and both roots sign. Afterwards only the new root's secret opens it. Whoever held the old root's secret keeps what they already read.", {
36137
36225
  credential_id: credentialId,
36138
36226
  expected_version: expectedVersion,
@@ -36335,11 +36423,12 @@ The keys of this machine, and backups, are handled by tools that never take or r
36335
36423
 
36336
36424
  A credential with \`sealed: true\` belongs to a seal root. Its title, username, url, tags, notes and visible custom fields are readable by the server: find it with the standard credential tools. Its password, TOTP seed, sealed_notes and sealed custom fields open only here: reveal_sealed_credential, get_sealed_totp, insert_sealed_credential, update_sealed_credential.
36337
36425
 
36426
+ - A secret the user has not given you yet (a card number, a PIN, a new password): never ask for it in the conversation. Call insert_sealed_credential or update_sealed_credential with typed_at_sidecar naming the fields; the user types them at the sidecar (safekb-mcp --sidecar) while the call waits. Or they enter it in the SafeKB web app.
36338
36427
  - Reveal a secret only when the task needs it, and prefer get_sealed_totp over revealing a TOTP seed.
36339
36428
  - llm_visibility is set per credential, and llm_field_visibility overrides it for single sealed fields. reveal_sealed_credential and get_sealed_totp work only on a field that is visible. Only the user makes anything visible, in the SafeKB app.
36340
36429
  - To use a secret without seeing it (visible or use_only fields): secret_exec runs your own program with it in env, stdin or a temp file; every integration (HTTP, browser, adb, ssh, ...) is that program. Name it by handle, {{secret://<credential_id>/<field>}}; a totp handle becomes the current code. Prefer this over revealing, even for a visible field. A human_only field is never used by a tool: ask the user to enter it.
36341
36430
  - visible_tampered means the visible fields were changed without the key since the last sealed save. Do not trust the url or username of such a credential until the user has reviewed the change.`;
36342
- function createSealedTools(ctx) {
36431
+ function createSealedTools(ctx, entry) {
36343
36432
  const services = createSealedServices(ctx);
36344
36433
  const { names } = ctx;
36345
36434
  return [
@@ -36348,7 +36437,7 @@ function createSealedTools(ctx) {
36348
36437
  ...treeTools(services.tree, names),
36349
36438
  ...attachmentTools(services.attachments),
36350
36439
  ...depositTools(services.deposits, names),
36351
- ...credentialTools(services.credentials, services.migration, names),
36440
+ ...credentialTools(services.credentials, services.migration, names, entry),
36352
36441
  ...sinkTools(new ExecSink(services.secrets))
36353
36442
  ];
36354
36443
  }
@@ -36694,7 +36783,7 @@ async function openTools(options, config3, session, systemKeyring, chosen) {
36694
36783
  };
36695
36784
  const conversions = createConversions(sealed, new KbApi(http), sharedCanvasMarkdown);
36696
36785
  const localTools = [
36697
- ...createSealedTools(sealed),
36786
+ ...createSealedTools(sealed, userQuestionsOf(options)("")),
36698
36787
  ...unsealTools(conversions.unseal),
36699
36788
  ...options.sealedOnly ? [] : sealTools(conversions.seal),
36700
36789
  ...await adminTools({ options, config: config3, systemKeyring, chosen })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safekb/mcp",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "SafeKB local MCP client: proxies the hosted SafeKB tools and adds zero-knowledge sealed documents.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Mustafa Öztürk",