@safekb/mcp 0.13.0 → 0.14.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/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.0",
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) {
@@ -34619,6 +34666,10 @@ class SidecarQuestions {
34619
34666
  const { passphrase } = await this.asking("The passphrase", () => this.client.request({ op: "passphrase", of }));
34620
34667
  return passphrase;
34621
34668
  }
34669
+ async secret(credential2, field2) {
34670
+ const { value } = await this.asking(`The value of ${field2}`, () => this.client.request({ op: "secret", credential: credential2, field: field2 }));
34671
+ return value;
34672
+ }
34622
34673
  async ask(notice, question) {
34623
34674
  const { typed: typed2 } = await this.asking("The confirmation", () => this.client.request({ op: "confirm", notice, question }));
34624
34675
  return typed2;
@@ -34648,6 +34699,9 @@ class NoSidecarQuestions {
34648
34699
  ask() {
34649
34700
  return this.refuse();
34650
34701
  }
34702
+ secret() {
34703
+ 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)."));
34704
+ }
34651
34705
  refuse() {
34652
34706
  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
34707
  }
@@ -34721,6 +34775,15 @@ The MCP client asks for the passphrase of ${printable2(of)}. It is used for this
34721
34775
  return typed2.length === 0 ? null : typed2;
34722
34776
  });
34723
34777
  }
34778
+ secret({ credential: credential2, field: field2 }, signal) {
34779
+ return this.inTurn(async () => {
34780
+ signal.throwIfAborted();
34781
+ this.reader.write(`
34782
+ The MCP client asks for ${printable2(field2)} of credential "${printable2(credential2)}". It is sealed on this machine; the model never sees it.
34783
+ `);
34784
+ return this.readTwice(`${printable2(field2)} (not shown; empty to decline): `, signal);
34785
+ });
34786
+ }
34724
34787
  typed(notice, question, signal) {
34725
34788
  return this.inTurn(async () => {
34726
34789
  signal.throwIfAborted();
@@ -34852,9 +34915,12 @@ ${printableLines(notice)}`);
34852
34915
  ` : `Forgotten.
34853
34916
  `);
34854
34917
  }
34855
- async readNewPassphrase(signal) {
34918
+ readNewPassphrase(signal) {
34919
+ return this.readTwice("New passphrase (not shown; empty to decline): ", signal);
34920
+ }
34921
+ async readTwice(prompt, signal) {
34856
34922
  for (;; ) {
34857
- const first = await this.reader.read("New passphrase (not shown; empty to decline): ", { hidden: true, signal });
34923
+ const first = await this.reader.read(prompt, { hidden: true, signal });
34858
34924
  if (first.length === 0)
34859
34925
  return null;
34860
34926
  const second = await this.reader.read("Repeat it: ", {
@@ -35118,6 +35184,8 @@ class SidecarServer {
35118
35184
  return {};
35119
35185
  case "passphrase":
35120
35186
  return { passphrase: await this.passphrase(request, clientGone) };
35187
+ case "secret":
35188
+ return { value: await this.secret(request, clientGone) };
35121
35189
  case "confirm":
35122
35190
  return {
35123
35191
  typed: await this.prompter.typed(requireText(request.notice, "notice"), requireText(request.question, "question"), clientGone)
@@ -35144,6 +35212,16 @@ class SidecarServer {
35144
35212
  }
35145
35213
  return typed2;
35146
35214
  }
35215
+ async secret(request, clientGone) {
35216
+ const credential2 = requireText(request.credential, "credential");
35217
+ const field2 = requireText(request.field, "field");
35218
+ const typed2 = await this.prompter.secret({ credential: credential2, field: field2 }, clientGone);
35219
+ clientGone.throwIfAborted();
35220
+ if (typed2 === null) {
35221
+ throw new ClientError(`The sidecar's user entered no ${field2} for credential "${credential2}"`);
35222
+ }
35223
+ return typed2;
35224
+ }
35147
35225
  async create(rootId, label2, clientGone) {
35148
35226
  const chosen = await this.prompter.newSecret(rootId, label2, clientGone);
35149
35227
  clientGone.throwIfAborted();
@@ -36097,13 +36175,15 @@ var sealedFields = {
36097
36175
  password: exports_external.string().describe(`Sealed. ${CLEARABLE}`).optional(),
36098
36176
  totp: exports_external.string().describe(`Sealed TOTP seed (base32) or otpauth://totp URI. ${CLEARABLE}`).optional(),
36099
36177
  sealed_notes: exports_external.string().describe(`Sealed notes. ${CLEARABLE}`).optional(),
36178
+ 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
36179
  custom: exports_external.array(exports_external.strictObject({
36101
36180
  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
36181
  value: exports_external.string(),
36103
36182
  sealed: exports_external.boolean().describe("true: the value is encrypted on this machine (the key name is not). false: visible to the server.")
36104
36183
  })).describe("The complete list of custom fields; it replaces the stored list.").optional()
36105
36184
  };
36106
- function credentialTools(credentials, migration, names) {
36185
+ function credentialTools(credentials, migration, names, entry) {
36186
+ const atSidecar = (fields2) => fields2 === undefined ? undefined : { fields: fields2, entry };
36107
36187
  return [
36108
36188
  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
36189
  credential_id: credentialId,
@@ -36123,7 +36203,8 @@ function credentialTools(credentials, migration, names) {
36123
36203
  ...sealedFields
36124
36204
  }, async (args) => credentials.insert({
36125
36205
  ...args,
36126
- root_id: await names.resolve(args.root_id)
36206
+ root_id: await names.resolve(args.root_id),
36207
+ typed_at_sidecar: atSidecar(args.typed_at_sidecar)
36127
36208
  })),
36128
36209
  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
36210
  credential_id: credentialId,
@@ -36132,7 +36213,10 @@ function credentialTools(credentials, migration, names) {
36132
36213
  title: exports_external.string().min(1).describe("Visible to the server.").optional(),
36133
36214
  ...visibleFields,
36134
36215
  ...sealedFields
36135
- }, (args) => credentials.update(args)),
36216
+ }, (args) => credentials.update({
36217
+ ...args,
36218
+ typed_at_sidecar: atSidecar(args.typed_at_sidecar)
36219
+ })),
36136
36220
  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
36221
  credential_id: credentialId,
36138
36222
  expected_version: expectedVersion,
@@ -36335,11 +36419,12 @@ The keys of this machine, and backups, are handled by tools that never take or r
36335
36419
 
36336
36420
  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
36421
 
36422
+ - 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
36423
  - Reveal a secret only when the task needs it, and prefer get_sealed_totp over revealing a TOTP seed.
36339
36424
  - 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
36425
  - 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
36426
  - 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) {
36427
+ function createSealedTools(ctx, entry) {
36343
36428
  const services = createSealedServices(ctx);
36344
36429
  const { names } = ctx;
36345
36430
  return [
@@ -36348,7 +36433,7 @@ function createSealedTools(ctx) {
36348
36433
  ...treeTools(services.tree, names),
36349
36434
  ...attachmentTools(services.attachments),
36350
36435
  ...depositTools(services.deposits, names),
36351
- ...credentialTools(services.credentials, services.migration, names),
36436
+ ...credentialTools(services.credentials, services.migration, names, entry),
36352
36437
  ...sinkTools(new ExecSink(services.secrets))
36353
36438
  ];
36354
36439
  }
@@ -36694,7 +36779,7 @@ async function openTools(options, config3, session, systemKeyring, chosen) {
36694
36779
  };
36695
36780
  const conversions = createConversions(sealed, new KbApi(http), sharedCanvasMarkdown);
36696
36781
  const localTools = [
36697
- ...createSealedTools(sealed),
36782
+ ...createSealedTools(sealed, userQuestionsOf(options)("")),
36698
36783
  ...unsealTools(conversions.unseal),
36699
36784
  ...options.sealedOnly ? [] : sealTools(conversions.seal),
36700
36785
  ...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.0",
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",