@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 +13 -9
- package/dist/safekb-mcp.js +173 -213
- package/package.json +1 -1
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
|
-
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
package/dist/safekb-mcp.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
31676
|
-
checked:
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
36173
|
-
|
|
36174
|
-
|
|
36175
|
-
|
|
36176
|
-
|
|
36177
|
-
|
|
36178
|
-
|
|
36179
|
-
|
|
36180
|
-
|
|
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
|
-
|
|
36183
|
-
|
|
36184
|
-
|
|
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(
|
|
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
|
|
36385
|
-
var
|
|
36386
|
-
|
|
36387
|
-
|
|
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",
|
|
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(
|
|
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
|
-
|
|
36395
|
-
|
|
36396
|
-
|
|
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
|
|
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)
|
|
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