@safekb/mcp 0.12.1 → 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,15 +377,19 @@ 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
- - Using a secret without showing it (0.12.0): `secret_exec` runs a program with it in env or stdin
381
- (never argv), `secret_http` sends one request with it in the url, headers or body (redirects are not
382
- followed), and `secret_adb_input` types it on an Android device (`adb shell input text`, value on stdin;
383
- printable ASCII only). The agent names it by handle, `{{secret://<credential_id>/<field>}}`; a `totp`
384
- handle becomes the current code. `|form`, `|json` or `|url` encodes the value for where it goes
385
- (`{{secret://<id>/password|form}}` in a form body); without one it goes in as is, URL-encoded in a url.
386
- `visible` and `use_only` fields can be used; `human_only` fields never.
387
- Output comes back scrubbed of the value, in any mix of percent-, form-, JSON- and adb-encoding and as
388
- base64 or hex, with the credentials and fields used. Scrubbing stops careless leaks, not a program written to leak one.
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.
384
+ - Using a secret without showing it: SafeKB only provides the value. `secret_exec` runs your own
385
+ program with it in env, stdin or a temp file (`files`, mode 0600, deleted afterwards), never argv;
386
+ every integration (HTTP API, browser form, `adb shell input text`, ssh, wallet, `cryptsetup`, …) is
387
+ that program. The agent names a secret by handle, `{{secret://<credential_id>/<field>}}`; a `totp`
388
+ handle becomes the current code, and `|form`, `|json` or `|url` encodes the value for where the
389
+ program puts it unchanged. `visible` and `use_only` fields can be used; `human_only` fields never.
390
+ Output comes back scrubbed of the value, in any mix of percent-, form- and JSON-encoding and as
391
+ base64 or hex, with the credentials and fields used. Scrubbing stops careless leaks, not a program
392
+ written to leak one.
389
393
  - Visible fields can be edited without the secret. Such an edit is flagged (`visible_tampered`) with the
390
394
  changed fields until a signed save accepts it (`accept_visible_changes`).
391
395
 
@@ -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.12.1",
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",
@@ -8971,7 +8971,7 @@ class CredentialAccess {
8971
8971
  return this.decrypt(await this.ctx.api.fetchSealedCredential(credentialId));
8972
8972
  }
8973
8973
  async openForReveal(credentialId, field) {
8974
- return this.openAllowing(credentialId, field, REVEALABLE, (visibility) => visibility === "use_only" ? "it is never shown to an LLM. Use it without seeing it: secret_exec, secret_http or secret_adb_input with the handle secret://<credential_id>/<field>." : "it is never shown to an LLM and never handed to a tool: ask the user to enter it.");
8974
+ return this.openAllowing(credentialId, field, REVEALABLE, (visibility) => visibility === "use_only" ? "it is never shown to an LLM. Use it without seeing it: secret_exec hands it to your own program with the handle {{secret://<credential_id>/<field>}}." : "it is never shown to an LLM and never handed to a tool: ask the user to enter it.");
8975
8975
  }
8976
8976
  async openForUse(credentialId, field) {
8977
8977
  return this.openAllowing(credentialId, field, USABLE, () => "no tool may use it: ask the user to enter it.");
@@ -9105,14 +9105,6 @@ function hasHandle(text) {
9105
9105
  function fillHandles(text, lookup, fallback = "raw") {
9106
9106
  return text.replace(HANDLE, (_, credentialId, field, encoding2) => ENCODINGS[encodingOf(encoding2, fallback)](lookup({ credentialId, field })));
9107
9107
  }
9108
- function requireHandlesAfterOrigin(url) {
9109
- const first = url.search(ANY_HANDLE);
9110
- if (first === -1)
9111
- return;
9112
- if (!/^https?:\/\/[^/?#{}]+[/?#]/.test(url.slice(0, first))) {
9113
- throw new ClientError("A secret handle can go in the path, query or fragment of a url, never in its scheme, host or port.");
9114
- }
9115
- }
9116
9108
 
9117
9109
  // src/sinks/secretResolver.ts
9118
9110
  var TOTP_FIELD = "totp";
@@ -9903,6 +9895,47 @@ class CredentialMigration {
9903
9895
  }
9904
9896
  }
9905
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
+
9906
9939
  // src/sealed/credentialService.ts
9907
9940
  var NOT_FOUND2 = 404;
9908
9941
  var VISIBLE_NAMES = [
@@ -10014,6 +10047,7 @@ class CredentialService {
10014
10047
  const id = newObjectId();
10015
10048
  const custom = splitCustom(args.custom ?? []);
10016
10049
  const { dek, encDek } = await this.access.newDek(args.root_id, id);
10050
+ const typed = await this.typed(args.title, args);
10017
10051
  const save = await this.access.seal(id, dek, {
10018
10052
  title: args.title,
10019
10053
  username: textOrNull(args.username, null),
@@ -10022,10 +10056,10 @@ class CredentialService {
10022
10056
  notes: textOrNull(args.notes, null),
10023
10057
  custom: custom.visible
10024
10058
  }, {
10025
- password: textOrNull(args.password, null),
10026
- totp: this.checkedTotp(textOrNull(args.totp, null)),
10027
- sealed_notes: textOrNull(args.sealed_notes, null),
10028
- 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)
10029
10063
  });
10030
10064
  const sigs = await this.access.sign({
10031
10065
  op: "credential_create",
@@ -10059,6 +10093,7 @@ class CredentialService {
10059
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).");
10060
10094
  }
10061
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);
10062
10097
  const save = await this.access.seal(credential2.id, dek, {
10063
10098
  title: args.title ?? current.title,
10064
10099
  username: textOrNull(args.username, current.username),
@@ -10067,10 +10102,10 @@ class CredentialService {
10067
10102
  notes: textOrNull(args.notes, current.notes),
10068
10103
  custom: custom.visible
10069
10104
  }, {
10070
- password: textOrNull(args.password, values.password),
10071
- totp: this.checkedTotp(textOrNull(args.totp, values.totp)),
10072
- sealed_notes: textOrNull(args.sealed_notes, values.sealed_notes),
10073
- 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)
10074
10109
  });
10075
10110
  const sigs = await this.access.sign({
10076
10111
  op: "credential_update",
@@ -10128,6 +10163,10 @@ class CredentialService {
10128
10163
  });
10129
10164
  return { id: credentialId, deleted: true };
10130
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
+ }
10131
10170
  checkedTotp(value) {
10132
10171
  if (value !== null)
10133
10172
  parseTotp(value);
@@ -31321,7 +31360,7 @@ function decodeDirective(html) {
31321
31360
 
31322
31361
  // ../shared/js/canvas-markdown/src/html.ts
31323
31362
  var ENTITY = /&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);/g;
31324
- var NAMED = {
31363
+ var NAMED2 = {
31325
31364
  amp: "&",
31326
31365
  lt: "<",
31327
31366
  gt: ">",
@@ -31337,7 +31376,7 @@ function unescapeHtmlText(text2) {
31337
31376
  return String.fromCodePoint(Number.parseInt(body.slice(2), 16));
31338
31377
  if (body.startsWith("#"))
31339
31378
  return String.fromCodePoint(Number(body.slice(1)));
31340
- return NAMED[body];
31379
+ return NAMED2[body];
31341
31380
  });
31342
31381
  }
31343
31382
 
@@ -31668,12 +31707,12 @@ class BlockParser {
31668
31707
  }
31669
31708
  item(taskable) {
31670
31709
  this.pos++;
31671
- const checked = taskable ? this.takeTaskMarker() : null;
31710
+ const checked2 = taskable ? this.takeTaskMarker() : null;
31672
31711
  const children = this.blocks((token) => token.type === "list_item_close");
31673
31712
  this.pos++;
31674
31713
  return {
31675
- task: checked !== null,
31676
- checked: checked === true,
31714
+ task: checked2 !== null,
31715
+ checked: checked2 === true,
31677
31716
  children: children.length > 0 ? children : [node("paragraph")]
31678
31717
  };
31679
31718
  }
@@ -34482,7 +34521,7 @@ import { connect } from "node:net";
34482
34521
 
34483
34522
  // src/sidecar/protocol.ts
34484
34523
  import { dirname as dirname4, join as join6 } from "node:path";
34485
- var SIDECAR_PROTOCOL_VERSION = 2;
34524
+ var SIDECAR_PROTOCOL_VERSION = 3;
34486
34525
  var SIDECAR_SOCKET_NAME = "sidecar.sock";
34487
34526
  var SIDECAR_SUPPORTED = process.platform !== "win32";
34488
34527
  function defaultSidecarSocket(configPath) {
@@ -34627,6 +34666,10 @@ class SidecarQuestions {
34627
34666
  const { passphrase } = await this.asking("The passphrase", () => this.client.request({ op: "passphrase", of }));
34628
34667
  return passphrase;
34629
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
+ }
34630
34673
  async ask(notice, question) {
34631
34674
  const { typed: typed2 } = await this.asking("The confirmation", () => this.client.request({ op: "confirm", notice, question }));
34632
34675
  return typed2;
@@ -34656,6 +34699,9 @@ class NoSidecarQuestions {
34656
34699
  ask() {
34657
34700
  return this.refuse();
34658
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
+ }
34659
34705
  refuse() {
34660
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}`));
34661
34707
  }
@@ -34729,6 +34775,15 @@ The MCP client asks for the passphrase of ${printable2(of)}. It is used for this
34729
34775
  return typed2.length === 0 ? null : typed2;
34730
34776
  });
34731
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
+ }
34732
34787
  typed(notice, question, signal) {
34733
34788
  return this.inTurn(async () => {
34734
34789
  signal.throwIfAborted();
@@ -34860,9 +34915,12 @@ ${printableLines(notice)}`);
34860
34915
  ` : `Forgotten.
34861
34916
  `);
34862
34917
  }
34863
- async readNewPassphrase(signal) {
34918
+ readNewPassphrase(signal) {
34919
+ return this.readTwice("New passphrase (not shown; empty to decline): ", signal);
34920
+ }
34921
+ async readTwice(prompt, signal) {
34864
34922
  for (;; ) {
34865
- 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 });
34866
34924
  if (first.length === 0)
34867
34925
  return null;
34868
34926
  const second = await this.reader.read("Repeat it: ", {
@@ -35126,6 +35184,8 @@ class SidecarServer {
35126
35184
  return {};
35127
35185
  case "passphrase":
35128
35186
  return { passphrase: await this.passphrase(request, clientGone) };
35187
+ case "secret":
35188
+ return { value: await this.secret(request, clientGone) };
35129
35189
  case "confirm":
35130
35190
  return {
35131
35191
  typed: await this.prompter.typed(requireText(request.notice, "notice"), requireText(request.question, "question"), clientGone)
@@ -35152,6 +35212,16 @@ class SidecarServer {
35152
35212
  }
35153
35213
  return typed2;
35154
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
+ }
35155
35225
  async create(rootId, label2, clientGone) {
35156
35226
  const chosen = await this.prompter.newSecret(rootId, label2, clientGone);
35157
35227
  clientGone.throwIfAborted();
@@ -35921,6 +35991,11 @@ function keyTools(parts) {
35921
35991
  ];
35922
35992
  }
35923
35993
 
35994
+ // src/sinks/execSink.ts
35995
+ import { mkdtemp, rm as rm4, writeFile } from "node:fs/promises";
35996
+ import { tmpdir } from "node:os";
35997
+ import { join as join9 } from "node:path";
35998
+
35924
35999
  // src/sinks/processRunner.ts
35925
36000
  import { spawn as spawn3 } from "node:child_process";
35926
36001
  var MAX_CAPTURE_BYTES = 1024 * 1024;
@@ -36005,74 +36080,9 @@ async function runProcess(request) {
36005
36080
  });
36006
36081
  }
36007
36082
 
36008
- // src/sinks/adbSink.ts
36009
- var ADB_TIMEOUT_MS = 15000;
36010
- var PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
36011
- var FOCUSED_PACKAGE = /mCurrentFocus=Window\{\S+ \S+ ([^/\s}]+)/;
36012
- function adbInputCommand(value) {
36013
- if (!PRINTABLE_ASCII.test(value)) {
36014
- throw new ClientError("adb input text types printable ASCII only; this value has other characters. The user has to enter it.");
36015
- }
36016
- if (value.includes("%s")) {
36017
- throw new ClientError("adb input text reads %s as a space, so this value cannot be typed exactly. The user has to enter it.");
36018
- }
36019
- const quoted = value.replaceAll(" ", "%s").replaceAll("'", `'\\''`);
36020
- return `input text '${quoted}'
36021
- `;
36022
- }
36023
-
36024
- class AdbSink {
36025
- resolver;
36026
- adb;
36027
- constructor(resolver, adb = "adb") {
36028
- this.resolver = resolver;
36029
- this.adb = adb;
36030
- }
36031
- async input(args) {
36032
- if (refsIn(args.text).length === 0) {
36033
- throw new ClientError("text needs a secret handle: {{secret://<credential_id>/<field>}}");
36034
- }
36035
- const target = args.serial === undefined ? [] : ["-s", args.serial];
36036
- const secrets = await this.resolver.resolve([args.text]);
36037
- const value = secrets.fill(args.text);
36038
- const command = adbInputCommand(value);
36039
- await this.requireFocus(target, args.expect_package);
36040
- await this.shell(target, command, secrets);
36041
- return {
36042
- entered_length: value.length,
36043
- package: args.expect_package,
36044
- used: secrets.used()
36045
- };
36046
- }
36047
- async requireFocus(target, expected) {
36048
- const outcome = await runProcess({
36049
- argv: [this.adb, ...target, "shell", "dumpsys", "window"],
36050
- timeoutMs: ADB_TIMEOUT_MS
36051
- });
36052
- if (outcome.exitCode !== 0) {
36053
- throw new ClientError(`adb could not read the focused window: ${outcome.stderr.trim()}`);
36054
- }
36055
- const focused = FOCUSED_PACKAGE.exec(outcome.stdout)?.[1];
36056
- if (focused === undefined) {
36057
- throw new ClientError("No focused app found on the device (is it locked?); nothing was typed.");
36058
- }
36059
- if (focused !== expected) {
36060
- throw new ClientError(`The focused app is ${focused}, not ${expected}; nothing was typed.`);
36061
- }
36062
- }
36063
- async shell(target, command, secrets) {
36064
- const outcome = await runProcess({
36065
- argv: [this.adb, ...target, "shell", "-T"],
36066
- stdin: command,
36067
- timeoutMs: ADB_TIMEOUT_MS
36068
- });
36069
- if (outcome.exitCode !== 0 || outcome.timedOut) {
36070
- throw new ClientError(`adb input text failed (exit ${outcome.exitCode}${outcome.timedOut ? ", timed out" : ""}): ${secrets.redact(`${outcome.stderr}${outcome.stdout}`).trim()}`);
36071
- }
36072
- }
36073
- }
36074
-
36075
36083
  // src/sinks/execSink.ts
36084
+ var filled = (texts, secrets) => Object.fromEntries(Object.entries(texts).map(([name, text2]) => [name, secrets.fill(text2)]));
36085
+
36076
36086
  class ExecSink {
36077
36087
  resolver;
36078
36088
  constructor(resolver) {
@@ -36080,108 +36090,49 @@ class ExecSink {
36080
36090
  }
36081
36091
  async run(args) {
36082
36092
  if (args.argv.some(hasHandle)) {
36083
- throw new ClientError("A secret handle cannot go in argv, which other processes can read: pass it in env or stdin.");
36093
+ throw new ClientError("A secret handle cannot go in argv, which other processes can read: pass it in env, stdin or files.");
36084
36094
  }
36085
36095
  const env = args.env ?? {};
36096
+ const files = args.files ?? {};
36097
+ const clash = Object.keys(files).find((name) => Object.hasOwn(env, name));
36098
+ if (clash !== undefined) {
36099
+ throw new ClientError(`${clash} is named in both env and files`);
36100
+ }
36086
36101
  const secrets = await this.resolver.resolve([
36087
36102
  ...Object.values(env),
36103
+ ...Object.values(files),
36088
36104
  args.stdin ?? ""
36089
36105
  ]);
36090
36106
  if (secrets.used().length === 0) {
36091
- throw new ClientError("secret_exec needs a secret handle in env or stdin; run other programs without it.");
36092
- }
36093
- const outcome = await runProcess({
36094
- argv: args.argv,
36095
- cwd: args.cwd,
36096
- env: Object.fromEntries(Object.entries(env).map(([name, value]) => [name, secrets.fill(value)])),
36097
- stdin: args.stdin === undefined ? undefined : secrets.fill(args.stdin),
36098
- timeoutMs: args.timeout_s * 1000
36099
- });
36100
- const stdout = secrets.shown(outcome.stdout, outcome.stdoutCut);
36101
- const stderr = secrets.shown(outcome.stderr, outcome.stderrCut);
36102
- return {
36103
- exit_code: outcome.exitCode,
36104
- signal: outcome.signal,
36105
- timed_out: outcome.timedOut,
36106
- stdout: stdout.text,
36107
- stderr: stderr.text,
36108
- output_truncated: stdout.truncated || stderr.truncated,
36109
- used: secrets.used()
36110
- };
36111
- }
36112
- }
36113
-
36114
- // src/sinks/httpSink.ts
36115
- var MAX_READ_BYTES = 1024 * 1024;
36116
- async function cappedText(response) {
36117
- const reader = response.body?.getReader();
36118
- if (reader === undefined)
36119
- return { text: "", cut: false };
36120
- const chunks = [];
36121
- let size = 0;
36122
- while (size < MAX_READ_BYTES) {
36123
- const { done, value } = await reader.read();
36124
- if (done) {
36125
- return { text: Buffer.concat(chunks).toString("utf8"), cut: false };
36107
+ throw new ClientError("secret_exec needs a secret handle in env, stdin or files; run other programs without it.");
36126
36108
  }
36127
- chunks.push(value);
36128
- size += value.length;
36129
- }
36130
- await reader.cancel();
36131
- return {
36132
- text: Buffer.concat(chunks).subarray(0, MAX_READ_BYTES).toString("utf8"),
36133
- cut: true
36134
- };
36135
- }
36136
- function causeOf(error48) {
36137
- if (!(error48 instanceof Error))
36138
- return String(error48);
36139
- const cause = error48.cause instanceof Error ? `: ${error48.cause.message}` : "";
36140
- return `${error48.message}${cause}`;
36141
- }
36142
-
36143
- class HttpSink {
36144
- resolver;
36145
- constructor(resolver) {
36146
- this.resolver = resolver;
36147
- }
36148
- async send(args) {
36149
- requireHandlesAfterOrigin(args.url);
36150
- const headers = args.headers ?? {};
36151
- const secrets = await this.resolver.resolve([
36152
- args.url,
36153
- ...Object.keys(headers),
36154
- ...Object.values(headers),
36155
- args.body ?? ""
36156
- ]);
36157
- const { response, text: text2, cut } = await this.exchange(args, headers, secrets);
36158
- const body = secrets.shown(text2, cut);
36159
- return {
36160
- status: response.status,
36161
- headers: Object.fromEntries([...response.headers].map(([name, value]) => [
36162
- name,
36163
- secrets.redact(value)
36164
- ])),
36165
- body: body.text,
36166
- body_truncated: body.truncated,
36167
- used: secrets.used()
36168
- };
36169
- }
36170
- async exchange(args, headers, secrets) {
36109
+ const dir = await mkdtemp(join9(tmpdir(), "safekb-secret-"));
36171
36110
  try {
36172
- const response = await fetch(secrets.fill(args.url, "url"), {
36173
- method: args.method,
36174
- headers: Object.fromEntries(Object.entries(headers).map(([name, value]) => [
36175
- secrets.fill(name),
36176
- secrets.fill(value)
36177
- ])),
36178
- body: args.body === undefined ? undefined : secrets.fill(args.body),
36179
- redirect: "manual",
36180
- signal: AbortSignal.timeout(args.timeout_s * 1000)
36111
+ const paths = {};
36112
+ for (const [name, content] of Object.entries(filled(files, secrets))) {
36113
+ paths[name] = join9(dir, name);
36114
+ await writeFile(paths[name], content, { mode: 384, flag: "wx" });
36115
+ }
36116
+ const outcome = await runProcess({
36117
+ argv: args.argv,
36118
+ cwd: args.cwd,
36119
+ env: { ...filled(env, secrets), ...paths },
36120
+ stdin: args.stdin === undefined ? undefined : secrets.fill(args.stdin),
36121
+ timeoutMs: args.timeout_s * 1000
36181
36122
  });
36182
- return { response, ...await cappedText(response) };
36183
- } catch (error48) {
36184
- throw new ClientError(`The request failed: ${secrets.redact(causeOf(error48))}`);
36123
+ const stdout = secrets.shown(outcome.stdout, outcome.stdoutCut);
36124
+ const stderr = secrets.shown(outcome.stderr, outcome.stderrCut);
36125
+ return {
36126
+ exit_code: outcome.exitCode,
36127
+ signal: outcome.signal,
36128
+ timed_out: outcome.timedOut,
36129
+ stdout: stdout.text,
36130
+ stderr: stderr.text,
36131
+ output_truncated: stdout.truncated || stderr.truncated,
36132
+ used: secrets.used()
36133
+ };
36134
+ } finally {
36135
+ await rm4(dir, { recursive: true, force: true });
36185
36136
  }
36186
36137
  }
36187
36138
  }
@@ -36224,13 +36175,15 @@ var sealedFields = {
36224
36175
  password: exports_external.string().describe(`Sealed. ${CLEARABLE}`).optional(),
36225
36176
  totp: exports_external.string().describe(`Sealed TOTP seed (base32) or otpauth://totp URI. ${CLEARABLE}`).optional(),
36226
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(),
36227
36179
  custom: exports_external.array(exports_external.strictObject({
36228
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."),
36229
36181
  value: exports_external.string(),
36230
36182
  sealed: exports_external.boolean().describe("true: the value is encrypted on this machine (the key name is not). false: visible to the server.")
36231
36183
  })).describe("The complete list of custom fields; it replaces the stored list.").optional()
36232
36184
  };
36233
- function credentialTools(credentials, migration, names) {
36185
+ function credentialTools(credentials, migration, names, entry) {
36186
+ const atSidecar = (fields2) => fields2 === undefined ? undefined : { fields: fields2, entry };
36234
36187
  return [
36235
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}`, {
36236
36189
  credential_id: credentialId,
@@ -36250,7 +36203,8 @@ function credentialTools(credentials, migration, names) {
36250
36203
  ...sealedFields
36251
36204
  }, async (args) => credentials.insert({
36252
36205
  ...args,
36253
- 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)
36254
36208
  })),
36255
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}`, {
36256
36210
  credential_id: credentialId,
@@ -36259,7 +36213,10 @@ function credentialTools(credentials, migration, names) {
36259
36213
  title: exports_external.string().min(1).describe("Visible to the server.").optional(),
36260
36214
  ...visibleFields,
36261
36215
  ...sealedFields
36262
- }, (args) => credentials.update(args)),
36216
+ }, (args) => credentials.update({
36217
+ ...args,
36218
+ typed_at_sidecar: atSidecar(args.typed_at_sidecar)
36219
+ })),
36263
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.", {
36264
36221
  credential_id: credentialId,
36265
36222
  expected_version: expectedVersion,
@@ -36381,30 +36338,32 @@ function documentTools(creator, documents, search, names) {
36381
36338
  }
36382
36339
 
36383
36340
  // src/tools/sinkTools.ts
36384
- var HANDLE_NOTE = "Name a secret by its handle {{secret://<credential_id>/<field>}} (field: password, totp, sealed_notes or custom:{key}); it is decrypted on this machine and the value never enters the conversation. A totp handle becomes the current code. Add |form, |json or |url to encode the value for where it goes, e.g. {{secret://<id>/password|form}} in a form body; without it the value goes in as is (in a url: URL-encoded). Fields whose llm_visibility is human_only are refused.";
36385
- var SCRUB_NOTE = "What comes back is scrubbed of the values (as is, URL-encoded, base64, hex, JSON-escaped) and names the secrets used. Scrubbing guards against careless output, not against a program written to leak: run only code you trust.";
36386
- var timeout = (fallback) => exports_external.number().int().min(1).max(600).default(fallback).describe("Seconds.");
36387
- function sinkTools(exec, http, adb) {
36341
+ var ENV_NAME = exports_external.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
36342
+ var DESCRIPTION = `Use a sealed secret without seeing it: run your own program with the secret in its environment, on its stdin or in a temp file. This is the only way to use a secret without revealing it. SafeKB only provides the value; every integration (HTTP API, browser form, Android typing, ssh, wallet, disk unlock, radio, ...) is your own program.
36343
+
36344
+ Name a secret by its handle {{secret://<credential_id>/<field>}} (field: password, totp, sealed_notes or custom:{key}). It is decrypted on this machine and never enters the conversation. A totp handle becomes the current code. Add |url, |form or |json to encode the value for where your program puts it unchanged (e.g. a form body in stdin: "a={{secret://<id>/password|form}}"); without one it goes in as is. Fields whose llm_visibility is human_only are refused.
36345
+
36346
+ Examples:
36347
+ - HTTP API key: env {"API_KEY": "{{secret://<id>/password}}"}, argv ["uv", "run", "call_api.py"]
36348
+ - Android typing: env {"PW": "{{secret://<id>/password}}"}, argv ["uv", "run", "adb_type.py", "--expect-package", "com.example.bank"] (your script runs adb shell input text)
36349
+ - Browser form: env {"PW": "{{secret://<id>/password}}"}, argv ["node", "fill.js"] (your script attaches to the open browser with patchright over CDP)
36350
+ - ssh with a key: files {"KEY_FILE": "{{secret://<id>/custom:private key}}"}, argv ["sh", "-c", "ssh -i \\"$KEY_FILE\\" host uptime"]
36351
+ - ssh with a password: env {"SSHPASS": "{{secret://<id>/password}}"}, argv ["sshpass", "-e", "ssh", "host", "uptime"]
36352
+ - LUKS: stdin "{{secret://<id>/password}}", argv ["sudo", "cryptsetup", "open", "--key-file=-", "/dev/sdb1", "vault"]
36353
+ - Wallet unlock, a LoRa radio write, anything else: your script reads the value from env, stdin or a file
36354
+ - One-time code: env {"OTP": "{{secret://<id>/totp}}"}
36355
+
36356
+ Rules: never put a handle in argv (other processes can read it; it is refused). Your program must not print the value. What comes back is scrubbed of it (as is, in any mix of percent-, form- and JSON-encoding, as base64 or hex) and names the secrets used, but scrubbing is a guard against carelessness, not a guarantee: a program can still leak a value it transforms. The program inherits this client's environment and runs without a shell.`;
36357
+ function sinkTools(exec) {
36388
36358
  return [
36389
- defineTool("secret_exec", `Run a program with secrets in its environment or on its stdin, without seeing them. ${HANDLE_NOTE} At least one handle is needed; handles are refused in argv, which other processes can read. The program inherits this client's environment. No shell: argv is run as is. ${SCRUB_NOTE}`, {
36359
+ defineTool("secret_exec", DESCRIPTION, {
36390
36360
  argv: exports_external.array(exports_external.string()).min(1).describe('Program and arguments, e.g. ["uv", "run", "script.py"].'),
36391
36361
  cwd: absolutePath("Working directory."),
36392
- env: exports_external.record(exports_external.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/), exports_external.string()).describe('Added to the environment; values may hold handles, e.g. {"API_KEY": "{{secret://<id>/password}}"}.').optional(),
36362
+ env: exports_external.record(ENV_NAME, exports_external.string()).describe('Added to the environment; values may hold handles, e.g. {"API_KEY": "{{secret://<id>/password}}"}.').optional(),
36393
36363
  stdin: exports_external.string().describe("Written to stdin, then closed; may hold handles.").optional(),
36394
- timeout_s: timeout(60)
36395
- }, (args) => exec.run(args)),
36396
- defineTool("secret_http", `Send one HTTP request with secrets in its url, headers or body, without seeing them. ${HANDLE_NOTE} Handles go in the path, query or fragment of the url (URL-encoded for you), never its host. A redirect is returned, not followed. ${SCRUB_NOTE}`, {
36397
- method: exports_external.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]),
36398
- url: exports_external.string().url().describe("May hold handles, e.g. in the query."),
36399
- headers: exports_external.record(exports_external.string(), exports_external.string()).describe('e.g. {"Authorization": "Bearer {{secret://<id>/password}}"}.').optional(),
36400
- body: exports_external.string().describe("May hold handles.").optional(),
36401
- timeout_s: timeout(30)
36402
- }, (args) => http.send(args)),
36403
- defineTool("secret_adb_input", `Type a secret into the focused field of an Android device with adb (input text), without seeing it. ${HANDLE_NOTE} Only printable ASCII can be typed; anything else is refused, never typed differently. Returns only the length typed.`, {
36404
- text: exports_external.string().min(1).describe("What to type, holding a handle: {{secret://<id>/password}}."),
36405
- serial: exports_external.string().min(1).describe("adb -s: which device, when several are connected.").optional(),
36406
- expect_package: exports_external.string().min(1).describe("Type only if this app has the focus, e.g. com.example.bank.")
36407
- }, (args) => adb.input(args))
36364
+ files: exports_external.record(ENV_NAME, exports_external.string()).describe('Each value (holding handles) is written to a temp file only you can read and deleted when the program ends; the env variable of that name holds its path, e.g. {"KEY_FILE": "{{secret://<id>/custom:private key}}"}.').optional(),
36365
+ timeout_s: exports_external.number().int().min(1).max(600).default(60).describe("Seconds.")
36366
+ }, (args) => exec.run(args))
36408
36367
  ];
36409
36368
  }
36410
36369
 
@@ -36460,11 +36419,12 @@ The keys of this machine, and backups, are handled by tools that never take or r
36460
36419
 
36461
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.
36462
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.
36463
36423
  - Reveal a secret only when the task needs it, and prefer get_sealed_totp over revealing a TOTP seed.
36464
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.
36465
- - To use a secret without seeing it (visible or use_only fields): secret_exec (a program gets it in env or stdin), secret_http (one request gets it in url, headers or body) or secret_adb_input (typed on an Android device). Name it by handle, {{secret://<credential_id>/<field>}}; a totp handle becomes the current code. Prefer these over revealing, even for a visible field. A human_only field is never used by a tool: ask the user to enter it.
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.
36466
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.`;
36467
- function createSealedTools(ctx) {
36427
+ function createSealedTools(ctx, entry) {
36468
36428
  const services = createSealedServices(ctx);
36469
36429
  const { names } = ctx;
36470
36430
  return [
@@ -36473,8 +36433,8 @@ function createSealedTools(ctx) {
36473
36433
  ...treeTools(services.tree, names),
36474
36434
  ...attachmentTools(services.attachments),
36475
36435
  ...depositTools(services.deposits, names),
36476
- ...credentialTools(services.credentials, services.migration, names),
36477
- ...sinkTools(new ExecSink(services.secrets), new HttpSink(services.secrets), new AdbSink(services.secrets))
36436
+ ...credentialTools(services.credentials, services.migration, names, entry),
36437
+ ...sinkTools(new ExecSink(services.secrets))
36478
36438
  ];
36479
36439
  }
36480
36440
 
@@ -36819,7 +36779,7 @@ async function openTools(options, config3, session, systemKeyring, chosen) {
36819
36779
  };
36820
36780
  const conversions = createConversions(sealed, new KbApi(http), sharedCanvasMarkdown);
36821
36781
  const localTools = [
36822
- ...createSealedTools(sealed),
36782
+ ...createSealedTools(sealed, userQuestionsOf(options)("")),
36823
36783
  ...unsealTools(conversions.unseal),
36824
36784
  ...options.sealedOnly ? [] : sealTools(conversions.seal),
36825
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.12.1",
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",